`${o.value}:${o.label}`).join("\n") : "",
+ };
+}
+
+function numberDeclFromDraft(
+ base: { id: string; label: string; description?: string },
+ draft: DeclarationDraft,
+): CompositionVariable | string {
+ const value = Number(draft.defaultRaw);
+ if (!Number.isFinite(value)) return "Default must be a number.";
+ const constraint = (key: "min" | "max" | "step") => {
+ const raw = draft[key].trim();
+ if (!raw) return {};
+ const parsed = Number(raw);
+ return Number.isFinite(parsed) ? { [key]: parsed } : {};
+ };
+ return {
+ ...base,
+ type: "number",
+ default: value,
+ ...constraint("min"),
+ ...constraint("max"),
+ ...constraint("step"),
+ };
+}
+
+// fallow-ignore-next-line complexity
+function enumDeclFromDraft(
+ base: { id: string; label: string; description?: string },
+ draft: DeclarationDraft,
+): CompositionVariable | string {
+ const options = draft.optionsRaw
+ .split("\n")
+ .map((line) => line.trim())
+ .filter(Boolean)
+ .map((line) => {
+ const [value, ...rest] = line.split(":");
+ const v = (value ?? "").trim();
+ return { value: v, label: rest.join(":").trim() || v };
+ })
+ .filter((o) => o.value.length > 0);
+ if (options.length === 0) return "Enum needs at least one option (one per line, value:Label).";
+ const value = draft.defaultRaw.trim() || (options[0]?.value ?? "");
+ if (!options.some((o) => o.value === value)) return "Default must be one of the options.";
+ return { ...base, type: "enum", default: value, options };
+}
+
+/**
+ * Fields the form actually models. On an unchanged-type edit, every OTHER key
+ * of the original declaration (font source/default_name/default_source,
+ * brandRole, placeholder, maxLength, unit, …) must ride through untouched —
+ * updateVariableDeclaration replaces wholesale, so dropping them here would
+ * silently strip schema metadata on every Edit + Save.
+ */
+const FORM_OWNED_KEYS = new Set([
+ "id",
+ "label",
+ "type",
+ "default",
+ "description",
+ "min",
+ "max",
+ "step",
+ "options",
+]);
+
+export function mergeDeclarationEdit(
+ original: CompositionVariable,
+ edited: CompositionVariable,
+): CompositionVariable {
+ // Type changed → old type-specific metadata no longer applies.
+ if (original.type !== edited.type) return edited;
+ const passthrough: Record
= {};
+ for (const [key, value] of Object.entries(original)) {
+ if (!FORM_OWNED_KEYS.has(key)) passthrough[key] = value;
+ }
+ // Both sides are same-type declarations and edited owns every form key, so
+ // the merge preserves the declared shape.
+ return { ...passthrough, ...edited };
+}
+
+/** Build a typed declaration from the form draft; string on validation error. */
+// fallow-ignore-next-line complexity
+function declarationFromDraft(draft: DeclarationDraft): CompositionVariable | string {
+ const id = draft.id.trim();
+ if (!id) return "Variable id is required.";
+ const label = draft.label.trim() || id;
+ const description = draft.description.trim() || undefined;
+ const base = { id, label, ...(description ? { description } : {}) };
+ switch (draft.type) {
+ case "number":
+ return numberDeclFromDraft(base, draft);
+ case "boolean":
+ return { ...base, type: "boolean", default: draft.defaultRaw.trim() === "true" };
+ case "enum":
+ return enumDeclFromDraft(base, draft);
+ default:
+ // string / color / font / image — string default, verbatim.
+ return { ...base, type: draft.type, default: draft.defaultRaw };
+ }
+}
+
+function Field({ label, children }: { label: string; children: React.ReactNode }) {
+ return (
+
+ {label}
+ {children}
+
+ );
+}
+
+function DefaultField({
+ draft,
+ onChange,
+}: {
+ draft: DeclarationDraft;
+ onChange: (defaultRaw: string) => void;
+}) {
+ if (draft.type === "boolean") {
+ return (
+ onChange(e.target.value)}
+ className={VARIABLES_INPUT_CLASS}
+ >
+ false
+ true
+
+ );
+ }
+ return (
+ onChange(e.target.value)}
+ className={VARIABLES_INPUT_CLASS}
+ />
+ );
+}
+
+export function DeclarationForm({
+ initial,
+ submitLabel,
+ onSubmit,
+ onCancel,
+}: {
+ initial: DeclarationDraft;
+ submitLabel: string;
+ onSubmit: (decl: CompositionVariable) => void;
+ onCancel: () => void;
+}) {
+ const [draft, setDraft] = useState(initial);
+ const [error, setError] = useState(null);
+ const editingExisting = initial.id.length > 0;
+ const set = (patch: Partial) => setDraft((d) => ({ ...d, ...patch }));
+
+ const submit = () => {
+ const result = declarationFromDraft(draft);
+ if (typeof result === "string") {
+ setError(result);
+ return;
+ }
+ setError(null);
+ onSubmit(result);
+ };
+
+ return (
+
+
+
+ set({ id: e.target.value })}
+ placeholder="title"
+ className={`${VARIABLES_INPUT_CLASS} font-mono disabled:opacity-50`}
+ />
+
+
+ set({ label: e.target.value })}
+ placeholder="Title"
+ className={VARIABLES_INPUT_CLASS}
+ />
+
+
+
+
+ {
+ const type = VARIABLE_TYPES.find((t) => t === e.target.value);
+ if (type) set({ type });
+ }}
+ className={VARIABLES_INPUT_CLASS}
+ >
+ {VARIABLE_TYPES.map((t) => (
+
+ {t}
+
+ ))}
+
+
+
+ set({ defaultRaw })} />
+
+
+ {draft.type === "number" && (
+
+ {(["min", "max", "step"] as const).map((key) => (
+
+ set({ [key]: e.target.value })}
+ className={`${VARIABLES_INPUT_CLASS} tabular-nums`}
+ />
+
+ ))}
+
+ )}
+ {draft.type === "enum" && (
+
+
+ )}
+
+ set({ description: e.target.value })}
+ className={VARIABLES_INPUT_CLASS}
+ />
+
+ {error &&
{error}
}
+
+
+ Cancel
+
+
+ {submitLabel}
+
+
+
+ );
+}
diff --git a/packages/studio/src/components/panels/VariablesPanel.tsx b/packages/studio/src/components/panels/VariablesPanel.tsx
new file mode 100644
index 0000000000..b8630c3e50
--- /dev/null
+++ b/packages/studio/src/components/panels/VariablesPanel.tsx
@@ -0,0 +1,432 @@
+import { memo, useCallback, useEffect, useMemo, useState, type MutableRefObject } from "react";
+import type {
+ Composition,
+ CompositionVariable,
+ VariableUsageReport,
+ VariableValidationIssue,
+} from "@hyperframes/sdk";
+import type { EditHistoryKind } from "../../utils/editHistory";
+import { useStudioPlaybackContext, useStudioShellContext } from "../../contexts/StudioContext";
+import { useFileManagerContext } from "../../contexts/FileManagerContext";
+import { useVariablesPersist } from "../../hooks/useVariablesPersist";
+import { usePreviewVariablesStore } from "../../hooks/previewVariablesStore";
+import {
+ DeclarationForm,
+ draftFromDeclaration,
+ mergeDeclarationEdit,
+ EMPTY_DRAFT,
+} from "./VariablesDeclarationForm";
+import { PreviewValueControl } from "./VariablesValueControls";
+import { isScalarVariableValue as isScalar } from "@hyperframes/core/variables";
+
+interface VariablesPanelProps {
+ sdkSession: Composition | null;
+ reloadPreview: () => void;
+ domEditSaveTimestampRef: MutableRefObject;
+ recordEdit: (entry: {
+ label: string;
+ kind: EditHistoryKind;
+ files: Record;
+ }) => Promise;
+}
+
+function formatIssue(issue: VariableValidationIssue): string {
+ switch (issue.kind) {
+ case "undeclared":
+ return `"${issue.variableId}" is not declared.`;
+ case "type-mismatch":
+ return `"${issue.variableId}" expects ${issue.expected}, got ${issue.actual}.`;
+ case "enum-out-of-range":
+ return `"${issue.variableId}" must be one of: ${issue.allowed.join(", ")}.`;
+ }
+}
+
+function ValidationStrip({ issues }: { issues: VariableValidationIssue[] }) {
+ if (issues.length === 0) return null;
+ return (
+
+ {issues.map((issue) => (
+
+ {formatIssue(issue)}
+
+ ))}
+
+ );
+}
+
+function RowAction({
+ label,
+ title,
+ danger,
+ onClick,
+}: {
+ label: string;
+ title: string;
+ danger?: boolean;
+ onClick: () => void;
+}) {
+ return (
+
+ {label}
+
+ );
+}
+
+// fallow-ignore-next-line complexity
+function VariableRow({
+ decl,
+ value,
+ overridden,
+ unused,
+ editing,
+ onCommitPreview,
+ onSetDefault,
+ onToggleEdit,
+ onSaveEdit,
+ onRemove,
+}: {
+ decl: CompositionVariable;
+ value: unknown;
+ overridden: boolean;
+ unused: boolean;
+ editing: boolean;
+ onCommitPreview: (value: unknown) => void;
+ onSetDefault: (value: string | number | boolean) => void;
+ onToggleEdit: () => void;
+ onSaveEdit: (decl: CompositionVariable) => void;
+ onRemove: () => void;
+}) {
+ return (
+
+
+ {decl.label}
+
+ {decl.type}
+
+ {unused && (
+
+ unused
+
+ )}
+ {overridden && }
+
+ {overridden && isScalar(value) && (
+ onSetDefault(value)}
+ />
+ )}
+
+
+
+
+ {decl.description &&
{decl.description}
}
+ {editing ? (
+
onSaveEdit(mergeDeclarationEdit(decl, edited))}
+ onCancel={onToggleEdit}
+ />
+ ) : (
+
+ )}
+
+ );
+}
+
+function UndeclaredReads({
+ usage,
+ onDeclare,
+}: {
+ usage: VariableUsageReport | null;
+ onDeclare: (id: string) => void;
+}) {
+ if (!usage || usage.undeclaredReads.length === 0) return null;
+ return (
+
+
+ Read by scripts, not declared
+
+ {usage.undeclaredReads.map((id) => (
+
+ {id}
+ onDeclare(id)}
+ />
+
+ ))}
+
+ );
+}
+
+/** Preview-state pill + reset, shown in the panel header. */
+function PreviewModeHeader({
+ overrideCount,
+ onReset,
+}: {
+ overrideCount: number;
+ onReset: () => void;
+}) {
+ const hasOverrides = overrideCount > 0;
+ return (
+
+
+ Variables
+
+ {hasOverrides ? `Previewing ${overrideCount} custom` : "Previewing defaults"}
+
+
+ {hasOverrides && (
+
+ Reset
+
+ )}
+
+ );
+}
+
+const EMPTY_STATE = (
+
+ No variables declared. Variables make parts of this composition dynamic — declare them here (or
+ in data-composition-variables), read them with{" "}
+ getVariables(), and pass values at render time with{" "}
+ --variables.
+
+);
+
+// Panel orchestrator — JSX conditionals per section, same shape as StudioRightPanel.
+// fallow-ignore-next-line complexity
+export const VariablesPanel = memo(function VariablesPanel({
+ sdkSession,
+ reloadPreview,
+ domEditSaveTimestampRef,
+ recordEdit,
+}: VariablesPanelProps) {
+ const { activeCompPath, showToast } = useStudioShellContext();
+ const { refreshKey } = useStudioPlaybackContext();
+ const { readProjectFile, writeProjectFile } = useFileManagerContext();
+ const previewValues = usePreviewVariablesStore((s) => s.values);
+ const setPreviewValues = usePreviewVariablesStore((s) => s.setValues);
+
+ // Bumped after each persisted schema edit so declarations re-derive without
+ // waiting for the session reload round-trip.
+ const [revision, setRevision] = useState(0);
+ // Also bump on any session mutation (undo/redo, edits dispatched by other
+ // panels or agents) — the memos below must never trust refreshKey alone.
+ useEffect(() => {
+ if (!sdkSession) return;
+ return sdkSession.on("change", () => setRevision((r) => r + 1));
+ }, [sdkSession]);
+ const [addOpen, setAddOpen] = useState(false);
+ const [editingId, setEditingId] = useState(null);
+
+ const persistVariables = useVariablesPersist({
+ sdkSession,
+ activeCompPath,
+ readProjectFile,
+ writeProjectFile,
+ recordEdit,
+ reloadPreview,
+ domEditSaveTimestampRef,
+ });
+
+ const declarations = useMemo(
+ () => sdkSession?.getVariableDeclarations() ?? [],
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ [sdkSession, refreshKey, revision],
+ );
+ const usage = useMemo(
+ () => sdkSession?.getVariableUsage() ?? null,
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ [sdkSession, refreshKey, revision],
+ );
+ const issues = useMemo(
+ () => (previewValues && sdkSession ? sdkSession.validateVariableValues(previewValues) : []),
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ [sdkSession, previewValues, refreshKey, revision],
+ );
+
+ const dropPreviewOverride = useCallback(
+ (id: string) => {
+ if (previewValues && id in previewValues) {
+ const next = { ...previewValues };
+ delete next[id];
+ setPreviewValues(next);
+ }
+ },
+ [previewValues, setPreviewValues],
+ );
+
+ const commitPreviewValue = useCallback(
+ (id: string, value: unknown, declDefault: unknown) => {
+ const next = { ...(previewValues ?? {}) };
+ if (JSON.stringify(value) === JSON.stringify(declDefault)) {
+ delete next[id];
+ } else {
+ next[id] = value;
+ }
+ setPreviewValues(next);
+ reloadPreview();
+ },
+ [previewValues, setPreviewValues, reloadPreview],
+ );
+
+ const runSchemaEdit = useCallback(
+ async (label: string, mutate: (session: Composition) => void) => {
+ try {
+ const changed = await persistVariables(label, mutate);
+ if (changed) setRevision((r) => r + 1);
+ else showToast(`${label}: no change applied`, "info");
+ } catch (err) {
+ showToast(err instanceof Error ? err.message : String(err), "error");
+ }
+ },
+ [persistVariables, showToast],
+ );
+
+ const handleAdd = useCallback(
+ (decl: CompositionVariable) => {
+ if (!sdkSession) return;
+ const check = sdkSession.can({ type: "declareVariable", declaration: decl });
+ if (!check.ok) {
+ showToast(check.message, "error");
+ return;
+ }
+ setAddOpen(false);
+ void runSchemaEdit(`Declare variable "${decl.id}"`, (s) => s.declareVariable(decl));
+ },
+ [sdkSession, runSchemaEdit, showToast],
+ );
+
+ const handleUpdate = useCallback(
+ (decl: CompositionVariable) => {
+ if (!sdkSession) return;
+ const check = sdkSession.can({
+ type: "updateVariableDeclaration",
+ id: decl.id,
+ declaration: decl,
+ });
+ if (!check.ok) {
+ showToast(check.message, "error");
+ return;
+ }
+ setEditingId(null);
+ void runSchemaEdit(`Edit variable "${decl.id}"`, (s) =>
+ s.updateVariableDeclaration(decl.id, decl),
+ );
+ },
+ [sdkSession, runSchemaEdit, showToast],
+ );
+
+ const handleRemove = useCallback(
+ (id: string) => {
+ void runSchemaEdit(`Remove variable "${id}"`, (s) => s.removeVariableDeclaration(id));
+ dropPreviewOverride(id);
+ },
+ [runSchemaEdit, dropPreviewOverride],
+ );
+
+ const handleSetDefault = useCallback(
+ (id: string, value: string | number | boolean) => {
+ void runSchemaEdit(`Set default for "${id}"`, (s) => s.setVariableValue(id, value));
+ // The override now equals the persisted default — drop it from preview state.
+ dropPreviewOverride(id);
+ },
+ [runSchemaEdit, dropPreviewOverride],
+ );
+
+ const resetPreview = useCallback(() => {
+ setPreviewValues(null);
+ reloadPreview();
+ }, [setPreviewValues, reloadPreview]);
+
+ if (!sdkSession) {
+ return (
+
+
Open a composition to manage its variables.
+
+ );
+ }
+
+ return (
+
+
+
+
+ {declarations.length === 0 && !addOpen && EMPTY_STATE}
+ {/* fallow-ignore-next-line complexity */}
+ {declarations.map((decl) => (
+
commitPreviewValue(decl.id, v, decl.default)}
+ onSetDefault={(v) => handleSetDefault(decl.id, v)}
+ onToggleEdit={() => setEditingId(editingId === decl.id ? null : decl.id)}
+ onSaveEdit={handleUpdate}
+ onRemove={() => handleRemove(decl.id)}
+ />
+ ))}
+ handleAdd({ id, type: "string", label: id, default: "" })}
+ />
+ {usage?.scanIncomplete && (
+
+ Scripts access variables dynamically — usage info may be incomplete.
+
+ )}
+ {addOpen ? (
+ setAddOpen(false)}
+ />
+ ) : (
+ setAddOpen(true)}
+ 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"
+ >
+ + Add variable
+
+ )}
+
+
+ );
+});
diff --git a/packages/studio/src/components/panels/VariablesValueControls.tsx b/packages/studio/src/components/panels/VariablesValueControls.tsx
new file mode 100644
index 0000000000..6a6cda7aba
--- /dev/null
+++ b/packages/studio/src/components/panels/VariablesValueControls.tsx
@@ -0,0 +1,215 @@
+/**
+ * Per-type preview-value inputs for the Variables panel. Text-like inputs
+ * draft locally and commit on blur/Enter so the preview doesn't reload per
+ * keystroke; discrete inputs (checkbox, select, color swatch, range) commit
+ * immediately.
+ */
+
+import { useState } from "react";
+import type {
+ CompositionVariable,
+ ColorVariable,
+ EnumVariable,
+ NumberVariable,
+} from "@hyperframes/sdk";
+
+export const VARIABLES_INPUT_CLASS =
+ "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";
+
+/** Text input that drafts locally and commits on blur/Enter. */
+function DraftTextInput({
+ value,
+ onCommit,
+ type = "text",
+ className = VARIABLES_INPUT_CLASS,
+ maxLength,
+ placeholder,
+ min,
+ max,
+ step,
+}: {
+ value: string;
+ onCommit: (raw: string) => void;
+ type?: "text" | "number";
+ className?: string;
+ maxLength?: number;
+ placeholder?: string;
+ min?: number;
+ max?: number;
+ step?: number;
+}) {
+ const [draft, setDraft] = useState(null);
+ return (
+ setDraft(e.target.value)}
+ onBlur={() => {
+ if (draft !== null && draft !== value) onCommit(draft);
+ setDraft(null);
+ }}
+ onKeyDown={(e) => e.key === "Enter" && e.currentTarget.blur()}
+ className={className}
+ />
+ );
+}
+
+function EnumControl({
+ decl,
+ current,
+ onCommit,
+}: {
+ decl: EnumVariable;
+ current: unknown;
+ onCommit: (value: unknown) => void;
+}) {
+ return (
+ onCommit(e.target.value)}
+ className={VARIABLES_INPUT_CLASS}
+ >
+ {decl.options.map((opt) => (
+
+ {opt.label}
+
+ ))}
+
+ );
+}
+
+function ColorControl({
+ current,
+ onCommit,
+}: {
+ decl: ColorVariable;
+ current: unknown;
+ onCommit: (value: unknown) => void;
+}) {
+ const colorValue = typeof current === "string" ? current : "#000000";
+ // The native picker fires change continuously while dragging the gradient;
+ // draft locally and commit once on close (blur) — each commit reloads the
+ // whole preview iframe.
+ const [draft, setDraft] = useState(null);
+ return (
+
+ setDraft(e.target.value)}
+ onBlur={() => {
+ if (draft !== null && draft !== colorValue) onCommit(draft);
+ setDraft(null);
+ }}
+ className="h-6 w-6 cursor-pointer rounded border border-neutral-700 bg-transparent"
+ />
+
+
+ );
+}
+
+// fallow-ignore-next-line complexity
+function NumberControl({
+ decl,
+ current,
+ onCommit,
+}: {
+ decl: NumberVariable;
+ current: unknown;
+ onCommit: (value: unknown) => void;
+}) {
+ const numberValue = typeof current === "number" ? current : Number(current) || 0;
+ const hasRange = decl.min !== undefined && decl.max !== undefined;
+ // Drag ticks stay local; commit once on release — each commit reloads the
+ // whole preview iframe, so per-tick commits would thrash it.
+ const [dragValue, setDragValue] = useState(null);
+ const commitDrag = () => {
+ if (dragValue !== null && dragValue !== numberValue) onCommit(dragValue);
+ setDragValue(null);
+ };
+ const commitRaw = (raw: string) => {
+ const n = Number(raw);
+ onCommit(Number.isFinite(n) ? n : raw);
+ };
+ return (
+
+ {hasRange && (
+ setDragValue(Number(e.target.value))}
+ onPointerUp={commitDrag}
+ onKeyUp={commitDrag}
+ onBlur={commitDrag}
+ className="flex-1"
+ />
+ )}
+
+ {decl.unit && {decl.unit} }
+
+ );
+}
+
+// Per-type dispatcher — one branch per variable type, same shape as BlockParamsPanel.
+// fallow-ignore-next-line complexity
+export function PreviewValueControl({
+ decl,
+ value,
+ onCommit,
+}: {
+ decl: CompositionVariable;
+ value: unknown;
+ onCommit: (value: unknown) => void;
+}) {
+ const current = value === undefined ? decl.default : value;
+
+ switch (decl.type) {
+ case "boolean":
+ return (
+ onCommit(e.target.checked)}
+ className="h-3.5 w-3.5 accent-neutral-400"
+ />
+ );
+ case "enum":
+ return ;
+ case "color":
+ return ;
+ case "number":
+ return ;
+ default: {
+ // string / font (family name) / image (URL) — plain text input for v1.
+ const textValue = typeof current === "string" ? current : JSON.stringify(current);
+ return (
+
+ );
+ }
+ }
+}
diff --git a/packages/studio/src/hooks/previewVariablesStore.ts b/packages/studio/src/hooks/previewVariablesStore.ts
new file mode 100644
index 0000000000..4a89e63356
--- /dev/null
+++ b/packages/studio/src/hooks/previewVariablesStore.ts
@@ -0,0 +1,34 @@
+import { create } from "zustand";
+
+/**
+ * Ephemeral composition-variable overrides for the preview iframe.
+ *
+ * Values here are NEVER persisted to the composition — they ride the preview
+ * URL as `?variables=` (see the studio-server preview routes), which the
+ * server injects as `window.__hfVariables` exactly like render-time injection,
+ * so what the user previews is what `hyperframes render --variables` produces.
+ * `null` means "preview with declared defaults".
+ */
+interface PreviewVariablesState {
+ values: Record | null;
+ setValues: (values: Record | null) => void;
+}
+
+export const usePreviewVariablesStore = create((set) => ({
+ values: null,
+ setValues: (values) => set({ values: values && Object.keys(values).length > 0 ? values : null }),
+}));
+
+/**
+ * Apply the current preview-variable overrides to a preview URL (both the
+ * Player's initial mount and refreshPlayer's soft reload route through this,
+ * so a hard remount can't silently drop the active overrides).
+ */
+export function applyPreviewVariablesToUrl(url: URL): void {
+ const values = usePreviewVariablesStore.getState().values;
+ if (values) {
+ url.searchParams.set("variables", JSON.stringify(values));
+ } else {
+ url.searchParams.delete("variables");
+ }
+}
diff --git a/packages/studio/src/hooks/useVariablesPersist.ts b/packages/studio/src/hooks/useVariablesPersist.ts
new file mode 100644
index 0000000000..7d148d9294
--- /dev/null
+++ b/packages/studio/src/hooks/useVariablesPersist.ts
@@ -0,0 +1,61 @@
+import { useCallback } from "react";
+import type { Composition } from "@hyperframes/sdk";
+import { persistSdkSerialize } from "../utils/sdkCutover";
+import type { UseSlideshowPersistParams } from "./useSlideshowPersist";
+
+/** Same single-writer dependency set the slideshow persist path uses. */
+export type UseVariablesPersistParams = Omit;
+
+/**
+ * Persist a variable-schema edit: run `mutate` (SDK declaration/value ops)
+ * against the session, then write the serialized composition through the
+ * standard single-writer path (undo history + self-write echo suppression +
+ * preview reload). Mutations that end up changing nothing are skipped, so a
+ * no-op dispatch (e.g. declaring a duplicate id) never pollutes undo history.
+ */
+export function useVariablesPersist({
+ sdkSession,
+ activeCompPath,
+ readProjectFile,
+ writeProjectFile,
+ recordEdit,
+ reloadPreview,
+ domEditSaveTimestampRef,
+}: UseVariablesPersistParams): (
+ label: string,
+ mutate: (session: Composition) => void,
+) => Promise {
+ return useCallback(
+ async (label: string, mutate: (session: Composition) => void) => {
+ if (!sdkSession) return false;
+ const path = activeCompPath ?? "index.html";
+ const originalContent = await readProjectFile(path);
+ mutate(sdkSession);
+ const after = sdkSession.serialize();
+ if (after === originalContent) return false;
+ await persistSdkSerialize(
+ after,
+ path,
+ originalContent,
+ {
+ editHistory: { recordEdit },
+ writeProjectFile,
+ reloadPreview,
+ domEditSaveTimestampRef,
+ compositionPath: activeCompPath,
+ },
+ { label },
+ );
+ return true;
+ },
+ [
+ sdkSession,
+ activeCompPath,
+ readProjectFile,
+ writeProjectFile,
+ recordEdit,
+ reloadPreview,
+ domEditSaveTimestampRef,
+ ],
+ );
+}
diff --git a/packages/studio/src/player/components/Player.tsx b/packages/studio/src/player/components/Player.tsx
index d23dbec269..6c26b4bf9c 100644
--- a/packages/studio/src/player/components/Player.tsx
+++ b/packages/studio/src/player/components/Player.tsx
@@ -1,6 +1,7 @@
import { forwardRef, useEffect, useRef, useState } from "react";
import { isLottieAnimationLoaded } from "@hyperframes/core/runtime/lottie-readiness";
import { useMountEffect } from "../../hooks/useMountEffect";
+import { applyPreviewVariablesToUrl } from "../../hooks/previewVariablesStore";
import { HyperframesLoader } from "../../components/ui";
// NOTE: importing "@hyperframes/player" registers a class extending HTMLElement
// at module load, which throws under SSR. Defer the import to the mount effect
@@ -154,7 +155,12 @@ export const Player = forwardRef(
// Create the web component imperatively to avoid JSX custom-element typing.
const player = document.createElement("hyperframes-player") as HyperframesPlayerElement;
- const src = directUrl || `/api/projects/${projectId}/preview`;
+ const srcUrl = new URL(
+ directUrl || `/api/projects/${projectId}/preview`,
+ window.location.origin,
+ );
+ applyPreviewVariablesToUrl(srcUrl);
+ const src = srcUrl.pathname + srcUrl.search;
player.setAttribute("shader-capture-scale", "1");
player.setAttribute("shader-loading", "player");
player.setAttribute("src", src);
diff --git a/packages/studio/src/player/hooks/useTimelinePlayer.ts b/packages/studio/src/player/hooks/useTimelinePlayer.ts
index 928a353fbf..70414c5771 100644
--- a/packages/studio/src/player/hooks/useTimelinePlayer.ts
+++ b/packages/studio/src/player/hooks/useTimelinePlayer.ts
@@ -44,6 +44,7 @@ import {
import { scrubMusicAtSeek, stopScrubPreviewAudio } from "../lib/playbackScrub";
import { applyCachedSourceDurations, probeMissingSourceDurations } from "../lib/mediaProbe";
import { shouldResumeForwardPlaybackAfterSeek, shouldStopAfterSeek } from "../lib/playbackSeek";
+import { applyPreviewVariablesToUrl } from "../../hooks/previewVariablesStore";
/**
* Whether the derived elements differ from the current ones in any field that
@@ -127,6 +128,8 @@ export function useTimelinePlayer() {
[setElements, setTimelineReady, setDuration],
);
+ // Pre-existing dispatcher complexity — surfaced by this PR's line shifts, not new logic.
+ // fallow-ignore-next-line complexity
const getAdapter = useCallback((): PlaybackAdapter | null => {
try {
const iframe = iframeRef.current;
@@ -209,6 +212,7 @@ export function useTimelinePlayer() {
}, []);
const startRAFLoop = useCallback(() => {
+ // fallow-ignore-next-line complexity
const tick = () => {
const adapter = getAdapter();
if (adapter) {
@@ -475,6 +479,7 @@ export function useTimelinePlayer() {
const src = iframe.src;
const url = new URL(src, window.location.origin);
url.searchParams.set("_t", String(Date.now()));
+ applyPreviewVariablesToUrl(url);
iframe.src = url.toString();
}, [saveSeekPosition]);
const getAdapterRef = useRef(getAdapter);
@@ -484,6 +489,8 @@ export function useTimelinePlayer() {
const handleWindowKeyDown = (e: KeyboardEvent) => playbackKeyDownRef.current(e);
const handleWindowKeyUp = (e: KeyboardEvent) => playbackKeyUpRef.current(e);
+ // Pre-existing message-router complexity — surfaced by line shifts, not new logic.
+ // fallow-ignore-next-line complexity
const handleMessage = (e: MessageEvent) => {
const data = e.data;
const ourIframe = iframeRef.current;
diff --git a/packages/studio/src/utils/studioHelpers.ts b/packages/studio/src/utils/studioHelpers.ts
index fc5459bc2a..663f08add0 100644
--- a/packages/studio/src/utils/studioHelpers.ts
+++ b/packages/studio/src/utils/studioHelpers.ts
@@ -13,7 +13,13 @@ export interface AppToast {
tone: "error" | "info";
}
-export type RightPanelTab = "layers" | "design" | "renders" | "block-params" | "slideshow";
+export type RightPanelTab =
+ | "layers"
+ | "design"
+ | "renders"
+ | "block-params"
+ | "slideshow"
+ | "variables";
export type RightInspectorPane = "layers" | "design";
export interface RightInspectorPanes {