@agent-native/core 0.80.9 → 0.80.10

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 (111) hide show
  1. package/corpus/README.md +1 -1
  2. package/corpus/core/CHANGELOG.md +7 -0
  3. package/corpus/core/package.json +1 -1
  4. package/corpus/core/src/cli/design-connect.ts +28 -2
  5. package/corpus/core/src/cli/skills.ts +45 -24
  6. package/corpus/templates/design/DESIGN-STUDIO-PLAN.md +717 -0
  7. package/corpus/templates/design/actions/add-breakpoint.ts +143 -0
  8. package/corpus/templates/design/actions/add-localhost-screens.ts +5 -0
  9. package/corpus/templates/design/actions/apply-a11y-fix.ts +317 -0
  10. package/corpus/templates/design/actions/apply-component-prop-edit.ts +433 -0
  11. package/corpus/templates/design/actions/apply-design-state.ts +200 -0
  12. package/corpus/templates/design/actions/apply-design-token-edit.ts +202 -0
  13. package/corpus/templates/design/actions/apply-motion-edit.ts +450 -0
  14. package/corpus/templates/design/actions/apply-shader-fill.ts +404 -0
  15. package/corpus/templates/design/actions/apply-visual-edit.ts +191 -5
  16. package/corpus/templates/design/actions/capture-design-state.ts +224 -0
  17. package/corpus/templates/design/actions/connect-builder-app.ts +162 -0
  18. package/corpus/templates/design/actions/create-component.ts +447 -0
  19. package/corpus/templates/design/actions/create-design-branch.ts +263 -0
  20. package/corpus/templates/design/actions/create-design-state.ts +162 -0
  21. package/corpus/templates/design/actions/delete-design-state.ts +55 -0
  22. package/corpus/templates/design/actions/deploy-design-preview.ts +275 -0
  23. package/corpus/templates/design/actions/get-component-details.ts +242 -0
  24. package/corpus/templates/design/actions/get-design-branch-diff.ts +362 -0
  25. package/corpus/templates/design/actions/get-design-review.ts +314 -0
  26. package/corpus/templates/design/actions/get-design-surface-index.ts +582 -0
  27. package/corpus/templates/design/actions/get-motion-timeline.ts +99 -0
  28. package/corpus/templates/design/actions/index-components.ts +258 -0
  29. package/corpus/templates/design/actions/index-design-tokens.ts +273 -0
  30. package/corpus/templates/design/actions/list-design-extensions.ts +309 -0
  31. package/corpus/templates/design/actions/list-design-source-capabilities.ts +153 -0
  32. package/corpus/templates/design/actions/list-design-states.ts +72 -0
  33. package/corpus/templates/design/actions/migrate-inline-design-to-app.ts +298 -0
  34. package/corpus/templates/design/actions/open-component-source.ts +237 -0
  35. package/corpus/templates/design/actions/preview-component-prop-edit.ts +254 -0
  36. package/corpus/templates/design/actions/preview-design-token-edit.ts +113 -0
  37. package/corpus/templates/design/actions/preview-shader-fill.ts +189 -0
  38. package/corpus/templates/design/actions/remove-breakpoint.ts +103 -0
  39. package/corpus/templates/design/actions/remove-motion-timeline.ts +196 -0
  40. package/corpus/templates/design/actions/run-design-audit.ts +421 -0
  41. package/corpus/templates/design/actions/run-design-extension-action.ts +284 -0
  42. package/corpus/templates/design/actions/set-active-breakpoint.ts +39 -0
  43. package/corpus/templates/design/app/components/design/DesignCanvas.tsx +1414 -71
  44. package/corpus/templates/design/app/components/design/DesignExtensionsPanel.tsx +632 -43
  45. package/corpus/templates/design/app/components/design/DrawOverlay.tsx +1 -1
  46. package/corpus/templates/design/app/components/design/EditPanel.tsx +1391 -52
  47. package/corpus/templates/design/app/components/design/LayersPanel.tsx +4 -3
  48. package/corpus/templates/design/app/components/design/LocalSourceEditBanner.tsx +157 -0
  49. package/corpus/templates/design/app/components/design/MotionDock.tsx +1062 -0
  50. package/corpus/templates/design/app/components/design/MultiScreenCanvas.tsx +1052 -98
  51. package/corpus/templates/design/app/components/design/ReviewPanel.tsx +759 -0
  52. package/corpus/templates/design/app/components/design/StatesPanel.tsx +579 -0
  53. package/corpus/templates/design/app/components/design/TokensPanel.tsx +573 -0
  54. package/corpus/templates/design/app/components/design/canvas-primitive-style.ts +241 -0
  55. package/corpus/templates/design/app/components/design/index.ts +16 -0
  56. package/corpus/templates/design/app/components/design/inspector/AutoLayoutMatrix.tsx +15 -6
  57. package/corpus/templates/design/app/components/design/inspector/DesignColorPicker.tsx +66 -3
  58. package/corpus/templates/design/app/components/design/inspector/InspectorAiActions.tsx +145 -0
  59. package/corpus/templates/design/app/components/design/inspector/SHADER_INTEGRATION.md +42 -12
  60. package/corpus/templates/design/app/components/visual-editor/DrawOverlay.tsx +1 -1
  61. package/corpus/templates/design/app/hooks/useAgentEditRequest.ts +131 -0
  62. package/corpus/templates/design/app/hooks/useDesignHotkeys.ts +3 -1
  63. package/corpus/templates/design/app/i18n/zh-TW.ts +31 -0
  64. package/corpus/templates/design/app/i18n-data.ts +269 -0
  65. package/corpus/templates/design/app/pages/DesignEditor.tsx +2478 -386
  66. package/corpus/templates/design/changelog/2026-06-29-added-a-review-panel-in-the-design-editor-s-inspector-with-a.md +6 -0
  67. package/corpus/templates/design/changelog/2026-06-29-copying-and-pasting-layers-no-longer-shows-success-notificat.md +6 -0
  68. package/corpus/templates/design/changelog/2026-06-29-device-presets-in-all-screens-view-resize-the-selected-previ.md +6 -0
  69. package/corpus/templates/design/changelog/2026-06-29-layer-and-canvas-drags-keep-the-layer-list-stable-while-chan.md +6 -0
  70. package/corpus/templates/design/changelog/2026-06-29-layer-moves-can-be-undone-and-redone-without-flashing-the-ca.md +6 -0
  71. package/corpus/templates/design/changelog/2026-06-29-screen-previews-use-a-single-blue-hover-border-in-all-screen.md +6 -0
  72. package/corpus/templates/design/changelog/2026-06-29-selected-containers-show-draggable-padding-and-gap-guides-on.md +6 -0
  73. package/corpus/templates/design/changelog/2026-06-29-the-design-editor-adds-a-studio-layer-with-tokens-respon.md +6 -0
  74. package/corpus/templates/design/changelog/2026-06-30-accessibility-findings-now-offer-a-one-click-fix.md +6 -0
  75. package/corpus/templates/design/changelog/2026-06-30-component-instances-now-have-editable-props-in-the-inspe.md +6 -0
  76. package/corpus/templates/design/changelog/2026-06-30-inspect-code-now-shows-the-elements-opening-tag-at-a-gla.md +6 -0
  77. package/corpus/templates/design/changelog/2026-06-30-shader-fill-presets-can-now-be-applied-to-an-element.md +6 -0
  78. package/corpus/templates/design/changelog/2026-06-30-the-motion-timeline-can-now-add-a-track-to-any-element-a.md +6 -0
  79. package/corpus/templates/design/changelog/2026-06-30-visual-editor-selection-layer-paint-and-drawing-controls-are-more-reliable.md +6 -0
  80. package/corpus/templates/design/e2e/helpers.ts +10 -4
  81. package/corpus/templates/design/server/db/schema.ts +123 -0
  82. package/corpus/templates/design/server/plugins/db.ts +90 -0
  83. package/corpus/templates/design/shared/builder-app.ts +297 -0
  84. package/corpus/templates/design/shared/capability-resolver.ts +123 -0
  85. package/corpus/templates/design/shared/capture-sanitize.ts +70 -0
  86. package/corpus/templates/design/shared/code-layer.ts +1016 -71
  87. package/corpus/templates/design/shared/component-model.ts +239 -0
  88. package/corpus/templates/design/shared/design-review.ts +275 -0
  89. package/corpus/templates/design/shared/design-source-capabilities.ts +286 -0
  90. package/corpus/templates/design/shared/design-state.ts +112 -0
  91. package/corpus/templates/design/shared/design-surface-index.ts +258 -0
  92. package/corpus/templates/design/shared/motion-compiler.ts +278 -0
  93. package/corpus/templates/design/shared/motion-timeline.ts +193 -0
  94. package/corpus/templates/design/shared/responsive-classes.ts +452 -0
  95. package/corpus/templates/design/shared/shader-fill.ts +323 -0
  96. package/corpus/templates/design/shared/source-mode.ts +245 -0
  97. package/corpus/templates/plan/app/components/plan/CanvasArea.tsx +6 -2
  98. package/corpus/templates/plan/changelog/2026-06-29-plan-canvases-open-without-an-initial-pan-and-zoom-flicker.md +6 -0
  99. package/dist/cli/design-connect.d.ts.map +1 -1
  100. package/dist/cli/design-connect.js +28 -2
  101. package/dist/cli/design-connect.js.map +1 -1
  102. package/dist/cli/skills.d.ts.map +1 -1
  103. package/dist/cli/skills.js +37 -21
  104. package/dist/cli/skills.js.map +1 -1
  105. package/dist/collab/routes.d.ts +1 -1
  106. package/dist/file-upload/actions/upload-image.d.ts +2 -2
  107. package/dist/notifications/routes.d.ts +2 -2
  108. package/dist/observability/routes.d.ts +7 -7
  109. package/dist/resources/handlers.d.ts +2 -2
  110. package/dist/server/transcribe-voice.d.ts +1 -1
  111. package/package.json +1 -1
@@ -1,3 +1,15 @@
1
+ import {
2
+ isComponentInstance,
3
+ instanceFromNode,
4
+ type ComponentInstance,
5
+ } from "./component-model";
6
+ import type { TailwindBreakpointPrefix } from "./design-state.js";
7
+ import {
8
+ parseClassGroups,
9
+ parseClassToken,
10
+ setPropertyClass,
11
+ removePropertyClass,
12
+ } from "./responsive-classes.js";
1
13
  import type { DesignSourceType } from "./source-mode";
2
14
 
3
15
  export type CodeLayerSourceKind =
@@ -137,10 +149,32 @@ export type VisualStyleProperty =
137
149
 
138
150
  export interface StyleToken {
139
151
  property: VisualStyleProperty;
152
+ /**
153
+ * The resolved value at the *base* breakpoint (unprefixed), or the inline
154
+ * style value. For class-sourced tokens this is the utility string of the
155
+ * base class (e.g. `"text-sm"`). Use `breakpointValues` to inspect how the
156
+ * value differs across responsive prefixes.
157
+ */
140
158
  value: string;
141
159
  token: string;
142
160
  source: "inline-style" | "class";
143
161
  confidence: number;
162
+ /**
163
+ * For class-sourced tokens only: the resolved utility string per responsive
164
+ * prefix. Only prefixes that have an explicit class token are included.
165
+ *
166
+ * @example
167
+ * // className="text-sm md:text-base lg:text-lg"
168
+ * // styleToken for "color" property would have:
169
+ * // breakpointValues = { base: "text-sm", md: "text-base", lg: "text-lg" }
170
+ */
171
+ breakpointValues?: Partial<Record<TailwindBreakpointPrefix, string>>;
172
+ /**
173
+ * For class-sourced tokens only: the prefixes (other than `"base"`) at which
174
+ * this property has a responsive override in the class list. Populated when
175
+ * `breakpointValues` has keys other than `"base"`.
176
+ */
177
+ overriddenAtPrefixes?: TailwindBreakpointPrefix[];
144
178
  }
145
179
 
146
180
  export interface LayoutContext {
@@ -177,6 +211,28 @@ export type EditCapability =
177
211
  confidence: number;
178
212
  reason?: string;
179
213
  }
214
+ | {
215
+ /**
216
+ * Responsive-class editing — adds, replaces, or removes a Tailwind
217
+ * utility at a specific breakpoint prefix without touching other
218
+ * breakpoints. Uses the helpers in `responsive-classes.ts`
219
+ * (setPropertyClass / removePropertyClass) and the same deterministic
220
+ * HTML-patch path as `class` edits.
221
+ *
222
+ * The `prefix` field indicates the active breakpoint scope for which
223
+ * this capability was computed (derived from the canvas frame width via
224
+ * `widthToPrefix`). Callers may target any prefix — `prefix` is
225
+ * informational, not a constraint.
226
+ */
227
+ kind: "responsive-class";
228
+ /** Active breakpoint scope (informational). */
229
+ prefix: TailwindBreakpointPrefix;
230
+ operations: Array<"add" | "remove" | "replace">;
231
+ /** Properties that currently have per-breakpoint overrides (non-empty means overrides exist). */
232
+ overriddenProperties: string[];
233
+ confidence: number;
234
+ reason?: string;
235
+ }
180
236
  | {
181
237
  kind: "text";
182
238
  operations: Array<"setTextContent">;
@@ -211,6 +267,15 @@ export interface CodeLayerNode {
211
267
  capabilities: EditCapability[];
212
268
  confidence: number;
213
269
  source: CodeLayerSourceSpan | null;
270
+ /**
271
+ * Present when the node is the root of a component instance — i.e. it
272
+ * carries a `data-agent-native-component` attribute (Alpine-annotated or
273
+ * build-time-instrumented). The canvas uses this to draw the component
274
+ * outline and the inspector uses it to surface component-level controls.
275
+ *
276
+ * `undefined` when the node is not a component root.
277
+ */
278
+ componentInstance?: ComponentInstance;
214
279
  }
215
280
 
216
281
  export interface ProjectionDiagnostic {
@@ -324,11 +389,91 @@ export interface MoveNodeEditIntent {
324
389
  placement: "before" | "after" | "inside";
325
390
  }
326
391
 
392
+ /**
393
+ * GROUP: wrap sibling nodes sharing a parent inside a new <div> wrapper.
394
+ * The wrapper is inserted at the position of the first target; targets are
395
+ * reparented into it in source order. The new wrapper gets a fresh
396
+ * data-agent-native-node-id and data-agent-native-layer-name="Group".
397
+ *
398
+ * When autoLayout is true the wrapper also receives
399
+ * `display:flex; flex-direction:column; gap:8px` and
400
+ * position/left/top/right/bottom are stripped from each wrapped child.
401
+ *
402
+ * Returns "unsupported" if the targets don't share a common parent.
403
+ */
404
+ export interface WrapNodesEditIntent {
405
+ kind: "wrapNodes";
406
+ targetIds: string[];
407
+ autoLayout?: boolean;
408
+ }
409
+
410
+ /**
411
+ * UNGROUP: replace the wrapper node with its children, spliced into the
412
+ * wrapper's parent at the wrapper's position, then remove the wrapper.
413
+ */
414
+ export interface UnwrapEditIntent {
415
+ kind: "unwrap";
416
+ targetId: string;
417
+ }
418
+
419
+ /**
420
+ * CONVERT an existing container to/from auto-layout.
421
+ * When enabled, sets display:flex (+ flex-direction, gap) on the target and
422
+ * strips position:absolute/left/top/right/bottom from its DIRECT children.
423
+ * When !enabled, sets display:block (turns auto-layout off).
424
+ */
425
+ export interface AutoLayoutEditIntent {
426
+ kind: "autoLayout";
427
+ targetId: string;
428
+ enabled: boolean;
429
+ direction?: "row" | "column";
430
+ gap?: string;
431
+ }
432
+
433
+ /**
434
+ * Responsive-class edit intent — adds, replaces, or removes a single Tailwind
435
+ * utility at the given `prefix` (breakpoint scope) without touching classes at
436
+ * other breakpoints.
437
+ *
438
+ * Examples:
439
+ * - Add `text-base` at `md:` on a node that already has `text-sm` base:
440
+ * `{ kind: "responsive-class", target, prefix: "md", operation: "add", utility: "text-base" }`
441
+ *
442
+ * - Replace whatever `text-*` class currently lives at `lg:` with `text-xl`:
443
+ * `{ kind: "responsive-class", target, prefix: "lg", operation: "replace", utility: "text-xl" }`
444
+ *
445
+ * - Remove the `md:` override for the `text` stem, falling back to the base:
446
+ * `{ kind: "responsive-class", target, prefix: "md", operation: "remove", stem: "text" }`
447
+ *
448
+ * `utility` should be the bare utility without its prefix (e.g. `"text-lg"`,
449
+ * not `"md:text-lg"`). The prefix is applied automatically.
450
+ * `stem` is required only for `"remove"` operations.
451
+ */
452
+ export interface ResponsiveClassEditIntent {
453
+ kind: "responsive-class";
454
+ target: EditIntentTarget;
455
+ /** Target breakpoint prefix. Use `"base"` to edit the unprefixed class. */
456
+ prefix: TailwindBreakpointPrefix;
457
+ operation: "add" | "remove" | "replace";
458
+ /** The bare utility to add or replace (without prefix). Required for add/replace. */
459
+ utility?: string;
460
+ /**
461
+ * The CSS-property stem to remove (e.g. `"text"`, `"bg"`, `"p"`).
462
+ * Required for `"remove"` operations; ignored for add/replace (the stem is
463
+ * derived from `utility` instead).
464
+ */
465
+ stem?: string;
466
+ }
467
+
327
468
  export type EditIntent =
328
469
  | StyleEditIntent
329
470
  | ClassEditIntent
330
471
  | TextEditIntent
331
- | MoveNodeEditIntent;
472
+ | MoveNodeEditIntent
473
+ | WrapNodesEditIntent
474
+ | UnwrapEditIntent
475
+ | AutoLayoutEditIntent
476
+ | ResponsiveClassEditIntent;
332
477
 
333
478
  export interface EditIntentResolution {
334
479
  status: "resolved" | "conflict" | "unsupported";
@@ -372,6 +517,8 @@ export interface PatchResult {
372
517
  after?: PatchNodeSummary;
373
518
  changed: boolean;
374
519
  message?: string;
520
+ /** For wrapNodes: the data-agent-native-node-id of the newly created wrapper. */
521
+ wrapperNodeId?: string;
375
522
  }
376
523
 
377
524
  export interface ApplyVisualEditResult {
@@ -1234,6 +1381,8 @@ function stableSourceIdForElement(element: ParsedElement): string | null {
1234
1381
 
1235
1382
  function styleTokensFor(element: ParsedElement): StyleToken[] {
1236
1383
  const tokens: StyleToken[] = [];
1384
+
1385
+ // --- Inline styles (no breakpoint concept) ---
1237
1386
  for (const declaration of parseStyleDeclarations(
1238
1387
  attributeValue(element, "style"),
1239
1388
  )) {
@@ -1248,61 +1397,104 @@ function styleTokensFor(element: ParsedElement): StyleToken[] {
1248
1397
  });
1249
1398
  }
1250
1399
 
1251
- for (const token of classList(element)) {
1252
- const classStyle = classStyleToken(token);
1253
- if (classStyle) tokens.push(classStyle);
1254
- }
1400
+ // --- Class tokens responsive-aware ---
1401
+ // Group all class tokens by breakpoint prefix so we can build per-property
1402
+ // breakpointValues maps and detect overrides.
1403
+ const classValue = attributeValue(element, "class") ?? "";
1404
+ const groups = parseClassGroups(classValue);
1255
1405
 
1256
- return tokens;
1257
- }
1406
+ // For each property, collect the utility value at every prefix that has one.
1407
+ // We key by property so we emit one token per property, not one per class.
1408
+ const propertyMap = new Map<
1409
+ VisualStyleProperty,
1410
+ {
1411
+ property: VisualStyleProperty;
1412
+ confidence: number;
1413
+ breakpointValues: Partial<Record<TailwindBreakpointPrefix, string>>;
1414
+ /** The raw token for the base occurrence (for backward-compat `token` field). */
1415
+ baseToken: string;
1416
+ }
1417
+ >();
1418
+
1419
+ const allPrefixes: ReadonlyArray<TailwindBreakpointPrefix> = [
1420
+ "base",
1421
+ "sm",
1422
+ "md",
1423
+ "lg",
1424
+ "xl",
1425
+ "2xl",
1426
+ ];
1258
1427
 
1259
- function classStyleToken(token: string): StyleToken | null {
1260
- const normalized = token.replace(/^[a-z]+:/, "");
1261
- if (/^w-/.test(normalized)) {
1262
- return {
1263
- property: "width",
1264
- value: token,
1265
- token,
1266
- source: "class",
1267
- confidence: 0.64,
1268
- };
1269
- }
1270
- if (/^h-/.test(normalized)) {
1271
- return {
1272
- property: "height",
1273
- value: token,
1274
- token,
1275
- source: "class",
1276
- confidence: 0.64,
1277
- };
1278
- }
1279
- if (/^bg-/.test(normalized)) {
1280
- return {
1281
- property: "background",
1282
- value: token,
1283
- token,
1284
- source: "class",
1285
- confidence: 0.6,
1286
- };
1287
- }
1288
- if (/^(p|px|py|pt|pr|pb|pl)-/.test(normalized)) {
1289
- return {
1290
- property: "padding",
1291
- value: token,
1292
- token,
1293
- source: "class",
1294
- confidence: 0.62,
1295
- };
1428
+ for (const prefix of allPrefixes) {
1429
+ for (const rawToken of groups[prefix]) {
1430
+ const parsed = parseClassToken(rawToken);
1431
+ const mapped = utilityToStyleProperty(parsed.utility);
1432
+ if (!mapped) continue;
1433
+ const { property, confidence } = mapped;
1434
+
1435
+ // Resolve the display value the same way classStyleToken does.
1436
+ const resolvedValue =
1437
+ property === "display"
1438
+ ? parsed.utility === "hidden"
1439
+ ? "none"
1440
+ : parsed.utility
1441
+ : parsed.utility; // utility without prefix, e.g. "text-sm"
1442
+
1443
+ const existing = propertyMap.get(property);
1444
+ if (existing) {
1445
+ existing.breakpointValues[prefix] = resolvedValue;
1446
+ if (existing.confidence < confidence) existing.confidence = confidence;
1447
+ } else {
1448
+ propertyMap.set(property, {
1449
+ property,
1450
+ confidence,
1451
+ breakpointValues: { [prefix]: resolvedValue },
1452
+ baseToken: rawToken,
1453
+ });
1454
+ }
1455
+ }
1296
1456
  }
1297
- if (/^gap-/.test(normalized)) {
1298
- return {
1299
- property: "gap",
1300
- value: token,
1301
- token,
1457
+
1458
+ for (const entry of propertyMap.values()) {
1459
+ const baseValue = entry.breakpointValues["base"] ?? "";
1460
+ // The full original token for the base occurrence (for backward compat).
1461
+ const rawBaseToken = entry.baseToken;
1462
+ const overriddenAt = Object.keys(entry.breakpointValues).filter(
1463
+ (p) => p !== "base",
1464
+ ) as TailwindBreakpointPrefix[];
1465
+
1466
+ tokens.push({
1467
+ property: entry.property,
1468
+ // `value` = the base utility string (backward-compatible).
1469
+ value: baseValue || rawBaseToken,
1470
+ token: rawBaseToken,
1302
1471
  source: "class",
1303
- confidence: 0.62,
1304
- };
1472
+ confidence: entry.confidence,
1473
+ breakpointValues: { ...entry.breakpointValues },
1474
+ overriddenAtPrefixes: overriddenAt.length > 0 ? overriddenAt : undefined,
1475
+ });
1305
1476
  }
1477
+
1478
+ return tokens;
1479
+ }
1480
+
1481
+ /**
1482
+ * Map a bare Tailwind utility (without any responsive prefix, e.g. `"text-sm"`
1483
+ * not `"md:text-sm"`) to the `VisualStyleProperty` it most likely controls.
1484
+ * Returns `null` when the utility is not recognised.
1485
+ *
1486
+ * This is called by both the legacy `classStyleToken` path and the new
1487
+ * responsive-aware `styleTokensFor` implementation.
1488
+ */
1489
+ function utilityToStyleProperty(
1490
+ utility: string,
1491
+ ): { property: VisualStyleProperty; confidence: number } | null {
1492
+ if (/^w-/.test(utility)) return { property: "width", confidence: 0.64 };
1493
+ if (/^h-/.test(utility)) return { property: "height", confidence: 0.64 };
1494
+ if (/^bg-/.test(utility)) return { property: "background", confidence: 0.6 };
1495
+ if (/^(p|px|py|pt|pr|pb|pl)-/.test(utility))
1496
+ return { property: "padding", confidence: 0.62 };
1497
+ if (/^gap-/.test(utility)) return { property: "gap", confidence: 0.62 };
1306
1498
  if (
1307
1499
  [
1308
1500
  "block",
@@ -1313,28 +1505,26 @@ function classStyleToken(token: string): StyleToken | null {
1313
1505
  "grid",
1314
1506
  "inline-grid",
1315
1507
  "hidden",
1316
- ].includes(normalized)
1508
+ ].includes(utility)
1317
1509
  ) {
1318
- return {
1319
- property: "display",
1320
- value: normalized === "hidden" ? "none" : normalized,
1321
- token,
1322
- source: "class",
1323
- confidence: 0.68,
1324
- };
1325
- }
1326
- if (/^text-/.test(normalized)) {
1327
- return {
1328
- property: "color",
1329
- value: token,
1330
- token,
1331
- source: "class",
1332
- confidence: 0.45,
1333
- };
1510
+ return { property: "display", confidence: 0.68 };
1334
1511
  }
1512
+ if (/^text-/.test(utility)) return { property: "color", confidence: 0.45 };
1335
1513
  return null;
1336
1514
  }
1337
1515
 
1516
+ function classStyleToken(token: string): StyleToken | null {
1517
+ const { utility } = parseClassToken(token);
1518
+ const mapped = utilityToStyleProperty(utility);
1519
+ if (!mapped) return null;
1520
+ const { property, confidence } = mapped;
1521
+ // The legacy value field preserves the original full token (including prefix)
1522
+ // for backward compatibility. The responsive path uses `breakpointValues`.
1523
+ const value =
1524
+ property === "display" ? (utility === "hidden" ? "none" : utility) : token;
1525
+ return { property, value, token, source: "class", confidence };
1526
+ }
1527
+
1338
1528
  function layoutFor(
1339
1529
  element: ParsedElement,
1340
1530
  parent: ParsedElement | undefined,
@@ -1472,6 +1662,9 @@ function treeTypeForNode(node: CodeLayerNode): CodeLayerTreeNodeType {
1472
1662
  if (TEXT_LAYER_TAGS.has(node.tag)) return "text";
1473
1663
  if (IMAGE_LAYER_TAGS.has(node.tag)) return "image";
1474
1664
  if (SHAPE_LAYER_TAGS.has(node.tag)) return "shape";
1665
+ // A node annotated as a component instance is always classified as "component"
1666
+ // regardless of its tag — this is the canonical detection path.
1667
+ if (node.componentInstance) return "component";
1475
1668
  if (
1476
1669
  COMPONENT_LAYER_TAGS.has(node.tag) ||
1477
1670
  node.classes.some((item) => /component|card|button|control/.test(item))
@@ -1542,6 +1735,39 @@ function capabilitiesFor(element: ParsedElement): EditCapability[] {
1542
1735
  },
1543
1736
  ];
1544
1737
 
1738
+ // Responsive-class capability — detect which properties already carry
1739
+ // breakpoint overrides so the inspector can surface override indicators.
1740
+ const classValue = attributeValue(element, "class") ?? "";
1741
+ const groups = parseClassGroups(classValue);
1742
+ const overriddenProps: string[] = [];
1743
+ const responsivePrefixes: ReadonlyArray<TailwindBreakpointPrefix> = [
1744
+ "sm",
1745
+ "md",
1746
+ "lg",
1747
+ "xl",
1748
+ "2xl",
1749
+ ];
1750
+ for (const prefix of responsivePrefixes) {
1751
+ for (const rawToken of groups[prefix]) {
1752
+ const { utility } = parseClassToken(rawToken);
1753
+ // Derive a property stem to use as the override indicator label.
1754
+ const stemPart = utility.split("-")[0];
1755
+ if (stemPart && !overriddenProps.includes(stemPart)) {
1756
+ overriddenProps.push(stemPart);
1757
+ }
1758
+ }
1759
+ }
1760
+ // The prefix here is "base" as the default; callers that know the active
1761
+ // frame width should use widthToPrefix() from responsive-classes.ts to
1762
+ // determine the appropriate editing prefix.
1763
+ capabilities.push({
1764
+ kind: "responsive-class",
1765
+ prefix: "base",
1766
+ operations: ["add", "remove", "replace"],
1767
+ overriddenProperties: overriddenProps,
1768
+ confidence: 0.87,
1769
+ });
1770
+
1545
1771
  if (!element.selfClosing) {
1546
1772
  capabilities.push({
1547
1773
  kind: "text",
@@ -1561,6 +1787,9 @@ function buildProjection(
1561
1787
  html: string,
1562
1788
  source: CodeLayerSource,
1563
1789
  ): ProjectionBuild {
1790
+ // Tolerate non-string input from any caller (e.g. content not yet loaded):
1791
+ // an empty projection is correct; crashing the editor is not.
1792
+ if (typeof html !== "string") html = "";
1564
1793
  const elements = parseHtmlElements(html);
1565
1794
  const nodeIdByElementIndex = new Map<number, string>();
1566
1795
  const nodes: CodeLayerNode[] = [];
@@ -1602,7 +1831,7 @@ function buildProjection(
1602
1831
  ]),
1603
1832
  );
1604
1833
 
1605
- nodes.push({
1834
+ const node: CodeLayerNode = {
1606
1835
  id: nodeId,
1607
1836
  tag: element.tag,
1608
1837
  layerName: layerName.name,
@@ -1640,7 +1869,17 @@ function buildProjection(
1640
1869
  closeStart: element.closeStart,
1641
1870
  closeEnd: element.closeEnd,
1642
1871
  },
1643
- });
1872
+ };
1873
+
1874
+ // Detect component instances — nodes that carry data-agent-native-component.
1875
+ // Populate the metadata so the canvas can outline component roots and the
1876
+ // inspector can surface component-level controls.
1877
+ if (isComponentInstance(node)) {
1878
+ const instance = instanceFromNode(node);
1879
+ if (instance) node.componentInstance = instance;
1880
+ }
1881
+
1882
+ nodes.push(node);
1644
1883
  elementByNodeId.set(nodeId, element);
1645
1884
  }
1646
1885
 
@@ -1671,7 +1910,11 @@ export function buildCodeLayerProjection(
1671
1910
  html: string,
1672
1911
  options: { source?: CodeLayerSource } = {},
1673
1912
  ): CodeLayerProjection {
1674
- return buildProjection(html, options.source ?? { kind: "inline-html" })
1913
+ // Defensive: callers (memos/effects) may project before content has loaded
1914
+ // (e.g. `activeContent` is briefly undefined on first render). Projecting a
1915
+ // non-string must yield an empty projection, never crash the editor.
1916
+ const safeHtml = typeof html === "string" ? html : "";
1917
+ return buildProjection(safeHtml, options.source ?? { kind: "inline-html" })
1675
1918
  .projection;
1676
1919
  }
1677
1920
 
@@ -2353,6 +2596,71 @@ function applyTextEdit(
2353
2596
  };
2354
2597
  }
2355
2598
 
2599
+ /**
2600
+ * Apply a responsive-class edit intent to the HTML source.
2601
+ *
2602
+ * - `"add"`: calls `setPropertyClass(className, prefix, utility)` — adds a
2603
+ * new token at the target prefix (or replaces the existing one for
2604
+ * the same stem).
2605
+ * - `"replace"`: same as `"add"` — `setPropertyClass` already handles the
2606
+ * replace-if-same-stem semantics.
2607
+ * - `"remove"`: calls `removePropertyClass(className, prefix, stem)` — strips
2608
+ * all tokens with the given property stem at the target prefix,
2609
+ * falling back to the base value (Tailwind cascade).
2610
+ *
2611
+ * Uses the helpers from `responsive-classes.ts` so the logic is shared with
2612
+ * the StatesPanel / inspector UI.
2613
+ */
2614
+ function applyResponsiveClassEdit(
2615
+ html: string,
2616
+ element: ParsedElement,
2617
+ intent: ResponsiveClassEditIntent,
2618
+ ): { content: string; capability: EditCapability } | PatchResultStatus {
2619
+ const currentClass = attributeValue(element, "class") ?? "";
2620
+
2621
+ let nextClass: string;
2622
+ if (intent.operation === "remove") {
2623
+ if (!intent.stem) {
2624
+ // stem is required for remove
2625
+ return "unsupported";
2626
+ }
2627
+ if (!isSafeClassToken(intent.stem)) return "unsupported";
2628
+ nextClass = removePropertyClass(currentClass, intent.prefix, intent.stem);
2629
+ } else {
2630
+ // "add" and "replace" both use setPropertyClass
2631
+ if (!intent.utility) return "unsupported";
2632
+ if (!isSafeClassToken(intent.utility)) return "unsupported";
2633
+ nextClass = setPropertyClass(currentClass, intent.prefix, intent.utility);
2634
+ }
2635
+
2636
+ if (nextClass === currentClass) {
2637
+ // No-op — nothing to patch.
2638
+ return {
2639
+ content: html,
2640
+ capability: {
2641
+ kind: "responsive-class",
2642
+ prefix: intent.prefix,
2643
+ operations: [intent.operation],
2644
+ overriddenProperties: [],
2645
+ confidence: 0.87,
2646
+ },
2647
+ };
2648
+ }
2649
+
2650
+ return {
2651
+ content: replaceOrInsertAttribute(html, element, "class", nextClass),
2652
+ capability: {
2653
+ kind: "responsive-class",
2654
+ prefix: intent.prefix,
2655
+ operations: [intent.operation],
2656
+ overriddenProperties: intent.utility
2657
+ ? [intent.utility.split("-")[0] ?? ""]
2658
+ : [],
2659
+ confidence: 0.87,
2660
+ },
2661
+ };
2662
+ }
2663
+
2356
2664
  function applyMoveNodeEdit(
2357
2665
  html: string,
2358
2666
  element: ParsedElement,
@@ -2393,6 +2701,379 @@ function applyMoveNodeEdit(
2393
2701
  };
2394
2702
  }
2395
2703
 
2704
+ /** Generate a fresh unique data-agent-native-node-id value not already in the set. */
2705
+ function freshNodeId(usedIds: Set<string>, basis: string): string {
2706
+ const base = `an-${hashStable(basis)}`;
2707
+ let value = base;
2708
+ let suffix = 1;
2709
+ while (usedIds.has(value)) {
2710
+ value = `an-${hashStable(`${base}:${suffix}`)}`;
2711
+ suffix += 1;
2712
+ }
2713
+ usedIds.add(value);
2714
+ return value;
2715
+ }
2716
+
2717
+ /**
2718
+ * Strip the given CSS property names from an inline style attribute value.
2719
+ * Returns the new style string (may be empty if all declarations were removed).
2720
+ */
2721
+ function stripStyleProperties(
2722
+ styleValue: string | null,
2723
+ propertiesToRemove: string[],
2724
+ ): string {
2725
+ if (!styleValue) return "";
2726
+ const toRemove = new Set(propertiesToRemove.map((p) => p.toLowerCase()));
2727
+ const remaining = parseStyleDeclarations(styleValue).filter(
2728
+ (decl) => !toRemove.has(decl.property),
2729
+ );
2730
+ return serializeStyleDeclarations(remaining);
2731
+ }
2732
+
2733
+ /** Absolute-positioning properties stripped when converting a child to auto-layout flow. */
2734
+ const AUTO_LAYOUT_STRIP_PROPS = [
2735
+ "position",
2736
+ "left",
2737
+ "top",
2738
+ "right",
2739
+ "bottom",
2740
+ "inset",
2741
+ ] as const;
2742
+
2743
+ /**
2744
+ * Apply display:flex + direction + gap to a raw open-tag string and return it.
2745
+ * Only touches the style attribute.
2746
+ */
2747
+ function addAutoLayoutStyleToOpenTag(
2748
+ openTag: string,
2749
+ element: ParsedElement,
2750
+ html: string,
2751
+ direction: "row" | "column",
2752
+ gap: string,
2753
+ ): string {
2754
+ const currentStyle = attributeValue(element, "style");
2755
+ let declarations = parseStyleDeclarations(currentStyle);
2756
+ const setOrReplace = (prop: string, val: string) => {
2757
+ const existing = declarations.find((d) => d.property === prop);
2758
+ if (existing) {
2759
+ existing.value = val;
2760
+ } else {
2761
+ declarations.push({ property: prop, value: val });
2762
+ }
2763
+ };
2764
+ setOrReplace("display", "flex");
2765
+ setOrReplace("flex-direction", direction);
2766
+ setOrReplace("gap", gap);
2767
+ const nextStyle = serializeStyleDeclarations(declarations);
2768
+ // We work on a scratch copy relative to element boundaries
2769
+ const attr = getAttribute(element, "style");
2770
+ if (attr) {
2771
+ return `${openTag.slice(0, attr.start - element.start)}${attr.name}="${escapeHtmlAttribute(nextStyle)}"${openTag.slice(attr.end - element.start)}`;
2772
+ }
2773
+ // Insert before closing >
2774
+ const closeChar = openTag.endsWith("/>")
2775
+ ? openTag.length - 2
2776
+ : openTag.length - 1;
2777
+ return `${openTag.slice(0, closeChar)} style="${escapeHtmlAttribute(nextStyle)}"${openTag.slice(closeChar)}`;
2778
+ }
2779
+
2780
+ /**
2781
+ * Strip absolute-positioning properties from a child's inline style, applying
2782
+ * the edit directly to the html string at the child element's source spans.
2783
+ * Returns the updated html string.
2784
+ */
2785
+ function stripAbsolutePositioningFromChild(
2786
+ html: string,
2787
+ child: ParsedElement,
2788
+ ): string {
2789
+ const currentStyle = attributeValue(child, "style");
2790
+ if (!currentStyle) return html;
2791
+ const nextStyle = stripStyleProperties(currentStyle, [
2792
+ ...AUTO_LAYOUT_STRIP_PROPS,
2793
+ ]);
2794
+ return replaceOrInsertAttribute(html, child, "style", nextStyle);
2795
+ }
2796
+
2797
+ /**
2798
+ * GROUP: wrap targetted sibling elements (sharing a common parent) in a new
2799
+ * <div> wrapper. Targets must all share the same parent element.
2800
+ */
2801
+ function applyWrapNodes(
2802
+ html: string,
2803
+ build: ProjectionBuild,
2804
+ intent: WrapNodesEditIntent,
2805
+ ):
2806
+ | { content: string; capability: EditCapability; wrapperNodeId: string }
2807
+ | PatchResultStatus {
2808
+ const { targetIds, autoLayout = false } = intent;
2809
+ if (targetIds.length === 0) return "unsupported";
2810
+
2811
+ // Resolve all target nodes via nodeId attribute matching.
2812
+ const targetElements: ParsedElement[] = [];
2813
+ for (const id of targetIds) {
2814
+ const node = build.projection.nodes.find(
2815
+ (n) =>
2816
+ n.dataAttributes["data-agent-native-node-id"] === id || n.id === id,
2817
+ );
2818
+ if (!node) return "conflict";
2819
+ const el = build.elementByNodeId.get(node.id);
2820
+ if (!el) return "conflict";
2821
+ targetElements.push(el);
2822
+ }
2823
+
2824
+ // All targets must share the same parent.
2825
+ const parentIndexes = new Set(targetElements.map((el) => el.parentIndex));
2826
+ if (parentIndexes.size !== 1) return "unsupported";
2827
+
2828
+ // Sort targets by their source position (ascending).
2829
+ targetElements.sort((a, b) => a.start - b.start);
2830
+
2831
+ const siblingIndexes = targetElements
2832
+ .map((el) => el.siblingIndex)
2833
+ .sort((a, b) => a - b);
2834
+ const firstSiblingIndex = siblingIndexes[0];
2835
+ if (firstSiblingIndex === undefined) return "unsupported";
2836
+ const targetsAreContiguous = siblingIndexes.every(
2837
+ (siblingIndex, index) => siblingIndex === firstSiblingIndex + index,
2838
+ );
2839
+ if (!targetsAreContiguous) return "unsupported";
2840
+
2841
+ // Collect existing node ids so we can generate a unique one.
2842
+ const usedIds = new Set(
2843
+ build.projection.nodes.flatMap((n) => {
2844
+ const id = n.dataAttributes["data-agent-native-node-id"];
2845
+ return id ? [id] : [];
2846
+ }),
2847
+ );
2848
+
2849
+ const wrapperNodeId = freshNodeId(
2850
+ usedIds,
2851
+ `wrap:${targetElements.map((el) => el.start).join(":")}`,
2852
+ );
2853
+
2854
+ // Collect the source fragments for all targets.
2855
+ const fragments = targetElements.map((el) => {
2856
+ let frag = html.slice(el.start, el.end);
2857
+ // If autoLayout, strip positioning from each wrapped child.
2858
+ if (autoLayout) {
2859
+ // We need a ParsedElement that reflects the fragment's own positions.
2860
+ // Re-parse the fragment to find the root element and strip its style.
2861
+ const fragElements = parseHtmlElements(frag);
2862
+ const root = fragElements.find((fe) => fe.parentIndex === undefined);
2863
+ if (root) {
2864
+ frag = stripAbsolutePositioningFromChild(frag, root);
2865
+ }
2866
+ }
2867
+ return frag;
2868
+ });
2869
+
2870
+ const autoLayoutStyle = autoLayout
2871
+ ? ` style="display: flex; flex-direction: column; gap: 8px"`
2872
+ : "";
2873
+ const wrapperOpen = `<div data-agent-native-node-id="${escapeHtmlAttribute(wrapperNodeId)}" data-agent-native-layer-name="Group"${autoLayoutStyle}>`;
2874
+ const wrapperClose = `</div>`;
2875
+ const wrapperContent = `${wrapperOpen}${fragments.join("")}${wrapperClose}`;
2876
+
2877
+ // Build the replacement: remove all targets from html (back to front) then
2878
+ // insert the wrapper at the first target's position.
2879
+ // Sort by position descending to remove safely.
2880
+ const sorted = [...targetElements].sort((a, b) => b.start - a.start);
2881
+
2882
+ // Remove all targets from the html (back to front).
2883
+ let result = html;
2884
+ let firstTargetStart = targetElements[0]!.start;
2885
+
2886
+ for (const el of sorted) {
2887
+ const start = el.start;
2888
+ const end = el.end;
2889
+ if (start < firstTargetStart) {
2890
+ firstTargetStart = start;
2891
+ }
2892
+ result = `${result.slice(0, start)}${result.slice(end)}`;
2893
+ }
2894
+
2895
+ // Re-compute firstTargetStart relative to the modified string: all removals
2896
+ // before it shift it. Count how many bytes were removed before firstTargetStart.
2897
+ let bytesRemovedBefore = 0;
2898
+ for (const el of targetElements) {
2899
+ if (el.start < targetElements[0]!.start) {
2900
+ bytesRemovedBefore += el.end - el.start;
2901
+ }
2902
+ }
2903
+ const insertAt = firstTargetStart - bytesRemovedBefore;
2904
+
2905
+ result = `${result.slice(0, insertAt)}${wrapperContent}${result.slice(insertAt)}`;
2906
+
2907
+ return {
2908
+ content: result,
2909
+ capability: {
2910
+ kind: "structure",
2911
+ operations: ["moveNode"],
2912
+ confidence: 0.82,
2913
+ },
2914
+ wrapperNodeId,
2915
+ };
2916
+ }
2917
+
2918
+ /**
2919
+ * UNGROUP: replace the wrapper node with its children, spliced into the
2920
+ * wrapper's parent at the wrapper's position, then remove the wrapper.
2921
+ */
2922
+ function applyUnwrap(
2923
+ html: string,
2924
+ build: ProjectionBuild,
2925
+ intent: UnwrapEditIntent,
2926
+ ): { content: string; capability: EditCapability } | PatchResultStatus {
2927
+ const { targetId } = intent;
2928
+ const node = build.projection.nodes.find(
2929
+ (n) =>
2930
+ n.dataAttributes["data-agent-native-node-id"] === targetId ||
2931
+ n.id === targetId,
2932
+ );
2933
+ if (!node) return "conflict";
2934
+ if (!node.source) return "needsAgent";
2935
+
2936
+ const element = build.elementByNodeId.get(node.id);
2937
+ if (!element) return "conflict";
2938
+ if (element.selfClosing || element.contentStart >= element.contentEnd) {
2939
+ // Nothing to unwrap from an empty/void element.
2940
+ return "unsupported";
2941
+ }
2942
+
2943
+ // The children's source content is the element's inner HTML.
2944
+ const innerContent = html.slice(element.contentStart, element.contentEnd);
2945
+
2946
+ // Replace the whole element (start..end) with its inner content.
2947
+ const result = `${html.slice(0, element.start)}${innerContent}${html.slice(element.end)}`;
2948
+
2949
+ return {
2950
+ content: result,
2951
+ capability: {
2952
+ kind: "structure",
2953
+ operations: ["moveNode"],
2954
+ confidence: 0.82,
2955
+ },
2956
+ };
2957
+ }
2958
+
2959
+ /**
2960
+ * CONVERT: toggle auto-layout (display:flex) on an existing container.
2961
+ */
2962
+ function applyAutoLayout(
2963
+ html: string,
2964
+ build: ProjectionBuild,
2965
+ intent: AutoLayoutEditIntent,
2966
+ ): { content: string; capability: EditCapability } | PatchResultStatus {
2967
+ const { targetId, enabled, direction = "column", gap = "8px" } = intent;
2968
+ const node = build.projection.nodes.find(
2969
+ (n) =>
2970
+ n.dataAttributes["data-agent-native-node-id"] === targetId ||
2971
+ n.id === targetId,
2972
+ );
2973
+ if (!node) return "conflict";
2974
+ if (!node.source) return "needsAgent";
2975
+
2976
+ const element = build.elementByNodeId.get(node.id);
2977
+ if (!element) return "conflict";
2978
+
2979
+ if (!enabled) {
2980
+ // Turn off auto-layout: set display:block.
2981
+ const currentStyle = attributeValue(element, "style");
2982
+ let declarations = parseStyleDeclarations(currentStyle);
2983
+ const displayDecl = declarations.find((d) => d.property === "display");
2984
+ if (displayDecl) {
2985
+ displayDecl.value = "block";
2986
+ } else {
2987
+ declarations.push({ property: "display", value: "block" });
2988
+ }
2989
+ const nextStyle = serializeStyleDeclarations(declarations);
2990
+ return {
2991
+ content: replaceOrInsertAttribute(html, element, "style", nextStyle),
2992
+ capability: {
2993
+ kind: "style",
2994
+ properties: ["display"],
2995
+ confidence: 0.9,
2996
+ },
2997
+ };
2998
+ }
2999
+
3000
+ // Enable auto-layout: set display:flex + direction + gap on the container.
3001
+ // Apply all three style properties in a single mutation against the already-
3002
+ // resolved element so that elements with no stable data attributes or HTML id
3003
+ // are handled correctly. Re-parsing after each individual property write
3004
+ // caused a silent no-op for those elements because the re-parse-based element
3005
+ // finder could not locate them after the first write changed the style attr.
3006
+ const currentStyle = attributeValue(element, "style");
3007
+ let declarations = parseStyleDeclarations(currentStyle);
3008
+ const setOrReplace = (prop: string, val: string) => {
3009
+ const existing = declarations.find((d) => d.property === prop);
3010
+ if (existing) {
3011
+ existing.value = val;
3012
+ } else {
3013
+ declarations.push({ property: prop, value: val });
3014
+ }
3015
+ };
3016
+ setOrReplace("display", "flex");
3017
+ setOrReplace("flex-direction", direction);
3018
+ setOrReplace("gap", gap);
3019
+ let result = replaceOrInsertAttribute(
3020
+ html,
3021
+ element,
3022
+ "style",
3023
+ serializeStyleDeclarations(declarations),
3024
+ );
3025
+
3026
+ // Strip absolute positioning from direct children.
3027
+ // Re-parse to get up-to-date child element positions after the style mutation.
3028
+ const updatedElements = parseHtmlElements(result);
3029
+ // Locate the target element in the updated parse. Prefer stable data
3030
+ // attributes and HTML id; fall back to matching by original source position
3031
+ // (safe because the container open-tag length only changed by the style attr
3032
+ // rewrite, which shifts nothing before element.start).
3033
+ const stableAttrPairs: Array<[string, string]> = [];
3034
+ for (const attrName of STABLE_NODE_ID_ATTRIBUTES) {
3035
+ const v = attributeValue(element, attrName);
3036
+ if (v) stableAttrPairs.push([attrName, v]);
3037
+ }
3038
+ const htmlIdValue = attributeValue(element, "id");
3039
+ const findElementInParsed = (
3040
+ elements: ParsedElement[],
3041
+ ): ParsedElement | undefined => {
3042
+ for (const attrPair of stableAttrPairs) {
3043
+ const found = elements.find(
3044
+ (fe) => attributeValue(fe, attrPair[0]) === attrPair[1],
3045
+ );
3046
+ if (found) return found;
3047
+ }
3048
+ if (htmlIdValue) {
3049
+ return elements.find((fe) => attributeValue(fe, "id") === htmlIdValue);
3050
+ }
3051
+ // Fallback: match by original start position. The container's start offset
3052
+ // is unchanged because only its open-tag content (style attr) was modified.
3053
+ return elements.find((fe) => fe.start === element.start);
3054
+ };
3055
+ const updatedTarget = findElementInParsed(updatedElements);
3056
+
3057
+ if (updatedTarget) {
3058
+ // Process children in reverse order so offsets stay valid.
3059
+ const childIndexes = [...updatedTarget.childIndexes].reverse();
3060
+ for (const childIndex of childIndexes) {
3061
+ const child = updatedElements[childIndex];
3062
+ if (!child) continue;
3063
+ result = stripAbsolutePositioningFromChild(result, child);
3064
+ }
3065
+ }
3066
+
3067
+ return {
3068
+ content: result,
3069
+ capability: {
3070
+ kind: "style",
3071
+ properties: ["display", "flex-direction", "gap"],
3072
+ confidence: 0.88,
3073
+ },
3074
+ };
3075
+ }
3076
+
2396
3077
  function findAfterNode(
2397
3078
  projection: CodeLayerProjection,
2398
3079
  before: CodeLayerNode,
@@ -2436,6 +3117,121 @@ export function applyVisualEdit(
2436
3117
  }
2437
3118
 
2438
3119
  const initial = buildProjection(html, source);
3120
+
3121
+ // --- Structural intents that don't resolve a single target node ---
3122
+
3123
+ if (intent.kind === "wrapNodes") {
3124
+ const wrapEdit = applyWrapNodes(html, initial, intent);
3125
+ if (typeof wrapEdit === "string") {
3126
+ return {
3127
+ content: html,
3128
+ projection: initial.projection,
3129
+ result: {
3130
+ ...patchResult(
3131
+ wrapEdit,
3132
+ source,
3133
+ intent,
3134
+ false,
3135
+ wrapEdit === "unsupported"
3136
+ ? "wrapNodes requires all targets to share a common parent element."
3137
+ : "Could not resolve one or more target nodes for wrapNodes.",
3138
+ ),
3139
+ },
3140
+ };
3141
+ }
3142
+ const nextProjection = buildCodeLayerProjection(wrapEdit.content, {
3143
+ source,
3144
+ });
3145
+ return {
3146
+ content: wrapEdit.content,
3147
+ projection: nextProjection,
3148
+ result: {
3149
+ ...patchResult(
3150
+ "applied",
3151
+ source,
3152
+ intent,
3153
+ wrapEdit.content !== html,
3154
+ wrapEdit.content === html
3155
+ ? "No source change was needed."
3156
+ : "Nodes wrapped.",
3157
+ ),
3158
+ wrapperNodeId: wrapEdit.wrapperNodeId,
3159
+ },
3160
+ };
3161
+ }
3162
+
3163
+ if (intent.kind === "unwrap") {
3164
+ const unwrapEdit = applyUnwrap(html, initial, intent);
3165
+ if (typeof unwrapEdit === "string") {
3166
+ return {
3167
+ content: html,
3168
+ projection: initial.projection,
3169
+ result: patchResult(
3170
+ unwrapEdit,
3171
+ source,
3172
+ intent,
3173
+ false,
3174
+ unwrapEdit === "conflict"
3175
+ ? `Could not resolve unwrap target "${intent.targetId}".`
3176
+ : "Cannot unwrap a self-closing or empty element.",
3177
+ ),
3178
+ };
3179
+ }
3180
+ const nextProjection = buildCodeLayerProjection(unwrapEdit.content, {
3181
+ source,
3182
+ });
3183
+ return {
3184
+ content: unwrapEdit.content,
3185
+ projection: nextProjection,
3186
+ result: patchResult(
3187
+ "applied",
3188
+ source,
3189
+ intent,
3190
+ unwrapEdit.content !== html,
3191
+ unwrapEdit.content === html
3192
+ ? "No source change was needed."
3193
+ : "Node unwrapped.",
3194
+ ),
3195
+ };
3196
+ }
3197
+
3198
+ if (intent.kind === "autoLayout") {
3199
+ const alEdit = applyAutoLayout(html, initial, intent);
3200
+ if (typeof alEdit === "string") {
3201
+ return {
3202
+ content: html,
3203
+ projection: initial.projection,
3204
+ result: patchResult(
3205
+ alEdit,
3206
+ source,
3207
+ intent,
3208
+ false,
3209
+ alEdit === "conflict"
3210
+ ? `Could not resolve autoLayout target "${intent.targetId}".`
3211
+ : "Cannot apply autoLayout to this element.",
3212
+ ),
3213
+ };
3214
+ }
3215
+ const nextProjection = buildCodeLayerProjection(alEdit.content, { source });
3216
+ return {
3217
+ content: alEdit.content,
3218
+ projection: nextProjection,
3219
+ result: patchResult(
3220
+ "applied",
3221
+ source,
3222
+ intent,
3223
+ alEdit.content !== html,
3224
+ alEdit.content === html
3225
+ ? "No source change was needed."
3226
+ : intent.enabled
3227
+ ? "Auto-layout enabled."
3228
+ : "Auto-layout disabled.",
3229
+ ),
3230
+ };
3231
+ }
3232
+
3233
+ // --- Target-resolved intents (style / class / textContent / moveNode) ---
3234
+
2439
3235
  const resolution = resolveTarget(initial, intent.target);
2440
3236
  if (resolution.status !== "resolved" || !resolution.node) {
2441
3237
  return {
@@ -2479,6 +3275,8 @@ export function applyVisualEdit(
2479
3275
  edit = applyClassEdit(html, element, intent);
2480
3276
  } else if (intent.kind === "textContent") {
2481
3277
  edit = applyTextEdit(html, element, intent);
3278
+ } else if (intent.kind === "responsive-class") {
3279
+ edit = applyResponsiveClassEdit(html, element, intent);
2482
3280
  } else {
2483
3281
  const anchorResolution = resolveTarget(initial, intent.anchor);
2484
3282
  if (anchorResolution.status !== "resolved" || !anchorResolution.node) {
@@ -2572,3 +3370,150 @@ export function applyVisualEdit(
2572
3370
  ),
2573
3371
  };
2574
3372
  }
3373
+
3374
+ export interface MoveNodeBetweenDocumentsOptions {
3375
+ nodeId: string;
3376
+ anchorNodeId?: string;
3377
+ placement?: "before" | "after" | "inside";
3378
+ }
3379
+
3380
+ export interface MoveNodeBetweenDocumentsResult {
3381
+ sourceHtml: string;
3382
+ destHtml: string;
3383
+ status: "applied" | "unsupported";
3384
+ message?: string;
3385
+ /**
3386
+ * The data-agent-native-node-id of the moved node in destHtml.
3387
+ * May differ from the original nodeId when a collision caused a re-stamp.
3388
+ * Only present when status is "applied".
3389
+ */
3390
+ movedNodeId?: string;
3391
+ }
3392
+
3393
+ /**
3394
+ * Move a node (by data-agent-native-node-id) from sourceHtml into destHtml.
3395
+ * The node's serialized subtree is removed from sourceHtml and inserted into
3396
+ * destHtml relative to anchorNodeId (default: append to <body> or end of doc).
3397
+ * Any node ids in the moved subtree that already exist in destHtml are
3398
+ * re-stamped to stay unique. No external dependencies.
3399
+ */
3400
+ export function moveNodeBetweenDocuments(
3401
+ sourceHtml: string,
3402
+ destHtml: string,
3403
+ opts: MoveNodeBetweenDocumentsOptions,
3404
+ ): MoveNodeBetweenDocumentsResult {
3405
+ const { nodeId, anchorNodeId, placement = "inside" } = opts;
3406
+
3407
+ // --- Locate the node in sourceHtml ---
3408
+ const sourceElements = parseHtmlElements(sourceHtml);
3409
+ const sourceTarget = sourceElements.find(
3410
+ (el) => attributeValue(el, "data-agent-native-node-id") === nodeId,
3411
+ );
3412
+ if (!sourceTarget) {
3413
+ return {
3414
+ sourceHtml,
3415
+ destHtml,
3416
+ status: "unsupported",
3417
+ message: `Node with data-agent-native-node-id="${nodeId}" not found in sourceHtml.`,
3418
+ };
3419
+ }
3420
+
3421
+ // --- Extract the subtree fragment ---
3422
+ let fragment = sourceHtml.slice(sourceTarget.start, sourceTarget.end);
3423
+
3424
+ // --- Collect all existing node ids in destHtml to avoid collisions ---
3425
+ const destElements = parseHtmlElements(destHtml);
3426
+ const destUsedIds = new Set<string>(
3427
+ destElements
3428
+ .map((el) => attributeValue(el, "data-agent-native-node-id"))
3429
+ .filter((v): v is string => v !== null),
3430
+ );
3431
+
3432
+ // --- Re-stamp any colliding node ids in the fragment ---
3433
+ // We do this by parsing the fragment as its own HTML and replacing ids.
3434
+ const fragElements = parseHtmlElements(fragment);
3435
+ // Build replacement map: old id → new id
3436
+ const idRemap = new Map<string, string>();
3437
+ for (const fragEl of fragElements) {
3438
+ const existingId = attributeValue(fragEl, "data-agent-native-node-id");
3439
+ if (!existingId) continue;
3440
+ if (destUsedIds.has(existingId)) {
3441
+ const newId = freshNodeId(
3442
+ destUsedIds,
3443
+ `moved:${existingId}:${fragEl.start}`,
3444
+ );
3445
+ idRemap.set(existingId, newId);
3446
+ } else {
3447
+ destUsedIds.add(existingId);
3448
+ }
3449
+ }
3450
+
3451
+ // Apply id remaps to the fragment (back to front by attribute position).
3452
+ if (idRemap.size > 0) {
3453
+ const remapEdits: Array<{ start: number; end: number; value: string }> = [];
3454
+ for (const fragEl of fragElements) {
3455
+ const attr = getAttribute(fragEl, "data-agent-native-node-id");
3456
+ if (!attr || typeof attr.value !== "string") continue;
3457
+ const newId = idRemap.get(attr.value);
3458
+ if (!newId) continue;
3459
+ remapEdits.push({
3460
+ start: attr.start,
3461
+ end: attr.end,
3462
+ value: `data-agent-native-node-id="${escapeHtmlAttribute(newId)}"`,
3463
+ });
3464
+ }
3465
+ // Apply back to front.
3466
+ remapEdits.sort((a, b) => b.start - a.start);
3467
+ for (const edit of remapEdits) {
3468
+ fragment = `${fragment.slice(0, edit.start)}${edit.value}${fragment.slice(edit.end)}`;
3469
+ }
3470
+ }
3471
+
3472
+ // --- Remove node from sourceHtml ---
3473
+ const nextSourceHtml = `${sourceHtml.slice(0, sourceTarget.start)}${sourceHtml.slice(sourceTarget.end)}`;
3474
+
3475
+ // --- Insert fragment into destHtml ---
3476
+ let nextDestHtml: string;
3477
+
3478
+ if (anchorNodeId) {
3479
+ const anchor = destElements.find(
3480
+ (el) => attributeValue(el, "data-agent-native-node-id") === anchorNodeId,
3481
+ );
3482
+ if (!anchor) {
3483
+ return {
3484
+ sourceHtml,
3485
+ destHtml,
3486
+ status: "unsupported",
3487
+ message: `Anchor node with data-agent-native-node-id="${anchorNodeId}" not found in destHtml.`,
3488
+ };
3489
+ }
3490
+ const insertAt =
3491
+ placement === "before"
3492
+ ? anchor.start
3493
+ : placement === "after"
3494
+ ? anchor.end
3495
+ : anchor.selfClosing
3496
+ ? anchor.end
3497
+ : anchor.contentEnd;
3498
+ nextDestHtml = `${destHtml.slice(0, insertAt)}${fragment}${destHtml.slice(insertAt)}`;
3499
+ } else {
3500
+ // Default: find <body> and append inside it, or append at end of doc.
3501
+ const bodyEl = destElements.find((el) => el.tag === "body");
3502
+ const insertAt = bodyEl
3503
+ ? bodyEl.selfClosing
3504
+ ? bodyEl.end
3505
+ : bodyEl.contentEnd
3506
+ : destHtml.length;
3507
+ nextDestHtml = `${destHtml.slice(0, insertAt)}${fragment}${destHtml.slice(insertAt)}`;
3508
+ }
3509
+
3510
+ // The moved node keeps its original id unless it collided and was re-stamped.
3511
+ const movedNodeId = idRemap.get(nodeId) ?? nodeId;
3512
+
3513
+ return {
3514
+ sourceHtml: nextSourceHtml,
3515
+ destHtml: nextDestHtml,
3516
+ status: "applied",
3517
+ movedNodeId,
3518
+ };
3519
+ }