@agent-native/core 0.80.9 → 0.80.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (111) hide show
  1. package/corpus/README.md +1 -1
  2. package/corpus/core/CHANGELOG.md +7 -0
  3. package/corpus/core/package.json +1 -1
  4. package/corpus/core/src/cli/design-connect.ts +28 -2
  5. package/corpus/core/src/cli/skills.ts +45 -24
  6. package/corpus/templates/design/DESIGN-STUDIO-PLAN.md +717 -0
  7. package/corpus/templates/design/actions/add-breakpoint.ts +143 -0
  8. package/corpus/templates/design/actions/add-localhost-screens.ts +5 -0
  9. package/corpus/templates/design/actions/apply-a11y-fix.ts +317 -0
  10. package/corpus/templates/design/actions/apply-component-prop-edit.ts +433 -0
  11. package/corpus/templates/design/actions/apply-design-state.ts +200 -0
  12. package/corpus/templates/design/actions/apply-design-token-edit.ts +202 -0
  13. package/corpus/templates/design/actions/apply-motion-edit.ts +450 -0
  14. package/corpus/templates/design/actions/apply-shader-fill.ts +404 -0
  15. package/corpus/templates/design/actions/apply-visual-edit.ts +191 -5
  16. package/corpus/templates/design/actions/capture-design-state.ts +224 -0
  17. package/corpus/templates/design/actions/connect-builder-app.ts +162 -0
  18. package/corpus/templates/design/actions/create-component.ts +447 -0
  19. package/corpus/templates/design/actions/create-design-branch.ts +263 -0
  20. package/corpus/templates/design/actions/create-design-state.ts +162 -0
  21. package/corpus/templates/design/actions/delete-design-state.ts +55 -0
  22. package/corpus/templates/design/actions/deploy-design-preview.ts +275 -0
  23. package/corpus/templates/design/actions/get-component-details.ts +242 -0
  24. package/corpus/templates/design/actions/get-design-branch-diff.ts +362 -0
  25. package/corpus/templates/design/actions/get-design-review.ts +314 -0
  26. package/corpus/templates/design/actions/get-design-surface-index.ts +582 -0
  27. package/corpus/templates/design/actions/get-motion-timeline.ts +99 -0
  28. package/corpus/templates/design/actions/index-components.ts +258 -0
  29. package/corpus/templates/design/actions/index-design-tokens.ts +273 -0
  30. package/corpus/templates/design/actions/list-design-extensions.ts +309 -0
  31. package/corpus/templates/design/actions/list-design-source-capabilities.ts +153 -0
  32. package/corpus/templates/design/actions/list-design-states.ts +72 -0
  33. package/corpus/templates/design/actions/migrate-inline-design-to-app.ts +298 -0
  34. package/corpus/templates/design/actions/open-component-source.ts +237 -0
  35. package/corpus/templates/design/actions/preview-component-prop-edit.ts +254 -0
  36. package/corpus/templates/design/actions/preview-design-token-edit.ts +113 -0
  37. package/corpus/templates/design/actions/preview-shader-fill.ts +189 -0
  38. package/corpus/templates/design/actions/remove-breakpoint.ts +103 -0
  39. package/corpus/templates/design/actions/remove-motion-timeline.ts +196 -0
  40. package/corpus/templates/design/actions/run-design-audit.ts +421 -0
  41. package/corpus/templates/design/actions/run-design-extension-action.ts +284 -0
  42. package/corpus/templates/design/actions/set-active-breakpoint.ts +39 -0
  43. package/corpus/templates/design/app/components/design/DesignCanvas.tsx +1414 -71
  44. package/corpus/templates/design/app/components/design/DesignExtensionsPanel.tsx +632 -43
  45. package/corpus/templates/design/app/components/design/DrawOverlay.tsx +1 -1
  46. package/corpus/templates/design/app/components/design/EditPanel.tsx +1391 -52
  47. package/corpus/templates/design/app/components/design/LayersPanel.tsx +4 -3
  48. package/corpus/templates/design/app/components/design/LocalSourceEditBanner.tsx +157 -0
  49. package/corpus/templates/design/app/components/design/MotionDock.tsx +1062 -0
  50. package/corpus/templates/design/app/components/design/MultiScreenCanvas.tsx +1052 -98
  51. package/corpus/templates/design/app/components/design/ReviewPanel.tsx +759 -0
  52. package/corpus/templates/design/app/components/design/StatesPanel.tsx +579 -0
  53. package/corpus/templates/design/app/components/design/TokensPanel.tsx +573 -0
  54. package/corpus/templates/design/app/components/design/canvas-primitive-style.ts +241 -0
  55. package/corpus/templates/design/app/components/design/index.ts +16 -0
  56. package/corpus/templates/design/app/components/design/inspector/AutoLayoutMatrix.tsx +15 -6
  57. package/corpus/templates/design/app/components/design/inspector/DesignColorPicker.tsx +66 -3
  58. package/corpus/templates/design/app/components/design/inspector/InspectorAiActions.tsx +145 -0
  59. package/corpus/templates/design/app/components/design/inspector/SHADER_INTEGRATION.md +42 -12
  60. package/corpus/templates/design/app/components/visual-editor/DrawOverlay.tsx +1 -1
  61. package/corpus/templates/design/app/hooks/useAgentEditRequest.ts +131 -0
  62. package/corpus/templates/design/app/hooks/useDesignHotkeys.ts +3 -1
  63. package/corpus/templates/design/app/i18n/zh-TW.ts +31 -0
  64. package/corpus/templates/design/app/i18n-data.ts +269 -0
  65. package/corpus/templates/design/app/pages/DesignEditor.tsx +2478 -386
  66. package/corpus/templates/design/changelog/2026-06-29-added-a-review-panel-in-the-design-editor-s-inspector-with-a.md +6 -0
  67. package/corpus/templates/design/changelog/2026-06-29-copying-and-pasting-layers-no-longer-shows-success-notificat.md +6 -0
  68. package/corpus/templates/design/changelog/2026-06-29-device-presets-in-all-screens-view-resize-the-selected-previ.md +6 -0
  69. package/corpus/templates/design/changelog/2026-06-29-layer-and-canvas-drags-keep-the-layer-list-stable-while-chan.md +6 -0
  70. package/corpus/templates/design/changelog/2026-06-29-layer-moves-can-be-undone-and-redone-without-flashing-the-ca.md +6 -0
  71. package/corpus/templates/design/changelog/2026-06-29-screen-previews-use-a-single-blue-hover-border-in-all-screen.md +6 -0
  72. package/corpus/templates/design/changelog/2026-06-29-selected-containers-show-draggable-padding-and-gap-guides-on.md +6 -0
  73. package/corpus/templates/design/changelog/2026-06-29-the-design-editor-adds-a-studio-layer-with-tokens-respon.md +6 -0
  74. package/corpus/templates/design/changelog/2026-06-30-accessibility-findings-now-offer-a-one-click-fix.md +6 -0
  75. package/corpus/templates/design/changelog/2026-06-30-component-instances-now-have-editable-props-in-the-inspe.md +6 -0
  76. package/corpus/templates/design/changelog/2026-06-30-inspect-code-now-shows-the-elements-opening-tag-at-a-gla.md +6 -0
  77. package/corpus/templates/design/changelog/2026-06-30-shader-fill-presets-can-now-be-applied-to-an-element.md +6 -0
  78. package/corpus/templates/design/changelog/2026-06-30-the-motion-timeline-can-now-add-a-track-to-any-element-a.md +6 -0
  79. package/corpus/templates/design/changelog/2026-06-30-visual-editor-selection-layer-paint-and-drawing-controls-are-more-reliable.md +6 -0
  80. package/corpus/templates/design/e2e/helpers.ts +10 -4
  81. package/corpus/templates/design/server/db/schema.ts +123 -0
  82. package/corpus/templates/design/server/plugins/db.ts +90 -0
  83. package/corpus/templates/design/shared/builder-app.ts +297 -0
  84. package/corpus/templates/design/shared/capability-resolver.ts +123 -0
  85. package/corpus/templates/design/shared/capture-sanitize.ts +70 -0
  86. package/corpus/templates/design/shared/code-layer.ts +1016 -71
  87. package/corpus/templates/design/shared/component-model.ts +239 -0
  88. package/corpus/templates/design/shared/design-review.ts +275 -0
  89. package/corpus/templates/design/shared/design-source-capabilities.ts +286 -0
  90. package/corpus/templates/design/shared/design-state.ts +112 -0
  91. package/corpus/templates/design/shared/design-surface-index.ts +258 -0
  92. package/corpus/templates/design/shared/motion-compiler.ts +278 -0
  93. package/corpus/templates/design/shared/motion-timeline.ts +193 -0
  94. package/corpus/templates/design/shared/responsive-classes.ts +452 -0
  95. package/corpus/templates/design/shared/shader-fill.ts +323 -0
  96. package/corpus/templates/design/shared/source-mode.ts +245 -0
  97. package/corpus/templates/plan/app/components/plan/CanvasArea.tsx +6 -2
  98. package/corpus/templates/plan/changelog/2026-06-29-plan-canvases-open-without-an-initial-pan-and-zoom-flicker.md +6 -0
  99. package/dist/cli/design-connect.d.ts.map +1 -1
  100. package/dist/cli/design-connect.js +28 -2
  101. package/dist/cli/design-connect.js.map +1 -1
  102. package/dist/cli/skills.d.ts.map +1 -1
  103. package/dist/cli/skills.js +37 -21
  104. package/dist/cli/skills.js.map +1 -1
  105. package/dist/collab/routes.d.ts +1 -1
  106. package/dist/file-upload/actions/upload-image.d.ts +2 -2
  107. package/dist/notifications/routes.d.ts +2 -2
  108. package/dist/observability/routes.d.ts +7 -7
  109. package/dist/resources/handlers.d.ts +2 -2
  110. package/dist/server/transcribe-voice.d.ts +1 -1
  111. package/package.json +1 -1
@@ -1,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,
@@ -14,13 +18,15 @@ import {
14
18
  IconAlignRight,
15
19
  IconArrowAutofitHeight,
16
20
  IconArrowAutofitWidth,
21
+ IconArrowRight,
17
22
  IconBackground,
18
23
  IconBlur,
19
24
  IconBorderStyle,
20
25
  IconBrush,
21
- IconChevronDown,
22
26
  IconCode,
23
27
  IconComponents,
28
+ IconDeviceDesktop,
29
+ IconExternalLink,
24
30
  IconDroplet,
25
31
  IconEye,
26
32
  IconEyeOff,
@@ -29,6 +35,8 @@ import {
29
35
  IconFrame,
30
36
  IconLayoutDistributeHorizontal,
31
37
  IconLayoutGrid,
38
+ IconLoader2,
39
+ IconShieldCheck,
32
40
  IconSlice,
33
41
  IconLayoutAlignBottom,
34
42
  IconLayoutAlignCenter,
@@ -52,6 +60,7 @@ import {
52
60
  IconUnlink,
53
61
  IconVector,
54
62
  } from "@tabler/icons-react";
63
+ import { useQueryClient } from "@tanstack/react-query";
55
64
  import {
56
65
  useCallback,
57
66
  useEffect,
@@ -59,8 +68,17 @@ import {
59
68
  useState,
60
69
  type ReactNode,
61
70
  } from "react";
71
+ import { useParams } from "react-router";
62
72
 
63
73
  import { Button } from "@/components/ui/button";
74
+ import {
75
+ Dialog,
76
+ DialogContent,
77
+ DialogDescription,
78
+ DialogFooter,
79
+ DialogHeader,
80
+ DialogTitle,
81
+ } from "@/components/ui/dialog";
64
82
  import {
65
83
  DropdownMenu,
66
84
  DropdownMenuContent,
@@ -82,6 +100,7 @@ import {
82
100
  SelectValue,
83
101
  } from "@/components/ui/select";
84
102
  import { Slider } from "@/components/ui/slider";
103
+ import { Switch } from "@/components/ui/switch";
85
104
  import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
86
105
  import {
87
106
  Tooltip,
@@ -117,11 +136,24 @@ import {
117
136
  } from "./inspector";
118
137
  import { IconLayoutSettings } from "./inspector/design-icons";
119
138
  import type { DesignPaintType } from "./inspector/DesignColorPicker";
139
+ import { InspectorAiActions } from "./inspector/InspectorAiActions";
140
+ import { ReviewPanel } from "./ReviewPanel";
141
+ import type { ReviewPanelProps } from "./ReviewPanel";
142
+ import { StatesPanel } from "./StatesPanel";
143
+ import type { StatesPanelProps } from "./StatesPanel";
144
+ import { TokensPanel } from "./TokensPanel";
120
145
  import { TweaksPanelContent } from "./TweaksPanel";
121
146
  import type { ElementInfo } from "./types";
122
147
 
123
148
  export type InspectorTab = "design" | "tweaks" | "extensions";
124
149
 
150
+ /**
151
+ * Sub-tab within the Tweaks area: "tokens" = TokensPanel face (CSS-var
152
+ * editing through the Tweaks loop); "tweaks" = raw TweakDefinition controls.
153
+ * Defaults to "tokens" when a designId is provided, otherwise "tweaks".
154
+ */
155
+ type TweaksSubTab = "tokens" | "tweaks";
156
+
125
157
  interface EditPanelProps {
126
158
  selectedElement: ElementInfo | null;
127
159
  pageStyles?: Record<string, string>;
@@ -137,9 +169,84 @@ interface EditPanelProps {
137
169
  onRequestTweaks?: (anchor: HTMLElement) => void;
138
170
  onStyleChange: (property: string, value: string) => void;
139
171
  onStylesChange?: (styles: Record<string, string>) => void;
172
+ onAutoLayoutConvert?: (
173
+ targetNodeId: string,
174
+ opts?: { direction?: "row" | "column"; gap?: string },
175
+ ) => void;
140
176
  onExport?: (settings: ExportSettingsValue[]) => void;
141
177
  exporting?: boolean;
142
178
  readOnly?: boolean;
179
+ /** Active file id — forwarded to InspectorAiActions for agent context. */
180
+ fileId?: string;
181
+ /** Active file name (e.g. "index.html") — forwarded to InspectorAiActions. */
182
+ filename?: string;
183
+ // -------------------------------------------------------------------------
184
+ // Design Studio panels (§6.2, §6.4, §6.5)
185
+ // Pass `designId` to unlock Tokens, States, and Review sections.
186
+ // -------------------------------------------------------------------------
187
+ /** The active design's id — required to mount Tokens / States / Review. */
188
+ designId?: string;
189
+ /**
190
+ * Called after a token edit is applied so the parent can push the resolved
191
+ * CSS-var map into the iframe via the tweak-values postMessage.
192
+ */
193
+ onTokensApplied?: (resolvedCssVars: Record<string, string>) => void;
194
+ /** Props forwarded to the StatesPanel (§6.4). Requires `designId`. */
195
+ statesPanelProps?: Omit<StatesPanelProps, "designId">;
196
+ /** Props forwarded to the ReviewPanel (§6.5). */
197
+ reviewPanelProps?: Omit<ReviewPanelProps, "className">;
198
+ // -------------------------------------------------------------------------
199
+ // Component section (§6.1)
200
+ // When a component instance is selected, pass its node id here to unlock
201
+ // the contextual Component section at the top of the Design tab.
202
+ // -------------------------------------------------------------------------
203
+ /**
204
+ * The `data-agent-native-node-id` of the currently-selected component root
205
+ * element. When provided (along with `designId`), a Component section is
206
+ * shown at the top of the Design tab with name, source path, prop controls,
207
+ * and an Edit component action.
208
+ */
209
+ componentNodeId?: string;
210
+ /**
211
+ * Source capabilities for the current design. Used to gate the Edit
212
+ * component / jump-to-source affordances. When absent all writes default
213
+ * to disabled (inline / Alpine tier behaviour).
214
+ */
215
+ sourceCapabilities?: string[];
216
+ // -------------------------------------------------------------------------
217
+ // Selection header quick actions ("Create component" + "Inspect code")
218
+ // -------------------------------------------------------------------------
219
+ /**
220
+ * Promote the current selection into a reusable component. Receives the
221
+ * (already-normalized-by-the-action) component name the user typed. When
222
+ * omitted the "Create component" button is disabled.
223
+ */
224
+ onCreateComponent?: (name: string) => void;
225
+ /** Suggested default name for the create-component dialog. */
226
+ defaultComponentName?: string;
227
+ /** Code-inspection data for the "Inspect code" popover. */
228
+ inspectCode?: InspectCodeData;
229
+ }
230
+
231
+ /**
232
+ * Data backing the "Inspect code" popover. The parent resolves the selected
233
+ * node's HTML and (for real-app sources) its source file location.
234
+ */
235
+ export interface InspectCodeData {
236
+ /** Outer HTML of the selected element (inline / Alpine source). */
237
+ html?: string | null;
238
+ /**
239
+ * Resolved source file for real-app sources (localhost / fusion), when the
240
+ * resolveNodeToFile capability is available.
241
+ */
242
+ sourceLocation?: {
243
+ /** Absolute path on disk — used to build the vscode:// deep link. */
244
+ absolutePath: string;
245
+ line?: number;
246
+ column?: number;
247
+ /** Optional snippet to show above the Open-in-VS-Code button. */
248
+ snippet?: string;
249
+ } | null;
143
250
  }
144
251
 
145
252
  /**
@@ -574,7 +681,7 @@ function ColorInput({
574
681
  onImageFillChange={onImageFillChange}
575
682
  blendMode={blendMode}
576
683
  onBlendModeChange={onBlendModeChange}
577
- showBlendMode={supportsLayeredFills}
684
+ showBlendMode={Boolean(onBlendModeChange)}
578
685
  fillRows={fillRows}
579
686
  selectedFillId={selectedFillId}
580
687
  onFillSelect={supportsLayeredFills ? setSelectedFillId : undefined}
@@ -1851,6 +1958,325 @@ function commitElementSizing(
1851
1958
  commitStylePatch(patch, onStyleChange, onStylesChange);
1852
1959
  }
1853
1960
 
1961
+ /**
1962
+ * Small modal that prompts for a component name, then promotes the current
1963
+ * selection into a reusable component via `onSubmit`.
1964
+ */
1965
+ function CreateComponentDialog({
1966
+ open,
1967
+ onOpenChange,
1968
+ defaultName,
1969
+ onSubmit,
1970
+ }: {
1971
+ open: boolean;
1972
+ onOpenChange: (open: boolean) => void;
1973
+ defaultName: string;
1974
+ onSubmit: (name: string) => void;
1975
+ }) {
1976
+ const [name, setName] = useState(defaultName);
1977
+
1978
+ // Reset the field to the freshest default each time the dialog opens.
1979
+ useEffect(() => {
1980
+ if (open) setName(defaultName);
1981
+ }, [open, defaultName]);
1982
+
1983
+ const commit = () => {
1984
+ const trimmed = name.trim();
1985
+ if (!trimmed) return;
1986
+ onSubmit(trimmed);
1987
+ onOpenChange(false);
1988
+ };
1989
+
1990
+ return (
1991
+ <Dialog open={open} onOpenChange={onOpenChange}>
1992
+ <DialogContent className="sm:max-w-sm">
1993
+ <DialogHeader>
1994
+ <DialogTitle>
1995
+ {"Create component" /* i18n-ignore design inspector action */}
1996
+ </DialogTitle>
1997
+ <DialogDescription>
1998
+ {
1999
+ "Name this element so it becomes a reusable component. The agent can then extract props and replace repeated instances." /* i18n-ignore design inspector copy */
2000
+ }
2001
+ </DialogDescription>
2002
+ </DialogHeader>
2003
+ <div className="space-y-1.5">
2004
+ <Label
2005
+ htmlFor="create-component-name"
2006
+ className="text-[11px] font-medium text-muted-foreground"
2007
+ >
2008
+ {"Component name" /* i18n-ignore design inspector label */}
2009
+ </Label>
2010
+ <Input
2011
+ id="create-component-name"
2012
+ autoFocus
2013
+ value={name}
2014
+ onChange={(e) => setName(e.target.value)}
2015
+ onKeyDown={(e) => {
2016
+ if (e.key === "Enter") {
2017
+ e.preventDefault();
2018
+ commit();
2019
+ }
2020
+ }}
2021
+ placeholder={
2022
+ "PrimaryButton" /* i18n-ignore design inspector placeholder */
2023
+ }
2024
+ />
2025
+ </div>
2026
+ <DialogFooter>
2027
+ <Button
2028
+ type="button"
2029
+ variant="ghost"
2030
+ onClick={() => onOpenChange(false)}
2031
+ >
2032
+ {"Cancel" /* i18n-ignore design inspector action */}
2033
+ </Button>
2034
+ <Button type="button" onClick={commit} disabled={!name.trim()}>
2035
+ {"Create" /* i18n-ignore design inspector action */}
2036
+ </Button>
2037
+ </DialogFooter>
2038
+ </DialogContent>
2039
+ </Dialog>
2040
+ );
2041
+ }
2042
+
2043
+ /** Build a `vscode://file/...` deep link for an absolute path + position. */
2044
+ function vscodeDeepLink(
2045
+ absolutePath: string,
2046
+ line?: number,
2047
+ column?: number,
2048
+ ): string {
2049
+ const base = `vscode://file/${absolutePath}`;
2050
+ if (line == null) return base;
2051
+ return column == null ? `${base}:${line}` : `${base}:${line}:${column}`;
2052
+ }
2053
+
2054
+ /**
2055
+ * Extract the *opening tag* from an element's outer HTML for an at-a-glance
2056
+ * summary (e.g. `<main class="hero" data-x="y">`). Self-closing tags keep
2057
+ * their `/>`. Returns `null` when no tag can be parsed.
2058
+ *
2059
+ * Pure — exported for tests.
2060
+ */
2061
+ export function openingTagOf(html: string | null | undefined): string | null {
2062
+ if (!html) return null;
2063
+ const trimmed = html.trimStart();
2064
+ // Match the first `<tag ...>` (greedy up to the first unquoted `>`), allowing
2065
+ // quoted attribute values to contain `>`.
2066
+ const match = /^<([a-zA-Z][\w-]*)((?:"[^"]*"|'[^']*'|[^>])*?)\/?>/.exec(
2067
+ trimmed,
2068
+ );
2069
+ if (!match) return null;
2070
+ return match[0];
2071
+ }
2072
+
2073
+ /**
2074
+ * Collapse long attribute values in an opening tag so the at-a-glance summary
2075
+ * stays readable. Values longer than `max` chars are truncated with an
2076
+ * ellipsis (the surrounding quotes are preserved).
2077
+ *
2078
+ * Pure — exported for tests.
2079
+ */
2080
+ export function truncateOpeningTag(openTag: string, max = 32): string {
2081
+ return openTag.replace(
2082
+ /("|')((?:\\.|(?!\1)[^\\])*)\1/g,
2083
+ (full, quote, value) => {
2084
+ if (typeof value !== "string" || value.length <= max) return full;
2085
+ return `${quote}${value.slice(0, max - 1)}…${quote}`;
2086
+ },
2087
+ );
2088
+ }
2089
+
2090
+ /**
2091
+ * Parse the top-level `key: value` pairs from an Alpine `x-data` object literal
2092
+ * (e.g. `{ variant: 'outline', size: 'lg', disabled: false }`).
2093
+ *
2094
+ * Best-effort: only handles a flat object of simple string / boolean / number
2095
+ * literals — exactly the shape used for component variant + state props. Nested
2096
+ * objects, methods, and computed expressions are ignored. Returns `null` when
2097
+ * the value is not a recognizable flat object literal.
2098
+ *
2099
+ * Pure — exported for tests.
2100
+ */
2101
+ export function parseAlpineDataObject(
2102
+ xData: string | null | undefined,
2103
+ ): Record<string, string> | null {
2104
+ if (!xData) return null;
2105
+ const trimmed = xData.trim();
2106
+ if (!trimmed.startsWith("{") || !trimmed.endsWith("}")) return null;
2107
+ const inner = trimmed.slice(1, -1).trim();
2108
+ if (!inner) return {};
2109
+
2110
+ const out: Record<string, string> = {};
2111
+ // Split on top-level commas only (no nesting / quotes inside values here).
2112
+ const pairRe =
2113
+ /(?:^|,)\s*(?:'([^']+)'|"([^"]+)"|([A-Za-z_$][\w$]*))\s*:\s*('[^']*'|"[^"]*"|true|false|-?\d+(?:\.\d+)?)/g;
2114
+ let m: RegExpExecArray | null;
2115
+ let matched = false;
2116
+ while ((m = pairRe.exec(inner)) !== null) {
2117
+ matched = true;
2118
+ const key = m[1] ?? m[2] ?? m[3];
2119
+ let raw = m[4]!;
2120
+ // Unwrap quotes for string literals; keep booleans / numbers verbatim.
2121
+ if (
2122
+ (raw.startsWith("'") && raw.endsWith("'")) ||
2123
+ (raw.startsWith('"') && raw.endsWith('"'))
2124
+ ) {
2125
+ raw = raw.slice(1, -1);
2126
+ }
2127
+ if (key) out[key] = raw;
2128
+ }
2129
+ // If there was content but nothing parsed, the shape is too complex to edit
2130
+ // safely — bail so the caller falls back to attribute-based prop edits.
2131
+ if (!matched) return null;
2132
+ return out;
2133
+ }
2134
+
2135
+ /**
2136
+ * Re-serialize a flat Alpine data object back into an `x-data` literal,
2137
+ * preserving boolean / number literals unquoted and single-quoting strings.
2138
+ *
2139
+ * Pure — exported for tests.
2140
+ */
2141
+ export function serializeAlpineDataObject(obj: Record<string, string>): string {
2142
+ const parts = Object.entries(obj).map(([key, value]) => {
2143
+ const isBoolean = value === "true" || value === "false";
2144
+ const isNumber = /^-?\d+(\.\d+)?$/.test(value);
2145
+ const literal =
2146
+ isBoolean || isNumber ? value : `'${value.replace(/'/g, "\\'")}'`;
2147
+ return `${key}: ${literal}`;
2148
+ });
2149
+ return parts.length ? `{ ${parts.join(", ")} }` : "{}";
2150
+ }
2151
+
2152
+ /** A boolean-ish prop value (`"true"` / `"false"`), case-insensitive. */
2153
+ export function isBooleanPropValue(value: string): boolean {
2154
+ const v = value.trim().toLowerCase();
2155
+ return v === "true" || v === "false";
2156
+ }
2157
+
2158
+ /**
2159
+ * Popover anchored to the "Inspect code" button showing the selected node's
2160
+ * code. Inline/Alpine sources show the element's outer HTML with a Copy
2161
+ * button; real-app sources additionally render an "Open in VS Code" button.
2162
+ *
2163
+ * TODO: replace the read-only <pre> with an inline Monaco editor for richer
2164
+ * (editable) code inspection once the editor bundle is wired into the inspector.
2165
+ */
2166
+ function InspectCodePopover({
2167
+ trigger,
2168
+ data,
2169
+ }: {
2170
+ trigger: ReactNode;
2171
+ data: InspectCodeData;
2172
+ }) {
2173
+ const [copied, setCopied] = useState(false);
2174
+ const html = data.html ?? "";
2175
+ const source = data.sourceLocation ?? null;
2176
+ const snippet = source?.snippet || html;
2177
+ // At-a-glance opening tag (tag name + attributes), truncated for readability.
2178
+ const openingTag = (() => {
2179
+ const raw = openingTagOf(html);
2180
+ return raw ? truncateOpeningTag(raw) : null;
2181
+ })();
2182
+
2183
+ const handleCopy = () => {
2184
+ if (!snippet) return;
2185
+ void navigator.clipboard
2186
+ ?.writeText(snippet)
2187
+ .then(() => {
2188
+ setCopied(true);
2189
+ window.setTimeout(() => setCopied(false), 1200);
2190
+ })
2191
+ .catch(() => {
2192
+ /* clipboard may be unavailable; ignore */
2193
+ });
2194
+ };
2195
+
2196
+ return (
2197
+ <Popover>
2198
+ <PopoverTrigger asChild>{trigger}</PopoverTrigger>
2199
+ <PopoverContent align="end" className="w-80 space-y-2 p-2 text-[11px]">
2200
+ <div className="flex items-center justify-between gap-2">
2201
+ <span className="text-[10px] font-semibold uppercase tracking-wide text-muted-foreground/70">
2202
+ {"Inspect code" /* i18n-ignore design inspector label */}
2203
+ </span>
2204
+ <Button
2205
+ type="button"
2206
+ variant="ghost"
2207
+ size="sm"
2208
+ className="h-6 px-2 text-[10px]"
2209
+ onClick={handleCopy}
2210
+ disabled={!snippet}
2211
+ >
2212
+ {
2213
+ copied
2214
+ ? "Copied" /* i18n-ignore design inspector action */
2215
+ : "Copy" /* i18n-ignore design inspector action */
2216
+ }
2217
+ </Button>
2218
+ </div>
2219
+
2220
+ {/* At-a-glance opening tag (tag name + attributes) above the full HTML. */}
2221
+ {openingTag && (
2222
+ <code
2223
+ className="block truncate rounded bg-[var(--design-editor-control-bg)] px-2 py-1 font-mono text-[10px] text-foreground"
2224
+ title={openingTag}
2225
+ >
2226
+ {openingTag}
2227
+ </code>
2228
+ )}
2229
+
2230
+ {source && (
2231
+ <div
2232
+ className="flex items-center gap-1 rounded bg-[var(--design-editor-control-bg)] px-2 py-1"
2233
+ title={source.absolutePath}
2234
+ >
2235
+ <IconCode className="size-3 shrink-0 text-muted-foreground/60" />
2236
+ <span className="min-w-0 flex-1 truncate font-mono text-[10px] text-muted-foreground">
2237
+ {source.absolutePath}
2238
+ {source.line != null ? `:${source.line}` : ""}
2239
+ </span>
2240
+ </div>
2241
+ )}
2242
+
2243
+ {snippet ? (
2244
+ <pre className="max-h-64 overflow-auto rounded bg-[var(--design-editor-control-bg)] p-2 font-mono text-[10px] leading-relaxed text-foreground">
2245
+ <code>{snippet}</code>
2246
+ </pre>
2247
+ ) : (
2248
+ <p className="px-1 py-2 text-muted-foreground">
2249
+ {
2250
+ "No source available for this element." /* i18n-ignore design inspector empty */
2251
+ }
2252
+ </p>
2253
+ )}
2254
+
2255
+ {source && (
2256
+ <a
2257
+ href={vscodeDeepLink(
2258
+ source.absolutePath,
2259
+ source.line,
2260
+ source.column,
2261
+ )}
2262
+ className="block"
2263
+ >
2264
+ <Button
2265
+ type="button"
2266
+ variant="outline"
2267
+ size="sm"
2268
+ className="h-7 w-full gap-1.5 text-[11px]"
2269
+ >
2270
+ <IconExternalLink className="size-3.5" />
2271
+ {"Open in VS Code" /* i18n-ignore design inspector action */}
2272
+ </Button>
2273
+ </a>
2274
+ )}
2275
+ </PopoverContent>
2276
+ </Popover>
2277
+ );
2278
+ }
2279
+
1854
2280
  function elementTypeIcon(element: ElementInfo) {
1855
2281
  const tag = element.tagName || "element";
1856
2282
  if (TEXT_TAGS.has(tag)) return IconTypography;
@@ -1860,35 +2286,55 @@ function elementTypeIcon(element: ElementInfo) {
1860
2286
  return IconFrame;
1861
2287
  }
1862
2288
 
1863
- function SelectionHeader({ element }: { element: ElementInfo | null }) {
2289
+ function SelectionHeader({
2290
+ element,
2291
+ onCreateComponent,
2292
+ inspectCodePopover,
2293
+ }: {
2294
+ element: ElementInfo | null;
2295
+ /** Promote the current selection into a reusable component. Omit/undefined to disable. */
2296
+ onCreateComponent?: () => void;
2297
+ /**
2298
+ * Render prop for the "Inspect code" affordance. Receives the trigger button
2299
+ * so the parent can anchor a Popover to it; returns the full popover element.
2300
+ * When omitted the button renders disabled.
2301
+ */
2302
+ inspectCodePopover?: (trigger: ReactNode) => ReactNode;
2303
+ }) {
1864
2304
  if (!element) return null;
1865
2305
 
1866
2306
  const title = inspectorObjectTitle(element);
1867
2307
  const TypeIcon = elementTypeIcon(element);
1868
2308
 
2309
+ const inspectTrigger = (
2310
+ <SectionIconButton
2311
+ label={"Inspect code" /* i18n-ignore design inspector action */}
2312
+ disabled={!inspectCodePopover}
2313
+ >
2314
+ <IconCode className="size-3.5" />
2315
+ </SectionIconButton>
2316
+ );
2317
+
1869
2318
  return (
1870
2319
  <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
- >
2320
+ {/* Node-type label. Rename lives in the layers panel and device sizing
2321
+ lives elsewhere, so this is a plain non-interactive label. */}
2322
+ <div className="flex min-w-0 items-center gap-1.5 text-left text-[13px] font-semibold text-foreground">
1876
2323
  <TypeIcon className="size-3.5 shrink-0 text-muted-foreground" />
1877
2324
  <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 (</>) */}
2325
+ </div>
2326
+ {/* Right-aligned quick actions: create-component + dev inspect (</>) */}
1881
2327
  <div className="flex shrink-0 items-center gap-0.5">
1882
2328
  <SectionIconButton
1883
2329
  label={"Create component" /* i18n-ignore design inspector action */}
2330
+ disabled={!onCreateComponent}
2331
+ onClick={onCreateComponent}
1884
2332
  >
1885
2333
  <IconComponents className="size-3.5" />
1886
2334
  </SectionIconButton>
1887
- <SectionIconButton
1888
- label={"Inspect code" /* i18n-ignore design inspector action */}
1889
- >
1890
- <IconCode className="size-3.5" />
1891
- </SectionIconButton>
2335
+ {inspectCodePopover
2336
+ ? inspectCodePopover(inspectTrigger)
2337
+ : inspectTrigger}
1892
2338
  </div>
1893
2339
  </div>
1894
2340
  );
@@ -2959,10 +3405,15 @@ function FlexContainerControls({
2959
3405
  element,
2960
3406
  onStyleChange,
2961
3407
  onStylesChange,
3408
+ onAutoLayoutConvert,
2962
3409
  }: {
2963
3410
  element: ElementInfo;
2964
3411
  onStyleChange: (property: string, value: string) => void;
2965
3412
  onStylesChange?: (styles: Record<string, string>) => void;
3413
+ onAutoLayoutConvert?: (
3414
+ targetNodeId: string,
3415
+ opts?: { direction?: "row" | "column"; gap?: string },
3416
+ ) => void;
2966
3417
  }) {
2967
3418
  const t = useT();
2968
3419
  const styles = element.computedStyles;
@@ -2975,12 +3426,55 @@ function FlexContainerControls({
2975
3426
  const isFlex = display.includes("flex");
2976
3427
  const flexDirection: AutoLayoutMatrixValue["direction"] =
2977
3428
  styles.flexDirection?.includes("column") ? "vertical" : "horizontal";
3429
+ const mainGapAxis =
3430
+ flexDirection === "horizontal" ? "horizontal" : "vertical";
2978
3431
  // When the element is in normal flow (not flex yet), picking any flow option
2979
3432
  // must first turn it into a flex container; otherwise setting flex-direction
2980
3433
  // alone is a no-op against a block element.
2981
3434
  const ensureFlex = () => {
2982
3435
  if (!isFlex) onStyleChange("display", "flex");
2983
3436
  };
3437
+
3438
+ /**
3439
+ * Handle the Flow control switching between flex and normal-flow (block).
3440
+ *
3441
+ * For 'flex': ensures display:flex is set (ensureFlex path).
3442
+ * For 'block': sets display:block and leaves children unchanged — mirrors
3443
+ * the { kind:"autoLayout", enabled:false } substrate intent exactly.
3444
+ */
3445
+ const handleDisplayChange = (nextDisplay: "flex" | "block") => {
3446
+ if (nextDisplay === "flex") {
3447
+ ensureFlex();
3448
+ return;
3449
+ }
3450
+ // Turn auto-layout off: set display:block, leaving children unchanged.
3451
+ // This is the direct equivalent of the autoLayout substrate with enabled:false.
3452
+ onStyleChange("display", "block");
3453
+ };
3454
+
3455
+ /**
3456
+ * "Convert to auto layout" — enables flex on a non-flex container and strips
3457
+ * position:absolute from direct children (full reflow via the autoLayout
3458
+ * substrate intent). Falls back to a style-only patch when no substrate
3459
+ * handler is wired (e.g. read-only or non-editable contexts).
3460
+ */
3461
+ const handleConvertToAutoLayout = () => {
3462
+ if (onAutoLayoutConvert && element.sourceId) {
3463
+ onAutoLayoutConvert(element.sourceId, { direction: "row", gap: "8px" });
3464
+ return;
3465
+ }
3466
+ // Fallback: set display:flex on the parent only (no child strip).
3467
+ commitStylePatch(
3468
+ {
3469
+ display: "flex",
3470
+ flexDirection: "row",
3471
+ gap: "8px",
3472
+ alignItems: "flex-start",
3473
+ },
3474
+ onStyleChange,
3475
+ onStylesChange,
3476
+ );
3477
+ };
2984
3478
  const padding = {
2985
3479
  top: parseNumericValue(styles.paddingTop || "0"),
2986
3480
  right: parseNumericValue(styles.paddingRight || "0"),
@@ -3022,12 +3516,27 @@ function FlexContainerControls({
3022
3516
  // optional contract field consumed by AutoLayoutMatrix; harmless when the
3023
3517
  // matrix ignores it.
3024
3518
  ...({ display } as Partial<AutoLayoutMatrixValue>),
3519
+ spaceBetween: styles.justifyContent === "space-between",
3025
3520
  };
3026
3521
 
3027
3522
  return (
3028
3523
  <div className="space-y-2">
3524
+ {/* Convert to auto layout — shown when the container is not yet flex */}
3525
+ {!isFlex ? (
3526
+ <Button
3527
+ type="button"
3528
+ variant="outline"
3529
+ size="sm"
3530
+ className="h-7 w-full gap-1.5 text-[11px]"
3531
+ onClick={handleConvertToAutoLayout}
3532
+ >
3533
+ <IconLayoutSettings className="size-3.5 shrink-0" />
3534
+ {"Convert to auto layout" /* i18n-ignore design inspector action */}
3535
+ </Button>
3536
+ ) : null}
3029
3537
  <AutoLayoutMatrix
3030
3538
  value={autoLayoutValue}
3539
+ onDisplayChange={handleDisplayChange}
3031
3540
  onDirectionChange={(direction) => {
3032
3541
  ensureFlex();
3033
3542
  onStyleChange(
@@ -3079,16 +3588,20 @@ function FlexContainerControls({
3079
3588
  onStyleChange("overflow", clipContent ? "hidden" : "visible")
3080
3589
  }
3081
3590
  onDistribute={(axis) => {
3082
- const mainAxis =
3083
- autoLayoutValue.direction === "horizontal"
3084
- ? "horizontal"
3085
- : "vertical";
3086
- if (axis === mainAxis) {
3591
+ if (axis === mainGapAxis) {
3087
3592
  onStyleChange("justifyContent", "space-between");
3088
3593
  } else if (autoLayoutValue.wrap === "wrap") {
3089
3594
  onStyleChange("alignContent", "space-between");
3090
3595
  }
3091
3596
  }}
3597
+ onGapModeChange={(gapMode, axis) => {
3598
+ if (axis !== mainGapAxis) return;
3599
+ ensureFlex();
3600
+ onStyleChange(
3601
+ "justifyContent",
3602
+ gapMode === "auto" ? "space-between" : "flex-start",
3603
+ );
3604
+ }}
3092
3605
  availableChildSizing={availableSizingForElement(element)}
3093
3606
  onChildSizingChange={(axis, sizing) => {
3094
3607
  commitElementSizing(
@@ -3207,10 +3720,15 @@ function LayoutContextProperties({
3207
3720
  element,
3208
3721
  onStyleChange,
3209
3722
  onStylesChange,
3723
+ onAutoLayoutConvert,
3210
3724
  }: {
3211
3725
  element: ElementInfo;
3212
3726
  onStyleChange: (property: string, value: string) => void;
3213
3727
  onStylesChange?: (styles: Record<string, string>) => void;
3728
+ onAutoLayoutConvert?: (
3729
+ targetNodeId: string,
3730
+ opts?: { direction?: "row" | "column"; gap?: string },
3731
+ ) => void;
3214
3732
  }) {
3215
3733
  const t = useT();
3216
3734
  const flexChild = isParentFlex(element);
@@ -3303,6 +3821,7 @@ function LayoutContextProperties({
3303
3821
  element={element}
3304
3822
  onStyleChange={onStyleChange}
3305
3823
  onStylesChange={onStylesChange}
3824
+ onAutoLayoutConvert={onAutoLayoutConvert}
3306
3825
  />
3307
3826
  {childControls}
3308
3827
  </PanelSection>
@@ -4650,6 +5169,580 @@ const TEXT_TAGS = new Set([
4650
5169
  "li",
4651
5170
  ]);
4652
5171
 
5172
+ // ─── Make it real — inline upgrade card (§3, §6.6) ──────────────────────────
5173
+
5174
+ /**
5175
+ * Payload shape returned by `connect-builder-app`. Only the fields used by
5176
+ * the card UI are typed here; the action may return additional fields.
5177
+ */
5178
+ interface ConnectBuilderAppResult {
5179
+ connected: boolean;
5180
+ builderEnabled: boolean;
5181
+ connectUrl: string;
5182
+ appHost: string;
5183
+ branchProjectId?: string;
5184
+ cta: {
5185
+ kind: "connect-builder" | "configure-project";
5186
+ label: string;
5187
+ description: string;
5188
+ primaryAction: string;
5189
+ connectUrl: string;
5190
+ } | null;
5191
+ message: string;
5192
+ }
5193
+
5194
+ /**
5195
+ * Inline "Make it real" upgrade card.
5196
+ *
5197
+ * Rendered wherever a real-app-only control is reached on an inline design
5198
+ * (Component source jump, token write-back, live captures, etc.). Queries
5199
+ * `connect-builder-app` to determine the current connection state, then
5200
+ * offers the appropriate CTA:
5201
+ *
5202
+ * - Not connected → "Connect Builder.io" button (opens connectUrl)
5203
+ * - Connected, no project → "Open Builder settings" (configure project ID)
5204
+ * - Fully enabled → "Make it real" button (calls migrate-inline-design-to-app)
5205
+ *
5206
+ * The card is progressively disclosed: it only mounts when a gated control is
5207
+ * actually reached, so it never appears for users who are already on a real-app
5208
+ * source (`localhost` / `fusion`) or whose `sourceCapabilities` already include
5209
+ * the needed capability.
5210
+ *
5211
+ * Matches the design-editor panel chrome: dashed-border, accent tint, small
5212
+ * text at 10px — same idiom as the existing `ctaRequired` block in
5213
+ * ComponentSection.
5214
+ */
5215
+ function MakeItRealCard({
5216
+ designId,
5217
+ featureLabel,
5218
+ }: {
5219
+ /** The active design id — required to call connect-builder-app. */
5220
+ designId: string;
5221
+ /**
5222
+ * Short human-readable label for the gated feature (e.g. "token write-back",
5223
+ * "component source jump", "live captures"). Shown in the card body so the
5224
+ * user understands exactly what they're unlocking.
5225
+ */
5226
+ featureLabel: string;
5227
+ }) {
5228
+ const { data, isLoading } = useActionQuery<ConnectBuilderAppResult>(
5229
+ "connect-builder-app",
5230
+ { designId },
5231
+ );
5232
+
5233
+ const migrateMutation = useActionMutation("migrate-inline-design-to-app");
5234
+
5235
+ // While fetching status, show a muted placeholder that matches the card
5236
+ // height so the inspector doesn't jump when the data arrives.
5237
+ if (isLoading || !data) {
5238
+ return (
5239
+ <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">
5240
+ <div className="h-3 w-32 animate-pulse rounded bg-muted/40" />
5241
+ </div>
5242
+ );
5243
+ }
5244
+
5245
+ // Determine which CTA to show.
5246
+ const cta = data.cta;
5247
+
5248
+ // Already fully enabled — no CTA needed (caller should already have gated
5249
+ // this component away, but guard here for safety).
5250
+ if (!cta) return null;
5251
+
5252
+ const isPending = migrateMutation.isPending;
5253
+ const migrateError = migrateMutation.error;
5254
+
5255
+ // "Make it real" primary action: open the connect URL or migrate.
5256
+ const handlePrimary = () => {
5257
+ if (cta.kind === "connect-builder") {
5258
+ // Open the Builder OAuth connect flow in a new tab. The user completes
5259
+ // it there and comes back; the card will re-query on next render.
5260
+ window.open(cta.connectUrl, "_blank", "noopener,noreferrer");
5261
+ return;
5262
+ }
5263
+ if (cta.kind === "configure-project") {
5264
+ window.open(cta.connectUrl, "_blank", "noopener,noreferrer");
5265
+ return;
5266
+ }
5267
+ };
5268
+
5269
+ const handleMigrate = () => {
5270
+ migrateMutation.mutate({ designId });
5271
+ };
5272
+
5273
+ // Migration result — show branch link.
5274
+ const migrateResult = migrateMutation.data as
5275
+ | {
5276
+ status: "processing";
5277
+ branchName?: string;
5278
+ url?: string;
5279
+ message?: string;
5280
+ }
5281
+ | undefined;
5282
+
5283
+ if (migrateResult?.status === "processing" && migrateResult.url) {
5284
+ return (
5285
+ <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">
5286
+ <p className="text-[10px] font-semibold text-[var(--design-editor-accent-color)]">
5287
+ {"Builder is working on it" /* i18n-ignore make-it-real card */}
5288
+ </p>
5289
+ <p className="text-[10px] leading-snug text-muted-foreground">
5290
+ {migrateResult.message ??
5291
+ `Branch "${migrateResult.branchName}" is being generated.`}
5292
+ </p>
5293
+ <a
5294
+ href={migrateResult.url}
5295
+ target="_blank"
5296
+ rel="noopener noreferrer"
5297
+ className="inline-flex items-center gap-1 text-[10px] font-medium text-[var(--design-editor-accent-color)] hover:underline"
5298
+ >
5299
+ {"Open in Builder" /* i18n-ignore make-it-real card */}
5300
+ <IconExternalLink className="size-2.5" />
5301
+ </a>
5302
+ </div>
5303
+ );
5304
+ }
5305
+
5306
+ return (
5307
+ <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">
5308
+ {/* Header */}
5309
+ <p className="text-[10px] font-semibold text-foreground">
5310
+ {"Make this a real app" /* i18n-ignore make-it-real card */}
5311
+ </p>
5312
+
5313
+ {/* Body */}
5314
+ <p className="text-[10px] leading-snug text-muted-foreground">
5315
+ {cta.description}
5316
+ {featureLabel ? (
5317
+ <>
5318
+ {" "}
5319
+ <span className="italic">{featureLabel}</span>
5320
+ {" requires a real-app source."}
5321
+ </>
5322
+ ) : null}
5323
+ </p>
5324
+
5325
+ {/* Error */}
5326
+ {migrateError ? (
5327
+ <p className="text-[10px] text-destructive">
5328
+ {migrateError instanceof Error
5329
+ ? migrateError.message
5330
+ : "Migration failed. Please try again."}
5331
+ </p>
5332
+ ) : null}
5333
+
5334
+ {/* Actions */}
5335
+ <div className="flex items-center gap-1.5 flex-wrap">
5336
+ <button
5337
+ type="button"
5338
+ onClick={handlePrimary}
5339
+ 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"
5340
+ >
5341
+ {cta.primaryAction}
5342
+ <IconArrowRight className="size-2.5" />
5343
+ </button>
5344
+
5345
+ {/* When Builder is fully connected, also offer direct migration */}
5346
+ {data.connected && data.builderEnabled && (
5347
+ <button
5348
+ type="button"
5349
+ onClick={handleMigrate}
5350
+ disabled={isPending}
5351
+ 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"
5352
+ >
5353
+ {isPending ? (
5354
+ <>
5355
+ <IconLoader2 className="size-2.5 animate-spin" />
5356
+ {"Migrating…" /* i18n-ignore make-it-real card */}
5357
+ </>
5358
+ ) : (
5359
+ <>{"Generate React app" /* i18n-ignore make-it-real card */}</>
5360
+ )}
5361
+ </button>
5362
+ )}
5363
+ </div>
5364
+ </div>
5365
+ );
5366
+ }
5367
+
5368
+ // ─── Component section (§6.1) ─────────────────────────────────────────────────
5369
+
5370
+ /**
5371
+ * Shape returned by `get-component-details`. Only the fields the UI needs are
5372
+ * typed here; the action may return additional fields.
5373
+ */
5374
+ interface ComponentDetailsResult {
5375
+ name: string;
5376
+ sourceType: string;
5377
+ observedProps: Array<{ name: string; value: string }>;
5378
+ persistedVariants: Record<string, string[]>;
5379
+ sourceLocation?: { filePath: string; exportName?: string } | null;
5380
+ /** Component instance shape, including the Alpine `x-data` expression. */
5381
+ instance?: {
5382
+ alpineData?: string | null;
5383
+ nodeId?: string;
5384
+ selector?: string;
5385
+ } | null;
5386
+ capabilities: {
5387
+ canResolveToFile: boolean;
5388
+ hasFullIndex: boolean;
5389
+ canEditProps: boolean;
5390
+ ctaRequired: boolean;
5391
+ ctaMessage?: string;
5392
+ };
5393
+ }
5394
+
5395
+ /**
5396
+ * Contextual COMPONENT section rendered inside the Design tab when the
5397
+ * selected element is a component instance (carries
5398
+ * `data-agent-native-component`).
5399
+ *
5400
+ * Shows: component name, source path (when capability available), observed
5401
+ * prop values, variant/size/state controls from `get-component-details`, and
5402
+ * an "Edit component source" action. Real-app features are gated by the
5403
+ * capabilities returned by the action; Alpine gets a lightweight read-only
5404
+ * view plus a Connect-Builder CTA.
5405
+ *
5406
+ * Matches the workbench artboard spec in DESIGN-STUDIO-PLAN.md §6.1.
5407
+ */
5408
+ function ComponentSection({
5409
+ designId,
5410
+ nodeId,
5411
+ sourceCapabilities = [],
5412
+ }: {
5413
+ designId: string;
5414
+ nodeId: string;
5415
+ /** Capability names advertised by the current source. */
5416
+ sourceCapabilities?: string[];
5417
+ }) {
5418
+ const queryClient = useQueryClient();
5419
+ const detailsKey = ["action", "get-component-details", { designId, nodeId }];
5420
+
5421
+ const { data, isLoading, error } = useActionQuery<ComponentDetailsResult>(
5422
+ "get-component-details",
5423
+ { designId, nodeId },
5424
+ );
5425
+
5426
+ const openSourceMutation = useActionMutation("open-component-source");
5427
+ const applyPropMutation = useActionMutation("apply-component-prop-edit");
5428
+
5429
+ // Persist a single prop change through apply-component-prop-edit. The canvas
5430
+ // re-renders from the freshly written design content (get-design refetch),
5431
+ // so no postMessage into the iframe is needed. The get-component-details
5432
+ // cache is optimistically patched so the control reflects the new value
5433
+ // immediately, then reconciled with the server on settle.
5434
+ const persistPropEdit = (
5435
+ edit:
5436
+ | { kind: "alpineData"; value: string }
5437
+ | { kind: "attribute"; attribute: string; value: string },
5438
+ optimistic: (prev: ComponentDetailsResult) => ComponentDetailsResult,
5439
+ ) => {
5440
+ queryClient.setQueryData<ComponentDetailsResult>(detailsKey, (prev) =>
5441
+ prev ? optimistic(prev) : prev,
5442
+ );
5443
+ applyPropMutation.mutate(
5444
+ { designId, nodeId, edit },
5445
+ {
5446
+ onSettled: () => {
5447
+ // Re-render the canvas from the new content and re-read the props.
5448
+ void queryClient.invalidateQueries({
5449
+ queryKey: ["action", "get-design"],
5450
+ });
5451
+ void queryClient.invalidateQueries({ queryKey: detailsKey });
5452
+ },
5453
+ },
5454
+ );
5455
+ };
5456
+
5457
+ // While loading, show a compact skeleton that matches the section width.
5458
+ if (isLoading) {
5459
+ return (
5460
+ <section className="shrink-0 border-t border-[var(--design-editor-control-border)] first:border-t-0">
5461
+ <div className="flex min-h-9 items-center gap-2 px-3">
5462
+ <div className="h-3 w-24 animate-pulse rounded bg-muted/50" />
5463
+ </div>
5464
+ <div className="space-y-1.5 px-3 pb-3 pt-0.5">
5465
+ <div className="h-5 w-full animate-pulse rounded bg-muted/40" />
5466
+ <div className="h-5 w-3/4 animate-pulse rounded bg-muted/40" />
5467
+ </div>
5468
+ </section>
5469
+ );
5470
+ }
5471
+
5472
+ // Hard error (node not found, no access, etc.) — collapse silently so
5473
+ // the rest of the inspector is not disrupted.
5474
+ if (error || !data) return null;
5475
+
5476
+ const {
5477
+ name,
5478
+ sourceType,
5479
+ sourceLocation,
5480
+ observedProps,
5481
+ persistedVariants,
5482
+ instance,
5483
+ capabilities,
5484
+ } = data;
5485
+
5486
+ // ── Editable prop model ───────────────────────────────────────────────────
5487
+ // Inline/Alpine designs persist through apply-component-prop-edit. Two write
5488
+ // surfaces:
5489
+ // • x-data keys → kind "alpineData" (rewrites the whole object)
5490
+ // • data-prop-* attrs → kind "attribute" (data-agent-native-prop-<kebab>)
5491
+ // Real-app sources keep the deeper source-prop controls gated as-is, so for
5492
+ // non-inline sources the controls are read-only here.
5493
+ const isInline = sourceType === "inline";
5494
+ const editingEnabled = isInline; // gated; real-app stays read-only for now
5495
+ const alpineData = parseAlpineDataObject(instance?.alpineData);
5496
+
5497
+ // Each editable row: name + current value + how it persists + its options.
5498
+ type PropRow = {
5499
+ name: string;
5500
+ value: string;
5501
+ /** Variant/enum options when the prop is a known group. */
5502
+ options?: string[];
5503
+ /** Persist surface for this prop. */
5504
+ surface: "alpineData" | "attribute";
5505
+ };
5506
+
5507
+ const rows: PropRow[] = [];
5508
+ const seen = new Set<string>();
5509
+
5510
+ // 1) Alpine x-data keys come first — they drive the live variant/state.
5511
+ if (alpineData) {
5512
+ for (const [key, value] of Object.entries(alpineData)) {
5513
+ rows.push({
5514
+ name: key,
5515
+ value,
5516
+ options: persistedVariants[key],
5517
+ surface: "alpineData",
5518
+ });
5519
+ seen.add(key);
5520
+ }
5521
+ }
5522
+
5523
+ // 2) data-agent-native-prop-* attributes not already covered by x-data.
5524
+ for (const prop of observedProps) {
5525
+ if (seen.has(prop.name)) continue;
5526
+ rows.push({
5527
+ name: prop.name,
5528
+ value: prop.value,
5529
+ options: persistedVariants[prop.name],
5530
+ surface: "attribute",
5531
+ });
5532
+ seen.add(prop.name);
5533
+ }
5534
+
5535
+ // 3) persistedVariant groups with no observed value yet (default to first).
5536
+ for (const [group, options] of Object.entries(persistedVariants)) {
5537
+ if (seen.has(group)) continue;
5538
+ rows.push({
5539
+ name: group,
5540
+ value: options[0] ?? "",
5541
+ options,
5542
+ surface: alpineData ? "alpineData" : "attribute",
5543
+ });
5544
+ seen.add(group);
5545
+ }
5546
+
5547
+ const hasRows = rows.length > 0;
5548
+
5549
+ // Build the apply-component-prop-edit payload + optimistic cache patch for a
5550
+ // single prop change.
5551
+ const commitProp = (row: PropRow, nextValue: string) => {
5552
+ if (!editingEnabled || nextValue === row.value) return;
5553
+
5554
+ if (row.surface === "alpineData") {
5555
+ const nextData = { ...(alpineData ?? {}), [row.name]: nextValue };
5556
+ const serialized = serializeAlpineDataObject(nextData);
5557
+ persistPropEdit({ kind: "alpineData", value: serialized }, (prev) => ({
5558
+ ...prev,
5559
+ instance: { ...(prev.instance ?? {}), alpineData: serialized },
5560
+ observedProps: prev.observedProps.map((p) =>
5561
+ p.name === row.name ? { ...p, value: nextValue } : p,
5562
+ ),
5563
+ }));
5564
+ } else {
5565
+ // camelCase prop name → data-agent-native-prop-<kebab>
5566
+ const kebab = row.name.replace(/[A-Z]/g, (c) => `-${c.toLowerCase()}`);
5567
+ persistPropEdit(
5568
+ {
5569
+ kind: "attribute",
5570
+ attribute: `data-agent-native-prop-${kebab}`,
5571
+ value: nextValue,
5572
+ },
5573
+ (prev) => {
5574
+ const exists = prev.observedProps.some((p) => p.name === row.name);
5575
+ return {
5576
+ ...prev,
5577
+ observedProps: exists
5578
+ ? prev.observedProps.map((p) =>
5579
+ p.name === row.name ? { ...p, value: nextValue } : p,
5580
+ )
5581
+ : [...prev.observedProps, { name: row.name, value: nextValue }],
5582
+ };
5583
+ },
5584
+ );
5585
+ }
5586
+ };
5587
+
5588
+ // ── Capability gates ──
5589
+ const canJumpToSource =
5590
+ capabilities.canResolveToFile &&
5591
+ Boolean(sourceLocation?.filePath) &&
5592
+ sourceCapabilities.includes("resolveNodeToFile");
5593
+
5594
+ // ── Source chip text ──
5595
+ const sourceChip = sourceLocation?.exportName
5596
+ ? `${sourceLocation.exportName} — ${sourceLocation.filePath}`
5597
+ : (sourceLocation?.filePath ?? null);
5598
+
5599
+ return (
5600
+ <section
5601
+ className="shrink-0 border-t border-[var(--design-editor-control-border)] first:border-t-0"
5602
+ data-testid="component-section"
5603
+ >
5604
+ {/* ── Section header ── */}
5605
+ <div className="flex min-h-9 items-center gap-2 px-3">
5606
+ {/* Accent diamond matching the workbench artboard component rows */}
5607
+ <span
5608
+ className="size-2 shrink-0 rotate-45 rounded-[2px] bg-[var(--design-editor-accent-color)]"
5609
+ aria-hidden="true"
5610
+ />
5611
+ <h3 className="min-w-0 flex-1 truncate text-[11px] font-semibold text-foreground">
5612
+ {name}
5613
+ </h3>
5614
+ {/* Jump-to-source action */}
5615
+ <Tooltip>
5616
+ <TooltipTrigger asChild>
5617
+ <Button
5618
+ type="button"
5619
+ variant="ghost"
5620
+ size="icon"
5621
+ className="size-6 rounded-md text-muted-foreground hover:text-foreground disabled:cursor-not-allowed disabled:opacity-40"
5622
+ disabled={!canJumpToSource}
5623
+ aria-label={
5624
+ "Edit component source" /* i18n-ignore design inspector action */
5625
+ }
5626
+ onClick={() => {
5627
+ openSourceMutation.mutate({ designId, nodeId });
5628
+ }}
5629
+ >
5630
+ <IconExternalLink className="size-3.5" />
5631
+ </Button>
5632
+ </TooltipTrigger>
5633
+ <TooltipContent>
5634
+ {
5635
+ canJumpToSource
5636
+ ? "Edit component source" /* i18n-ignore design inspector action */
5637
+ : (capabilities.ctaMessage ??
5638
+ "Connect Builder to jump to component source") /* i18n-ignore design inspector tooltip */
5639
+ }
5640
+ </TooltipContent>
5641
+ </Tooltip>
5642
+ </div>
5643
+
5644
+ {/* ── Body ── */}
5645
+ <div className="space-y-1.5 px-3 pb-3 pt-0.5 text-[11px]">
5646
+ {/* Source path chip */}
5647
+ {sourceChip && (
5648
+ <div
5649
+ className="flex items-center gap-1 rounded bg-[var(--design-editor-control-bg)] px-2 py-1"
5650
+ title={sourceChip}
5651
+ >
5652
+ <IconCode className="size-3 shrink-0 text-muted-foreground/60" />
5653
+ <span className="min-w-0 flex-1 truncate font-mono text-[10px] text-muted-foreground">
5654
+ {sourceChip}
5655
+ </span>
5656
+ </div>
5657
+ )}
5658
+
5659
+ {/* Typed prop controls. Inline/Alpine designs are editable and persist
5660
+ through apply-component-prop-edit; real-app sources are read-only
5661
+ until the deeper source-prop controls land. */}
5662
+ {hasRows && (
5663
+ <div className="space-y-1">
5664
+ <p className="text-[10px] font-semibold uppercase tracking-wide text-muted-foreground/70">
5665
+ {"Props" /* i18n-ignore design inspector label */}
5666
+ </p>
5667
+ {rows.map((row) => {
5668
+ const hasOptions = (row.options?.length ?? 0) > 0;
5669
+ const isBoolean = !hasOptions && isBooleanPropValue(row.value);
5670
+ return (
5671
+ <div key={row.name} className="flex items-center gap-1.5">
5672
+ <Label className="w-[64px] shrink-0 truncate text-[11px] font-medium capitalize text-muted-foreground">
5673
+ {row.name}
5674
+ </Label>
5675
+ {hasOptions ? (
5676
+ // Dropdown for variant / enum groups.
5677
+ <Select
5678
+ value={row.value || row.options![0] || ""}
5679
+ onValueChange={(v) => commitProp(row, v)}
5680
+ disabled={!editingEnabled}
5681
+ >
5682
+ <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)]">
5683
+ <SelectValue />
5684
+ </SelectTrigger>
5685
+ <SelectContent>
5686
+ {row.options!.map((opt) => (
5687
+ <SelectItem
5688
+ key={opt}
5689
+ value={opt}
5690
+ className="text-[11px]"
5691
+ >
5692
+ {opt}
5693
+ </SelectItem>
5694
+ ))}
5695
+ </SelectContent>
5696
+ </Select>
5697
+ ) : isBoolean ? (
5698
+ // Toggle for boolean props.
5699
+ <div className="flex min-w-0 flex-1 items-center">
5700
+ <Switch
5701
+ checked={row.value.trim().toLowerCase() === "true"}
5702
+ onCheckedChange={(checked) =>
5703
+ commitProp(row, checked ? "true" : "false")
5704
+ }
5705
+ disabled={!editingEnabled}
5706
+ className="h-4 w-7 [&>span]:size-3 [&>span]:data-[state=checked]:translate-x-3"
5707
+ aria-label={
5708
+ row.name /* i18n-ignore dynamic prop name */
5709
+ }
5710
+ />
5711
+ </div>
5712
+ ) : (
5713
+ // Text input for string props (e.g. a label).
5714
+ <Input
5715
+ defaultValue={row.value}
5716
+ key={`${row.name}:${row.value}`}
5717
+ disabled={!editingEnabled}
5718
+ onBlur={(e) => commitProp(row, e.target.value)}
5719
+ onKeyDown={(e) => {
5720
+ if (e.key === "Enter") {
5721
+ e.preventDefault();
5722
+ e.currentTarget.blur();
5723
+ }
5724
+ }}
5725
+ 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)]"
5726
+ />
5727
+ )}
5728
+ </div>
5729
+ );
5730
+ })}
5731
+ </div>
5732
+ )}
5733
+
5734
+ {/* Connect-Builder CTA (inline, only when real-app features are gated) */}
5735
+ {capabilities.ctaRequired && (
5736
+ <MakeItRealCard
5737
+ designId={designId}
5738
+ featureLabel="component source jump and full prop controls"
5739
+ />
5740
+ )}
5741
+ </div>
5742
+ </section>
5743
+ );
5744
+ }
5745
+
4653
5746
  export function EditPanel({
4654
5747
  selectedElement,
4655
5748
  pageStyles = {},
@@ -4664,15 +5757,35 @@ export function EditPanel({
4664
5757
  onRequestTweaks,
4665
5758
  onStyleChange,
4666
5759
  onStylesChange,
5760
+ onAutoLayoutConvert,
4667
5761
  onExport,
4668
5762
  exporting = false,
4669
5763
  readOnly = false,
5764
+ fileId,
5765
+ filename,
5766
+ designId,
5767
+ onTokensApplied,
5768
+ statesPanelProps,
5769
+ reviewPanelProps,
5770
+ componentNodeId,
5771
+ sourceCapabilities = [],
5772
+ onCreateComponent,
5773
+ defaultComponentName = "Component",
5774
+ inspectCode,
4670
5775
  }: EditPanelProps) {
4671
5776
  const t = useT();
5777
+ const [createComponentOpen, setCreateComponentOpen] = useState(false);
4672
5778
  const [exportSettings, setExportSettings] = useState<ExportSettingsValue>(
4673
5779
  DEFAULT_EXPORT_SETTINGS,
4674
5780
  );
4675
5781
  const [showExportPreview, setShowExportPreview] = useState(false);
5782
+ // Sub-tab within the Tweaks area: "tokens" (TokensPanel face) or "tweaks"
5783
+ // (raw TweakDefinition controls). Always default to the existing "tweaks"
5784
+ // controls so opening Tweaks lands on the user's TweakDefinition controls
5785
+ // (and the + affordance) rather than auto-firing token indexing. "Tokens"
5786
+ // remains available as a sub-tab the user can click when designId is set.
5787
+ const [tweaksSubTab, setTweaksSubTab] = useState<TweaksSubTab>("tweaks");
5788
+
4676
5789
  const selectedElementKey = selectedElement
4677
5790
  ? elementIdentityKey(selectedElement)
4678
5791
  : "none";
@@ -4689,6 +5802,16 @@ export function EditPanel({
4689
5802
  },
4690
5803
  [onTweakChange],
4691
5804
  );
5805
+ // The "Add Tweaks" (+) flow lives only on the "tweaks" sub-tab. Switch to it
5806
+ // before opening the prompt so the + button and the newly added control are
5807
+ // visible (avoids leaving the user on the Tokens sub-tab where + isn't shown).
5808
+ const handleRequestTweaks = useCallback(
5809
+ (anchor: HTMLElement) => {
5810
+ setTweaksSubTab("tweaks");
5811
+ onRequestTweaks?.(anchor);
5812
+ },
5813
+ [onRequestTweaks],
5814
+ );
4692
5815
 
4693
5816
  useEffect(() => {
4694
5817
  setExportSettings(DEFAULT_EXPORT_SETTINGS);
@@ -4755,7 +5878,29 @@ export function EditPanel({
4755
5878
 
4756
5879
  {activeTab === "design" ? (
4757
5880
  <>
4758
- <SelectionHeader element={selectedElement} />
5881
+ <SelectionHeader
5882
+ element={selectedElement}
5883
+ onCreateComponent={
5884
+ onCreateComponent && selectedElement
5885
+ ? () => setCreateComponentOpen(true)
5886
+ : undefined
5887
+ }
5888
+ inspectCodePopover={
5889
+ inspectCode && selectedElement
5890
+ ? (trigger) => (
5891
+ <InspectCodePopover trigger={trigger} data={inspectCode} />
5892
+ )
5893
+ : undefined
5894
+ }
5895
+ />
5896
+ {onCreateComponent && (
5897
+ <CreateComponentDialog
5898
+ open={createComponentOpen}
5899
+ onOpenChange={setCreateComponentOpen}
5900
+ defaultName={defaultComponentName}
5901
+ onSubmit={onCreateComponent}
5902
+ />
5903
+ )}
4759
5904
 
4760
5905
  <div
4761
5906
  className="design-inspector-scroll min-h-0 flex-1 overflow-y-auto overscroll-contain"
@@ -4821,6 +5966,16 @@ export function EditPanel({
4821
5966
  next?.focus();
4822
5967
  }}
4823
5968
  >
5969
+ {/* §6.1 Component section — shown at the top when a component
5970
+ instance is selected. Requires designId + componentNodeId. */}
5971
+ {designId && componentNodeId && (
5972
+ <ComponentSection
5973
+ designId={designId}
5974
+ nodeId={componentNodeId}
5975
+ sourceCapabilities={sourceCapabilities}
5976
+ />
5977
+ )}
5978
+
4824
5979
  {!selectedElement && (
4825
5980
  <PageProperties
4826
5981
  styles={pageStyles}
@@ -4839,6 +5994,7 @@ export function EditPanel({
4839
5994
  element={selectedElement}
4840
5995
  onStyleChange={onStyleChange}
4841
5996
  onStylesChange={onStylesChange}
5997
+ onAutoLayoutConvert={onAutoLayoutConvert}
4842
5998
  />
4843
5999
  <AppearanceProperties
4844
6000
  element={selectedElement}
@@ -4875,6 +6031,17 @@ export function EditPanel({
4875
6031
  onStyleChange={onStyleChange}
4876
6032
  />
4877
6033
  ) : null}
6034
+ {/* AI edit request block — collapsible, collapsed by default */}
6035
+ <section className="shrink-0 border-t border-[var(--design-editor-control-border)]">
6036
+ <InspectorAiActions
6037
+ selector={selectedElement.selector}
6038
+ sourceId={selectedElement.sourceId}
6039
+ designId={designId}
6040
+ fileId={fileId}
6041
+ filename={filename}
6042
+ canEdit={!readOnly}
6043
+ />
6044
+ </section>
4878
6045
  </>
4879
6046
  )}
4880
6047
  {onExport ? (
@@ -4909,40 +6076,212 @@ export function EditPanel({
4909
6076
  ) : null}
4910
6077
  </PanelSection>
4911
6078
  ) : null}
6079
+
6080
+ {/* §6.4 States & Responsive — contextual section in Design tab.
6081
+ Collapsed by default so existing Design content is not buried.
6082
+ Only mounts when designId is provided so there is something to
6083
+ load; without it the panel would fire an action with no id. */}
6084
+ {designId && statesPanelProps ? (
6085
+ <PanelSection
6086
+ title={"States" /* i18n-ignore design inspector section */}
6087
+ defaultCollapsed
6088
+ actions={
6089
+ <Tooltip>
6090
+ <TooltipTrigger asChild>
6091
+ <Button
6092
+ type="button"
6093
+ variant="ghost"
6094
+ size="icon"
6095
+ className="size-6 rounded-md text-muted-foreground hover:text-foreground"
6096
+ aria-label={
6097
+ "Breakpoints & states" /* i18n-ignore design inspector action */
6098
+ }
6099
+ >
6100
+ <IconDeviceDesktop className="size-3.5" />
6101
+ </Button>
6102
+ </TooltipTrigger>
6103
+ <TooltipContent>
6104
+ {
6105
+ "Breakpoints & states" /* i18n-ignore design inspector action */
6106
+ }
6107
+ </TooltipContent>
6108
+ </Tooltip>
6109
+ }
6110
+ >
6111
+ <StatesPanel designId={designId} {...statesPanelProps} />
6112
+ {/* §6.4 / §6.6 — live captures (real running-app data, route
6113
+ props, API responses) are a real-app capability. When
6114
+ canCapture is false (inline source) surface a compact
6115
+ "Make it real" CTA so the user knows it's one step away. */}
6116
+ {!statesPanelProps.canCapture && (
6117
+ <MakeItRealCard
6118
+ designId={designId}
6119
+ featureLabel="live data captures and real-app states"
6120
+ />
6121
+ )}
6122
+ </PanelSection>
6123
+ ) : null}
6124
+
6125
+ {/* §6.5 Review — contextual section in Design tab.
6126
+ Collapsed by default. Renders when reviewPanelProps is provided,
6127
+ no designId check needed since ReviewPanel is statically fed. */}
6128
+ {reviewPanelProps ? (
6129
+ <PanelSection
6130
+ title={"Review" /* i18n-ignore design inspector section */}
6131
+ defaultCollapsed
6132
+ actions={
6133
+ <Tooltip>
6134
+ <TooltipTrigger asChild>
6135
+ <Button
6136
+ type="button"
6137
+ variant="ghost"
6138
+ size="icon"
6139
+ className="size-6 rounded-md text-muted-foreground hover:text-foreground"
6140
+ aria-label={
6141
+ "Accessibility & visual diff" /* i18n-ignore design inspector action */
6142
+ }
6143
+ >
6144
+ <IconShieldCheck className="size-3.5" />
6145
+ </Button>
6146
+ </TooltipTrigger>
6147
+ <TooltipContent>
6148
+ {
6149
+ "Accessibility & visual diff" /* i18n-ignore design inspector action */
6150
+ }
6151
+ </TooltipContent>
6152
+ </Tooltip>
6153
+ }
6154
+ >
6155
+ {/* ReviewPanel manages its own scroll; no extra wrapper needed. */}
6156
+ <ReviewPanel {...reviewPanelProps} />
6157
+ </PanelSection>
6158
+ ) : null}
4912
6159
  </div>
4913
6160
  </>
4914
6161
  ) : activeTab === "tweaks" ? (
4915
6162
  <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>
6163
+ {/* Tweaks tab header: when designId is present show a Tokens | Tweaks
6164
+ sub-tab switcher so the TokensPanel is the first-class face of
6165
+ this area per §6.2. When there is no designId only the raw tweaks
6166
+ title is shown (original behaviour). */}
6167
+ {designId ? (
6168
+ <div className="flex h-9 shrink-0 items-center gap-0.5 border-b border-border/90 px-2">
6169
+ <Button
6170
+ type="button"
6171
+ variant="ghost"
6172
+ size="sm"
6173
+ className={cn(
6174
+ "h-6 rounded-md px-2 text-[11px] font-semibold text-muted-foreground transition-colors hover:text-foreground",
6175
+ tweaksSubTab === "tokens" &&
6176
+ "bg-[var(--design-editor-panel-raised-bg)] text-foreground",
6177
+ )}
6178
+ onClick={() => setTweaksSubTab("tokens")}
6179
+ >
6180
+ <IconPalette className="mr-1 size-3" />
6181
+ {"Tokens" /* i18n-ignore design inspector sub-tab */}
6182
+ </Button>
6183
+ <Button
6184
+ type="button"
6185
+ variant="ghost"
6186
+ size="sm"
6187
+ className={cn(
6188
+ "h-6 rounded-md px-2 text-[11px] font-semibold text-muted-foreground transition-colors hover:text-foreground",
6189
+ tweaksSubTab === "tweaks" &&
6190
+ "bg-[var(--design-editor-panel-raised-bg)] text-foreground",
6191
+ )}
6192
+ onClick={() => setTweaksSubTab("tweaks")}
6193
+ >
6194
+ {t("designEditor.tweaks")}
6195
+ </Button>
6196
+ </div>
6197
+ ) : (
6198
+ <div className="flex h-10 shrink-0 items-center justify-between gap-2 border-b border-border/90 px-3">
6199
+ <h3 className="min-w-0 flex-1 truncate text-[13px] font-semibold text-foreground">
6200
+ {t("designEditor.tweaks")}
6201
+ </h3>
6202
+ {onRequestTweaks ? (
6203
+ <Tooltip>
6204
+ <TooltipTrigger asChild>
6205
+ <Button
6206
+ type="button"
6207
+ variant="ghost"
6208
+ size="icon"
6209
+ className="size-7 rounded-sm text-muted-foreground hover:bg-accent hover:text-foreground"
6210
+ aria-label={t("designEditor.addTweaks")}
6211
+ onClick={(event) =>
6212
+ handleRequestTweaks(event.currentTarget)
6213
+ }
6214
+ >
6215
+ <IconPlus className="size-3.5" />
6216
+ </Button>
6217
+ </TooltipTrigger>
6218
+ <TooltipContent>{t("designEditor.addTweaks")}</TooltipContent>
6219
+ </Tooltip>
6220
+ ) : null}
6221
+ </div>
6222
+ )}
6223
+
4938
6224
  <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
- />
6225
+ {designId && tweaksSubTab === "tokens" ? (
6226
+ /* §6.2 Tokens face — first-class view of the Tweaks area.
6227
+ TokensPanel calls apply-design-token-edit which routes through
6228
+ the Tweaks loop (designs.data.tweakSelections persist). */
6229
+ <>
6230
+ <TokensPanel
6231
+ designId={designId}
6232
+ onTokensApplied={onTokensApplied}
6233
+ />
6234
+ {/* §5 / §6.6 — token write-back to source is a real-app
6235
+ capability. On inline sources (sourceCapabilities lacks
6236
+ "writeFile") show a compact "Make it real" CTA after the
6237
+ token list so the user knows write-back is one step away. */}
6238
+ {!sourceCapabilities.includes("writeFile") && (
6239
+ <div className="px-3 pb-3 pt-1">
6240
+ <MakeItRealCard
6241
+ designId={designId}
6242
+ featureLabel="token write-back to source files"
6243
+ />
6244
+ </div>
6245
+ )}
6246
+ </>
6247
+ ) : (
6248
+ /* Raw TweakDefinition controls — shown when no designId, or when
6249
+ the user switches to the Tweaks sub-tab. */
6250
+ <>
6251
+ {designId && onRequestTweaks ? (
6252
+ /* When in sub-tab mode, show the add-tweaks action in the
6253
+ scrollable content area above the raw controls. */
6254
+ <div className="flex items-center justify-end px-3 pt-2">
6255
+ <Tooltip>
6256
+ <TooltipTrigger asChild>
6257
+ <Button
6258
+ type="button"
6259
+ variant="ghost"
6260
+ size="icon"
6261
+ className="size-7 rounded-sm text-muted-foreground hover:bg-accent hover:text-foreground"
6262
+ aria-label={t("designEditor.addTweaks")}
6263
+ onClick={(event) =>
6264
+ handleRequestTweaks(event.currentTarget)
6265
+ }
6266
+ >
6267
+ <IconPlus className="size-3.5" />
6268
+ </Button>
6269
+ </TooltipTrigger>
6270
+ <TooltipContent>
6271
+ {t("designEditor.addTweaks")}
6272
+ </TooltipContent>
6273
+ </Tooltip>
6274
+ </div>
6275
+ ) : null}
6276
+ <TweaksPanelContent
6277
+ tweaks={tweaks}
6278
+ values={tweakValues}
6279
+ onChange={handleTweakChange}
6280
+ onRequestTweaks={!designId ? handleRequestTweaks : undefined}
6281
+ className="px-3 py-3"
6282
+ />
6283
+ </>
6284
+ )}
4946
6285
  </div>
4947
6286
  </div>
4948
6287
  ) : extensionContext ? (