@1agh/maude 0.29.0 → 0.31.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 +6 -6
- package/apps/studio/acp/bridge.ts +285 -0
- package/apps/studio/acp/env.ts +48 -0
- package/apps/studio/acp/index.ts +132 -0
- package/apps/studio/acp/probe.ts +112 -0
- package/apps/studio/acp/transcript.ts +149 -0
- package/apps/studio/ai-banner.tsx +0 -1
- package/apps/studio/annotations-align.ts +149 -0
- package/apps/studio/annotations-bindings.ts +197 -0
- package/apps/studio/annotations-context-toolbar.tsx +436 -186
- package/apps/studio/annotations-groups.ts +270 -0
- package/apps/studio/annotations-layer.tsx +1714 -1661
- package/apps/studio/annotations-model.ts +2077 -0
- package/apps/studio/annotations-snap.ts +125 -0
- package/apps/studio/api.ts +433 -182
- package/apps/studio/bin/_png-playwright.mjs +1 -1
- package/apps/studio/bin/annotate.mjs +732 -0
- package/apps/studio/bin/annotate.sh +17 -0
- package/apps/studio/bin/chat-open.sh +44 -0
- package/apps/studio/bin/read-annotations.mjs +152 -17
- package/apps/studio/build.ts +1 -1
- package/apps/studio/canvas-arrowheads.ts +78 -9
- package/apps/studio/canvas-cursors.ts +2 -0
- package/apps/studio/canvas-edit.ts +257 -7
- package/apps/studio/canvas-icons.tsx +105 -0
- package/apps/studio/canvas-lib.tsx +112 -19
- package/apps/studio/canvas-list-watch.ts +177 -0
- package/apps/studio/canvas-shell.tsx +326 -9
- package/apps/studio/client/app.jsx +3579 -250
- package/apps/studio/client/canvas-url.js +5 -0
- package/apps/studio/client/github.js +99 -0
- package/apps/studio/client/index.html +1 -1
- package/apps/studio/client/panels/ChatPanel.jsx +770 -0
- package/apps/studio/client/panels/CollabModelInfographic.jsx +71 -0
- package/apps/studio/client/panels/CreateProject.jsx +334 -0
- package/apps/studio/client/panels/DiffView.jsx +590 -0
- package/apps/studio/client/panels/GitPanel.jsx +767 -0
- package/apps/studio/client/panels/IdentityBar.jsx +294 -0
- package/apps/studio/client/panels/OnboardingWizard.jsx +563 -0
- package/apps/studio/client/panels/RepoBranchSwitcher.jsx +349 -0
- package/apps/studio/client/panels/acp-runtime.js +286 -0
- package/apps/studio/client/panels/chat-markdown.jsx +138 -0
- package/apps/studio/client/panels/git-grouping.js +86 -0
- package/apps/studio/client/styles/0-reset.css +4 -0
- package/apps/studio/client/styles/3-shell-maude.css +1326 -14
- package/apps/studio/client/styles/4-components.css +39 -0
- package/apps/studio/client/styles/5-maude-overrides.css +41 -2
- package/apps/studio/client/styles/6-acp-chat.css +771 -0
- package/apps/studio/client/styles/_index.css +2 -0
- package/apps/studio/client/tour/collab-tour.js +61 -0
- package/apps/studio/client/tour/overlay.jsx +116 -18
- package/apps/studio/client/tour/usage-tour.js +23 -10
- package/apps/studio/client/whats-new.jsx +25 -10
- package/apps/studio/collab/registry.ts +13 -0
- package/apps/studio/collab/room.ts +36 -0
- package/apps/studio/commands/annotation-strokes-command.ts +1 -1
- package/apps/studio/commands/move-artboards-command.ts +1 -1
- package/apps/studio/comments-overlay.tsx +7 -5
- package/apps/studio/context-menu.tsx +4 -3
- package/apps/studio/contextual-toolbar.tsx +14 -0
- package/apps/studio/cursors-overlay.tsx +37 -21
- package/apps/studio/dist/client.bundle.js +31795 -1829
- package/apps/studio/dist/comment-mount.js +97 -9
- package/apps/studio/dist/styles.css +7938 -1599
- package/apps/studio/dom-selection.ts +115 -0
- package/apps/studio/export-dialog.tsx +3 -3
- package/apps/studio/git/endpoints.ts +338 -0
- package/apps/studio/git/service.ts +1334 -0
- package/apps/studio/git/watch.ts +97 -0
- package/apps/studio/github/endpoints.ts +358 -0
- package/apps/studio/github/service.ts +231 -0
- package/apps/studio/github/token.ts +53 -0
- package/apps/studio/history.ts +20 -3
- package/apps/studio/hmr-broadcast.ts +9 -2
- package/apps/studio/http.ts +508 -6
- package/apps/studio/input-router.tsx +7 -0
- package/apps/studio/inspect.ts +37 -8
- package/apps/studio/participants-chrome.tsx +70 -16
- package/apps/studio/paths.ts +12 -0
- package/apps/studio/scaffold-design.ts +57 -0
- package/apps/studio/server.ts +70 -4
- package/apps/studio/sync/agent.ts +311 -57
- package/apps/studio/sync/codec.ts +69 -0
- package/apps/studio/sync/cold-start.ts +198 -0
- package/apps/studio/sync/connection-state.ts +58 -2
- package/apps/studio/sync/hub-link.ts +137 -0
- package/apps/studio/sync/index.ts +563 -221
- package/apps/studio/sync/journal.ts +190 -0
- package/apps/studio/sync/migrate-seed.ts +202 -44
- package/apps/studio/sync/projection.ts +22 -3
- package/apps/studio/sync/status.ts +15 -4
- package/apps/studio/test/acp-bridge.test.ts +127 -0
- package/apps/studio/test/acp-env.test.ts +65 -0
- package/apps/studio/test/acp-origin-gate.test.ts +78 -0
- package/apps/studio/test/acp-transcript.test.ts +112 -0
- package/apps/studio/test/activity.test.ts +1 -7
- package/apps/studio/test/annotate-write.test.ts +184 -0
- package/apps/studio/test/annotations-align.test.ts +88 -0
- package/apps/studio/test/annotations-bindings.test.ts +124 -0
- package/apps/studio/test/annotations-groups.test.ts +231 -0
- package/apps/studio/test/annotations-snap.test.ts +79 -0
- package/apps/studio/test/canvas-create-api.test.ts +72 -0
- package/apps/studio/test/canvas-edit.test.ts +181 -1
- package/apps/studio/test/canvas-list-watch.test.ts +322 -0
- package/apps/studio/test/canvas-meta-api.test.ts +161 -27
- package/apps/studio/test/canvas-origin-gate.test.ts +43 -0
- package/apps/studio/test/chat-markdown.test.tsx +58 -0
- package/apps/studio/test/collab-bridge.test.ts +0 -2
- package/apps/studio/test/collab-room.test.ts +2 -7
- package/apps/studio/test/collab-session-survival.test.tsx +176 -0
- package/apps/studio/test/csp-canvas-shell.test.ts +5 -0
- package/apps/studio/test/csrf-write-guard.test.ts +78 -0
- package/apps/studio/test/editing-presence.test.ts +103 -0
- package/apps/studio/test/figjam-v3-model.test.ts +342 -0
- package/apps/studio/test/fixtures/figjam-v3-groups-bindings.svg +1 -0
- package/apps/studio/test/fixtures/mock-acp-agent.mjs +45 -0
- package/apps/studio/test/git-api.test.ts +0 -0
- package/apps/studio/test/git-branches.test.ts +106 -0
- package/apps/studio/test/git-grouping.test.ts +106 -0
- package/apps/studio/test/git-watch.test.ts +97 -0
- package/apps/studio/test/github-api.test.ts +465 -0
- package/apps/studio/test/hub-link.test.ts +69 -0
- package/apps/studio/test/participants-chrome.test.ts +36 -1
- package/apps/studio/test/shared-doc-migrate.test.ts +224 -14
- package/apps/studio/test/sync-agent.test.ts +272 -2
- package/apps/studio/test/sync-codec.test.ts +65 -0
- package/apps/studio/test/sync-cold-seed-dedup.test.ts +187 -0
- package/apps/studio/test/sync-cold-start.test.ts +304 -0
- package/apps/studio/test/sync-connection-state.test.ts +68 -0
- package/apps/studio/test/sync-hardening.test.ts +0 -8
- package/apps/studio/test/sync-hubs-config.test.ts +0 -1
- package/apps/studio/test/sync-incident-replay.test.ts +211 -0
- package/apps/studio/test/sync-journal.test.ts +176 -0
- package/apps/studio/test/sync-runtime.test.ts +360 -5
- package/apps/studio/test/sync-status.test.ts +66 -0
- package/apps/studio/test/tour-overlay.test.tsx +18 -0
- package/apps/studio/test/use-annotation-resize.test.ts +159 -0
- package/apps/studio/test/use-artboard-drag.test.ts +0 -1
- package/apps/studio/test/use-collab.test.ts +0 -0
- package/apps/studio/test/use-tool-mode.test.tsx +5 -2
- package/apps/studio/tool-palette.tsx +30 -11
- package/apps/studio/undo-hud.tsx +0 -1
- package/apps/studio/use-agent-presence.tsx +4 -2
- package/apps/studio/use-annotation-resize.tsx +484 -54
- package/apps/studio/use-annotation-selection.tsx +16 -4
- package/apps/studio/use-canvas-activity.tsx +2 -2
- package/apps/studio/use-chrome-visibility.tsx +66 -0
- package/apps/studio/use-collab.tsx +434 -202
- package/apps/studio/use-selection-set.tsx +9 -0
- package/apps/studio/use-tool-mode.tsx +5 -1
- package/apps/studio/whats-new.json +208 -10
- package/apps/studio/whats-new.schema.json +18 -2
- package/apps/studio/ws.ts +44 -1
- package/cli/commands/design-link.test.mjs +84 -0
- package/cli/commands/design.mjs +3 -1
- package/cli/commands/design.test.mjs +5 -1
- package/cli/lib/design-link.mjs +51 -1
- package/cli/lib/gitignore-block.mjs +16 -3
- package/cli/lib/gitignore-block.test.mjs +13 -1
- package/package.json +11 -9
- package/plugins/design/dependencies.json +17 -0
- package/plugins/design/templates/_shell.html +58 -12
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
// External-canvas list watcher.
|
|
2
|
+
//
|
|
3
|
+
// The browser file tree re-reads `/_index-data` only when it receives a
|
|
4
|
+
// `canvas-list-update` WS message. Until now that message was emitted ONLY by
|
|
5
|
+
// the dev-server's own create/delete API (`api.ts` createCanvas / deleteCanvas),
|
|
6
|
+
// so a canvas written straight to disk by something OUTSIDE the server — the ACP
|
|
7
|
+
// chat agent running `/design:new`, an agent `Write`, `git checkout`, a terminal
|
|
8
|
+
// `cp`/`rm` — never refreshed the tree. The file landed on disk, `fs:any` fired,
|
|
9
|
+
// the HMR/activity/git-status subscribers reacted, but the tree stayed stale
|
|
10
|
+
// until a full page reload. See `.ai/logs/rca/issue-acp-new-canvas-not-in-filetree.md`.
|
|
11
|
+
//
|
|
12
|
+
// This is the symmetric server-side counterpart: subscribe to `fs:any`, and when
|
|
13
|
+
// the set of openable canvas files under the configured canvas groups changes
|
|
14
|
+
// (add or remove), emit the same `canvas-list-update` the API does. The client's
|
|
15
|
+
// handler just calls `loadTree()` (a wholesale `/_index-data` re-read), so a
|
|
16
|
+
// nudge is all that's required — the diff makes it precise enough to skip every
|
|
17
|
+
// non-structural edit (a canvas body save, a `.meta.json` rewrite) and so avoid
|
|
18
|
+
// thrashing the tree on every `/design:edit`.
|
|
19
|
+
//
|
|
20
|
+
// Bun-native (DDR-009); the canvas listing reuses `findHtmlFiles` and the slug
|
|
21
|
+
// reuses `canvasSlugFromRel`, both from api.ts — the single source of truth that
|
|
22
|
+
// also backs `/_index-data` and the UI create/delete emits (no duplicated
|
|
23
|
+
// SKIP_DIRS / group rules to drift; DDR-115 runtime-state exclusions come along
|
|
24
|
+
// for free because `findHtmlFiles` already skips `_`-prefixed + SKIP_DIRS paths).
|
|
25
|
+
|
|
26
|
+
import path from 'node:path';
|
|
27
|
+
|
|
28
|
+
import { canvasSlugFromRel, findHtmlFiles, SKIP_DIRS } from './api.ts';
|
|
29
|
+
import type { Context } from './context.ts';
|
|
30
|
+
|
|
31
|
+
/** Burst-collapse window. A new canvas writes both `.tsx` and `.meta.json`; a
|
|
32
|
+
* git checkout touches many files at once — one snapshot per quiet window. */
|
|
33
|
+
export const CANVAS_LIST_DEBOUNCE_MS = 150;
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Cheap gate: is this `fs:any` path worth a (debounced) full recompute? True
|
|
37
|
+
* only for an openable canvas file (`.tsx` / `.html`) that lives under one of
|
|
38
|
+
* the configured canvas groups and carries no `_`-prefixed or SKIP_DIRS segment.
|
|
39
|
+
* A loose gate just means an occasional needless recompute — never a wrong emit,
|
|
40
|
+
* since `snapshot()` is authoritative. Input is design-root-relative (the shape
|
|
41
|
+
* fs-watch emits, e.g. `ui/project/Pricing.tsx`).
|
|
42
|
+
*
|
|
43
|
+
* Pure — no disk access. Accepts back-slash or forward-slash input.
|
|
44
|
+
*/
|
|
45
|
+
export function isCanvasCandidate(rel: string, groupPaths: string[]): boolean {
|
|
46
|
+
if (typeof rel !== 'string') return false;
|
|
47
|
+
const p = rel.replace(/\\/g, '/').replace(/^\/+/, '');
|
|
48
|
+
if (!p) return false;
|
|
49
|
+
const low = p.toLowerCase();
|
|
50
|
+
if (!low.endsWith('.tsx') && !low.endsWith('.html')) return false;
|
|
51
|
+
const segs = p.split('/');
|
|
52
|
+
for (const s of segs) {
|
|
53
|
+
if (!s) return false;
|
|
54
|
+
if (s.startsWith('_')) return false;
|
|
55
|
+
if (SKIP_DIRS.has(s)) return false;
|
|
56
|
+
}
|
|
57
|
+
return groupPaths.some((gp) => {
|
|
58
|
+
const g = gp.replace(/^\/+|\/+$/g, '');
|
|
59
|
+
return !!g && (p === g || p.startsWith(`${g}/`));
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export interface CanvasListWatch {
|
|
64
|
+
/** Resolves once the initial canvas set has been seeded (no emit). */
|
|
65
|
+
readonly ready: Promise<void>;
|
|
66
|
+
/** Test seam — force a recompute + diff + emit. Serialized after the seed and
|
|
67
|
+
* any in-flight refresh, so a create observed between calls always diffs
|
|
68
|
+
* against the pre-create set. */
|
|
69
|
+
refresh(): Promise<void>;
|
|
70
|
+
/** Current known openable-canvas set (design-rel-prefixed). For assertions. */
|
|
71
|
+
readonly known: ReadonlySet<string>;
|
|
72
|
+
stop(): void;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export interface CanvasListWatchOptions {
|
|
76
|
+
debounceMs?: number;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function createCanvasListWatch(
|
|
80
|
+
ctx: Context,
|
|
81
|
+
opts: CanvasListWatchOptions = {}
|
|
82
|
+
): CanvasListWatch {
|
|
83
|
+
const debounceMs = opts.debounceMs ?? CANVAS_LIST_DEBOUNCE_MS;
|
|
84
|
+
// Mirror /_index-data's group set exactly (cfg.canvasGroups) — NOT api.ts's
|
|
85
|
+
// create-allowlist, which additionally permits cfg.newCanvasDir. On the
|
|
86
|
+
// default config newCanvasDir IS a group; a config pointing it outside
|
|
87
|
+
// canvasGroups would have the canvas absent from /_index-data too, so the
|
|
88
|
+
// watcher stays consistent with what the tree can actually show.
|
|
89
|
+
const groupPaths = (ctx.cfg.canvasGroups ?? []).map((g) => g.path);
|
|
90
|
+
|
|
91
|
+
// null = not yet seeded; the first refresh captures the baseline and emits
|
|
92
|
+
// nothing (canvases already on disk at boot aren't "added").
|
|
93
|
+
let known: Set<string> | null = null;
|
|
94
|
+
let chain: Promise<void> = Promise.resolve();
|
|
95
|
+
let pending: ReturnType<typeof setTimeout> | null = null;
|
|
96
|
+
|
|
97
|
+
async function snapshot(): Promise<Set<string>> {
|
|
98
|
+
const out = new Set<string>();
|
|
99
|
+
for (const g of ctx.cfg.canvasGroups ?? []) {
|
|
100
|
+
const groupAbs = path.join(ctx.paths.designRoot, g.path);
|
|
101
|
+
const groupRel = path.posix.join(ctx.paths.designRel, g.path);
|
|
102
|
+
let files: string[];
|
|
103
|
+
try {
|
|
104
|
+
files = await findHtmlFiles(groupAbs, groupRel);
|
|
105
|
+
} catch {
|
|
106
|
+
continue; // group dir missing / unreadable — treat as empty
|
|
107
|
+
}
|
|
108
|
+
for (const f of files) out.add(f);
|
|
109
|
+
}
|
|
110
|
+
return out;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function emit(action: 'added' | 'removed', rel: string) {
|
|
114
|
+
// `rel` from findHtmlFiles is designRel-prefixed (`.design/ui/X.tsx`); strip
|
|
115
|
+
// the prefix so the wire shape matches api.ts's group-relative emit
|
|
116
|
+
// (`ui/X.tsx`). The client IGNORES the payload entirely — it just re-reads
|
|
117
|
+
// /_index-data — so rel/slug are advisory only. SECURITY TRIPWIRE: any FUTURE
|
|
118
|
+
// consumer of this event must treat rel/slug as ATTACKER-CONTROLLED (agent /
|
|
119
|
+
// `git checkout`-authored filenames) and must NOT feed them to a
|
|
120
|
+
// render/open/build sink without re-validating against /_index-data.
|
|
121
|
+
const prefix = `${ctx.paths.designRel.replace(/^\/+|\/+$/g, '')}/`;
|
|
122
|
+
const relOut = rel.startsWith(prefix) ? rel.slice(prefix.length) : rel;
|
|
123
|
+
ctx.bus.emit('canvas-list-update', {
|
|
124
|
+
action,
|
|
125
|
+
rel: relOut,
|
|
126
|
+
slug: canvasSlugFromRel(rel, ctx.paths.designRel),
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
async function doRefresh(): Promise<void> {
|
|
131
|
+
const next = await snapshot();
|
|
132
|
+
if (known === null) {
|
|
133
|
+
known = next; // seed — no emit
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
for (const rel of next) if (!known.has(rel)) emit('added', rel);
|
|
137
|
+
for (const rel of known) if (!next.has(rel)) emit('removed', rel);
|
|
138
|
+
known = next;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// Serialize every recompute through one chain so the boot-time seed always
|
|
142
|
+
// runs first and concurrent snapshots can't race a create into a missed diff.
|
|
143
|
+
function refresh(): Promise<void> {
|
|
144
|
+
chain = chain.then(doRefresh, doRefresh);
|
|
145
|
+
return chain;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const ready = refresh(); // seed at construction
|
|
149
|
+
|
|
150
|
+
function schedule() {
|
|
151
|
+
if (pending) clearTimeout(pending);
|
|
152
|
+
pending = setTimeout(() => {
|
|
153
|
+
pending = null;
|
|
154
|
+
void refresh();
|
|
155
|
+
}, debounceMs);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const off = ctx.bus.on('fs:any', (rel: string) => {
|
|
159
|
+
if (!isCanvasCandidate(rel, groupPaths)) return;
|
|
160
|
+
schedule();
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
function stop() {
|
|
164
|
+
off();
|
|
165
|
+
if (pending) clearTimeout(pending);
|
|
166
|
+
pending = null;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
return {
|
|
170
|
+
ready,
|
|
171
|
+
refresh,
|
|
172
|
+
get known() {
|
|
173
|
+
return known ?? new Set<string>();
|
|
174
|
+
},
|
|
175
|
+
stop,
|
|
176
|
+
};
|
|
177
|
+
}
|
|
@@ -82,6 +82,7 @@ import {
|
|
|
82
82
|
useAnnotationSelectionOptional,
|
|
83
83
|
} from './use-annotation-selection.tsx';
|
|
84
84
|
import { AnnotationsVisibilityProvider } from './use-annotations-visibility.tsx';
|
|
85
|
+
import { ChromeVisibilityProvider, useChromeVisibility } from './use-chrome-visibility.tsx';
|
|
85
86
|
import { useCollab } from './use-collab.tsx';
|
|
86
87
|
import { useCursorModifiers } from './use-cursor-modifiers.tsx';
|
|
87
88
|
import { useKeyboardDiscipline } from './use-keyboard-discipline.tsx';
|
|
@@ -302,6 +303,14 @@ const HALO_CSS = `
|
|
|
302
303
|
-webkit-font-smoothing: subpixel-antialiased;
|
|
303
304
|
font-smooth: always;
|
|
304
305
|
}
|
|
306
|
+
/* Phase 12 (DDR-103) — inline text editing: the element being edited carries an
|
|
307
|
+
accent ring + caret. plaintext-only contenteditable; commit writes to source. */
|
|
308
|
+
[contenteditable].dc-text-editing {
|
|
309
|
+
outline: 2px solid var(--maude-hud-accent, #0d99ff);
|
|
310
|
+
outline-offset: 1px;
|
|
311
|
+
border-radius: 1px;
|
|
312
|
+
cursor: text;
|
|
313
|
+
}
|
|
305
314
|
`.trim();
|
|
306
315
|
|
|
307
316
|
function ensureHaloStyles(): void {
|
|
@@ -322,6 +331,104 @@ function ensureHaloStyles(): void {
|
|
|
322
331
|
document.head.appendChild(s);
|
|
323
332
|
}
|
|
324
333
|
|
|
334
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
335
|
+
// Layers tree (Phase 12 Task 4, DDR-103). The shell's Layers tab is a browsable
|
|
336
|
+
// tree of the active artboard's stamped (`data-cd-id`) elements. We walk the
|
|
337
|
+
// artboard subtree in the iframe, serialize a nested tree, and post it to the
|
|
338
|
+
// shell; the shell renders it + posts back `select-by-id` / `highlight`.
|
|
339
|
+
|
|
340
|
+
interface LayerNode {
|
|
341
|
+
id: string;
|
|
342
|
+
tag: string;
|
|
343
|
+
label: string;
|
|
344
|
+
type: string;
|
|
345
|
+
/** Occurrence index of this id within the artboard — matches the resolver's
|
|
346
|
+
* `querySelectorAll([data-dc-screen=…] [data-cd-id=…])[index]`. */
|
|
347
|
+
index: number;
|
|
348
|
+
children: LayerNode[];
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
function kebabToTitle(s: string): string {
|
|
352
|
+
return s
|
|
353
|
+
.split('-')
|
|
354
|
+
.filter(Boolean)
|
|
355
|
+
.map((w) => w.charAt(0).toUpperCase() + w.slice(1))
|
|
356
|
+
.join(' ');
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
// Human label: data-dc-element (Title Case) → aria-label → role → tag(.class).
|
|
360
|
+
function layerLabel(el: Element): string {
|
|
361
|
+
const dc = el.getAttribute('data-dc-element');
|
|
362
|
+
if (dc) return kebabToTitle(dc);
|
|
363
|
+
const aria = el.getAttribute('aria-label');
|
|
364
|
+
if (aria) return aria.slice(0, 40);
|
|
365
|
+
const role = el.getAttribute('role');
|
|
366
|
+
if (role) return role;
|
|
367
|
+
const tag = el.tagName.toLowerCase();
|
|
368
|
+
const cls = realClasses(el).split(/\s+/).filter(Boolean)[0];
|
|
369
|
+
return cls ? `${tag}.${cls}` : tag;
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
function layerType(el: Element): string {
|
|
373
|
+
const tag = el.tagName.toLowerCase();
|
|
374
|
+
const role = el.getAttribute('role') ?? '';
|
|
375
|
+
const dc = el.getAttribute('data-dc-element') ?? '';
|
|
376
|
+
if (tag === 'button' || role === 'button' || /(^|-)(btn|cta|button)(-|$)/.test(dc))
|
|
377
|
+
return 'button';
|
|
378
|
+
if (/^h[1-6]$/.test(tag) || role === 'heading') return 'heading';
|
|
379
|
+
if (tag === 'input' || tag === 'textarea' || tag === 'select') return 'input';
|
|
380
|
+
if (tag === 'img' || tag === 'svg' || tag === 'picture' || tag === 'video') return 'image';
|
|
381
|
+
if (tag === 'a') return 'link';
|
|
382
|
+
if (tag === 'ul' || tag === 'ol' || tag === 'li') return 'list';
|
|
383
|
+
if (tag === 'nav') return 'nav';
|
|
384
|
+
if (tag === 'form') return 'form';
|
|
385
|
+
if (tag === 'p' || tag === 'span' || tag === 'label') return 'text';
|
|
386
|
+
return 'box';
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
function serializeArtboardTree(root: Element): LayerNode[] {
|
|
390
|
+
const seen = new Map<string, number>();
|
|
391
|
+
function walk(el: Element): LayerNode[] {
|
|
392
|
+
const out: LayerNode[] = [];
|
|
393
|
+
for (const child of Array.from(el.children)) {
|
|
394
|
+
// Skip dev-server overlay chrome (pins, halos) that lives inside the artboard.
|
|
395
|
+
if (child.closest('.dgn-pin, .dc-cv-halo, .dc-cv-group-bbox')) continue;
|
|
396
|
+
const cd = child.getAttribute('data-cd-id');
|
|
397
|
+
if (cd) {
|
|
398
|
+
const idx = seen.get(cd) ?? 0;
|
|
399
|
+
seen.set(cd, idx + 1);
|
|
400
|
+
out.push({
|
|
401
|
+
id: cd,
|
|
402
|
+
tag: child.tagName.toLowerCase(),
|
|
403
|
+
label: layerLabel(child),
|
|
404
|
+
type: layerType(child),
|
|
405
|
+
index: idx,
|
|
406
|
+
children: walk(child),
|
|
407
|
+
});
|
|
408
|
+
} else {
|
|
409
|
+
// Unstamped wrapper — descend so its stamped descendants still surface.
|
|
410
|
+
out.push(...walk(child));
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
return out;
|
|
414
|
+
}
|
|
415
|
+
return walk(root);
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
function postLayersTree(artboardId: string | null | undefined): void {
|
|
419
|
+
if (typeof document === 'undefined' || !artboardId) return;
|
|
420
|
+
const root = document.querySelector(`[data-dc-screen="${CSS.escape(artboardId)}"]`);
|
|
421
|
+
if (!root) return;
|
|
422
|
+
try {
|
|
423
|
+
window.parent.postMessage(
|
|
424
|
+
{ dgn: 'layers-tree', artboardId, tree: serializeArtboardTree(root) },
|
|
425
|
+
'*'
|
|
426
|
+
);
|
|
427
|
+
} catch {
|
|
428
|
+
/* detached / cross-origin */
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
|
|
325
432
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
326
433
|
// Shell
|
|
327
434
|
|
|
@@ -375,7 +482,9 @@ export function CanvasShell({
|
|
|
375
482
|
<MaybeSelectionSetProvider>
|
|
376
483
|
<AnnotationSelectionProvider>
|
|
377
484
|
<AnnotationsVisibilityProvider>
|
|
378
|
-
<
|
|
485
|
+
<ChromeVisibilityProvider>
|
|
486
|
+
<CanvasCore hostRef={hostRef}>{children}</CanvasCore>
|
|
487
|
+
</ChromeVisibilityProvider>
|
|
379
488
|
</AnnotationsVisibilityProvider>
|
|
380
489
|
</AnnotationSelectionProvider>
|
|
381
490
|
</MaybeSelectionSetProvider>
|
|
@@ -434,7 +543,7 @@ function CanvasCore({
|
|
|
434
543
|
if (!host) return;
|
|
435
544
|
const onDbl = (e: MouseEvent) => {
|
|
436
545
|
const t = e.target as Element | null;
|
|
437
|
-
if (!t
|
|
546
|
+
if (!t?.closest) return;
|
|
438
547
|
// Floating chrome / overlays / drawn user content / any artboard
|
|
439
548
|
// surface → leave alone. Only dblclick that lands on the canvas
|
|
440
549
|
// background outside every artboard triggers `fit()`.
|
|
@@ -517,7 +626,7 @@ function CanvasCore({
|
|
|
517
626
|
useEffect(() => {
|
|
518
627
|
if (!collab) return;
|
|
519
628
|
const first = selSet.selected[0];
|
|
520
|
-
if (!first
|
|
629
|
+
if (!first?.selector) {
|
|
521
630
|
collab.publishAwareness({ selection: null });
|
|
522
631
|
return;
|
|
523
632
|
}
|
|
@@ -947,7 +1056,7 @@ function applyArtboardFollowers(): void {
|
|
|
947
1056
|
function buildRegistry(deps: {
|
|
948
1057
|
controller: ViewportControllerHandle | null;
|
|
949
1058
|
clearSelection: () => void;
|
|
950
|
-
selSet: { selected: Selection[] };
|
|
1059
|
+
selSet: { selected: Selection[]; replace: (s: Selection | Selection[]) => void };
|
|
951
1060
|
distributeArtboards: (axis: 'x' | 'y') => void;
|
|
952
1061
|
alignArtboards: (mode: 'left' | 'right' | 'center-x' | 'top' | 'bottom' | 'center-y') => void;
|
|
953
1062
|
focusArtboard: (artboardId: string) => void;
|
|
@@ -1109,9 +1218,24 @@ function buildRegistry(deps: {
|
|
|
1109
1218
|
id: 'inspect',
|
|
1110
1219
|
label: 'Inspect',
|
|
1111
1220
|
shortcut: '⌥I',
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1221
|
+
onSelect: (target) => {
|
|
1222
|
+
// Phase 12 — select the right-clicked element + open the shell's
|
|
1223
|
+
// right Inspector panel (Inspect / Layers / CSS). Was a disabled TODO
|
|
1224
|
+
// before the inspector shipped.
|
|
1225
|
+
if (target.el) {
|
|
1226
|
+
selSet.replace(
|
|
1227
|
+
hoverTargetToSelection({
|
|
1228
|
+
el: target.el,
|
|
1229
|
+
cdId: target.cdId ?? null,
|
|
1230
|
+
artboardId: target.artboardId ?? null,
|
|
1231
|
+
} as HoverTarget)
|
|
1232
|
+
);
|
|
1233
|
+
}
|
|
1234
|
+
try {
|
|
1235
|
+
window.parent.postMessage({ dgn: 'open-inspector' }, '*');
|
|
1236
|
+
} catch {
|
|
1237
|
+
/* detached / cross-origin */
|
|
1238
|
+
}
|
|
1115
1239
|
},
|
|
1116
1240
|
},
|
|
1117
1241
|
],
|
|
@@ -1248,6 +1372,13 @@ function CanvasRouter({
|
|
|
1248
1372
|
const annotSel = useAnnotationSelection();
|
|
1249
1373
|
const ctxMenu = useContextMenu();
|
|
1250
1374
|
const undoStack = useUndoStack();
|
|
1375
|
+
// Shell View-menu zoom bridge (dgn:'zoom') — same controller the zoom pill uses.
|
|
1376
|
+
const zoomController = useViewportControllerContext();
|
|
1377
|
+
// Shell View-menu chrome-visibility bridge (dgn:'view-chrome') — minimap /
|
|
1378
|
+
// zoom-controls toggles + Presentation Mode. Drives the shared chrome store
|
|
1379
|
+
// the floating chrome (DCMiniMap, DCZoomToolbar, ToolPalette, AnnotationsLayer)
|
|
1380
|
+
// consumes.
|
|
1381
|
+
const chromeCtx = useChromeVisibility();
|
|
1251
1382
|
|
|
1252
1383
|
// Hover state drives the floating .dc-cv-halo--hover overlay. The overlay
|
|
1253
1384
|
// itself reads getBoundingClientRect on every rAF tick to follow pan/zoom.
|
|
@@ -1312,6 +1443,87 @@ function CanvasRouter({
|
|
|
1312
1443
|
void undoStack.redo();
|
|
1313
1444
|
return;
|
|
1314
1445
|
}
|
|
1446
|
+
// Phase 12 Task 4 (DDR-103) — Layers-tree round-trip. select-by-id resolves
|
|
1447
|
+
// the node + replaces the selection (which posts select-set back, updating
|
|
1448
|
+
// the Inspect/CSS tabs + halos); highlight shows the transient hover halo;
|
|
1449
|
+
// request-layers re-walks + posts the tree for an artboard.
|
|
1450
|
+
if (m.dgn === 'select-by-id') {
|
|
1451
|
+
const mm = m as { id?: string; artboardId?: string | null; index?: number };
|
|
1452
|
+
if (mm.id) {
|
|
1453
|
+
const target = resolveSelectionEl(document, {
|
|
1454
|
+
id: mm.id,
|
|
1455
|
+
artboardId: mm.artboardId,
|
|
1456
|
+
index: mm.index,
|
|
1457
|
+
});
|
|
1458
|
+
if (target) {
|
|
1459
|
+
selSet.replace(
|
|
1460
|
+
hoverTargetToSelection({
|
|
1461
|
+
el: target,
|
|
1462
|
+
cdId: mm.id,
|
|
1463
|
+
artboardId: mm.artboardId ?? null,
|
|
1464
|
+
} as HoverTarget)
|
|
1465
|
+
);
|
|
1466
|
+
(target as HTMLElement).scrollIntoView?.({ block: 'nearest', behavior: 'smooth' });
|
|
1467
|
+
}
|
|
1468
|
+
}
|
|
1469
|
+
return;
|
|
1470
|
+
}
|
|
1471
|
+
if (m.dgn === 'highlight') {
|
|
1472
|
+
const mm = m as { id?: string; artboardId?: string | null; index?: number };
|
|
1473
|
+
setHoverEl(
|
|
1474
|
+
mm.id
|
|
1475
|
+
? resolveSelectionEl(document, {
|
|
1476
|
+
id: mm.id,
|
|
1477
|
+
artboardId: mm.artboardId,
|
|
1478
|
+
index: mm.index,
|
|
1479
|
+
})
|
|
1480
|
+
: null
|
|
1481
|
+
);
|
|
1482
|
+
return;
|
|
1483
|
+
}
|
|
1484
|
+
// Phase 12.3 (W1.1) — optimistic inline-style apply. The CSS panel posts
|
|
1485
|
+
// this on commit so the selected element updates INSTANTLY, before the
|
|
1486
|
+
// edit-css → file-watcher HMR reload re-renders from source. `value` null/
|
|
1487
|
+
// empty removes the property (the reset path). Purely a live-DOM preview;
|
|
1488
|
+
// the authoritative value is whatever the reload renders from source.
|
|
1489
|
+
// SECURITY (ethical-hacker A1/A2) — accept apply-style ONLY from the parent
|
|
1490
|
+
// shell, never from a canvas self-post. The canvas iframe is untrusted
|
|
1491
|
+
// (DDR-054); without this gate hostile canvas JS could repaint elements AND
|
|
1492
|
+
// (paired with the reload echo-guard) suppress reloads to desync view/source.
|
|
1493
|
+
// The reload-suppression timestamp now lives in _shell.html's closure (NOT a
|
|
1494
|
+
// canvas-writable window global) — set there from the same parent-gated msg.
|
|
1495
|
+
if (m.dgn === 'apply-style') {
|
|
1496
|
+
if (e.source !== window.parent) return;
|
|
1497
|
+
const mm = m as {
|
|
1498
|
+
id?: string;
|
|
1499
|
+
artboardId?: string | null;
|
|
1500
|
+
index?: number;
|
|
1501
|
+
prop?: string;
|
|
1502
|
+
value?: string | null;
|
|
1503
|
+
};
|
|
1504
|
+
if (mm.id && mm.prop) {
|
|
1505
|
+
const target = resolveSelectionEl(document, {
|
|
1506
|
+
id: mm.id,
|
|
1507
|
+
artboardId: mm.artboardId,
|
|
1508
|
+
index: mm.index,
|
|
1509
|
+
});
|
|
1510
|
+
if (target) {
|
|
1511
|
+
const style = (target as HTMLElement).style;
|
|
1512
|
+
try {
|
|
1513
|
+
// Live-DOM preview only; the reload renders the authoritative value.
|
|
1514
|
+
if (mm.value == null || mm.value === '') style.removeProperty(mm.prop);
|
|
1515
|
+
else style.setProperty(mm.prop, mm.value);
|
|
1516
|
+
} catch {
|
|
1517
|
+
/* invalid prop/value — ignore; the reload is the source of truth */
|
|
1518
|
+
}
|
|
1519
|
+
}
|
|
1520
|
+
}
|
|
1521
|
+
return;
|
|
1522
|
+
}
|
|
1523
|
+
if (m.dgn === 'request-layers') {
|
|
1524
|
+
postLayersTree((m as { artboardId?: string }).artboardId ?? null);
|
|
1525
|
+
return;
|
|
1526
|
+
}
|
|
1315
1527
|
// D9 — canvas-shell chrome follows the Maude chrome theme. The chrome's
|
|
1316
1528
|
// `--maude-chrome-*` token family is keyed by `data-maude-theme` on the
|
|
1317
1529
|
// iframe documentElement (see HUD_TOKENS_CSS). This attribute is
|
|
@@ -1325,10 +1537,113 @@ function CanvasRouter({
|
|
|
1325
1537
|
}
|
|
1326
1538
|
return;
|
|
1327
1539
|
}
|
|
1540
|
+
// Shell View-menu zoom items — route to the live viewport controller
|
|
1541
|
+
// (the same methods the in-canvas zoom pill calls).
|
|
1542
|
+
if (m.dgn === 'zoom' && zoomController) {
|
|
1543
|
+
const op = (m as { op?: string }).op;
|
|
1544
|
+
if (op === 'in') zoomController.zoomIn();
|
|
1545
|
+
else if (op === 'out') zoomController.zoomOut();
|
|
1546
|
+
else if (op === 'fit') zoomController.fit();
|
|
1547
|
+
else if (op === 'actual') zoomController.reset();
|
|
1548
|
+
return;
|
|
1549
|
+
}
|
|
1550
|
+
// Shell View-menu chrome toggles + Presentation Mode. Only the fields the
|
|
1551
|
+
// shell sends are merged, so a `{ present: true }` enter keeps the user's
|
|
1552
|
+
// individual minimap/zoom toggles for the eventual exit.
|
|
1553
|
+
if (m.dgn === 'view-chrome' && chromeCtx) {
|
|
1554
|
+
const mm = m as { minimap?: boolean; zoom?: boolean; present?: boolean };
|
|
1555
|
+
const patch: { minimap?: boolean; zoom?: boolean; present?: boolean } = {};
|
|
1556
|
+
if (typeof mm.minimap === 'boolean') patch.minimap = mm.minimap;
|
|
1557
|
+
if (typeof mm.zoom === 'boolean') patch.zoom = mm.zoom;
|
|
1558
|
+
if (typeof mm.present === 'boolean') patch.present = mm.present;
|
|
1559
|
+
chromeCtx.setChrome(patch);
|
|
1560
|
+
return;
|
|
1561
|
+
}
|
|
1328
1562
|
};
|
|
1329
1563
|
window.addEventListener('message', onMessage);
|
|
1330
1564
|
return () => window.removeEventListener('message', onMessage);
|
|
1331
|
-
}, [selSet, annotSel, setTool, undoStack]);
|
|
1565
|
+
}, [selSet, annotSel, setTool, undoStack, zoomController, chromeCtx]);
|
|
1566
|
+
|
|
1567
|
+
// Phase 12 (DDR-103) — double-click a LEAF-TEXT element (children all text
|
|
1568
|
+
// nodes) to edit its copy in place. Commit (blur / Enter) posts `dgn:edit-text`
|
|
1569
|
+
// to the shell, which calls the main-origin /_api/edit-text → editText writes
|
|
1570
|
+
// the escaped JSXText to source; the file-watcher HMR reload re-renders from
|
|
1571
|
+
// the persisted source. Esc restores the original. Mixed/expression elements
|
|
1572
|
+
// are skipped (the engine would refuse them anyway — honest no-op).
|
|
1573
|
+
useEffect(() => {
|
|
1574
|
+
if (typeof document === 'undefined') return;
|
|
1575
|
+
const host = hostRef.current;
|
|
1576
|
+
if (!host) return;
|
|
1577
|
+
let editing: HTMLElement | null = null;
|
|
1578
|
+
let original = '';
|
|
1579
|
+
const onBlur = (): void => commitEdit(true);
|
|
1580
|
+
const onKey = (e: KeyboardEvent): void => {
|
|
1581
|
+
if (e.key === 'Escape') {
|
|
1582
|
+
e.preventDefault();
|
|
1583
|
+
commitEdit(false);
|
|
1584
|
+
} else if (e.key === 'Enter' && !e.shiftKey) {
|
|
1585
|
+
e.preventDefault();
|
|
1586
|
+
(e.currentTarget as HTMLElement | null)?.blur?.();
|
|
1587
|
+
}
|
|
1588
|
+
};
|
|
1589
|
+
function teardown(elx: HTMLElement): void {
|
|
1590
|
+
elx.removeAttribute('contenteditable');
|
|
1591
|
+
elx.classList.remove('dc-text-editing');
|
|
1592
|
+
elx.removeEventListener('blur', onBlur, true);
|
|
1593
|
+
elx.removeEventListener('keydown', onKey, true);
|
|
1594
|
+
}
|
|
1595
|
+
function commitEdit(commit: boolean): void {
|
|
1596
|
+
const elx = editing;
|
|
1597
|
+
if (!elx) return;
|
|
1598
|
+
editing = null;
|
|
1599
|
+
const text = (elx.textContent ?? '').trim();
|
|
1600
|
+
teardown(elx);
|
|
1601
|
+
if (!commit) {
|
|
1602
|
+
elx.textContent = original;
|
|
1603
|
+
return;
|
|
1604
|
+
}
|
|
1605
|
+
if (text === original.trim()) return;
|
|
1606
|
+
const cdId = elx.getAttribute('data-cd-id');
|
|
1607
|
+
if (!cdId) return;
|
|
1608
|
+
try {
|
|
1609
|
+
window.parent.postMessage({ dgn: 'edit-text', id: cdId, file: deriveFile(), text }, '*');
|
|
1610
|
+
} catch {
|
|
1611
|
+
/* detached / cross-origin */
|
|
1612
|
+
}
|
|
1613
|
+
}
|
|
1614
|
+
const onDbl = (e: MouseEvent): void => {
|
|
1615
|
+
if (editing) return;
|
|
1616
|
+
const t = e.target as HTMLElement | null;
|
|
1617
|
+
const stamped = (t?.closest?.('[data-cd-id]') as HTMLElement | null) ?? null;
|
|
1618
|
+
if (!stamped) return;
|
|
1619
|
+
const kids = Array.from(stamped.childNodes);
|
|
1620
|
+
if (kids.length === 0 || !kids.every((n) => n.nodeType === 3)) return; // leaf-text only
|
|
1621
|
+
e.preventDefault();
|
|
1622
|
+
e.stopPropagation();
|
|
1623
|
+
editing = stamped;
|
|
1624
|
+
original = stamped.textContent ?? '';
|
|
1625
|
+
stamped.setAttribute('contenteditable', 'plaintext-only');
|
|
1626
|
+
stamped.classList.add('dc-text-editing');
|
|
1627
|
+
stamped.addEventListener('blur', onBlur, true);
|
|
1628
|
+
stamped.addEventListener('keydown', onKey, true);
|
|
1629
|
+
stamped.focus();
|
|
1630
|
+
try {
|
|
1631
|
+
const range = document.createRange();
|
|
1632
|
+
range.selectNodeContents(stamped);
|
|
1633
|
+
const sel = window.getSelection();
|
|
1634
|
+
sel?.removeAllRanges();
|
|
1635
|
+
sel?.addRange(range);
|
|
1636
|
+
} catch {
|
|
1637
|
+
/* selection API unavailable */
|
|
1638
|
+
}
|
|
1639
|
+
};
|
|
1640
|
+
// Capture phase so we beat the fit-to-view dblclick handler.
|
|
1641
|
+
host.addEventListener('dblclick', onDbl, true);
|
|
1642
|
+
return () => {
|
|
1643
|
+
host.removeEventListener('dblclick', onDbl, true);
|
|
1644
|
+
if (editing) teardown(editing);
|
|
1645
|
+
};
|
|
1646
|
+
}, [hostRef]);
|
|
1332
1647
|
|
|
1333
1648
|
// Cleanup any pending rAF on unmount.
|
|
1334
1649
|
useEffect(
|
|
@@ -1363,6 +1678,8 @@ function CanvasRouter({
|
|
|
1363
1678
|
const sel = hoverTargetToSelection(target);
|
|
1364
1679
|
if (mode === 'replace') selSet.replace(sel);
|
|
1365
1680
|
else selSet.add(sel);
|
|
1681
|
+
// Phase 12 Task 4 — refresh the Layers tree for the selected artboard.
|
|
1682
|
+
postLayersTree(target.artboardId);
|
|
1366
1683
|
},
|
|
1367
1684
|
onContextMenu: ({ clientX, clientY }) => {
|
|
1368
1685
|
const target = resolveHoverTarget(document, clientX, clientY, { deep: true });
|
|
@@ -1754,7 +2071,7 @@ function HoverHalo({ el }: { el: Element | null }) {
|
|
|
1754
2071
|
rafRef.current = null;
|
|
1755
2072
|
const div = ref.current;
|
|
1756
2073
|
const t = targetRef.current;
|
|
1757
|
-
if (!div || !t
|
|
2074
|
+
if (!div || !t?.isConnected) {
|
|
1758
2075
|
if (div) div.style.display = 'none';
|
|
1759
2076
|
return;
|
|
1760
2077
|
}
|