@hyperframes/studio 0.7.46 → 0.7.47

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (110) hide show
  1. package/dist/assets/index-Dq7FEg0K.css +1 -0
  2. package/dist/assets/{index-BGVoOXdg.js → index-DsckwbdW.js} +1 -1
  3. package/dist/assets/index-VsAbY3rl.js +423 -0
  4. package/dist/assets/{index-DO2MAFSG.js → index-uvB30_yI.js} +1 -1
  5. package/dist/index.d.ts +96 -29
  6. package/dist/index.html +2 -2
  7. package/dist/index.js +8443 -4305
  8. package/dist/index.js.map +1 -1
  9. package/package.json +7 -7
  10. package/src/App.tsx +26 -32
  11. package/src/components/DesignPanelPromoteProvider.tsx +45 -0
  12. package/src/components/PanelTabButton.tsx +31 -0
  13. package/src/components/StudioPreviewArea.tsx +75 -3
  14. package/src/components/StudioRightPanel.tsx +111 -113
  15. package/src/components/editor/LayersPanel.tsx +2 -22
  16. package/src/components/editor/PromotableControl.tsx +105 -0
  17. package/src/components/editor/propertyPanelSections.tsx +42 -21
  18. package/src/components/panels/VariablesBindElement.tsx +266 -0
  19. package/src/components/panels/VariablesDeclarationForm.test.ts +135 -0
  20. package/src/components/panels/VariablesDeclarationForm.tsx +321 -0
  21. package/src/components/panels/VariablesOtherCompositions.tsx +159 -0
  22. package/src/components/panels/VariablesPanel.tsx +554 -0
  23. package/src/components/panels/VariablesRowAction.tsx +25 -0
  24. package/src/components/panels/VariablesValueControls.tsx +215 -0
  25. package/src/components/renders/useRenderQueue.ts +12 -0
  26. package/src/contexts/TimelineEditContext.tsx +7 -2
  27. package/src/contexts/VariablePromoteContext.tsx +138 -0
  28. package/src/contexts/variablePromoteHelpers.test.ts +124 -0
  29. package/src/contexts/variablePromoteHelpers.ts +53 -0
  30. package/src/contexts/variablePromoteIntegration.test.ts +80 -0
  31. package/src/hooks/previewVariablesStore.ts +34 -0
  32. package/src/hooks/timelineEditingHelpers.test.ts +104 -0
  33. package/src/hooks/timelineEditingHelpers.ts +386 -10
  34. package/src/hooks/useDomEditWiring.ts +9 -7
  35. package/src/hooks/useDomSelection.test.ts +36 -2
  36. package/src/hooks/useDomSelection.ts +56 -75
  37. package/src/hooks/useElementLifecycleOps.ts +57 -17
  38. package/src/hooks/usePreviewDocumentVersion.ts +27 -0
  39. package/src/hooks/useProjectCompositionVariables.ts +131 -0
  40. package/src/hooks/useStudioContextValue.ts +6 -1
  41. package/src/hooks/useStudioSdkSessions.ts +37 -0
  42. package/src/hooks/useTimelineEditing.test.tsx +963 -0
  43. package/src/hooks/useTimelineEditing.ts +122 -100
  44. package/src/hooks/useTimelineEditingTypes.ts +15 -0
  45. package/src/hooks/useTimelineGroupEditing.ts +370 -0
  46. package/src/hooks/useTimelineSelectionPreviewSync.test.tsx +129 -0
  47. package/src/hooks/useTimelineSelectionPreviewSync.ts +130 -0
  48. package/src/hooks/useVariablesPersist.ts +61 -0
  49. package/src/player/components/BeatStrip.tsx +19 -3
  50. package/src/player/components/Player.tsx +7 -1
  51. package/src/player/components/Timeline.test.ts +38 -0
  52. package/src/player/components/Timeline.tsx +102 -80
  53. package/src/player/components/TimelineCanvas.tsx +340 -296
  54. package/src/player/components/TimelineClip.test.tsx +11 -0
  55. package/src/player/components/TimelineClipDiamonds.tsx +40 -9
  56. package/src/player/components/TimelineDragGhost.tsx +59 -0
  57. package/src/player/components/TimelineDropInsertionLine.tsx +32 -0
  58. package/src/player/components/TimelineLayerGroupHeader.tsx +97 -0
  59. package/src/player/components/TimelineLayerGutter.tsx +61 -0
  60. package/src/player/components/TimelineRuler.tsx +4 -2
  61. package/src/player/components/TimelineSelectionOverlays.tsx +55 -0
  62. package/src/player/components/timelineCallbacks.ts +20 -2
  63. package/src/player/components/timelineClipDragPreview.ts +52 -0
  64. package/src/player/components/timelineDragDrop.ts +19 -3
  65. package/src/player/components/timelineDropIndicator.test.ts +48 -0
  66. package/src/player/components/timelineDropIndicator.ts +33 -0
  67. package/src/player/components/timelineEditing.test.ts +251 -1
  68. package/src/player/components/timelineEditing.ts +178 -19
  69. package/src/player/components/timelineGroupEditing.ts +159 -0
  70. package/src/player/components/timelineLayerDrag.test.ts +200 -0
  71. package/src/player/components/timelineLayerDrag.ts +383 -0
  72. package/src/player/components/timelineLayout.test.ts +34 -0
  73. package/src/player/components/timelineLayout.ts +47 -0
  74. package/src/player/components/timelineMarqueeSelection.test.ts +87 -0
  75. package/src/player/components/timelineSnapTargets.test.ts +144 -0
  76. package/src/player/components/timelineSnapTargets.ts +164 -0
  77. package/src/player/components/timelineStacking.ts +62 -0
  78. package/src/player/components/timelineTrackOrder.test.ts +144 -0
  79. package/src/player/components/timelineTrackOrder.ts +173 -0
  80. package/src/player/components/useTimelineActiveClips.test.ts +20 -0
  81. package/src/player/components/useTimelineActiveClips.ts +15 -4
  82. package/src/player/components/useTimelineClipDrag.test.tsx +506 -0
  83. package/src/player/components/useTimelineClipDrag.ts +172 -126
  84. package/src/player/components/useTimelineClipGroupDrag.ts +417 -0
  85. package/src/player/components/useTimelineKeyframeHandlers.ts +73 -0
  86. package/src/player/components/useTimelineMarqueeSelection.test.tsx +231 -0
  87. package/src/player/components/useTimelineMarqueeSelection.ts +276 -0
  88. package/src/player/hooks/useExpandedTimelineElements.ts +3 -0
  89. package/src/player/hooks/useTimelinePlayer.test.ts +4 -1
  90. package/src/player/hooks/useTimelinePlayer.ts +7 -0
  91. package/src/player/lib/layerOrdering.test.ts +77 -0
  92. package/src/player/lib/layerOrdering.ts +98 -0
  93. package/src/player/lib/playbackTypes.ts +3 -0
  94. package/src/player/lib/timelineDOM.test.ts +103 -0
  95. package/src/player/lib/timelineDOM.ts +80 -0
  96. package/src/player/lib/timelineElementHelpers.ts +1 -1
  97. package/src/player/store/playerStore.test.ts +92 -0
  98. package/src/player/store/playerStore.ts +69 -9
  99. package/src/utils/blockInstaller.ts +2 -14
  100. package/src/utils/editHistory.test.ts +61 -0
  101. package/src/utils/editHistory.ts +7 -1
  102. package/src/utils/rootDuration.test.ts +34 -0
  103. package/src/utils/rootDuration.ts +17 -0
  104. package/src/utils/sdkCutover.ts +45 -0
  105. package/src/utils/studioHelpers.test.ts +31 -0
  106. package/src/utils/studioHelpers.ts +29 -1
  107. package/src/utils/studioUrlState.test.ts +29 -0
  108. package/src/utils/studioUrlState.ts +16 -1
  109. package/dist/assets/index-BsLQ2qGn.js +0 -420
  110. package/dist/assets/index-DoVLXke0.css +0 -1
@@ -0,0 +1,554 @@
1
+ import { memo, useCallback, useEffect, useMemo, useState, type MutableRefObject } from "react";
2
+ import type {
3
+ Composition,
4
+ CompositionVariable,
5
+ VariableUsageReport,
6
+ VariableValidationIssue,
7
+ } from "@hyperframes/sdk";
8
+ import type { EditHistoryKind } from "../../utils/editHistory";
9
+ import { useStudioPlaybackContext, useStudioShellContext } from "../../contexts/StudioContext";
10
+ import { useDomEditContext } from "../../contexts/DomEditContext";
11
+ import { useFileManagerContext } from "../../contexts/FileManagerContext";
12
+ import { VariablesBindElement, type BindAction, applyBind } from "./VariablesBindElement";
13
+ import { useVariablesPersist } from "../../hooks/useVariablesPersist";
14
+ import { VariablesOtherCompositions } from "./VariablesOtherCompositions";
15
+ import { RowAction } from "./VariablesRowAction";
16
+ import { usePreviewVariablesStore } from "../../hooks/previewVariablesStore";
17
+ import {
18
+ DeclarationForm,
19
+ draftFromDeclaration,
20
+ mergeDeclarationEdit,
21
+ EMPTY_DRAFT,
22
+ } from "./VariablesDeclarationForm";
23
+ import { PreviewValueControl } from "./VariablesValueControls";
24
+ import { copyTextToClipboard } from "../../utils/clipboard";
25
+ import { resolveMasterCompositionPath } from "../../utils/studioUrlState";
26
+ import { isScalarVariableValue as isScalar } from "@hyperframes/core/variables";
27
+
28
+ /** POSIX single-quote escaping so the copied command survives quotes in values. */
29
+ function shellSingleQuote(value: string): string {
30
+ return `'${value.replace(/'/g, `'\\''`)}'`;
31
+ }
32
+
33
+ interface VariablesPanelProps {
34
+ sdkSession: Composition | null;
35
+ reloadPreview: () => void;
36
+ domEditSaveTimestampRef: MutableRefObject<number>;
37
+ recordEdit: (entry: {
38
+ label: string;
39
+ kind: EditHistoryKind;
40
+ files: Record<string, { before: string; after: string }>;
41
+ }) => Promise<void>;
42
+ }
43
+
44
+ function formatIssue(issue: VariableValidationIssue): string {
45
+ switch (issue.kind) {
46
+ case "undeclared":
47
+ return `"${issue.variableId}" is not declared.`;
48
+ case "type-mismatch":
49
+ return `"${issue.variableId}" expects ${issue.expected}, got ${issue.actual}.`;
50
+ case "enum-out-of-range":
51
+ return `"${issue.variableId}" must be one of: ${issue.allowed.join(", ")}.`;
52
+ }
53
+ }
54
+
55
+ function ValidationStrip({ issues }: { issues: VariableValidationIssue[] }) {
56
+ if (issues.length === 0) return null;
57
+ return (
58
+ <div className="space-y-1 rounded-lg border border-red-900/60 bg-red-950/30 p-2">
59
+ {issues.map((issue) => (
60
+ <p key={`${issue.kind}:${issue.variableId}`} className="text-[10px] text-red-300">
61
+ {formatIssue(issue)}
62
+ </p>
63
+ ))}
64
+ </div>
65
+ );
66
+ }
67
+
68
+ // fallow-ignore-next-line complexity
69
+ function VariableRow({
70
+ decl,
71
+ value,
72
+ overridden,
73
+ unused,
74
+ editing,
75
+ onCommitPreview,
76
+ onSetDefault,
77
+ onToggleEdit,
78
+ onSaveEdit,
79
+ onRemove,
80
+ }: {
81
+ decl: CompositionVariable;
82
+ value: unknown;
83
+ overridden: boolean;
84
+ unused: boolean;
85
+ editing: boolean;
86
+ onCommitPreview: (value: unknown) => void;
87
+ onSetDefault: (value: string | number | boolean) => void;
88
+ onToggleEdit: () => void;
89
+ onSaveEdit: (decl: CompositionVariable) => void;
90
+ onRemove: () => void;
91
+ }) {
92
+ return (
93
+ <div className="space-y-1.5 rounded-lg border border-neutral-800/70 p-2">
94
+ <div className="flex items-center gap-1.5">
95
+ <span className="truncate text-[10px] font-medium text-neutral-300">{decl.label}</span>
96
+ <span className="rounded bg-neutral-800 px-1 py-px font-mono text-[8px] text-neutral-500">
97
+ {decl.type}
98
+ </span>
99
+ {unused && (
100
+ <span
101
+ className="rounded bg-amber-900/40 px-1 py-px text-[8px] text-amber-400"
102
+ title="No script reads this variable"
103
+ >
104
+ unused
105
+ </span>
106
+ )}
107
+ {overridden && <span className="h-1.5 w-1.5 rounded-full bg-studio-accent" />}
108
+ <span className="ml-auto flex items-center gap-1">
109
+ {overridden && isScalar(value) && (
110
+ <RowAction
111
+ label="Set default"
112
+ title="Persist this value as the declared default"
113
+ onClick={() => onSetDefault(value)}
114
+ />
115
+ )}
116
+ <RowAction label="Edit" title="Edit declaration" onClick={onToggleEdit} />
117
+ <RowAction label="✕" title="Remove declaration" danger onClick={onRemove} />
118
+ </span>
119
+ </div>
120
+ {decl.description && <p className="text-[9px] text-neutral-500">{decl.description}</p>}
121
+ {editing ? (
122
+ <DeclarationForm
123
+ initial={draftFromDeclaration(decl)}
124
+ submitLabel="Save"
125
+ onSubmit={(edited) => onSaveEdit(mergeDeclarationEdit(decl, edited))}
126
+ onCancel={onToggleEdit}
127
+ />
128
+ ) : (
129
+ <PreviewValueControl decl={decl} value={value} onCommit={onCommitPreview} />
130
+ )}
131
+ </div>
132
+ );
133
+ }
134
+
135
+ function UndeclaredReads({
136
+ usage,
137
+ onDeclare,
138
+ }: {
139
+ usage: VariableUsageReport | null;
140
+ onDeclare: (id: string) => void;
141
+ }) {
142
+ if (!usage || usage.undeclaredReads.length === 0) return null;
143
+ return (
144
+ <div className="space-y-1 rounded-lg border border-neutral-800/70 bg-neutral-900/40 p-2">
145
+ <p className="text-[9px] font-medium uppercase tracking-wider text-neutral-500">
146
+ Read by scripts, not declared
147
+ </p>
148
+ {usage.undeclaredReads.map((id) => (
149
+ <div key={id} className="flex items-center gap-2">
150
+ <code className="font-mono text-[10px] text-neutral-400">{id}</code>
151
+ <RowAction
152
+ label="Declare"
153
+ title="Declare as a string variable"
154
+ onClick={() => onDeclare(id)}
155
+ />
156
+ </div>
157
+ ))}
158
+ </div>
159
+ );
160
+ }
161
+
162
+ /** Preview-state pill + reset, shown in the panel header. */
163
+ function PreviewModeHeader({
164
+ overrideCount,
165
+ onReset,
166
+ }: {
167
+ overrideCount: number;
168
+ onReset: () => void;
169
+ }) {
170
+ const hasOverrides = overrideCount > 0;
171
+ return (
172
+ <div className="flex items-center justify-between border-b border-neutral-800 px-3 py-2">
173
+ <div className="flex items-center gap-2">
174
+ <span className="text-[11px] font-semibold text-neutral-200">Variables</span>
175
+ <span
176
+ className={`rounded-full px-2 py-0.5 text-[9px] font-medium ${
177
+ hasOverrides
178
+ ? "bg-studio-accent/20 text-studio-accent"
179
+ : "bg-neutral-800 text-neutral-500"
180
+ }`}
181
+ >
182
+ {hasOverrides ? `Previewing ${overrideCount} custom` : "Previewing defaults"}
183
+ </span>
184
+ </div>
185
+ {hasOverrides && (
186
+ <button
187
+ type="button"
188
+ onClick={onReset}
189
+ className="h-6 rounded px-2 text-[10px] text-neutral-400 hover:text-neutral-200"
190
+ >
191
+ Reset
192
+ </button>
193
+ )}
194
+ </div>
195
+ );
196
+ }
197
+
198
+ /**
199
+ * Developer/agent handoff: copy the effective values as JSON or as a
200
+ * ready-to-run render command mirroring exactly what the preview shows.
201
+ */
202
+ function HandoffFooter({
203
+ effectiveValues,
204
+ compPath,
205
+ onCopy,
206
+ }: {
207
+ effectiveValues: Record<string, unknown>;
208
+ compPath: string;
209
+ onCopy: (text: string, what: string) => void;
210
+ }) {
211
+ const json = JSON.stringify(effectiveValues);
212
+ const command = `npx hyperframes render ${shellSingleQuote(compPath)} --variables ${shellSingleQuote(json)}`;
213
+ return (
214
+ <div className="space-y-1.5 rounded-lg border border-neutral-800/70 bg-neutral-900/40 p-2">
215
+ <p className="text-[9px] font-medium uppercase tracking-wider text-neutral-500">
216
+ Use this template
217
+ </p>
218
+ <code className="block truncate font-mono text-[9px] text-neutral-500" title={command}>
219
+ {command}
220
+ </code>
221
+ <div className="flex items-center gap-2">
222
+ <RowAction
223
+ label="Copy render command"
224
+ title="CLI command rendering exactly what the preview shows"
225
+ onClick={() => onCopy(command, "Render command")}
226
+ />
227
+ <RowAction
228
+ label="Copy values JSON"
229
+ title="Effective values (defaults merged with preview overrides)"
230
+ onClick={() => onCopy(json, "Values JSON")}
231
+ />
232
+ </div>
233
+ </div>
234
+ );
235
+ }
236
+
237
+ const EMPTY_STATE = (
238
+ <p className="text-[10px] leading-relaxed text-neutral-500">
239
+ No variables declared. Variables make parts of this composition dynamic — declare them here (or
240
+ in <code className="font-mono">data-composition-variables</code>), read them with{" "}
241
+ <code className="font-mono">getVariables()</code>, and pass values at render time with{" "}
242
+ <code className="font-mono">--variables</code>.
243
+ </p>
244
+ );
245
+
246
+ // Panel orchestrator — JSX conditionals per section, same shape as StudioRightPanel.
247
+ // fallow-ignore-next-line complexity
248
+ export const VariablesPanel = memo(function VariablesPanel({
249
+ sdkSession,
250
+ reloadPreview,
251
+ domEditSaveTimestampRef,
252
+ recordEdit,
253
+ }: VariablesPanelProps) {
254
+ const { activeCompPath, showToast } = useStudioShellContext();
255
+ const { refreshKey } = useStudioPlaybackContext();
256
+ const { readProjectFile, writeProjectFile, fileTree } = useFileManagerContext();
257
+ const { domEditSelection } = useDomEditContext();
258
+ // On the master view (no activeCompPath) the panel targets the project's real
259
+ // main composition — the first .html in the tree — not a hardcoded index.html
260
+ // that may not exist. This same path is used for the persist write target (so
261
+ // an edit never lands in a phantom index.html) AND the handoff render command.
262
+ // Null only when the project has no composition yet, in which case sdkSession
263
+ // is also null and the panel is inert.
264
+ const effectiveCompPath = activeCompPath ?? resolveMasterCompositionPath(fileTree);
265
+ const previewValues = usePreviewVariablesStore((s) => s.values);
266
+ const setPreviewValues = usePreviewVariablesStore((s) => s.setValues);
267
+
268
+ // Bumped after each persisted schema edit so declarations re-derive without
269
+ // waiting for the session reload round-trip.
270
+ const [revision, setRevision] = useState(0);
271
+ // Also bump on any session mutation (undo/redo, edits dispatched by other
272
+ // panels or agents) — the memos below must never trust refreshKey alone.
273
+ useEffect(() => {
274
+ if (!sdkSession) return;
275
+ return sdkSession.on("change", () => setRevision((r) => r + 1));
276
+ }, [sdkSession]);
277
+ const [addOpen, setAddOpen] = useState(false);
278
+ const [editingId, setEditingId] = useState<string | null>(null);
279
+
280
+ const persistVariables = useVariablesPersist({
281
+ sdkSession,
282
+ activeCompPath: effectiveCompPath,
283
+ readProjectFile,
284
+ writeProjectFile,
285
+ recordEdit,
286
+ reloadPreview,
287
+ domEditSaveTimestampRef,
288
+ });
289
+
290
+ const declarations = useMemo(
291
+ () => sdkSession?.getVariableDeclarations() ?? [],
292
+ // eslint-disable-next-line react-hooks/exhaustive-deps
293
+ [sdkSession, refreshKey, revision],
294
+ );
295
+ const usage = useMemo(
296
+ () => sdkSession?.getVariableUsage() ?? null,
297
+ // eslint-disable-next-line react-hooks/exhaustive-deps
298
+ [sdkSession, refreshKey, revision],
299
+ );
300
+ const issues = useMemo(
301
+ () => (previewValues && sdkSession ? sdkSession.validateVariableValues(previewValues) : []),
302
+ // eslint-disable-next-line react-hooks/exhaustive-deps
303
+ [sdkSession, previewValues, refreshKey, revision],
304
+ );
305
+ const effectiveValues = useMemo(
306
+ () => sdkSession?.getVariableValues(previewValues ?? undefined) ?? {},
307
+ // eslint-disable-next-line react-hooks/exhaustive-deps
308
+ [sdkSession, previewValues, refreshKey, revision],
309
+ );
310
+
311
+ const copyToClipboard = useCallback(
312
+ (text: string, what: string) => {
313
+ // Shared helper carries the execCommand fallback Safari needs.
314
+ void copyTextToClipboard(text).then((ok) =>
315
+ showToast(
316
+ ok ? `${what} copied` : `Couldn't copy ${what.toLowerCase()}`,
317
+ ok ? "info" : "error",
318
+ ),
319
+ );
320
+ },
321
+ [showToast],
322
+ );
323
+
324
+ const dropPreviewOverride = useCallback(
325
+ (id: string) => {
326
+ if (previewValues && id in previewValues) {
327
+ const next = { ...previewValues };
328
+ delete next[id];
329
+ setPreviewValues(next);
330
+ }
331
+ },
332
+ [previewValues, setPreviewValues],
333
+ );
334
+
335
+ const commitPreviewValue = useCallback(
336
+ (id: string, value: unknown, declDefault: unknown) => {
337
+ const next = { ...(previewValues ?? {}) };
338
+ if (JSON.stringify(value) === JSON.stringify(declDefault)) {
339
+ delete next[id];
340
+ } else {
341
+ next[id] = value;
342
+ }
343
+ setPreviewValues(next);
344
+ reloadPreview();
345
+ },
346
+ [previewValues, setPreviewValues, reloadPreview],
347
+ );
348
+
349
+ const runSchemaEdit = useCallback(
350
+ async (label: string, mutate: (session: Composition) => void): Promise<boolean> => {
351
+ try {
352
+ const changed = await persistVariables(label, mutate);
353
+ if (changed) setRevision((r) => r + 1);
354
+ else showToast(`${label}: no change applied`, "info");
355
+ return changed;
356
+ } catch (err) {
357
+ showToast(err instanceof Error ? err.message : String(err), "error");
358
+ return false;
359
+ }
360
+ },
361
+ [persistVariables, showToast],
362
+ );
363
+
364
+ const handleAdd = useCallback(
365
+ (decl: CompositionVariable) => {
366
+ if (!sdkSession) return;
367
+ const check = sdkSession.can({ type: "declareVariable", declaration: decl });
368
+ if (!check.ok) {
369
+ showToast(check.message, "error");
370
+ return;
371
+ }
372
+ setAddOpen(false);
373
+ void runSchemaEdit(`Declare variable "${decl.id}"`, (s) => s.declareVariable(decl));
374
+ },
375
+ [sdkSession, runSchemaEdit, showToast],
376
+ );
377
+
378
+ const handleUpdate = useCallback(
379
+ (decl: CompositionVariable) => {
380
+ if (!sdkSession) return;
381
+ const check = sdkSession.can({
382
+ type: "updateVariableDeclaration",
383
+ id: decl.id,
384
+ declaration: decl,
385
+ });
386
+ if (!check.ok) {
387
+ showToast(check.message, "error");
388
+ return;
389
+ }
390
+ setEditingId(null);
391
+ void runSchemaEdit(`Edit variable "${decl.id}"`, (s) =>
392
+ s.updateVariableDeclaration(decl.id, decl),
393
+ );
394
+ },
395
+ [sdkSession, runSchemaEdit, showToast],
396
+ );
397
+
398
+ const handleRemove = useCallback(
399
+ (id: string) => {
400
+ if (!sdkSession) return;
401
+ const check = sdkSession.can({ type: "removeVariableDeclaration", id });
402
+ if (!check.ok) {
403
+ showToast(check.message, "error");
404
+ return;
405
+ }
406
+ // Drop the preview override only if the declaration was actually removed —
407
+ // otherwise a rejected/failed edit would leave the row on disk but silently
408
+ // wipe the user's custom preview value.
409
+ void runSchemaEdit(`Remove variable "${id}"`, (s) => s.removeVariableDeclaration(id)).then(
410
+ (changed) => {
411
+ if (changed) dropPreviewOverride(id);
412
+ },
413
+ );
414
+ },
415
+ [sdkSession, runSchemaEdit, dropPreviewOverride, showToast],
416
+ );
417
+
418
+ const handleSetDefault = useCallback(
419
+ (id: string, value: string | number | boolean) => {
420
+ void runSchemaEdit(`Set default for "${id}"`, (s) => s.setVariableValue(id, value));
421
+ // The override now equals the persisted default — drop it from preview state.
422
+ dropPreviewOverride(id);
423
+ },
424
+ [runSchemaEdit, dropPreviewOverride],
425
+ );
426
+
427
+ const resetPreview = useCallback(() => {
428
+ setPreviewValues(null);
429
+ reloadPreview();
430
+ }, [setPreviewValues, reloadPreview]);
431
+
432
+ const handleBind = useCallback(
433
+ // Guard chain (session, selection, type-compat) — one branch per guard.
434
+ // fallow-ignore-next-line complexity
435
+ (action: BindAction, id: string) => {
436
+ if (!sdkSession || !domEditSelection?.hfId) return;
437
+ // Binding to an existing variable is allowed, but only when the types
438
+ // agree — wiring a color style to a string variable silently breaks
439
+ // the element's styling.
440
+ const existing = sdkSession.getVariableDeclarations().find((d) => d.id === id);
441
+ const wanted = action.declaration(id).type;
442
+ if (existing && existing.type !== wanted) {
443
+ showToast(
444
+ `"${id}" is already a ${existing.type} variable — pick another id for this ${wanted} binding`,
445
+ "error",
446
+ );
447
+ return;
448
+ }
449
+ const hfId = domEditSelection.hfId;
450
+ void runSchemaEdit(`Bind ${action.label.toLowerCase()} to "${id}"`, (s) =>
451
+ applyBind(s, hfId, action, id),
452
+ );
453
+ },
454
+ [sdkSession, domEditSelection, runSchemaEdit, showToast],
455
+ );
456
+
457
+ // The bind gesture targets the composition the session models — a selection
458
+ // from another source file must not write bindings into this one.
459
+ const bindableSelection =
460
+ domEditSelection?.hfId && domEditSelection.sourceFile === (activeCompPath ?? "index.html")
461
+ ? domEditSelection
462
+ : null;
463
+
464
+ if (!sdkSession) {
465
+ return (
466
+ <div className="flex h-full items-center justify-center px-6 text-center">
467
+ <p className="text-xs text-neutral-500">Open a composition to manage its variables.</p>
468
+ </div>
469
+ );
470
+ }
471
+
472
+ return (
473
+ <div className="flex h-full flex-col">
474
+ <PreviewModeHeader
475
+ overrideCount={previewValues ? Object.keys(previewValues).length : 0}
476
+ onReset={resetPreview}
477
+ />
478
+ <div className="flex-1 space-y-3 overflow-y-auto p-3">
479
+ {bindableSelection && (
480
+ <VariablesBindElement
481
+ key={bindableSelection.hfId}
482
+ selection={bindableSelection}
483
+ sdkSession={sdkSession}
484
+ onBind={handleBind}
485
+ />
486
+ )}
487
+ <ValidationStrip issues={issues} />
488
+ {declarations.length === 0 && !addOpen && EMPTY_STATE}
489
+ {/* fallow-ignore-next-line complexity */}
490
+ {declarations.map((decl) => (
491
+ <VariableRow
492
+ key={decl.id}
493
+ decl={decl}
494
+ value={
495
+ previewValues && decl.id in previewValues ? previewValues[decl.id] : decl.default
496
+ }
497
+ overridden={previewValues !== null && decl.id in previewValues}
498
+ unused={
499
+ usage !== null && !usage.scanIncomplete && usage.unusedDeclarations.includes(decl.id)
500
+ }
501
+ editing={editingId === decl.id}
502
+ onCommitPreview={(v) => commitPreviewValue(decl.id, v, decl.default)}
503
+ onSetDefault={(v) => handleSetDefault(decl.id, v)}
504
+ onToggleEdit={() => setEditingId(editingId === decl.id ? null : decl.id)}
505
+ onSaveEdit={handleUpdate}
506
+ onRemove={() => handleRemove(decl.id)}
507
+ />
508
+ ))}
509
+ <UndeclaredReads
510
+ usage={usage}
511
+ onDeclare={(id) => handleAdd({ id, type: "string", label: id, default: "" })}
512
+ />
513
+ {usage?.scanIncomplete && (
514
+ <p className="text-[9px] text-neutral-600">
515
+ Scripts access variables dynamically — usage info may be incomplete.
516
+ </p>
517
+ )}
518
+ {declarations.length > 0 && (
519
+ <HandoffFooter
520
+ effectiveValues={effectiveValues}
521
+ compPath={effectiveCompPath ?? "index.html"}
522
+ onCopy={copyToClipboard}
523
+ />
524
+ )}
525
+ {addOpen ? (
526
+ <DeclarationForm
527
+ initial={EMPTY_DRAFT}
528
+ submitLabel="Add variable"
529
+ onSubmit={handleAdd}
530
+ onCancel={() => setAddOpen(false)}
531
+ />
532
+ ) : (
533
+ <button
534
+ type="button"
535
+ onClick={() => setAddOpen(true)}
536
+ className="h-7 w-full rounded-lg border border-dashed border-neutral-800 text-[10px] font-medium text-neutral-500 transition-colors hover:border-neutral-700 hover:text-neutral-300"
537
+ >
538
+ + Add variable
539
+ </button>
540
+ )}
541
+ <VariablesOtherCompositions
542
+ fileTree={fileTree}
543
+ excludePath={activeCompPath ?? "index.html"}
544
+ refreshKey={`${refreshKey}:${revision}`}
545
+ readProjectFile={readProjectFile}
546
+ writeProjectFile={writeProjectFile}
547
+ recordEdit={recordEdit}
548
+ reloadPreview={reloadPreview}
549
+ domEditSaveTimestampRef={domEditSaveTimestampRef}
550
+ />
551
+ </div>
552
+ </div>
553
+ );
554
+ });
@@ -0,0 +1,25 @@
1
+ /** Small text-button used in the Variables tab rows (Edit / Remove / Set default / Declare). */
2
+ export function RowAction({
3
+ label,
4
+ title,
5
+ danger,
6
+ onClick,
7
+ }: {
8
+ label: string;
9
+ title: string;
10
+ danger?: boolean;
11
+ onClick: () => void;
12
+ }) {
13
+ return (
14
+ <button
15
+ type="button"
16
+ onClick={onClick}
17
+ title={title}
18
+ className={`h-5 rounded px-1.5 text-[9px] text-neutral-500 hover:bg-neutral-800 ${
19
+ danger ? "hover:text-red-400" : "hover:text-neutral-200"
20
+ }`}
21
+ >
22
+ {label}
23
+ </button>
24
+ );
25
+ }