feat(studio): promote to variable from the design panel

This commit is contained in:
James
2026-07-09 13:31:03 -07:00
parent 267b289bb8
commit 7c144ecc30
9 changed files with 647 additions and 80 deletions
@@ -0,0 +1,31 @@
/**
* Wires the Design panel's promote-to-variable context: instantiates the
* single-writer variables persist path and feeds it to VariablePromoteProvider,
* so schema edits from Design-panel controls (declare + bind, or edit a bound
* variable's default) flow through the same path the Variables tab uses.
*/
import type { ReactNode } from "react";
import type { DomEditSelection } from "./editor/domEditingTypes";
import { useVariablesPersist, type UseVariablesPersistParams } from "../hooks/useVariablesPersist";
import { VariablePromoteProvider } from "../contexts/VariablePromoteContext";
export function DesignPanelPromoteProvider({
selection,
children,
...persistParams
}: UseVariablesPersistParams & {
selection: DomEditSelection | null;
children: ReactNode;
}) {
const persist = useVariablesPersist(persistParams);
return (
<VariablePromoteProvider
session={persistParams.sdkSession}
selection={selection}
persist={persist}
>
{children}
</VariablePromoteProvider>
);
}
@@ -24,6 +24,7 @@ import { STUDIO_INSPECTOR_PANELS_ENABLED } from "./editor/manualEditingAvailabil
import type { Composition } from "@hyperframes/sdk";
import type { EditHistoryKind } from "../utils/editHistory";
import { useSlideshowPersist } from "../hooks/useSlideshowPersist";
import { DesignPanelPromoteProvider } from "./DesignPanelPromoteProvider";
import { useStudioPlaybackContext, useStudioShellContext } from "../contexts/StudioContext";
import { usePanelLayoutContext } from "../contexts/PanelLayoutContext";
@@ -342,63 +343,74 @@ export function StudioRightPanel({
);
const propertyPanel = (
<PropertyPanel
projectId={projectId}
projectDir={projectDir}
assets={assets}
element={domEditGroupSelections.length > 1 ? null : domEditSelection}
multiSelectCount={domEditGroupSelections.length}
copiedAgentPrompt={copiedAgentPrompt}
onClearSelection={clearDomSelection}
onToggleElementHidden={onToggleElementHidden}
onUngroup={handleUngroupSelection}
onSetStyle={handleDomStyleCommit}
onSetAttribute={handleDomAttributeCommit}
onSetAttributeLive={handleDomAttributeLiveCommit}
onApplyColorGradingScope={handleApplyColorGradingScope}
onSetHtmlAttribute={handleDomHtmlAttributeCommit}
onRemoveBackground={handleRemoveBackground}
onSetManualOffset={handleDomPathOffsetCommit}
onSetManualSize={handleDomBoxSizeCommit}
onSetManualRotation={handleDomRotationCommit}
onSetText={handleDomTextCommit}
onSetTextFieldStyle={handleDomTextFieldStyleCommit}
onAddTextField={handleDomAddTextField}
onRemoveTextField={handleDomRemoveTextField}
onAskAgent={handleAskAgent}
onImportAssets={handleImportFiles}
fontAssets={fontAssets}
onImportFonts={handleImportFonts}
previewIframeRef={previewIframeRef}
gsapAnimations={selectedGsapAnimations}
gsapMultipleTimelines={gsapMultipleTimelines}
gsapUnsupportedTimelinePattern={gsapUnsupportedTimelinePattern}
onUpdateGsapProperty={handleGsapUpdateProperty}
onUpdateGsapMeta={handleGsapUpdateMeta}
onDeleteGsapAnimation={handleGsapDeleteAnimation}
onAddGsapProperty={handleGsapAddProperty}
onRemoveGsapProperty={handleGsapRemoveProperty}
onUpdateGsapFromProperty={handleGsapUpdateFromProperty}
onAddGsapFromProperty={handleGsapAddFromProperty}
onRemoveGsapFromProperty={handleGsapRemoveFromProperty}
onAddGsapAnimation={handleGsapAddAnimation}
onCommitAnimatedProperty={commitAnimatedProperty}
onCommitAnimatedProperties={commitAnimatedProperties}
onAddKeyframe={handleGsapAddKeyframe}
onRemoveKeyframe={handleGsapRemoveKeyframe}
onConvertToKeyframes={(animId, duration) =>
handleGsapConvertToKeyframes(animId, undefined, duration)
}
onSeekToTime={(t) => usePlayerStore.getState().requestSeek(t)}
onSetArcPath={handleSetArcPath}
onUpdateArcSegment={handleUpdateArcSegment}
onUnroll={handleUnroll}
onUpdateKeyframeEase={handleUpdateKeyframeEase}
onSetAllKeyframeEases={handleSetAllKeyframeEases}
recordingState={recordingState}
recordingDuration={recordingDuration}
onToggleRecording={onToggleRecording}
/>
<DesignPanelPromoteProvider
selection={domEditGroupSelections.length > 1 ? null : domEditSelection}
sdkSession={sdkSession}
activeCompPath={activeCompPath}
readProjectFile={readProjectFile}
writeProjectFile={writeProjectFile}
recordEdit={recordEdit}
reloadPreview={reloadPreview}
domEditSaveTimestampRef={domEditSaveTimestampRef}
>
<PropertyPanel
projectId={projectId}
projectDir={projectDir}
assets={assets}
element={domEditGroupSelections.length > 1 ? null : domEditSelection}
multiSelectCount={domEditGroupSelections.length}
copiedAgentPrompt={copiedAgentPrompt}
onClearSelection={clearDomSelection}
onToggleElementHidden={onToggleElementHidden}
onUngroup={handleUngroupSelection}
onSetStyle={handleDomStyleCommit}
onSetAttribute={handleDomAttributeCommit}
onSetAttributeLive={handleDomAttributeLiveCommit}
onApplyColorGradingScope={handleApplyColorGradingScope}
onSetHtmlAttribute={handleDomHtmlAttributeCommit}
onRemoveBackground={handleRemoveBackground}
onSetManualOffset={handleDomPathOffsetCommit}
onSetManualSize={handleDomBoxSizeCommit}
onSetManualRotation={handleDomRotationCommit}
onSetText={handleDomTextCommit}
onSetTextFieldStyle={handleDomTextFieldStyleCommit}
onAddTextField={handleDomAddTextField}
onRemoveTextField={handleDomRemoveTextField}
onAskAgent={handleAskAgent}
onImportAssets={handleImportFiles}
fontAssets={fontAssets}
onImportFonts={handleImportFonts}
previewIframeRef={previewIframeRef}
gsapAnimations={selectedGsapAnimations}
gsapMultipleTimelines={gsapMultipleTimelines}
gsapUnsupportedTimelinePattern={gsapUnsupportedTimelinePattern}
onUpdateGsapProperty={handleGsapUpdateProperty}
onUpdateGsapMeta={handleGsapUpdateMeta}
onDeleteGsapAnimation={handleGsapDeleteAnimation}
onAddGsapProperty={handleGsapAddProperty}
onRemoveGsapProperty={handleGsapRemoveProperty}
onUpdateGsapFromProperty={handleGsapUpdateFromProperty}
onAddGsapFromProperty={handleGsapAddFromProperty}
onRemoveGsapFromProperty={handleGsapRemoveFromProperty}
onAddGsapAnimation={handleGsapAddAnimation}
onCommitAnimatedProperty={commitAnimatedProperty}
onCommitAnimatedProperties={commitAnimatedProperties}
onAddKeyframe={handleGsapAddKeyframe}
onRemoveKeyframe={handleGsapRemoveKeyframe}
onConvertToKeyframes={(animId, duration) =>
handleGsapConvertToKeyframes(animId, undefined, duration)
}
onSeekToTime={(t) => usePlayerStore.getState().requestSeek(t)}
onSetArcPath={handleSetArcPath}
onUpdateArcSegment={handleUpdateArcSegment}
onUnroll={handleUnroll}
onUpdateKeyframeEase={handleUpdateKeyframeEase}
onSetAllKeyframeEases={handleSetAllKeyframeEases}
recordingState={recordingState}
recordingDuration={recordingDuration}
onToggleRecording={onToggleRecording}
/>
</DesignPanelPromoteProvider>
);
const renderQueuePanel = (
@@ -0,0 +1,105 @@
/**
* Wraps a Design-panel property control with the promote-to-variable gesture.
* When a control can be promoted it shows a visible "◇ var" button; clicking it
* declares a variable (default = current value, so the render is unchanged) and
* binds this property to it. Once bound, the button is replaced by a "◆ {id}"
* chip and edits route to the variable's default (edit-in-place). Controls that
* aren't eligible (or render outside a promote context, or are disabled by the
* caller) pass through untouched. Uses a render-prop so each control keeps its
* own value/onCommit shape.
*/
import { useEffect } from "react";
import {
useVariablePromoteChannel,
type PromoteChannel,
} from "../../contexts/VariablePromoteContext";
interface RenderArgs {
/** When bound, the variable's default to display; otherwise undefined. */
value?: string;
/** When bound, routes commits to the variable default; otherwise undefined. */
onCommit?: (value: string) => void;
bound: boolean;
}
export function PromotableControl({
channel,
enabled = true,
children,
}: {
channel: PromoteChannel;
/**
* Caller-side gate. Text-section controls only promote when the edited field
* is the selected element's OWN text (source "self"); binding a child/text-
* node field would target a different element than the control edits.
*/
enabled?: boolean;
children: (args: RenderArgs) => React.ReactNode;
}) {
const promote = useVariablePromoteChannel(channel);
// A binding attribute (`data-var-*` / `var(--id)`) pointing at a declaration
// that no longer exists renders as a plain unbound control — a silent
// fallback that leaves a dev wondering why "their binding isn't showing".
// Surface it in the console so the dangling reference is discoverable.
const danglingId =
enabled && promote && promote.boundId != null && promote.declaration == null
? promote.boundId
: null;
useEffect(() => {
if (danglingId != null) {
console.warn(
`[hyperframes] Control is bound to variable "${danglingId}", but no such declaration exists. The element still carries the binding on disk — re-declare the variable or unbind the element.`,
);
}
}, [danglingId]);
if (!promote || !enabled) return <>{children({ bound: false })}</>;
// A binding whose declaration was removed elsewhere is dangling: don't show
// it as an editable bound control (setDefault would silently no-op) — let it
// fall back to a plain, re-promotable control.
const bound = promote.boundId != null && promote.declaration != null;
const canPromote = promote.action != null && !bound;
const defaultValue = promote.declaration?.default;
const rendered = children(
bound
? {
// Only string defaults render inline; a FontValue/ImageValue object
// falls back to the element's real value instead of "[object Object]".
value: typeof defaultValue === "string" ? defaultValue : undefined,
onCommit: promote.setDefault,
bound: true,
}
: { bound: false },
);
return (
<div className={`relative ${bound ? "rounded-lg ring-1 ring-studio-accent/40" : ""}`}>
{rendered}
{bound && (
<span
className="pointer-events-none absolute right-1.5 top-0 z-10 inline-flex max-w-[60%] items-center gap-1 truncate rounded bg-studio-accent/20 px-1 py-px font-mono text-[8px] font-medium text-studio-accent"
title={`Bound to variable "${promote.boundId}"`}
>
{promote.boundId}
</span>
)}
{canPromote && (
<button
type="button"
title="Make this a variable"
onClick={(e) => {
e.stopPropagation();
promote.promote();
}}
className="absolute right-1.5 top-0 z-10 inline-flex items-center gap-1 rounded bg-neutral-800/80 px-1 py-px font-mono text-[8px] font-medium text-neutral-400 opacity-70 transition-colors hover:bg-studio-accent/20 hover:text-studio-accent hover:opacity-100"
>
var
</button>
)}
</div>
);
}
@@ -6,6 +6,7 @@ import { FIELD, LABEL, normalizeTextMetricValue, RESPONSIVE_GRID } from "./prope
import { MetricField, Section, SelectField } from "./propertyPanelPrimitives";
import { ColorField } from "./propertyPanelColor";
import { FontFamilyField } from "./propertyPanelFont";
import { PromotableControl } from "./PromotableControl";
/* ------------------------------------------------------------------ */
/* Text helpers (used only by text section components) */
@@ -280,20 +281,31 @@ function TextFieldEditor({
</button>
)}
</div>
<TextAreaField
key={field.key}
label="Content"
value={field.value}
disabled={false}
autoFocus={showRemove}
onCommit={(next) => onSetText(next, field.key)}
/>
<ColorField
label="Text color"
value={getTextFieldColor(field, styles)}
disabled={false}
onCommit={(next) => onSetTextFieldStyle(field.key, "color", next)}
/>
<PromotableControl channel={{ kind: "text" }} enabled={field.source === "self"}>
{({ value, onCommit }) => (
<TextAreaField
key={field.key}
label="Content"
value={value ?? field.value}
disabled={false}
autoFocus={showRemove}
onCommit={onCommit ?? ((next) => onSetText(next, field.key))}
/>
)}
</PromotableControl>
<PromotableControl
channel={{ kind: "style", prop: "color" }}
enabled={field.source === "self"}
>
{({ value, onCommit }) => (
<ColorField
label="Text color"
value={value ?? getTextFieldColor(field, styles)}
disabled={false}
onCommit={onCommit ?? ((next) => onSetTextFieldStyle(field.key, "color", next))}
/>
)}
</PromotableControl>
<div className={RESPONSIVE_GRID}>
<MetricField
label="Size"
@@ -309,13 +321,22 @@ function TextFieldEditor({
onCommit={(next) => onSetTextFieldStyle(field.key, "font-weight", next)}
/>
</div>
<FontFamilyField
value={field.computedStyles["font-family"] || styles["font-family"] || "inherit"}
disabled={false}
importedFonts={fontAssets}
onImportFonts={onImportFonts}
onCommit={(next) => onSetTextFieldStyle(field.key, "font-family", next)}
/>
<PromotableControl
channel={{ kind: "style", prop: "font-family" }}
enabled={field.source === "self"}
>
{({ value, onCommit }) => (
<FontFamilyField
value={
value ?? (field.computedStyles["font-family"] || styles["font-family"] || "inherit")
}
disabled={false}
importedFonts={fontAssets}
onImportFonts={onImportFonts}
onCommit={onCommit ?? ((next) => onSetTextFieldStyle(field.key, "font-family", next))}
/>
)}
</PromotableControl>
<AdvancedTextControls
field={field}
inheritedStyles={styles}
@@ -43,7 +43,7 @@ function sanitizeId(raw: string): string {
* #000000, which the picker can at least display. Unrecognized formats pass
* through verbatim.
*/
function rgbToHex(value: string): string {
export function rgbToHex(value: string): string {
const m = /^rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*(?:,\s*[\d.]+\s*)?\)$/.exec(value);
if (!m) return value;
return `#${m
@@ -58,7 +58,10 @@ function firstFontFamily(value: string): string {
}
// fallow-ignore-next-line complexity
function buildBindActions(selection: DomEditSelection, sdkSession: Composition): BindAction[] {
export function buildBindActions(
selection: DomEditSelection,
sdkSession: Composition,
): BindAction[] {
const hfId = selection.hfId;
if (!hfId) return [];
// No snapshot = the session can't resolve this element (e.g. a sub-comp
@@ -0,0 +1,138 @@
/**
* Promote-to-variable from the Design panel. Provides the same bind gesture the
* Variables tab offers (declare a variable defaulting to the element's current
* value + write the declarative binding), surfaced contextually on individual
* property controls. A control asks about its channel (text / src / a style
* prop) and gets back: whether it can be promoted, whether it is already bound,
* and callbacks to promote or to edit the bound variable's default in place.
*/
import { createContext, useContext, useEffect, useMemo, useState } from "react";
import type { Composition, CompositionVariable } from "@hyperframes/sdk";
import type { DomEditSelection } from "../components/editor/domEditingTypes";
import {
applyBind,
buildBindActions,
rgbToHex,
type BindAction,
} from "../components/panels/VariablesBindElement";
import {
matchAction,
readBindingFrom,
uniqueId,
type PromoteChannel,
} from "./variablePromoteHelpers";
export type { PromoteChannel };
export interface ChannelPromote {
/** The bind action for this channel, present only when the element supports it. */
action: BindAction | null;
/** Id of the variable this channel is already bound to, or null. */
boundId: string | null;
/** The bound variable's declaration (for its type + current default), if bound. */
declaration: CompositionVariable | null;
/** Declare a new variable (default = current value) and bind this channel to it. */
promote: () => void;
/** Update the bound variable's default value in place. */
setDefault: (value: string) => void;
}
interface VariablePromoteContextValue {
session: Composition | null;
selection: DomEditSelection | null;
actions: BindAction[];
declarations: CompositionVariable[];
persist: (label: string, mutate: (session: Composition) => void) => Promise<boolean>;
}
const VariablePromoteContext = createContext<VariablePromoteContextValue | null>(null);
function readBinding(session: Composition, hfId: string, channel: PromoteChannel): string | null {
const snapshot = session.getElement(hfId);
if (!snapshot) return null;
return readBindingFrom(snapshot, channel);
}
export function VariablePromoteProvider({
session,
selection,
persist,
children,
}: {
session: Composition | null;
selection: DomEditSelection | null;
persist: (label: string, mutate: (session: Composition) => void) => Promise<boolean>;
children: React.ReactNode;
}) {
// Re-derive actions/bindings after each persisted schema edit.
const [revision, setRevision] = useState(0);
useEffect(() => {
if (!session) return;
return session.on("change", () => setRevision((r) => r + 1));
}, [session]);
const actions = useMemo(() => {
if (!session || !selection) return [];
return buildBindActions(selection, session);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [session, selection, revision]);
const declarations = useMemo(() => {
if (!session) return [];
return session.getVariableDeclarations();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [session, revision]);
const value = useMemo<VariablePromoteContextValue>(
() => ({ session, selection, actions, declarations, persist }),
[session, selection, actions, declarations, persist],
);
return (
<VariablePromoteContext.Provider value={value}>{children}</VariablePromoteContext.Provider>
);
}
/**
* Binding state + actions for one control's channel. Returns null when there is
* no selection, no session, or the channel is neither bindable nor bound here —
* so a control renders plain unless promoting genuinely applies.
*/
export function useVariablePromoteChannel(channel: PromoteChannel): ChannelPromote | null {
const ctx = useContext(VariablePromoteContext);
const key = channel.kind === "style" ? `style:${channel.prop}` : channel.kind;
return useMemo(() => {
if (!ctx || !ctx.session || !ctx.selection?.hfId) return null;
const { session, selection, actions, declarations, persist } = ctx;
const hfId = selection.hfId!;
const action = matchAction(actions, channel);
const boundId = readBinding(session, hfId, channel);
if (!action && !boundId) return null;
const declaration = boundId ? (declarations.find((d) => d.id === boundId) ?? null) : null;
return {
action,
boundId,
declaration,
promote: () => {
if (!action) return;
// Right-click promote auto-names, so always mint a fresh id — unlike the
// Variables-tab card, where the user types the id and may intentionally
// reuse one. Auto-binding to a colliding pre-existing variable here would
// silently couple two unrelated elements.
const id = uniqueId(action.suggestedId, declarations);
void persist(`Bind ${action.label.toLowerCase()} to variable "${id}"`, (s) =>
applyBind(s, hfId, action, id),
);
},
setDefault: (raw: string) => {
if (!boundId || !declaration) return;
const next = declaration.type === "color" ? rgbToHex(raw) : raw;
void persist(`Set default for "${boundId}"`, (s) => s.setVariableValue(boundId, next));
},
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [ctx, key]);
}
@@ -0,0 +1,124 @@
import { describe, expect, it } from "vitest";
import type { CompositionVariable } from "@hyperframes/sdk";
import type { BindAction } from "../components/panels/VariablesBindElement";
import {
matchAction,
parseVarId,
readBindingFrom,
toCamel,
uniqueId,
type PromoteChannel,
} from "./variablePromoteHelpers";
function action(kind: BindAction["kind"], styleProp?: string): BindAction {
return {
key: `${kind}:${styleProp ?? ""}`,
label: kind,
kind,
styleProp,
suggestedId: "x",
declaration: (id) => ({ id, type: "string", label: id, default: "" }),
};
}
function decl(id: string): CompositionVariable {
return { id, type: "string", label: id, default: "" };
}
describe("toCamel", () => {
it("hyphenates CSS props to camelCase", () => {
expect(toCamel("font-family")).toBe("fontFamily");
expect(toCamel("background-color")).toBe("backgroundColor");
expect(toCamel("color")).toBe("color");
});
});
describe("parseVarId", () => {
it("extracts the id from a var() reference", () => {
expect(parseVarId("var(--headline-color)")).toBe("headline-color");
expect(parseVarId(" var(--brand_1) ")).toBe("brand_1");
});
it("tolerates a simple fallback value and !important", () => {
expect(parseVarId("var(--accent, #000)")).toBe("accent");
expect(parseVarId("var(--accent, transparent)")).toBe("accent");
expect(parseVarId("var(--accent) !important")).toBe("accent");
});
it("does not parse a nested-paren fallback (degrades to unbound, not a wrong id)", () => {
// Rare hand-authored case; regex can't balance parens. Returning null means
// the control shows as unbound rather than binding to a wrong id.
expect(parseVarId("var(--accent, rgb(0,0,0))")).toBeNull();
});
it("returns null for non-var values", () => {
expect(parseVarId("#00c3ff")).toBeNull();
expect(parseVarId("rgb(0,0,0)")).toBeNull();
expect(parseVarId(undefined)).toBeNull();
expect(parseVarId("")).toBeNull();
});
});
describe("readBindingFrom", () => {
const channels: Record<string, PromoteChannel> = {
text: { kind: "text" },
src: { kind: "src" },
color: { kind: "style", prop: "color" },
font: { kind: "style", prop: "font-family" },
};
it("reads data-var-text / data-var-src from attributes", () => {
const src = {
attributes: { "data-var-text": "title", "data-var-src": "logo" },
inlineStyles: {},
};
expect(readBindingFrom(src, channels.text)).toBe("title");
expect(readBindingFrom(src, channels.src)).toBe("logo");
});
it("reads a style-prop binding from inlineStyles var()", () => {
const src = {
attributes: {},
inlineStyles: { color: "var(--accent)", fontFamily: "var(--brand-font)" },
};
expect(readBindingFrom(src, channels.color)).toBe("accent");
expect(readBindingFrom(src, channels.font)).toBe("brand-font");
});
it("returns null when the channel is not bound", () => {
const src = { attributes: {}, inlineStyles: { color: "#fff" } };
expect(readBindingFrom(src, channels.text)).toBeNull();
expect(readBindingFrom(src, channels.color)).toBeNull();
});
});
describe("matchAction", () => {
const actions = [
action("text"),
action("src"),
action("style", "color"),
action("style", "font-family"),
];
it("matches text/src by kind", () => {
expect(matchAction(actions, { kind: "text" })?.kind).toBe("text");
expect(matchAction(actions, { kind: "src" })?.kind).toBe("src");
});
it("matches a style channel by its prop", () => {
expect(matchAction(actions, { kind: "style", prop: "color" })?.styleProp).toBe("color");
expect(matchAction(actions, { kind: "style", prop: "font-family" })?.styleProp).toBe(
"font-family",
);
});
it("returns null when no action fits the channel", () => {
expect(matchAction(actions, { kind: "style", prop: "background-color" })).toBeNull();
expect(matchAction([], { kind: "text" })).toBeNull();
});
});
describe("uniqueId", () => {
it("returns the base when free", () => {
expect(uniqueId("headline", [decl("other")])).toBe("headline");
});
it("suffixes to avoid collisions", () => {
expect(uniqueId("headline", [decl("headline")])).toBe("headline-2");
expect(uniqueId("headline", [decl("headline"), decl("headline-2")])).toBe("headline-3");
});
});
@@ -0,0 +1,53 @@
/**
* Pure helpers for promote-to-variable from the Design panel. Kept free of React
* and the SDK session so the binding-detection and id logic can be unit-tested.
*/
import type { CompositionVariable } from "@hyperframes/sdk";
import type { BindAction } from "../components/panels/VariablesBindElement";
export type PromoteChannel = { kind: "text" } | { kind: "src" } | { kind: "style"; prop: string };
/** Minimal element shape needed to read a binding — mirrors the SDK snapshot. */
export interface BindingSource {
attributes: Readonly<Record<string, string>>;
inlineStyles: Readonly<Record<string, string>>;
}
/** "font-family" → "fontFamily" to index inlineStyles (camelCase, per SDK convention). */
export function toCamel(prop: string): string {
return prop.replace(/-([a-z])/g, (_, c: string) => c.toUpperCase());
}
/**
* "var(--headline-color)" "headline-color". Tolerates a fallback
* ("var(--id, #fff)") and a trailing "!important"; anything else null.
*/
export function parseVarId(value: string | undefined): string | null {
if (!value) return null;
const m = /^var\(\s*--([A-Za-z0-9_-]+)\s*(?:,[^)]*)?\)\s*(?:!important)?\s*$/.exec(value.trim());
return m ? m[1] : null;
}
export function matchAction(actions: BindAction[], channel: PromoteChannel): BindAction | null {
if (channel.kind === "style") {
return actions.find((a) => a.kind === "style" && a.styleProp === channel.prop) ?? null;
}
return actions.find((a) => a.kind === channel.kind) ?? null;
}
/** Id of the variable this channel is bound to on the element, or null. */
export function readBindingFrom(source: BindingSource, channel: PromoteChannel): string | null {
if (channel.kind === "text") return source.attributes["data-var-text"] ?? null;
if (channel.kind === "src") return source.attributes["data-var-src"] ?? null;
return parseVarId(source.inlineStyles[toCamel(channel.prop)]);
}
/** Unique id from a suggested base, avoiding collisions with existing declarations. */
export function uniqueId(base: string, existing: CompositionVariable[]): string {
const taken = new Set(existing.map((d) => d.id));
if (!taken.has(base)) return base;
let n = 2;
while (taken.has(`${base}-${n}`)) n += 1;
return `${base}-${n}`;
}
@@ -0,0 +1,80 @@
// @vitest-environment happy-dom
/**
* Round-trip proof for Design-panel promote-to-variable: a bind written by
* applyBind against a real SDK session must be detected by readBindingFrom off
* the resulting element snapshot the same read the Design controls rely on to
* show bound state.
*/
import { describe, expect, it } from "vitest";
import { openComposition } from "@hyperframes/sdk";
import { createMemoryAdapter } from "@hyperframes/sdk/adapters/memory";
import { applyBind, type BindAction } from "../components/panels/VariablesBindElement";
import { readBindingFrom } from "./variablePromoteHelpers";
const HTML = /* html */ `<!DOCTYPE html>
<html>
<body>
<div data-hf-id="hf-title" style="color: rgb(255, 0, 0)">Hello</div>
<img data-hf-id="hf-logo" src="logo.png" />
</body>
</html>`;
function open() {
return openComposition(HTML, { persist: createMemoryAdapter() });
}
const styleAction: BindAction = {
key: "color",
label: "Text color",
kind: "style",
styleProp: "color",
suggestedId: "title-color",
declaration: (id) => ({ id, type: "color", label: "Title color", default: "#ff0000" }),
};
const textAction: BindAction = {
key: "text",
label: "Text",
kind: "text",
suggestedId: "title-text",
declaration: (id) => ({ id, type: "string", label: "Title text", default: "Hello" }),
};
const srcAction: BindAction = {
key: "src",
label: "Image source",
kind: "src",
suggestedId: "logo",
declaration: (id) => ({ id, type: "image", label: "Logo", default: "logo.png" }),
};
describe("promote round-trip", () => {
it("style bind writes var() and reads back the id", async () => {
const comp = await open();
applyBind(comp, "hf-title", styleAction, "title-color");
const snap = comp.getElement("hf-title")!;
expect(readBindingFrom(snap, { kind: "style", prop: "color" })).toBe("title-color");
expect(comp.getVariableDeclarations().some((d) => d.id === "title-color")).toBe(true);
});
it("text bind writes data-var-text and reads back the id", async () => {
const comp = await open();
applyBind(comp, "hf-title", textAction, "title-text");
const snap = comp.getElement("hf-title")!;
expect(readBindingFrom(snap, { kind: "text" })).toBe("title-text");
});
it("src bind writes data-var-src and reads back the id", async () => {
const comp = await open();
applyBind(comp, "hf-logo", srcAction, "logo");
const snap = comp.getElement("hf-logo")!;
expect(readBindingFrom(snap, { kind: "src" })).toBe("logo");
});
it("does not report a binding before promote", async () => {
const comp = await open();
const snap = comp.getElement("hf-title")!;
expect(readBindingFrom(snap, { kind: "style", prop: "color" })).toBeNull();
expect(readBindingFrom(snap, { kind: "text" })).toBeNull();
});
});