@1agh/maude 0.24.0 → 0.26.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 (84) hide show
  1. package/cli/commands/design.mjs +5 -0
  2. package/cli/lib/design-link.mjs +13 -6
  3. package/cli/lib/gitignore-block.mjs +1 -0
  4. package/cli/lib/gitignore-block.test.mjs +1 -0
  5. package/package.json +8 -8
  6. package/plugins/design/dependencies.json +30 -2
  7. package/plugins/design/dev-server/annotations-context-toolbar.tsx +481 -78
  8. package/plugins/design/dev-server/annotations-layer.tsx +817 -170
  9. package/plugins/design/dev-server/api.ts +15 -1
  10. package/plugins/design/dev-server/bin/_enumerate-artboards-playwright.mjs +2 -2
  11. package/plugins/design/dev-server/bin/_html-playwright.mjs +2 -2
  12. package/plugins/design/dev-server/bin/_pdf-playwright.mjs +25 -8
  13. package/plugins/design/dev-server/bin/_png-playwright.mjs +20 -20
  14. package/plugins/design/dev-server/bin/_pptx-playwright.mjs +2 -2
  15. package/plugins/design/dev-server/bin/_pw-launch.mjs +61 -0
  16. package/plugins/design/dev-server/bin/_screenshot-playwright.mjs +2 -2
  17. package/plugins/design/dev-server/bin/_svg-optimize.mjs +35 -0
  18. package/plugins/design/dev-server/bin/_svg-playwright.mjs +125 -19
  19. package/plugins/design/dev-server/bin/draw-build.sh +48 -0
  20. package/plugins/design/dev-server/bin/draw-proof.sh +129 -0
  21. package/plugins/design/dev-server/bin/smoke.sh +114 -12
  22. package/plugins/design/dev-server/bin/svg-optimize.sh +24 -0
  23. package/plugins/design/dev-server/canvas-arrowheads.ts +220 -0
  24. package/plugins/design/dev-server/canvas-comment-mount.tsx +8 -24
  25. package/plugins/design/dev-server/canvas-cursors.ts +130 -82
  26. package/plugins/design/dev-server/canvas-icons.tsx +169 -0
  27. package/plugins/design/dev-server/canvas-lib.tsx +110 -0
  28. package/plugins/design/dev-server/canvas-shell.tsx +113 -89
  29. package/plugins/design/dev-server/client/app.jsx +1084 -417
  30. package/plugins/design/dev-server/config.schema.json +10 -0
  31. package/plugins/design/dev-server/context.ts +9 -0
  32. package/plugins/design/dev-server/dist/client.bundle.js +242 -20
  33. package/plugins/design/dev-server/dist/comment-mount.js +40 -62
  34. package/plugins/design/dev-server/draw/brush.ts +639 -0
  35. package/plugins/design/dev-server/draw/composition.ts +229 -0
  36. package/plugins/design/dev-server/draw/geometry.ts +578 -0
  37. package/plugins/design/dev-server/draw/index.ts +28 -0
  38. package/plugins/design/dev-server/draw/layout.ts +260 -0
  39. package/plugins/design/dev-server/draw/optimize.ts +65 -0
  40. package/plugins/design/dev-server/draw/palette.ts +417 -0
  41. package/plugins/design/dev-server/draw/primitives.ts +643 -0
  42. package/plugins/design/dev-server/draw/serialize.ts +458 -0
  43. package/plugins/design/dev-server/draw/test/brush.test.ts +213 -0
  44. package/plugins/design/dev-server/draw/test/composition.test.ts +141 -0
  45. package/plugins/design/dev-server/draw/test/geometry.test.ts +199 -0
  46. package/plugins/design/dev-server/draw/test/gradient.test.ts +167 -0
  47. package/plugins/design/dev-server/draw/test/layout.test.ts +120 -0
  48. package/plugins/design/dev-server/draw/test/optimize.test.ts +40 -0
  49. package/plugins/design/dev-server/draw/test/palette.test.ts +123 -0
  50. package/plugins/design/dev-server/draw/test/primitives.test.ts +129 -0
  51. package/plugins/design/dev-server/draw/test/serialize.test.ts +105 -0
  52. package/plugins/design/dev-server/export-dialog.tsx +189 -1
  53. package/plugins/design/dev-server/exporters/html.ts +4 -1
  54. package/plugins/design/dev-server/exporters/index.ts +4 -1
  55. package/plugins/design/dev-server/exporters/pdf.ts +7 -1
  56. package/plugins/design/dev-server/exporters/png.ts +21 -2
  57. package/plugins/design/dev-server/exporters/pptx.ts +330 -201
  58. package/plugins/design/dev-server/exporters/scope.ts +107 -11
  59. package/plugins/design/dev-server/exporters/svg.ts +4 -1
  60. package/plugins/design/dev-server/http.ts +40 -9
  61. package/plugins/design/dev-server/input-router.tsx +9 -2
  62. package/plugins/design/dev-server/sync/index.ts +73 -17
  63. package/plugins/design/dev-server/test/annotations-draw-modifiers.test.ts +206 -0
  64. package/plugins/design/dev-server/test/annotations-layer.test.ts +83 -3
  65. package/plugins/design/dev-server/test/annotations-roundtrip.test.ts +243 -0
  66. package/plugins/design/dev-server/test/canvas-cursors.test.ts +95 -6
  67. package/plugins/design/dev-server/test/canvas-route.test.ts +4 -1
  68. package/plugins/design/dev-server/test/exporters/png.test.ts +24 -1
  69. package/plugins/design/dev-server/test/exporters/pptx-deck.test.ts +112 -0
  70. package/plugins/design/dev-server/test/exporters/pw-launch.test.ts +49 -0
  71. package/plugins/design/dev-server/test/exporters/scope.test.ts +0 -0
  72. package/plugins/design/dev-server/test/fixtures/phase-21-annotations.svg +1 -0
  73. package/plugins/design/dev-server/test/input-router.test.ts +11 -4
  74. package/plugins/design/dev-server/test/sanitize-annotation-svg.test.ts +32 -0
  75. package/plugins/design/dev-server/test/sync-runtime.test.ts +52 -0
  76. package/plugins/design/dev-server/test/use-annotation-resize.test.ts +200 -0
  77. package/plugins/design/dev-server/test/use-tool-mode.test.tsx +9 -11
  78. package/plugins/design/dev-server/tool-palette.tsx +140 -11
  79. package/plugins/design/dev-server/tsconfig.json +8 -1
  80. package/plugins/design/dev-server/use-annotation-resize.tsx +208 -61
  81. package/plugins/design/dev-server/use-tool-mode.tsx +55 -9
  82. package/plugins/design/templates/_shell.html +36 -9
  83. package/plugins/design/templates/design-system-inspiration/_MAPPING.md +15 -0
  84. package/plugins/design/templates/design-system-inspiration/core/config.json.tpl +1 -0
@@ -33,6 +33,17 @@ export type Target =
33
33
  file: string;
34
34
  /** True when `cssPath` is expected to match many elements; the adapter iterates. */
35
35
  multi?: boolean;
36
+ /**
37
+ * True when the adapter should widen `cssPath` to its closest
38
+ * `[data-dc-screen]` ancestor before capture — i.e. "export the artboard
39
+ * containing this element". `selection` scope sets this `false` so the
40
+ * raster/vector captures the element EXACTLY (the item-3 bug was every
41
+ * single-target export hard-widening). `artboard` scope sets it `true`
42
+ * only in the fallback where we have a descendant selector but no
43
+ * artboard id; when the id IS known we target `[data-dc-screen="<id>"]`
44
+ * directly and leave this `false`.
45
+ */
46
+ widen?: boolean;
36
47
  }
37
48
  | {
38
49
  kind: 'file-tree';
@@ -49,6 +60,21 @@ export interface ActiveJsonShape {
49
60
  | null;
50
61
  }
51
62
 
63
+ /**
64
+ * Submit-time hints captured from the LIVE iframe (the export dialog snapshots
65
+ * the canvas selection + the artboard under the viewport centre and rides them
66
+ * on the export `options` bag). The resolver prefers these over `activeJson`
67
+ * because opening the dialog / clicking Export can drop the persisted
68
+ * `_active.json.selected`, and they survive the cross-origin bridge unchanged.
69
+ * See the export-pipeline-fixes plan Task 1 + the selection-passthrough DDR.
70
+ */
71
+ export interface ExportScopeHints {
72
+ /** Live selection at submit time. `selector` wins over `activeJson.selected`. */
73
+ selection?: { selector?: string; file?: string } | null;
74
+ /** `data-dc-screen` id of the artboard to export for `scope=artboard`. */
75
+ artboardId?: string | null;
76
+ }
77
+
52
78
  export interface ResolveScopeArgs {
53
79
  scope: Scope;
54
80
  activeJson: ActiveJsonShape;
@@ -56,6 +82,33 @@ export interface ResolveScopeArgs {
56
82
  designRoot: string;
57
83
  /** Absolute path to repo root. Required for `project-raw` to bound the walk. */
58
84
  repoRoot?: string;
85
+ /**
86
+ * The export `options` bag, threaded from `runExport`. Read additively so
87
+ * existing callers (and the pure unit tests) that omit it stay green.
88
+ */
89
+ options?: Record<string, unknown>;
90
+ }
91
+
92
+ /** Narrow the free-form options bag to the selection/artboard hints we read. */
93
+ function readHints(options: Record<string, unknown> | undefined): ExportScopeHints {
94
+ if (!options || typeof options !== 'object') return {};
95
+ const sel = options.selection;
96
+ const selection =
97
+ sel && typeof sel === 'object'
98
+ ? {
99
+ selector:
100
+ typeof (sel as Record<string, unknown>).selector === 'string'
101
+ ? ((sel as Record<string, unknown>).selector as string)
102
+ : undefined,
103
+ file:
104
+ typeof (sel as Record<string, unknown>).file === 'string'
105
+ ? ((sel as Record<string, unknown>).file as string)
106
+ : undefined,
107
+ }
108
+ : null;
109
+ const artboardId =
110
+ typeof options.artboardId === 'string' && options.artboardId ? options.artboardId : null;
111
+ return { selection, artboardId };
59
112
  }
60
113
 
61
114
  const RAW_EXCLUDES = new Set([
@@ -88,6 +141,16 @@ function slugify(file: string, designRel: string): string {
88
141
  .toLowerCase();
89
142
  }
90
143
 
144
+ /**
145
+ * Escape a value for use inside a double-quoted CSS attribute selector
146
+ * (`[data-dc-screen="<here>"]`). Artboard ids are normally plain slugs, but a
147
+ * stray `"` or `\` would break out of the selector — escape defensively so the
148
+ * id is matched literally.
149
+ */
150
+ function cssAttrEscape(value: string): string {
151
+ return value.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
152
+ }
153
+
91
154
  interface SelectionShape {
92
155
  file?: string;
93
156
  selector?: string;
@@ -147,37 +210,70 @@ export async function resolveScope(args: ResolveScopeArgs): Promise<Target[]> {
147
210
  if (!activeFile) return [];
148
211
  const slug = slugify(activeFile, designRel);
149
212
  const sel = firstSelection(activeJson.selected);
213
+ const hints = readHints(args.options);
150
214
 
151
215
  if (scope === 'selection') {
152
- const selector = sel?.selector ?? sel?.cssPath;
153
- if (!sel || !selector) {
154
- // Plan: "Falls back to artboard if no selection." Recurse with the
155
- // artboard scope so the fallback semantics live in one place.
216
+ // Prefer the live submit-time snapshot over the persisted `_active.json`
217
+ // selection the dialog can clear the canvas selection before the bridged
218
+ // export runs, leaving `activeJson.selected` null (item 3 root cause #1).
219
+ const selector = hints.selection?.selector ?? sel?.selector ?? sel?.cssPath;
220
+ if (!selector) {
221
+ // No selection anywhere → fall back to artboard scope so the export
222
+ // still produces something useful. Single fallback site.
156
223
  return resolveScope({ ...args, scope: 'artboard' });
157
224
  }
158
- const file = sel.file ?? activeFile;
225
+ const file = hints.selection?.file ?? sel?.file ?? activeFile;
159
226
  return [
160
227
  {
161
228
  kind: 'element',
162
229
  cssPath: selector,
163
230
  canvasSlug: slugify(file, designRel),
164
231
  file,
232
+ // Capture the element EXACTLY — do NOT widen to the enclosing artboard
233
+ // (item 3 root cause #2: every single-target export hard-widened).
234
+ widen: false,
165
235
  },
166
236
  ];
167
237
  }
168
238
 
169
239
  if (scope === 'artboard') {
170
- // The adapter handles "closest [data-dc-screen] ancestor" at render time
171
- // via Playwright. Server-side we only know the selection's selector we
172
- // pass it through with a marker and the adapter widens to the artboard.
173
- // If no selection, fall back to "first artboard on the active canvas".
174
- const baseSelector = sel?.selector ?? sel?.cssPath ?? '[data-dc-screen]:first-of-type';
240
+ // Preferred path: the dialog captured which artboard is active (the
241
+ // selection's host, or the artboard under the viewport centre). Target it
242
+ // by id so EVERY format (PDF included its shim doesn't widen) renders
243
+ // the right artboard, not `:first-of-type` (item 5 root cause).
244
+ if (hints.artboardId) {
245
+ return [
246
+ {
247
+ kind: 'element',
248
+ cssPath: `[data-dc-screen="${cssAttrEscape(hints.artboardId)}"]`,
249
+ canvasSlug: slug,
250
+ file: activeFile,
251
+ widen: false,
252
+ },
253
+ ];
254
+ }
255
+ // Fallback: we only have a descendant selector → pass it through and let
256
+ // the adapter widen to the closest `[data-dc-screen]` ancestor.
257
+ const descendant = hints.selection?.selector ?? sel?.selector ?? sel?.cssPath;
258
+ if (descendant) {
259
+ return [
260
+ {
261
+ kind: 'element',
262
+ cssPath: descendant,
263
+ canvasSlug: slug,
264
+ file: activeFile,
265
+ widen: true,
266
+ },
267
+ ];
268
+ }
269
+ // Last resort — first artboard on the active canvas.
175
270
  return [
176
271
  {
177
272
  kind: 'element',
178
- cssPath: baseSelector,
273
+ cssPath: '[data-dc-screen]:first-of-type',
179
274
  canvasSlug: slug,
180
275
  file: activeFile,
276
+ widen: false,
181
277
  },
182
278
  ];
183
279
  }
@@ -44,7 +44,10 @@ async function captureSvg(
44
44
  if (target.multi) {
45
45
  args.push('--multi', '1', '--out-dir', outDir);
46
46
  } else {
47
- args.push('--widen-to-artboard', '1', '--out', path.join(outDir, `${target.canvasSlug}.svg`));
47
+ // Widen to the enclosing artboard only when scope.ts requested it; a
48
+ // `selection` target captures the element exactly.
49
+ if (target.widen) args.push('--widen-to-artboard', '1');
50
+ args.push('--out', path.join(outDir, `${target.canvasSlug}.svg`));
48
51
  }
49
52
 
50
53
  // Run via `node` so the shim's `import 'playwright'` resolves against
@@ -153,6 +153,26 @@ interface CanvasCacheEntry {
153
153
  }
154
154
  const canvasCache = new Map<string, CanvasCacheEntry>();
155
155
 
156
+ // Per-process boot id, folded into every canvas ETag. The canvas transpile
157
+ // BUNDLES the shared chrome (canvas-lib → tool-palette / annotations-layer /
158
+ // use-tool-mode / canvas-cursors / …), but the freshness sig + base etag only
159
+ // track the canvas file + its inlined CSS — NOT those chrome sources. So a
160
+ // chrome edit left the etag unchanged → the browser's `If-None-Match` matched →
161
+ // 304 → the user kept getting the STALE transpile even after a server restart
162
+ // (the recurring "my cursor / chrome change didn't show up" bug). Folding the
163
+ // boot id in means a restart — the expected workflow after a dev-server source
164
+ // edit (see CLAUDE.md) — changes every canvas etag, so a normal reload re-fetches
165
+ // fresh chrome. (DDR-067.)
166
+ const RUNTIME_BOOT_ID = `${Date.now().toString(36)}${Math.random().toString(36).slice(2, 8)}`;
167
+
168
+ // Bumped by the chrome source watchers (below) on every canvas-lib / dev-server
169
+ // `.tsx`/`.ts` edit. Folded into the canvas ETag so a LIVE chrome edit (no
170
+ // restart) also changes the etag — otherwise clearing `canvasCache` re-transpiles
171
+ // fresh on the server, but the unchanged canvas SOURCE yields the same
172
+ // `result.etag`, the browser's `If-None-Match` matches → 304 → the user still
173
+ // gets the stale bundle even after the HMR hard-reload. (DDR-067.)
174
+ let CHROME_EPOCH = 0;
175
+
156
176
  /** Relative `.css` import specifiers in a canvas source → absolute paths (the
157
177
  * files Bun.build inlines). Bare / virtual specifiers (`@maude/…`, npm) are
158
178
  * skipped — only on-disk relative CSS affects the inlined output. Resolved
@@ -228,7 +248,10 @@ async function serveCanvasTsx(
228
248
  // may have added or removed a `.css` import.
229
249
  cached = {
230
250
  sig: canvasFreshnessSig(absPath, cssDeps),
231
- etag: result.etag,
251
+ // Fold in the boot id (restart) + chrome epoch (live edit) so a chrome
252
+ // change busts the browser's cached transpile even when the canvas source
253
+ // (hence result.etag) is unchanged. See RUNTIME_BOOT_ID / CHROME_EPOCH.
254
+ etag: `${result.etag}-${RUNTIME_BOOT_ID}-${CHROME_EPOCH}`,
232
255
  js: result.js,
233
256
  cssDeps,
234
257
  };
@@ -309,6 +332,7 @@ export function createHttp(ctx: Context, api: Api, inspect: Inspect, ai: AiActiv
309
332
  ctx.bus.on('fs:any', (rel: string) => {
310
333
  if (rel.startsWith('_lib/')) {
311
334
  canvasCache.clear();
335
+ CHROME_EPOCH++;
312
336
  }
313
337
  });
314
338
 
@@ -316,6 +340,7 @@ export function createHttp(ctx: Context, api: Api, inspect: Inspect, ai: AiActiv
316
340
  try {
317
341
  libWatcher = watch(canvasLibPath(), () => {
318
342
  canvasCache.clear();
343
+ CHROME_EPOCH++;
319
344
  ctx.bus.emit('fs:any', '_lib/canvas-lib.tsx');
320
345
  });
321
346
  } catch (err) {
@@ -327,21 +352,27 @@ export function createHttp(ctx: Context, api: Api, inspect: Inspect, ai: AiActiv
327
352
  void libWatcher;
328
353
 
329
354
  // G7v2 — canvas-lib.tsx transitively imports many dev-server siblings
330
- // (canvas-shell, contextual-toolbar, equal-spacing-handles, ...). Editing
331
- // any of them invalidates the bundled canvas output. Without watching them
332
- // the mtime-keyed `canvasCache` keeps serving the stale bundle and the
333
- // user sees pre-edit behaviour even after a hard iframe reload.
355
+ // (canvas-shell, contextual-toolbar, equal-spacing-handles, tool-palette,
356
+ // use-tool-mode, ...). Editing any of them invalidates the bundled canvas
357
+ // output. Without watching them the mtime-keyed `canvasCache` keeps serving
358
+ // the stale bundle and the user sees pre-edit behaviour even after a hard
359
+ // iframe reload.
334
360
  //
335
- // Recursive watch over DEV_SERVER_ROOT, filtered to .tsx server-only .ts
336
- // (api / http / context / etc.) doesn't reach the canvas. Test files
337
- // (`test/`) and built output (`dist/`, `client/`) also skipped.
361
+ // Recursive watch over DEV_SERVER_ROOT, filtered to .tsx AND .ts: the chrome
362
+ // graph includes plain `.ts` modules that DO reach the canvas
363
+ // (`canvas-cursors.ts`, `canvas-arrowheads.ts`) the prior `.tsx`-only filter
364
+ // skipped them, so cursor edits never busted the cache (the recurring stale-
365
+ // cursor bug, DDR-067). Server-only `.ts` (api / http / context) also clears
366
+ // here, which is harmless (a needless rebuild, not a stale serve). Test files
367
+ // (`test/`), built output (`dist/`, `client/`), and node_modules are skipped.
338
368
  let devSrcWatcher: ReturnType<typeof watch> | null = null;
339
369
  try {
340
370
  devSrcWatcher = watch(DEV_SERVER_ROOT, { recursive: true }, (_evt, filename) => {
341
371
  if (!filename) return;
342
- if (!filename.endsWith('.tsx')) return;
372
+ if (!filename.endsWith('.tsx') && !filename.endsWith('.ts')) return;
343
373
  if (filename.startsWith('test/') || filename.startsWith('test\\')) return;
344
374
  if (filename.startsWith('dist/') || filename.startsWith('client/')) return;
375
+ if (filename.includes('node_modules')) return;
345
376
  canvasCache.clear();
346
377
  ctx.bus.emit('fs:any', `_lib/${filename}`);
347
378
  });
@@ -68,6 +68,11 @@ export type Tool =
68
68
  | 'hand'
69
69
  | 'comment'
70
70
  | 'pen'
71
+ // Phase 24 — `shape` is the single active draw tool that produces rect /
72
+ // ellipse / polygon strokes (the kind is chosen via the palette popover).
73
+ // `rect` / `ellipse` stay in the union as the stroke discriminants they
74
+ // always were, but are no longer directly selectable active tools.
75
+ | 'shape'
71
76
  | 'rect'
72
77
  | 'ellipse'
73
78
  | 'arrow'
@@ -77,6 +82,7 @@ export type Tool =
77
82
 
78
83
  const ANNOTATION_TOOLS = new Set<Tool>([
79
84
  'pen',
85
+ 'shape',
80
86
  'rect',
81
87
  'ellipse',
82
88
  'arrow',
@@ -161,8 +167,9 @@ export function classify(input: ClassifyInput): RouterAction {
161
167
  if (k === 'h') return { kind: 'tool', tool: 'hand' };
162
168
  if (k === 'c') return { kind: 'tool', tool: 'comment' };
163
169
  if (k === 'b') return { kind: 'tool', tool: 'pen' };
164
- if (k === 'r') return { kind: 'tool', tool: 'rect' };
165
- if (k === 'o') return { kind: 'tool', tool: 'ellipse' };
170
+ // Phase 24 — R (and legacy O) both arm the single Shape tool; the specific
171
+ // primitive is picked from the palette's shape-kind popover.
172
+ if (k === 'r' || k === 'o') return { kind: 'tool', tool: 'shape' };
166
173
  if (k === 'a') return { kind: 'tool', tool: 'arrow' };
167
174
  // Phase 21 — N = sticky Note ('S' is taken by the shell Design-system view
168
175
  // + Shift-marquee); T = standalone Text. Both are bare letters the shell
@@ -246,6 +246,19 @@ export function createSyncRuntime(
246
246
  return;
247
247
  }
248
248
 
249
+ // DDR-072 — loud boot banner when the project-level TSX opt-in is on against
250
+ // a NON-loopback hub. Broadening from hand-picked canvases to "all .tsx"
251
+ // also broadens the WebRTC/self-nav exfil residual (the sandbox contains
252
+ // execution but not that lane) to every synced canvas, so this must never be
253
+ // silent: a sneaky PR that flips `linkedHub.syncTsx` surfaces here on the
254
+ // next `serve`. Loopback hubs (local dev) skip it — no remote exfil concern.
255
+ if (linkedHub.syncTsx === true && !isLoopbackHubUrl(linkedHub.url)) {
256
+ const tsxCount = canvases.filter((c) => c.html.toLowerCase().endsWith('.tsx')).length;
257
+ console.warn(
258
+ `[sync] syncTsx=true — ${tsxCount} TSX canvas BODIES will sync to ${linkedHub.url} (project-level opt-in, DDR-072). The sandbox contains execution, but the WebRTC/self-nav exfil residual now applies to ALL synced canvases — only for hubs you operate or fully trust. Set a canvas .meta.json "syncable": false to exclude it.`
259
+ );
260
+ }
261
+
249
262
  statusStore =
250
263
  opts.statusStore ??
251
264
  createSyncStatusStore({
@@ -599,6 +612,12 @@ export async function scanCanvases(ctx: Context): Promise<CanvasScan> {
599
612
  // `.tsx` syncs — the per-canvas opt-in is inert without the sandbox, and
600
613
  // decoupling them would re-open the CRITICAL F1 RCE (DDR-060, DDR-054 §F1).
601
614
  const splitActive = !!ctx.canvasOrigin;
615
+ // DDR-072 — project-level TSX opt-in. When `linkedHub.syncTsx` is true, a
616
+ // `.tsx` with no explicit sidecar verdict defaults to syncable (the per-canvas
617
+ // sidecar still wins either way — see resolveSyncable). Coupled with the
618
+ // sandbox via `splitActive` exactly like the per-canvas opt-in, so the flag is
619
+ // inert without the F1 containment (DDR-060 Lock-2 invariant).
620
+ const projectSyncTsx = ctx.cfg.linkedHub?.syncTsx === true;
602
621
  for (const group of ctx.cfg.canvasGroups) {
603
622
  const groupAbs = path.join(ctx.paths.designRoot, group.path);
604
623
  if (!existsSync(groupAbs)) continue;
@@ -609,27 +628,37 @@ export async function scanCanvases(ctx: Context): Promise<CanvasScan> {
609
628
  ctx.paths.designRel,
610
629
  out,
611
630
  counter,
612
- splitActive
631
+ splitActive,
632
+ projectSyncTsx
613
633
  );
614
634
  }
615
635
  return { canvases: out, tsxCount: counter.tsx };
616
636
  }
617
637
 
618
638
  /**
619
- * T3 (9.1-B) per-canvas sync opt-in. A `.tsx` canvas is syncable only if its
620
- * sibling `<name>.meta.json` declares `"syncable": true`. The flag is set by a
621
- * human editing the sidecar; it is deliberately NOT in the untrusted
622
- * `/_api/canvas-meta` PATCH whitelist (api.ts), so a hostile canvas/hub cannot
623
- * flip its own body into the sync set. Missing sidecar / parse error → false.
639
+ * T3 (9.1-B) + DDR-072 resolve whether a `.tsx` body is syncable.
640
+ *
641
+ * Tri-state precedence:
642
+ * 1. The sibling `<name>.meta.json` `"syncable"` boolean ALWAYS wins when
643
+ * present (`true` opts in, `false` opts out) set by a human editing the
644
+ * sidecar; deliberately NOT in the untrusted `/_api/canvas-meta` PATCH
645
+ * whitelist (api.ts), so a hostile canvas/hub cannot flip its own body into
646
+ * or out of the sync set.
647
+ * 2. Otherwise fall back to the project-level `linkedHub.syncTsx` default.
648
+ *
649
+ * Missing sidecar / parse error → no explicit verdict → the project default.
624
650
  */
625
- function readSyncableFlag(bodyAbs: string): boolean {
651
+ function resolveSyncable(bodyAbs: string, projectSyncTsx: boolean): boolean {
626
652
  const metaAbs = bodyAbs.replace(/\.(tsx|html)$/i, '.meta.json');
627
653
  try {
628
654
  const obj = JSON.parse(readFileSync(metaAbs, 'utf8'));
629
- return obj && typeof obj === 'object' && obj.syncable === true;
655
+ if (obj && typeof obj === 'object' && typeof obj.syncable === 'boolean') {
656
+ return obj.syncable; // per-canvas verdict wins (true OR explicit false)
657
+ }
630
658
  } catch {
631
- return false;
659
+ /* missing / unparseable sidecar → defer to the project default below */
632
660
  }
661
+ return projectSyncTsx;
633
662
  }
634
663
 
635
664
  async function walk(
@@ -639,7 +668,8 @@ async function walk(
639
668
  designRel: string,
640
669
  acc: CanvasDescriptor[],
641
670
  counter: { tsx: number },
642
- splitActive: boolean
671
+ splitActive: boolean,
672
+ projectSyncTsx: boolean
643
673
  ): Promise<void> {
644
674
  let entries: import('node:fs').Dirent[];
645
675
  try {
@@ -652,16 +682,26 @@ async function walk(
652
682
  if (entry.isDirectory()) {
653
683
  // Skip plugin runtime dirs.
654
684
  if (entry.name.startsWith('_')) continue;
655
- await walk(abs, designRoot, commentsDir, designRel, acc, counter, splitActive);
685
+ await walk(
686
+ abs,
687
+ designRoot,
688
+ commentsDir,
689
+ designRel,
690
+ acc,
691
+ counter,
692
+ splitActive,
693
+ projectSyncTsx
694
+ );
656
695
  continue;
657
696
  }
658
697
  const ext = path.extname(entry.name).toLowerCase();
659
- // T3 (9.1-B) — a `.tsx` syncs ONLY when the sandbox is active AND the
660
- // sidecar opts in (see readSyncableFlag + the splitActive coupling above).
661
- // Otherwise tally it for 9.1-D's loud zero-syncable surface so the message
662
- // can explain *why* it isn't syncing.
698
+ // T3 (9.1-B) + DDR-072 — a `.tsx` syncs ONLY when the sandbox is active AND
699
+ // it resolves to syncable (per-canvas sidecar verdict, else the project-level
700
+ // `linkedHub.syncTsx` default see resolveSyncable + the splitActive
701
+ // coupling above). Otherwise tally it for 9.1-D's loud zero-syncable surface
702
+ // so the message can explain *why* it isn't syncing.
663
703
  if (ext === '.tsx') {
664
- if (!(splitActive && readSyncableFlag(abs))) {
704
+ if (!(splitActive && resolveSyncable(abs, projectSyncTsx))) {
665
705
  counter.tsx += 1;
666
706
  continue;
667
707
  }
@@ -712,7 +752,7 @@ export function buildNoSyncablePayload(
712
752
  ): NoSyncablePayload {
713
753
  const reason =
714
754
  tsxCount > 0
715
- ? `${tsxCount} TSX canvas(es) found but none are syncable — a TSX body syncs only when the canvas opts in (.meta.json "syncable": true). The canvas sandbox is on by default; MAUDE_CANVAS_ORIGIN_SPLIT=0 disables it (and TSX sync with it). See DDR-060.`
755
+ ? `${tsxCount} TSX canvas(es) found but none are syncable — a TSX body syncs when the canvas opts in (.meta.json "syncable": true) OR the project opts in all of them (.design/config.json linkedHub.syncTsx: true, DDR-072). The canvas sandbox is on by default; MAUDE_CANVAS_ORIGIN_SPLIT=0 disables it (and TSX sync with it). See DDR-060.`
716
756
  : `no canvases found under ${designRoot}.`;
717
757
  return {
718
758
  linked: true,
@@ -916,3 +956,19 @@ export function checkUrlScheme(url: string): string | null {
916
956
  }
917
957
  return null;
918
958
  }
959
+
960
+ /**
961
+ * DDR-072 — true when the hub URL points at a loopback host (localhost,
962
+ * 127.0.0.1, ::1). Used to suppress the `syncTsx` boot banner for local dev
963
+ * hubs (no remote exfil concern). Unparseable URL → treated as non-loopback
964
+ * (fail loud / show the banner). Mirrors checkUrlScheme's loopback host set.
965
+ */
966
+ export function isLoopbackHubUrl(url: string): boolean {
967
+ let host: string;
968
+ try {
969
+ host = new URL(url).hostname.toLowerCase();
970
+ } catch {
971
+ return false;
972
+ }
973
+ return host === 'localhost' || host === '127.0.0.1' || host === '::1' || host === '[::1]';
974
+ }
@@ -0,0 +1,206 @@
1
+ // annotations-layer — draw-time resize modifiers (constrainDrawBox +
2
+ // applyDrawModifiers). FigJam parity: Shift = 1:1, Alt = from-center, applied
3
+ // while CREATING a shape (not just resizing an existing one). Pure functions.
4
+
5
+ import { describe, expect, test } from 'bun:test';
6
+
7
+ import {
8
+ type ArrowStroke,
9
+ type EllipseStroke,
10
+ type PenStroke,
11
+ type PolygonStroke,
12
+ type RectStroke,
13
+ type StickyStroke,
14
+ applyDrawModifiers,
15
+ constrainDrawBox,
16
+ } from '../annotations-layer.tsx';
17
+
18
+ const NONE = { shift: false, alt: false };
19
+
20
+ describe('constrainDrawBox', () => {
21
+ test('no modifiers — corner drag from the anchor (back-compat)', () => {
22
+ expect(constrainDrawBox(10, 20, 110, 70, NONE)).toEqual({ x: 10, y: 20, w: 100, h: 50 });
23
+ });
24
+
25
+ test('Shift — locks to a square via the dominant axis, keeps sign', () => {
26
+ expect(constrainDrawBox(0, 0, 120, 200, { shift: true, alt: false })).toEqual({
27
+ x: 0,
28
+ y: 0,
29
+ w: 200,
30
+ h: 200,
31
+ });
32
+ expect(constrainDrawBox(0, 0, -120, -200, { shift: true, alt: false })).toEqual({
33
+ x: 0,
34
+ y: 0,
35
+ w: -200,
36
+ h: -200,
37
+ });
38
+ });
39
+
40
+ test('Alt — grows from the anchor as center', () => {
41
+ const b = constrainDrawBox(50, 50, 120, 80, { shift: false, alt: true });
42
+ expect(b).toEqual({ x: -20, y: 20, w: 140, h: 60 });
43
+ expect(b.x + b.w / 2).toBe(50); // center invariant
44
+ expect(b.y + b.h / 2).toBe(50);
45
+ });
46
+
47
+ test('Shift+Alt — centered square', () => {
48
+ expect(constrainDrawBox(0, 0, 120, 200, { shift: true, alt: true })).toEqual({
49
+ x: -200,
50
+ y: -200,
51
+ w: 400,
52
+ h: 400,
53
+ });
54
+ });
55
+ });
56
+
57
+ describe('applyDrawModifiers / rect + polygon', () => {
58
+ const rect: RectStroke = {
59
+ id: 'r',
60
+ tool: 'rect',
61
+ color: '#000',
62
+ width: 2,
63
+ x: 0,
64
+ y: 0,
65
+ w: 0,
66
+ h: 0,
67
+ };
68
+ const poly: PolygonStroke = {
69
+ id: 'p',
70
+ tool: 'polygon',
71
+ shape: 'triangle',
72
+ color: '#000',
73
+ width: 2,
74
+ x: 0,
75
+ y: 0,
76
+ w: 0,
77
+ h: 0,
78
+ };
79
+ test('rect no-mod corner drag', () => {
80
+ expect(applyDrawModifiers(rect, { x: 0, y: 0 }, 100, 50, NONE)).toMatchObject({
81
+ x: 0,
82
+ y: 0,
83
+ w: 100,
84
+ h: 50,
85
+ });
86
+ });
87
+ test('rect Shift → square', () => {
88
+ expect(
89
+ applyDrawModifiers(rect, { x: 0, y: 0 }, 100, 50, { shift: true, alt: false })
90
+ ).toMatchObject({ w: 100, h: 100 });
91
+ });
92
+ test('polygon Alt → centered, preserves shape discriminant', () => {
93
+ const out = applyDrawModifiers(poly, { x: 50, y: 50 }, 120, 80, {
94
+ shift: false,
95
+ alt: true,
96
+ }) as PolygonStroke;
97
+ expect(out).toMatchObject({ x: -20, y: 20, w: 140, h: 60, shape: 'triangle' });
98
+ });
99
+ });
100
+
101
+ describe('applyDrawModifiers / ellipse', () => {
102
+ const ell: EllipseStroke = {
103
+ id: 'e',
104
+ tool: 'ellipse',
105
+ color: '#000',
106
+ width: 2,
107
+ cx: 0,
108
+ cy: 0,
109
+ rx: 0,
110
+ ry: 0,
111
+ };
112
+ test('no-mod — corner drag (bbox 0..100 × 0..50)', () => {
113
+ expect(applyDrawModifiers(ell, { x: 0, y: 0 }, 100, 50, NONE)).toMatchObject({
114
+ cx: 50,
115
+ cy: 25,
116
+ rx: 50,
117
+ ry: 25,
118
+ });
119
+ });
120
+ test('Alt — centered on the anchor', () => {
121
+ expect(
122
+ applyDrawModifiers(ell, { x: 50, y: 50 }, 120, 80, { shift: false, alt: true })
123
+ ).toMatchObject({ cx: 50, cy: 50, rx: 70, ry: 30 });
124
+ });
125
+ test('Shift — circle (rx === ry)', () => {
126
+ const out = applyDrawModifiers(ell, { x: 0, y: 0 }, 100, 40, {
127
+ shift: true,
128
+ alt: false,
129
+ }) as EllipseStroke;
130
+ expect(out.rx).toBe(out.ry);
131
+ });
132
+ });
133
+
134
+ describe('applyDrawModifiers / sticky — always square', () => {
135
+ const sticky: StickyStroke = {
136
+ id: 's',
137
+ tool: 'sticky',
138
+ color: '#fce8a6',
139
+ x: 0,
140
+ y: 0,
141
+ w: 0,
142
+ h: 0,
143
+ text: '',
144
+ fontSize: 16,
145
+ };
146
+ test('no-mod still square (larger axis)', () => {
147
+ expect(applyDrawModifiers(sticky, { x: 0, y: 0 }, 150, 120, NONE)).toMatchObject({
148
+ w: 150,
149
+ h: 150,
150
+ });
151
+ });
152
+ test('Alt — centered square', () => {
153
+ expect(
154
+ applyDrawModifiers(sticky, { x: 0, y: 0 }, 150, 120, { shift: false, alt: true })
155
+ ).toMatchObject({ x: -150, y: -150, w: 300, h: 300 });
156
+ });
157
+ });
158
+
159
+ describe('applyDrawModifiers / arrow', () => {
160
+ const arrow: ArrowStroke = {
161
+ id: 'a',
162
+ tool: 'arrow',
163
+ color: '#000',
164
+ width: 2,
165
+ x1: 0,
166
+ y1: 0,
167
+ x2: 0,
168
+ y2: 0,
169
+ };
170
+ test('no-mod — start fixed at anchor, end follows cursor', () => {
171
+ expect(applyDrawModifiers(arrow, { x: 0, y: 0 }, 100, 40, NONE)).toMatchObject({
172
+ x1: 0,
173
+ y1: 0,
174
+ x2: 100,
175
+ y2: 40,
176
+ });
177
+ });
178
+ test('Alt — anchor is the midpoint, start mirrors the end', () => {
179
+ expect(
180
+ applyDrawModifiers(arrow, { x: 0, y: 0 }, 100, 40, { shift: false, alt: true })
181
+ ).toMatchObject({ x1: -100, y1: -40, x2: 100, y2: 40 });
182
+ });
183
+ test('Shift — end snaps to a 45° shaft', () => {
184
+ const out = applyDrawModifiers(arrow, { x: 0, y: 0 }, 50, 60, {
185
+ shift: true,
186
+ alt: false,
187
+ }) as ArrowStroke;
188
+ expect(out.x2).toBeCloseTo(out.y2, 6);
189
+ });
190
+ });
191
+
192
+ describe('applyDrawModifiers / pen — pass-through', () => {
193
+ const pen: PenStroke = {
194
+ id: 'pen',
195
+ tool: 'pen',
196
+ color: '#000',
197
+ width: 2,
198
+ points: [
199
+ [0, 0],
200
+ [5, 5],
201
+ ],
202
+ };
203
+ test('pen is returned unchanged (no box constraint)', () => {
204
+ expect(applyDrawModifiers(pen, { x: 0, y: 0 }, 99, 99, { shift: true, alt: true })).toBe(pen);
205
+ });
206
+ });