@1agh/maude 0.60.4 → 0.60.5

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.
@@ -1499,11 +1499,14 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
1499
1499
  // shared `.meta.json` document. It lives in `_canvas-state/<slug>.view.json`
1500
1500
  // ({ viewport }) — gitignored, swept on delete, export-excluded. DISTINCT from
1501
1501
  // the legacy `_canvas-state/<slug>.json` ({ sections, viewport:{x,y,scale} })
1502
- // store: that uses `scale` clamped 0.05–8, this uses `zoom` clamped 0.1–4, so
1502
+ // store: that uses `scale` clamped 0.05–8, this uses `zoom` clamped 0.02–4, so
1503
1503
  // overloading one file would let the two writers clobber each other's shape.
1504
1504
 
1505
- /** Validate a candidate viewport — finite x/y, zoom clamped [0.1, 4] (the
1506
- * Phase 4 rule). Returns the normalized viewport, or null when invalid. */
1505
+ /** Validate a candidate viewport — finite x/y, zoom clamped [0.02, 4] (the
1506
+ * Phase 4 rule). Mirrors `ZOOM_MIN` in canvas-lib.tsx (issue #91) — keep the
1507
+ * two floors in lockstep or a save/reload round-trip silently re-clamps a
1508
+ * zoom the client just let the user reach. Returns the normalized viewport,
1509
+ * or null when invalid. */
1507
1510
  function normalizeViewport(v: unknown): { x: number; y: number; zoom: number } | null {
1508
1511
  if (!v || typeof v !== 'object' || Array.isArray(v)) return null;
1509
1512
  const vv = v as { x?: unknown; y?: unknown; zoom?: unknown };
@@ -1512,7 +1515,7 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
1512
1515
  Number.isFinite(vv.y as number) &&
1513
1516
  Number.isFinite(vv.zoom as number)
1514
1517
  ) {
1515
- const zoom = Math.min(4, Math.max(0.1, vv.zoom as number));
1518
+ const zoom = Math.min(4, Math.max(0.02, vv.zoom as number));
1516
1519
  return { x: vv.x as number, y: vv.y as number, zoom };
1517
1520
  }
1518
1521
  return null;
@@ -1571,6 +1574,41 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
1571
1574
  return [...new Set(out)];
1572
1575
  }
1573
1576
 
1577
+ // Issue #91 security follow-up — `layout.artboards[]` (DDR-027 position-only
1578
+ // entries) is reachable from the untrusted canvas origin (DDR-054) via the
1579
+ // same PATCH lane as overlays/locked above, but previously had no count or
1580
+ // magnitude bound. `fit()`/`computeFit` (canvas-lib.tsx) now correctly frames
1581
+ // whatever bounding box this describes — before the zoom-floor fix, a hard
1582
+ // 0.1 zoom clamp accidentally masked an unbounded layout into a cropped,
1583
+ // mismatched view; after it, an oversized synced layout can legitimately be
1584
+ // framed whole, promoting every artboard in it to its own GPU layer at once
1585
+ // (canvas-lib.tsx `content-visibility` comment). Cap count + coordinate
1586
+ // magnitude the same way `normalizeOverlays`/`normalizeLocked` cap theirs.
1587
+ const MAX_LAYOUT_ARTBOARDS = 2000;
1588
+ const MAX_LAYOUT_COORD = 1_000_000;
1589
+
1590
+ function normalizeLayoutArtboards(
1591
+ v: unknown
1592
+ ): Array<{ id: string; x: number; y: number }> | null {
1593
+ if (!Array.isArray(v) || v.length > MAX_LAYOUT_ARTBOARDS) return null;
1594
+ const out: Array<{ id: string; x: number; y: number }> = [];
1595
+ for (const entry of v) {
1596
+ if (!entry || typeof entry !== 'object' || Array.isArray(entry)) return null;
1597
+ const e = entry as { id?: unknown; x?: unknown; y?: unknown };
1598
+ if (typeof e.id !== 'string' || e.id.length === 0 || e.id.length > 128) return null;
1599
+ if (
1600
+ !Number.isFinite(e.x as number) ||
1601
+ !Number.isFinite(e.y as number) ||
1602
+ Math.abs(e.x as number) > MAX_LAYOUT_COORD ||
1603
+ Math.abs(e.y as number) > MAX_LAYOUT_COORD
1604
+ ) {
1605
+ return null;
1606
+ }
1607
+ out.push({ id: e.id, x: e.x as number, y: e.y as number });
1608
+ }
1609
+ return out;
1610
+ }
1611
+
1574
1612
  /** Raw view-file contents, tolerant of a missing/corrupt file (→ `{}`) — the
1575
1613
  * read half of the read-modify-write both `saveCanvasView` and
1576
1614
  * `saveCanvasOverlays` need so writing one lane never clobbers the other. */
@@ -1819,7 +1857,18 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
1819
1857
  if (patch.layout === null) {
1820
1858
  next.layout = undefined;
1821
1859
  } else if (typeof patch.layout === 'object' && !Array.isArray(patch.layout)) {
1822
- next.layout = patch.layout;
1860
+ const rawLayout = patch.layout as Record<string, unknown>;
1861
+ if ('artboards' in rawLayout) {
1862
+ // Bounded — see normalizeLayoutArtboards. An oversized/malformed
1863
+ // `artboards` array is a silent no-op (leaves the prior persisted
1864
+ // layout in place), matching the overlays/locked lanes' convention.
1865
+ const artboards = normalizeLayoutArtboards(rawLayout.artboards);
1866
+ if (artboards !== null) {
1867
+ next.layout = { ...rawLayout, artboards };
1868
+ }
1869
+ } else {
1870
+ next.layout = patch.layout;
1871
+ }
1823
1872
  }
1824
1873
  // Defensive: a stale inline viewport must never persist in the versioned
1825
1874
  // file (the camera lane owns it now). JSON.stringify drops undefined keys.
@@ -353,6 +353,16 @@ export function writeCompileEntry(target: PlatformTarget): string {
353
353
  // Side-effect module: must be imported BEFORE any oxc-parser usage.
354
354
  import bindingPath from ${JSON.stringify(bindingSpec)} with { type: 'file' };
355
355
  process.env.NAPI_RS_NATIVE_LIBRARY_PATH = bindingPath;
356
+ // DDR-177 — a compiled binary must be self-contained: every runtime-spawned
357
+ // child (canvas-build worker, asset-sweep worker) resolves its JS runtime via
358
+ // resolveBunPath() = MAUDE_BUN_PATH || 'bun'. The desktop app launches this
359
+ // binary with a GUI PATH that has no 'bun', and NOTHING set MAUDE_BUN_PATH —
360
+ // so the asset sweep died with "Executable not found in $PATH" on machines
361
+ // without a user-installed bun. Default it to THIS binary: workerEnv() adds
362
+ // BUN_BE_BUN=1 when MAUDE_BUN_PATH === process.execPath, so children re-enter
363
+ // the sidecar as a plain JS runtime. An explicit launcher-set value (the
364
+ // cell's studio-child.mjs) still wins.
365
+ process.env.MAUDE_BUN_PATH ||= process.execPath;
356
366
  `;
357
367
  writeFileSync(initPath, initContent);
358
368
 
@@ -214,7 +214,15 @@ export function AIPlaceholder({
214
214
  // ─────────────────────────────────────────────────────────────────────────────
215
215
  // Module constants
216
216
 
217
- const ZOOM_MIN = 0.1;
217
+ // Issue #91 — was 0.1. A board spanning more world units than ~10x the
218
+ // viewport could never be framed whole: `fit()` and manual zoom-out both
219
+ // funnel through `clampZoom` (below), so the old floor blocked "fit to
220
+ // screen" from ever reaching the zoom a wide board actually needs. 0.02
221
+ // matches the interactive floor other canvas tools (Figma, tldraw) ship.
222
+ // Keep in lockstep with the mirror clamp in `api.ts`'s `normalizeViewport`
223
+ // (the per-machine camera persisted to `_canvas-state/<slug>.view.json`,
224
+ // DDR-115) — that one re-clamps on save/reload independently of this file.
225
+ const ZOOM_MIN = 0.02;
218
226
  const ZOOM_MAX = 4.0;
219
227
  const ZOOM_STEP_IN = 1.2;
220
228
  const ZOOM_STEP_OUT = 1 / 1.2;
@@ -685,7 +693,9 @@ function synthDefaultGrid(seeds: ArtboardSeed[]): ArtboardRect[] {
685
693
  });
686
694
  }
687
695
 
688
- function computeFit(rects: ArtboardRect[], hostEl: HTMLElement, pad = 24): ViewportState {
696
+ // Exported for the same reason as `clampZoom` above — lets a unit test prove
697
+ // the raw fit computation for a wide board isn't what's clamping the zoom.
698
+ export function computeFit(rects: ArtboardRect[], hostEl: HTMLElement, pad = 24): ViewportState {
689
699
  if (rects.length === 0) return { x: 0, y: 0, zoom: 1 };
690
700
  let xMin = Number.POSITIVE_INFINITY;
691
701
  let yMin = Number.POSITIVE_INFINITY;
@@ -907,7 +917,10 @@ export interface ViewportControllerHandle {
907
917
  isInteracting: boolean;
908
918
  }
909
919
 
910
- function clampZoom(z: number): number {
920
+ // Exported (like `revealAxisDelta` in canvas-shell.tsx) so a pure-logic unit
921
+ // test can pin the zoom floor without booting the DOM/host-dependent
922
+ // viewport controller.
923
+ export function clampZoom(z: number): number {
911
924
  if (!Number.isFinite(z)) return 1;
912
925
  return Math.min(ZOOM_MAX, Math.max(ZOOM_MIN, z));
913
926
  }
@@ -67,15 +67,13 @@ import {
67
67
  import { ContextualToolbar } from './contextual-toolbar.tsx';
68
68
  import { CursorsOverlay } from './cursors-overlay.tsx';
69
69
  import {
70
+ buildComposeSelection,
70
71
  cssPath,
71
72
  deriveFile,
72
- domPath,
73
73
  hoverTargetToSelection,
74
+ openCommentComposer,
74
75
  realClasses,
75
76
  resolveSelectionEl,
76
- scopedCdSelector,
77
- selectorIndex,
78
- shortText,
79
77
  } from './dom-selection.ts';
80
78
  import { setElementDragActive } from './drag-state.ts';
81
79
  import { EqualSpacingHandles } from './equal-spacing-handles.tsx';
@@ -1378,33 +1376,17 @@ function buildRegistry(deps: {
1378
1376
  });
1379
1377
  };
1380
1378
 
1379
+ // issue-90 — right-click "Add comment" (element / artboard-chrome / world)
1380
+ // used to only postMessage the parent shell, which merely mirrors the
1381
+ // target for the StatusBar/sidebar; it never dispatched `cm:open-composer`,
1382
+ // the event `comments-overlay.tsx` actually listens on to open the
1383
+ // composer. Selecting the menu item did nothing. `openCommentComposer`
1384
+ // sends both, matching the working comment-tool-click path
1385
+ // (canvas-comment-mount.tsx's `dropComment`).
1381
1386
  const postComposeForTarget = (target: ContextTarget): void => {
1382
1387
  if (typeof window === 'undefined') return;
1383
- const composeSelector = target.cdId
1384
- ? scopedCdSelector(target.cdId, target.artboardId)
1385
- : cssPath(target.el);
1386
- const sel: Selection | null = target.el
1387
- ? {
1388
- file: deriveFile(),
1389
- id: target.cdId ?? undefined,
1390
- selector: composeSelector,
1391
- artboardId: target.artboardId,
1392
- index: target.cdId ? selectorIndex(document, composeSelector, target.el) : 0,
1393
- tag: target.el.tagName.toLowerCase(),
1394
- classes: realClasses(target.el),
1395
- text: shortText(target.el, 240),
1396
- dom_path: domPath(target.el),
1397
- bounds: (target.el as HTMLElement).getBoundingClientRect
1398
- ? boundsOf(target.el as HTMLElement)
1399
- : null,
1400
- html: (target.el.outerHTML ?? '').slice(0, 4000),
1401
- }
1402
- : null;
1403
- try {
1404
- window.parent.postMessage({ dgn: 'comment-compose', selection: sel }, '*');
1405
- } catch {
1406
- /* ignore */
1407
- }
1388
+ const sel = buildComposeSelection(target, target.clientX, target.clientY);
1389
+ openCommentComposer(sel, target.clientX, target.clientY);
1408
1390
  };
1409
1391
 
1410
1392
  const fitItem: MenuItem = {
@@ -2141,6 +2123,18 @@ function buildRegistry(deps: {
2141
2123
  ],
2142
2124
  ],
2143
2125
  'artboard-chrome': [
2126
+ [
2127
+ // issue-90 — right-click on an artboard's own label/border chrome
2128
+ // (outside its body, where `data-cd-id` never resolves) had no
2129
+ // "Add comment" entry at all; only a hit inside the body counted as
2130
+ // `element`. Anchors at the click point (no element under it).
2131
+ {
2132
+ id: 'add-comment',
2133
+ label: 'Add comment',
2134
+ shortcut: 'C',
2135
+ onSelect: postComposeForTarget,
2136
+ },
2137
+ ],
2144
2138
  [
2145
2139
  {
2146
2140
  id: 'fit-one',
@@ -2273,6 +2267,17 @@ function buildRegistry(deps: {
2273
2267
  [exportItem('export-artboard', 'Export this artboard…', 'artboard')],
2274
2268
  ],
2275
2269
  world: [
2270
+ // issue-90 — right-click on empty canvas background (no artboard, no
2271
+ // element) had no "Add comment" entry at all. Anchors at the click
2272
+ // point, same as the comment tool's own floating-pin fallback.
2273
+ [
2274
+ {
2275
+ id: 'add-comment',
2276
+ label: 'Add comment',
2277
+ shortcut: 'C',
2278
+ onSelect: postComposeForTarget,
2279
+ },
2280
+ ],
2276
2281
  [fitItem, resetItem],
2277
2282
  [
2278
2283
  exportItem('export-canvas', 'Export canvas as separate…', 'canvas-as-separate'),
@@ -2320,16 +2325,6 @@ function filterRegistryForReadOnly(registry: ContextRegistry): ContextRegistry {
2320
2325
  return out;
2321
2326
  }
2322
2327
 
2323
- function boundsOf(el: HTMLElement) {
2324
- const r = el.getBoundingClientRect();
2325
- return {
2326
- x: Math.round(r.left),
2327
- y: Math.round(r.top),
2328
- w: Math.round(r.width),
2329
- h: Math.round(r.height),
2330
- };
2331
- }
2332
-
2333
2328
  // ─────────────────────────────────────────────────────────────────────────────
2334
2329
  // Task A1 (feature-element-editing-robustness) — camera-aware reveal.
2335
2330
  //