@agent-native/core 0.80.9 → 0.80.11

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 (148) hide show
  1. package/corpus/README.md +1 -1
  2. package/corpus/core/CHANGELOG.md +13 -0
  3. package/corpus/core/package.json +1 -1
  4. package/corpus/core/src/agent/production-agent.ts +108 -15
  5. package/corpus/core/src/cli/design-connect.ts +28 -2
  6. package/corpus/core/src/cli/skills.ts +45 -24
  7. package/corpus/core/src/client/AssistantChat.tsx +123 -26
  8. package/corpus/core/src/client/sse-event-processor.ts +9 -0
  9. package/corpus/templates/assets/.agents/skills/image-generation/SKILL.md +9 -2
  10. package/corpus/templates/assets/AGENTS.md +4 -0
  11. package/corpus/templates/assets/actions/_tool-activity.ts +46 -0
  12. package/corpus/templates/assets/actions/generate-image-batch.ts +26 -5
  13. package/corpus/templates/assets/actions/generate-image.ts +118 -67
  14. package/corpus/templates/assets/actions/list-draft-assets.ts +50 -0
  15. package/corpus/templates/assets/actions/rerun-generation-run.ts +52 -31
  16. package/corpus/templates/assets/app/components/create/RecentDraftsSection.tsx +100 -0
  17. package/corpus/templates/assets/app/i18n/zh-TW.ts +7 -0
  18. package/corpus/templates/assets/app/i18n-data.ts +61 -0
  19. package/corpus/templates/assets/app/routes/_index.tsx +4 -0
  20. package/corpus/templates/assets/app/routes/library.tsx +145 -38
  21. package/corpus/templates/assets/changelog/2026-06-29-image-generation-can-now-render-requested-text-and-respect-b.md +6 -0
  22. package/corpus/templates/assets/changelog/2026-06-30-image-generation-no-longer-looks-stuck-while-the-provider-is-still-working.md +6 -0
  23. package/corpus/templates/assets/changelog/2026-06-30-tagged-presets-now-control-the-image-aspect-ratio.md +6 -0
  24. package/corpus/templates/assets/server/lib/generation.ts +135 -11
  25. package/corpus/templates/assets/shared/api.ts +4 -0
  26. package/corpus/templates/clips/actions/list-ai-requests.ts +60 -0
  27. package/corpus/templates/clips/app/hooks/use-auto-title.ts +24 -25
  28. package/corpus/templates/design/DESIGN-STUDIO-PLAN.md +717 -0
  29. package/corpus/templates/design/actions/add-breakpoint.ts +143 -0
  30. package/corpus/templates/design/actions/add-localhost-screens.ts +5 -0
  31. package/corpus/templates/design/actions/apply-a11y-fix.ts +317 -0
  32. package/corpus/templates/design/actions/apply-component-prop-edit.ts +441 -0
  33. package/corpus/templates/design/actions/apply-design-state.ts +213 -0
  34. package/corpus/templates/design/actions/apply-design-token-edit.ts +202 -0
  35. package/corpus/templates/design/actions/apply-motion-edit.ts +413 -0
  36. package/corpus/templates/design/actions/apply-shader-fill.ts +404 -0
  37. package/corpus/templates/design/actions/apply-visual-edit.ts +191 -5
  38. package/corpus/templates/design/actions/capture-design-state.ts +224 -0
  39. package/corpus/templates/design/actions/connect-builder-app.ts +162 -0
  40. package/corpus/templates/design/actions/create-component.ts +447 -0
  41. package/corpus/templates/design/actions/create-design-branch.ts +263 -0
  42. package/corpus/templates/design/actions/create-design-state.ts +162 -0
  43. package/corpus/templates/design/actions/delete-design-state.ts +55 -0
  44. package/corpus/templates/design/actions/delete-design.ts +20 -0
  45. package/corpus/templates/design/actions/deploy-design-preview.ts +275 -0
  46. package/corpus/templates/design/actions/get-component-details.ts +251 -0
  47. package/corpus/templates/design/actions/get-design-branch-diff.ts +362 -0
  48. package/corpus/templates/design/actions/get-design-review.ts +314 -0
  49. package/corpus/templates/design/actions/get-design-surface-index.ts +582 -0
  50. package/corpus/templates/design/actions/get-motion-timeline.ts +99 -0
  51. package/corpus/templates/design/actions/index-components.ts +258 -0
  52. package/corpus/templates/design/actions/index-design-tokens.ts +277 -0
  53. package/corpus/templates/design/actions/list-design-extensions.ts +309 -0
  54. package/corpus/templates/design/actions/list-design-source-capabilities.ts +153 -0
  55. package/corpus/templates/design/actions/list-design-states.ts +90 -0
  56. package/corpus/templates/design/actions/migrate-inline-design-to-app.ts +298 -0
  57. package/corpus/templates/design/actions/open-component-source.ts +242 -0
  58. package/corpus/templates/design/actions/preview-component-prop-edit.ts +260 -0
  59. package/corpus/templates/design/actions/preview-design-token-edit.ts +113 -0
  60. package/corpus/templates/design/actions/preview-shader-fill.ts +187 -0
  61. package/corpus/templates/design/actions/remove-breakpoint.ts +103 -0
  62. package/corpus/templates/design/actions/remove-motion-timeline.ts +196 -0
  63. package/corpus/templates/design/actions/run-design-audit.ts +436 -0
  64. package/corpus/templates/design/actions/run-design-extension-action.ts +284 -0
  65. package/corpus/templates/design/actions/set-active-breakpoint.ts +39 -0
  66. package/corpus/templates/design/app/components/design/DesignCanvas.tsx +1550 -82
  67. package/corpus/templates/design/app/components/design/DesignExtensionsPanel.tsx +649 -43
  68. package/corpus/templates/design/app/components/design/DrawOverlay.tsx +1 -1
  69. package/corpus/templates/design/app/components/design/EditPanel.tsx +1788 -52
  70. package/corpus/templates/design/app/components/design/LayersPanel.tsx +114 -33
  71. package/corpus/templates/design/app/components/design/LocalSourceEditBanner.tsx +157 -0
  72. package/corpus/templates/design/app/components/design/MotionDock.tsx +1071 -0
  73. package/corpus/templates/design/app/components/design/MultiScreenCanvas.tsx +1053 -99
  74. package/corpus/templates/design/app/components/design/ReviewPanel.tsx +766 -0
  75. package/corpus/templates/design/app/components/design/StatesPanel.tsx +581 -0
  76. package/corpus/templates/design/app/components/design/TokensPanel.tsx +578 -0
  77. package/corpus/templates/design/app/components/design/canvas-primitive-style.ts +241 -0
  78. package/corpus/templates/design/app/components/design/index.ts +16 -0
  79. package/corpus/templates/design/app/components/design/inspector/AutoLayoutMatrix.tsx +15 -6
  80. package/corpus/templates/design/app/components/design/inspector/DesignColorPicker.tsx +66 -3
  81. package/corpus/templates/design/app/components/design/inspector/InspectorAiActions.tsx +145 -0
  82. package/corpus/templates/design/app/components/design/inspector/SHADER_INTEGRATION.md +42 -12
  83. package/corpus/templates/design/app/components/visual-editor/DrawOverlay.tsx +1 -1
  84. package/corpus/templates/design/app/hooks/useAgentEditRequest.ts +131 -0
  85. package/corpus/templates/design/app/hooks/useDesignHotkeys.ts +3 -1
  86. package/corpus/templates/design/app/i18n/zh-TW.ts +32 -0
  87. package/corpus/templates/design/app/i18n-data.ts +280 -0
  88. package/corpus/templates/design/app/pages/DesignEditor.tsx +3087 -490
  89. package/corpus/templates/design/changelog/2026-06-29-added-a-review-panel-in-the-design-editor-s-inspector-with-a.md +6 -0
  90. package/corpus/templates/design/changelog/2026-06-29-copying-and-pasting-layers-no-longer-shows-success-notificat.md +6 -0
  91. package/corpus/templates/design/changelog/2026-06-29-device-presets-in-all-screens-view-resize-the-selected-previ.md +6 -0
  92. package/corpus/templates/design/changelog/2026-06-29-layer-and-canvas-drags-keep-the-layer-list-stable-while-chan.md +6 -0
  93. package/corpus/templates/design/changelog/2026-06-29-layer-moves-can-be-undone-and-redone-without-flashing-the-ca.md +6 -0
  94. package/corpus/templates/design/changelog/2026-06-29-screen-previews-use-a-single-blue-hover-border-in-all-screen.md +6 -0
  95. package/corpus/templates/design/changelog/2026-06-29-selected-containers-show-draggable-padding-and-gap-guides-on.md +6 -0
  96. package/corpus/templates/design/changelog/2026-06-29-the-design-editor-adds-a-studio-layer-with-tokens-respon.md +6 -0
  97. package/corpus/templates/design/changelog/2026-06-30-accessibility-findings-now-offer-a-one-click-fix.md +6 -0
  98. package/corpus/templates/design/changelog/2026-06-30-component-instances-now-have-editable-props-in-the-inspe.md +6 -0
  99. package/corpus/templates/design/changelog/2026-06-30-design-token-edits-now-stay-visible-in-previews-and-token-li.md +6 -0
  100. package/corpus/templates/design/changelog/2026-06-30-inspect-code-now-shows-the-elements-opening-tag-at-a-gla.md +6 -0
  101. package/corpus/templates/design/changelog/2026-06-30-shader-fill-presets-can-now-be-applied-to-an-element.md +6 -0
  102. package/corpus/templates/design/changelog/2026-06-30-the-motion-timeline-can-now-add-a-track-to-any-element-a.md +6 -0
  103. package/corpus/templates/design/changelog/2026-06-30-visual-editor-selection-layer-paint-and-drawing-controls-are-more-reliable.md +6 -0
  104. package/corpus/templates/design/e2e/global-setup.ts +94 -2
  105. package/corpus/templates/design/e2e/helpers.ts +10 -4
  106. package/corpus/templates/design/server/db/schema.ts +123 -0
  107. package/corpus/templates/design/server/plugins/db.ts +90 -0
  108. package/corpus/templates/design/shared/builder-app.ts +297 -0
  109. package/corpus/templates/design/shared/capability-resolver.ts +123 -0
  110. package/corpus/templates/design/shared/capture-sanitize.ts +70 -0
  111. package/corpus/templates/design/shared/code-layer.ts +1016 -71
  112. package/corpus/templates/design/shared/component-model.ts +274 -0
  113. package/corpus/templates/design/shared/design-review.ts +275 -0
  114. package/corpus/templates/design/shared/design-source-capabilities.ts +286 -0
  115. package/corpus/templates/design/shared/design-state.ts +112 -0
  116. package/corpus/templates/design/shared/design-surface-index.ts +258 -0
  117. package/corpus/templates/design/shared/motion-compiler.ts +349 -0
  118. package/corpus/templates/design/shared/motion-timeline.ts +193 -0
  119. package/corpus/templates/design/shared/resolve-tweaks.ts +32 -9
  120. package/corpus/templates/design/shared/responsive-classes.ts +452 -0
  121. package/corpus/templates/design/shared/shader-fill.ts +323 -0
  122. package/corpus/templates/design/shared/source-mode.ts +245 -0
  123. package/corpus/templates/plan/app/components/plan/CanvasArea.tsx +6 -2
  124. package/corpus/templates/plan/changelog/2026-06-29-plan-canvases-open-without-an-initial-pan-and-zoom-flicker.md +6 -0
  125. package/dist/agent/production-agent.d.ts.map +1 -1
  126. package/dist/agent/production-agent.js +94 -10
  127. package/dist/agent/production-agent.js.map +1 -1
  128. package/dist/cli/design-connect.d.ts.map +1 -1
  129. package/dist/cli/design-connect.js +28 -2
  130. package/dist/cli/design-connect.js.map +1 -1
  131. package/dist/cli/skills.d.ts.map +1 -1
  132. package/dist/cli/skills.js +37 -21
  133. package/dist/cli/skills.js.map +1 -1
  134. package/dist/client/AssistantChat.d.ts +14 -0
  135. package/dist/client/AssistantChat.d.ts.map +1 -1
  136. package/dist/client/AssistantChat.js +115 -22
  137. package/dist/client/AssistantChat.js.map +1 -1
  138. package/dist/client/sse-event-processor.d.ts.map +1 -1
  139. package/dist/client/sse-event-processor.js +10 -0
  140. package/dist/client/sse-event-processor.js.map +1 -1
  141. package/dist/collab/routes.d.ts +1 -1
  142. package/dist/file-upload/actions/upload-image.d.ts +2 -2
  143. package/dist/notifications/routes.d.ts +2 -2
  144. package/dist/observability/routes.d.ts +8 -8
  145. package/dist/resources/handlers.d.ts +2 -2
  146. package/dist/server/agent-engine-api-key-route.d.ts +1 -1
  147. package/dist/server/transcribe-voice.d.ts +1 -1
  148. package/package.json +1 -1
@@ -1,4 +1,8 @@
1
- import { useT } from "@agent-native/core/client";
1
+ import {
2
+ useActionMutation,
3
+ useActionQuery,
4
+ useT,
5
+ } from "@agent-native/core/client";
2
6
  import type { TweakDefinition } from "@shared/api";
3
7
  import {
4
8
  alphaToOpacity,
@@ -7,6 +11,7 @@ import {
7
11
  rgbaToHex,
8
12
  withColorOpacity,
9
13
  } from "@shared/color-utils";
14
+ import { propNameToDataAttribute } from "@shared/component-model";
10
15
  import {
11
16
  IconAlignCenter,
12
17
  IconAlignJustified,
@@ -14,13 +19,15 @@ import {
14
19
  IconAlignRight,
15
20
  IconArrowAutofitHeight,
16
21
  IconArrowAutofitWidth,
22
+ IconArrowRight,
17
23
  IconBackground,
18
24
  IconBlur,
19
25
  IconBorderStyle,
20
26
  IconBrush,
21
- IconChevronDown,
22
27
  IconCode,
23
28
  IconComponents,
29
+ IconDeviceDesktop,
30
+ IconExternalLink,
24
31
  IconDroplet,
25
32
  IconEye,
26
33
  IconEyeOff,
@@ -29,6 +36,8 @@ import {
29
36
  IconFrame,
30
37
  IconLayoutDistributeHorizontal,
31
38
  IconLayoutGrid,
39
+ IconLoader2,
40
+ IconShieldCheck,
32
41
  IconSlice,
33
42
  IconLayoutAlignBottom,
34
43
  IconLayoutAlignCenter,
@@ -52,6 +61,7 @@ import {
52
61
  IconUnlink,
53
62
  IconVector,
54
63
  } from "@tabler/icons-react";
64
+ import { useQueryClient } from "@tanstack/react-query";
55
65
  import {
56
66
  useCallback,
57
67
  useEffect,
@@ -59,8 +69,18 @@ import {
59
69
  useState,
60
70
  type ReactNode,
61
71
  } from "react";
72
+ import { useParams } from "react-router";
73
+ import { toast } from "sonner";
62
74
 
63
75
  import { Button } from "@/components/ui/button";
76
+ import {
77
+ Dialog,
78
+ DialogContent,
79
+ DialogDescription,
80
+ DialogFooter,
81
+ DialogHeader,
82
+ DialogTitle,
83
+ } from "@/components/ui/dialog";
64
84
  import {
65
85
  DropdownMenu,
66
86
  DropdownMenuContent,
@@ -82,6 +102,7 @@ import {
82
102
  SelectValue,
83
103
  } from "@/components/ui/select";
84
104
  import { Slider } from "@/components/ui/slider";
105
+ import { Switch } from "@/components/ui/switch";
85
106
  import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
86
107
  import {
87
108
  Tooltip,
@@ -117,11 +138,24 @@ import {
117
138
  } from "./inspector";
118
139
  import { IconLayoutSettings } from "./inspector/design-icons";
119
140
  import type { DesignPaintType } from "./inspector/DesignColorPicker";
141
+ import { InspectorAiActions } from "./inspector/InspectorAiActions";
142
+ import { ReviewPanel } from "./ReviewPanel";
143
+ import type { ReviewPanelProps } from "./ReviewPanel";
144
+ import { StatesPanel } from "./StatesPanel";
145
+ import type { StatesPanelProps } from "./StatesPanel";
146
+ import { TokensPanel } from "./TokensPanel";
120
147
  import { TweaksPanelContent } from "./TweaksPanel";
121
148
  import type { ElementInfo } from "./types";
122
149
 
123
150
  export type InspectorTab = "design" | "tweaks" | "extensions";
124
151
 
152
+ /**
153
+ * Sub-tab within the Tweaks area: "tokens" = TokensPanel face (CSS-var
154
+ * editing through the Tweaks loop); "tweaks" = raw TweakDefinition controls.
155
+ * Defaults to "tokens" when a designId is provided, otherwise "tweaks".
156
+ */
157
+ type TweaksSubTab = "tokens" | "tweaks";
158
+
125
159
  interface EditPanelProps {
126
160
  selectedElement: ElementInfo | null;
127
161
  pageStyles?: Record<string, string>;
@@ -137,9 +171,89 @@ interface EditPanelProps {
137
171
  onRequestTweaks?: (anchor: HTMLElement) => void;
138
172
  onStyleChange: (property: string, value: string) => void;
139
173
  onStylesChange?: (styles: Record<string, string>) => void;
174
+ onAutoLayoutConvert?: (
175
+ targetNodeId: string,
176
+ opts?: { direction?: "row" | "column"; gap?: string },
177
+ ) => void;
140
178
  onExport?: (settings: ExportSettingsValue[]) => void;
141
179
  exporting?: boolean;
142
180
  readOnly?: boolean;
181
+ /** Active file id — forwarded to InspectorAiActions for agent context. */
182
+ fileId?: string;
183
+ /** Active file name (e.g. "index.html") — forwarded to InspectorAiActions. */
184
+ filename?: string;
185
+ // -------------------------------------------------------------------------
186
+ // Design Studio panels (§6.2, §6.4, §6.5)
187
+ // Pass `designId` to unlock Tokens, States, and Review sections.
188
+ // -------------------------------------------------------------------------
189
+ /** The active design's id — required to mount Tokens / States / Review. */
190
+ designId?: string;
191
+ /**
192
+ * Called after a component prop edit returns the patched source so the parent
193
+ * editor can sync local/Yjs content instead of waiting for query invalidation.
194
+ */
195
+ onComponentPropApplied?: (fileId: string, content: string) => void;
196
+ /**
197
+ * Called after a token edit is applied so the parent can push the resolved
198
+ * CSS-var map into the iframe via the tweak-values postMessage.
199
+ */
200
+ onTokensApplied?: (resolvedCssVars: Record<string, string>) => void;
201
+ /** Props forwarded to the StatesPanel (§6.4). Requires `designId`. */
202
+ statesPanelProps?: Omit<StatesPanelProps, "designId">;
203
+ /** Props forwarded to the ReviewPanel (§6.5). */
204
+ reviewPanelProps?: Omit<ReviewPanelProps, "className">;
205
+ // -------------------------------------------------------------------------
206
+ // Component section (§6.1)
207
+ // When a component instance is selected, pass its node id here to unlock
208
+ // the contextual Component section at the top of the Design tab.
209
+ // -------------------------------------------------------------------------
210
+ /**
211
+ * The `data-agent-native-node-id` of the currently-selected component root
212
+ * element. When provided (along with `designId`), a Component section is
213
+ * shown at the top of the Design tab with name, source path, prop controls,
214
+ * and an Edit component action.
215
+ */
216
+ componentNodeId?: string;
217
+ /**
218
+ * Source capabilities for the current design. Used to gate the Edit
219
+ * component / jump-to-source affordances. When absent all writes default
220
+ * to disabled (inline / Alpine tier behaviour).
221
+ */
222
+ sourceCapabilities?: string[];
223
+ // -------------------------------------------------------------------------
224
+ // Selection header quick actions ("Create component" + "Inspect code")
225
+ // -------------------------------------------------------------------------
226
+ /**
227
+ * Promote the current selection into a reusable component. Receives the
228
+ * (already-normalized-by-the-action) component name the user typed. When
229
+ * omitted the "Create component" button is disabled.
230
+ */
231
+ onCreateComponent?: (name: string) => void;
232
+ /** Suggested default name for the create-component dialog. */
233
+ defaultComponentName?: string;
234
+ /** Code-inspection data for the "Inspect code" popover. */
235
+ inspectCode?: InspectCodeData;
236
+ }
237
+
238
+ /**
239
+ * Data backing the "Inspect code" popover. The parent resolves the selected
240
+ * node's HTML and (for real-app sources) its source file location.
241
+ */
242
+ export interface InspectCodeData {
243
+ /** Outer HTML of the selected element (inline / Alpine source). */
244
+ html?: string | null;
245
+ /**
246
+ * Resolved source file for real-app sources (localhost / fusion), when the
247
+ * resolveNodeToFile capability is available.
248
+ */
249
+ sourceLocation?: {
250
+ /** Absolute path on disk — used to build the vscode:// deep link. */
251
+ absolutePath: string;
252
+ line?: number;
253
+ column?: number;
254
+ /** Optional snippet to show above the Open-in-VS-Code button. */
255
+ snippet?: string;
256
+ } | null;
143
257
  }
144
258
 
145
259
  /**
@@ -574,7 +688,7 @@ function ColorInput({
574
688
  onImageFillChange={onImageFillChange}
575
689
  blendMode={blendMode}
576
690
  onBlendModeChange={onBlendModeChange}
577
- showBlendMode={supportsLayeredFills}
691
+ showBlendMode={Boolean(onBlendModeChange)}
578
692
  fillRows={fillRows}
579
693
  selectedFillId={selectedFillId}
580
694
  onFillSelect={supportsLayeredFills ? setSelectedFillId : undefined}
@@ -1851,6 +1965,611 @@ function commitElementSizing(
1851
1965
  commitStylePatch(patch, onStyleChange, onStylesChange);
1852
1966
  }
1853
1967
 
1968
+ /**
1969
+ * Small modal that prompts for a component name, then promotes the current
1970
+ * selection into a reusable component via `onSubmit`.
1971
+ */
1972
+ function CreateComponentDialog({
1973
+ open,
1974
+ onOpenChange,
1975
+ defaultName,
1976
+ onSubmit,
1977
+ }: {
1978
+ open: boolean;
1979
+ onOpenChange: (open: boolean) => void;
1980
+ defaultName: string;
1981
+ onSubmit: (name: string) => void;
1982
+ }) {
1983
+ const [name, setName] = useState(defaultName);
1984
+
1985
+ // Reset the field to the freshest default each time the dialog opens.
1986
+ useEffect(() => {
1987
+ if (open) setName(defaultName);
1988
+ }, [open, defaultName]);
1989
+
1990
+ const commit = () => {
1991
+ const trimmed = name.trim();
1992
+ if (!trimmed) return;
1993
+ onSubmit(trimmed);
1994
+ onOpenChange(false);
1995
+ };
1996
+
1997
+ return (
1998
+ <Dialog open={open} onOpenChange={onOpenChange}>
1999
+ <DialogContent className="sm:max-w-sm">
2000
+ <DialogHeader>
2001
+ <DialogTitle>
2002
+ {"Create component" /* i18n-ignore design inspector action */}
2003
+ </DialogTitle>
2004
+ <DialogDescription>
2005
+ {
2006
+ "Name this element so it becomes a reusable component. The agent can then extract props and replace repeated instances." /* i18n-ignore design inspector copy */
2007
+ }
2008
+ </DialogDescription>
2009
+ </DialogHeader>
2010
+ <div className="space-y-1.5">
2011
+ <Label
2012
+ htmlFor="create-component-name"
2013
+ className="text-[11px] font-medium text-muted-foreground"
2014
+ >
2015
+ {"Component name" /* i18n-ignore design inspector label */}
2016
+ </Label>
2017
+ <Input
2018
+ id="create-component-name"
2019
+ autoFocus
2020
+ value={name}
2021
+ onChange={(e) => setName(e.target.value)}
2022
+ onKeyDown={(e) => {
2023
+ if (e.key === "Enter") {
2024
+ e.preventDefault();
2025
+ commit();
2026
+ }
2027
+ }}
2028
+ placeholder={
2029
+ "PrimaryButton" /* i18n-ignore design inspector placeholder */
2030
+ }
2031
+ />
2032
+ </div>
2033
+ <DialogFooter>
2034
+ <Button
2035
+ type="button"
2036
+ variant="ghost"
2037
+ onClick={() => onOpenChange(false)}
2038
+ >
2039
+ {"Cancel" /* i18n-ignore design inspector action */}
2040
+ </Button>
2041
+ <Button type="button" onClick={commit} disabled={!name.trim()}>
2042
+ {"Create" /* i18n-ignore design inspector action */}
2043
+ </Button>
2044
+ </DialogFooter>
2045
+ </DialogContent>
2046
+ </Dialog>
2047
+ );
2048
+ }
2049
+
2050
+ /** Build a `vscode://file/...` deep link for an absolute path + position. */
2051
+ function vscodeDeepLink(
2052
+ absolutePath: string,
2053
+ line?: number,
2054
+ column?: number,
2055
+ ): string {
2056
+ const base = `vscode://file/${absolutePath}`;
2057
+ if (line == null) return base;
2058
+ return column == null ? `${base}:${line}` : `${base}:${line}:${column}`;
2059
+ }
2060
+
2061
+ /**
2062
+ * Extract the *opening tag* from an element's outer HTML for an at-a-glance
2063
+ * summary (e.g. `<main class="hero" data-x="y">`). Self-closing tags keep
2064
+ * their `/>`. Returns `null` when no tag can be parsed.
2065
+ *
2066
+ * Pure — exported for tests.
2067
+ */
2068
+ export function openingTagOf(html: string | null | undefined): string | null {
2069
+ if (!html) return null;
2070
+ const trimmed = html.trimStart();
2071
+ // Match the first `<tag ...>` (greedy up to the first unquoted `>`), allowing
2072
+ // quoted attribute values to contain `>`.
2073
+ const match = /^<([a-zA-Z][\w-]*)((?:"[^"]*"|'[^']*'|[^>])*?)\/?>/.exec(
2074
+ trimmed,
2075
+ );
2076
+ if (!match) return null;
2077
+ return match[0];
2078
+ }
2079
+
2080
+ /**
2081
+ * Collapse long attribute values in an opening tag so the at-a-glance summary
2082
+ * stays readable. Values longer than `max` chars are truncated with an
2083
+ * ellipsis (the surrounding quotes are preserved).
2084
+ *
2085
+ * Pure — exported for tests.
2086
+ */
2087
+ export function truncateOpeningTag(openTag: string, max = 32): string {
2088
+ return openTag.replace(
2089
+ /("|')((?:\\.|(?!\1)[^\\])*)\1/g,
2090
+ (full, quote, value) => {
2091
+ if (typeof value !== "string" || value.length <= max) return full;
2092
+ return `${quote}${value.slice(0, max - 1)}…${quote}`;
2093
+ },
2094
+ );
2095
+ }
2096
+
2097
+ /**
2098
+ * Parse the top-level `key: value` pairs from an Alpine `x-data` object literal
2099
+ * (e.g. `{ variant: 'outline', size: 'lg', disabled: false }`).
2100
+ *
2101
+ * Best-effort: only handles a flat object of simple string / boolean / number
2102
+ * literals — exactly the shape used for component variant + state props. Nested
2103
+ * objects, methods, and computed expressions are ignored. Returns `null` when
2104
+ * the value is not a recognizable flat object literal.
2105
+ *
2106
+ * Pure — exported for tests.
2107
+ */
2108
+ export function parseAlpineDataObject(
2109
+ xData: string | null | undefined,
2110
+ ): Record<string, string> | null {
2111
+ if (!xData) return null;
2112
+ const trimmed = xData.trim();
2113
+ if (!trimmed.startsWith("{") || !trimmed.endsWith("}")) return null;
2114
+ const inner = trimmed.slice(1, -1).trim();
2115
+ if (!inner) return {};
2116
+
2117
+ const out: Record<string, string> = {};
2118
+ // Split on top-level commas only (no nesting / quotes inside values here).
2119
+ const pairRe =
2120
+ /(?:^|,)\s*(?:'([^']+)'|"([^"]+)"|([A-Za-z_$][\w$]*))\s*:\s*('[^']*'|"[^"]*"|true|false|-?\d+(?:\.\d+)?)/g;
2121
+ let m: RegExpExecArray | null;
2122
+ let matched = false;
2123
+ while ((m = pairRe.exec(inner)) !== null) {
2124
+ matched = true;
2125
+ const key = m[1] ?? m[2] ?? m[3];
2126
+ let raw = m[4]!;
2127
+ // Unwrap quotes for string literals; keep booleans / numbers verbatim.
2128
+ if (
2129
+ (raw.startsWith("'") && raw.endsWith("'")) ||
2130
+ (raw.startsWith('"') && raw.endsWith('"'))
2131
+ ) {
2132
+ raw = raw.slice(1, -1);
2133
+ }
2134
+ if (key) out[key] = raw;
2135
+ }
2136
+ // If there was content but nothing parsed, the shape is too complex to edit
2137
+ // safely — bail so the caller falls back to attribute-based prop edits.
2138
+ if (!matched) return null;
2139
+ return out;
2140
+ }
2141
+
2142
+ /**
2143
+ * Re-serialize a flat Alpine data object back into an `x-data` literal,
2144
+ * preserving boolean / number literals unquoted and single-quoting strings.
2145
+ *
2146
+ * Pure — exported for tests.
2147
+ */
2148
+ export function serializeAlpineDataObject(obj: Record<string, string>): string {
2149
+ const parts = Object.entries(obj).map(([key, value]) => {
2150
+ const isBoolean = value === "true" || value === "false";
2151
+ const isNumber = /^-?\d+(\.\d+)?$/.test(value);
2152
+ const literal =
2153
+ isBoolean || isNumber ? value : `'${value.replace(/'/g, "\\'")}'`;
2154
+ return `${key}: ${literal}`;
2155
+ });
2156
+ return parts.length ? `{ ${parts.join(", ")} }` : "{}";
2157
+ }
2158
+
2159
+ /**
2160
+ * Format a single editable prop value as an `x-data` literal: bare for
2161
+ * boolean / number values, single-quoted (with escaping) for strings.
2162
+ *
2163
+ * Pure — exported for tests.
2164
+ */
2165
+ export function alpineDataValueLiteral(value: string): string {
2166
+ const isBoolean = value === "true" || value === "false";
2167
+ const isNumber = /^-?\d+(\.\d+)?$/.test(value);
2168
+ return isBoolean || isNumber ? value : `'${value.replace(/'/g, "\\'")}'`;
2169
+ }
2170
+
2171
+ /**
2172
+ * Surgically replace a single top-level key's value inside an Alpine `x-data`
2173
+ * object literal, preserving everything else byte-for-byte — methods
2174
+ * (`toggle() { … }`), nested objects, escaped strings, quoted keys, comments,
2175
+ * and whitespace are all left untouched.
2176
+ *
2177
+ * Unlike a `parseAlpineDataObject` → mutate → `serializeAlpineDataObject`
2178
+ * round-trip (which only understands a flat object of simple literals and so
2179
+ * *drops* anything it can't model), this walks the original string, finds the
2180
+ * `key:` token at the top level (depth 0, not inside a string/comment), and
2181
+ * rewrites only the value literal that immediately follows it.
2182
+ *
2183
+ * Returns `null` when the key cannot be located surgically (e.g. the value is
2184
+ * an expression/function/object rather than a simple string/boolean/number, or
2185
+ * the literal isn't a `{ … }` object) so the caller can fail safe instead of
2186
+ * persisting a lossy rewrite.
2187
+ *
2188
+ * Pure — exported for tests.
2189
+ */
2190
+ export function replaceAlpineDataKeyValue(
2191
+ xData: string | null | undefined,
2192
+ key: string,
2193
+ nextValue: string,
2194
+ ): string | null {
2195
+ if (!xData) return null;
2196
+ const trimmed = xData.trim();
2197
+ if (!trimmed.startsWith("{") || !trimmed.endsWith("}")) return null;
2198
+
2199
+ const s = xData;
2200
+ const n = s.length;
2201
+ // Walk the whole string tracking nesting depth and skipping over strings,
2202
+ // template literals, regex-ish slashes are not handled (Alpine x-data does
2203
+ // not use them at the object-key level), and line / block comments. Only at
2204
+ // object depth 1 (directly inside the outermost `{ … }`) do we look for the
2205
+ // target `key :` token.
2206
+ let depth = 0;
2207
+ let i = 0;
2208
+
2209
+ /** Advance `i` past a quoted string starting at `i` (handles escapes). */
2210
+ const skipString = (quote: string): void => {
2211
+ i += 1; // opening quote
2212
+ while (i < n) {
2213
+ const c = s[i];
2214
+ if (c === "\\") {
2215
+ i += 2;
2216
+ continue;
2217
+ }
2218
+ if (c === quote) {
2219
+ i += 1;
2220
+ return;
2221
+ }
2222
+ i += 1;
2223
+ }
2224
+ };
2225
+
2226
+ const escapedKey = key.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
2227
+ // Bare identifier key at a token boundary: `key` then optional ws then `:`.
2228
+ const bareRe = new RegExp(`^(${escapedKey})(\\s*:\\s*)`);
2229
+ // Quoted key: `'key'` or `"key"` then optional ws then `:`.
2230
+ const quotedRe = new RegExp(`^(['"]${escapedKey}['"])(\\s*:\\s*)`);
2231
+
2232
+ while (i < n) {
2233
+ const c = s[i];
2234
+
2235
+ // At the top level, a `{` / `,` (or the string start) opens a fresh value
2236
+ // slot. Try to match the target key here *before* treating a quote as an
2237
+ // opaque string — this is how quoted keys (`'size': …`) are recognised.
2238
+ if (depth === 1) {
2239
+ const prev = lastNonSpaceBefore(s, i);
2240
+ if (prev === "{" || prev === ",") {
2241
+ const rest = s.slice(i);
2242
+ const m = bareRe.exec(rest) ?? quotedRe.exec(rest);
2243
+ if (m) {
2244
+ const valueStart = i + m[1].length + m[2].length;
2245
+ const valueEnd = simpleValueEnd(s, valueStart);
2246
+ if (valueEnd === null) return null; // value is not a simple literal
2247
+ return (
2248
+ s.slice(0, valueStart) +
2249
+ alpineDataValueLiteral(nextValue) +
2250
+ s.slice(valueEnd)
2251
+ );
2252
+ }
2253
+ }
2254
+ }
2255
+
2256
+ // Skip strings / template literals wholesale.
2257
+ if (c === '"' || c === "'" || c === "`") {
2258
+ skipString(c);
2259
+ continue;
2260
+ }
2261
+ // Skip comments.
2262
+ if (c === "/" && s[i + 1] === "/") {
2263
+ i += 2;
2264
+ while (i < n && s[i] !== "\n") i += 1;
2265
+ continue;
2266
+ }
2267
+ if (c === "/" && s[i + 1] === "*") {
2268
+ i += 2;
2269
+ while (i < n && !(s[i] === "*" && s[i + 1] === "/")) i += 1;
2270
+ i += 2;
2271
+ continue;
2272
+ }
2273
+
2274
+ if (c === "{" || c === "[" || c === "(") {
2275
+ depth += 1;
2276
+ i += 1;
2277
+ continue;
2278
+ }
2279
+ if (c === "}" || c === "]" || c === ")") {
2280
+ depth -= 1;
2281
+ i += 1;
2282
+ continue;
2283
+ }
2284
+
2285
+ i += 1;
2286
+ }
2287
+
2288
+ return null;
2289
+ }
2290
+
2291
+ /** Last non-whitespace char strictly before index `i` (or `""`). */
2292
+ function lastNonSpaceBefore(s: string, i: number): string {
2293
+ let j = i - 1;
2294
+ while (j >= 0 && /\s/.test(s[j]!)) j -= 1;
2295
+ return j >= 0 ? s[j]! : "";
2296
+ }
2297
+
2298
+ /**
2299
+ * Given the start index of a value in an `x-data` literal, return the index
2300
+ * just past a *simple* literal value (single/double-quoted string with
2301
+ * escapes, boolean, or number). Returns `null` when the value is anything else
2302
+ * (an expression, function, object, array, template literal, etc.) so the
2303
+ * caller can fail safe rather than mangle it.
2304
+ */
2305
+ function simpleValueEnd(s: string, start: number): number | null {
2306
+ const c = s[start];
2307
+ if (c === "'" || c === '"') {
2308
+ let i = start + 1;
2309
+ while (i < s.length) {
2310
+ if (s[i] === "\\") {
2311
+ i += 2;
2312
+ continue;
2313
+ }
2314
+ if (s[i] === c) return i + 1;
2315
+ i += 1;
2316
+ }
2317
+ return null; // unterminated string
2318
+ }
2319
+ // Boolean / number: read the bare token, then confirm it is exactly one.
2320
+ const m = /^[A-Za-z0-9_.+-]+/.exec(s.slice(start));
2321
+ if (!m) return null;
2322
+ const token = m[0];
2323
+ const isBoolean = token === "true" || token === "false";
2324
+ const isNumber = /^-?\d+(\.\d+)?$/.test(token);
2325
+ if (!isBoolean && !isNumber) return null;
2326
+ return start + token.length;
2327
+ }
2328
+
2329
+ /**
2330
+ * True when an `x-data` literal can be rebuilt from its flat parsed map with
2331
+ * no loss — i.e. there is nothing richer than the simple `key: literal` pairs
2332
+ * that `serializeAlpineDataObject` already round-trips. Used as the gate for
2333
+ * falling back to a full rebuild when a surgical single-key replace is not
2334
+ * possible (e.g. when adding a brand-new key).
2335
+ *
2336
+ * Returns `true` for an empty / absent literal (nothing to lose) and for a
2337
+ * flat object whose `parse → serialize` round-trip is semantically stable.
2338
+ * Returns `false` when the original holds methods, nested objects, comments,
2339
+ * or expressions that a rebuild would silently drop.
2340
+ *
2341
+ * Pure — exported for tests.
2342
+ */
2343
+ export function canRebuildAlpineDataLosslessly(
2344
+ xData: string | null | undefined,
2345
+ ): boolean {
2346
+ const trimmed = (xData ?? "").trim();
2347
+ // No object literal at all → there is nothing richer to preserve.
2348
+ if (!trimmed || trimmed === "{}" || trimmed === "{ }") return true;
2349
+ if (!trimmed.startsWith("{") || !trimmed.endsWith("}")) return false;
2350
+
2351
+ const parsed = parseAlpineDataObject(trimmed);
2352
+ if (!parsed) return false;
2353
+
2354
+ // Re-serialize and re-parse; if the round-trip is stable AND the parsed map
2355
+ // accounts for every top-level key actually present in the original, a
2356
+ // rebuild loses nothing.
2357
+ const reserialized = serializeAlpineDataObject(parsed);
2358
+ const reparsed = parseAlpineDataObject(reserialized);
2359
+ if (!reparsed) return false;
2360
+ const keysA = Object.keys(parsed).sort().join(",");
2361
+ const keysB = Object.keys(reparsed).sort().join(",");
2362
+ if (keysA !== keysB) return false;
2363
+
2364
+ // Guard against dropped content the flat parser ignores (e.g. a trailing
2365
+ // method): the number of top-level `key:` tokens in the original must match
2366
+ // the number of parsed keys. Count top-level `:` separators conservatively.
2367
+ return countTopLevelKeys(trimmed) === Object.keys(parsed).length;
2368
+ }
2369
+
2370
+ /**
2371
+ * Count top-level `key:` entries in an `x-data` object literal, skipping
2372
+ * strings, comments, and nested braces/brackets/parens. A method like
2373
+ * `toggle() { … }` is counted as a key too (its `:`-less form still occupies a
2374
+ * top-level slot), so a mismatch against the flat parser's key count reveals
2375
+ * dropped content.
2376
+ */
2377
+ function countTopLevelKeys(xData: string): number {
2378
+ const s = xData;
2379
+ const n = s.length;
2380
+ let depth = 0;
2381
+ let i = 0;
2382
+ let count = 0;
2383
+ let sawTokenInSlot = false;
2384
+
2385
+ const skipString = (quote: string): void => {
2386
+ i += 1;
2387
+ while (i < n) {
2388
+ if (s[i] === "\\") {
2389
+ i += 2;
2390
+ continue;
2391
+ }
2392
+ if (s[i] === quote) {
2393
+ i += 1;
2394
+ return;
2395
+ }
2396
+ i += 1;
2397
+ }
2398
+ };
2399
+
2400
+ while (i < n) {
2401
+ const c = s[i]!;
2402
+ if (c === '"' || c === "'" || c === "`") {
2403
+ if (depth === 1) sawTokenInSlot = true;
2404
+ skipString(c);
2405
+ continue;
2406
+ }
2407
+ if (c === "/" && s[i + 1] === "/") {
2408
+ i += 2;
2409
+ while (i < n && s[i] !== "\n") i += 1;
2410
+ continue;
2411
+ }
2412
+ if (c === "/" && s[i + 1] === "*") {
2413
+ i += 2;
2414
+ while (i < n && !(s[i] === "*" && s[i + 1] === "/")) i += 1;
2415
+ i += 2;
2416
+ continue;
2417
+ }
2418
+ if (c === "{" || c === "[" || c === "(") {
2419
+ depth += 1;
2420
+ i += 1;
2421
+ continue;
2422
+ }
2423
+ if (c === "}" || c === "]" || c === ")") {
2424
+ if (depth === 1 && c === "}" && sawTokenInSlot) {
2425
+ count += 1;
2426
+ sawTokenInSlot = false;
2427
+ }
2428
+ depth -= 1;
2429
+ i += 1;
2430
+ continue;
2431
+ }
2432
+ if (depth === 1) {
2433
+ if (c === ",") {
2434
+ if (sawTokenInSlot) count += 1;
2435
+ sawTokenInSlot = false;
2436
+ } else if (!/\s/.test(c)) {
2437
+ sawTokenInSlot = true;
2438
+ }
2439
+ }
2440
+ i += 1;
2441
+ }
2442
+ return count;
2443
+ }
2444
+
2445
+ /** A boolean-ish prop value (`"true"` / `"false"`), case-insensitive. */
2446
+ export function isBooleanPropValue(value: string): boolean {
2447
+ const v = value.trim().toLowerCase();
2448
+ return v === "true" || v === "false";
2449
+ }
2450
+
2451
+ /**
2452
+ * Popover anchored to the "Inspect code" button showing the selected node's
2453
+ * code. Inline/Alpine sources show the element's outer HTML with a Copy
2454
+ * button; real-app sources additionally render an "Open in VS Code" button.
2455
+ *
2456
+ * TODO: replace the read-only <pre> with an inline Monaco editor for richer
2457
+ * (editable) code inspection once the editor bundle is wired into the inspector.
2458
+ */
2459
+ function InspectCodePopover({
2460
+ trigger,
2461
+ data,
2462
+ }: {
2463
+ trigger: ReactNode;
2464
+ data: InspectCodeData;
2465
+ }) {
2466
+ const [copied, setCopied] = useState(false);
2467
+ const html = data.html ?? "";
2468
+ const source = data.sourceLocation ?? null;
2469
+ const snippet = source?.snippet || html;
2470
+ // At-a-glance opening tag (tag name + attributes), truncated for readability.
2471
+ const openingTag = (() => {
2472
+ const raw = openingTagOf(html);
2473
+ return raw ? truncateOpeningTag(raw) : null;
2474
+ })();
2475
+
2476
+ const handleCopy = () => {
2477
+ if (!snippet) return;
2478
+ void navigator.clipboard
2479
+ ?.writeText(snippet)
2480
+ .then(() => {
2481
+ setCopied(true);
2482
+ window.setTimeout(() => setCopied(false), 1200);
2483
+ })
2484
+ .catch(() => {
2485
+ /* clipboard may be unavailable; ignore */
2486
+ });
2487
+ };
2488
+
2489
+ return (
2490
+ <Popover>
2491
+ <PopoverTrigger asChild>{trigger}</PopoverTrigger>
2492
+ <PopoverContent align="end" className="w-80 space-y-2 p-2 text-[11px]">
2493
+ <div className="flex items-center justify-between gap-2">
2494
+ <span className="text-[10px] font-semibold uppercase tracking-wide text-muted-foreground/70">
2495
+ {"Inspect code" /* i18n-ignore design inspector label */}
2496
+ </span>
2497
+ <Button
2498
+ type="button"
2499
+ variant="ghost"
2500
+ size="sm"
2501
+ className="h-6 px-2 text-[10px]"
2502
+ onClick={handleCopy}
2503
+ disabled={!snippet}
2504
+ >
2505
+ {
2506
+ copied
2507
+ ? "Copied" /* i18n-ignore design inspector action */
2508
+ : "Copy" /* i18n-ignore design inspector action */
2509
+ }
2510
+ </Button>
2511
+ </div>
2512
+
2513
+ {/* At-a-glance opening tag (tag name + attributes) above the full HTML. */}
2514
+ {openingTag && (
2515
+ <code
2516
+ className="block truncate rounded bg-[var(--design-editor-control-bg)] px-2 py-1 font-mono text-[10px] text-foreground"
2517
+ title={openingTag}
2518
+ >
2519
+ {openingTag}
2520
+ </code>
2521
+ )}
2522
+
2523
+ {source && (
2524
+ <div
2525
+ className="flex items-center gap-1 rounded bg-[var(--design-editor-control-bg)] px-2 py-1"
2526
+ title={source.absolutePath}
2527
+ >
2528
+ <IconCode className="size-3 shrink-0 text-muted-foreground/60" />
2529
+ <span className="min-w-0 flex-1 truncate font-mono text-[10px] text-muted-foreground">
2530
+ {source.absolutePath}
2531
+ {source.line != null ? `:${source.line}` : ""}
2532
+ </span>
2533
+ </div>
2534
+ )}
2535
+
2536
+ {snippet ? (
2537
+ <pre className="max-h-64 overflow-auto rounded bg-[var(--design-editor-control-bg)] p-2 font-mono text-[10px] leading-relaxed text-foreground">
2538
+ <code>{snippet}</code>
2539
+ </pre>
2540
+ ) : (
2541
+ <p className="px-1 py-2 text-muted-foreground">
2542
+ {
2543
+ "No source available for this element." /* i18n-ignore design inspector empty */
2544
+ }
2545
+ </p>
2546
+ )}
2547
+
2548
+ {source && (
2549
+ <a
2550
+ href={vscodeDeepLink(
2551
+ source.absolutePath,
2552
+ source.line,
2553
+ source.column,
2554
+ )}
2555
+ className="block"
2556
+ >
2557
+ <Button
2558
+ type="button"
2559
+ variant="outline"
2560
+ size="sm"
2561
+ className="h-7 w-full gap-1.5 text-[11px]"
2562
+ >
2563
+ <IconExternalLink className="size-3.5" />
2564
+ {"Open in VS Code" /* i18n-ignore design inspector action */}
2565
+ </Button>
2566
+ </a>
2567
+ )}
2568
+ </PopoverContent>
2569
+ </Popover>
2570
+ );
2571
+ }
2572
+
1854
2573
  function elementTypeIcon(element: ElementInfo) {
1855
2574
  const tag = element.tagName || "element";
1856
2575
  if (TEXT_TAGS.has(tag)) return IconTypography;
@@ -1860,35 +2579,55 @@ function elementTypeIcon(element: ElementInfo) {
1860
2579
  return IconFrame;
1861
2580
  }
1862
2581
 
1863
- function SelectionHeader({ element }: { element: ElementInfo | null }) {
2582
+ function SelectionHeader({
2583
+ element,
2584
+ onCreateComponent,
2585
+ inspectCodePopover,
2586
+ }: {
2587
+ element: ElementInfo | null;
2588
+ /** Promote the current selection into a reusable component. Omit/undefined to disable. */
2589
+ onCreateComponent?: () => void;
2590
+ /**
2591
+ * Render prop for the "Inspect code" affordance. Receives the trigger button
2592
+ * so the parent can anchor a Popover to it; returns the full popover element.
2593
+ * When omitted the button renders disabled.
2594
+ */
2595
+ inspectCodePopover?: (trigger: ReactNode) => ReactNode;
2596
+ }) {
1864
2597
  if (!element) return null;
1865
2598
 
1866
2599
  const title = inspectorObjectTitle(element);
1867
2600
  const TypeIcon = elementTypeIcon(element);
1868
2601
 
2602
+ const inspectTrigger = (
2603
+ <SectionIconButton
2604
+ label={"Inspect code" /* i18n-ignore design inspector action */}
2605
+ disabled={!inspectCodePopover}
2606
+ >
2607
+ <IconCode className="size-3.5" />
2608
+ </SectionIconButton>
2609
+ );
2610
+
1869
2611
  return (
1870
2612
  <div className="flex min-h-8 shrink-0 items-center justify-between gap-2 border-b border-border/90 px-3">
1871
- {/* M3 · Node-type label + rename/type dropdown affordance (▾) */}
1872
- <button
1873
- type="button"
1874
- className="flex min-w-0 items-center gap-1.5 bg-transparent text-left text-[13px] font-semibold text-foreground"
1875
- >
2613
+ {/* Node-type label. Rename lives in the layers panel and device sizing
2614
+ lives elsewhere, so this is a plain non-interactive label. */}
2615
+ <div className="flex min-w-0 items-center gap-1.5 text-left text-[13px] font-semibold text-foreground">
1876
2616
  <TypeIcon className="size-3.5 shrink-0 text-muted-foreground" />
1877
2617
  <span className="truncate">{title}</span>
1878
- <IconChevronDown className="size-2.5 shrink-0 text-muted-foreground" />
1879
- </button>
1880
- {/* M3 · Right-aligned quick actions: create-component + dev inspect (</>) */}
2618
+ </div>
2619
+ {/* Right-aligned quick actions: create-component + dev inspect (</>) */}
1881
2620
  <div className="flex shrink-0 items-center gap-0.5">
1882
2621
  <SectionIconButton
1883
2622
  label={"Create component" /* i18n-ignore design inspector action */}
2623
+ disabled={!onCreateComponent}
2624
+ onClick={onCreateComponent}
1884
2625
  >
1885
2626
  <IconComponents className="size-3.5" />
1886
2627
  </SectionIconButton>
1887
- <SectionIconButton
1888
- label={"Inspect code" /* i18n-ignore design inspector action */}
1889
- >
1890
- <IconCode className="size-3.5" />
1891
- </SectionIconButton>
2628
+ {inspectCodePopover
2629
+ ? inspectCodePopover(inspectTrigger)
2630
+ : inspectTrigger}
1892
2631
  </div>
1893
2632
  </div>
1894
2633
  );
@@ -2959,10 +3698,15 @@ function FlexContainerControls({
2959
3698
  element,
2960
3699
  onStyleChange,
2961
3700
  onStylesChange,
3701
+ onAutoLayoutConvert,
2962
3702
  }: {
2963
3703
  element: ElementInfo;
2964
3704
  onStyleChange: (property: string, value: string) => void;
2965
3705
  onStylesChange?: (styles: Record<string, string>) => void;
3706
+ onAutoLayoutConvert?: (
3707
+ targetNodeId: string,
3708
+ opts?: { direction?: "row" | "column"; gap?: string },
3709
+ ) => void;
2966
3710
  }) {
2967
3711
  const t = useT();
2968
3712
  const styles = element.computedStyles;
@@ -2975,12 +3719,55 @@ function FlexContainerControls({
2975
3719
  const isFlex = display.includes("flex");
2976
3720
  const flexDirection: AutoLayoutMatrixValue["direction"] =
2977
3721
  styles.flexDirection?.includes("column") ? "vertical" : "horizontal";
3722
+ const mainGapAxis =
3723
+ flexDirection === "horizontal" ? "horizontal" : "vertical";
2978
3724
  // When the element is in normal flow (not flex yet), picking any flow option
2979
3725
  // must first turn it into a flex container; otherwise setting flex-direction
2980
3726
  // alone is a no-op against a block element.
2981
3727
  const ensureFlex = () => {
2982
3728
  if (!isFlex) onStyleChange("display", "flex");
2983
3729
  };
3730
+
3731
+ /**
3732
+ * Handle the Flow control switching between flex and normal-flow (block).
3733
+ *
3734
+ * For 'flex': ensures display:flex is set (ensureFlex path).
3735
+ * For 'block': sets display:block and leaves children unchanged — mirrors
3736
+ * the { kind:"autoLayout", enabled:false } substrate intent exactly.
3737
+ */
3738
+ const handleDisplayChange = (nextDisplay: "flex" | "block") => {
3739
+ if (nextDisplay === "flex") {
3740
+ ensureFlex();
3741
+ return;
3742
+ }
3743
+ // Turn auto-layout off: set display:block, leaving children unchanged.
3744
+ // This is the direct equivalent of the autoLayout substrate with enabled:false.
3745
+ onStyleChange("display", "block");
3746
+ };
3747
+
3748
+ /**
3749
+ * "Convert to auto layout" — enables flex on a non-flex container and strips
3750
+ * position:absolute from direct children (full reflow via the autoLayout
3751
+ * substrate intent). Falls back to a style-only patch when no substrate
3752
+ * handler is wired (e.g. read-only or non-editable contexts).
3753
+ */
3754
+ const handleConvertToAutoLayout = () => {
3755
+ if (onAutoLayoutConvert && element.sourceId) {
3756
+ onAutoLayoutConvert(element.sourceId, { direction: "row", gap: "8px" });
3757
+ return;
3758
+ }
3759
+ // Fallback: set display:flex on the parent only (no child strip).
3760
+ commitStylePatch(
3761
+ {
3762
+ display: "flex",
3763
+ flexDirection: "row",
3764
+ gap: "8px",
3765
+ alignItems: "flex-start",
3766
+ },
3767
+ onStyleChange,
3768
+ onStylesChange,
3769
+ );
3770
+ };
2984
3771
  const padding = {
2985
3772
  top: parseNumericValue(styles.paddingTop || "0"),
2986
3773
  right: parseNumericValue(styles.paddingRight || "0"),
@@ -3022,12 +3809,27 @@ function FlexContainerControls({
3022
3809
  // optional contract field consumed by AutoLayoutMatrix; harmless when the
3023
3810
  // matrix ignores it.
3024
3811
  ...({ display } as Partial<AutoLayoutMatrixValue>),
3812
+ spaceBetween: styles.justifyContent === "space-between",
3025
3813
  };
3026
3814
 
3027
3815
  return (
3028
3816
  <div className="space-y-2">
3817
+ {/* Convert to auto layout — shown when the container is not yet flex */}
3818
+ {!isFlex ? (
3819
+ <Button
3820
+ type="button"
3821
+ variant="outline"
3822
+ size="sm"
3823
+ className="h-7 w-full gap-1.5 text-[11px]"
3824
+ onClick={handleConvertToAutoLayout}
3825
+ >
3826
+ <IconLayoutSettings className="size-3.5 shrink-0" />
3827
+ {"Convert to auto layout" /* i18n-ignore design inspector action */}
3828
+ </Button>
3829
+ ) : null}
3029
3830
  <AutoLayoutMatrix
3030
3831
  value={autoLayoutValue}
3832
+ onDisplayChange={handleDisplayChange}
3031
3833
  onDirectionChange={(direction) => {
3032
3834
  ensureFlex();
3033
3835
  onStyleChange(
@@ -3079,16 +3881,20 @@ function FlexContainerControls({
3079
3881
  onStyleChange("overflow", clipContent ? "hidden" : "visible")
3080
3882
  }
3081
3883
  onDistribute={(axis) => {
3082
- const mainAxis =
3083
- autoLayoutValue.direction === "horizontal"
3084
- ? "horizontal"
3085
- : "vertical";
3086
- if (axis === mainAxis) {
3884
+ if (axis === mainGapAxis) {
3087
3885
  onStyleChange("justifyContent", "space-between");
3088
3886
  } else if (autoLayoutValue.wrap === "wrap") {
3089
3887
  onStyleChange("alignContent", "space-between");
3090
3888
  }
3091
3889
  }}
3890
+ onGapModeChange={(gapMode, axis) => {
3891
+ if (axis !== mainGapAxis) return;
3892
+ ensureFlex();
3893
+ onStyleChange(
3894
+ "justifyContent",
3895
+ gapMode === "auto" ? "space-between" : "flex-start",
3896
+ );
3897
+ }}
3092
3898
  availableChildSizing={availableSizingForElement(element)}
3093
3899
  onChildSizingChange={(axis, sizing) => {
3094
3900
  commitElementSizing(
@@ -3207,10 +4013,15 @@ function LayoutContextProperties({
3207
4013
  element,
3208
4014
  onStyleChange,
3209
4015
  onStylesChange,
4016
+ onAutoLayoutConvert,
3210
4017
  }: {
3211
4018
  element: ElementInfo;
3212
4019
  onStyleChange: (property: string, value: string) => void;
3213
4020
  onStylesChange?: (styles: Record<string, string>) => void;
4021
+ onAutoLayoutConvert?: (
4022
+ targetNodeId: string,
4023
+ opts?: { direction?: "row" | "column"; gap?: string },
4024
+ ) => void;
3214
4025
  }) {
3215
4026
  const t = useT();
3216
4027
  const flexChild = isParentFlex(element);
@@ -3303,6 +4114,7 @@ function LayoutContextProperties({
3303
4114
  element={element}
3304
4115
  onStyleChange={onStyleChange}
3305
4116
  onStylesChange={onStylesChange}
4117
+ onAutoLayoutConvert={onAutoLayoutConvert}
3306
4118
  />
3307
4119
  {childControls}
3308
4120
  </PanelSection>
@@ -4650,6 +5462,671 @@ const TEXT_TAGS = new Set([
4650
5462
  "li",
4651
5463
  ]);
4652
5464
 
5465
+ // ─── Make it real — inline upgrade card (§3, §6.6) ──────────────────────────
5466
+
5467
+ /**
5468
+ * Payload shape returned by `connect-builder-app`. Only the fields used by
5469
+ * the card UI are typed here; the action may return additional fields.
5470
+ */
5471
+ interface ConnectBuilderAppResult {
5472
+ connected: boolean;
5473
+ builderEnabled: boolean;
5474
+ connectUrl: string;
5475
+ appHost: string;
5476
+ branchProjectId?: string;
5477
+ cta: {
5478
+ kind: "connect-builder" | "configure-project";
5479
+ label: string;
5480
+ description: string;
5481
+ primaryAction: string;
5482
+ connectUrl: string;
5483
+ } | null;
5484
+ message: string;
5485
+ }
5486
+
5487
+ /**
5488
+ * Inline "Make it real" upgrade card.
5489
+ *
5490
+ * Rendered wherever a real-app-only control is reached on an inline design
5491
+ * (Component source jump, token write-back, live captures, etc.). Queries
5492
+ * `connect-builder-app` to determine the current connection state, then
5493
+ * offers the appropriate CTA:
5494
+ *
5495
+ * - Not connected → "Connect Builder.io" button (opens connectUrl)
5496
+ * - Connected, no project → "Open Builder settings" (configure project ID)
5497
+ * - Fully enabled → "Make it real" button (calls migrate-inline-design-to-app)
5498
+ *
5499
+ * The card is progressively disclosed: it only mounts when a gated control is
5500
+ * actually reached, so it never appears for users who are already on a real-app
5501
+ * source (`localhost` / `fusion`) or whose `sourceCapabilities` already include
5502
+ * the needed capability.
5503
+ *
5504
+ * Matches the design-editor panel chrome: dashed-border, accent tint, small
5505
+ * text at 10px — same idiom as the existing `ctaRequired` block in
5506
+ * ComponentSection.
5507
+ */
5508
+ function MakeItRealCard({
5509
+ designId,
5510
+ featureLabel,
5511
+ }: {
5512
+ /** The active design id — required to call connect-builder-app. */
5513
+ designId: string;
5514
+ /**
5515
+ * Short human-readable label for the gated feature (e.g. "token write-back",
5516
+ * "component source jump", "live captures"). Shown in the card body so the
5517
+ * user understands exactly what they're unlocking.
5518
+ */
5519
+ featureLabel: string;
5520
+ }) {
5521
+ const { data, isLoading } = useActionQuery<ConnectBuilderAppResult>(
5522
+ "connect-builder-app",
5523
+ { designId },
5524
+ );
5525
+
5526
+ const migrateMutation = useActionMutation("migrate-inline-design-to-app");
5527
+
5528
+ // While fetching status, show a muted placeholder that matches the card
5529
+ // height so the inspector doesn't jump when the data arrives.
5530
+ if (isLoading || !data) {
5531
+ return (
5532
+ <div className="rounded-md border border-dashed border-[var(--design-editor-accent-color)]/20 bg-[var(--design-editor-accent-color)]/3 px-2.5 py-2">
5533
+ <div className="h-3 w-32 animate-pulse rounded bg-muted/40" />
5534
+ </div>
5535
+ );
5536
+ }
5537
+
5538
+ // Determine which CTA to show.
5539
+ const cta = data.cta;
5540
+
5541
+ // Already fully enabled — no CTA needed (caller should already have gated
5542
+ // this component away, but guard here for safety).
5543
+ if (!cta) return null;
5544
+
5545
+ const isPending = migrateMutation.isPending;
5546
+ const migrateError = migrateMutation.error;
5547
+
5548
+ // "Make it real" primary action: open the connect URL or migrate.
5549
+ const handlePrimary = () => {
5550
+ if (cta.kind === "connect-builder") {
5551
+ // Open the Builder OAuth connect flow in a new tab. The user completes
5552
+ // it there and comes back; the card will re-query on next render.
5553
+ window.open(cta.connectUrl, "_blank", "noopener,noreferrer");
5554
+ return;
5555
+ }
5556
+ if (cta.kind === "configure-project") {
5557
+ window.open(cta.connectUrl, "_blank", "noopener,noreferrer");
5558
+ return;
5559
+ }
5560
+ };
5561
+
5562
+ const handleMigrate = () => {
5563
+ migrateMutation.mutate({ designId });
5564
+ };
5565
+
5566
+ // Migration result — show branch link.
5567
+ const migrateResult = migrateMutation.data as
5568
+ | {
5569
+ status: "processing";
5570
+ branchName?: string;
5571
+ url?: string;
5572
+ message?: string;
5573
+ }
5574
+ | undefined;
5575
+
5576
+ if (migrateResult?.status === "processing" && migrateResult.url) {
5577
+ return (
5578
+ <div className="rounded-md border border-[var(--design-editor-accent-color)]/30 bg-[var(--design-editor-accent-color)]/8 px-2.5 py-2 space-y-1.5">
5579
+ <p className="text-[10px] font-semibold text-[var(--design-editor-accent-color)]">
5580
+ {"Builder is working on it" /* i18n-ignore make-it-real card */}
5581
+ </p>
5582
+ <p className="text-[10px] leading-snug text-muted-foreground">
5583
+ {migrateResult.message ??
5584
+ `Branch "${migrateResult.branchName}" is being generated.`}
5585
+ </p>
5586
+ <a
5587
+ href={migrateResult.url}
5588
+ target="_blank"
5589
+ rel="noopener noreferrer"
5590
+ className="inline-flex items-center gap-1 text-[10px] font-medium text-[var(--design-editor-accent-color)] hover:underline"
5591
+ >
5592
+ {"Open in Builder" /* i18n-ignore make-it-real card */}
5593
+ <IconExternalLink className="size-2.5" />
5594
+ </a>
5595
+ </div>
5596
+ );
5597
+ }
5598
+
5599
+ return (
5600
+ <div className="rounded-md border border-dashed border-[var(--design-editor-accent-color)]/30 bg-[var(--design-editor-accent-color)]/5 px-2.5 py-2 space-y-1.5">
5601
+ {/* Header */}
5602
+ <p className="text-[10px] font-semibold text-foreground">
5603
+ {"Make this a real app" /* i18n-ignore make-it-real card */}
5604
+ </p>
5605
+
5606
+ {/* Body */}
5607
+ <p className="text-[10px] leading-snug text-muted-foreground">
5608
+ {cta.description}
5609
+ {featureLabel ? (
5610
+ <>
5611
+ {" "}
5612
+ <span className="italic">{featureLabel}</span>
5613
+ {" requires a real-app source."}
5614
+ </>
5615
+ ) : null}
5616
+ </p>
5617
+
5618
+ {/* Error */}
5619
+ {migrateError ? (
5620
+ <p className="text-[10px] text-destructive">
5621
+ {migrateError instanceof Error
5622
+ ? migrateError.message
5623
+ : "Migration failed. Please try again."}
5624
+ </p>
5625
+ ) : null}
5626
+
5627
+ {/* Actions */}
5628
+ <div className="flex items-center gap-1.5 flex-wrap">
5629
+ <button
5630
+ type="button"
5631
+ onClick={handlePrimary}
5632
+ className="inline-flex items-center gap-1 rounded px-2 py-1 text-[10px] font-medium bg-[var(--design-editor-accent-color)] text-white hover:opacity-90 transition-opacity"
5633
+ >
5634
+ {cta.primaryAction}
5635
+ <IconArrowRight className="size-2.5" />
5636
+ </button>
5637
+
5638
+ {/* When Builder is fully connected, also offer direct migration */}
5639
+ {data.connected && data.builderEnabled && (
5640
+ <button
5641
+ type="button"
5642
+ onClick={handleMigrate}
5643
+ disabled={isPending}
5644
+ className="inline-flex items-center gap-1 rounded border border-[var(--design-editor-control-border)] bg-[var(--design-editor-control-bg)] px-2 py-1 text-[10px] font-medium text-muted-foreground hover:text-foreground transition-colors disabled:cursor-wait disabled:opacity-60"
5645
+ >
5646
+ {isPending ? (
5647
+ <>
5648
+ <IconLoader2 className="size-2.5 animate-spin" />
5649
+ {"Migrating…" /* i18n-ignore make-it-real card */}
5650
+ </>
5651
+ ) : (
5652
+ <>{"Generate React app" /* i18n-ignore make-it-real card */}</>
5653
+ )}
5654
+ </button>
5655
+ )}
5656
+ </div>
5657
+ </div>
5658
+ );
5659
+ }
5660
+
5661
+ // ─── Component section (§6.1) ─────────────────────────────────────────────────
5662
+
5663
+ /**
5664
+ * Shape returned by `get-component-details`. Only the fields the UI needs are
5665
+ * typed here; the action may return additional fields.
5666
+ */
5667
+ interface ComponentDetailsResult {
5668
+ nodeId: string;
5669
+ name: string;
5670
+ sourceType: string;
5671
+ observedProps: Array<{ name: string; value: string }>;
5672
+ persistedVariants: Record<string, string[]>;
5673
+ sourceLocation?: { filePath: string; exportName?: string } | null;
5674
+ /** Component instance shape, including the Alpine `x-data` expression. */
5675
+ instance?: {
5676
+ alpineData?: string | null;
5677
+ nodeId?: string;
5678
+ selector?: string;
5679
+ } | null;
5680
+ capabilities: {
5681
+ canResolveToFile: boolean;
5682
+ hasFullIndex: boolean;
5683
+ canEditProps: boolean;
5684
+ ctaRequired: boolean;
5685
+ ctaMessage?: string;
5686
+ };
5687
+ }
5688
+
5689
+ /**
5690
+ * Contextual COMPONENT section rendered inside the Design tab when the
5691
+ * selected element is a component instance (carries
5692
+ * `data-agent-native-component`).
5693
+ *
5694
+ * Shows: component name, source path (when capability available), observed
5695
+ * prop values, variant/size/state controls from `get-component-details`, and
5696
+ * an "Edit component source" action. Real-app features are gated by the
5697
+ * capabilities returned by the action; Alpine gets a lightweight read-only
5698
+ * view plus a Connect-Builder CTA.
5699
+ *
5700
+ * Matches the workbench artboard spec in DESIGN-STUDIO-PLAN.md §6.1.
5701
+ */
5702
+ export function ComponentSection({
5703
+ designId,
5704
+ fileId,
5705
+ nodeId,
5706
+ onComponentPropApplied,
5707
+ sourceCapabilities = [],
5708
+ }: {
5709
+ designId: string;
5710
+ fileId?: string;
5711
+ nodeId: string;
5712
+ onComponentPropApplied?: (fileId: string, content: string) => void;
5713
+ /** Capability names advertised by the current source. */
5714
+ sourceCapabilities?: string[];
5715
+ }) {
5716
+ const queryClient = useQueryClient();
5717
+ const detailsParams = { designId, nodeId, ...(fileId ? { fileId } : {}) };
5718
+ const detailsKey = ["action", "get-component-details", detailsParams];
5719
+
5720
+ const { data, isLoading, error, refetch } =
5721
+ useActionQuery<ComponentDetailsResult>(
5722
+ "get-component-details",
5723
+ detailsParams,
5724
+ { refetchOnMount: "always" },
5725
+ );
5726
+
5727
+ const openSourceMutation = useActionMutation("open-component-source");
5728
+ const applyPropMutation = useActionMutation("apply-component-prop-edit");
5729
+
5730
+ const postComponentPropPreview = useCallback(
5731
+ (attribute: string, value: string) => {
5732
+ if (typeof document === "undefined") return;
5733
+
5734
+ const iframe = document.querySelector<HTMLIFrameElement>(
5735
+ "iframe[data-design-preview-iframe]",
5736
+ );
5737
+ iframe?.contentWindow?.postMessage(
5738
+ {
5739
+ type: "style-change",
5740
+ selector: data?.instance?.selector ?? "",
5741
+ nodeId: data?.instance?.nodeId ?? nodeId,
5742
+ attributeOverrides: { [attribute]: value },
5743
+ },
5744
+ "*",
5745
+ );
5746
+ },
5747
+ [data?.instance?.nodeId, data?.instance?.selector, nodeId],
5748
+ );
5749
+
5750
+ // Persist a single prop change through apply-component-prop-edit. Attribute
5751
+ // props also preview immediately in the iframe so the selected component
5752
+ // changes without waiting for the write/refetch round-trip.
5753
+ const persistPropEdit = (
5754
+ edit:
5755
+ | { kind: "alpineData"; value: string }
5756
+ | { kind: "attribute"; attribute: string; value: string },
5757
+ optimistic: (prev: ComponentDetailsResult) => ComponentDetailsResult,
5758
+ ) => {
5759
+ queryClient.setQueryData<ComponentDetailsResult>(detailsKey, (prev) =>
5760
+ prev ? optimistic(prev) : prev,
5761
+ );
5762
+ if (edit.kind === "attribute") {
5763
+ postComponentPropPreview(edit.attribute, edit.value);
5764
+ }
5765
+ applyPropMutation.mutate(
5766
+ { designId, nodeId, ...(fileId ? { fileId } : {}), edit },
5767
+ {
5768
+ onSuccess: (result) => {
5769
+ const response = result as {
5770
+ content?: unknown;
5771
+ fileId?: unknown;
5772
+ };
5773
+ if (
5774
+ typeof response.fileId === "string" &&
5775
+ typeof response.content === "string"
5776
+ ) {
5777
+ onComponentPropApplied?.(response.fileId, response.content);
5778
+ }
5779
+ },
5780
+ onSettled: () => {
5781
+ void queryClient.invalidateQueries({
5782
+ queryKey: ["action", "get-design"],
5783
+ });
5784
+ void queryClient.invalidateQueries({ queryKey: detailsKey });
5785
+ void refetch();
5786
+ },
5787
+ },
5788
+ );
5789
+ };
5790
+
5791
+ useEffect(() => {
5792
+ if (typeof document === "undefined") return;
5793
+
5794
+ const handleMessage = (event: MessageEvent) => {
5795
+ if (
5796
+ (event.data as { type?: unknown } | null)?.type === "element-select"
5797
+ ) {
5798
+ void refetch();
5799
+ }
5800
+ };
5801
+ window.addEventListener("message", handleMessage);
5802
+ return () => {
5803
+ window.removeEventListener("message", handleMessage);
5804
+ };
5805
+ }, [refetch]);
5806
+
5807
+ // While loading, show a compact skeleton that matches the section width.
5808
+ if (isLoading) {
5809
+ return (
5810
+ <section className="shrink-0 border-t border-[var(--design-editor-control-border)] first:border-t-0">
5811
+ <div className="flex min-h-9 items-center gap-2 px-3">
5812
+ <div className="h-3 w-24 animate-pulse rounded bg-muted/50" />
5813
+ </div>
5814
+ <div className="space-y-1.5 px-3 pb-3 pt-0.5">
5815
+ <div className="h-5 w-full animate-pulse rounded bg-muted/40" />
5816
+ <div className="h-5 w-3/4 animate-pulse rounded bg-muted/40" />
5817
+ </div>
5818
+ </section>
5819
+ );
5820
+ }
5821
+
5822
+ // Hard error (node not found, no access, etc.) — collapse silently so
5823
+ // the rest of the inspector is not disrupted.
5824
+ if (error || !data) return null;
5825
+
5826
+ const {
5827
+ name,
5828
+ sourceType,
5829
+ sourceLocation,
5830
+ observedProps,
5831
+ persistedVariants,
5832
+ instance,
5833
+ capabilities,
5834
+ } = data;
5835
+
5836
+ // ── Editable prop model ───────────────────────────────────────────────────
5837
+ // Inline/Alpine designs persist through apply-component-prop-edit. Two write
5838
+ // surfaces:
5839
+ // • x-data keys → kind "alpineData" (rewrites the whole object)
5840
+ // • data-prop-* attrs → kind "attribute" (data-agent-native-prop-<kebab>)
5841
+ // Real-app sources keep the deeper source-prop controls gated as-is, so for
5842
+ // non-inline sources the controls are read-only here.
5843
+ const isInline = sourceType === "inline";
5844
+ const editingEnabled = isInline && capabilities.canEditProps; // gated; real-app stays read-only for now
5845
+ const alpineData = parseAlpineDataObject(instance?.alpineData);
5846
+
5847
+ // Each editable row: name + current value + how it persists + its options.
5848
+ type PropRow = {
5849
+ name: string;
5850
+ value: string;
5851
+ /** Variant/enum options when the prop is a known group. */
5852
+ options?: string[];
5853
+ /** Persist surface for this prop. */
5854
+ surface: "alpineData" | "attribute";
5855
+ };
5856
+
5857
+ const rows: PropRow[] = [];
5858
+ const seen = new Set<string>();
5859
+
5860
+ // 1) Alpine x-data keys come first — they drive the live variant/state.
5861
+ if (alpineData) {
5862
+ for (const [key, value] of Object.entries(alpineData)) {
5863
+ rows.push({
5864
+ name: key,
5865
+ value,
5866
+ options: persistedVariants[key],
5867
+ surface: "alpineData",
5868
+ });
5869
+ seen.add(key);
5870
+ }
5871
+ }
5872
+
5873
+ // 2) data-agent-native-prop-* attributes not already covered by x-data.
5874
+ for (const prop of observedProps) {
5875
+ if (seen.has(prop.name)) continue;
5876
+ rows.push({
5877
+ name: prop.name,
5878
+ value: prop.value,
5879
+ options: persistedVariants[prop.name],
5880
+ surface: "attribute",
5881
+ });
5882
+ seen.add(prop.name);
5883
+ }
5884
+
5885
+ // 3) persistedVariant groups with no observed value yet (default to first).
5886
+ for (const [group, options] of Object.entries(persistedVariants)) {
5887
+ if (seen.has(group)) continue;
5888
+ rows.push({
5889
+ name: group,
5890
+ value: options[0] ?? "",
5891
+ options,
5892
+ surface: alpineData ? "alpineData" : "attribute",
5893
+ });
5894
+ seen.add(group);
5895
+ }
5896
+
5897
+ const hasRows = rows.length > 0;
5898
+
5899
+ // Build the apply-component-prop-edit payload + optimistic cache patch for a
5900
+ // single prop change.
5901
+ const commitProp = (row: PropRow, nextValue: string) => {
5902
+ if (!editingEnabled || nextValue === row.value) return;
5903
+
5904
+ if (row.surface === "alpineData") {
5905
+ // Surgically replace only the edited key's value inside the original
5906
+ // x-data string so methods, nested objects, escaped strings, quoted
5907
+ // keys, and whitespace survive byte-for-byte. A full
5908
+ // parse→mutate→serialize round-trip would drop anything
5909
+ // parseAlpineDataObject can't model (e.g. `toggle() { … }`).
5910
+ const original = instance?.alpineData ?? "";
5911
+ const surgical = replaceAlpineDataKeyValue(original, row.name, nextValue);
5912
+
5913
+ let serialized: string;
5914
+ if (surgical != null) {
5915
+ serialized = surgical;
5916
+ } else if (canRebuildAlpineDataLosslessly(original)) {
5917
+ // The key isn't present yet (or there is no original literal). Rebuild
5918
+ // from the flat map — safe here precisely because the original holds
5919
+ // nothing richer than the flat literals serialize already preserves.
5920
+ const nextData = { ...(alpineData ?? {}), [row.name]: nextValue };
5921
+ serialized = serializeAlpineDataObject(nextData);
5922
+ } else {
5923
+ // The original carries content (methods / nested / expressions) we
5924
+ // can't rewrite for this key without dropping it. Fail safe: skip the
5925
+ // edit rather than persist a lossy rewrite, and tell the user why so
5926
+ // the change doesn't silently vanish.
5927
+ toast.error(
5928
+ // i18n-ignore
5929
+ "Can’t safely edit this prop inline — this component’s Alpine state is too complex. Edit the source instead.",
5930
+ );
5931
+ return;
5932
+ }
5933
+
5934
+ const nextSerialized = serialized;
5935
+ persistPropEdit(
5936
+ { kind: "alpineData", value: nextSerialized },
5937
+ (prev) => ({
5938
+ ...prev,
5939
+ instance: { ...(prev.instance ?? {}), alpineData: nextSerialized },
5940
+ observedProps: prev.observedProps.map((p) =>
5941
+ p.name === row.name ? { ...p, value: nextValue } : p,
5942
+ ),
5943
+ }),
5944
+ );
5945
+ } else {
5946
+ persistPropEdit(
5947
+ {
5948
+ kind: "attribute",
5949
+ attribute: propNameToDataAttribute(row.name),
5950
+ value: nextValue,
5951
+ },
5952
+ (prev) => {
5953
+ const exists = prev.observedProps.some((p) => p.name === row.name);
5954
+ return {
5955
+ ...prev,
5956
+ observedProps: exists
5957
+ ? prev.observedProps.map((p) =>
5958
+ p.name === row.name ? { ...p, value: nextValue } : p,
5959
+ )
5960
+ : [...prev.observedProps, { name: row.name, value: nextValue }],
5961
+ };
5962
+ },
5963
+ );
5964
+ }
5965
+ };
5966
+
5967
+ // ── Capability gates ──
5968
+ const canJumpToSource =
5969
+ capabilities.canResolveToFile &&
5970
+ Boolean(sourceLocation?.filePath) &&
5971
+ sourceCapabilities.includes("resolveNodeToFile");
5972
+
5973
+ // ── Source chip text ──
5974
+ const sourceChip = sourceLocation?.exportName
5975
+ ? `${sourceLocation.exportName} — ${sourceLocation.filePath}`
5976
+ : (sourceLocation?.filePath ?? null);
5977
+
5978
+ return (
5979
+ <section
5980
+ className="shrink-0 border-t border-[var(--design-editor-control-border)] first:border-t-0"
5981
+ data-testid="component-section"
5982
+ >
5983
+ {/* ── Section header ── */}
5984
+ <div className="flex min-h-9 items-center gap-2 px-3">
5985
+ {/* Accent diamond matching the workbench artboard component rows */}
5986
+ <span
5987
+ className="size-2 shrink-0 rotate-45 rounded-[2px] bg-[var(--design-editor-accent-color)]"
5988
+ aria-hidden="true"
5989
+ />
5990
+ <h3 className="min-w-0 flex-1 truncate text-[11px] font-semibold text-foreground">
5991
+ {name}
5992
+ </h3>
5993
+ {/* Jump-to-source action */}
5994
+ <Tooltip>
5995
+ <TooltipTrigger asChild>
5996
+ <Button
5997
+ type="button"
5998
+ variant="ghost"
5999
+ size="icon"
6000
+ className="size-6 rounded-md text-muted-foreground hover:text-foreground disabled:cursor-not-allowed disabled:opacity-40"
6001
+ disabled={!canJumpToSource}
6002
+ aria-label={
6003
+ "Edit component source" /* i18n-ignore design inspector action */
6004
+ }
6005
+ onClick={() => {
6006
+ openSourceMutation.mutate({
6007
+ designId,
6008
+ nodeId,
6009
+ ...(fileId ? { fileId } : {}),
6010
+ });
6011
+ }}
6012
+ >
6013
+ <IconExternalLink className="size-3.5" />
6014
+ </Button>
6015
+ </TooltipTrigger>
6016
+ <TooltipContent>
6017
+ {
6018
+ canJumpToSource
6019
+ ? "Edit component source" /* i18n-ignore design inspector action */
6020
+ : (capabilities.ctaMessage ??
6021
+ "Connect Builder to jump to component source") /* i18n-ignore design inspector tooltip */
6022
+ }
6023
+ </TooltipContent>
6024
+ </Tooltip>
6025
+ </div>
6026
+
6027
+ {/* ── Body ── */}
6028
+ <div className="space-y-1.5 px-3 pb-3 pt-0.5 text-[11px]">
6029
+ {/* Source path chip */}
6030
+ {sourceChip && (
6031
+ <div
6032
+ className="flex items-center gap-1 rounded bg-[var(--design-editor-control-bg)] px-2 py-1"
6033
+ title={sourceChip}
6034
+ >
6035
+ <IconCode className="size-3 shrink-0 text-muted-foreground/60" />
6036
+ <span className="min-w-0 flex-1 truncate font-mono text-[10px] text-muted-foreground">
6037
+ {sourceChip}
6038
+ </span>
6039
+ </div>
6040
+ )}
6041
+
6042
+ {/* Typed prop controls. Inline/Alpine designs are editable and persist
6043
+ through apply-component-prop-edit; real-app sources are read-only
6044
+ until the deeper source-prop controls land. */}
6045
+ {hasRows && (
6046
+ <div className="space-y-1">
6047
+ <p className="text-[10px] font-semibold uppercase tracking-wide text-muted-foreground/70">
6048
+ {"Props" /* i18n-ignore design inspector label */}
6049
+ </p>
6050
+ {rows.map((row) => {
6051
+ const hasOptions = (row.options?.length ?? 0) > 0;
6052
+ const isBoolean = !hasOptions && isBooleanPropValue(row.value);
6053
+ const disabled = !editingEnabled || applyPropMutation.isPending;
6054
+ return (
6055
+ <div key={row.name} className="flex items-center gap-1.5">
6056
+ <Label className="w-[64px] shrink-0 truncate text-[11px] font-medium capitalize text-muted-foreground">
6057
+ {row.name}
6058
+ </Label>
6059
+ {hasOptions ? (
6060
+ // Dropdown for variant / enum groups.
6061
+ <Select
6062
+ value={row.value || row.options![0] || ""}
6063
+ onValueChange={(v) => commitProp(row, v)}
6064
+ disabled={disabled}
6065
+ >
6066
+ <SelectTrigger className="h-6 min-w-0 flex-1 rounded-md border-[var(--design-editor-control-border)] bg-[var(--design-editor-control-bg)] px-1.5 text-[11px] shadow-none focus:ring-1 focus:ring-[var(--design-editor-accent-color)]">
6067
+ <SelectValue />
6068
+ </SelectTrigger>
6069
+ <SelectContent>
6070
+ {row.options!.map((opt) => (
6071
+ <SelectItem
6072
+ key={opt}
6073
+ value={opt}
6074
+ className="text-[11px]"
6075
+ >
6076
+ {opt}
6077
+ </SelectItem>
6078
+ ))}
6079
+ </SelectContent>
6080
+ </Select>
6081
+ ) : isBoolean ? (
6082
+ // Toggle for boolean props.
6083
+ <div className="flex min-w-0 flex-1 items-center">
6084
+ <Switch
6085
+ checked={row.value.trim().toLowerCase() === "true"}
6086
+ onCheckedChange={(checked) =>
6087
+ commitProp(row, checked ? "true" : "false")
6088
+ }
6089
+ disabled={disabled}
6090
+ className="h-4 w-7 [&>span]:size-3 [&>span]:data-[state=checked]:translate-x-3"
6091
+ aria-label={
6092
+ row.name /* i18n-ignore dynamic prop name */
6093
+ }
6094
+ />
6095
+ </div>
6096
+ ) : (
6097
+ // Text input for string props (e.g. a label).
6098
+ <Input
6099
+ defaultValue={row.value}
6100
+ key={`${row.name}:${row.value}`}
6101
+ disabled={disabled}
6102
+ onBlur={(e) => commitProp(row, e.target.value)}
6103
+ onKeyDown={(e) => {
6104
+ if (e.key === "Enter") {
6105
+ e.preventDefault();
6106
+ e.currentTarget.blur();
6107
+ }
6108
+ }}
6109
+ className="h-6 min-w-0 flex-1 rounded-md border-[var(--design-editor-control-border)] bg-[var(--design-editor-control-bg)] px-1.5 text-[11px] shadow-none focus-visible:ring-1 focus-visible:ring-[var(--design-editor-accent-color)]"
6110
+ />
6111
+ )}
6112
+ </div>
6113
+ );
6114
+ })}
6115
+ </div>
6116
+ )}
6117
+
6118
+ {/* Connect-Builder CTA (inline, only when real-app features are gated) */}
6119
+ {capabilities.ctaRequired && (
6120
+ <MakeItRealCard
6121
+ designId={designId}
6122
+ featureLabel="component source jump and full prop controls"
6123
+ />
6124
+ )}
6125
+ </div>
6126
+ </section>
6127
+ );
6128
+ }
6129
+
4653
6130
  export function EditPanel({
4654
6131
  selectedElement,
4655
6132
  pageStyles = {},
@@ -4664,15 +6141,36 @@ export function EditPanel({
4664
6141
  onRequestTweaks,
4665
6142
  onStyleChange,
4666
6143
  onStylesChange,
6144
+ onAutoLayoutConvert,
4667
6145
  onExport,
4668
6146
  exporting = false,
4669
6147
  readOnly = false,
6148
+ fileId,
6149
+ filename,
6150
+ designId,
6151
+ onComponentPropApplied,
6152
+ onTokensApplied,
6153
+ statesPanelProps,
6154
+ reviewPanelProps,
6155
+ componentNodeId,
6156
+ sourceCapabilities = [],
6157
+ onCreateComponent,
6158
+ defaultComponentName = "Component",
6159
+ inspectCode,
4670
6160
  }: EditPanelProps) {
4671
6161
  const t = useT();
6162
+ const [createComponentOpen, setCreateComponentOpen] = useState(false);
4672
6163
  const [exportSettings, setExportSettings] = useState<ExportSettingsValue>(
4673
6164
  DEFAULT_EXPORT_SETTINGS,
4674
6165
  );
4675
6166
  const [showExportPreview, setShowExportPreview] = useState(false);
6167
+ // Sub-tab within the Tweaks area: "tokens" (TokensPanel face) or "tweaks"
6168
+ // (raw TweakDefinition controls). Always default to the existing "tweaks"
6169
+ // controls so opening Tweaks lands on the user's TweakDefinition controls
6170
+ // (and the + affordance) rather than auto-firing token indexing. "Tokens"
6171
+ // remains available as a sub-tab the user can click when designId is set.
6172
+ const [tweaksSubTab, setTweaksSubTab] = useState<TweaksSubTab>("tweaks");
6173
+
4676
6174
  const selectedElementKey = selectedElement
4677
6175
  ? elementIdentityKey(selectedElement)
4678
6176
  : "none";
@@ -4689,6 +6187,16 @@ export function EditPanel({
4689
6187
  },
4690
6188
  [onTweakChange],
4691
6189
  );
6190
+ // The "Add Tweaks" (+) flow lives only on the "tweaks" sub-tab. Switch to it
6191
+ // before opening the prompt so the + button and the newly added control are
6192
+ // visible (avoids leaving the user on the Tokens sub-tab where + isn't shown).
6193
+ const handleRequestTweaks = useCallback(
6194
+ (anchor: HTMLElement) => {
6195
+ setTweaksSubTab("tweaks");
6196
+ onRequestTweaks?.(anchor);
6197
+ },
6198
+ [onRequestTweaks],
6199
+ );
4692
6200
 
4693
6201
  useEffect(() => {
4694
6202
  setExportSettings(DEFAULT_EXPORT_SETTINGS);
@@ -4702,6 +6210,7 @@ export function EditPanel({
4702
6210
  // over-scroll bounce, could briefly un-shield the canvas and allow a stray
4703
6211
  // pointer event to deselect the selected canvas element (R3 regression).
4704
6212
  const scrolledRecentlyRef = useRef(false);
6213
+ const userScrollIntentRef = useRef(false);
4705
6214
  const scrollTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
4706
6215
 
4707
6216
  if (readOnly) {
@@ -4755,11 +6264,40 @@ export function EditPanel({
4755
6264
 
4756
6265
  {activeTab === "design" ? (
4757
6266
  <>
4758
- <SelectionHeader element={selectedElement} />
6267
+ <SelectionHeader
6268
+ element={selectedElement}
6269
+ onCreateComponent={
6270
+ onCreateComponent && selectedElement
6271
+ ? () => setCreateComponentOpen(true)
6272
+ : undefined
6273
+ }
6274
+ inspectCodePopover={
6275
+ inspectCode && selectedElement
6276
+ ? (trigger) => (
6277
+ <InspectCodePopover trigger={trigger} data={inspectCode} />
6278
+ )
6279
+ : undefined
6280
+ }
6281
+ />
6282
+ {onCreateComponent && (
6283
+ <CreateComponentDialog
6284
+ open={createComponentOpen}
6285
+ onOpenChange={setCreateComponentOpen}
6286
+ defaultName={defaultComponentName}
6287
+ onSubmit={onCreateComponent}
6288
+ />
6289
+ )}
4759
6290
 
4760
6291
  <div
4761
6292
  className="design-inspector-scroll min-h-0 flex-1 overflow-y-auto overscroll-contain"
6293
+ onWheelCapture={() => {
6294
+ userScrollIntentRef.current = true;
6295
+ }}
6296
+ onTouchMoveCapture={() => {
6297
+ userScrollIntentRef.current = true;
6298
+ }}
4762
6299
  onScroll={() => {
6300
+ if (!userScrollIntentRef.current) return;
4763
6301
  // Mark that a scroll just happened so the click that some
4764
6302
  // browsers fire at the end of a scroll gesture (or after an
4765
6303
  // overscroll/rubber-band bounce) is suppressed. Crucially this
@@ -4774,6 +6312,7 @@ export function EditPanel({
4774
6312
  }
4775
6313
  scrollTimerRef.current = setTimeout(() => {
4776
6314
  scrolledRecentlyRef.current = false;
6315
+ userScrollIntentRef.current = false;
4777
6316
  scrollTimerRef.current = null;
4778
6317
  }, 300);
4779
6318
  }}
@@ -4785,6 +6324,7 @@ export function EditPanel({
4785
6324
  // browsers generate after a touch-scroll ends.
4786
6325
  if (!scrolledRecentlyRef.current) return;
4787
6326
  scrolledRecentlyRef.current = false;
6327
+ userScrollIntentRef.current = false;
4788
6328
  if (scrollTimerRef.current !== null) {
4789
6329
  clearTimeout(scrollTimerRef.current);
4790
6330
  scrollTimerRef.current = null;
@@ -4821,6 +6361,18 @@ export function EditPanel({
4821
6361
  next?.focus();
4822
6362
  }}
4823
6363
  >
6364
+ {/* §6.1 Component section — shown at the top when a component
6365
+ instance is selected. Requires designId + componentNodeId. */}
6366
+ {designId && componentNodeId && (
6367
+ <ComponentSection
6368
+ designId={designId}
6369
+ fileId={fileId}
6370
+ nodeId={componentNodeId}
6371
+ onComponentPropApplied={onComponentPropApplied}
6372
+ sourceCapabilities={sourceCapabilities}
6373
+ />
6374
+ )}
6375
+
4824
6376
  {!selectedElement && (
4825
6377
  <PageProperties
4826
6378
  styles={pageStyles}
@@ -4839,6 +6391,7 @@ export function EditPanel({
4839
6391
  element={selectedElement}
4840
6392
  onStyleChange={onStyleChange}
4841
6393
  onStylesChange={onStylesChange}
6394
+ onAutoLayoutConvert={onAutoLayoutConvert}
4842
6395
  />
4843
6396
  <AppearanceProperties
4844
6397
  element={selectedElement}
@@ -4875,6 +6428,17 @@ export function EditPanel({
4875
6428
  onStyleChange={onStyleChange}
4876
6429
  />
4877
6430
  ) : null}
6431
+ {/* AI edit request block — collapsible, collapsed by default */}
6432
+ <section className="shrink-0 border-t border-[var(--design-editor-control-border)]">
6433
+ <InspectorAiActions
6434
+ selector={selectedElement.selector}
6435
+ sourceId={selectedElement.sourceId}
6436
+ designId={designId}
6437
+ fileId={fileId}
6438
+ filename={filename}
6439
+ canEdit={!readOnly}
6440
+ />
6441
+ </section>
4878
6442
  </>
4879
6443
  )}
4880
6444
  {onExport ? (
@@ -4909,40 +6473,212 @@ export function EditPanel({
4909
6473
  ) : null}
4910
6474
  </PanelSection>
4911
6475
  ) : null}
6476
+
6477
+ {/* §6.4 States & Responsive — contextual section in Design tab.
6478
+ Collapsed by default so existing Design content is not buried.
6479
+ Only mounts when designId is provided so there is something to
6480
+ load; without it the panel would fire an action with no id. */}
6481
+ {designId && statesPanelProps ? (
6482
+ <PanelSection
6483
+ title={"States" /* i18n-ignore design inspector section */}
6484
+ defaultCollapsed
6485
+ actions={
6486
+ <Tooltip>
6487
+ <TooltipTrigger asChild>
6488
+ <Button
6489
+ type="button"
6490
+ variant="ghost"
6491
+ size="icon"
6492
+ className="size-6 rounded-md text-muted-foreground hover:text-foreground"
6493
+ aria-label={
6494
+ "Breakpoints & states" /* i18n-ignore design inspector action */
6495
+ }
6496
+ >
6497
+ <IconDeviceDesktop className="size-3.5" />
6498
+ </Button>
6499
+ </TooltipTrigger>
6500
+ <TooltipContent>
6501
+ {
6502
+ "Breakpoints & states" /* i18n-ignore design inspector action */
6503
+ }
6504
+ </TooltipContent>
6505
+ </Tooltip>
6506
+ }
6507
+ >
6508
+ <StatesPanel designId={designId} {...statesPanelProps} />
6509
+ {/* §6.4 / §6.6 — live captures (real running-app data, route
6510
+ props, API responses) are a real-app capability. When
6511
+ canCapture is false (inline source) surface a compact
6512
+ "Make it real" CTA so the user knows it's one step away. */}
6513
+ {!statesPanelProps.canCapture && (
6514
+ <MakeItRealCard
6515
+ designId={designId}
6516
+ featureLabel="live data captures and real-app states"
6517
+ />
6518
+ )}
6519
+ </PanelSection>
6520
+ ) : null}
6521
+
6522
+ {/* §6.5 Review — contextual section in Design tab.
6523
+ Collapsed by default. Renders when reviewPanelProps is provided,
6524
+ no designId check needed since ReviewPanel is statically fed. */}
6525
+ {reviewPanelProps ? (
6526
+ <PanelSection
6527
+ title={"Review" /* i18n-ignore design inspector section */}
6528
+ defaultCollapsed
6529
+ actions={
6530
+ <Tooltip>
6531
+ <TooltipTrigger asChild>
6532
+ <Button
6533
+ type="button"
6534
+ variant="ghost"
6535
+ size="icon"
6536
+ className="size-6 rounded-md text-muted-foreground hover:text-foreground"
6537
+ aria-label={
6538
+ "Accessibility & visual diff" /* i18n-ignore design inspector action */
6539
+ }
6540
+ >
6541
+ <IconShieldCheck className="size-3.5" />
6542
+ </Button>
6543
+ </TooltipTrigger>
6544
+ <TooltipContent>
6545
+ {
6546
+ "Accessibility & visual diff" /* i18n-ignore design inspector action */
6547
+ }
6548
+ </TooltipContent>
6549
+ </Tooltip>
6550
+ }
6551
+ >
6552
+ {/* ReviewPanel manages its own scroll; no extra wrapper needed. */}
6553
+ <ReviewPanel {...reviewPanelProps} />
6554
+ </PanelSection>
6555
+ ) : null}
4912
6556
  </div>
4913
6557
  </>
4914
6558
  ) : activeTab === "tweaks" ? (
4915
6559
  <div className="flex min-h-0 flex-1 flex-col overflow-hidden">
4916
- <div className="flex h-10 shrink-0 items-center justify-between gap-2 border-b border-border/90 px-3">
4917
- <h3 className="min-w-0 flex-1 truncate text-[13px] font-semibold text-foreground">
4918
- {t("designEditor.tweaks")}
4919
- </h3>
4920
- {onRequestTweaks ? (
4921
- <Tooltip>
4922
- <TooltipTrigger asChild>
4923
- <Button
4924
- type="button"
4925
- variant="ghost"
4926
- size="icon"
4927
- className="size-7 rounded-sm text-muted-foreground hover:bg-accent hover:text-foreground"
4928
- aria-label={t("designEditor.addTweaks")}
4929
- onClick={(event) => onRequestTweaks(event.currentTarget)}
4930
- >
4931
- <IconPlus className="size-3.5" />
4932
- </Button>
4933
- </TooltipTrigger>
4934
- <TooltipContent>{t("designEditor.addTweaks")}</TooltipContent>
4935
- </Tooltip>
4936
- ) : null}
4937
- </div>
6560
+ {/* Tweaks tab header: when designId is present show a Tokens | Tweaks
6561
+ sub-tab switcher so the TokensPanel is the first-class face of
6562
+ this area per §6.2. When there is no designId only the raw tweaks
6563
+ title is shown (original behaviour). */}
6564
+ {designId ? (
6565
+ <div className="flex h-9 shrink-0 items-center gap-0.5 border-b border-border/90 px-2">
6566
+ <Button
6567
+ type="button"
6568
+ variant="ghost"
6569
+ size="sm"
6570
+ className={cn(
6571
+ "h-6 rounded-md px-2 text-[11px] font-semibold text-muted-foreground transition-colors hover:text-foreground",
6572
+ tweaksSubTab === "tokens" &&
6573
+ "bg-[var(--design-editor-panel-raised-bg)] text-foreground",
6574
+ )}
6575
+ onClick={() => setTweaksSubTab("tokens")}
6576
+ >
6577
+ <IconPalette className="mr-1 size-3" />
6578
+ {"Tokens" /* i18n-ignore design inspector sub-tab */}
6579
+ </Button>
6580
+ <Button
6581
+ type="button"
6582
+ variant="ghost"
6583
+ size="sm"
6584
+ className={cn(
6585
+ "h-6 rounded-md px-2 text-[11px] font-semibold text-muted-foreground transition-colors hover:text-foreground",
6586
+ tweaksSubTab === "tweaks" &&
6587
+ "bg-[var(--design-editor-panel-raised-bg)] text-foreground",
6588
+ )}
6589
+ onClick={() => setTweaksSubTab("tweaks")}
6590
+ >
6591
+ {t("designEditor.tweaks")}
6592
+ </Button>
6593
+ </div>
6594
+ ) : (
6595
+ <div className="flex h-10 shrink-0 items-center justify-between gap-2 border-b border-border/90 px-3">
6596
+ <h3 className="min-w-0 flex-1 truncate text-[13px] font-semibold text-foreground">
6597
+ {t("designEditor.tweaks")}
6598
+ </h3>
6599
+ {onRequestTweaks ? (
6600
+ <Tooltip>
6601
+ <TooltipTrigger asChild>
6602
+ <Button
6603
+ type="button"
6604
+ variant="ghost"
6605
+ size="icon"
6606
+ className="size-7 rounded-sm text-muted-foreground hover:bg-accent hover:text-foreground"
6607
+ aria-label={t("designEditor.addTweaks")}
6608
+ onClick={(event) =>
6609
+ handleRequestTweaks(event.currentTarget)
6610
+ }
6611
+ >
6612
+ <IconPlus className="size-3.5" />
6613
+ </Button>
6614
+ </TooltipTrigger>
6615
+ <TooltipContent>{t("designEditor.addTweaks")}</TooltipContent>
6616
+ </Tooltip>
6617
+ ) : null}
6618
+ </div>
6619
+ )}
6620
+
4938
6621
  <div className="design-inspector-scroll min-h-0 flex-1 overflow-y-auto overscroll-contain">
4939
- <TweaksPanelContent
4940
- tweaks={tweaks}
4941
- values={tweakValues}
4942
- onChange={handleTweakChange}
4943
- onRequestTweaks={onRequestTweaks}
4944
- className="px-3 py-3"
4945
- />
6622
+ {designId && tweaksSubTab === "tokens" ? (
6623
+ /* §6.2 Tokens face — first-class view of the Tweaks area.
6624
+ TokensPanel calls apply-design-token-edit which routes through
6625
+ the Tweaks loop (designs.data.tweakSelections persist). */
6626
+ <>
6627
+ <TokensPanel
6628
+ designId={designId}
6629
+ onTokensApplied={onTokensApplied}
6630
+ />
6631
+ {/* §5 / §6.6 — token write-back to source is a real-app
6632
+ capability. On inline sources (sourceCapabilities lacks
6633
+ "writeFile") show a compact "Make it real" CTA after the
6634
+ token list so the user knows write-back is one step away. */}
6635
+ {!sourceCapabilities.includes("writeFile") && (
6636
+ <div className="px-3 pb-3 pt-1">
6637
+ <MakeItRealCard
6638
+ designId={designId}
6639
+ featureLabel="token write-back to source files"
6640
+ />
6641
+ </div>
6642
+ )}
6643
+ </>
6644
+ ) : (
6645
+ /* Raw TweakDefinition controls — shown when no designId, or when
6646
+ the user switches to the Tweaks sub-tab. */
6647
+ <>
6648
+ {designId && onRequestTweaks ? (
6649
+ /* When in sub-tab mode, show the add-tweaks action in the
6650
+ scrollable content area above the raw controls. */
6651
+ <div className="flex items-center justify-end px-3 pt-2">
6652
+ <Tooltip>
6653
+ <TooltipTrigger asChild>
6654
+ <Button
6655
+ type="button"
6656
+ variant="ghost"
6657
+ size="icon"
6658
+ className="size-7 rounded-sm text-muted-foreground hover:bg-accent hover:text-foreground"
6659
+ aria-label={t("designEditor.addTweaks")}
6660
+ onClick={(event) =>
6661
+ handleRequestTweaks(event.currentTarget)
6662
+ }
6663
+ >
6664
+ <IconPlus className="size-3.5" />
6665
+ </Button>
6666
+ </TooltipTrigger>
6667
+ <TooltipContent>
6668
+ {t("designEditor.addTweaks")}
6669
+ </TooltipContent>
6670
+ </Tooltip>
6671
+ </div>
6672
+ ) : null}
6673
+ <TweaksPanelContent
6674
+ tweaks={tweaks}
6675
+ values={tweakValues}
6676
+ onChange={handleTweakChange}
6677
+ onRequestTweaks={!designId ? handleRequestTweaks : undefined}
6678
+ className="px-3 py-3"
6679
+ />
6680
+ </>
6681
+ )}
4946
6682
  </div>
4947
6683
  </div>
4948
6684
  ) : extensionContext ? (