@hyperframes/studio 0.7.45 → 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-CZkdm5YL.js → index-DsckwbdW.js} +1 -1
  3. package/dist/assets/index-VsAbY3rl.js +423 -0
  4. package/dist/assets/{index-CbQKp0Ek.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-CZUbpYhQ.js +0 -416
  110. package/dist/assets/index-DoVLXke0.css +0 -1
@@ -0,0 +1,215 @@
1
+ /**
2
+ * Per-type preview-value inputs for the Variables panel. Text-like inputs
3
+ * draft locally and commit on blur/Enter so the preview doesn't reload per
4
+ * keystroke; discrete inputs (checkbox, select, color swatch, range) commit
5
+ * immediately.
6
+ */
7
+
8
+ import { useState } from "react";
9
+ import type {
10
+ CompositionVariable,
11
+ ColorVariable,
12
+ EnumVariable,
13
+ NumberVariable,
14
+ } from "@hyperframes/sdk";
15
+
16
+ export const VARIABLES_INPUT_CLASS =
17
+ "w-full bg-neutral-900 border border-neutral-800 rounded px-2 py-1 text-[10px] text-neutral-200 focus:outline-none focus:border-neutral-700";
18
+
19
+ /** Text input that drafts locally and commits on blur/Enter. */
20
+ function DraftTextInput({
21
+ value,
22
+ onCommit,
23
+ type = "text",
24
+ className = VARIABLES_INPUT_CLASS,
25
+ maxLength,
26
+ placeholder,
27
+ min,
28
+ max,
29
+ step,
30
+ }: {
31
+ value: string;
32
+ onCommit: (raw: string) => void;
33
+ type?: "text" | "number";
34
+ className?: string;
35
+ maxLength?: number;
36
+ placeholder?: string;
37
+ min?: number;
38
+ max?: number;
39
+ step?: number;
40
+ }) {
41
+ const [draft, setDraft] = useState<string | null>(null);
42
+ return (
43
+ <input
44
+ type={type}
45
+ value={draft ?? value}
46
+ maxLength={maxLength}
47
+ placeholder={placeholder}
48
+ min={min}
49
+ max={max}
50
+ step={step}
51
+ onChange={(e) => setDraft(e.target.value)}
52
+ onBlur={() => {
53
+ if (draft !== null && draft !== value) onCommit(draft);
54
+ setDraft(null);
55
+ }}
56
+ onKeyDown={(e) => e.key === "Enter" && e.currentTarget.blur()}
57
+ className={className}
58
+ />
59
+ );
60
+ }
61
+
62
+ function EnumControl({
63
+ decl,
64
+ current,
65
+ onCommit,
66
+ }: {
67
+ decl: EnumVariable;
68
+ current: unknown;
69
+ onCommit: (value: unknown) => void;
70
+ }) {
71
+ return (
72
+ <select
73
+ value={String(current)}
74
+ onChange={(e) => onCommit(e.target.value)}
75
+ className={VARIABLES_INPUT_CLASS}
76
+ >
77
+ {decl.options.map((opt) => (
78
+ <option key={opt.value} value={opt.value}>
79
+ {opt.label}
80
+ </option>
81
+ ))}
82
+ </select>
83
+ );
84
+ }
85
+
86
+ function ColorControl({
87
+ current,
88
+ onCommit,
89
+ }: {
90
+ decl: ColorVariable;
91
+ current: unknown;
92
+ onCommit: (value: unknown) => void;
93
+ }) {
94
+ const colorValue = typeof current === "string" ? current : "#000000";
95
+ // The native picker fires change continuously while dragging the gradient;
96
+ // draft locally and commit once on close (blur) — each commit reloads the
97
+ // whole preview iframe.
98
+ const [draft, setDraft] = useState<string | null>(null);
99
+ return (
100
+ <div className="flex items-center gap-2">
101
+ <input
102
+ type="color"
103
+ value={draft ?? (/^#[0-9a-fA-F]{6}$/.test(colorValue) ? colorValue : "#000000")}
104
+ onChange={(e) => setDraft(e.target.value)}
105
+ onBlur={() => {
106
+ if (draft !== null && draft !== colorValue) onCommit(draft);
107
+ setDraft(null);
108
+ }}
109
+ className="h-6 w-6 cursor-pointer rounded border border-neutral-700 bg-transparent"
110
+ />
111
+ <DraftTextInput
112
+ value={colorValue}
113
+ onCommit={onCommit}
114
+ className={`${VARIABLES_INPUT_CLASS} flex-1 font-mono`}
115
+ />
116
+ </div>
117
+ );
118
+ }
119
+
120
+ // fallow-ignore-next-line complexity
121
+ function NumberControl({
122
+ decl,
123
+ current,
124
+ onCommit,
125
+ }: {
126
+ decl: NumberVariable;
127
+ current: unknown;
128
+ onCommit: (value: unknown) => void;
129
+ }) {
130
+ const numberValue = typeof current === "number" ? current : Number(current) || 0;
131
+ const hasRange = decl.min !== undefined && decl.max !== undefined;
132
+ // Drag ticks stay local; commit once on release — each commit reloads the
133
+ // whole preview iframe, so per-tick commits would thrash it.
134
+ const [dragValue, setDragValue] = useState<number | null>(null);
135
+ const commitDrag = () => {
136
+ if (dragValue !== null && dragValue !== numberValue) onCommit(dragValue);
137
+ setDragValue(null);
138
+ };
139
+ const commitRaw = (raw: string) => {
140
+ const n = Number(raw);
141
+ onCommit(Number.isFinite(n) ? n : raw);
142
+ };
143
+ return (
144
+ <div className="flex items-center gap-2">
145
+ {hasRange && (
146
+ <input
147
+ type="range"
148
+ min={decl.min}
149
+ max={decl.max}
150
+ step={decl.step ?? 1}
151
+ value={dragValue ?? numberValue}
152
+ onChange={(e) => setDragValue(Number(e.target.value))}
153
+ onPointerUp={commitDrag}
154
+ onKeyUp={commitDrag}
155
+ onBlur={commitDrag}
156
+ className="flex-1"
157
+ />
158
+ )}
159
+ <DraftTextInput
160
+ type="number"
161
+ value={String(numberValue)}
162
+ onCommit={commitRaw}
163
+ min={decl.min}
164
+ max={decl.max}
165
+ step={decl.step}
166
+ className={`${VARIABLES_INPUT_CLASS} ${hasRange ? "w-16" : "flex-1"} tabular-nums`}
167
+ />
168
+ {decl.unit && <span className="text-[9px] text-neutral-500">{decl.unit}</span>}
169
+ </div>
170
+ );
171
+ }
172
+
173
+ // Per-type dispatcher — one branch per variable type, same shape as BlockParamsPanel.
174
+ // fallow-ignore-next-line complexity
175
+ export function PreviewValueControl({
176
+ decl,
177
+ value,
178
+ onCommit,
179
+ }: {
180
+ decl: CompositionVariable;
181
+ value: unknown;
182
+ onCommit: (value: unknown) => void;
183
+ }) {
184
+ const current = value === undefined ? decl.default : value;
185
+
186
+ switch (decl.type) {
187
+ case "boolean":
188
+ return (
189
+ <input
190
+ type="checkbox"
191
+ checked={current === true}
192
+ onChange={(e) => onCommit(e.target.checked)}
193
+ className="h-3.5 w-3.5 accent-neutral-400"
194
+ />
195
+ );
196
+ case "enum":
197
+ return <EnumControl decl={decl} current={current} onCommit={onCommit} />;
198
+ case "color":
199
+ return <ColorControl decl={decl} current={current} onCommit={onCommit} />;
200
+ case "number":
201
+ return <NumberControl decl={decl} current={current} onCommit={onCommit} />;
202
+ default: {
203
+ // string / font (family name) / image (URL) — plain text input for v1.
204
+ const textValue = typeof current === "string" ? current : JSON.stringify(current);
205
+ return (
206
+ <DraftTextInput
207
+ value={textValue}
208
+ onCommit={onCommit}
209
+ maxLength={decl.type === "string" ? decl.maxLength : undefined}
210
+ placeholder={decl.type === "string" ? decl.placeholder : undefined}
211
+ />
212
+ );
213
+ }
214
+ }
215
+ }
@@ -34,6 +34,12 @@ export interface StartRenderOptions {
34
34
  resolution?: ResolutionPreset | "auto";
35
35
  /** Render a specific composition file instead of index.html. */
36
36
  composition?: string;
37
+ /**
38
+ * Composition-variable overrides ({variableId: value}), forwarded to the
39
+ * render route and injected as window.__hfVariables — the same channel
40
+ * `hyperframes render --variables` uses.
41
+ */
42
+ variables?: Record<string, unknown>;
37
43
  }
38
44
 
39
45
  // "Hide" (formerly "Clear") is a view operation, not a delete: hidden ids are
@@ -126,7 +132,9 @@ export function useRenderQueue(projectId: string | null) {
126
132
  }, [loadRenders]);
127
133
 
128
134
  // Start a render and track progress via SSE
135
+ // Pre-existing branchy fetch/poll flow — the variables passthrough added one branch.
129
136
  const startRender = useCallback(
137
+ // fallow-ignore-next-line complexity
130
138
  async (opts: StartRenderOptions = {}) => {
131
139
  if (!projectId) return;
132
140
 
@@ -154,6 +162,7 @@ export function useRenderQueue(projectId: string | null) {
154
162
  format: string;
155
163
  resolution?: string;
156
164
  composition?: string;
165
+ variables?: Record<string, unknown>;
157
166
  telemetryDistinctId: string;
158
167
  } = {
159
168
  fps,
@@ -166,6 +175,9 @@ export function useRenderQueue(projectId: string | null) {
166
175
  };
167
176
  if (resolution && resolution !== "auto") body.resolution = resolution;
168
177
  if (composition) body.composition = composition;
178
+ if (opts.variables && Object.keys(opts.variables).length > 0) {
179
+ body.variables = opts.variables;
180
+ }
169
181
  let res: Response;
170
182
  try {
171
183
  res = await fetch(`/api/projects/${projectId}/render`, {
@@ -10,7 +10,7 @@ export function useTimelineEditContext(): TimelineEditCallbacks {
10
10
  }
11
11
 
12
12
  /**
13
- * Optional access returns an empty object when outside a provider.
13
+ * Optional access, returns an empty object when outside a provider.
14
14
  * Useful in components that can render both inside and outside the NLE.
15
15
  */
16
16
  export function useTimelineEditContextOptional(): TimelineEditCallbacks {
@@ -26,13 +26,18 @@ export function TimelineEditProvider({
26
26
  }) {
27
27
  const memoized = useMemo(
28
28
  () => value,
29
- // Each callback is a stable reference from the parent memoize the bag
29
+ // Each callback is a stable reference from the parent, memoize the bag
30
30
  // so consumers don't re-render when unrelated parent state changes.
31
31
  // eslint-disable-next-line react-hooks/exhaustive-deps
32
32
  [
33
33
  value.onMoveElement,
34
34
  value.onResizeElement,
35
+ value.onMoveElements,
36
+ value.onResizeElements,
37
+ value.onPreviewMoveElements,
38
+ value.onPreviewResizeElements,
35
39
  value.onToggleTrackHidden,
40
+ value.onToggleElementHidden,
36
41
  value.onBlockedEditAttempt,
37
42
  value.onSplitElement,
38
43
  value.onRazorSplit,
@@ -0,0 +1,138 @@
1
+ /**
2
+ * Promote-to-variable from the Design panel. Provides the same bind gesture the
3
+ * Variables tab offers (declare a variable defaulting to the element's current
4
+ * value + write the declarative binding), surfaced contextually on individual
5
+ * property controls. A control asks about its channel (text / src / a style
6
+ * prop) and gets back: whether it can be promoted, whether it is already bound,
7
+ * and callbacks to promote or to edit the bound variable's default in place.
8
+ */
9
+
10
+ import { createContext, useContext, useEffect, useMemo, useState } from "react";
11
+ import type { Composition, CompositionVariable } from "@hyperframes/sdk";
12
+ import type { DomEditSelection } from "../components/editor/domEditingTypes";
13
+ import {
14
+ applyBind,
15
+ buildBindActions,
16
+ rgbToHex,
17
+ type BindAction,
18
+ } from "../components/panels/VariablesBindElement";
19
+ import {
20
+ matchAction,
21
+ readBindingFrom,
22
+ uniqueId,
23
+ type PromoteChannel,
24
+ } from "./variablePromoteHelpers";
25
+
26
+ export type { PromoteChannel };
27
+
28
+ export interface ChannelPromote {
29
+ /** The bind action for this channel, present only when the element supports it. */
30
+ action: BindAction | null;
31
+ /** Id of the variable this channel is already bound to, or null. */
32
+ boundId: string | null;
33
+ /** The bound variable's declaration (for its type + current default), if bound. */
34
+ declaration: CompositionVariable | null;
35
+ /** Declare a new variable (default = current value) and bind this channel to it. */
36
+ promote: () => void;
37
+ /** Update the bound variable's default value in place. */
38
+ setDefault: (value: string) => void;
39
+ }
40
+
41
+ interface VariablePromoteContextValue {
42
+ session: Composition | null;
43
+ selection: DomEditSelection | null;
44
+ actions: BindAction[];
45
+ declarations: CompositionVariable[];
46
+ persist: (label: string, mutate: (session: Composition) => void) => Promise<boolean>;
47
+ }
48
+
49
+ const VariablePromoteContext = createContext<VariablePromoteContextValue | null>(null);
50
+
51
+ function readBinding(session: Composition, hfId: string, channel: PromoteChannel): string | null {
52
+ const snapshot = session.getElement(hfId);
53
+ if (!snapshot) return null;
54
+ return readBindingFrom(snapshot, channel);
55
+ }
56
+
57
+ export function VariablePromoteProvider({
58
+ session,
59
+ selection,
60
+ persist,
61
+ children,
62
+ }: {
63
+ session: Composition | null;
64
+ selection: DomEditSelection | null;
65
+ persist: (label: string, mutate: (session: Composition) => void) => Promise<boolean>;
66
+ children: React.ReactNode;
67
+ }) {
68
+ // Re-derive actions/bindings after each persisted schema edit.
69
+ const [revision, setRevision] = useState(0);
70
+ useEffect(() => {
71
+ if (!session) return;
72
+ return session.on("change", () => setRevision((r) => r + 1));
73
+ }, [session]);
74
+
75
+ const actions = useMemo(() => {
76
+ if (!session || !selection) return [];
77
+ return buildBindActions(selection, session);
78
+ // eslint-disable-next-line react-hooks/exhaustive-deps
79
+ }, [session, selection, revision]);
80
+
81
+ const declarations = useMemo(() => {
82
+ if (!session) return [];
83
+ return session.getVariableDeclarations();
84
+ // eslint-disable-next-line react-hooks/exhaustive-deps
85
+ }, [session, revision]);
86
+
87
+ const value = useMemo<VariablePromoteContextValue>(
88
+ () => ({ session, selection, actions, declarations, persist }),
89
+ [session, selection, actions, declarations, persist],
90
+ );
91
+
92
+ return (
93
+ <VariablePromoteContext.Provider value={value}>{children}</VariablePromoteContext.Provider>
94
+ );
95
+ }
96
+
97
+ /**
98
+ * Binding state + actions for one control's channel. Returns null when there is
99
+ * no selection, no session, or the channel is neither bindable nor bound here —
100
+ * so a control renders plain unless promoting genuinely applies.
101
+ */
102
+ export function useVariablePromoteChannel(channel: PromoteChannel): ChannelPromote | null {
103
+ const ctx = useContext(VariablePromoteContext);
104
+ const key = channel.kind === "style" ? `style:${channel.prop}` : channel.kind;
105
+
106
+ return useMemo(() => {
107
+ if (!ctx || !ctx.session || !ctx.selection?.hfId) return null;
108
+ const { session, selection, actions, declarations, persist } = ctx;
109
+ const hfId = selection.hfId!;
110
+ const action = matchAction(actions, channel);
111
+ const boundId = readBinding(session, hfId, channel);
112
+ if (!action && !boundId) return null;
113
+ const declaration = boundId ? (declarations.find((d) => d.id === boundId) ?? null) : null;
114
+
115
+ return {
116
+ action,
117
+ boundId,
118
+ declaration,
119
+ promote: () => {
120
+ if (!action) return;
121
+ // Right-click promote auto-names, so always mint a fresh id — unlike the
122
+ // Variables-tab card, where the user types the id and may intentionally
123
+ // reuse one. Auto-binding to a colliding pre-existing variable here would
124
+ // silently couple two unrelated elements.
125
+ const id = uniqueId(action.suggestedId, declarations);
126
+ void persist(`Bind ${action.label.toLowerCase()} to variable "${id}"`, (s) =>
127
+ applyBind(s, hfId, action, id),
128
+ );
129
+ },
130
+ setDefault: (raw: string) => {
131
+ if (!boundId || !declaration) return;
132
+ const next = declaration.type === "color" ? rgbToHex(raw) : raw;
133
+ void persist(`Set default for "${boundId}"`, (s) => s.setVariableValue(boundId, next));
134
+ },
135
+ };
136
+ // eslint-disable-next-line react-hooks/exhaustive-deps
137
+ }, [ctx, key]);
138
+ }
@@ -0,0 +1,124 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import type { CompositionVariable } from "@hyperframes/sdk";
3
+ import type { BindAction } from "../components/panels/VariablesBindElement";
4
+ import {
5
+ matchAction,
6
+ parseVarId,
7
+ readBindingFrom,
8
+ toCamel,
9
+ uniqueId,
10
+ type PromoteChannel,
11
+ } from "./variablePromoteHelpers";
12
+
13
+ function action(kind: BindAction["kind"], styleProp?: string): BindAction {
14
+ return {
15
+ key: `${kind}:${styleProp ?? ""}`,
16
+ label: kind,
17
+ kind,
18
+ styleProp,
19
+ suggestedId: "x",
20
+ declaration: (id) => ({ id, type: "string", label: id, default: "" }),
21
+ };
22
+ }
23
+
24
+ function decl(id: string): CompositionVariable {
25
+ return { id, type: "string", label: id, default: "" };
26
+ }
27
+
28
+ describe("toCamel", () => {
29
+ it("hyphenates CSS props to camelCase", () => {
30
+ expect(toCamel("font-family")).toBe("fontFamily");
31
+ expect(toCamel("background-color")).toBe("backgroundColor");
32
+ expect(toCamel("color")).toBe("color");
33
+ });
34
+ });
35
+
36
+ describe("parseVarId", () => {
37
+ it("extracts the id from a var() reference", () => {
38
+ expect(parseVarId("var(--headline-color)")).toBe("headline-color");
39
+ expect(parseVarId(" var(--brand_1) ")).toBe("brand_1");
40
+ });
41
+ it("tolerates a simple fallback value and !important", () => {
42
+ expect(parseVarId("var(--accent, #000)")).toBe("accent");
43
+ expect(parseVarId("var(--accent, transparent)")).toBe("accent");
44
+ expect(parseVarId("var(--accent) !important")).toBe("accent");
45
+ });
46
+ it("does not parse a nested-paren fallback (degrades to unbound, not a wrong id)", () => {
47
+ // Rare hand-authored case; regex can't balance parens. Returning null means
48
+ // the control shows as unbound rather than binding to a wrong id.
49
+ expect(parseVarId("var(--accent, rgb(0,0,0))")).toBeNull();
50
+ });
51
+ it("returns null for non-var values", () => {
52
+ expect(parseVarId("#00c3ff")).toBeNull();
53
+ expect(parseVarId("rgb(0,0,0)")).toBeNull();
54
+ expect(parseVarId(undefined)).toBeNull();
55
+ expect(parseVarId("")).toBeNull();
56
+ });
57
+ });
58
+
59
+ describe("readBindingFrom", () => {
60
+ const channels: Record<string, PromoteChannel> = {
61
+ text: { kind: "text" },
62
+ src: { kind: "src" },
63
+ color: { kind: "style", prop: "color" },
64
+ font: { kind: "style", prop: "font-family" },
65
+ };
66
+
67
+ it("reads data-var-text / data-var-src from attributes", () => {
68
+ const src = {
69
+ attributes: { "data-var-text": "title", "data-var-src": "logo" },
70
+ inlineStyles: {},
71
+ };
72
+ expect(readBindingFrom(src, channels.text)).toBe("title");
73
+ expect(readBindingFrom(src, channels.src)).toBe("logo");
74
+ });
75
+
76
+ it("reads a style-prop binding from inlineStyles var()", () => {
77
+ const src = {
78
+ attributes: {},
79
+ inlineStyles: { color: "var(--accent)", fontFamily: "var(--brand-font)" },
80
+ };
81
+ expect(readBindingFrom(src, channels.color)).toBe("accent");
82
+ expect(readBindingFrom(src, channels.font)).toBe("brand-font");
83
+ });
84
+
85
+ it("returns null when the channel is not bound", () => {
86
+ const src = { attributes: {}, inlineStyles: { color: "#fff" } };
87
+ expect(readBindingFrom(src, channels.text)).toBeNull();
88
+ expect(readBindingFrom(src, channels.color)).toBeNull();
89
+ });
90
+ });
91
+
92
+ describe("matchAction", () => {
93
+ const actions = [
94
+ action("text"),
95
+ action("src"),
96
+ action("style", "color"),
97
+ action("style", "font-family"),
98
+ ];
99
+
100
+ it("matches text/src by kind", () => {
101
+ expect(matchAction(actions, { kind: "text" })?.kind).toBe("text");
102
+ expect(matchAction(actions, { kind: "src" })?.kind).toBe("src");
103
+ });
104
+ it("matches a style channel by its prop", () => {
105
+ expect(matchAction(actions, { kind: "style", prop: "color" })?.styleProp).toBe("color");
106
+ expect(matchAction(actions, { kind: "style", prop: "font-family" })?.styleProp).toBe(
107
+ "font-family",
108
+ );
109
+ });
110
+ it("returns null when no action fits the channel", () => {
111
+ expect(matchAction(actions, { kind: "style", prop: "background-color" })).toBeNull();
112
+ expect(matchAction([], { kind: "text" })).toBeNull();
113
+ });
114
+ });
115
+
116
+ describe("uniqueId", () => {
117
+ it("returns the base when free", () => {
118
+ expect(uniqueId("headline", [decl("other")])).toBe("headline");
119
+ });
120
+ it("suffixes to avoid collisions", () => {
121
+ expect(uniqueId("headline", [decl("headline")])).toBe("headline-2");
122
+ expect(uniqueId("headline", [decl("headline"), decl("headline-2")])).toBe("headline-3");
123
+ });
124
+ });
@@ -0,0 +1,53 @@
1
+ /**
2
+ * Pure helpers for promote-to-variable from the Design panel. Kept free of React
3
+ * and the SDK session so the binding-detection and id logic can be unit-tested.
4
+ */
5
+
6
+ import type { CompositionVariable } from "@hyperframes/sdk";
7
+ import type { BindAction } from "../components/panels/VariablesBindElement";
8
+
9
+ export type PromoteChannel = { kind: "text" } | { kind: "src" } | { kind: "style"; prop: string };
10
+
11
+ /** Minimal element shape needed to read a binding — mirrors the SDK snapshot. */
12
+ export interface BindingSource {
13
+ attributes: Readonly<Record<string, string>>;
14
+ inlineStyles: Readonly<Record<string, string>>;
15
+ }
16
+
17
+ /** "font-family" → "fontFamily" to index inlineStyles (camelCase, per SDK convention). */
18
+ export function toCamel(prop: string): string {
19
+ return prop.replace(/-([a-z])/g, (_, c: string) => c.toUpperCase());
20
+ }
21
+
22
+ /**
23
+ * "var(--headline-color)" → "headline-color". Tolerates a fallback
24
+ * ("var(--id, #fff)") and a trailing "!important"; anything else → null.
25
+ */
26
+ export function parseVarId(value: string | undefined): string | null {
27
+ if (!value) return null;
28
+ const m = /^var\(\s*--([A-Za-z0-9_-]+)\s*(?:,[^)]*)?\)\s*(?:!important)?\s*$/.exec(value.trim());
29
+ return m ? m[1] : null;
30
+ }
31
+
32
+ export function matchAction(actions: BindAction[], channel: PromoteChannel): BindAction | null {
33
+ if (channel.kind === "style") {
34
+ return actions.find((a) => a.kind === "style" && a.styleProp === channel.prop) ?? null;
35
+ }
36
+ return actions.find((a) => a.kind === channel.kind) ?? null;
37
+ }
38
+
39
+ /** Id of the variable this channel is bound to on the element, or null. */
40
+ export function readBindingFrom(source: BindingSource, channel: PromoteChannel): string | null {
41
+ if (channel.kind === "text") return source.attributes["data-var-text"] ?? null;
42
+ if (channel.kind === "src") return source.attributes["data-var-src"] ?? null;
43
+ return parseVarId(source.inlineStyles[toCamel(channel.prop)]);
44
+ }
45
+
46
+ /** Unique id from a suggested base, avoiding collisions with existing declarations. */
47
+ export function uniqueId(base: string, existing: CompositionVariable[]): string {
48
+ const taken = new Set(existing.map((d) => d.id));
49
+ if (!taken.has(base)) return base;
50
+ let n = 2;
51
+ while (taken.has(`${base}-${n}`)) n += 1;
52
+ return `${base}-${n}`;
53
+ }
@@ -0,0 +1,80 @@
1
+ // @vitest-environment happy-dom
2
+ /**
3
+ * Round-trip proof for Design-panel promote-to-variable: a bind written by
4
+ * applyBind against a real SDK session must be detected by readBindingFrom off
5
+ * the resulting element snapshot — the same read the Design controls rely on to
6
+ * show bound state.
7
+ */
8
+ import { describe, expect, it } from "vitest";
9
+ import { openComposition } from "@hyperframes/sdk";
10
+ import { createMemoryAdapter } from "@hyperframes/sdk/adapters/memory";
11
+ import { applyBind, type BindAction } from "../components/panels/VariablesBindElement";
12
+ import { readBindingFrom } from "./variablePromoteHelpers";
13
+
14
+ const HTML = /* html */ `<!DOCTYPE html>
15
+ <html>
16
+ <body>
17
+ <div data-hf-id="hf-title" style="color: rgb(255, 0, 0)">Hello</div>
18
+ <img data-hf-id="hf-logo" src="logo.png" />
19
+ </body>
20
+ </html>`;
21
+
22
+ function open() {
23
+ return openComposition(HTML, { persist: createMemoryAdapter() });
24
+ }
25
+
26
+ const styleAction: BindAction = {
27
+ key: "color",
28
+ label: "Text color",
29
+ kind: "style",
30
+ styleProp: "color",
31
+ suggestedId: "title-color",
32
+ declaration: (id) => ({ id, type: "color", label: "Title color", default: "#ff0000" }),
33
+ };
34
+
35
+ const textAction: BindAction = {
36
+ key: "text",
37
+ label: "Text",
38
+ kind: "text",
39
+ suggestedId: "title-text",
40
+ declaration: (id) => ({ id, type: "string", label: "Title text", default: "Hello" }),
41
+ };
42
+
43
+ const srcAction: BindAction = {
44
+ key: "src",
45
+ label: "Image source",
46
+ kind: "src",
47
+ suggestedId: "logo",
48
+ declaration: (id) => ({ id, type: "image", label: "Logo", default: "logo.png" }),
49
+ };
50
+
51
+ describe("promote round-trip", () => {
52
+ it("style bind writes var() and reads back the id", async () => {
53
+ const comp = await open();
54
+ applyBind(comp, "hf-title", styleAction, "title-color");
55
+ const snap = comp.getElement("hf-title")!;
56
+ expect(readBindingFrom(snap, { kind: "style", prop: "color" })).toBe("title-color");
57
+ expect(comp.getVariableDeclarations().some((d) => d.id === "title-color")).toBe(true);
58
+ });
59
+
60
+ it("text bind writes data-var-text and reads back the id", async () => {
61
+ const comp = await open();
62
+ applyBind(comp, "hf-title", textAction, "title-text");
63
+ const snap = comp.getElement("hf-title")!;
64
+ expect(readBindingFrom(snap, { kind: "text" })).toBe("title-text");
65
+ });
66
+
67
+ it("src bind writes data-var-src and reads back the id", async () => {
68
+ const comp = await open();
69
+ applyBind(comp, "hf-logo", srcAction, "logo");
70
+ const snap = comp.getElement("hf-logo")!;
71
+ expect(readBindingFrom(snap, { kind: "src" })).toBe("logo");
72
+ });
73
+
74
+ it("does not report a binding before promote", async () => {
75
+ const comp = await open();
76
+ const snap = comp.getElement("hf-title")!;
77
+ expect(readBindingFrom(snap, { kind: "style", prop: "color" })).toBeNull();
78
+ expect(readBindingFrom(snap, { kind: "text" })).toBeNull();
79
+ });
80
+ });