@1agh/maude 0.58.2 → 0.59.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 (140) hide show
  1. package/apps/studio/annotations-bindings.ts +83 -4
  2. package/apps/studio/annotations-layer.tsx +49 -15
  3. package/apps/studio/api.ts +6 -1
  4. package/apps/studio/bin/_fetch-asset.mjs +169 -5
  5. package/apps/studio/bin/_import-asset.mjs +90 -0
  6. package/apps/studio/bin/_import-figma.mjs +1775 -0
  7. package/apps/studio/bin/_perf-probe-safari.mjs +332 -0
  8. package/apps/studio/bin/_perf-probe.mjs +228 -0
  9. package/apps/studio/bin/_perf-shared.mjs +345 -0
  10. package/apps/studio/bin/_video-playwright.mjs +103 -7
  11. package/apps/studio/bin/import-figma.sh +47 -0
  12. package/apps/studio/bin/perf.sh +228 -0
  13. package/apps/studio/bin/read-annotations.mjs +11 -1
  14. package/apps/studio/bin/smoke.sh +49 -5
  15. package/apps/studio/bun.lock +16 -22
  16. package/apps/studio/canvas-edit.ts +29 -5
  17. package/apps/studio/canvas-lib.tsx +148 -6
  18. package/apps/studio/client/app.jsx +196 -38
  19. package/apps/studio/client/export-center.jsx +42 -4
  20. package/apps/studio/client/panels/CloudBar.jsx +92 -1
  21. package/apps/studio/client/panels/FigmaImportPanel.jsx +264 -0
  22. package/apps/studio/client/panels/GitPanel.jsx +26 -6
  23. package/apps/studio/client/panels/SettingsPanel.jsx +181 -0
  24. package/apps/studio/client/panels/SetupChecklist.jsx +26 -2
  25. package/apps/studio/client/panels/SyncPanel.jsx +229 -0
  26. package/apps/studio/client/panels/TimelinePanel.jsx +31 -3
  27. package/apps/studio/client/panels/timeline-comp-target.js +101 -0
  28. package/apps/studio/client/panels/timeline-parse.js +3 -3
  29. package/apps/studio/client/styles/3-shell-maude.css +37 -0
  30. package/apps/studio/client/styles/4-components.css +134 -0
  31. package/apps/studio/clip-ops.ts +93 -17
  32. package/apps/studio/cloud/endpoints.ts +78 -10
  33. package/apps/studio/cloud/renew.ts +183 -0
  34. package/apps/studio/context.ts +2 -1
  35. package/apps/studio/dist/client.bundle.js +1231 -1231
  36. package/apps/studio/dist/runtime/@remotion_media.js +56 -136
  37. package/apps/studio/dist/runtime/@remotion_player.js +18 -18
  38. package/apps/studio/dist/runtime/@remotion_transitions.js +9 -9
  39. package/apps/studio/dist/runtime/@remotion_transitions_clock-wipe.js +1 -1
  40. package/apps/studio/dist/runtime/remotion.js +12 -12
  41. package/apps/studio/dist/styles.css +1 -1
  42. package/apps/studio/exporters/_browser-bundles.ts +20 -6
  43. package/apps/studio/exporters/_runtime.ts +19 -0
  44. package/apps/studio/exporters/degraded.ts +92 -0
  45. package/apps/studio/exporters/index.ts +5 -0
  46. package/apps/studio/exporters/jobs.ts +19 -0
  47. package/apps/studio/exporters/unsupported-media.ts +170 -0
  48. package/apps/studio/exporters/video-encode-lib.ts +35 -6
  49. package/apps/studio/exporters/video-render-lib.ts +6 -0
  50. package/apps/studio/exporters/video.ts +72 -1
  51. package/apps/studio/figma/assets.test.ts +464 -0
  52. package/apps/studio/figma/assets.ts +452 -0
  53. package/apps/studio/figma/client.test.ts +395 -0
  54. package/apps/studio/figma/client.ts +513 -0
  55. package/apps/studio/figma/codegen-client.test.ts +276 -0
  56. package/apps/studio/figma/codegen-client.ts +509 -0
  57. package/apps/studio/figma/codegen-fonts.test.ts +103 -0
  58. package/apps/studio/figma/codegen-fonts.ts +195 -0
  59. package/apps/studio/figma/codegen-values.test.ts +179 -0
  60. package/apps/studio/figma/codegen-values.ts +270 -0
  61. package/apps/studio/figma/comments-to-strokes.test.ts +194 -0
  62. package/apps/studio/figma/comments-to-strokes.ts +173 -0
  63. package/apps/studio/figma/endpoints.ts +273 -0
  64. package/apps/studio/figma/fig-decode.test.ts +702 -0
  65. package/apps/studio/figma/fig-decode.ts +617 -0
  66. package/apps/studio/figma/fig-kiwi.ts +410 -0
  67. package/apps/studio/figma/fig-zip.ts +270 -0
  68. package/apps/studio/figma/from-codegen.test.ts +408 -0
  69. package/apps/studio/figma/from-codegen.ts +1103 -0
  70. package/apps/studio/figma/sanitize.test.ts +325 -0
  71. package/apps/studio/figma/sanitize.ts +407 -0
  72. package/apps/studio/figma/style-map.ts +352 -0
  73. package/apps/studio/figma/tailwind-map.test.ts +142 -0
  74. package/apps/studio/figma/tailwind-map.ts +545 -0
  75. package/apps/studio/figma/to-artboard.test.ts +808 -0
  76. package/apps/studio/figma/to-artboard.ts +701 -0
  77. package/apps/studio/figma/to-render.test.ts +180 -0
  78. package/apps/studio/figma/to-render.ts +328 -0
  79. package/apps/studio/figma/to-strokes-roundtrip.test.ts +152 -0
  80. package/apps/studio/figma/to-strokes.test.ts +705 -0
  81. package/apps/studio/figma/to-strokes.ts +749 -0
  82. package/apps/studio/figma/to-tokens.test.ts +321 -0
  83. package/apps/studio/figma/to-tokens.ts +305 -0
  84. package/apps/studio/figma/types.ts +544 -0
  85. package/apps/studio/figma/url.test.ts +167 -0
  86. package/apps/studio/figma/url.ts +160 -0
  87. package/apps/studio/http.ts +176 -0
  88. package/apps/studio/sync/asset-push.ts +432 -0
  89. package/apps/studio/sync/connection-state.ts +82 -3
  90. package/apps/studio/sync/hub-link.ts +63 -7
  91. package/apps/studio/sync/hubs-config.ts +31 -3
  92. package/apps/studio/sync/index.ts +286 -27
  93. package/apps/studio/sync/migrate-flat-fallback.ts +121 -0
  94. package/apps/studio/sync/presentation.ts +45 -1
  95. package/apps/studio/sync/status.ts +18 -0
  96. package/apps/studio/sync/supervisor.ts +5 -1
  97. package/apps/studio/sync/workspace-signin.ts +7 -3
  98. package/apps/studio/test/annotations-bindings.test.ts +150 -12
  99. package/apps/studio/test/canvas-create-api.test.ts +4 -1
  100. package/apps/studio/test/canvas-origin-gate.test.ts +17 -0
  101. package/apps/studio/test/capture-determinism-shape.test.ts +135 -0
  102. package/apps/studio/test/clip-addressing.test.ts +6 -1
  103. package/apps/studio/test/clip-ops.test.ts +5 -1
  104. package/apps/studio/test/cloud-endpoints.test.ts +96 -0
  105. package/apps/studio/test/cloud-renew.test.ts +205 -0
  106. package/apps/studio/test/cloud-shell-surfaces.test.ts +11 -2
  107. package/apps/studio/test/exporters/degraded-propagation.test.ts +123 -0
  108. package/apps/studio/test/exporters/unsupported-media.test.ts +123 -0
  109. package/apps/studio/test/fetch-asset-gate.test.ts +189 -0
  110. package/apps/studio/test/figma-explode.test.ts +438 -0
  111. package/apps/studio/test/figma-provenance.test.ts +108 -0
  112. package/apps/studio/test/figma-routes.test.ts +294 -0
  113. package/apps/studio/test/fixtures/perf-canvas.mjs +201 -0
  114. package/apps/studio/test/git-cloud-posture.test.ts +50 -0
  115. package/apps/studio/test/hub-link.test.ts +11 -0
  116. package/apps/studio/test/import-figma.test.ts +667 -0
  117. package/apps/studio/test/sync-asset-push.test.ts +567 -0
  118. package/apps/studio/test/sync-connection-state.test.ts +79 -0
  119. package/apps/studio/test/sync-hubs-config.test.ts +5 -0
  120. package/apps/studio/test/sync-migrate-flat-fallback.test.ts +98 -0
  121. package/apps/studio/test/sync-panel-surface.test.ts +90 -0
  122. package/apps/studio/test/sync-path-pull.test.ts +63 -0
  123. package/apps/studio/test/sync-presentation.test.ts +77 -0
  124. package/apps/studio/test/sync-runtime.test.ts +316 -1
  125. package/apps/studio/test/sync-status.test.ts +28 -0
  126. package/apps/studio/test/timeline-comp-target.test.ts +139 -0
  127. package/apps/studio/test/video-comp.test.ts +104 -2
  128. package/apps/studio/test/video-encode-lib.test.ts +63 -0
  129. package/apps/studio/test/workspace-containment.test.ts +1 -0
  130. package/apps/studio/use-artboard-drag.tsx +37 -3
  131. package/apps/studio/video-comp.tsx +121 -6
  132. package/apps/studio/whats-new.json +98 -0
  133. package/apps/studio/workspace-mode.ts +4 -0
  134. package/cli/commands/design.mjs +15 -0
  135. package/cli/commands/kg.mjs +8 -1
  136. package/cli/commands/kg.test.mjs +24 -0
  137. package/cli/lib/figma-codegen-reachability.test.mjs +104 -0
  138. package/cli/lib/figma-import-controls.test.mjs +70 -0
  139. package/package.json +8 -8
  140. package/plugins/flow/.claude-plugin/config.schema.json +3 -3
@@ -17,18 +17,69 @@ import {
17
17
  rotatePoint,
18
18
  type Stroke,
19
19
  strokeBBox,
20
+ strokeHitTest,
20
21
  strokeRotation,
21
22
  } from './annotations-model.ts';
22
23
 
23
- /** Hosts an arrow endpoint can attach to (shapes + cards, FigJam set). */
24
+ /**
25
+ * The largest bbox a TEXT stroke may present as a bind target, PER AXIS.
26
+ *
27
+ * A `TextStroke` has no stored `w`/`h` — `strokeBBox` SYNTHESIZES one from the
28
+ * content (`max(8, longest * fontSize * 0.55)` wide). So a long single line can
29
+ * project a bind strip tens of thousands of px wide, and `bindCandidate` scans
30
+ * topmost-first with no area preference: one such stroke would silently become a
31
+ * board-wide magnet that steals every endpoint the user drags. Text bigger than
32
+ * a plausible label is therefore not offered as a host at all (DDR-216 D9).
33
+ */
34
+ export const MAX_TEXT_BIND_W = 1200;
35
+ export const MAX_TEXT_BIND_H = 480;
36
+
37
+ /**
38
+ * Hosts an arrow endpoint can attach to (shapes + cards, FigJam set).
39
+ *
40
+ * **Widened to `text` + `section` (DDR-216 D9).** FigJam binds connectors to
41
+ * anything, and on the only real board measured 2/2 connectors had at least one
42
+ * endpoint Maude could not bind — so an imported connector degraded to a frozen
43
+ * line. It is also a plain native gap: you could not attach an arrow to a text
44
+ * label or a section, independent of import.
45
+ *
46
+ * Three deliberate exclusions, each closing a regression the naive widening
47
+ * would have introduced:
48
+ *
49
+ * 1. **ANCHORED text is still not bindable.** `bindCandidate`/`anchorPoint`/
50
+ * `recomputeBoundArrows` call `strokeBBox(s)` WITHOUT the anchors map, and
51
+ * for a text stroke with `anchorId` that returns `null`. Admitting it would
52
+ * mint a bind that `anchorPoint` can never resolve — and `applyEnd` would
53
+ * keep it (it only strips when the host stops being bindable), freezing the
54
+ * arrow at stale coordinates forever. That is strictly worse than today's
55
+ * honest "strip the bind, freeze the endpoint". Anchored text lives inside a
56
+ * host shape which is itself bindable, so nothing is lost: bind to the shape.
57
+ * 2. **Oversized text is not offered** — see `MAX_TEXT_BIND_W`/`_H`.
58
+ * 3. **Groups are not bindable by construction** — Maude groups are a flat
59
+ * `groupIds[]` tag array, not addressable objects. A group-targeted import
60
+ * endpoint falls back to the group's geometric bbox and is reported.
61
+ */
24
62
  export function isBindable(s: Stroke): boolean {
25
- return (
63
+ if (
26
64
  s.tool === 'rect' ||
27
65
  s.tool === 'ellipse' ||
28
66
  s.tool === 'polygon' ||
29
67
  s.tool === 'sticky' ||
30
68
  s.tool === 'image'
31
- );
69
+ ) {
70
+ return true;
71
+ }
72
+ if (s.tool === 'section') return true;
73
+ if (s.tool === 'text') {
74
+ if (s.anchorId) return false; // (1) — no resolvable bbox without the anchors map
75
+ const bb = strokeBBox(s);
76
+ // Per-AXIS, not an area product: an area cap is satisfiable by a very wide,
77
+ // very short strip, which is precisely the board-wide magnet shape. 4 000
78
+ // chars at the fontSize floor of 8 gives w ≈ 17 600 × h ≈ 9.6 — area 168 960,
79
+ // comfortably under a 640×480 product (post-implementation review F8).
80
+ return !!bb && bb.w > 0 && bb.h > 0 && bb.w <= MAX_TEXT_BIND_W && bb.h <= MAX_TEXT_BIND_H; // (2)
81
+ }
82
+ return false;
32
83
  }
33
84
 
34
85
  /** Bind proximity threshold in world px at zoom 1 (Excalidraw's 15 px). */
@@ -78,6 +129,20 @@ export function bindCandidate(
78
129
  ) {
79
130
  continue;
80
131
  }
132
+ // A SECTION is a large container, and the natural gesture is to draw one
133
+ // AROUND existing notes — which puts it later in the array, i.e. topmost.
134
+ // Testing its raw bbox would make the whole interior a magnet, so every
135
+ // arrow drawn between two stickies inside a section would bind to the
136
+ // section instead. `strokeHitTest` already encodes the right rule (a section
137
+ // is grabbed by its BORDER or its label chip; the interior stays
138
+ // click-through), so defer to it rather than inventing a second geometry.
139
+ //
140
+ // Deliberately NOT "prefer the smallest containing bbox": containment here
141
+ // is over a bbox INFLATED by `threshold`, so smallest-area lets an 8×8 dot
142
+ // 14 px AWAY beat the large rect the pointer is actually inside — and it
143
+ // inverts the visual invariant, letting a shape hidden behind an opaque one
144
+ // win a bind the user cannot see happening (DDR-216 D9 fix 4).
145
+ if (s.tool === 'section' && !strokeHitTest(s, wx, wy, threshold)) continue;
81
146
  return {
82
147
  hostId: s.id,
83
148
  nx: snapMagnet(clamp01((wx - bb.x) / bb.w)),
@@ -155,7 +220,21 @@ export function recomputeBoundArrows(strokes: readonly Stroke[]): Stroke[] {
155
220
  }
156
221
  }
157
222
  const pt = anchorPoint(host, effective.nx, effective.ny);
158
- if (!pt) return;
223
+ if (!pt) {
224
+ // The host is bindable but its bbox is unresolvable right now (e.g. a
225
+ // text stroke whose anchors map isn't reachable from here). Returning
226
+ // silently would KEEP a bind that can never be honoured — the arrow
227
+ // would freeze at stale coordinates permanently, across save/load,
228
+ // while still claiming to be attached. Strip it instead: the arrow
229
+ // survives unbound with its endpoint frozen, which is the same honest
230
+ // degradation a deleted host already gets. (DDR-216 D9 fix 2 — inert
231
+ // before the `isBindable` widening, load-bearing after it.)
232
+ const copy: ArrowStroke = { ...next };
233
+ delete copy[end === 'start' ? 'startBind' : 'endBind'];
234
+ next = copy;
235
+ mutated = true;
236
+ return;
237
+ }
159
238
  if (end === 'start') {
160
239
  if (next.x1 !== pt[0] || next.y1 !== pt[1]) {
161
240
  next = { ...next, x1: pt[0], y1: pt[1] };
@@ -154,7 +154,13 @@ import {
154
154
  } from './annotations-snap.ts';
155
155
  import { arrowPrimitives, type SvgPrimitive } from './canvas-arrowheads.ts';
156
156
  import { IconLineThick, IconLineThin } from './canvas-icons.tsx';
157
- import { useViewportControllerContext, useWorldRefContext } from './canvas-lib.tsx';
157
+ import {
158
+ countRender,
159
+ getLiveViewport,
160
+ useLiveViewport,
161
+ useViewportControllerContext,
162
+ useWorldRefContext,
163
+ } from './canvas-lib.tsx';
158
164
  import { buildAnnotationStrokesRecord } from './commands/annotation-strokes-command.ts';
159
165
  import { ensureMenuStyles as ensureCtxMenuStyles } from './context-menu.tsx';
160
166
  import { crossedDragThreshold, type Tool } from './input-router.tsx';
@@ -948,6 +954,7 @@ function scaleStrokeInGroup(
948
954
  // Component
949
955
 
950
956
  export function AnnotationsLayer() {
957
+ countRender('annotationRenders');
951
958
  ensureAnnotStyles();
952
959
  const { tool, setTool, sticky, tools, shapeKind } = useToolMode();
953
960
  const theme = useCanvasChromeTheme();
@@ -1011,8 +1018,22 @@ export function AnnotationsLayer() {
1011
1018
  // endpoint free). Tracked here because endStroke (pointerup) carries no
1012
1019
  // modifier state of its own.
1013
1020
  const cmdHeldRef = useRef(false);
1014
- const vpRef = useRef(vp);
1015
- vpRef.current = vp;
1021
+ const vpFallbackRef = useRef(vp);
1022
+ vpFallbackRef.current = vp;
1023
+ // Reads the LIVE viewport, not the published one. Publishing is settle-only
1024
+ // (gesture-static React), so mirroring the published value here would freeze
1025
+ // this ref for the whole gesture and every zoom-scaled threshold computed
1026
+ // from it — bind distance, snap tolerance, hit-test slop, sticker placement —
1027
+ // would silently use the pre-gesture zoom. Shaped as a ref so the ~40 existing
1028
+ // `vpRef.current` call sites keep working unchanged.
1029
+ const vpRef = useMemo(
1030
+ () => ({
1031
+ get current() {
1032
+ return getLiveViewport() ?? vpFallbackRef.current;
1033
+ },
1034
+ }),
1035
+ []
1036
+ );
1016
1037
  const visibilityCtx = useAnnotationsVisibility();
1017
1038
  const chrome = useChromeVisibility();
1018
1039
  // Presentation Mode hides annotations without mutating the user's own
@@ -1655,7 +1676,9 @@ export function AnnotationsLayer() {
1655
1676
 
1656
1677
  const screenToWorld = useCallback(
1657
1678
  (cx: number, cy: number): [number, number] => {
1658
- const v = vp ?? { x: 0, y: 0, zoom: 1 };
1679
+ // Live: this runs at event time (drop/placement), where the published
1680
+ // viewport can be a whole gesture behind.
1681
+ const v = vpRef.current ?? { x: 0, y: 0, zoom: 1 };
1659
1682
  const z = v.zoom || 1;
1660
1683
  return [(cx - v.x) / z, (cy - v.y) / z];
1661
1684
  },
@@ -1826,7 +1849,7 @@ export function AnnotationsLayer() {
1826
1849
 
1827
1850
  const eraseAt = useCallback(
1828
1851
  (wx: number, wy: number) => {
1829
- const zoom = vp?.zoom || 1;
1852
+ const zoom = vpRef.current?.zoom || 1;
1830
1853
  const tol = 8 / zoom;
1831
1854
  const prev = strokesRef.current;
1832
1855
  for (let i = prev.length - 1; i >= 0; i--) {
@@ -4750,8 +4773,15 @@ function AddTextHint({ strokes, hintId }: { strokes: readonly Stroke[]; hintId:
4750
4773
  * marquee/drag handler yields).
4751
4774
  */
4752
4775
  function ConnectorDots({ stroke }: { stroke: Stroke }) {
4753
- const controller = useViewportControllerContext();
4754
- const zoom = controller?.viewport?.zoom || 1;
4776
+ // LIVE zoom, deliberately not the published one. These are the counter-scaled
4777
+ // chrome elements (connector dots, selection bbox, section-label chips): they
4778
+ // must hold a constant SCREEN size while the world scales. Publishing is
4779
+ // settle-only since the gesture-static change, so reading published zoom here
4780
+ // would let this chrome grow and shrink with the world through a pinch and
4781
+ // then jump back at settle — the visible fidelity-pop a previous LOD attempt
4782
+ // was reverted for. These components render only for selected/hovered
4783
+ // strokes, so tracking the gesture frame-by-frame stays cheap.
4784
+ const zoom = useLiveViewport().zoom || 1;
4755
4785
  if (!isBindable(stroke)) return null;
4756
4786
  const center = strokeCenter(stroke);
4757
4787
  if (!center) return null;
@@ -4942,8 +4972,9 @@ function SelectionHalo({
4942
4972
  anchorsById: Map<string, AnchorHost>;
4943
4973
  multi: boolean;
4944
4974
  }) {
4945
- const controller = useViewportControllerContext();
4946
- const zoom = controller?.viewport?.zoom || 1;
4975
+ // Counter-scaled chrome — must hold a constant screen size while the world
4976
+ // scales, so it needs the live zoom, not the settle-cadence published one.
4977
+ const zoom = useLiveViewport().zoom || 1;
4947
4978
  const bbox = strokeBBox(stroke, anchorsById);
4948
4979
  if (!bbox) return null;
4949
4980
  // T17 + post-Wave-2 fix — annotation halo idioms:
@@ -4990,8 +5021,9 @@ function AnnotGroupBbox({
4990
5021
  selectedStrokes: readonly Stroke[];
4991
5022
  anchorsById: Map<string, AnchorHost>;
4992
5023
  }) {
4993
- const controller = useViewportControllerContext();
4994
- const zoom = controller?.viewport?.zoom || 1;
5024
+ // Counter-scaled chrome — must hold a constant screen size while the world
5025
+ // scales, so it needs the live zoom, not the settle-cadence published one.
5026
+ const zoom = useLiveViewport().zoom || 1;
4995
5027
  if (selectedStrokes.length < 2) return null;
4996
5028
  let xMin = Number.POSITIVE_INFINITY;
4997
5029
  let yMin = Number.POSITIVE_INFINITY;
@@ -5225,8 +5257,9 @@ function SectionLabelChip({
5225
5257
  y: number;
5226
5258
  hitMode: 'visiblePainted' | 'none';
5227
5259
  }) {
5228
- const controller = useViewportControllerContext();
5229
- const zoom = controller?.viewport?.zoom || 1;
5260
+ // Counter-scaled chrome — must hold a constant screen size while the world
5261
+ // scales, so it needs the live zoom, not the settle-cadence published one.
5262
+ const zoom = useLiveViewport().zoom || 1;
5230
5263
  const fontSize = SECTION_LABEL_FONT / zoom;
5231
5264
  const chipH = SECTION_LABEL_H / zoom;
5232
5265
  const gap = 4 / zoom;
@@ -5279,8 +5312,9 @@ function SectionTitleEditor({
5279
5312
  onCommit: (text: string, fmt?: EditorFmt) => void;
5280
5313
  onCancel: () => void;
5281
5314
  }) {
5282
- const controller = useViewportControllerContext();
5283
- const zoom = controller?.viewport?.zoom || 1;
5315
+ // Counter-scaled chrome — must hold a constant screen size while the world
5316
+ // scales, so it needs the live zoom, not the settle-cadence published one.
5317
+ const zoom = useLiveViewport().zoom || 1;
5284
5318
  const fontSize = SECTION_LABEL_FONT / zoom;
5285
5319
  const chipH = SECTION_LABEL_H / zoom;
5286
5320
  const gap = 4 / zoom;
@@ -5260,7 +5260,12 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
5260
5260
  // `loadCanvasMeta` call the DDR-093 DS-map loop below already makes for
5261
5261
  // non-path-owned (`ui/`) canvases — no extra I/O for the common case.
5262
5262
  const canvasKinds: Record<string, string> = {};
5263
- const NOTABLE_KINDS = new Set(['reconstructed-experimental']);
5263
+ // DDR-216 D7 adds `imported-figma`. The badge deliberately reads as
5264
+ // THIRD-PARTY CONTENT rather than as provenance-therefore-trustworthy: a
5265
+ // clean "imported from Figma" stamp otherwise makes the most
5266
+ // attacker-influenced artifact in the tree look like real design work, to a
5267
+ // human AND to `design-system-keeper` / the critic panel.
5268
+ const NOTABLE_KINDS = new Set(['reconstructed-experimental', 'imported-figma']);
5264
5269
  const defaultDs = cfg.defaultDesignSystem || cfg.designSystems?.[0]?.name || 'project';
5265
5270
  // A file under `system/<folder>/` belongs to the DS that owns that folder —
5266
5271
  // path-authoritative, because specimens/ui_kits rarely carry a sidecar
@@ -39,9 +39,9 @@
39
39
  import { execFileSync } from 'node:child_process';
40
40
  import { createHash } from 'node:crypto';
41
41
  import { lookup } from 'node:dns/promises';
42
- import { existsSync, mkdirSync, readFileSync, renameSync, rmSync } from 'node:fs';
42
+ import { existsSync, mkdirSync, readFileSync, realpathSync, renameSync, rmSync } from 'node:fs';
43
43
  import { isIP } from 'node:net';
44
- import { join, resolve, sep } from 'node:path';
44
+ import { basename, dirname, join, resolve, sep } from 'node:path';
45
45
  import { pathToFileURL } from 'node:url';
46
46
 
47
47
  // A real browser UA — some CDNs (wikimedia) 403 a bare curl/UA; the memory
@@ -202,7 +202,7 @@ export function classifyAddress(addr) {
202
202
  * FetchAssetError (code 3) on anything else — non-https scheme, embedded
203
203
  * credentials, missing host.
204
204
  */
205
- export function parseHttpsTarget(rawUrl) {
205
+ export function parseHttpsTarget(rawUrl, opts = {}) {
206
206
  let u;
207
207
  try {
208
208
  u = new URL(rawUrl);
@@ -221,9 +221,71 @@ export function parseHttpsTarget(rawUrl) {
221
221
  if (!Number.isInteger(port) || port < 1 || port > 65535) {
222
222
  throw new FetchAssetError(3, `bad port ${u.port}`);
223
223
  }
224
+ // DDR-216 D4 — an OPTIONAL host allowlist, at the gate rather than at the
225
+ // caller. Absent (the default) = today's unrestricted behaviour, so the
226
+ // existing moodboard/research callers are untouched. The Figma lane always
227
+ // passes it.
228
+ //
229
+ // Matching is exact-or-DOTTED-SUFFIX, never a bare `endsWith`: `endsWith(
230
+ // 'figma.com')` admits `evil-figma.com`, and `endsWith('.amazonaws.com')`
231
+ // admits every S3 bucket on earth.
232
+ //
233
+ // This is a REACH control (which endpoint), never a CONTENT control — the
234
+ // Figma render bucket is a SHARED object store any account can write into,
235
+ // so the byte-level sniff below is what actually protects the tree. And it
236
+ // NARROWS the IP gate, never replaces it: an allowlisted hostname that
237
+ // resolves to 127.0.0.1 is still refused by `resolveSafeIp`.
238
+ if (opts.allowHosts?.length) {
239
+ const h = host.toLowerCase();
240
+ const ok = opts.allowHosts.some((suffix) => {
241
+ const s = String(suffix).toLowerCase().replace(/^\./, '');
242
+ return h === s || h.endsWith(`.${s}`);
243
+ });
244
+ if (!ok) throw new FetchAssetError(3, `host not in this lane's allowlist: ${safeHostLabel(h)}`);
245
+ }
246
+ // DDR-216 D4 — the Figma lane pins 443. Named as NEW logic rather than
247
+ // assumed: this function accepts 1–65535 by default and always has.
248
+ if (opts.pinPort443 && port !== 443) {
249
+ throw new FetchAssetError(3, `this lane requires port 443 (got ${port})`);
250
+ }
224
251
  return { host, port };
225
252
  }
226
253
 
254
+ /**
255
+ * A hostname reduced to a charset-validated token before it can be printed.
256
+ *
257
+ * DDR-216 D10: this verb's output is read BY an agent, so an upstream-controlled
258
+ * string must never appear verbatim in a message. A DNS label charset is narrow
259
+ * enough to be safe and specific enough to diagnose.
260
+ */
261
+ function safeHostLabel(host) {
262
+ const cleaned = String(host)
263
+ .toLowerCase()
264
+ .replace(/[^a-z0-9.-]/g, '');
265
+ return cleaned.slice(0, 253) || '(unprintable)';
266
+ }
267
+
268
+ /**
269
+ * The `--raw-out` accept set: everything `sniffImageExt` accepts, PLUS SVG.
270
+ *
271
+ * `sniffImageExt` is deliberately NOT modified — it is the type gate the
272
+ * moodboard lane depends on, and a standing test asserts it still returns null
273
+ * for `<svg`/`<?xml`. The SVG probe below reuses the exact leading-token shape
274
+ * `_import-asset.mjs`'s own `svgPreParseReject` uses, so a payload accepted here
275
+ * is one that lane will actually attempt to parse (and then sanitize).
276
+ */
277
+ export function sniffStagedKind(bytes) {
278
+ const raster = sniffImageExt(bytes);
279
+ if (raster) return raster;
280
+ // Text probe on the first bytes only — never decode the whole body to guess.
281
+ const head = Buffer.from(bytes.subarray(0, 256)).toString('utf8');
282
+ // Deliberately NARROWER than `svgPreParseReject`'s own leading-token set: a
283
+ // bare `<!--` prologue is legal SVG but it is also what lets an arbitrary text
284
+ // payload masquerade as one (review F2), and no Figma export starts with it.
285
+ // The full DDR-167 lane still validates properly downstream.
286
+ return /^\s*(<\?xml|<svg)/i.test(head) ? 'svg' : null;
287
+ }
288
+
227
289
  // ── image sniff + naming ─────────────────────────────────────────────────────
228
290
 
229
291
  /**
@@ -408,10 +470,91 @@ export async function fetchAsset({
408
470
  designRootRel = '.design',
409
471
  maxBytes = DEFAULT_MAX_BYTES,
410
472
  maxTime = DEFAULT_MAX_TIME,
473
+ allowHosts,
474
+ pinPort443 = false,
475
+ rawOut = null,
476
+ rawRoot = null,
411
477
  }) {
412
- const { host, port } = parseHttpsTarget(url);
478
+ const { host, port } = parseHttpsTarget(url, { allowHosts, pinPort443 });
413
479
  const pinIp = await resolveSafeIp(host);
414
480
 
481
+ // ── `--raw-out` (DDR-216 D11) ──────────────────────────────────────────────
482
+ //
483
+ // Download under the FULL network gate (IP classification, DNS pin, redirect
484
+ // ban, size/time caps, host allowlist) but write to a CALLER-SUPPLIED path
485
+ // instead of `assets/`, and do NOT content-address or name the file.
486
+ //
487
+ // Why this exists: D11 composes two already-reviewed gates rather than
488
+ // widening one. A Figma vector export is an SVG, and this helper's raster-only
489
+ // sniff is load-bearing for its OTHER caller (the DDR-147 moodboard lane,
490
+ // where URLs are research-harvested and there is no allowlist at all). So the
491
+ // Figma lane stages the bytes here and hands them to `_import-asset.mjs`'s
492
+ // DDR-167 SVG lane (allowlist DOM-sanitize → SVGO validity gate) before
493
+ // anything is named or lands in the versioned tree.
494
+ //
495
+ // IT STILL SNIFFS. `--raw-out` must never mean "skip the type gate" — that
496
+ // would turn the one reviewable downloader into an unsniffed one (HTML, JS,
497
+ // polyglots, archives) for the next caller who copies it. It sniffs against
498
+ // an EXTENDED accept set; `sniffImageExt` itself is untouched, which is what
499
+ // keeps the standing "sniffImageExt still rejects SVG" test literally true.
500
+ if (rawOut) {
501
+ // CONTAINMENT (post-implementation review F2). Without this, `--raw-out`
502
+ // was an arbitrary-file-write primitive on the ONE helper whose whole job
503
+ // is to be the safe downloader: `resolve()` + `renameSync()` with no root
504
+ // relationship, no charset assertion, and a type gate a leading `<!--`
505
+ // satisfies. An agent that can run `maude design fetch-asset` (ACP
506
+ // default-allows `Bash(maude:*)`) could have written remote bytes over
507
+ // `CLAUDE.md` — durable prompt injection.
508
+ //
509
+ // So the mode now REQUIRES the caller to declare the directory it owns, and
510
+ // the target must resolve inside it. The Figma lane passes its per-run
511
+ // staging dir; there is no way to express "anywhere".
512
+ if (!rawRoot) {
513
+ throw new FetchAssetError(2, '--raw-out requires --raw-root (the directory the caller owns)');
514
+ }
515
+ // BOTH sides get realpath'd, or neither. On macOS `/var` and `/tmp` are
516
+ // themselves OS-level symlinks (`-> /private/var`, `-> /private/tmp`), so
517
+ // realpath'ing only the root compares `/private/var/…` against `/var/…` and
518
+ // rejects EVERY legitimate staging path — which is exactly how this shipped
519
+ // and why the first real import skipped all 29 of its assets. Same trap
520
+ // DDR-172 Decision 1 documents for its own symlink check.
521
+ //
522
+ // The target does not exist yet, so realpath its PARENT (which does — the
523
+ // caller made the staging dir) and rebuild the leaf onto it.
524
+ const rootAbs = realpathSync(resolve(rawRoot));
525
+ const requested = resolve(rawOut);
526
+ const parentAbs = realpathSync(dirname(requested));
527
+ const outAbs = join(parentAbs, basename(requested));
528
+ if (outAbs !== rootAbs && !outAbs.startsWith(rootAbs + sep)) {
529
+ throw new FetchAssetError(6, '--raw-out must resolve inside --raw-root');
530
+ }
531
+ // Never overwrite: a staged download is always a fresh file, so an existing
532
+ // target means either a collision or an attempt to clobber something.
533
+ if (existsSync(outAbs)) {
534
+ throw new FetchAssetError(6, '--raw-out target already exists');
535
+ }
536
+ const tmpRaw = `${outAbs}.part`;
537
+ try {
538
+ curlDownload({ url, host, port, pinIp, tmpAbs: tmpRaw, maxBytes, maxTime });
539
+ let data;
540
+ try {
541
+ data = readFileSync(tmpRaw);
542
+ } catch {
543
+ throw new FetchAssetError(4, 'download produced no file');
544
+ }
545
+ if (data.length === 0) throw new FetchAssetError(4, 'downloaded empty body');
546
+ if (data.length > maxBytes) throw new FetchAssetError(4, `file exceeds ${maxBytes} bytes`);
547
+ const kind = sniffStagedKind(data);
548
+ if (!kind) {
549
+ throw new FetchAssetError(5, 'not a png/jpg/gif/webp/svg payload (HTML/script rejected)');
550
+ }
551
+ renameSync(tmpRaw, outAbs);
552
+ return { ref: null, path: outAbs, name: null, bytes: data.length, ext: kind };
553
+ } finally {
554
+ rmSync(tmpRaw, { force: true });
555
+ }
556
+ }
557
+
415
558
  const { assetsDir } = containedAssetPath(root, designRootRel, 'placeholder.png');
416
559
  mkdirSync(assetsDir, { recursive: true });
417
560
  const tmpAbs = join(
@@ -476,6 +619,21 @@ function parseArgv(argv) {
476
619
  case '--max-time':
477
620
  out.maxTime = Number(argv[++i]);
478
621
  break;
622
+ // DDR-216 D4/D11 — all three default to today's behaviour, so every
623
+ // existing caller of this helper is untouched.
624
+ case '--allow-host':
625
+ if (!out.allowHosts) out.allowHosts = [];
626
+ out.allowHosts.push(argv[++i]);
627
+ break;
628
+ case '--pin-port-443':
629
+ out.pinPort443 = true;
630
+ break;
631
+ case '--raw-out':
632
+ out.rawOut = argv[++i];
633
+ break;
634
+ case '--raw-root':
635
+ out.rawRoot = argv[++i];
636
+ break;
479
637
  case '--json':
480
638
  out.json = true;
481
639
  break;
@@ -540,8 +698,14 @@ async function main() {
540
698
  designRootRel: opts.designRoot,
541
699
  maxBytes: opts.maxBytes,
542
700
  maxTime: opts.maxTime,
701
+ ...(opts.allowHosts ? { allowHosts: opts.allowHosts } : {}),
702
+ ...(opts.pinPort443 ? { pinPort443: true } : {}),
703
+ ...(opts.rawOut ? { rawOut: opts.rawOut } : {}),
704
+ ...(opts.rawRoot ? { rawRoot: opts.rawRoot } : {}),
543
705
  });
544
- process.stdout.write(opts.json ? `${JSON.stringify(r)}\n` : `${r.ref}\n`);
706
+ // `--raw-out` has no canvas ref (it deliberately writes outside `assets/`),
707
+ // so print the staged path instead of a null.
708
+ process.stdout.write(opts.json ? `${JSON.stringify(r)}\n` : `${r.ref ?? r.path}\n`);
545
709
  } catch (err) {
546
710
  process.stderr.write(`fetch-asset: ${err.message}\n`);
547
711
  process.exit(err instanceof FetchAssetError ? err.code : 1);
@@ -203,7 +203,25 @@ const ALLOWED_PLAIN_ATTRS = new Set([
203
203
  'stop-color',
204
204
  'stop-opacity',
205
205
  'patternUnits',
206
+ // Required by spec for pattern CONTENT, and we were stripping it. A
207
+ // `<pattern>` whose children are authored in the 0..1 unit box — which is what
208
+ // `<use transform="scale(0.00097 0.00075)">` (1/1028 × 1/1331) means — is read
209
+ // in user space without it, so its content collapses to about a pixel.
210
+ //
211
+ // HONEST SCOPE: this is an allowlist bug, NOT the cause of Figma's raster
212
+ // fills failing to appear. That was chased here first and the diagnosis was
213
+ // wrong: Figma's OWN UNTOUCHED export, fetched straight from `/v1/images` and
214
+ // loaded directly, does not paint its `<pattern>`/`<use>`/`<image
215
+ // xlink:href="data:…">` chain either. Allowing this attribute fixes nothing
216
+ // user-visible; it just stops us corrupting a pattern that would otherwise
217
+ // have worked. Same enum-valued, no-URL, no-script risk class as
218
+ // `patternUnits` directly above.
219
+ 'patternContentUnits',
206
220
  'patternTransform',
221
+ // Presentational text metrics. Their absence is not fatal the way the pattern
222
+ // one is, but it silently drifts imported type away from the source.
223
+ 'letter-spacing',
224
+ 'xml:space',
207
225
  'version',
208
226
  ]);
209
227
 
@@ -623,6 +641,78 @@ export async function importSvg(svgText, { root, designRootRel = '.design' }) {
623
641
  * only the rasterize step is blocked, so this fails loud there rather than
624
642
  * pretending the whole pipeline is unavailable.
625
643
  */
644
+
645
+ /**
646
+ * The execution canary for MANY sanitized SVGs, in ONE browser session.
647
+ *
648
+ * Same coverage as `runSvgExecutionCanary`, same probe, same hard network
649
+ * denial, same directly-navigated document — the only thing that changes is
650
+ * that the session is created once instead of once per file.
651
+ *
652
+ * Why it exists: measured on a real 6-page product file, a per-file canary cost
653
+ * ~4.5 s each, so a normal design system's icon set (530 distinct vector
654
+ * renders after component dedupe) would have taken ~40 minutes and forced the
655
+ * import to drop most of its artwork against an asset-count cap. The canary is
656
+ * not what needed relaxing — the session-per-file was.
657
+ *
658
+ * Returns the set of indices that TRIPPED, so the caller refuses exactly those
659
+ * and keeps the rest. A tripped canary is still a hard refusal for that file.
660
+ */
661
+ export async function runSvgExecutionCanaryBatch(sanitizedSvgs, { timeoutMs = 15_000 } = {}) {
662
+ const tripped = new Set();
663
+ if (sanitizedSvgs.length === 0) return tripped;
664
+ const dir = mkdtempSync(join(tmpdir(), 'maude-import-svg-batch-'));
665
+ const probePath = join(dir, 'probe.js');
666
+ try {
667
+ writeFileSync(probePath, 'window.__MAUDE_IMPORT_CANARY__ = false;\n');
668
+ const paths = sanitizedSvgs.map((svg, i) => {
669
+ const p = join(dir, `candidate-${i}.svg`);
670
+ writeFileSync(p, svg);
671
+ return p;
672
+ });
673
+ await withSandboxedRender(
674
+ async (session) => {
675
+ for (let i = 0; i < paths.length; i += 1) {
676
+ session.open(pathToFileURL(paths[i]).href);
677
+ // The probe re-runs on EVERY navigation in this session (it is
678
+ // registered as an init script), so the flag is per-document — a
679
+ // clean file after a tripped one still reads false.
680
+ if (session.eval('window.__MAUDE_IMPORT_CANARY__ === true').trim() === 'true') {
681
+ tripped.add(i);
682
+ }
683
+ }
684
+ },
685
+ { timeoutMs, initScriptPath: probePath }
686
+ );
687
+ } finally {
688
+ rmSync(dir, { recursive: true, force: true });
689
+ }
690
+ return tripped;
691
+ }
692
+
693
+ /**
694
+ * Sanitize + canary + write MANY SVGs. The batched counterpart of `importSvg`.
695
+ * Returns one result per input, or `null` where the file was refused.
696
+ */
697
+ export async function importSvgBatch(svgTexts, { root, designRootRel = '.design' }) {
698
+ const sanitized = svgTexts.map((t) => {
699
+ try {
700
+ return sanitizeSvgAllowlist(t);
701
+ } catch {
702
+ return null;
703
+ }
704
+ });
705
+ const live = [];
706
+ for (let i = 0; i < sanitized.length; i += 1) if (sanitized[i] !== null) live.push(i);
707
+
708
+ const tripped = await runSvgExecutionCanaryBatch(live.map((i) => sanitized[i]));
709
+ return sanitized.map((svg, i) => {
710
+ if (svg === null) return null;
711
+ if (tripped.has(live.indexOf(i))) return null;
712
+ return writeContainedAsset(root, designRootRel, Buffer.from(svg, 'utf8'), 'svg');
713
+ });
714
+ }
715
+
626
716
  export async function importPdf(inputPath, { root, designRootRel = '.design' }) {
627
717
  const buffer = readPdfCapped(inputPath);
628
718
  const pageCount = await getPdfPageCountIsolated(buffer);