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 { useDomEditContext } from "../../contexts/DomEditContext"; import { useFileManagerContext } from "../../contexts/FileManagerContext"; import { VariablesBindElement, type BindAction, applyBind } from "./VariablesBindElement"; import { useVariablesPersist } from "../../hooks/useVariablesPersist"; import { VariablesOtherCompositions } from "./VariablesOtherCompositions"; import { RowAction } from "./VariablesRowAction"; import { usePreviewVariablesStore } from "../../hooks/previewVariablesStore"; import { DeclarationForm, draftFromDeclaration, mergeDeclarationEdit, EMPTY_DRAFT, } from "./VariablesDeclarationForm"; import { PreviewValueControl } from "./VariablesValueControls"; import { copyTextToClipboard } from "../../utils/clipboard"; import { resolveMasterCompositionPath } from "../../utils/studioUrlState"; import { isScalarVariableValue as isScalar } from "@hyperframes/core/variables"; /** POSIX single-quote escaping so the copied command survives quotes in values. */ function shellSingleQuote(value: string): string { return `'${value.replace(/'/g, `'\\''`)}'`; } 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)}

))}
); } // 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 && ( )}
); } /** * Developer/agent handoff: copy the effective values as JSON or as a * ready-to-run render command mirroring exactly what the preview shows. */ function HandoffFooter({ effectiveValues, compPath, onCopy, }: { effectiveValues: Record; compPath: string; onCopy: (text: string, what: string) => void; }) { const json = JSON.stringify(effectiveValues); const command = `npx hyperframes render ${shellSingleQuote(compPath)} --variables ${shellSingleQuote(json)}`; return (

Use this template

{command}
onCopy(command, "Render command")} /> onCopy(json, "Values JSON")} />
); } 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, fileTree } = useFileManagerContext(); const { domEditSelection } = useDomEditContext(); // On the master view (no activeCompPath) the panel targets the project's real // main composition — the first .html in the tree — not a hardcoded index.html // that may not exist. This same path is used for the persist write target (so // an edit never lands in a phantom index.html) AND the handoff render command. // Null only when the project has no composition yet, in which case sdkSession // is also null and the panel is inert. const effectiveCompPath = activeCompPath ?? resolveMasterCompositionPath(fileTree); 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: effectiveCompPath, 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 effectiveValues = useMemo( () => sdkSession?.getVariableValues(previewValues ?? undefined) ?? {}, // eslint-disable-next-line react-hooks/exhaustive-deps [sdkSession, previewValues, refreshKey, revision], ); const copyToClipboard = useCallback( (text: string, what: string) => { // Shared helper carries the execCommand fallback Safari needs. void copyTextToClipboard(text).then((ok) => showToast( ok ? `${what} copied` : `Couldn't copy ${what.toLowerCase()}`, ok ? "info" : "error", ), ); }, [showToast], ); 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): Promise => { try { const changed = await persistVariables(label, mutate); if (changed) setRevision((r) => r + 1); else showToast(`${label}: no change applied`, "info"); return changed; } catch (err) { showToast(err instanceof Error ? err.message : String(err), "error"); return false; } }, [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) => { if (!sdkSession) return; const check = sdkSession.can({ type: "removeVariableDeclaration", id }); if (!check.ok) { showToast(check.message, "error"); return; } // Drop the preview override only if the declaration was actually removed — // otherwise a rejected/failed edit would leave the row on disk but silently // wipe the user's custom preview value. void runSchemaEdit(`Remove variable "${id}"`, (s) => s.removeVariableDeclaration(id)).then( (changed) => { if (changed) dropPreviewOverride(id); }, ); }, [sdkSession, runSchemaEdit, dropPreviewOverride, showToast], ); 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]); const handleBind = useCallback( // Guard chain (session, selection, type-compat) — one branch per guard. // fallow-ignore-next-line complexity (action: BindAction, id: string) => { if (!sdkSession || !domEditSelection?.hfId) return; // Binding to an existing variable is allowed, but only when the types // agree — wiring a color style to a string variable silently breaks // the element's styling. const existing = sdkSession.getVariableDeclarations().find((d) => d.id === id); const wanted = action.declaration(id).type; if (existing && existing.type !== wanted) { showToast( `"${id}" is already a ${existing.type} variable — pick another id for this ${wanted} binding`, "error", ); return; } const hfId = domEditSelection.hfId; void runSchemaEdit(`Bind ${action.label.toLowerCase()} to "${id}"`, (s) => applyBind(s, hfId, action, id), ); }, [sdkSession, domEditSelection, runSchemaEdit, showToast], ); // The bind gesture targets the composition the session models — a selection // from another source file must not write bindings into this one. const bindableSelection = domEditSelection?.hfId && domEditSelection.sourceFile === (activeCompPath ?? "index.html") ? domEditSelection : null; if (!sdkSession) { return (

Open a composition to manage its variables.

); } return (
{bindableSelection && ( )} {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.

)} {declarations.length > 0 && ( )} {addOpen ? ( setAddOpen(false)} /> ) : ( )}
); });