@1agh/maude 0.29.0 → 0.30.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 +1 -1
- 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 +1708 -1660
- package/apps/studio/annotations-model.ts +2077 -0
- package/apps/studio/annotations-snap.ts +125 -0
- package/apps/studio/api.ts +208 -116
- 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/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-shell.tsx +305 -8
- package/apps/studio/client/app.jsx +2812 -238
- package/apps/studio/client/index.html +1 -1
- package/apps/studio/client/styles/3-shell-maude.css +713 -11
- package/apps/studio/client/styles/4-components.css +39 -0
- package/apps/studio/client/styles/5-maude-overrides.css +16 -2
- package/apps/studio/client/tour/overlay.jsx +105 -17
- package/apps/studio/client/tour/usage-tour.js +23 -10
- 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 +20 -20
- package/apps/studio/dist/client.bundle.js +3322 -602
- package/apps/studio/dist/comment-mount.js +95 -9
- package/apps/studio/dist/styles.css +2490 -167
- package/apps/studio/dom-selection.ts +115 -0
- package/apps/studio/export-dialog.tsx +3 -3
- package/apps/studio/history.ts +20 -3
- package/apps/studio/http.ts +124 -5
- package/apps/studio/input-router.tsx +7 -0
- package/apps/studio/inspect.ts +37 -8
- package/apps/studio/participants-chrome.tsx +1 -7
- package/apps/studio/server.ts +5 -2
- package/apps/studio/sync/agent.ts +231 -57
- package/apps/studio/sync/codec.ts +45 -0
- package/apps/studio/sync/cold-start.ts +158 -0
- package/apps/studio/sync/connection-state.ts +58 -2
- 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/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-edit.test.ts +181 -1
- package/apps/studio/test/canvas-origin-gate.test.ts +8 -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/csp-canvas-shell.test.ts +5 -0
- package/apps/studio/test/csrf-write-guard.test.ts +52 -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/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-start.test.ts +244 -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/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 +12 -2
- 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-collab.tsx +20 -15
- package/apps/studio/use-selection-set.tsx +9 -0
- package/apps/studio/use-tool-mode.tsx +5 -1
- package/apps/studio/whats-new.json +135 -10
- package/apps/studio/whats-new.schema.json +18 -2
- package/cli/commands/design-link.test.mjs +84 -0
- package/cli/commands/design.mjs +2 -1
- package/cli/commands/design.test.mjs +5 -1
- package/cli/lib/design-link.mjs +51 -1
- package/package.json +8 -8
- package/plugins/design/templates/_shell.html +28 -4
|
@@ -175,6 +175,120 @@ export function resolveSelectionEl(
|
|
|
175
175
|
return null;
|
|
176
176
|
}
|
|
177
177
|
|
|
178
|
+
/**
|
|
179
|
+
* Phase 12.2 — style maps for the CSS-knob properties. `authored` is what the
|
|
180
|
+
* element sets INLINE (React renders `style={{padding:8}}` → `style="padding:8px"`),
|
|
181
|
+
* so the knob pre-fills the EDITABLE source value and is blank when unset — not
|
|
182
|
+
* the noisy resolved default (`656.003px`, `rgb(0,0,0)`). `computed` is the
|
|
183
|
+
* resolved value, shown only as a faint placeholder hint. Empty for a detached
|
|
184
|
+
* node / SSR.
|
|
185
|
+
*/
|
|
186
|
+
// Phase 12.3 (W2.2 fix) — EVERY property the CSS panel has a control for, so each
|
|
187
|
+
// reads back into `authored` (pre-fills the right control) and is excluded from
|
|
188
|
+
// `customStyles`. The earlier short list omitted the box-model LONGHANDS
|
|
189
|
+
// (`padding-top`, `margin-left`, …) + the Layout/border longhands, so a value the
|
|
190
|
+
// panel wrote (e.g. an alt-scrub `padding-top`) fell through to customStyles and
|
|
191
|
+
// the box-model widget showed it as a "custom CSS property" instead of in the box.
|
|
192
|
+
const KNOB_PROPS = [
|
|
193
|
+
// Layout
|
|
194
|
+
'display',
|
|
195
|
+
'flex-direction',
|
|
196
|
+
'align-items',
|
|
197
|
+
'justify-content',
|
|
198
|
+
'gap',
|
|
199
|
+
// Typography
|
|
200
|
+
'font-family',
|
|
201
|
+
'color',
|
|
202
|
+
'font-size',
|
|
203
|
+
'font-weight',
|
|
204
|
+
'line-height',
|
|
205
|
+
'letter-spacing',
|
|
206
|
+
'text-align',
|
|
207
|
+
// Spacing — shorthand (for customStyles exclusion + whole-side authoring) AND
|
|
208
|
+
// the 8 longhands the box-model widget actually reads/writes.
|
|
209
|
+
'margin',
|
|
210
|
+
'margin-top',
|
|
211
|
+
'margin-right',
|
|
212
|
+
'margin-bottom',
|
|
213
|
+
'margin-left',
|
|
214
|
+
'padding',
|
|
215
|
+
'padding-top',
|
|
216
|
+
'padding-right',
|
|
217
|
+
'padding-bottom',
|
|
218
|
+
'padding-left',
|
|
219
|
+
// Size
|
|
220
|
+
'width',
|
|
221
|
+
'height',
|
|
222
|
+
'max-width',
|
|
223
|
+
// Appearance
|
|
224
|
+
'background-color',
|
|
225
|
+
'border-radius',
|
|
226
|
+
'border-top-left-radius',
|
|
227
|
+
'border-top-right-radius',
|
|
228
|
+
'border-bottom-left-radius',
|
|
229
|
+
'border-bottom-right-radius',
|
|
230
|
+
'border-width',
|
|
231
|
+
'border-style',
|
|
232
|
+
'border-color',
|
|
233
|
+
'box-shadow',
|
|
234
|
+
'opacity',
|
|
235
|
+
] as const;
|
|
236
|
+
|
|
237
|
+
// Phase 12.3 — HTML attributes the custom-attribute hatch may have written, so a
|
|
238
|
+
// just-added `data-*`/`aria-*`/`role`/`title` round-trips back into a panel row.
|
|
239
|
+
// Excludes the structural ones the panel manages elsewhere (style, class,
|
|
240
|
+
// data-cd-id pipeline anchor, the data-dc-* canvas chrome markers).
|
|
241
|
+
const ATTR_SKIP = /^(style|class|data-cd-id|data-dc-|data-dcid$)/;
|
|
242
|
+
|
|
243
|
+
function styleMapsFor(el: Element | null): {
|
|
244
|
+
authored: Record<string, string>;
|
|
245
|
+
computed: Record<string, string>;
|
|
246
|
+
customStyles: Record<string, string>;
|
|
247
|
+
attrs: Record<string, string>;
|
|
248
|
+
} {
|
|
249
|
+
if (!el || typeof window === 'undefined' || !window.getComputedStyle) {
|
|
250
|
+
return { authored: {}, computed: {}, customStyles: {}, attrs: {} };
|
|
251
|
+
}
|
|
252
|
+
try {
|
|
253
|
+
const inline = (el as HTMLElement).style;
|
|
254
|
+
const cs = window.getComputedStyle(el as HTMLElement);
|
|
255
|
+
const authored: Record<string, string> = {};
|
|
256
|
+
const computed: Record<string, string> = {};
|
|
257
|
+
const knob = new Set<string>(KNOB_PROPS as readonly string[]);
|
|
258
|
+
// Curated knob props: authored value (knob pre-fill) + computed (placeholder).
|
|
259
|
+
for (const p of KNOB_PROPS) {
|
|
260
|
+
const a = inline.getPropertyValue(p);
|
|
261
|
+
if (a) authored[p] = a.trim();
|
|
262
|
+
const c = cs.getPropertyValue(p);
|
|
263
|
+
if (c) computed[p] = c.trim();
|
|
264
|
+
}
|
|
265
|
+
// Every OTHER authored inline property → customStyles, so the panel can show
|
|
266
|
+
// a custom CSS property the user added that no curated row covers. EXCLUDE
|
|
267
|
+
// the panel-managed FAMILIES wholesale: setting `border`/`border-radius` (or
|
|
268
|
+
// their 3-way shorthands) makes the CSSOM expand them to the per-side
|
|
269
|
+
// longhands — `border-top-width`, `border-left-color`, … — which the panel
|
|
270
|
+
// controls but can't enumerate in the knob set. Without the family guard they
|
|
271
|
+
// leak into "custom CSS properties" (the same class of bug fixed for spacing).
|
|
272
|
+
const MANAGED_FAMILY = /^(margin|padding|border)(-|$)/;
|
|
273
|
+
const customStyles: Record<string, string> = {};
|
|
274
|
+
for (let i = 0; i < inline.length; i++) {
|
|
275
|
+
const p = inline.item(i);
|
|
276
|
+
if (!p || knob.has(p) || MANAGED_FAMILY.test(p)) continue;
|
|
277
|
+
const v = inline.getPropertyValue(p);
|
|
278
|
+
if (v) customStyles[p] = v.trim();
|
|
279
|
+
}
|
|
280
|
+
// Custom HTML attributes (the escape-hatch surface).
|
|
281
|
+
const attrs: Record<string, string> = {};
|
|
282
|
+
for (const a of Array.from((el as HTMLElement).attributes)) {
|
|
283
|
+
if (ATTR_SKIP.test(a.name)) continue;
|
|
284
|
+
attrs[a.name] = a.value;
|
|
285
|
+
}
|
|
286
|
+
return { authored, computed, customStyles, attrs };
|
|
287
|
+
} catch {
|
|
288
|
+
return { authored: {}, computed: {}, customStyles: {}, attrs: {} };
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
|
|
178
292
|
export function hoverTargetToSelection(target: HoverTarget, file?: string): Selection {
|
|
179
293
|
const el = target.el;
|
|
180
294
|
const rect =
|
|
@@ -223,5 +337,6 @@ export function hoverTargetToSelection(target: HoverTarget, file?: string): Sele
|
|
|
223
337
|
}
|
|
224
338
|
: null,
|
|
225
339
|
html: el ? (el.outerHTML ?? '').slice(0, 4000) : '',
|
|
340
|
+
...styleMapsFor(el),
|
|
226
341
|
};
|
|
227
342
|
}
|
|
@@ -321,6 +321,7 @@ export function ExportDialogProvider({ children }: { children: ReactNode }): Rea
|
|
|
321
321
|
}, [openState, loadHistory]);
|
|
322
322
|
|
|
323
323
|
// ⌘E / Ctrl+E to open; ⌘⇧E / Ctrl+Shift+E to re-run last.
|
|
324
|
+
// biome-ignore lint/correctness/useExhaustiveDependencies: re-bind on `open` only — rerunLast is stable per render; re-running on it would tear down the global hotkey listener needlessly.
|
|
324
325
|
useEffect(() => {
|
|
325
326
|
function onKey(e: KeyboardEvent) {
|
|
326
327
|
const mod = e.metaKey || e.ctrlKey;
|
|
@@ -331,7 +332,6 @@ export function ExportDialogProvider({ children }: { children: ReactNode }): Rea
|
|
|
331
332
|
}
|
|
332
333
|
window.addEventListener('keydown', onKey);
|
|
333
334
|
return () => window.removeEventListener('keydown', onKey);
|
|
334
|
-
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
335
335
|
}, [open]);
|
|
336
336
|
|
|
337
337
|
// Phase 6.5 T9 — context-menu entries dispatch `maude:open-export` so they
|
|
@@ -559,8 +559,8 @@ const DialogShell = (() => {
|
|
|
559
559
|
<div className="dc-ed-recent">
|
|
560
560
|
<h3>Recent</h3>
|
|
561
561
|
<ul>
|
|
562
|
-
{history.slice(0, 5).map((h
|
|
563
|
-
<li key={`${h.at}-${
|
|
562
|
+
{history.slice(0, 5).map((h) => (
|
|
563
|
+
<li key={`${h.at}-${h.filename}`}>
|
|
564
564
|
<button
|
|
565
565
|
type="button"
|
|
566
566
|
onClick={() => {
|
package/apps/studio/history.ts
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
// from the WS layer and from a future server-driven auto-snapshot hook.
|
|
6
6
|
|
|
7
7
|
import type { Dirent } from 'node:fs';
|
|
8
|
+
import { existsSync } from 'node:fs';
|
|
8
9
|
import { readdir } from 'node:fs/promises';
|
|
9
10
|
import path from 'node:path';
|
|
10
11
|
|
|
@@ -37,10 +38,14 @@ function fileSlug(file: string, designRel: string): string {
|
|
|
37
38
|
}
|
|
38
39
|
const prefix = `${designRel.replace(/^\/+|\/+$/g, '')}/`;
|
|
39
40
|
if (p.startsWith(prefix)) p = p.slice(prefix.length);
|
|
41
|
+
// Extension strip mirrors bin/slug.sh (the canonical `_history/<slug>/`
|
|
42
|
+
// recipe) — previously only `.html` was stripped, so `.tsx` canvases got a
|
|
43
|
+
// `ui-foo.tsx` history dir that /design:rollback (slug.sh: `ui-foo`) could
|
|
44
|
+
// never find. DDR-102 conflict snapshots rely on the dirs matching.
|
|
40
45
|
return p
|
|
41
46
|
.replace(/\//g, '-')
|
|
42
47
|
.replace(/\s+/g, '_')
|
|
43
|
-
.replace(/\.html$/i, '')
|
|
48
|
+
.replace(/\.(tsx|jsx|html?|css|json|md)$/i, '')
|
|
44
49
|
.replace(/^\.+/, '')
|
|
45
50
|
.toLowerCase();
|
|
46
51
|
}
|
|
@@ -60,8 +65,20 @@ export function createHistory(ctx: Context): History {
|
|
|
60
65
|
|
|
61
66
|
async function writeSnapshot(file: string, contentBytes: Uint8Array | string, reason: string) {
|
|
62
67
|
const slug = fileSlug(file, ctx.paths.designRel);
|
|
63
|
-
|
|
64
|
-
|
|
68
|
+
// Snapshot blob keeps the source file's extension (a `.tsx` body snapshot
|
|
69
|
+
// is a `.tsx` blob); unknown/absent extension falls back to `.html`.
|
|
70
|
+
const extMatch = /\.(tsx|jsx|html?|css|json|svg|md)$/i.exec(file);
|
|
71
|
+
const ext = extMatch ? extMatch[0].toLowerCase() : '.html';
|
|
72
|
+
// Two snapshots can land within the same millisecond (the DDR-102 dual
|
|
73
|
+
// pre-sync pair does) — toISOString() would collide and the second blob
|
|
74
|
+
// would overwrite the first. Bump by 1 ms until the slot is free.
|
|
75
|
+
let tsMs = Date.now();
|
|
76
|
+
let ts = new Date(tsMs).toISOString();
|
|
77
|
+
while (existsSync(snapshotPath(slug, ts, ext)) || existsSync(metaPath(slug, ts))) {
|
|
78
|
+
tsMs += 1;
|
|
79
|
+
ts = new Date(tsMs).toISOString();
|
|
80
|
+
}
|
|
81
|
+
const contentPath = snapshotPath(slug, ts, ext);
|
|
65
82
|
const meta: Snapshot = {
|
|
66
83
|
slug,
|
|
67
84
|
ts,
|
package/apps/studio/http.ts
CHANGED
|
@@ -19,7 +19,7 @@ import type { ActiveJsonShape } from './exporters/scope.ts';
|
|
|
19
19
|
import type { Inspect } from './inspect.ts';
|
|
20
20
|
import { canvasSlug, writeLocator } from './locator.ts';
|
|
21
21
|
import { DEV_SERVER_ROOT } from './paths.ts';
|
|
22
|
-
import { getRuntimeBundle, packageForSlug
|
|
22
|
+
import { getRuntimeBundle, packageForSlug } from './runtime-bundle.ts';
|
|
23
23
|
import { loadWhatsNew } from './whats-new.ts';
|
|
24
24
|
|
|
25
25
|
// Real disk install root — never the virtual `/$bunfs/root` of compiled bins.
|
|
@@ -81,10 +81,11 @@ function ext(p: string): string {
|
|
|
81
81
|
*
|
|
82
82
|
* `frame-ancestors` (A6) — restricts who may embed the canvas document. The
|
|
83
83
|
* legit embedder is the main dev-server origin, so we allowlist exactly that
|
|
84
|
-
* (`mainOrigin`
|
|
85
|
-
*
|
|
86
|
-
*
|
|
87
|
-
*
|
|
84
|
+
* (`mainOrigin` — a space-separated source list; server.ts advertises both
|
|
85
|
+
* loopback spellings, `localhost` + `127.0.0.1`) plus `'self'`; an arbitrary
|
|
86
|
+
* external page can no longer reframe the canvas. When `mainOrigin` is unknown
|
|
87
|
+
* (tests / pre-boot) the directive is OMITTED rather than set to `'self'` —
|
|
88
|
+
* `'self'` alone would forbid the legit cross-origin embed and blank the canvas.
|
|
88
89
|
*/
|
|
89
90
|
export function cspForCanvasShell(html: string, mainOrigin?: string): string {
|
|
90
91
|
const hashes: string[] = [];
|
|
@@ -115,6 +116,34 @@ export function cspForCanvasShell(html: string, mainOrigin?: string): string {
|
|
|
115
116
|
return directives.join('; ');
|
|
116
117
|
}
|
|
117
118
|
|
|
119
|
+
/**
|
|
120
|
+
* CSRF guard for the main-origin source-write routes (edit-css / edit-text /
|
|
121
|
+
* edit-attr). Those routes are reachable only from the shell, which is
|
|
122
|
+
* same-origin — but `readJson` enforces no `Content-Type`, so a cross-site page
|
|
123
|
+
* could otherwise forge a `text/plain` CORS *simple-request* POST to
|
|
124
|
+
* `http://localhost:<port>/_api/edit-*` (no preflight) and drive a write into
|
|
125
|
+
* the user's source `.tsx`. The browser stamps every cross-origin POST with an
|
|
126
|
+
* unspoofable `Origin` header, so we reject any request whose Origin is PRESENT
|
|
127
|
+
* and ≠ the server's own origin. A request with NO Origin (bun:test, curl,
|
|
128
|
+
* non-browser programmatic clients — none of which are the CSRF threat, which
|
|
129
|
+
* requires a browser executing attacker markup that always sends Origin on a
|
|
130
|
+
* cross-origin POST) is allowed through. This is layered on top of the DDR-054
|
|
131
|
+
* origin-split (which blocks the untrusted canvas *iframe*); it closes the
|
|
132
|
+
* *other* untrusted origin — a malicious top-level page in another tab.
|
|
133
|
+
* Deliberately NOT applied to `/_api/asset` (that route is canvas-origin
|
|
134
|
+
* reachable by design — drag-drop/paste upload runs inside the iframe). See
|
|
135
|
+
* DDR-105. Exported for unit testing (the decision is a pure function of `req`).
|
|
136
|
+
*/
|
|
137
|
+
export function sameOriginWrite(req: Request): boolean {
|
|
138
|
+
const origin = req.headers.get('origin');
|
|
139
|
+
if (!origin) return true; // non-browser / same-origin omitting Origin → allow
|
|
140
|
+
try {
|
|
141
|
+
return origin === new URL(req.url).origin;
|
|
142
|
+
} catch {
|
|
143
|
+
return false;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
118
147
|
function safePathUnderRoot(reqUrl: string, repoRoot: string): string | null {
|
|
119
148
|
let pathname: string;
|
|
120
149
|
try {
|
|
@@ -639,6 +668,96 @@ export function createHttp(ctx: Context, api: Api, inspect: Inspect, ai: AiActiv
|
|
|
639
668
|
);
|
|
640
669
|
},
|
|
641
670
|
|
|
671
|
+
'/_api/edit-css': async (req: Request) => {
|
|
672
|
+
// Phase 12 (DDR-103) — single-property inline CSS edit. POST body
|
|
673
|
+
// { canvas, id, property, value } → writes one key into the element's
|
|
674
|
+
// inline `style={{}}` object via api.editCss → editAttribute. MAIN ORIGIN
|
|
675
|
+
// ONLY: intentionally absent from CANVAS_SAFE_API + startCanvasServer's
|
|
676
|
+
// route allowlist (DDR-054) — the untrusted canvas iframe origin must never
|
|
677
|
+
// reach a source-write endpoint. The CSS-knob UI lives in the shell (main
|
|
678
|
+
// origin) and calls it directly.
|
|
679
|
+
if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 });
|
|
680
|
+
if (!sameOriginWrite(req))
|
|
681
|
+
return new Response('cross-origin write rejected', { status: 403 });
|
|
682
|
+
const body = await readJson<{
|
|
683
|
+
canvas?: unknown;
|
|
684
|
+
id?: unknown;
|
|
685
|
+
property?: unknown;
|
|
686
|
+
value?: unknown;
|
|
687
|
+
reset?: unknown;
|
|
688
|
+
}>(req, 8 * 1024);
|
|
689
|
+
if (!body) return new Response('body required', { status: 400 });
|
|
690
|
+
const result = await api.editCss(body);
|
|
691
|
+
if (!result.ok) {
|
|
692
|
+
return Response.json(
|
|
693
|
+
{ ok: false, error: result.error },
|
|
694
|
+
{ status: result.status, headers: { 'Cache-Control': 'no-store' } }
|
|
695
|
+
);
|
|
696
|
+
}
|
|
697
|
+
return Response.json(
|
|
698
|
+
{ ok: true, delta: result.delta },
|
|
699
|
+
{ status: 200, headers: { 'Cache-Control': 'no-store' } }
|
|
700
|
+
);
|
|
701
|
+
},
|
|
702
|
+
|
|
703
|
+
'/_api/edit-text': async (req: Request) => {
|
|
704
|
+
// Phase 12 (DDR-103) — inline element text-content edit. POST body
|
|
705
|
+
// { canvas, id, text } → overwrites the element's single JSXText child
|
|
706
|
+
// (JSX-escaped) via api.editText → editText. Same MAIN-ORIGIN-ONLY trust
|
|
707
|
+
// boundary as /_api/edit-css + /_api/canvas. The inline contenteditable
|
|
708
|
+
// editor reaches it via the dgn:* bus → shell relay (never the iframe).
|
|
709
|
+
if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 });
|
|
710
|
+
if (!sameOriginWrite(req))
|
|
711
|
+
return new Response('cross-origin write rejected', { status: 403 });
|
|
712
|
+
const body = await readJson<{ canvas?: unknown; id?: unknown; text?: unknown }>(
|
|
713
|
+
req,
|
|
714
|
+
16 * 1024
|
|
715
|
+
);
|
|
716
|
+
if (!body) return new Response('body required', { status: 400 });
|
|
717
|
+
const result = await api.editText(body);
|
|
718
|
+
if (!result.ok) {
|
|
719
|
+
return Response.json(
|
|
720
|
+
{ ok: false, error: result.error },
|
|
721
|
+
{ status: result.status, headers: { 'Cache-Control': 'no-store' } }
|
|
722
|
+
);
|
|
723
|
+
}
|
|
724
|
+
return Response.json(
|
|
725
|
+
{ ok: true, delta: result.delta },
|
|
726
|
+
{ status: 200, headers: { 'Cache-Control': 'no-store' } }
|
|
727
|
+
);
|
|
728
|
+
},
|
|
729
|
+
|
|
730
|
+
'/_api/edit-attr': async (req: Request) => {
|
|
731
|
+
// Phase 12.2 (DDR-104) — the CSS panel's "custom HTML attribute" escape
|
|
732
|
+
// hatch. POST body { canvas, id, attr, value } → writes a plain JSX
|
|
733
|
+
// attribute (data-*, aria-*, role, …) via api.editAttr → editAttribute's
|
|
734
|
+
// non-`style.` path. Same MAIN-ORIGIN-ONLY trust boundary as
|
|
735
|
+
// /_api/edit-css + /_api/edit-text (absent from CANVAS_SAFE_API +
|
|
736
|
+
// startCanvasServer's route map; the untrusted iframe can never reach it).
|
|
737
|
+
if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 });
|
|
738
|
+
if (!sameOriginWrite(req))
|
|
739
|
+
return new Response('cross-origin write rejected', { status: 403 });
|
|
740
|
+
const body = await readJson<{
|
|
741
|
+
canvas?: unknown;
|
|
742
|
+
id?: unknown;
|
|
743
|
+
attr?: unknown;
|
|
744
|
+
value?: unknown;
|
|
745
|
+
reset?: unknown;
|
|
746
|
+
}>(req, 8 * 1024);
|
|
747
|
+
if (!body) return new Response('body required', { status: 400 });
|
|
748
|
+
const result = await api.editAttr(body);
|
|
749
|
+
if (!result.ok) {
|
|
750
|
+
return Response.json(
|
|
751
|
+
{ ok: false, error: result.error },
|
|
752
|
+
{ status: result.status, headers: { 'Cache-Control': 'no-store' } }
|
|
753
|
+
);
|
|
754
|
+
}
|
|
755
|
+
return Response.json(
|
|
756
|
+
{ ok: true, delta: result.delta },
|
|
757
|
+
{ status: 200, headers: { 'Cache-Control': 'no-store' } }
|
|
758
|
+
);
|
|
759
|
+
},
|
|
760
|
+
|
|
642
761
|
'/_api/asset': async (req: Request) => {
|
|
643
762
|
// Phase 23 — binary image upload from the canvas (drag-drop / paste / the
|
|
644
763
|
// a11y file picker). POST raw image bytes → content-addressed write under
|
|
@@ -82,6 +82,8 @@ export type Tool =
|
|
|
82
82
|
| 'arrow'
|
|
83
83
|
| 'sticky'
|
|
84
84
|
| 'text'
|
|
85
|
+
// FigJam v3 — labelled organizing container (Shift+S).
|
|
86
|
+
| 'section'
|
|
85
87
|
| 'eraser';
|
|
86
88
|
|
|
87
89
|
const ANNOTATION_TOOLS = new Set<Tool>([
|
|
@@ -93,6 +95,7 @@ const ANNOTATION_TOOLS = new Set<Tool>([
|
|
|
93
95
|
'arrow',
|
|
94
96
|
'sticky',
|
|
95
97
|
'text',
|
|
98
|
+
'section',
|
|
96
99
|
'eraser',
|
|
97
100
|
]);
|
|
98
101
|
|
|
@@ -183,6 +186,10 @@ export function classify(input: ClassifyInput): RouterAction {
|
|
|
183
186
|
// yields when focus is inside the canvas iframe (app.jsx onKey bail).
|
|
184
187
|
if (k === 'n') return { kind: 'tool', tool: 'sticky' };
|
|
185
188
|
if (k === 't') return { kind: 'tool', tool: 'text' };
|
|
189
|
+
// FigJam v3 — Shift+S arms the Section tool (FigJam's own binding; bare S
|
|
190
|
+
// stays with the shell's Design-system view). Checked here because the
|
|
191
|
+
// modifier guard above only filters Cmd/Ctrl/Alt.
|
|
192
|
+
if (k === 's' && input.shiftKey) return { kind: 'tool', tool: 'section' };
|
|
186
193
|
if (k === 'e') return { kind: 'tool', tool: 'eraser' };
|
|
187
194
|
if (input.key === 'Escape') return { kind: 'escape' };
|
|
188
195
|
return { kind: 'no-op' };
|
package/apps/studio/inspect.ts
CHANGED
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
// Active-canvas state, selected-element tracking, and HTML injection
|
|
2
2
|
// (inspector overlay + canvas runtime). See plan Task 7 + DDR-007.
|
|
3
3
|
|
|
4
|
-
import path from 'node:path';
|
|
5
4
|
import type { Context } from './context.ts';
|
|
6
5
|
|
|
7
6
|
export interface SelectedElement {
|
|
@@ -356,16 +355,31 @@ const INSPECTOR_SCRIPT = `
|
|
|
356
355
|
new MutationObserver(function(){ startTick(); }).observe(document.documentElement, { subtree: true, attributes: true, attributeFilter: ['style', 'class'], childList: true });
|
|
357
356
|
}
|
|
358
357
|
|
|
359
|
-
//
|
|
360
|
-
//
|
|
361
|
-
//
|
|
362
|
-
//
|
|
363
|
-
//
|
|
364
|
-
// handler can't swallow
|
|
358
|
+
// Shell chords — keydown fired inside the canvas iframe never reaches the
|
|
359
|
+
// shell's window-scoped listener (iframe keyboard isolation), so each shell
|
|
360
|
+
// chord must be forwarded to the parent. preventDefault is load-bearing for
|
|
361
|
+
// ⌘R: without it the BROWSER reloads the whole shell while focus is in the
|
|
362
|
+
// canvas (the advertised behavior is "reload the active canvas"). Capture
|
|
363
|
+
// phase so canvas-lib's pan/zoom keydown handler can't swallow them first.
|
|
365
364
|
document.addEventListener('keydown', function(e) {
|
|
366
|
-
if ((e.metaKey || e.ctrlKey)
|
|
365
|
+
if (!(e.metaKey || e.ctrlKey)) return;
|
|
366
|
+
var k = (e.key || '').toLowerCase();
|
|
367
|
+
if (k === 'k' && !e.shiftKey) {
|
|
367
368
|
e.preventDefault();
|
|
368
369
|
try { window.parent.postMessage({ dgn: 'toggle-palette' }, '*'); } catch (err) {}
|
|
370
|
+
return;
|
|
371
|
+
}
|
|
372
|
+
if (k === 'r' && !e.shiftKey) {
|
|
373
|
+
e.preventDefault();
|
|
374
|
+
try { window.parent.postMessage({ dgn: 'shell-shortcut', id: 'reload' }, '*'); } catch (err) {}
|
|
375
|
+
return;
|
|
376
|
+
}
|
|
377
|
+
if (e.shiftKey) {
|
|
378
|
+
var id = k === 'i' ? 'inspector' : k === 'm' ? 'comments' : k === 'e' ? 'export' : k === 'h' ? 'handoff' : null;
|
|
379
|
+
if (id) {
|
|
380
|
+
e.preventDefault();
|
|
381
|
+
try { window.parent.postMessage({ dgn: 'shell-shortcut', id: id }, '*'); } catch (err) {}
|
|
382
|
+
}
|
|
369
383
|
}
|
|
370
384
|
}, true);
|
|
371
385
|
|
|
@@ -389,6 +403,21 @@ const INSPECTOR_SCRIPT = `
|
|
|
389
403
|
if (t) t.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
|
390
404
|
} catch (e) {}
|
|
391
405
|
}
|
|
406
|
+
} else if (m.dgn === 'theme') {
|
|
407
|
+
// Catch the chrome-theme seed the shell posts in reply to our 'loaded'
|
|
408
|
+
// ping (sent below). THIS listener registers during page-load — before
|
|
409
|
+
// the React canvas-shell mounts and adds its OWN 'dgn:theme' listener —
|
|
410
|
+
// so without this branch the shell's reply lands in the gap between
|
|
411
|
+
// page-load and React-mount and is dropped, leaving the canvas stuck on
|
|
412
|
+
// its hardcoded dark default until a manual theme toggle re-broadcasts
|
|
413
|
+
// it (the recurring "open a canvas under a light shell → canvas chrome is
|
|
414
|
+
// dark; toggle twice to fix; next canvas reverts" bug). We set the same
|
|
415
|
+
// attribute the canvas-shell chrome reads (data-maude-theme on <html>);
|
|
416
|
+
// canvas-shell's default-dark guard then sees the value already present
|
|
417
|
+
// and won't override it, and its own listener takes over live toggles.
|
|
418
|
+
if (m.theme === 'light' || m.theme === 'dark') {
|
|
419
|
+
try { document.documentElement.setAttribute('data-maude-theme', m.theme); } catch (e) {}
|
|
420
|
+
}
|
|
392
421
|
}
|
|
393
422
|
/* force-clear is now consumed by canvas-shell.tsx — the inspector
|
|
394
423
|
overlay has no per-element selection state to clear anymore. */
|
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
* own avatar to release. tldraw-style.
|
|
15
15
|
*/
|
|
16
16
|
|
|
17
|
-
import { useCallback, useEffect,
|
|
17
|
+
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
18
18
|
|
|
19
19
|
import { useViewportControllerContext } from './canvas-lib.tsx';
|
|
20
20
|
import { type AgentPresence, useAgentPresence } from './use-agent-presence.tsx';
|
|
@@ -192,7 +192,6 @@ function Avatar({ peer, isFollowing, onToggleFollow }: AvatarProps): JSX.Element
|
|
|
192
192
|
setOpen((v) => !v);
|
|
193
193
|
}
|
|
194
194
|
}}
|
|
195
|
-
// biome-ignore lint/a11y/useSemanticElements: 28px round chip needs raw <div> + inline bg + custom focus ring; <button> reset is more code than the rule saves.
|
|
196
195
|
role="button"
|
|
197
196
|
tabIndex={0}
|
|
198
197
|
title={peer.name}
|
|
@@ -201,8 +200,6 @@ function Avatar({ peer, isFollowing, onToggleFollow }: AvatarProps): JSX.Element
|
|
|
201
200
|
>
|
|
202
201
|
{initialsFor(peer.name)}
|
|
203
202
|
{open && (
|
|
204
|
-
// biome-ignore lint/a11y/useKeyWithClickEvents: stop-propagation wrapper; keyboard focus stays in the button child.
|
|
205
|
-
// biome-ignore lint/a11y/useSemanticElements: popover wrapper carries no semantic role; the inner <button> is the actionable element.
|
|
206
203
|
<div className="dc-participant-popover" onClick={(e) => e.stopPropagation()}>
|
|
207
204
|
<div className="dc-participant-popover__name">{peer.name}</div>
|
|
208
205
|
<button
|
|
@@ -250,7 +247,6 @@ function AgentAvatar({ agent }: { agent: AgentPresence }): JSX.Element {
|
|
|
250
247
|
setOpen((v) => !v);
|
|
251
248
|
}
|
|
252
249
|
}}
|
|
253
|
-
// biome-ignore lint/a11y/useSemanticElements: matches the peer Avatar chip pattern.
|
|
254
250
|
role="button"
|
|
255
251
|
tabIndex={0}
|
|
256
252
|
title={`${agent.name} (AI agent)`}
|
|
@@ -262,8 +258,6 @@ function AgentAvatar({ agent }: { agent: AgentPresence }): JSX.Element {
|
|
|
262
258
|
✦
|
|
263
259
|
</span>
|
|
264
260
|
{open && (
|
|
265
|
-
// biome-ignore lint/a11y/useKeyWithClickEvents: stop-propagation wrapper; no actionable child.
|
|
266
|
-
// biome-ignore lint/a11y/useSemanticElements: popover wrapper carries no semantic role.
|
|
267
261
|
<div className="dc-participant-popover" onClick={(e) => e.stopPropagation()}>
|
|
268
262
|
<div className="dc-participant-popover__name">{agent.name}</div>
|
|
269
263
|
<div className="dc-participant-popover__sub">AI agent · {agent.author}</div>
|
package/apps/studio/server.ts
CHANGED
|
@@ -294,8 +294,11 @@ let server: BunServer;
|
|
|
294
294
|
|
|
295
295
|
// T2 (9.1-A) — advertise the main origin so the canvas origin's CSP can
|
|
296
296
|
// allowlist it in `frame-ancestors` (the legit embedder). Must be set before
|
|
297
|
-
// the canvas listener serves its first shell.
|
|
298
|
-
|
|
297
|
+
// the canvas listener serves its first shell. Both loopback host spellings are
|
|
298
|
+
// listed: the server binds 127.0.0.1, so a user who opens the printed URL as
|
|
299
|
+
// `127.0.0.1:<port>` is the same legit embedder — with only `localhost` allowed
|
|
300
|
+
// the canvas iframe was silently refused (blank sad-page, no error anywhere).
|
|
301
|
+
ctx.mainOrigin = `http://localhost:${server.port} http://127.0.0.1:${server.port}`;
|
|
299
302
|
|
|
300
303
|
// T2 (9.1-A) — segregated canvas-content origin. ON BY DEFAULT (opt-OUT) since
|
|
301
304
|
// phase-9.1: a second listener binds an OS-assigned free port, advertised as
|