@1agh/maude 0.46.0 → 0.47.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 (56) hide show
  1. package/apps/studio/acp/bootstrap-brief.ts +8 -0
  2. package/apps/studio/acp/bridge.ts +113 -5
  3. package/apps/studio/annotations-layer.tsx +42 -0
  4. package/apps/studio/api.ts +417 -2
  5. package/apps/studio/bin/_agent-browser-safe-config.json +1 -0
  6. package/apps/studio/bin/_agent-browser-safe.mjs +228 -0
  7. package/apps/studio/bin/_agent-browser-safe.test.mjs +165 -0
  8. package/apps/studio/bin/_curl-local.mjs +349 -0
  9. package/apps/studio/bin/_curl-local.test.mjs +280 -0
  10. package/apps/studio/bin/agent-browser-safe.sh +29 -0
  11. package/apps/studio/bin/curl-local.sh +28 -0
  12. package/apps/studio/canvas-cursors.ts +6 -0
  13. package/apps/studio/canvas-edit.ts +678 -11
  14. package/apps/studio/canvas-icons.tsx +13 -0
  15. package/apps/studio/canvas-lib.tsx +3 -0
  16. package/apps/studio/canvas-shell.tsx +646 -26
  17. package/apps/studio/client/app.jsx +898 -52
  18. package/apps/studio/client/panels/ChatPanel.jsx +47 -2
  19. package/apps/studio/client/styles/3-shell-maude.css +39 -0
  20. package/apps/studio/contextual-toolbar.tsx +5 -3
  21. package/apps/studio/dist/client.bundle.js +1103 -1103
  22. package/apps/studio/dist/comment-mount.js +2 -2
  23. package/apps/studio/dist/styles.css +1 -1
  24. package/apps/studio/grid-track-handles.ts +179 -0
  25. package/apps/studio/handoff.ts +35 -0
  26. package/apps/studio/http.ts +118 -0
  27. package/apps/studio/input-router.tsx +73 -17
  28. package/apps/studio/test/acp-session-allowed-tools.test.ts +107 -11
  29. package/apps/studio/test/browse-posture.test.tsx +107 -0
  30. package/apps/studio/test/canvas-hide-chrome.test.ts +58 -0
  31. package/apps/studio/test/canvas-meta-api.test.ts +70 -0
  32. package/apps/studio/test/canvas-origin-gate.test.ts +4 -0
  33. package/apps/studio/test/comment-mount.test.ts +2 -1
  34. package/apps/studio/test/component-map.test.ts +48 -0
  35. package/apps/studio/test/convert-to-absolute.test.ts +333 -0
  36. package/apps/studio/test/detach-component.test.ts +94 -0
  37. package/apps/studio/test/edit-scope-api.test.ts +8 -4
  38. package/apps/studio/test/element-structural-api.test.ts +74 -0
  39. package/apps/studio/test/element-structural-edit.test.ts +113 -0
  40. package/apps/studio/test/grid-track-handles.test.ts +160 -0
  41. package/apps/studio/test/handoff.test.ts +48 -0
  42. package/apps/studio/test/input-router.test.ts +82 -8
  43. package/apps/studio/test/layers-synthetic-groups.test.ts +96 -0
  44. package/apps/studio/test/pdf-print-boxes.test.ts +54 -0
  45. package/apps/studio/test/use-tool-mode.test.tsx +10 -2
  46. package/apps/studio/tool-palette.tsx +3 -1
  47. package/apps/studio/use-canvas-media-drop.tsx +126 -0
  48. package/apps/studio/use-element-resize.tsx +3 -1
  49. package/apps/studio/use-grid-track-handles.tsx +364 -0
  50. package/apps/studio/use-keyboard-discipline.tsx +15 -0
  51. package/apps/studio/use-tool-mode.tsx +30 -3
  52. package/apps/studio/web-overlay-content.tsx +52 -0
  53. package/apps/studio/whats-new.json +27 -0
  54. package/cli/commands/design.mjs +16 -0
  55. package/package.json +8 -8
  56. package/plugins/design/templates/_shell.html +4 -0
@@ -82,6 +82,23 @@ interface OpeningHit {
82
82
  * pipeline uses, so the ID arithmetic stays in lockstep. Returns null if no
83
83
  * match.
84
84
  */
85
+ /**
86
+ * Hand-authored `data-cd-id` literal on an opening element, when present. The
87
+ * pipeline PRESERVES an authored id (it skips injection — canvas-pipeline.ts
88
+ * `hasJsxAttr` gate), so the DOM carries the AUTHORED value while the
89
+ * positional `computeId` for that element never exists anywhere. Every walker
90
+ * that maps ids therefore has to prefer the authored literal — without this,
91
+ * authored-id elements were unreachable by the whole edit engine (dogfood
92
+ * 2026-07-20: "Convert failed: invalid container data-cd-id" on
93
+ * `data-cd-id="wal-hero-nav"`).
94
+ */
95
+ function authoredCdId(opening: AnyNode): string | null {
96
+ const attr = findAttribute(opening, 'data-cd-id');
97
+ const v = attr?.value;
98
+ if (v?.type === 'Literal' && typeof v.value === 'string' && v.value) return v.value;
99
+ return null;
100
+ }
101
+
85
102
  function findOpening(program: AnyNode, targetId: string): OpeningHit | null {
86
103
  interface Frame {
87
104
  componentName: string;
@@ -112,7 +129,7 @@ function findOpening(program: AnyNode, targetId: string): OpeningHit | null {
112
129
  const frame = stack[stack.length - 1] as Frame;
113
130
  const idx = frame.jsxIndex;
114
131
  frame.jsxIndex += 1;
115
- const id = computeId(frame.componentName, idx);
132
+ const id = authoredCdId(node.openingElement) ?? computeId(frame.componentName, idx);
116
133
  if (id === targetId) {
117
134
  hit = { opening: node.openingElement, element: node };
118
135
  }
@@ -689,6 +706,14 @@ function objPropValue(objExpr: AnyNode, propName: string): AnyNode | null {
689
706
  */
690
707
  function resolveValueNode(program: AnyNode, expr: AnyNode, depth = 0): AnyNode | null {
691
708
  if (!expr || depth > 8) return null;
709
+ // Unwrap `x as T` / `x satisfies T` type-cast wrappers (e.g. a shared
710
+ // `print={A1_PRINT as any}` prop, or a `const X = {...} as const`
711
+ // declaration) — the cast carries no runtime value, so the wrapped
712
+ // expression is what every branch below actually needs to match against.
713
+ while (expr && (expr.type === 'TSAsExpression' || expr.type === 'TSSatisfiesExpression')) {
714
+ expr = expr.expression;
715
+ }
716
+ if (!expr) return null;
692
717
  if (isStringLit(expr)) return expr;
693
718
  if (expr.type === 'ObjectExpression' || expr.type === 'ArrayExpression') return expr;
694
719
  if (expr.type === 'Identifier') {
@@ -974,7 +999,10 @@ function collectElements(program: AnyNode): Array<{ id: string; node: AnyNode }>
974
999
  const frame = stack[stack.length - 1] as Frame;
975
1000
  const idx = frame.jsxIndex;
976
1001
  frame.jsxIndex += 1;
977
- out.push({ id: computeId(frame.componentName, idx), node });
1002
+ out.push({
1003
+ id: authoredCdId(node.openingElement) ?? computeId(frame.componentName, idx),
1004
+ node,
1005
+ });
978
1006
  if (node.openingElement) visit(node.openingElement.attributes);
979
1007
  visit(node.children);
980
1008
  if (pushed) stack.pop();
@@ -1034,7 +1062,7 @@ function collectElementsFull(
1034
1062
  const idx = frame.jsxIndex;
1035
1063
  frame.jsxIndex += 1;
1036
1064
  out.push({
1037
- id: computeId(frame.componentName, idx),
1065
+ id: authoredCdId(node.openingElement) ?? computeId(frame.componentName, idx),
1038
1066
  componentName: frame.componentName,
1039
1067
  isFrameRoot: idx === 0,
1040
1068
  tag: tagOf(node),
@@ -1054,6 +1082,44 @@ function collectElementsFull(
1054
1082
  return out;
1055
1083
  }
1056
1084
 
1085
+ /**
1086
+ * feature-4 T7a (layers purple instances, DDR-187 scope note) — the component
1087
+ * map the shell's Layers panel renders instance rows from. For every element id
1088
+ * whose ENCLOSING component is actually instantiated as a JSX element in this
1089
+ * file (`<Card/>` — i.e. a reusable component, not the top-level canvas
1090
+ * component, which is exported/mounted but never referenced as JSX here),
1091
+ * report `{ component, root, usages }`. `root` marks the component's own frame
1092
+ * root (the "instance" row in Figma terms — ◆-adjacent); inner members get the
1093
+ * same purple treatment at lower prominence. Elements of never-instantiated
1094
+ * components (the canvas root) are omitted — keeps the payload proportional to
1095
+ * actual instances.
1096
+ */
1097
+ export function componentMapForCanvas(
1098
+ canvasAbsPath: string,
1099
+ source: string
1100
+ ): Record<string, { component: string; root: boolean; usages: number }> {
1101
+ const parsed = parseSync(canvasAbsPath, source, { sourceType: 'module' });
1102
+ if (parsed.errors && parsed.errors.length > 0) return {};
1103
+ const all = collectElementsFull(parsed.program);
1104
+ // Usage count per component name = JSX elements whose TAG is that name.
1105
+ const usages = new Map<string, number>();
1106
+ for (const e of all) {
1107
+ if (e.tag && /^[A-Z]/.test(e.tag)) {
1108
+ usages.set(e.tag, (usages.get(e.tag) ?? 0) + 1);
1109
+ }
1110
+ }
1111
+ const out: Record<string, { component: string; root: boolean; usages: number }> = {};
1112
+ for (const e of all) {
1113
+ if (!e.componentName) continue;
1114
+ const n = usages.get(e.componentName) ?? 0;
1115
+ if (n < 1) continue; // top-level canvas component — not an instance
1116
+ // First writer wins — repeated ids (a `.map`) share one source element; the
1117
+ // map is keyed by source id, which is exactly what the Layers rows carry.
1118
+ if (!out[e.id]) out[e.id] = { component: e.componentName, root: e.isFrameRoot, usages: n };
1119
+ }
1120
+ return out;
1121
+ }
1122
+
1057
1123
  /**
1058
1124
  * Resolve a reused-component INSTANCE id to the parent's `<Component>` USAGE id.
1059
1125
  *
@@ -3963,12 +4029,21 @@ function objectExpressionToPlain(node: AnyNode, depth = 0): Record<string, unkno
3963
4029
  * Read an artboard's `print` JSX prop as a plain object — used by the PDF
3964
4030
  * exporter (T5) to resolve bleed/paper geometry for the artboard being
3965
4031
  * exported. Read-only (no lock, no write). Returns null when the canvas/
3966
- * artboard/prop doesn't exist, `print` isn't a `{{...}}` object-expression,
3967
- * or `kind` isn't the literal string `"print"` — a `print` prop left over
3968
- * from a prior kind switch (or hand-authored on a non-print artboard) must
3969
- * NOT leak bleed/marks into an export; `kind="print"` is the sole gate
3970
- * (unlike `video`, print has no implicit structural-fallback resolution, so
3971
- * checking the explicit attr is sufficient — see DDR-181/canvas-lib.tsx).
4032
+ * artboard/prop doesn't exist, `kind` isn't the literal string `"print"` — a
4033
+ * `print` prop left over from a prior kind switch (or hand-authored on a
4034
+ * non-print artboard) must NOT leak bleed/marks into an export; `kind="print"`
4035
+ * is the sole gate (unlike `video`, print has no implicit structural-fallback
4036
+ * resolution, so checking the explicit attr is sufficient — see
4037
+ * DDR-181/canvas-lib.tsx) — or `print` doesn't resolve to a `{{...}}`
4038
+ * object-expression.
4039
+ *
4040
+ * The prop is accepted either as an inline literal (`print={{ paper: 'a4' }}`)
4041
+ * or as a reference to a top-level `const` sharing one spec across several
4042
+ * artboards (`print={A1_PRINT}` / `print={A1_PRINT as any}`) — resolved via
4043
+ * `resolveValueNode`'s same no-eval identifier lookup used elsewhere in this
4044
+ * file. Anything else genuinely computed (an import, a function call, a
4045
+ * spread) still returns null rather than guessing (RCA
4046
+ * issue-pdf-print-export-marks-missing).
3972
4047
  */
3973
4048
  export function readArtboardPrintProp(
3974
4049
  canvasAbsPath: string,
@@ -3984,8 +4059,10 @@ export function readArtboardPrintProp(
3984
4059
  const attr = findAttribute(target.openingElement, 'print');
3985
4060
  if (attr?.value?.type !== 'JSXExpressionContainer') return null;
3986
4061
  const expr = attr.value.expression as AnyNode | undefined;
3987
- if (expr?.type !== 'ObjectExpression') return null;
3988
- return objectExpressionToPlain(expr);
4062
+ if (!expr) return null;
4063
+ const resolved = resolveValueNode(parsed.program, expr);
4064
+ if (resolved?.type !== 'ObjectExpression') return null;
4065
+ return objectExpressionToPlain(resolved);
3989
4066
  }
3990
4067
 
3991
4068
  /**
@@ -4148,6 +4225,148 @@ export async function insertArtboard(
4148
4225
  });
4149
4226
  }
4150
4227
 
4228
+ /**
4229
+ * Duplicate the `<DCArtboard id="…">` whose `id` prop equals `artboardId` at
4230
+ * a NEW width — feature-3-web-artboards T3 "Duplicate at width…". Clones the
4231
+ * artboard's full source span (opening tag through children through closing
4232
+ * tag) as the NEXT sibling in source order, then patches the clone's `id`
4233
+ * (suffixed with the new width, de-duped against every existing artboard id),
4234
+ * `label` (suffixed `" (WIDTHpx)"`), and `width` attributes. Every other prop
4235
+ * (`kind`, `guides`, `print`, `background`, `layout`, `gap`, `fixed`, and the
4236
+ * children themselves) carries over verbatim — this is a structural copy, not
4237
+ * a linked variant (Design Decision 3): the agent or the clone's own
4238
+ * `@container` rules adapt the content afterward.
4239
+ *
4240
+ * Unlike `applyDuplicateElement` (whose target has no explicit source `id` —
4241
+ * data-cd-id is transpile-time positional, so a byte-verbatim clone never
4242
+ * collides), an artboard's `id`/`label`/`width` ARE explicit JSX attributes
4243
+ * that WOULD collide verbatim — they're rewritten in the clone text via a
4244
+ * small isolated MagicString pass keyed off the original attribute nodes'
4245
+ * offsets translated into clone-relative coordinates (offset - elStart),
4246
+ * rather than re-parsing the extracted substring.
4247
+ *
4248
+ * Splicing the clone right after the SOURCE (not appended at the end of the
4249
+ * file, unlike applyInsertArtboard) is what gives it a "beside the source"
4250
+ * position for free: an artboard with no explicit `layout.artboards[]` entry
4251
+ * places via the runtime's index-ordered default-grid (DDR-027,
4252
+ * canvas-lib.tsx VP_GRID) — the very next index after the source is the next
4253
+ * default-grid cell, so the clone lands beside the original with no extra
4254
+ * position-writing plumbing. An artboard that DOES carry an explicit
4255
+ * `layout.artboards[]` entry is unaffected by its index (position is keyed by
4256
+ * id, not order).
4257
+ *
4258
+ * Reparse-gated. Whole-file-snapshot undo, like the other structural ops.
4259
+ */
4260
+ export function applyDuplicateArtboard(
4261
+ canvasAbsPath: string,
4262
+ source: string,
4263
+ artboardId: string,
4264
+ newWidth: number
4265
+ ): { source: string; artboardId: string } {
4266
+ const parsed = parseSync(canvasAbsPath, source, { sourceType: 'module' });
4267
+ if (parsed.errors && parsed.errors.length > 0) {
4268
+ throw new CanvasEditError(
4269
+ `oxc-parser failed on ${canvasAbsPath}: ${parsed.errors[0]?.message ?? 'unknown'}`,
4270
+ { canvas: canvasAbsPath, id: artboardId }
4271
+ );
4272
+ }
4273
+ const artboards = collectJsxByTag(parsed.program, 'DCArtboard');
4274
+ const target = artboards.find((a) => getStringAttr(a.openingElement, 'id') === artboardId);
4275
+ if (!target) {
4276
+ throw new CanvasEditError(`<DCArtboard id="${artboardId}"> not found in ${canvasAbsPath}`, {
4277
+ canvas: canvasAbsPath,
4278
+ id: artboardId,
4279
+ });
4280
+ }
4281
+ const width = Math.max(1, Math.round(newWidth));
4282
+
4283
+ const existingIds = new Set(
4284
+ artboards
4285
+ .map((a) => getStringAttr(a.openingElement, 'id'))
4286
+ .filter((x): x is string => typeof x === 'string')
4287
+ );
4288
+ let newId = `${artboardId}-${width}`;
4289
+ let suffix = 2;
4290
+ while (existingIds.has(newId)) {
4291
+ newId = `${artboardId}-${width}-${suffix}`;
4292
+ suffix += 1;
4293
+ }
4294
+
4295
+ const sourceLabel = getStringAttr(target.openingElement, 'label') ?? artboardId;
4296
+ const newLabel = `${sourceLabel} (${width}px)`;
4297
+
4298
+ const elStart = target.start as number;
4299
+ const elEnd = target.end as number;
4300
+ const cloneText = source.slice(elStart, elEnd);
4301
+
4302
+ const idAttr = findAttribute(target.openingElement, 'id');
4303
+ if (!idAttr || typeof idAttr.start !== 'number' || typeof idAttr.end !== 'number') {
4304
+ throw new CanvasEditError(`<DCArtboard id="${artboardId}"> has no readable id attribute`, {
4305
+ canvas: canvasAbsPath,
4306
+ id: artboardId,
4307
+ });
4308
+ }
4309
+ const labelAttr = findAttribute(target.openingElement, 'label');
4310
+ const widthAttr = findAttribute(target.openingElement, 'width');
4311
+
4312
+ const cs = new MagicString(cloneText);
4313
+ cs.overwrite(idAttr.start - elStart, idAttr.end - elStart, `id="${escapeAttr(newId)}"`);
4314
+ if (labelAttr && typeof labelAttr.start === 'number' && typeof labelAttr.end === 'number') {
4315
+ cs.overwrite(
4316
+ labelAttr.start - elStart,
4317
+ labelAttr.end - elStart,
4318
+ `label="${escapeAttr(newLabel)}"`
4319
+ );
4320
+ } else {
4321
+ cs.appendLeft(idAttr.end - elStart, ` label="${escapeAttr(newLabel)}"`);
4322
+ }
4323
+ if (widthAttr && typeof widthAttr.start === 'number' && typeof widthAttr.end === 'number') {
4324
+ cs.overwrite(widthAttr.start - elStart, widthAttr.end - elStart, `width={${width}}`);
4325
+ } else {
4326
+ cs.appendLeft(idAttr.end - elStart, ` width={${width}}`);
4327
+ }
4328
+ const patchedClone = cs.toString();
4329
+
4330
+ const targetIndent = lineStartInfo(source, elStart).indent;
4331
+ const insertText = `\n${targetIndent}${patchedClone}`;
4332
+ const s = new MagicString(source);
4333
+ s.appendLeft(elEnd, insertText);
4334
+ const out = s.toString();
4335
+ const check = parseSync(canvasAbsPath, out, { sourceType: 'module' });
4336
+ if (check.errors && check.errors.length > 0) {
4337
+ throw new CanvasEditError(
4338
+ `duplicate-artboard produced invalid source (${check.errors[0]?.message ?? 'parse error'})`,
4339
+ { canvas: canvasAbsPath, id: artboardId }
4340
+ );
4341
+ }
4342
+ return { source: out, artboardId: newId };
4343
+ }
4344
+
4345
+ /** Duplicate an artboard on disk (atomic write + cross-process lock). */
4346
+ export async function duplicateArtboard(
4347
+ canvasAbsPath: string,
4348
+ artboardId: string,
4349
+ newWidth: number
4350
+ ): Promise<{ source: string; artboardId: string }> {
4351
+ return withLock(canvasAbsPath, async () => {
4352
+ const file = Bun.file(canvasAbsPath);
4353
+ if (!(await file.exists())) {
4354
+ throw new CanvasEditError(`Canvas not found: ${canvasAbsPath}`, {
4355
+ canvas: canvasAbsPath,
4356
+ id: artboardId,
4357
+ });
4358
+ }
4359
+ const source = await file.text();
4360
+ const next = applyDuplicateArtboard(canvasAbsPath, source, artboardId, newWidth);
4361
+ if (next.source === source) return next;
4362
+ const tmp = `${canvasAbsPath}.tmp.${Math.random().toString(36).slice(2, 10)}`;
4363
+ await Bun.write(tmp, next.source);
4364
+ const { rename } = await import('node:fs/promises');
4365
+ await rename(tmp, canvasAbsPath);
4366
+ return next;
4367
+ });
4368
+ }
4369
+
4151
4370
  /** A clip in an assemble request — a dropped reference chip's src + kind. */
4152
4371
  export interface AssembleClip {
4153
4372
  src: string;
@@ -4415,6 +4634,454 @@ function editStyleProp(
4415
4634
  s.appendLeft((obj.end as number) - 1, `${sep}${jsKey(prop)}: ${value} `);
4416
4635
  }
4417
4636
 
4637
+ /**
4638
+ * feature-4 T8 (convert-to-absolute, DDR-188) — set SEVERAL inline-style props on
4639
+ * ONE opening element in ONE MagicString pass. `editStyleProp` can't be called
4640
+ * N× per element on a fresh (style-less) element: it re-reads the ORIGINAL AST
4641
+ * each call (which still shows no `style` attr) and would emit N duplicate
4642
+ * `style={{…}}` attributes. So the no-style case is handled here with a single
4643
+ * combined insert; the existing-style case safely delegates per-prop to
4644
+ * `editStyleProp` (distinct keys → non-overlapping overwrites; new-key appends
4645
+ * stack correctly before the closing brace). `entries` values are ALREADY
4646
+ * source-ready (e.g. `'"absolute"'`, `JSON.stringify("12px")`).
4647
+ */
4648
+ function setMultipleStyleProps(
4649
+ s: MagicString,
4650
+ opening: AnyNode,
4651
+ entries: Array<[string, string]>,
4652
+ canvasAbsPath: string,
4653
+ id: string
4654
+ ): void {
4655
+ if (entries.length === 0) return;
4656
+ const attr = findAttribute(opening, 'style');
4657
+ if (!attr) {
4658
+ const insertAt: number | undefined = opening?.name?.end;
4659
+ if (typeof insertAt !== 'number') {
4660
+ throw new Error('Opening element has no resolvable name range');
4661
+ }
4662
+ const body = entries.map(([k, v]) => `${jsKey(k)}: ${v}`).join(', ');
4663
+ s.appendLeft(insertAt, ` style={{ ${body} }}`);
4664
+ return;
4665
+ }
4666
+ for (const [k, v] of entries) {
4667
+ editStyleProp(s, opening, k, v, canvasAbsPath, id);
4668
+ }
4669
+ }
4670
+
4671
+ /**
4672
+ * feature-4 T8 (convert-to-absolute, DDR-188) — the "Remove auto layout" analogue.
4673
+ * Pure AST batch write: rewrite each stamped CHILD to `position:absolute` with the
4674
+ * frozen `left/top/width/height` the client measured (border-box, world units),
4675
+ * and set the CONTAINER to `position:relative` when it's currently static — all in
4676
+ * ONE MagicString pass so a single whole-file snapshot reverts the whole operation.
4677
+ *
4678
+ * Guards (all-or-nothing, throws `CanvasEditError` — the shell surfaces the reason
4679
+ * and writes nothing):
4680
+ * - Every child id must be a plain, UNIQUE, in-source element. If any child's
4681
+ * `idIndex` resolves to a different `<Component/>` usage (a shared instance),
4682
+ * we refuse — converting one usage's props doesn't generalize, and doing it
4683
+ * silently would move every instance (the DDR-188 shared-instance abort; the
4684
+ * "affects N instances" confirm path is a deferred follow-up).
4685
+ * - The caller (canvas iframe) already refuses unstamped or duplicate-cd-id
4686
+ * (`.map`ed) direct children before it ever posts — this is the server-side
4687
+ * backstop for the component-usage case it can't see.
4688
+ */
4689
+ export interface ConvertChildBox {
4690
+ id: string;
4691
+ idIndex?: number;
4692
+ left: number;
4693
+ top: number;
4694
+ width: number;
4695
+ height: number;
4696
+ }
4697
+
4698
+ export interface ConvertContainerSpec {
4699
+ /** Absent for the artboard-body ROOT level — `.dc-artboard-body` is already
4700
+ * `position:relative` engine chrome with no `data-cd-id`, so its children
4701
+ * get absolute boxes with no container write. */
4702
+ containerId?: string;
4703
+ containerIdIndex?: number;
4704
+ containerSetRelative: boolean;
4705
+ /** feature-4 (dogfood round 4) — freeze the container's OWN border-box size.
4706
+ * Needed for the element-level subtree ROOT: it keeps its flow position but
4707
+ * its auto height would collapse once every child goes absolute. */
4708
+ freezeSize?: { width: number; height: number };
4709
+ children: ConvertChildBox[];
4710
+ }
4711
+
4712
+ export function applyConvertToAbsolute(
4713
+ canvasAbsPath: string,
4714
+ source: string,
4715
+ spec: {
4716
+ containerId?: string;
4717
+ containerIdIndex?: number;
4718
+ containerSetRelative?: boolean;
4719
+ /** feature-4 T8b — user CONFIRMED converting component-instance children:
4720
+ * each child's `idIndex` routes the write to that occurrence's own
4721
+ * `<Component/>` USAGE (the Stage-H3 local-instance model — the usage
4722
+ * element carries the frozen box; the component must forward `style` for
4723
+ * it to paint, same assumption as instance drag-reposition). Without the
4724
+ * flag a shared child still throws (the pre-confirm abort). */
4725
+ allowShared?: boolean;
4726
+ children?: ConvertChildBox[];
4727
+ /** feature-4 artboard-level convert (2026-07-19) — MULTI-container batch:
4728
+ * the whole artboard's layout flattened to absolute in ONE pass / ONE
4729
+ * undo seq. When present, the legacy single-container fields above are
4730
+ * ignored. */
4731
+ containers?: ConvertContainerSpec[];
4732
+ /** feature-4 TRUE FLATTEN (user steer 2026-07-20) — ids of UNSTYLED layout
4733
+ * wrappers to DISSOLVE: their opening+closing tags are removed from the
4734
+ * JSX (children hoist textually into the parent), so the tree genuinely
4735
+ * flattens. The client only nominates visually-inert wrappers (no
4736
+ * background/border/shadow/radius/clip) whose id is unique in the
4737
+ * artboard; their children's boxes are measured against the nearest
4738
+ * SURVIVING ancestor. */
4739
+ dissolve?: string[];
4740
+ }
4741
+ ): EditResult {
4742
+ const parsed = parseSync(canvasAbsPath, source, { sourceType: 'module' });
4743
+ if (parsed.errors && parsed.errors.length > 0) {
4744
+ const first = parsed.errors[0];
4745
+ throw new CanvasEditError(
4746
+ `oxc-parser failed on ${canvasAbsPath}: ${first?.message ?? 'unknown'}`,
4747
+ {
4748
+ canvas: canvasAbsPath,
4749
+ id: spec.containerId ?? 'artboard',
4750
+ }
4751
+ );
4752
+ }
4753
+ const containers: ConvertContainerSpec[] = Array.isArray(spec.containers)
4754
+ ? spec.containers
4755
+ : [
4756
+ {
4757
+ containerId: spec.containerId,
4758
+ containerIdIndex: spec.containerIdIndex,
4759
+ containerSetRelative: spec.containerSetRelative === true,
4760
+ children: spec.children ?? [],
4761
+ },
4762
+ ];
4763
+ const totalChildren = containers.reduce((n, c) => n + c.children.length, 0);
4764
+ if (totalChildren === 0) {
4765
+ throw new CanvasEditError('convert-to-absolute: no children to convert', {
4766
+ canvas: canvasAbsPath,
4767
+ id: spec.containerId ?? 'artboard',
4768
+ });
4769
+ }
4770
+ const s = new MagicString(source);
4771
+ // Shared across ALL containers — a `.map()`ed child (two DOM children
4772
+ // resolving to the SAME source element) can never convert: N rendered copies
4773
+ // of ONE source element can't hold N absolute positions.
4774
+ const writtenTargets = new Set<string>();
4775
+ // A container may legitimately receive BOTH a `position:relative` write and
4776
+ // (as a child of ITS parent container) an absolute box — track containers we
4777
+ // already touched so the batch never double-writes `position` on one element.
4778
+ const relativeWritten = new Set<string>();
4779
+
4780
+ for (const c of containers) {
4781
+ // Container → position:relative (only when the client says it's currently
4782
+ // static; already-positioned containers are left as-is; the artboard-body
4783
+ // root level has no container to write).
4784
+ if (c.containerId && (c.containerSetRelative || c.freezeSize)) {
4785
+ const cid =
4786
+ typeof c.containerIdIndex === 'number' && Number.isFinite(c.containerIdIndex)
4787
+ ? resolveUsageId(parsed.program, c.containerId, c.containerIdIndex)
4788
+ : c.containerId;
4789
+ if (!relativeWritten.has(cid) && !writtenTargets.has(cid)) {
4790
+ relativeWritten.add(cid);
4791
+ const chit = findOpening(parsed.program, cid);
4792
+ if (!chit) {
4793
+ throw new CanvasEditError(`container "${c.containerId}" not found in ${canvasAbsPath}`, {
4794
+ canvas: canvasAbsPath,
4795
+ id: c.containerId,
4796
+ });
4797
+ }
4798
+ const entries: Array<[string, string]> = [];
4799
+ if (c.containerSetRelative) entries.push(['position', '"relative"']);
4800
+ if (c.freezeSize) {
4801
+ entries.push(
4802
+ ['width', JSON.stringify(`${c.freezeSize.width}px`)],
4803
+ ['height', JSON.stringify(`${c.freezeSize.height}px`)],
4804
+ ['box-sizing', '"border-box"']
4805
+ );
4806
+ }
4807
+ setMultipleStyleProps(s, chit.opening, entries, canvasAbsPath, cid);
4808
+ }
4809
+ }
4810
+
4811
+ // Each child → absolute + frozen box. A shared-component usage either
4812
+ // routes to its own `<Component/>` usage (allowShared — the user
4813
+ // confirmed) or throws (the pre-confirm abort).
4814
+ for (const child of c.children) {
4815
+ let cid = child.id;
4816
+ if (typeof child.idIndex === 'number' && Number.isFinite(child.idIndex)) {
4817
+ cid = resolveUsageId(parsed.program, child.id, child.idIndex);
4818
+ if (cid !== child.id && !spec.allowShared) {
4819
+ throw new CanvasEditError(
4820
+ `convert-to-absolute: "${child.id}" is a shared component instance — confirm required`,
4821
+ { canvas: canvasAbsPath, id: child.id }
4822
+ );
4823
+ }
4824
+ }
4825
+ if (writtenTargets.has(cid)) {
4826
+ throw new CanvasEditError(
4827
+ `convert-to-absolute: "${child.id}" renders from a repeated (.map) source element — cannot convert`,
4828
+ { canvas: canvasAbsPath, id: child.id }
4829
+ );
4830
+ }
4831
+ writtenTargets.add(cid);
4832
+ const hit = findOpening(parsed.program, cid);
4833
+ if (!hit) {
4834
+ throw new CanvasEditError(`child "${child.id}" not found in ${canvasAbsPath}`, {
4835
+ canvas: canvasAbsPath,
4836
+ id: child.id,
4837
+ });
4838
+ }
4839
+ // A COMPONENT-INSTANCE child (cid routed to a `<Component/>` usage):
4840
+ // writing `style` on the usage tag only works when the component
4841
+ // forwards it — most don't (live-dogfood 2026-07-20: the frozen Cards
4842
+ // piled at the artboard's top-left). WRAP the usage in a positioned div
4843
+ // instead — correct for ANY component, no forwarding assumption. The
4844
+ // wrapper gets its own data-cd-id on the next transpile.
4845
+ if (cid !== child.id) {
4846
+ const elStart = hit.element?.start as number;
4847
+ const elEnd = hit.element?.end as number;
4848
+ // appendRight for the OPEN + appendLeft for the CLOSE: at a shared
4849
+ // boundary between two ADJACENT usages (`<Card /><Card />`), MagicString
4850
+ // renders the appendLeft bucket before the appendRight bucket, so the
4851
+ // previous usage's close always lands before the next usage's open.
4852
+ s.appendRight(
4853
+ elStart,
4854
+ `<div style={{ position: "absolute", left: ${JSON.stringify(`${child.left}px`)}, top: ${JSON.stringify(`${child.top}px`)}, width: ${JSON.stringify(`${child.width}px`)}, height: ${JSON.stringify(`${child.height}px`)} }}>`
4855
+ );
4856
+ s.appendLeft(elEnd, '</div>');
4857
+ continue;
4858
+ }
4859
+ const entries: Array<[string, string]> = [
4860
+ ['position', '"absolute"'],
4861
+ ['left', JSON.stringify(`${child.left}px`)],
4862
+ ['top', JSON.stringify(`${child.top}px`)],
4863
+ ['width', JSON.stringify(`${child.width}px`)],
4864
+ ['height', JSON.stringify(`${child.height}px`)],
4865
+ ['box-sizing', '"border-box"'],
4866
+ ];
4867
+ // If this element was already made `relative` as a container in this
4868
+ // batch, drop the child-role `position:absolute` write for it — one
4869
+ // element gets ONE position key. (Deeper-level nesting keeps the
4870
+ // relative context; children of an absolute element also resolve fine —
4871
+ // absolute IS a positioning context — but never write both.)
4872
+ if (relativeWritten.has(cid)) entries.shift();
4873
+ setMultipleStyleProps(s, hit.opening, entries, canvasAbsPath, cid);
4874
+ }
4875
+ }
4876
+
4877
+ // feature-4 TRUE FLATTEN — dissolve nominated layout wrappers: strip the
4878
+ // opening + closing tags, leaving the children's JSX in place (they hoist
4879
+ // into the wrapper's parent). Style writes above never target a dissolved
4880
+ // id (the client keeps the sets disjoint; enforced here as a hard error so
4881
+ // a drifted client can't half-write a removed element).
4882
+ if (Array.isArray(spec.dissolve) && spec.dissolve.length > 0) {
4883
+ for (const did of spec.dissolve) {
4884
+ if (writtenTargets.has(did) || relativeWritten.has(did)) {
4885
+ throw new CanvasEditError(
4886
+ `convert-to-absolute: "${did}" is both a style target and a dissolve target`,
4887
+ { canvas: canvasAbsPath, id: did }
4888
+ );
4889
+ }
4890
+ const hit = findOpening(parsed.program, did);
4891
+ if (!hit) {
4892
+ throw new CanvasEditError(`dissolve target "${did}" not found in ${canvasAbsPath}`, {
4893
+ canvas: canvasAbsPath,
4894
+ id: did,
4895
+ });
4896
+ }
4897
+ const opening = hit.opening;
4898
+ const closing = hit.element?.closingElement;
4899
+ if (!closing || typeof closing.start !== 'number' || typeof closing.end !== 'number') {
4900
+ throw new CanvasEditError(`dissolve target "${did}" is self-closing — nothing to hoist`, {
4901
+ canvas: canvasAbsPath,
4902
+ id: did,
4903
+ });
4904
+ }
4905
+ s.remove(opening.start as number, opening.end as number);
4906
+ s.remove(closing.start as number, closing.end as number);
4907
+ }
4908
+ }
4909
+
4910
+ const out = s.toString();
4911
+ return { source: out, delta: out.length - source.length };
4912
+ }
4913
+
4914
+ /**
4915
+ * Async wrapper for {@link applyConvertToAbsolute} — read, apply, atomic write,
4916
+ * per-file lock (same shape as `editAttribute`). Returns the changed source.
4917
+ */
4918
+ export async function convertToAbsolute(
4919
+ canvasAbsPath: string,
4920
+ spec: Parameters<typeof applyConvertToAbsolute>[2]
4921
+ ): Promise<EditResult> {
4922
+ return withLock(canvasAbsPath, async () => {
4923
+ const file = Bun.file(canvasAbsPath);
4924
+ if (!(await file.exists())) {
4925
+ throw new CanvasEditError(`Canvas not found: ${canvasAbsPath}`, {
4926
+ canvas: canvasAbsPath,
4927
+ id: spec.containerId ?? 'artboard',
4928
+ });
4929
+ }
4930
+ const source = await file.text();
4931
+ const next = applyConvertToAbsolute(canvasAbsPath, source, spec);
4932
+ if (next.source === source) return { source, delta: 0, changed: false };
4933
+ const tmp = `${canvasAbsPath}.tmp.${Math.random().toString(36).slice(2, 10)}`;
4934
+ await Bun.write(tmp, next.source);
4935
+ const { rename } = await import('node:fs/promises');
4936
+ await rename(tmp, canvasAbsPath);
4937
+ return { ...next, changed: true };
4938
+ });
4939
+ }
4940
+
4941
+ /**
4942
+ * feature-4 detach-component (user steer 2026-07-19) — make ONE component
4943
+ * instance independently editable without inlining its JSX. Strategy: CLONE the
4944
+ * component's definition under a fresh name (`<C>Detached`, `<C>Detached2`, …)
4945
+ * and repoint THIS usage's tags at the clone. 100% behavior-preserving for ANY
4946
+ * component (props/children/expressions flow unchanged — no substitution
4947
+ * heuristics), and every subsequent edit lands on the clone's single-usage
4948
+ * definition, so `resolveEditScope` reports it LOCAL. This is the answer to
4949
+ * "moving an absolute child inside a shared component moved it in every
4950
+ * artboard" — detach first, then edit freely.
4951
+ */
4952
+ export function applyDetachComponent(
4953
+ canvasAbsPath: string,
4954
+ source: string,
4955
+ id: string,
4956
+ occurrence?: number
4957
+ ): { source: string; delta: number; detachedName: string } {
4958
+ const parsed = parseSync(canvasAbsPath, source, { sourceType: 'module' });
4959
+ if (parsed.errors && parsed.errors.length > 0) {
4960
+ const first = parsed.errors[0];
4961
+ throw new CanvasEditError(
4962
+ `oxc-parser failed on ${canvasAbsPath}: ${first?.message ?? 'unknown'}`,
4963
+ { canvas: canvasAbsPath, id }
4964
+ );
4965
+ }
4966
+ const usageId = resolveUsageId(parsed.program, id, occurrence ?? 0);
4967
+ if (usageId === id) {
4968
+ throw new CanvasEditError('detach: this element is not part of a reused component instance', {
4969
+ canvas: canvasAbsPath,
4970
+ id,
4971
+ });
4972
+ }
4973
+ const hit = findOpening(parsed.program, usageId);
4974
+ if (!hit) {
4975
+ throw new CanvasEditError(`detach: usage "${usageId}" not found`, {
4976
+ canvas: canvasAbsPath,
4977
+ id,
4978
+ });
4979
+ }
4980
+ const nameNode = hit.opening?.name;
4981
+ if (nameNode?.type !== 'JSXIdentifier' || typeof nameNode.name !== 'string') {
4982
+ throw new CanvasEditError('detach: usage tag is not a plain component identifier', {
4983
+ canvas: canvasAbsPath,
4984
+ id,
4985
+ });
4986
+ }
4987
+ const componentName: string = nameNode.name;
4988
+
4989
+ // Locate the component's top-level definition (function declaration or a
4990
+ // const initialized with a function/arrow), unwrapping an export wrapper.
4991
+ let defStart: number | null = null;
4992
+ let defEnd: number | null = null;
4993
+ let nameStart: number | null = null;
4994
+ let nameEnd: number | null = null;
4995
+ const body: AnyNode[] = Array.isArray(parsed.program?.body) ? parsed.program.body : [];
4996
+ for (const stmtRaw of body) {
4997
+ const stmt =
4998
+ stmtRaw?.type === 'ExportNamedDeclaration' || stmtRaw?.type === 'ExportDefaultDeclaration'
4999
+ ? (stmtRaw.declaration ?? stmtRaw)
5000
+ : stmtRaw;
5001
+ if (!stmt || typeof stmt !== 'object') continue;
5002
+ if (stmt.type === 'FunctionDeclaration' && stmt.id?.name === componentName) {
5003
+ defStart = stmt.start as number;
5004
+ defEnd = stmt.end as number;
5005
+ nameStart = stmt.id.start as number;
5006
+ nameEnd = stmt.id.end as number;
5007
+ break;
5008
+ }
5009
+ if (stmt.type === 'VariableDeclaration' && Array.isArray(stmt.declarations)) {
5010
+ const d = stmt.declarations.find(
5011
+ (dd: AnyNode) => dd?.id?.type === 'Identifier' && dd.id.name === componentName
5012
+ );
5013
+ if (
5014
+ d &&
5015
+ (d.init?.type === 'ArrowFunctionExpression' || d.init?.type === 'FunctionExpression')
5016
+ ) {
5017
+ defStart = stmt.start as number;
5018
+ defEnd = stmt.end as number;
5019
+ nameStart = d.id.start as number;
5020
+ nameEnd = d.id.end as number;
5021
+ break;
5022
+ }
5023
+ }
5024
+ }
5025
+ if (defStart === null || defEnd === null || nameStart === null || nameEnd === null) {
5026
+ throw new CanvasEditError(
5027
+ `detach: definition of <${componentName}> not found in this canvas (imported components can't be detached here)`,
5028
+ { canvas: canvasAbsPath, id }
5029
+ );
5030
+ }
5031
+
5032
+ // Fresh, collision-free clone name. A plain substring check is sufficient —
5033
+ // false positives only bump the counter.
5034
+ let detachedName = `${componentName}Detached`;
5035
+ let n = 2;
5036
+ while (source.includes(detachedName)) {
5037
+ detachedName = `${componentName}Detached${n}`;
5038
+ n += 1;
5039
+ }
5040
+
5041
+ const defText = source.slice(defStart, defEnd);
5042
+ const cloned =
5043
+ defText.slice(0, nameStart - defStart) + detachedName + defText.slice(nameEnd - defStart);
5044
+
5045
+ const s = new MagicString(source);
5046
+ s.appendRight(defEnd, `\n\n${cloned}`);
5047
+ // Repoint the usage's opening (and closing, when present) tag name.
5048
+ s.overwrite(nameNode.start as number, nameNode.end as number, detachedName);
5049
+ const closingName = hit.element?.closingElement?.name;
5050
+ if (closingName?.type === 'JSXIdentifier') {
5051
+ s.overwrite(closingName.start as number, closingName.end as number, detachedName);
5052
+ }
5053
+ const out = s.toString();
5054
+ return { source: out, delta: out.length - source.length, detachedName };
5055
+ }
5056
+
5057
+ /** Async wrapper for {@link applyDetachComponent} — read, apply, atomic write,
5058
+ * per-file lock (same shape as `editAttribute`). */
5059
+ export async function detachComponent(
5060
+ canvasAbsPath: string,
5061
+ id: string,
5062
+ occurrence?: number
5063
+ ): Promise<{ source: string; delta: number; changed: boolean; detachedName: string }> {
5064
+ return withLock(canvasAbsPath, async () => {
5065
+ const file = Bun.file(canvasAbsPath);
5066
+ if (!(await file.exists())) {
5067
+ throw new CanvasEditError(`Canvas not found: ${canvasAbsPath}`, {
5068
+ canvas: canvasAbsPath,
5069
+ id,
5070
+ });
5071
+ }
5072
+ const source = await file.text();
5073
+ const next = applyDetachComponent(canvasAbsPath, source, id, occurrence);
5074
+ if (next.source === source) {
5075
+ return { source, delta: 0, changed: false, detachedName: next.detachedName };
5076
+ }
5077
+ const tmp = `${canvasAbsPath}.tmp.${Math.random().toString(36).slice(2, 10)}`;
5078
+ await Bun.write(tmp, next.source);
5079
+ const { rename } = await import('node:fs/promises');
5080
+ await rename(tmp, canvasAbsPath);
5081
+ return { ...next, changed: true };
5082
+ });
5083
+ }
5084
+
4418
5085
  /**
4419
5086
  * Remove a single inline-style property (the "reset to original" path — DDR-104
4420
5087
  * Phase 12.3). No-op when the style attribute or the key is absent. When the key