@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.
Files changed (99) hide show
  1. package/README.md +1 -1
  2. package/apps/studio/ai-banner.tsx +0 -1
  3. package/apps/studio/annotations-align.ts +149 -0
  4. package/apps/studio/annotations-bindings.ts +197 -0
  5. package/apps/studio/annotations-context-toolbar.tsx +436 -186
  6. package/apps/studio/annotations-groups.ts +270 -0
  7. package/apps/studio/annotations-layer.tsx +1708 -1660
  8. package/apps/studio/annotations-model.ts +2077 -0
  9. package/apps/studio/annotations-snap.ts +125 -0
  10. package/apps/studio/api.ts +208 -116
  11. package/apps/studio/bin/_png-playwright.mjs +1 -1
  12. package/apps/studio/bin/annotate.mjs +732 -0
  13. package/apps/studio/bin/annotate.sh +17 -0
  14. package/apps/studio/bin/read-annotations.mjs +152 -17
  15. package/apps/studio/build.ts +1 -1
  16. package/apps/studio/canvas-arrowheads.ts +78 -9
  17. package/apps/studio/canvas-cursors.ts +2 -0
  18. package/apps/studio/canvas-edit.ts +257 -7
  19. package/apps/studio/canvas-icons.tsx +105 -0
  20. package/apps/studio/canvas-shell.tsx +305 -8
  21. package/apps/studio/client/app.jsx +2812 -238
  22. package/apps/studio/client/index.html +1 -1
  23. package/apps/studio/client/styles/3-shell-maude.css +713 -11
  24. package/apps/studio/client/styles/4-components.css +39 -0
  25. package/apps/studio/client/styles/5-maude-overrides.css +16 -2
  26. package/apps/studio/client/tour/overlay.jsx +105 -17
  27. package/apps/studio/client/tour/usage-tour.js +23 -10
  28. package/apps/studio/commands/annotation-strokes-command.ts +1 -1
  29. package/apps/studio/commands/move-artboards-command.ts +1 -1
  30. package/apps/studio/comments-overlay.tsx +7 -5
  31. package/apps/studio/context-menu.tsx +4 -3
  32. package/apps/studio/contextual-toolbar.tsx +14 -0
  33. package/apps/studio/cursors-overlay.tsx +20 -20
  34. package/apps/studio/dist/client.bundle.js +3322 -602
  35. package/apps/studio/dist/comment-mount.js +95 -9
  36. package/apps/studio/dist/styles.css +2490 -167
  37. package/apps/studio/dom-selection.ts +115 -0
  38. package/apps/studio/export-dialog.tsx +3 -3
  39. package/apps/studio/history.ts +20 -3
  40. package/apps/studio/http.ts +124 -5
  41. package/apps/studio/input-router.tsx +7 -0
  42. package/apps/studio/inspect.ts +37 -8
  43. package/apps/studio/participants-chrome.tsx +1 -7
  44. package/apps/studio/server.ts +5 -2
  45. package/apps/studio/sync/agent.ts +231 -57
  46. package/apps/studio/sync/codec.ts +45 -0
  47. package/apps/studio/sync/cold-start.ts +158 -0
  48. package/apps/studio/sync/connection-state.ts +58 -2
  49. package/apps/studio/sync/index.ts +563 -221
  50. package/apps/studio/sync/journal.ts +190 -0
  51. package/apps/studio/sync/migrate-seed.ts +202 -44
  52. package/apps/studio/sync/projection.ts +22 -3
  53. package/apps/studio/sync/status.ts +15 -4
  54. package/apps/studio/test/activity.test.ts +1 -7
  55. package/apps/studio/test/annotate-write.test.ts +184 -0
  56. package/apps/studio/test/annotations-align.test.ts +88 -0
  57. package/apps/studio/test/annotations-bindings.test.ts +124 -0
  58. package/apps/studio/test/annotations-groups.test.ts +231 -0
  59. package/apps/studio/test/annotations-snap.test.ts +79 -0
  60. package/apps/studio/test/canvas-edit.test.ts +181 -1
  61. package/apps/studio/test/canvas-origin-gate.test.ts +8 -0
  62. package/apps/studio/test/collab-bridge.test.ts +0 -2
  63. package/apps/studio/test/collab-room.test.ts +2 -7
  64. package/apps/studio/test/csp-canvas-shell.test.ts +5 -0
  65. package/apps/studio/test/csrf-write-guard.test.ts +52 -0
  66. package/apps/studio/test/figjam-v3-model.test.ts +342 -0
  67. package/apps/studio/test/fixtures/figjam-v3-groups-bindings.svg +1 -0
  68. package/apps/studio/test/shared-doc-migrate.test.ts +224 -14
  69. package/apps/studio/test/sync-agent.test.ts +272 -2
  70. package/apps/studio/test/sync-codec.test.ts +65 -0
  71. package/apps/studio/test/sync-cold-start.test.ts +244 -0
  72. package/apps/studio/test/sync-connection-state.test.ts +68 -0
  73. package/apps/studio/test/sync-hardening.test.ts +0 -8
  74. package/apps/studio/test/sync-hubs-config.test.ts +0 -1
  75. package/apps/studio/test/sync-incident-replay.test.ts +211 -0
  76. package/apps/studio/test/sync-journal.test.ts +176 -0
  77. package/apps/studio/test/sync-runtime.test.ts +360 -5
  78. package/apps/studio/test/sync-status.test.ts +66 -0
  79. package/apps/studio/test/use-annotation-resize.test.ts +159 -0
  80. package/apps/studio/test/use-artboard-drag.test.ts +0 -1
  81. package/apps/studio/test/use-collab.test.ts +0 -0
  82. package/apps/studio/test/use-tool-mode.test.tsx +5 -2
  83. package/apps/studio/tool-palette.tsx +12 -2
  84. package/apps/studio/undo-hud.tsx +0 -1
  85. package/apps/studio/use-agent-presence.tsx +4 -2
  86. package/apps/studio/use-annotation-resize.tsx +484 -54
  87. package/apps/studio/use-annotation-selection.tsx +16 -4
  88. package/apps/studio/use-canvas-activity.tsx +2 -2
  89. package/apps/studio/use-collab.tsx +20 -15
  90. package/apps/studio/use-selection-set.tsx +9 -0
  91. package/apps/studio/use-tool-mode.tsx +5 -1
  92. package/apps/studio/whats-new.json +135 -10
  93. package/apps/studio/whats-new.schema.json +18 -2
  94. package/cli/commands/design-link.test.mjs +84 -0
  95. package/cli/commands/design.mjs +2 -1
  96. package/cli/commands/design.test.mjs +5 -1
  97. package/cli/lib/design-link.mjs +51 -1
  98. package/package.json +8 -8
  99. 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, i) => (
563
- <li key={`${h.at}-${i}`}>
562
+ {history.slice(0, 5).map((h) => (
563
+ <li key={`${h.at}-${h.filename}`}>
564
564
  <button
565
565
  type="button"
566
566
  onClick={() => {
@@ -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
- const ts = new Date().toISOString();
64
- const contentPath = snapshotPath(slug, ts);
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,
@@ -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, RUNTIME_PACKAGES, slugFor } from './runtime-bundle.ts';
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`) plus `'self'`; an arbitrary external page can no longer reframe
85
- * the canvas. When `mainOrigin` is unknown (tests / pre-boot) the directive is
86
- * OMITTED rather than set to `'self'` `'self'` alone would forbid the legit
87
- * cross-origin embed and blank the canvas.
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' };
@@ -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
- // ⌘K / Ctrl+K command palette — keydown fired inside the canvas iframe never
360
- // reaches the shell's window-scoped listener (iframe keyboard isolation), so
361
- // the palette wouldn't open while focus is in the canvas. Forward the chord to
362
- // the parent; the shell toggles the palette (mirrors its own handler, which
363
- // fires "even in inputs"). Capture phase so canvas-lib's pan/zoom keydown
364
- // handler can't swallow it first.
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) && (e.key === 'k' || e.key === 'K')) {
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, useMemo, useRef, useState } from 'react';
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>
@@ -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
- ctx.mainOrigin = `http://localhost:${server.port}`;
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