@1agh/maude 0.45.2 → 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 (91) hide show
  1. package/apps/studio/acp/bootstrap-brief.ts +8 -0
  2. package/apps/studio/acp/bridge.ts +151 -1
  3. package/apps/studio/annotations-layer.tsx +42 -0
  4. package/apps/studio/annotations-snap.ts +50 -10
  5. package/apps/studio/api.ts +760 -15
  6. package/apps/studio/artboard-guides-overlay.tsx +270 -0
  7. package/apps/studio/bin/_agent-browser-safe-config.json +1 -0
  8. package/apps/studio/bin/_agent-browser-safe.mjs +228 -0
  9. package/apps/studio/bin/_agent-browser-safe.test.mjs +165 -0
  10. package/apps/studio/bin/_curl-local.mjs +349 -0
  11. package/apps/studio/bin/_curl-local.test.mjs +280 -0
  12. package/apps/studio/bin/_pdf-playwright.mjs +35 -4
  13. package/apps/studio/bin/_png-playwright.mjs +38 -4
  14. package/apps/studio/bin/_pw-launch.mjs +52 -0
  15. package/apps/studio/bin/_pw-launch.test.mjs +90 -0
  16. package/apps/studio/bin/_smart-frames.mjs +419 -0
  17. package/apps/studio/bin/_smart-frames.test.mjs +140 -0
  18. package/apps/studio/bin/agent-browser-safe.sh +29 -0
  19. package/apps/studio/bin/curl-local.sh +28 -0
  20. package/apps/studio/bin/smart-frames.sh +30 -0
  21. package/apps/studio/canvas-cursors.ts +6 -0
  22. package/apps/studio/canvas-edit.ts +972 -6
  23. package/apps/studio/canvas-icons.tsx +13 -0
  24. package/apps/studio/canvas-lib.tsx +206 -11
  25. package/apps/studio/canvas-shell.tsx +809 -50
  26. package/apps/studio/client/app.jsx +1554 -206
  27. package/apps/studio/client/panels/ChatPanel.jsx +92 -3
  28. package/apps/studio/client/panels/SettingsPanel.jsx +211 -0
  29. package/apps/studio/client/panels/acp-capabilities.js +11 -0
  30. package/apps/studio/client/styles/3-shell-maude.css +39 -0
  31. package/apps/studio/client/styles/6-acp-chat.css +41 -0
  32. package/apps/studio/contextual-toolbar.tsx +5 -3
  33. package/apps/studio/dist/client.bundle.js +1627 -1627
  34. package/apps/studio/dist/comment-mount.js +2 -2
  35. package/apps/studio/dist/styles.css +1 -1
  36. package/apps/studio/dom-selection.ts +20 -0
  37. package/apps/studio/export-dialog.tsx +138 -18
  38. package/apps/studio/exporters/pdf.ts +332 -18
  39. package/apps/studio/exporters/png.ts +45 -3
  40. package/apps/studio/footage/schema.ts +17 -1
  41. package/apps/studio/generation/gemma-models.ts +224 -0
  42. package/apps/studio/generation/prefs.ts +43 -0
  43. package/apps/studio/grid-track-handles.ts +179 -0
  44. package/apps/studio/handoff.ts +35 -0
  45. package/apps/studio/http.ts +331 -7
  46. package/apps/studio/input-router.tsx +73 -17
  47. package/apps/studio/print/marks.ts +113 -0
  48. package/apps/studio/print/units.ts +269 -0
  49. package/apps/studio/print-overlay-content.tsx +132 -0
  50. package/apps/studio/test/acp-mode-banner.test.ts +45 -0
  51. package/apps/studio/test/acp-session-allowed-tools.test.ts +187 -0
  52. package/apps/studio/test/annotations-snap.test.ts +56 -0
  53. package/apps/studio/test/artboard-guides-overlay.test.tsx +152 -0
  54. package/apps/studio/test/artboard-kinds.test.tsx +83 -0
  55. package/apps/studio/test/artboard-selection-attrs.test.ts +69 -0
  56. package/apps/studio/test/browse-posture.test.tsx +107 -0
  57. package/apps/studio/test/canvas-hide-chrome.test.ts +58 -0
  58. package/apps/studio/test/canvas-meta-api.test.ts +237 -0
  59. package/apps/studio/test/canvas-origin-gate.test.ts +4 -0
  60. package/apps/studio/test/comment-mount.test.ts +2 -1
  61. package/apps/studio/test/component-map.test.ts +48 -0
  62. package/apps/studio/test/convert-to-absolute.test.ts +333 -0
  63. package/apps/studio/test/detach-component.test.ts +94 -0
  64. package/apps/studio/test/edit-scope-api.test.ts +8 -4
  65. package/apps/studio/test/element-structural-api.test.ts +74 -0
  66. package/apps/studio/test/element-structural-edit.test.ts +363 -0
  67. package/apps/studio/test/exporters/png.test.ts +49 -1
  68. package/apps/studio/test/grid-track-handles.test.ts +160 -0
  69. package/apps/studio/test/handoff.test.ts +48 -0
  70. package/apps/studio/test/input-router.test.ts +82 -8
  71. package/apps/studio/test/layers-synthetic-groups.test.ts +96 -0
  72. package/apps/studio/test/pdf-print-boxes.test.ts +326 -0
  73. package/apps/studio/test/print-marks.test.ts +113 -0
  74. package/apps/studio/test/print-units.test.ts +173 -0
  75. package/apps/studio/test/use-snap-guides.test.ts +81 -0
  76. package/apps/studio/test/use-tool-mode.test.tsx +10 -2
  77. package/apps/studio/tool-palette.tsx +3 -1
  78. package/apps/studio/use-canvas-media-drop.tsx +126 -0
  79. package/apps/studio/use-chrome-visibility.tsx +19 -0
  80. package/apps/studio/use-element-resize.tsx +24 -3
  81. package/apps/studio/use-grid-track-handles.tsx +364 -0
  82. package/apps/studio/use-keyboard-discipline.tsx +15 -0
  83. package/apps/studio/use-snap-guides.tsx +73 -5
  84. package/apps/studio/use-spacing-handles.tsx +9 -5
  85. package/apps/studio/use-tool-mode.tsx +30 -3
  86. package/apps/studio/web-overlay-content.tsx +52 -0
  87. package/apps/studio/whats-new.json +55 -0
  88. package/cli/commands/design.mjs +25 -1
  89. package/package.json +8 -8
  90. package/plugins/design/dependencies.json +35 -0
  91. 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
  *
@@ -3598,12 +3664,16 @@ export async function setArtboardHug(
3598
3664
  }
3599
3665
 
3600
3666
  /** Patch shape for {@link applySetArtboardStyle} — `null` resets/removes that
3601
- * prop back to the engine default; `undefined`/absent leaves it untouched. */
3667
+ * prop back to the engine default; `undefined`/absent leaves it untouched.
3668
+ * padding/gap accept a `var(--token)` STRING alongside raw px numbers
3669
+ * (dogfood: the Inspector's artboard panel binds space tokens the same way
3670
+ * the CSS panel's ValueTokenField does) — validation of the string shape
3671
+ * lives at the api.ts layer, same split as every other artboard writer. */
3602
3672
  export interface ArtboardStylePatch {
3603
3673
  background?: string | null;
3604
- padding?: number | null;
3674
+ padding?: number | string | null;
3605
3675
  layout?: string | null;
3606
- gap?: number | null;
3676
+ gap?: number | string | null;
3607
3677
  }
3608
3678
 
3609
3679
  /**
@@ -3647,10 +3717,14 @@ export function applySetArtboardStyle(
3647
3717
  }
3648
3718
  if ('padding' in patch) {
3649
3719
  if (patch.padding == null) removeStringAttr(s, opening, 'padding', source);
3720
+ else if (typeof patch.padding === 'string')
3721
+ editStringAttr(s, opening, 'padding', patch.padding, canvasAbsPath, artboardId);
3650
3722
  else writeNumericAttr(s, opening, 'padding', patch.padding, 0);
3651
3723
  }
3652
3724
  if ('gap' in patch) {
3653
3725
  if (patch.gap == null) removeStringAttr(s, opening, 'gap', source);
3726
+ else if (typeof patch.gap === 'string')
3727
+ editStringAttr(s, opening, 'gap', patch.gap, canvasAbsPath, artboardId);
3654
3728
  else writeNumericAttr(s, opening, 'gap', patch.gap, 0);
3655
3729
  }
3656
3730
  const out = s.toString();
@@ -3689,6 +3763,308 @@ export async function setArtboardStyle(
3689
3763
  });
3690
3764
  }
3691
3765
 
3766
+ const ARTBOARD_KIND_VALUES = new Set(['digital', 'print', 'web', 'video']);
3767
+
3768
+ /**
3769
+ * Write/clear the DCArtboard `kind` prop (feature-1-artboard-kinds-foundation
3770
+ * T5/T8) — kind-switch surfaces (context menu, Inspector) route here. Plain
3771
+ * string prop, so this reuses the same `editStringAttr`/`removeStringAttr`
3772
+ * toolkit + id-prop addressing as `applySetArtboardStyle`. `kind: null` clears
3773
+ * back to the implicit default (`digital`, or the `subtreeHasVideoComp`
3774
+ * fallback). Pure; reparse-gated.
3775
+ */
3776
+ export function applySetArtboardKind(
3777
+ canvasAbsPath: string,
3778
+ source: string,
3779
+ artboardId: string,
3780
+ kind: string | null
3781
+ ): { source: string } {
3782
+ if (kind !== null && !ARTBOARD_KIND_VALUES.has(kind)) {
3783
+ throw new CanvasEditError(`invalid artboard kind "${kind}"`, {
3784
+ canvas: canvasAbsPath,
3785
+ id: artboardId,
3786
+ });
3787
+ }
3788
+ const parsed = parseSync(canvasAbsPath, source, { sourceType: 'module' });
3789
+ if (parsed.errors && parsed.errors.length > 0) {
3790
+ throw new CanvasEditError(
3791
+ `oxc-parser failed on ${canvasAbsPath}: ${parsed.errors[0]?.message ?? 'unknown'}`,
3792
+ { canvas: canvasAbsPath, id: artboardId }
3793
+ );
3794
+ }
3795
+ const artboards = collectJsxByTag(parsed.program, 'DCArtboard');
3796
+ const target = artboards.find((a) => getStringAttr(a.openingElement, 'id') === artboardId);
3797
+ if (!target) {
3798
+ throw new CanvasEditError(`<DCArtboard id="${artboardId}"> not found in ${canvasAbsPath}`, {
3799
+ canvas: canvasAbsPath,
3800
+ id: artboardId,
3801
+ });
3802
+ }
3803
+ const s = new MagicString(source);
3804
+ const opening = target.openingElement;
3805
+ if (kind === null) removeStringAttr(s, opening, 'kind', source);
3806
+ else editStringAttr(s, opening, 'kind', kind, canvasAbsPath, artboardId);
3807
+ const out = s.toString();
3808
+ const check = parseSync(canvasAbsPath, out, { sourceType: 'module' });
3809
+ if (check.errors && check.errors.length > 0) {
3810
+ throw new CanvasEditError(
3811
+ `artboard kind edit produced invalid source (${check.errors[0]?.message ?? 'parse error'})`,
3812
+ { canvas: canvasAbsPath, id: artboardId }
3813
+ );
3814
+ }
3815
+ return { source: out };
3816
+ }
3817
+
3818
+ /** Set an artboard's `kind` on disk (atomic write + cross-process lock). */
3819
+ export async function setArtboardKind(
3820
+ canvasAbsPath: string,
3821
+ artboardId: string,
3822
+ kind: string | null
3823
+ ): Promise<{ source: string }> {
3824
+ return withLock(canvasAbsPath, async () => {
3825
+ const file = Bun.file(canvasAbsPath);
3826
+ if (!(await file.exists())) {
3827
+ throw new CanvasEditError(`Canvas not found: ${canvasAbsPath}`, {
3828
+ canvas: canvasAbsPath,
3829
+ id: artboardId,
3830
+ });
3831
+ }
3832
+ const source = await file.text();
3833
+ const next = applySetArtboardKind(canvasAbsPath, source, artboardId, kind);
3834
+ if (next.source === source) return next;
3835
+ const tmp = `${canvasAbsPath}.tmp.${Math.random().toString(36).slice(2, 10)}`;
3836
+ await Bun.write(tmp, next.source);
3837
+ const { rename } = await import('node:fs/promises');
3838
+ await rename(tmp, canvasAbsPath);
3839
+ return next;
3840
+ });
3841
+ }
3842
+
3843
+ /**
3844
+ * Write/clear an object-VALUED DCArtboard prop (`guides={{ columns: {...} }}`,
3845
+ * `print={{ paper: 'a4' }}`), unlike every other artboard writer above.
3846
+ * Per the plan's own gotcha, object-prop editing via AST is the hard part;
3847
+ * this scopes to REPLACE-WHOLE-PROP (stringify the given object and overwrite
3848
+ * the entire `{{...}}` span), not a deep merge — a caller that wants to keep
3849
+ * one key while adding another must read the current value back (Inspector
3850
+ * pre-fill reads `data-dc-*`-style attrs same as every other artboard knob)
3851
+ * and send the full merged object. `value: null` removes the prop entirely.
3852
+ * `JSON.stringify` output is valid as a JS object-literal expression (quoted
3853
+ * keys are legal JS, not just JSON), so no separate JS-literal serializer is
3854
+ * needed. Shape validation lives at the api.ts layer; this is pure AST
3855
+ * surgery. Pure; reparse-gated. Shared by `applySetArtboardGuides` and
3856
+ * `applySetArtboardPrint` (feature-2-print-artboards T2) below.
3857
+ */
3858
+ function applySetArtboardObjectProp(
3859
+ canvasAbsPath: string,
3860
+ source: string,
3861
+ artboardId: string,
3862
+ propName: string,
3863
+ value: Record<string, unknown> | null
3864
+ ): { source: string } {
3865
+ const parsed = parseSync(canvasAbsPath, source, { sourceType: 'module' });
3866
+ if (parsed.errors && parsed.errors.length > 0) {
3867
+ throw new CanvasEditError(
3868
+ `oxc-parser failed on ${canvasAbsPath}: ${parsed.errors[0]?.message ?? 'unknown'}`,
3869
+ { canvas: canvasAbsPath, id: artboardId }
3870
+ );
3871
+ }
3872
+ const artboards = collectJsxByTag(parsed.program, 'DCArtboard');
3873
+ const target = artboards.find((a) => getStringAttr(a.openingElement, 'id') === artboardId);
3874
+ if (!target) {
3875
+ throw new CanvasEditError(`<DCArtboard id="${artboardId}"> not found in ${canvasAbsPath}`, {
3876
+ canvas: canvasAbsPath,
3877
+ id: artboardId,
3878
+ });
3879
+ }
3880
+ const s = new MagicString(source);
3881
+ const opening = target.openingElement;
3882
+ if (value === null) {
3883
+ removeStringAttr(s, opening, propName, source);
3884
+ } else {
3885
+ const literal = JSON.stringify(value);
3886
+ const attr = findAttribute(opening, propName);
3887
+ if (!attr) {
3888
+ const insertAt: number | undefined = opening?.name?.end;
3889
+ if (typeof insertAt !== 'number') {
3890
+ throw new Error('Opening element has no resolvable name range');
3891
+ }
3892
+ s.appendLeft(insertAt, ` ${propName}={${literal}}`);
3893
+ } else if (attr.value?.type === 'JSXExpressionContainer') {
3894
+ s.overwrite(attr.value.start, attr.value.end, `{${literal}}`);
3895
+ } else {
3896
+ throw new CanvasEditError(
3897
+ `${propName} attribute is not a {{...}} expression — refusing to edit`,
3898
+ {
3899
+ canvas: canvasAbsPath,
3900
+ id: artboardId,
3901
+ }
3902
+ );
3903
+ }
3904
+ }
3905
+ const out = s.toString();
3906
+ const check = parseSync(canvasAbsPath, out, { sourceType: 'module' });
3907
+ if (check.errors && check.errors.length > 0) {
3908
+ throw new CanvasEditError(
3909
+ `artboard ${propName} edit produced invalid source (${check.errors[0]?.message ?? 'parse error'})`,
3910
+ { canvas: canvasAbsPath, id: artboardId }
3911
+ );
3912
+ }
3913
+ return { source: out };
3914
+ }
3915
+
3916
+ /** Set an artboard's object-valued prop on disk (atomic write + cross-process lock). */
3917
+ async function setArtboardObjectProp(
3918
+ canvasAbsPath: string,
3919
+ artboardId: string,
3920
+ propName: string,
3921
+ value: Record<string, unknown> | null
3922
+ ): Promise<{ source: string }> {
3923
+ return withLock(canvasAbsPath, async () => {
3924
+ const file = Bun.file(canvasAbsPath);
3925
+ if (!(await file.exists())) {
3926
+ throw new CanvasEditError(`Canvas not found: ${canvasAbsPath}`, {
3927
+ canvas: canvasAbsPath,
3928
+ id: artboardId,
3929
+ });
3930
+ }
3931
+ const source = await file.text();
3932
+ const next = applySetArtboardObjectProp(canvasAbsPath, source, artboardId, propName, value);
3933
+ if (next.source === source) return next;
3934
+ const tmp = `${canvasAbsPath}.tmp.${Math.random().toString(36).slice(2, 10)}`;
3935
+ await Bun.write(tmp, next.source);
3936
+ const { rename } = await import('node:fs/promises');
3937
+ await rename(tmp, canvasAbsPath);
3938
+ return next;
3939
+ });
3940
+ }
3941
+
3942
+ export function applySetArtboardGuides(
3943
+ canvasAbsPath: string,
3944
+ source: string,
3945
+ artboardId: string,
3946
+ guides: Record<string, unknown> | null
3947
+ ): { source: string } {
3948
+ return applySetArtboardObjectProp(canvasAbsPath, source, artboardId, 'guides', guides);
3949
+ }
3950
+
3951
+ /** Set an artboard's `guides` on disk (atomic write + cross-process lock). */
3952
+ export function setArtboardGuides(
3953
+ canvasAbsPath: string,
3954
+ artboardId: string,
3955
+ guides: Record<string, unknown> | null
3956
+ ): Promise<{ source: string }> {
3957
+ return setArtboardObjectProp(canvasAbsPath, artboardId, 'guides', guides);
3958
+ }
3959
+
3960
+ /**
3961
+ * Write/clear the DCArtboard `print` prop (feature-2-print-artboards T2) —
3962
+ * see `applySetArtboardObjectProp`'s doc comment for the shared shape.
3963
+ * `print: null` removes the prop entirely (e.g. reverting an artboard to a
3964
+ * non-print kind).
3965
+ */
3966
+ export function applySetArtboardPrint(
3967
+ canvasAbsPath: string,
3968
+ source: string,
3969
+ artboardId: string,
3970
+ print: Record<string, unknown> | null
3971
+ ): { source: string } {
3972
+ return applySetArtboardObjectProp(canvasAbsPath, source, artboardId, 'print', print);
3973
+ }
3974
+
3975
+ /** Set an artboard's `print` on disk (atomic write + cross-process lock). */
3976
+ export function setArtboardPrint(
3977
+ canvasAbsPath: string,
3978
+ artboardId: string,
3979
+ print: Record<string, unknown> | null
3980
+ ): Promise<{ source: string }> {
3981
+ return setArtboardObjectProp(canvasAbsPath, artboardId, 'print', print);
3982
+ }
3983
+
3984
+ /**
3985
+ * Read a JS object-literal AST node into a plain object WITHOUT eval —
3986
+ * `print` (like every other canvas-authored JSX prop) is part of the
3987
+ * DDR-054 untrusted-canvas surface, so evaluating its source text
3988
+ * (`Function('return ' + src)()`) would be a code-injection hole. Handles
3989
+ * string/numeric/boolean/null literals and nested object literals; any other
3990
+ * value shape (identifiers, calls, template strings, arrays) is silently
3991
+ * skipped — conservative by design, since a print-geometry reader should
3992
+ * degrade to "field absent" rather than guess. Depth-capped defensively,
3993
+ * though `print`'s own shape never nests past marginsMm (depth 2).
3994
+ */
3995
+ function objectExpressionToPlain(node: AnyNode, depth = 0): Record<string, unknown> {
3996
+ const out: Record<string, unknown> = {};
3997
+ if (depth > 6 || node?.type !== 'ObjectExpression') return out;
3998
+ for (const p of node.properties ?? []) {
3999
+ if ((p?.type !== 'Property' && p?.type !== 'ObjectProperty') || p.computed) continue;
4000
+ const key =
4001
+ p.key?.type === 'Identifier'
4002
+ ? (p.key.name as string)
4003
+ : isStringLit(p.key)
4004
+ ? String(p.key.value)
4005
+ : null;
4006
+ if (key === null) continue;
4007
+ const v = p.value;
4008
+ if (!v) continue;
4009
+ if (v.type === 'ObjectExpression') {
4010
+ out[key] = objectExpressionToPlain(v, depth + 1);
4011
+ } else if (
4012
+ (v.type === 'Literal' ||
4013
+ v.type === 'StringLiteral' ||
4014
+ v.type === 'NumericLiteral' ||
4015
+ v.type === 'BooleanLiteral') &&
4016
+ (typeof v.value === 'string' ||
4017
+ typeof v.value === 'number' ||
4018
+ typeof v.value === 'boolean' ||
4019
+ v.value === null)
4020
+ ) {
4021
+ out[key] = v.value;
4022
+ }
4023
+ // else: unsupported value shape — key silently omitted.
4024
+ }
4025
+ return out;
4026
+ }
4027
+
4028
+ /**
4029
+ * Read an artboard's `print` JSX prop as a plain object — used by the PDF
4030
+ * exporter (T5) to resolve bleed/paper geometry for the artboard being
4031
+ * exported. Read-only (no lock, no write). Returns null when the canvas/
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).
4047
+ */
4048
+ export function readArtboardPrintProp(
4049
+ canvasAbsPath: string,
4050
+ source: string,
4051
+ artboardId: string
4052
+ ): Record<string, unknown> | null {
4053
+ const parsed = parseSync(canvasAbsPath, source, { sourceType: 'module' });
4054
+ if (parsed.errors && parsed.errors.length > 0) return null;
4055
+ const artboards = collectJsxByTag(parsed.program, 'DCArtboard');
4056
+ const target = artboards.find((a) => getStringAttr(a.openingElement, 'id') === artboardId);
4057
+ if (!target) return null;
4058
+ if (getStringAttr(target.openingElement, 'kind') !== 'print') return null;
4059
+ const attr = findAttribute(target.openingElement, 'print');
4060
+ if (attr?.value?.type !== 'JSXExpressionContainer') return null;
4061
+ const expr = attr.value.expression as AnyNode | undefined;
4062
+ if (!expr) return null;
4063
+ const resolved = resolveValueNode(parsed.program, expr);
4064
+ if (resolved?.type !== 'ObjectExpression') return null;
4065
+ return objectExpressionToPlain(resolved);
4066
+ }
4067
+
3692
4068
  /**
3693
4069
  * Delete the `<DCArtboard id="…">` whose `id` prop equals `artboardId` — the
3694
4070
  * artboard counterpart of applyDeleteElement (an artboard is addressed by its id
@@ -3849,6 +4225,148 @@ export async function insertArtboard(
3849
4225
  });
3850
4226
  }
3851
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
+
3852
4370
  /** A clip in an assemble request — a dropped reference chip's src + kind. */
3853
4371
  export interface AssembleClip {
3854
4372
  src: string;
@@ -4116,6 +4634,454 @@ function editStyleProp(
4116
4634
  s.appendLeft((obj.end as number) - 1, `${sep}${jsKey(prop)}: ${value} `);
4117
4635
  }
4118
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
+
4119
5085
  /**
4120
5086
  * Remove a single inline-style property (the "reset to original" path — DDR-104
4121
5087
  * Phase 12.3). No-op when the style attribute or the key is absent. When the key