@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.
- package/cli/commands/design.mjs +5 -0
- package/cli/lib/design-link.mjs +13 -6
- package/cli/lib/gitignore-block.mjs +1 -0
- package/cli/lib/gitignore-block.test.mjs +1 -0
- package/package.json +8 -8
- package/plugins/design/dependencies.json +30 -2
- package/plugins/design/dev-server/annotations-context-toolbar.tsx +481 -78
- package/plugins/design/dev-server/annotations-layer.tsx +817 -170
- package/plugins/design/dev-server/api.ts +15 -1
- package/plugins/design/dev-server/bin/_enumerate-artboards-playwright.mjs +2 -2
- package/plugins/design/dev-server/bin/_html-playwright.mjs +2 -2
- package/plugins/design/dev-server/bin/_pdf-playwright.mjs +25 -8
- package/plugins/design/dev-server/bin/_png-playwright.mjs +20 -20
- package/plugins/design/dev-server/bin/_pptx-playwright.mjs +2 -2
- package/plugins/design/dev-server/bin/_pw-launch.mjs +61 -0
- package/plugins/design/dev-server/bin/_screenshot-playwright.mjs +2 -2
- package/plugins/design/dev-server/bin/_svg-optimize.mjs +35 -0
- package/plugins/design/dev-server/bin/_svg-playwright.mjs +125 -19
- package/plugins/design/dev-server/bin/draw-build.sh +48 -0
- package/plugins/design/dev-server/bin/draw-proof.sh +129 -0
- package/plugins/design/dev-server/bin/smoke.sh +114 -12
- package/plugins/design/dev-server/bin/svg-optimize.sh +24 -0
- package/plugins/design/dev-server/canvas-arrowheads.ts +220 -0
- package/plugins/design/dev-server/canvas-comment-mount.tsx +8 -24
- package/plugins/design/dev-server/canvas-cursors.ts +130 -82
- package/plugins/design/dev-server/canvas-icons.tsx +169 -0
- package/plugins/design/dev-server/canvas-lib.tsx +110 -0
- package/plugins/design/dev-server/canvas-shell.tsx +113 -89
- package/plugins/design/dev-server/client/app.jsx +1084 -417
- package/plugins/design/dev-server/config.schema.json +10 -0
- package/plugins/design/dev-server/context.ts +9 -0
- package/plugins/design/dev-server/dist/client.bundle.js +242 -20
- package/plugins/design/dev-server/dist/comment-mount.js +40 -62
- package/plugins/design/dev-server/draw/brush.ts +639 -0
- package/plugins/design/dev-server/draw/composition.ts +229 -0
- package/plugins/design/dev-server/draw/geometry.ts +578 -0
- package/plugins/design/dev-server/draw/index.ts +28 -0
- package/plugins/design/dev-server/draw/layout.ts +260 -0
- package/plugins/design/dev-server/draw/optimize.ts +65 -0
- package/plugins/design/dev-server/draw/palette.ts +417 -0
- package/plugins/design/dev-server/draw/primitives.ts +643 -0
- package/plugins/design/dev-server/draw/serialize.ts +458 -0
- package/plugins/design/dev-server/draw/test/brush.test.ts +213 -0
- package/plugins/design/dev-server/draw/test/composition.test.ts +141 -0
- package/plugins/design/dev-server/draw/test/geometry.test.ts +199 -0
- package/plugins/design/dev-server/draw/test/gradient.test.ts +167 -0
- package/plugins/design/dev-server/draw/test/layout.test.ts +120 -0
- package/plugins/design/dev-server/draw/test/optimize.test.ts +40 -0
- package/plugins/design/dev-server/draw/test/palette.test.ts +123 -0
- package/plugins/design/dev-server/draw/test/primitives.test.ts +129 -0
- package/plugins/design/dev-server/draw/test/serialize.test.ts +105 -0
- package/plugins/design/dev-server/export-dialog.tsx +189 -1
- package/plugins/design/dev-server/exporters/html.ts +4 -1
- package/plugins/design/dev-server/exporters/index.ts +4 -1
- package/plugins/design/dev-server/exporters/pdf.ts +7 -1
- package/plugins/design/dev-server/exporters/png.ts +21 -2
- package/plugins/design/dev-server/exporters/pptx.ts +330 -201
- package/plugins/design/dev-server/exporters/scope.ts +107 -11
- package/plugins/design/dev-server/exporters/svg.ts +4 -1
- package/plugins/design/dev-server/http.ts +40 -9
- package/plugins/design/dev-server/input-router.tsx +9 -2
- package/plugins/design/dev-server/sync/index.ts +73 -17
- package/plugins/design/dev-server/test/annotations-draw-modifiers.test.ts +206 -0
- package/plugins/design/dev-server/test/annotations-layer.test.ts +83 -3
- package/plugins/design/dev-server/test/annotations-roundtrip.test.ts +243 -0
- package/plugins/design/dev-server/test/canvas-cursors.test.ts +95 -6
- package/plugins/design/dev-server/test/canvas-route.test.ts +4 -1
- package/plugins/design/dev-server/test/exporters/png.test.ts +24 -1
- package/plugins/design/dev-server/test/exporters/pptx-deck.test.ts +112 -0
- package/plugins/design/dev-server/test/exporters/pw-launch.test.ts +49 -0
- package/plugins/design/dev-server/test/exporters/scope.test.ts +0 -0
- package/plugins/design/dev-server/test/fixtures/phase-21-annotations.svg +1 -0
- package/plugins/design/dev-server/test/input-router.test.ts +11 -4
- package/plugins/design/dev-server/test/sanitize-annotation-svg.test.ts +32 -0
- package/plugins/design/dev-server/test/sync-runtime.test.ts +52 -0
- package/plugins/design/dev-server/test/use-annotation-resize.test.ts +200 -0
- package/plugins/design/dev-server/test/use-tool-mode.test.tsx +9 -11
- package/plugins/design/dev-server/tool-palette.tsx +140 -11
- package/plugins/design/dev-server/tsconfig.json +8 -1
- package/plugins/design/dev-server/use-annotation-resize.tsx +208 -61
- package/plugins/design/dev-server/use-tool-mode.tsx +55 -9
- package/plugins/design/templates/_shell.html +36 -9
- package/plugins/design/templates/design-system-inspiration/_MAPPING.md +15 -0
- 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
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
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 =
|
|
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
|
-
//
|
|
171
|
-
//
|
|
172
|
-
//
|
|
173
|
-
//
|
|
174
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
331
|
-
// any of them invalidates the bundled canvas
|
|
332
|
-
// the mtime-keyed `canvasCache` keeps serving
|
|
333
|
-
// user sees pre-edit behaviour even after a hard
|
|
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
|
|
336
|
-
//
|
|
337
|
-
// (`
|
|
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
|
-
|
|
165
|
-
|
|
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)
|
|
620
|
-
*
|
|
621
|
-
*
|
|
622
|
-
*
|
|
623
|
-
*
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
|
660
|
-
//
|
|
661
|
-
//
|
|
662
|
-
//
|
|
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 &&
|
|
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
|
|
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
|
+
});
|