@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.
- package/corpus/README.md +1 -1
- package/corpus/core/CHANGELOG.md +13 -0
- package/corpus/core/package.json +1 -1
- package/corpus/core/src/agent/production-agent.ts +108 -15
- package/corpus/core/src/cli/design-connect.ts +28 -2
- package/corpus/core/src/cli/skills.ts +45 -24
- package/corpus/core/src/client/AssistantChat.tsx +123 -26
- package/corpus/core/src/client/sse-event-processor.ts +9 -0
- package/corpus/templates/assets/.agents/skills/image-generation/SKILL.md +9 -2
- package/corpus/templates/assets/AGENTS.md +4 -0
- package/corpus/templates/assets/actions/_tool-activity.ts +46 -0
- package/corpus/templates/assets/actions/generate-image-batch.ts +26 -5
- package/corpus/templates/assets/actions/generate-image.ts +118 -67
- package/corpus/templates/assets/actions/list-draft-assets.ts +50 -0
- package/corpus/templates/assets/actions/rerun-generation-run.ts +52 -31
- package/corpus/templates/assets/app/components/create/RecentDraftsSection.tsx +100 -0
- package/corpus/templates/assets/app/i18n/zh-TW.ts +7 -0
- package/corpus/templates/assets/app/i18n-data.ts +61 -0
- package/corpus/templates/assets/app/routes/_index.tsx +4 -0
- package/corpus/templates/assets/app/routes/library.tsx +145 -38
- package/corpus/templates/assets/changelog/2026-06-29-image-generation-can-now-render-requested-text-and-respect-b.md +6 -0
- package/corpus/templates/assets/changelog/2026-06-30-image-generation-no-longer-looks-stuck-while-the-provider-is-still-working.md +6 -0
- package/corpus/templates/assets/changelog/2026-06-30-tagged-presets-now-control-the-image-aspect-ratio.md +6 -0
- package/corpus/templates/assets/server/lib/generation.ts +135 -11
- package/corpus/templates/assets/shared/api.ts +4 -0
- package/corpus/templates/clips/actions/list-ai-requests.ts +60 -0
- package/corpus/templates/clips/app/hooks/use-auto-title.ts +24 -25
- package/corpus/templates/design/DESIGN-STUDIO-PLAN.md +717 -0
- package/corpus/templates/design/actions/add-breakpoint.ts +143 -0
- package/corpus/templates/design/actions/add-localhost-screens.ts +5 -0
- package/corpus/templates/design/actions/apply-a11y-fix.ts +317 -0
- package/corpus/templates/design/actions/apply-component-prop-edit.ts +441 -0
- package/corpus/templates/design/actions/apply-design-state.ts +213 -0
- package/corpus/templates/design/actions/apply-design-token-edit.ts +202 -0
- package/corpus/templates/design/actions/apply-motion-edit.ts +413 -0
- package/corpus/templates/design/actions/apply-shader-fill.ts +404 -0
- package/corpus/templates/design/actions/apply-visual-edit.ts +191 -5
- package/corpus/templates/design/actions/capture-design-state.ts +224 -0
- package/corpus/templates/design/actions/connect-builder-app.ts +162 -0
- package/corpus/templates/design/actions/create-component.ts +447 -0
- package/corpus/templates/design/actions/create-design-branch.ts +263 -0
- package/corpus/templates/design/actions/create-design-state.ts +162 -0
- package/corpus/templates/design/actions/delete-design-state.ts +55 -0
- package/corpus/templates/design/actions/delete-design.ts +20 -0
- package/corpus/templates/design/actions/deploy-design-preview.ts +275 -0
- package/corpus/templates/design/actions/get-component-details.ts +251 -0
- package/corpus/templates/design/actions/get-design-branch-diff.ts +362 -0
- package/corpus/templates/design/actions/get-design-review.ts +314 -0
- package/corpus/templates/design/actions/get-design-surface-index.ts +582 -0
- package/corpus/templates/design/actions/get-motion-timeline.ts +99 -0
- package/corpus/templates/design/actions/index-components.ts +258 -0
- package/corpus/templates/design/actions/index-design-tokens.ts +277 -0
- package/corpus/templates/design/actions/list-design-extensions.ts +309 -0
- package/corpus/templates/design/actions/list-design-source-capabilities.ts +153 -0
- package/corpus/templates/design/actions/list-design-states.ts +90 -0
- package/corpus/templates/design/actions/migrate-inline-design-to-app.ts +298 -0
- package/corpus/templates/design/actions/open-component-source.ts +242 -0
- package/corpus/templates/design/actions/preview-component-prop-edit.ts +260 -0
- package/corpus/templates/design/actions/preview-design-token-edit.ts +113 -0
- package/corpus/templates/design/actions/preview-shader-fill.ts +187 -0
- package/corpus/templates/design/actions/remove-breakpoint.ts +103 -0
- package/corpus/templates/design/actions/remove-motion-timeline.ts +196 -0
- package/corpus/templates/design/actions/run-design-audit.ts +436 -0
- package/corpus/templates/design/actions/run-design-extension-action.ts +284 -0
- package/corpus/templates/design/actions/set-active-breakpoint.ts +39 -0
- package/corpus/templates/design/app/components/design/DesignCanvas.tsx +1550 -82
- package/corpus/templates/design/app/components/design/DesignExtensionsPanel.tsx +649 -43
- package/corpus/templates/design/app/components/design/DrawOverlay.tsx +1 -1
- package/corpus/templates/design/app/components/design/EditPanel.tsx +1788 -52
- package/corpus/templates/design/app/components/design/LayersPanel.tsx +114 -33
- package/corpus/templates/design/app/components/design/LocalSourceEditBanner.tsx +157 -0
- package/corpus/templates/design/app/components/design/MotionDock.tsx +1071 -0
- package/corpus/templates/design/app/components/design/MultiScreenCanvas.tsx +1053 -99
- package/corpus/templates/design/app/components/design/ReviewPanel.tsx +766 -0
- package/corpus/templates/design/app/components/design/StatesPanel.tsx +581 -0
- package/corpus/templates/design/app/components/design/TokensPanel.tsx +578 -0
- package/corpus/templates/design/app/components/design/canvas-primitive-style.ts +241 -0
- package/corpus/templates/design/app/components/design/index.ts +16 -0
- package/corpus/templates/design/app/components/design/inspector/AutoLayoutMatrix.tsx +15 -6
- package/corpus/templates/design/app/components/design/inspector/DesignColorPicker.tsx +66 -3
- package/corpus/templates/design/app/components/design/inspector/InspectorAiActions.tsx +145 -0
- package/corpus/templates/design/app/components/design/inspector/SHADER_INTEGRATION.md +42 -12
- package/corpus/templates/design/app/components/visual-editor/DrawOverlay.tsx +1 -1
- package/corpus/templates/design/app/hooks/useAgentEditRequest.ts +131 -0
- package/corpus/templates/design/app/hooks/useDesignHotkeys.ts +3 -1
- package/corpus/templates/design/app/i18n/zh-TW.ts +32 -0
- package/corpus/templates/design/app/i18n-data.ts +280 -0
- package/corpus/templates/design/app/pages/DesignEditor.tsx +3087 -490
- package/corpus/templates/design/changelog/2026-06-29-added-a-review-panel-in-the-design-editor-s-inspector-with-a.md +6 -0
- package/corpus/templates/design/changelog/2026-06-29-copying-and-pasting-layers-no-longer-shows-success-notificat.md +6 -0
- package/corpus/templates/design/changelog/2026-06-29-device-presets-in-all-screens-view-resize-the-selected-previ.md +6 -0
- package/corpus/templates/design/changelog/2026-06-29-layer-and-canvas-drags-keep-the-layer-list-stable-while-chan.md +6 -0
- package/corpus/templates/design/changelog/2026-06-29-layer-moves-can-be-undone-and-redone-without-flashing-the-ca.md +6 -0
- package/corpus/templates/design/changelog/2026-06-29-screen-previews-use-a-single-blue-hover-border-in-all-screen.md +6 -0
- package/corpus/templates/design/changelog/2026-06-29-selected-containers-show-draggable-padding-and-gap-guides-on.md +6 -0
- package/corpus/templates/design/changelog/2026-06-29-the-design-editor-adds-a-studio-layer-with-tokens-respon.md +6 -0
- package/corpus/templates/design/changelog/2026-06-30-accessibility-findings-now-offer-a-one-click-fix.md +6 -0
- package/corpus/templates/design/changelog/2026-06-30-component-instances-now-have-editable-props-in-the-inspe.md +6 -0
- package/corpus/templates/design/changelog/2026-06-30-design-token-edits-now-stay-visible-in-previews-and-token-li.md +6 -0
- package/corpus/templates/design/changelog/2026-06-30-inspect-code-now-shows-the-elements-opening-tag-at-a-gla.md +6 -0
- package/corpus/templates/design/changelog/2026-06-30-shader-fill-presets-can-now-be-applied-to-an-element.md +6 -0
- package/corpus/templates/design/changelog/2026-06-30-the-motion-timeline-can-now-add-a-track-to-any-element-a.md +6 -0
- package/corpus/templates/design/changelog/2026-06-30-visual-editor-selection-layer-paint-and-drawing-controls-are-more-reliable.md +6 -0
- package/corpus/templates/design/e2e/global-setup.ts +94 -2
- package/corpus/templates/design/e2e/helpers.ts +10 -4
- package/corpus/templates/design/server/db/schema.ts +123 -0
- package/corpus/templates/design/server/plugins/db.ts +90 -0
- package/corpus/templates/design/shared/builder-app.ts +297 -0
- package/corpus/templates/design/shared/capability-resolver.ts +123 -0
- package/corpus/templates/design/shared/capture-sanitize.ts +70 -0
- package/corpus/templates/design/shared/code-layer.ts +1016 -71
- package/corpus/templates/design/shared/component-model.ts +274 -0
- package/corpus/templates/design/shared/design-review.ts +275 -0
- package/corpus/templates/design/shared/design-source-capabilities.ts +286 -0
- package/corpus/templates/design/shared/design-state.ts +112 -0
- package/corpus/templates/design/shared/design-surface-index.ts +258 -0
- package/corpus/templates/design/shared/motion-compiler.ts +349 -0
- package/corpus/templates/design/shared/motion-timeline.ts +193 -0
- package/corpus/templates/design/shared/resolve-tweaks.ts +32 -9
- package/corpus/templates/design/shared/responsive-classes.ts +452 -0
- package/corpus/templates/design/shared/shader-fill.ts +323 -0
- package/corpus/templates/design/shared/source-mode.ts +245 -0
- package/corpus/templates/plan/app/components/plan/CanvasArea.tsx +6 -2
- package/corpus/templates/plan/changelog/2026-06-29-plan-canvases-open-without-an-initial-pan-and-zoom-flicker.md +6 -0
- package/dist/agent/production-agent.d.ts.map +1 -1
- package/dist/agent/production-agent.js +94 -10
- package/dist/agent/production-agent.js.map +1 -1
- package/dist/cli/design-connect.d.ts.map +1 -1
- package/dist/cli/design-connect.js +28 -2
- package/dist/cli/design-connect.js.map +1 -1
- package/dist/cli/skills.d.ts.map +1 -1
- package/dist/cli/skills.js +37 -21
- package/dist/cli/skills.js.map +1 -1
- package/dist/client/AssistantChat.d.ts +14 -0
- package/dist/client/AssistantChat.d.ts.map +1 -1
- package/dist/client/AssistantChat.js +115 -22
- package/dist/client/AssistantChat.js.map +1 -1
- package/dist/client/sse-event-processor.d.ts.map +1 -1
- package/dist/client/sse-event-processor.js +10 -0
- package/dist/client/sse-event-processor.js.map +1 -1
- package/dist/collab/routes.d.ts +1 -1
- package/dist/file-upload/actions/upload-image.d.ts +2 -2
- package/dist/notifications/routes.d.ts +2 -2
- package/dist/observability/routes.d.ts +8 -8
- package/dist/resources/handlers.d.ts +2 -2
- package/dist/server/agent-engine-api-key-route.d.ts +1 -1
- package/dist/server/transcribe-voice.d.ts +1 -1
- package/package.json +1 -1
|
@@ -1,4 +1,8 @@
|
|
|
1
|
-
import {
|
|
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={
|
|
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({
|
|
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
|
-
{/*
|
|
1872
|
-
|
|
1873
|
-
|
|
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
|
-
|
|
1879
|
-
|
|
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
|
-
|
|
1888
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
4917
|
-
|
|
4918
|
-
|
|
4919
|
-
|
|
4920
|
-
|
|
4921
|
-
|
|
4922
|
-
|
|
4923
|
-
|
|
4924
|
-
|
|
4925
|
-
|
|
4926
|
-
|
|
4927
|
-
|
|
4928
|
-
|
|
4929
|
-
|
|
4930
|
-
|
|
4931
|
-
|
|
4932
|
-
|
|
4933
|
-
|
|
4934
|
-
|
|
4935
|
-
</
|
|
4936
|
-
|
|
4937
|
-
|
|
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
|
-
|
|
4940
|
-
|
|
4941
|
-
|
|
4942
|
-
|
|
4943
|
-
|
|
4944
|
-
|
|
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 ? (
|