@agent-native/core 0.85.5 → 0.85.7

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 (86) hide show
  1. package/corpus/README.md +1 -1
  2. package/corpus/core/CHANGELOG.md +12 -0
  3. package/corpus/core/package.json +1 -1
  4. package/corpus/core/src/client/AssistantChat.tsx +1 -1
  5. package/corpus/core/src/client/use-pinch-zoom.ts +76 -11
  6. package/corpus/core/src/collab/presence.ts +63 -3
  7. package/corpus/templates/clips/desktop/src/lib/recorder.ts +245 -77
  8. package/corpus/templates/design/.agents/skills/design-generation/SKILL.md +190 -13
  9. package/corpus/templates/design/.agents/skills/design-systems/SKILL.md +16 -4
  10. package/corpus/templates/design/.agents/skills/export-handoff/SKILL.md +46 -3
  11. package/corpus/templates/design/AGENTS.md +40 -4
  12. package/corpus/templates/design/actions/apply-component-prop-edit.ts +20 -73
  13. package/corpus/templates/design/actions/apply-motion-edit.ts +29 -2
  14. package/corpus/templates/design/actions/apply-visual-edit.ts +4 -2
  15. package/corpus/templates/design/actions/connect-localhost.ts +25 -18
  16. package/corpus/templates/design/actions/edit-design.ts +5 -3
  17. package/corpus/templates/design/actions/export-pdf.ts +8 -1
  18. package/corpus/templates/design/actions/export-zip.ts +10 -1
  19. package/corpus/templates/design/actions/get-motion-timeline.ts +26 -20
  20. package/corpus/templates/design/actions/grant-localhost-write-consent.ts +10 -0
  21. package/corpus/templates/design/actions/insert-asset.ts +190 -7
  22. package/corpus/templates/design/actions/open-visual-edit.ts +27 -5
  23. package/corpus/templates/design/actions/present-design-variants.ts +12 -8
  24. package/corpus/templates/design/actions/revoke-localhost-write-consent.ts +18 -14
  25. package/corpus/templates/design/actions/run-design-audit.ts +7 -4
  26. package/corpus/templates/design/actions/write-local-file.ts +47 -22
  27. package/corpus/templates/design/app/components/design/DesignCanvas.tsx +221 -66
  28. package/corpus/templates/design/app/components/design/EditPanel.tsx +650 -237
  29. package/corpus/templates/design/app/components/design/LayersPanel.tsx +526 -127
  30. package/corpus/templates/design/app/components/design/MotionDock.tsx +234 -46
  31. package/corpus/templates/design/app/components/design/MultiScreenCanvas.tsx +1520 -410
  32. package/corpus/templates/design/app/components/design/StatesPanel.tsx +25 -2
  33. package/corpus/templates/design/app/components/design/bridge/editor-chrome.bridge.ts +640 -72
  34. package/corpus/templates/design/app/components/design/bridge/motion-preview.bridge.ts +605 -0
  35. package/corpus/templates/design/app/components/design/canvas-primitive-style.ts +50 -26
  36. package/corpus/templates/design/app/components/design/inspector/AutoLayoutMatrix.tsx +16 -4
  37. package/corpus/templates/design/app/components/design/inspector/DesignColorPicker.tsx +90 -103
  38. package/corpus/templates/design/app/components/design/inspector/GradientEditor.tsx +111 -5
  39. package/corpus/templates/design/app/components/design/inspector/ImageFillControls.tsx +13 -2
  40. package/corpus/templates/design/app/components/design/inspector/ScrubInput.tsx +42 -2
  41. package/corpus/templates/design/app/components/design/inspector/scrub-input-utils.ts +11 -2
  42. package/corpus/templates/design/app/components/design/types.ts +16 -0
  43. package/corpus/templates/design/app/components/visual-editor/CanvasCommentPins.tsx +24 -6
  44. package/corpus/templates/design/app/components/visual-editor/DrawOverlay.tsx +15 -1
  45. package/corpus/templates/design/app/hooks/use-question-flow.ts +2 -2
  46. package/corpus/templates/design/app/hooks/useDesignHotkeys.ts +77 -0
  47. package/corpus/templates/design/app/i18n/zh-TW.ts +1 -0
  48. package/corpus/templates/design/app/i18n-data.ts +18 -0
  49. package/corpus/templates/design/app/lib/design-import.ts +95 -0
  50. package/corpus/templates/design/app/pages/DesignEditor.tsx +4015 -840
  51. package/corpus/templates/design/changelog/2026-07-02-creating-two-frames-in-a-row-no-longer-freezes-and-reloads-t.md +6 -0
  52. package/corpus/templates/design/changelog/2026-07-03-canvas-interactions-now-match-figma-rotation-aware-resizing-.md +6 -0
  53. package/corpus/templates/design/changelog/2026-07-03-design-retries-stalled-screen-edits-with-a-smaller-safer-fol.md +6 -0
  54. package/corpus/templates/design/changelog/2026-07-03-inspector-fixes-mixed-selections-show-mixed-instead-of-wrong.md +6 -0
  55. package/corpus/templates/design/changelog/2026-07-03-keyframe-timeline-works-reliably-drag-keyframes-smoothly-pic.md +6 -0
  56. package/corpus/templates/design/changelog/2026-07-03-layers-panel-matches-figma-top-layer-on-top-drag-with-auto-s.md +6 -0
  57. package/corpus/templates/design/changelog/2026-07-03-much-faster-editor-smooth-dragging-zooming-and-panel-updates.md +6 -0
  58. package/corpus/templates/design/changelog/2026-07-03-pen-tool-refinements-click-the-first-point-to-close-with-a-d.md +6 -0
  59. package/corpus/templates/design/changelog/2026-07-03-text-editing-enter-adds-a-line-break-international-ime-typin.md +6 -0
  60. package/corpus/templates/design/changelog/2026-07-03-undo-and-redo-are-dependable-across-agent-edits-screen-switc.md +6 -0
  61. package/corpus/templates/design/server/lib/design-export.ts +43 -1
  62. package/corpus/templates/design/server/plugins/agent-chat.ts +1 -1
  63. package/corpus/templates/design/shared/board-file.ts +25 -5
  64. package/corpus/templates/design/shared/canvas-math.ts +754 -9
  65. package/corpus/templates/design/shared/code-layer.ts +304 -26
  66. package/corpus/templates/design/shared/color-utils.ts +84 -0
  67. package/corpus/templates/design/shared/design-source-capabilities.ts +16 -7
  68. package/corpus/templates/design/shared/motion-compiler.ts +75 -31
  69. package/corpus/templates/design/shared/motion-timeline.ts +335 -0
  70. package/corpus/templates/design/shared/pen-path.ts +200 -23
  71. package/dist/client/AssistantChat.js +1 -1
  72. package/dist/client/AssistantChat.js.map +1 -1
  73. package/dist/client/use-pinch-zoom.d.ts.map +1 -1
  74. package/dist/client/use-pinch-zoom.js +76 -11
  75. package/dist/client/use-pinch-zoom.js.map +1 -1
  76. package/dist/collab/awareness.d.ts +2 -2
  77. package/dist/collab/awareness.d.ts.map +1 -1
  78. package/dist/collab/presence.d.ts.map +1 -1
  79. package/dist/collab/presence.js +54 -3
  80. package/dist/collab/presence.js.map +1 -1
  81. package/dist/collab/routes.d.ts +1 -1
  82. package/dist/collab/struct-routes.d.ts +1 -1
  83. package/dist/file-upload/actions/upload-image.d.ts +2 -2
  84. package/dist/observability/routes.d.ts +2 -2
  85. package/dist/secrets/routes.d.ts +3 -3
  86. package/package.json +1 -1
@@ -5,10 +5,12 @@ import {
5
5
  } from "./component-model";
6
6
  import type { TailwindBreakpointPrefix } from "./design-state.js";
7
7
  import {
8
+ getPropertyClasses,
8
9
  parseClassGroups,
9
10
  parseClassToken,
10
11
  setPropertyClass,
11
12
  removePropertyClass,
13
+ utilityStem,
12
14
  } from "./responsive-classes.js";
13
15
  import type { DesignSourceType } from "./source-mode";
14
16
 
@@ -454,6 +456,13 @@ export interface AutoLayoutEditIntent {
454
456
  * `utility` should be the bare utility without its prefix (e.g. `"text-lg"`,
455
457
  * not `"md:text-lg"`). The prefix is applied automatically.
456
458
  * `stem` is required only for `"remove"` operations.
459
+ * `from` is an optional guard for `"replace"`: when present, the replace only
460
+ * applies if the utility EFFECTIVE at `prefix` equals `from` (bare, without
461
+ * prefix). "Effective" follows the Tailwind mobile-first cascade — an explicit
462
+ * override at `prefix` wins, otherwise the nearest smaller breakpoint's
463
+ * utility (down to base) is what renders there. If the guard fails (a stale
464
+ * selection targeting a different element/state than the caller expected) the
465
+ * edit reports `"conflict"` instead of silently overwriting whatever is there.
457
466
  */
458
467
  export interface ResponsiveClassEditIntent {
459
468
  kind: "responsive-class";
@@ -469,6 +478,13 @@ export interface ResponsiveClassEditIntent {
469
478
  * derived from `utility` instead).
470
479
  */
471
480
  stem?: string;
481
+ /**
482
+ * Guard for `"replace"` (honoured for `"add"` too when provided): the bare
483
+ * utility (without prefix) the caller expects to be EFFECTIVE at `prefix`,
484
+ * following the Tailwind mobile-first cascade. On mismatch the edit is
485
+ * rejected as `"conflict"` rather than applied. Ignored for `"remove"`.
486
+ */
487
+ from?: string;
472
488
  }
473
489
 
474
490
  export type EditIntent =
@@ -2658,7 +2674,13 @@ function applyTextEdit(
2658
2674
  * new token at the target prefix (or replaces the existing one for
2659
2675
  * the same stem).
2660
2676
  * - `"replace"`: same as `"add"` — `setPropertyClass` already handles the
2661
- * replace-if-same-stem semantics.
2677
+ * replace-if-same-stem semantics. When `intent.from` is set, the
2678
+ * EFFECTIVE utility at `prefix` must match it exactly or the edit
2679
+ * is rejected as `"conflict"` (guards against a stale selection
2680
+ * silently rewriting the wrong element/breakpoint). "Effective"
2681
+ * follows the Tailwind mobile-first cascade: an explicit override
2682
+ * at `prefix` wins; otherwise the nearest smaller breakpoint's
2683
+ * utility (down to base) is what the caller would have seen.
2662
2684
  * - `"remove"`: calls `removePropertyClass(className, prefix, stem)` — strips
2663
2685
  * all tokens with the given property stem at the target prefix,
2664
2686
  * falling back to the base value (Tailwind cascade).
@@ -2666,6 +2688,36 @@ function applyTextEdit(
2666
2688
  * Uses the helpers from `responsive-classes.ts` so the logic is shared with
2667
2689
  * the StatesPanel / inspector UI.
2668
2690
  */
2691
+ /** Mobile-first breakpoint order used to resolve the effective utility at a prefix. */
2692
+ const BREAKPOINT_CASCADE: ReadonlyArray<TailwindBreakpointPrefix> = [
2693
+ "base",
2694
+ "sm",
2695
+ "md",
2696
+ "lg",
2697
+ "xl",
2698
+ "2xl",
2699
+ ];
2700
+
2701
+ /**
2702
+ * Resolve the utilities EFFECTIVE at `prefix` for the given property `stem`,
2703
+ * following the Tailwind mobile-first cascade: an explicit override at
2704
+ * `prefix` wins; otherwise the nearest smaller breakpoint (down to base) with
2705
+ * a token for that stem is what actually renders there.
2706
+ */
2707
+ function effectivePropertyUtilities(
2708
+ className: string,
2709
+ prefix: TailwindBreakpointPrefix,
2710
+ stem: string,
2711
+ ): string[] {
2712
+ for (let i = BREAKPOINT_CASCADE.indexOf(prefix); i >= 0; i--) {
2713
+ const tokens = getPropertyClasses(className, BREAKPOINT_CASCADE[i], stem);
2714
+ if (tokens.length > 0) {
2715
+ return tokens.map((token) => parseClassToken(token).utility);
2716
+ }
2717
+ }
2718
+ return [];
2719
+ }
2720
+
2669
2721
  function applyResponsiveClassEdit(
2670
2722
  html: string,
2671
2723
  element: ParsedElement,
@@ -2685,6 +2737,18 @@ function applyResponsiveClassEdit(
2685
2737
  // "add" and "replace" both use setPropertyClass
2686
2738
  if (!intent.utility) return "unsupported";
2687
2739
  if (!isSafeClassToken(intent.utility)) return "unsupported";
2740
+ if (intent.from) {
2741
+ // `from` guard: the utility the caller expects to be effective at this
2742
+ // prefix. On mismatch (stale selection / wrong element) reject instead
2743
+ // of silently overwriting whatever is actually there.
2744
+ if (!isSafeClassToken(intent.from)) return "unsupported";
2745
+ const effective = effectivePropertyUtilities(
2746
+ currentClass,
2747
+ intent.prefix,
2748
+ utilityStem(intent.from),
2749
+ );
2750
+ if (!effective.includes(intent.from)) return "conflict";
2751
+ }
2688
2752
  nextClass = setPropertyClass(currentClass, intent.prefix, intent.utility);
2689
2753
  }
2690
2754
 
@@ -2849,6 +2913,85 @@ function stripAbsolutePositioningFromChild(
2849
2913
  return replaceOrInsertAttribute(html, child, "style", nextStyle);
2850
2914
  }
2851
2915
 
2916
+ /**
2917
+ * L7: sequential "Group N" naming. Counts existing layer names already
2918
+ * matching "Group" or "Group <number>" in the projection (via
2919
+ * data-agent-native-layer-name / layerName) and returns the next unused
2920
+ * name in that sequence, so repeated grouping doesn't leave multiple
2921
+ * ambiguous "Group" layers.
2922
+ */
2923
+ function nextSequentialGroupName(nodes: CodeLayerNode[]): string {
2924
+ const groupNamePattern = /^Group(?: (\d+))?$/;
2925
+ let highestNumbered = 0;
2926
+ let hasBareGroup = false;
2927
+ for (const node of nodes) {
2928
+ const match = groupNamePattern.exec(node.layerName.trim());
2929
+ if (!match) continue;
2930
+ if (match[1]) {
2931
+ highestNumbered = Math.max(highestNumbered, Number(match[1]));
2932
+ } else {
2933
+ hasBareGroup = true;
2934
+ }
2935
+ }
2936
+ if (!hasBareGroup && highestNumbered === 0) return "Group";
2937
+ return `Group ${Math.max(highestNumbered, hasBareGroup ? 1 : 0) + 1}`;
2938
+ }
2939
+
2940
+ interface AbsoluteUnionBounds {
2941
+ left: number;
2942
+ top: number;
2943
+ width: number;
2944
+ height: number;
2945
+ }
2946
+
2947
+ /**
2948
+ * L7: computes the union bounding box of a set of sibling elements, but only
2949
+ * when EVERY element is absolutely positioned with pixel left/top/width/
2950
+ * height. Returns null otherwise (mixed/flow children have no meaningful
2951
+ * bounding box without an actual layout pass — the wrapper falls back to a
2952
+ * plain flow div in that case).
2953
+ */
2954
+ function computeAbsoluteUnionBounds(
2955
+ elements: ParsedElement[],
2956
+ ): AbsoluteUnionBounds | null {
2957
+ let minLeft = Infinity;
2958
+ let minTop = Infinity;
2959
+ let maxRight = -Infinity;
2960
+ let maxBottom = -Infinity;
2961
+
2962
+ for (const element of elements) {
2963
+ const style = parseStyle(attributeValue(element, "style"));
2964
+ if (style.position !== "absolute") return null;
2965
+ const left = parsePixelLength(style.left);
2966
+ const top = parsePixelLength(style.top);
2967
+ const width = parsePixelLength(style.width);
2968
+ const height = parsePixelLength(style.height);
2969
+ if (left === null || top === null || width === null || height === null) {
2970
+ return null;
2971
+ }
2972
+ minLeft = Math.min(minLeft, left);
2973
+ minTop = Math.min(minTop, top);
2974
+ maxRight = Math.max(maxRight, left + width);
2975
+ maxBottom = Math.max(maxBottom, top + height);
2976
+ }
2977
+
2978
+ if (
2979
+ !Number.isFinite(minLeft) ||
2980
+ !Number.isFinite(minTop) ||
2981
+ !Number.isFinite(maxRight) ||
2982
+ !Number.isFinite(maxBottom)
2983
+ ) {
2984
+ return null;
2985
+ }
2986
+
2987
+ return {
2988
+ left: minLeft,
2989
+ top: minTop,
2990
+ width: maxRight - minLeft,
2991
+ height: maxBottom - minTop,
2992
+ };
2993
+ }
2994
+
2852
2995
  /**
2853
2996
  * GROUP: wrap targetted sibling elements (sharing a common parent) in a new
2854
2997
  * <div> wrapper. Targets must all share the same parent element.
@@ -2883,15 +3026,15 @@ function applyWrapNodes(
2883
3026
  // Sort targets by their source position (ascending).
2884
3027
  targetElements.sort((a, b) => a.start - b.start);
2885
3028
 
2886
- const siblingIndexes = targetElements
2887
- .map((el) => el.siblingIndex)
2888
- .sort((a, b) => a - b);
2889
- const firstSiblingIndex = siblingIndexes[0];
2890
- if (firstSiblingIndex === undefined) return "unsupported";
2891
- const targetsAreContiguous = siblingIndexes.every(
2892
- (siblingIndex, index) => siblingIndex === firstSiblingIndex + index,
2893
- );
2894
- if (!targetsAreContiguous) return "unsupported";
3029
+ // L6: targets no longer need to be sibling-index-CONTIGUOUS. The removal +
3030
+ // single-reinsertion-point algorithm below already extracts every target
3031
+ // (regardless of gaps) and re-inserts them together at the topmost
3032
+ // target's position — i.e. it already "moves members adjacent to the
3033
+ // topmost member, then wraps." A non-adjacent same-parent selection (e.g.
3034
+ // sibling indexes 0, 2, 4) closes its own gaps naturally: the un-selected
3035
+ // siblings that were between them (1, 3) end up adjacent to each other
3036
+ // once the targets are pulled out, and the targets end up adjacent to each
3037
+ // other inside the new wrapper. This matches Figma's group behavior.
2895
3038
 
2896
3039
  // Collect existing node ids so we can generate a unique one.
2897
3040
  const usedIds = new Set(
@@ -2906,10 +3049,26 @@ function applyWrapNodes(
2906
3049
  `wrap:${targetElements.map((el) => el.start).join(":")}`,
2907
3050
  );
2908
3051
 
3052
+ // L7: sequential "Group N" naming — count existing "Group"/"Group N" names
3053
+ // already in the projection so repeated grouping doesn't produce multiple
3054
+ // ambiguous layers all just named "Group".
3055
+ const wrapperLayerName = nextSequentialGroupName(build.projection.nodes);
3056
+
3057
+ // L7: when EVERY target is absolutely positioned with pixel left/top (and
3058
+ // ideally width/height), give the wrapper real computed geometry — the
3059
+ // union bounding box of its children — instead of a zero-geometry static
3060
+ // div. This matches Figma: grouping absolutely-positioned layers produces
3061
+ // a group frame sized/positioned to fit them, not a layout-only wrapper.
3062
+ // Falls back to the previous flow/auto-layout wrapper when any child isn't
3063
+ // absolutely positioned (there is no meaningful bounding box to compute
3064
+ // without a layout pass).
3065
+ const targetGeometry = !autoLayout
3066
+ ? computeAbsoluteUnionBounds(targetElements)
3067
+ : null;
3068
+
2909
3069
  // Collect the source fragments for all targets.
2910
3070
  const fragments = targetElements.map((el) => {
2911
3071
  let frag = html.slice(el.start, el.end);
2912
- // If autoLayout, strip positioning from each wrapped child.
2913
3072
  if (autoLayout) {
2914
3073
  // We need a ParsedElement that reflects the fragment's own positions.
2915
3074
  // Re-parse the fragment to find the root element and strip its style.
@@ -2918,14 +3077,31 @@ function applyWrapNodes(
2918
3077
  if (root) {
2919
3078
  frag = stripAbsolutePositioningFromChild(frag, root);
2920
3079
  }
3080
+ } else if (targetGeometry) {
3081
+ // Rebase each child's left/top from the old parent's coordinate space
3082
+ // into the new wrapper's coordinate space (wrapper now sits at the
3083
+ // union's top-left origin).
3084
+ const fragElements = parseHtmlElements(frag);
3085
+ const root = fragElements.find((fe) => fe.parentIndex === undefined);
3086
+ if (root) {
3087
+ frag = rebaseChildOffset(
3088
+ frag,
3089
+ root,
3090
+ -targetGeometry.left,
3091
+ -targetGeometry.top,
3092
+ );
3093
+ }
2921
3094
  }
2922
3095
  return frag;
2923
3096
  });
2924
3097
 
2925
- const autoLayoutStyle = autoLayout
2926
- ? ` style="display: flex; flex-direction: column; gap: 8px"`
2927
- : "";
2928
- const wrapperOpen = `<div data-agent-native-node-id="${escapeHtmlAttribute(wrapperNodeId)}" data-agent-native-layer-name="Group"${autoLayoutStyle}>`;
3098
+ const wrapperStyle = autoLayout
3099
+ ? "display: flex; flex-direction: column; gap: 8px"
3100
+ : targetGeometry
3101
+ ? `position: absolute; left: ${targetGeometry.left}px; top: ${targetGeometry.top}px; width: ${targetGeometry.width}px; height: ${targetGeometry.height}px;`
3102
+ : null;
3103
+ const wrapperStyleAttr = wrapperStyle ? ` style="${wrapperStyle}"` : "";
3104
+ const wrapperOpen = `<div data-agent-native-node-id="${escapeHtmlAttribute(wrapperNodeId)}" data-agent-native-layer-name="${escapeHtmlAttribute(wrapperLayerName)}"${wrapperStyleAttr}>`;
2929
3105
  const wrapperClose = `</div>`;
2930
3106
  const wrapperContent = `${wrapperOpen}${fragments.join("")}${wrapperClose}`;
2931
3107
 
@@ -2970,9 +3146,71 @@ function applyWrapNodes(
2970
3146
  };
2971
3147
  }
2972
3148
 
3149
+ /** Parse a CSS length like "12px" into a finite pixel number, else null. */
3150
+ function parsePixelLength(value: string | undefined): number | null {
3151
+ if (!value) return null;
3152
+ const match = /^(-?[\d.]+)px$/.exec(value.trim());
3153
+ if (!match) return null;
3154
+ const parsed = Number(match[1]);
3155
+ return Number.isFinite(parsed) ? parsed : null;
3156
+ }
3157
+
3158
+ /**
3159
+ * Rebase a single child element's `left`/`top` inline-style offsets by the
3160
+ * given deltas (adding the unwrapped wrapper's own offset so the child keeps
3161
+ * its absolute screen position once reparented into the wrapper's parent).
3162
+ * No-ops (returns html unchanged) when the child isn't absolutely positioned
3163
+ * with a pixel offset — non-absolute children have no coordinate space to
3164
+ * rebase, and non-pixel units (%, calc(), var()) can't be safely combined
3165
+ * without a layout pass.
3166
+ */
3167
+ function rebaseChildOffset(
3168
+ html: string,
3169
+ child: ParsedElement,
3170
+ deltaLeftPx: number,
3171
+ deltaTopPx: number,
3172
+ ): string {
3173
+ const currentStyle = attributeValue(child, "style");
3174
+ const style = parseStyle(currentStyle);
3175
+ if (style.position !== "absolute") return html;
3176
+
3177
+ let nextStyle = currentStyle ?? "";
3178
+ const currentLeft = parsePixelLength(style.left);
3179
+ if (currentLeft !== null && deltaLeftPx !== 0) {
3180
+ nextStyle = setStyleValue(
3181
+ nextStyle,
3182
+ "left",
3183
+ `${currentLeft + deltaLeftPx}px`,
3184
+ );
3185
+ }
3186
+ const currentTop = parsePixelLength(style.top);
3187
+ if (currentTop !== null && deltaTopPx !== 0) {
3188
+ nextStyle = setStyleValue(nextStyle, "top", `${currentTop + deltaTopPx}px`);
3189
+ }
3190
+ if (nextStyle === (currentStyle ?? "")) return html;
3191
+ return replaceOrInsertAttribute(html, child, "style", nextStyle);
3192
+ }
3193
+
2973
3194
  /**
2974
3195
  * UNGROUP: replace the wrapper node with its children, spliced into the
2975
3196
  * wrapper's parent at the wrapper's position, then remove the wrapper.
3197
+ *
3198
+ * L3 safety: only containers (elements with at least one direct element
3199
+ * child) can be ungrouped. A leaf node (text-only element like <p>Hello</p>
3200
+ * or a void/self-closing element) has no children to "release" — splicing
3201
+ * its inner text/content directly into the parent would silently destroy
3202
+ * the element's own tag, attributes, and styles. Callers (canUngroup gate)
3203
+ * are expected to also pre-filter to containers, but this is the
3204
+ * authoritative, safety-critical check since applyUnwrap can be invoked
3205
+ * directly.
3206
+ *
3207
+ * L3 coordinate rebase: when the wrapper being removed is itself absolutely
3208
+ * positioned with pixel left/top offsets, its direct children were
3209
+ * positioned relative to IT. Splicing them into the wrapper's parent without
3210
+ * adjustment would silently shift every absolutely-positioned child by the
3211
+ * wrapper's former offset. Rebase each such child's left/top by adding the
3212
+ * wrapper's offset so it keeps the same absolute screen position under the
3213
+ * new parent.
2976
3214
  */
2977
3215
  function applyUnwrap(
2978
3216
  html: string,
@@ -2994,9 +3232,46 @@ function applyUnwrap(
2994
3232
  // Nothing to unwrap from an empty/void element.
2995
3233
  return "unsupported";
2996
3234
  }
3235
+ if (element.childIndexes.length === 0) {
3236
+ // Leaf node (text-only or otherwise childless): there is nothing to
3237
+ // "release" as children, and splicing raw inner content into the parent
3238
+ // would destroy this element's own identity (tag/attrs/styles).
3239
+ return "unsupported";
3240
+ }
2997
3241
 
2998
- // The children's source content is the element's inner HTML.
2999
- const innerContent = html.slice(element.contentStart, element.contentEnd);
3242
+ // If the wrapper itself is absolutely positioned with pixel left/top,
3243
+ // rebase each direct child's own absolute left/top by that offset before
3244
+ // splicing, so children keep their absolute screen position once
3245
+ // reparented. Re-parse the wrapper's inner HTML in isolation so child
3246
+ // element spans are relative to that fragment (matches how
3247
+ // replaceOrInsertAttribute below operates on the fragment, not the outer
3248
+ // document offsets).
3249
+ const wrapperStyle = parseStyle(attributeValue(element, "style"));
3250
+ const wrapperLeft = parsePixelLength(wrapperStyle.left);
3251
+ const wrapperTop = parsePixelLength(wrapperStyle.top);
3252
+ const shouldRebase =
3253
+ wrapperStyle.position === "absolute" &&
3254
+ (wrapperLeft !== null || wrapperTop !== null);
3255
+
3256
+ let innerContent = html.slice(element.contentStart, element.contentEnd);
3257
+ if (shouldRebase) {
3258
+ const deltaLeftPx = wrapperLeft ?? 0;
3259
+ const deltaTopPx = wrapperTop ?? 0;
3260
+ const fragmentElements = parseHtmlElements(innerContent);
3261
+ const directChildren = fragmentElements.filter(
3262
+ (fe) => fe.parentIndex === undefined,
3263
+ );
3264
+ // Apply back-to-front so earlier offsets in the fragment stay valid as
3265
+ // later ones are rewritten.
3266
+ for (const child of [...directChildren].sort((a, b) => b.start - a.start)) {
3267
+ innerContent = rebaseChildOffset(
3268
+ innerContent,
3269
+ child,
3270
+ deltaLeftPx,
3271
+ deltaTopPx,
3272
+ );
3273
+ }
3274
+ }
3000
3275
 
3001
3276
  // Replace the whole element (start..end) with its inner content.
3002
3277
  const result = `${html.slice(0, element.start)}${innerContent}${html.slice(element.end)}`;
@@ -3178,19 +3453,22 @@ export function applyVisualEdit(
3178
3453
  if (intent.kind === "wrapNodes") {
3179
3454
  const wrapEdit = applyWrapNodes(html, initial, intent);
3180
3455
  if (typeof wrapEdit === "string") {
3456
+ // L6: a distinct, specific message per failure kind instead of one
3457
+ // generic "group failed" toast — the previous single message
3458
+ // ("...share a common parent element") was misleadingly shown even
3459
+ // when the real cause was an empty selection or an unresolvable node,
3460
+ // making it hard for the user to tell what to fix.
3461
+ const message =
3462
+ wrapEdit === "unsupported"
3463
+ ? intent.targetIds.length === 0
3464
+ ? "Select at least one layer to group."
3465
+ : "Group requires all selected layers to share the same parent."
3466
+ : "Could not find one or more selected layers to group — the selection may be stale.";
3181
3467
  return {
3182
3468
  content: html,
3183
3469
  projection: initial.projection,
3184
3470
  result: {
3185
- ...patchResult(
3186
- wrapEdit,
3187
- source,
3188
- intent,
3189
- false,
3190
- wrapEdit === "unsupported"
3191
- ? "wrapNodes requires all targets to share a common parent element."
3192
- : "Could not resolve one or more target nodes for wrapNodes.",
3193
- ),
3471
+ ...patchResult(wrapEdit, source, intent, false, message),
3194
3472
  },
3195
3473
  };
3196
3474
  }
@@ -205,6 +205,90 @@ export function parseCssColor(value: string): RgbaColor | null {
205
205
  return null;
206
206
  }
207
207
 
208
+ // `parseCssColor` above handles hex, comma-separated rgb/rgba, and hsl/hsla.
209
+ // Browsers increasingly emit modern CSS Level 4 formats from getComputedStyle:
210
+ // space-separated `rgb(R G B)`, `rgb(R G B / A)`, and opaque formats like
211
+ // `oklch(...)` or `color(display-p3 ...)`. `parseCssColorExtended` covers those
212
+ // cases so that colors arriving from a canvas's computed-style bridge are
213
+ // always usable.
214
+
215
+ const MODERN_RGB_PATTERN =
216
+ /^rgba?\(\s*([0-9.]+)\s+([0-9.]+)\s+([0-9.]+)(?:\s*\/\s*([0-9.]+%?))?\s*\)$/i;
217
+
218
+ /** Canvas element reused across calls for DOM-based color resolution. */
219
+ let _resolverCanvas: HTMLCanvasElement | null = null;
220
+ let _resolverCtx: CanvasRenderingContext2D | null = null;
221
+
222
+ /**
223
+ * Parses a CSS color string into RgbaColor, extending the base parser with:
224
+ * - Modern space-separated `rgb(R G B)` / `rgb(R G B / A)` syntax
225
+ * - Opaque formats (oklch, color, etc.) resolved via a hidden canvas
226
+ *
227
+ * Falls back to null if the value is unparseable and the DOM is unavailable.
228
+ */
229
+ export function parseCssColorExtended(value: string): RgbaColor | null {
230
+ // 1. Try the standard parser first (handles hex, comma rgb/rgba, hsl/hsla).
231
+ const standard = parseCssColor(value);
232
+ if (standard) return standard;
233
+
234
+ const trimmed = value.trim();
235
+ if (!trimmed || trimmed === "transparent" || trimmed === "none") return null;
236
+
237
+ // 2. Modern space-separated rgb/rgba — CSS Level 4.
238
+ const modernRgb = trimmed.match(MODERN_RGB_PATTERN);
239
+ if (modernRgb) {
240
+ const parseAlphaLocal = (v: string | undefined): number => {
241
+ if (!v) return 1;
242
+ if (v.endsWith("%"))
243
+ return Math.max(0, Math.min(1, Number(v.slice(0, -1)) / 100));
244
+ return Math.max(0, Math.min(1, Number(v)));
245
+ };
246
+ return {
247
+ r: Math.round(Math.max(0, Math.min(255, Number(modernRgb[1])))),
248
+ g: Math.round(Math.max(0, Math.min(255, Number(modernRgb[2])))),
249
+ b: Math.round(Math.max(0, Math.min(255, Number(modernRgb[3])))),
250
+ a: parseAlphaLocal(modernRgb[4]),
251
+ };
252
+ }
253
+
254
+ // 3. DOM-based resolver for oklch, color(display-p3 ...), hsl (modern), etc.
255
+ // Uses a hidden 1×1 canvas to resolve any valid CSS color to rgb().
256
+ if (typeof document === "undefined") return null;
257
+ try {
258
+ if (!_resolverCanvas) {
259
+ _resolverCanvas = document.createElement("canvas");
260
+ _resolverCanvas.width = 1;
261
+ _resolverCanvas.height = 1;
262
+ }
263
+ if (!_resolverCtx) {
264
+ _resolverCtx = _resolverCanvas.getContext("2d", {
265
+ willReadFrequently: true,
266
+ });
267
+ }
268
+ const ctx = _resolverCtx;
269
+ if (!ctx) return null;
270
+ // Detect invalid color values: reset fillStyle to a sentinel that can
271
+ // never be the browser's normalized output for a real color (an
272
+ // out-of-gamut placeholder), then assign the candidate and compare.
273
+ // Comparing against the *previous* candidate's fillStyle (instead of a
274
+ // fixed sentinel) misfires when two consecutive calls resolve to the
275
+ // same valid color — the "rejected, unchanged" check would incorrectly
276
+ // trip even though the value was accepted and just happens to match.
277
+ const sentinel = "#010203";
278
+ ctx.fillStyle = sentinel;
279
+ ctx.fillStyle = trimmed;
280
+ const next = ctx.fillStyle; // browser normalises to rgb/hex on accept
281
+ // If the value was rejected, fillStyle stays at the sentinel.
282
+ if (next === sentinel) return null;
283
+ ctx.clearRect(0, 0, 1, 1);
284
+ ctx.fillRect(0, 0, 1, 1);
285
+ const [r, g, b, a] = ctx.getImageData(0, 0, 1, 1).data;
286
+ return { r, g, b, a: a / 255 };
287
+ } catch {
288
+ return null;
289
+ }
290
+ }
291
+
208
292
  export function hexToRgba(value: string): RgbaColor | null {
209
293
  const raw = value.trim().replace(/^#/, "");
210
294
  const expanded =
@@ -174,16 +174,25 @@ export const INLINE_DEFAULT_CAPABILITIES: DesignSourceCapabilities = {
174
174
  /**
175
175
  * Default capability map for **localhost** designs.
176
176
  *
177
- * Starts read-only / preview-only; write ops become `available` after bridge
178
- * hardening. Real-app features (`indexComponents`, `writeTokens`, etc.) are
179
- * `planned` and light up once the bridge proves capability.
177
+ * File I/O (`readFile`, `writeFile`, `applyEdit`) is `available` through the
178
+ * design bridge started by `agent-native design connect`: reads and writes go
179
+ * through the bridge's token-authenticated `/read-file`, `/write-file`, and
180
+ * `/apply-edit` endpoints, and every write additionally requires an explicit
181
+ * user write-consent grant (`grant-localhost-write-consent` +
182
+ * `verifyWriteGrant`). Genuinely-unshipped real-app features
183
+ * (`indexComponents`, `writeTokens`) remain `planned` and light up once the
184
+ * bridge proves those capabilities.
180
185
  */
181
186
  export const LOCALHOST_DEFAULT_CAPABILITIES: DesignSourceCapabilities = {
182
- readFile: planned("Local file reads require the next bridge hardening pass"),
183
- writeFile: planned(
184
- "Local file writes require the next bridge hardening pass",
187
+ readFile: available(
188
+ "Local file reads go through the design bridge (agent-native design connect)",
189
+ ),
190
+ writeFile: available(
191
+ "Local file writes go through the design bridge after user write consent",
192
+ ),
193
+ applyEdit: available(
194
+ "Local source edits go through the design bridge after user write consent",
185
195
  ),
186
- applyEdit: planned("Source edits require the next bridge hardening pass"),
187
196
  resolveNodeToFile: available(),
188
197
  previewPatch: available(),
189
198
  diffPatch: available(),