diff --git a/packages/core/src/variables.ts b/packages/core/src/variables.ts index 1eb1ebd71..da4f382fb 100644 --- a/packages/core/src/variables.ts +++ b/packages/core/src/variables.ts @@ -21,6 +21,7 @@ export type { export { COMPOSITION_VARIABLE_TYPES, parseCompositionVariables, + isCompositionVariable, isScalarVariableValue, } from "@hyperframes/parsers/composition"; diff --git a/packages/parsers/src/composition.ts b/packages/parsers/src/composition.ts index 933f69683..e4cef33e6 100644 --- a/packages/parsers/src/composition.ts +++ b/packages/parsers/src/composition.ts @@ -10,4 +10,8 @@ export { resolveAliasDisplayName, } from "./fontAliases.js"; export { decodeUrlPathVariants } from "./utils/urlPath.js"; -export { parseCompositionVariables, isScalarVariableValue } from "./compositionVariables.js"; +export { + parseCompositionVariables, + isCompositionVariable, + isScalarVariableValue, +} from "./compositionVariables.js"; diff --git a/packages/parsers/src/compositionVariables.ts b/packages/parsers/src/compositionVariables.ts index fa09d3ef8..2bf9960ad 100644 --- a/packages/parsers/src/compositionVariables.ts +++ b/packages/parsers/src/compositionVariables.ts @@ -40,7 +40,13 @@ function isVariableType(t: unknown): t is CompositionVariableType { return typeof t === "string" && t in DEFAULT_TYPEOF; } -function isCompositionVariable(v: unknown): v is CompositionVariable { +/** + * True when the value is a structurally valid variable declaration: id, label, + * a known type, a default matching that type, and options[] for enums. The + * same predicate parseCompositionVariables filters with — exported so writers + * (SDK declaration ops, Studio forms) can validate before persisting. + */ +export function isCompositionVariable(v: unknown): v is CompositionVariable { if (!isRecord(v)) return false; if (typeof v.id !== "string" || typeof v.label !== "string") return false; if (!isVariableType(v.type)) return false; diff --git a/packages/sdk/src/engine/apply-patches.ts b/packages/sdk/src/engine/apply-patches.ts index b975f94b4..57df576bb 100644 --- a/packages/sdk/src/engine/apply-patches.ts +++ b/packages/sdk/src/engine/apply-patches.ts @@ -19,7 +19,21 @@ import { setStyleSheet, } from "./model.js"; import { keyToPath, stylePath } from "./patches.js"; -import { writeVariableDefault, clearVariableDefault } from "./variableModel.js"; +import { + writeVariableDefault, + clearVariableDefault, + writeVariableDeclaration, + removeVariableDeclarationEntry, +} from "./variableModel.js"; + +function isRawDeclarationEntry(value: unknown): value is { id: string } & Record { + return ( + typeof value === "object" && + value !== null && + !Array.isArray(value) && + typeof (value as { id?: unknown }).id === "string" + ); +} // ─── Path parser ──────────────────────────────────────────────────────────── @@ -32,6 +46,7 @@ interface ParsedPath { | "hold" | "element" | "variable" + | "variableDeclaration" | "metadata" | "script" | "stylesheet"; @@ -65,6 +80,9 @@ function parsePath(path: string): ParsedPath | null { const elemM = /^\/elements\/([^/]+)$/.exec(path); if (elemM) return { type: "element", id: elemM[1] }; + const varDeclM = /^\/variableDeclarations\/(.+)$/.exec(path); + if (varDeclM) return { type: "variableDeclaration", id: varDeclM[1] }; + const varM = /^\/variables\/(.+)$/.exec(path); if (varM) return { type: "variable", id: varM[1] }; @@ -229,6 +247,20 @@ function applyOne(parsed: ParsedDocument, patch: JsonPatchOp, p: ParsedPath): vo break; } + case "variableDeclaration": { + if (!p.id) return; + if (patch.op === "remove") { + removeVariableDeclarationEntry(parsed.document, p.id); + } else if (isRawDeclarationEntry(patch.value)) { + // Replay is faithful, not strict: inverse patches capture raw entries + // (loose hand-authored declarations included) and undo must restore + // them verbatim — gating on isCompositionVariable here would make + // undo of a remove/update on a loose entry silently no-op. + writeVariableDeclaration(parsed.document, patch.value); + } + break; + } + case "variable": { if (!p.id) return; // B1: update the JSON model (data-composition-variables) so diff --git a/packages/sdk/src/engine/mutate.ts b/packages/sdk/src/engine/mutate.ts index 3f2c5c69f..15ee6398a 100644 --- a/packages/sdk/src/engine/mutate.ts +++ b/packages/sdk/src/engine/mutate.ts @@ -40,6 +40,7 @@ import { holdPath, elementPath, variablePath, + variableDeclPath, metaPath, gsapScriptPath, styleSheetPath, @@ -76,7 +77,18 @@ import { unrollDynamicAnimations, } from "@hyperframes/core/gsap-writer-acorn"; import { deriveKeyframeBackfillDefaults } from "./keyframeBackfill.js"; -import { readVariableDefault, writeVariableDefault } from "./variableModel.js"; +import { + readVariableDefault, + writeVariableDefault, + findVariableDeclaration, + writeVariableDeclaration, + removeVariableDeclarationEntry, +} from "./variableModel.js"; +import { + isCompositionVariable, + isScalarVariableValue as isScalar, +} from "@hyperframes/core/variables"; +import type { CompositionVariable } from "@hyperframes/core/variables"; import { URI_BEARING_ATTRS, DANGEROUS_URI_SCHEMES, @@ -288,6 +300,12 @@ export function applyOp(parsed: ParsedDocument, op: EditOp): MutationResult { return handleSetCompositionMetadata(parsed, op); case "setVariableValue": return handleSetVariableValue(parsed, op.id, op.value); + case "declareVariable": + return handleDeclareVariable(parsed, op.declaration); + case "updateVariableDeclaration": + return handleUpdateVariableDeclaration(parsed, op.id, op.declaration); + case "removeVariableDeclaration": + return handleRemoveVariableDeclaration(parsed, op.id); case "setClassStyle": return handleSetClassStyle(parsed, op.selector, op.styles); case "addLabel": @@ -885,6 +903,132 @@ function handleSetVariableValue( return { forward, inverse }; } +/** + * Keep the `--{id}` CSS compat custom property on the root in sync with a + * scalar default (same secondary channel handleSetVariableValue maintains). + * Pass null to clear. Returns the patch pair, or null when there is no root + * or nothing to change. + */ +function cssCompatChange( + parsed: ParsedDocument, + id: string, + newVal: string | null, +): { forward: JsonPatchOp; inverse: JsonPatchOp } | null { + const root = findRoot(parsed.document); + const rootId = root?.getAttribute("data-hf-id"); + if (!root || !rootId) return null; + const cssVar = `--${id}`; + const oldCssValue = getElementStyles(root)[cssVar] ?? null; + if (newVal !== null) { + if (oldCssValue === newVal) return null; + setElementStyles(root, { [cssVar]: newVal }); + return scalarChange(stylePath(rootId, cssVar), oldCssValue, newVal); + } + if (oldCssValue === null) return null; + setElementStyles(root, { [cssVar]: null }); + return scalarDelete(stylePath(rootId, cssVar), oldCssValue); +} + +/** + * Declaration ops require a real `` in the source: fragment inputs get + * a synthetic wrapper that serialize() strips, so a declaration written there + * would silently vanish on save. + */ +function fragmentCompositionErr(parsed: ParsedDocument): CanResult | null { + if (!parsed.wrapped) return null; + return canErr( + "E_FRAGMENT_COMPOSITION", + "Fragment compositions cannot carry variable declarations.", + "data-composition-variables lives on the element — convert the composition to a full HTML document first.", + ); +} + +function invalidDeclarationErr(): CanResult { + return canErr( + "E_INVALID_ARGS", + "Not a valid variable declaration.", + "Requires id, label, type (string|number|color|boolean|enum|font|image), and a default matching the type; enum also requires options[].", + ); +} + +function handleDeclareVariable( + parsed: ParsedDocument, + declaration: CompositionVariable, +): MutationResult { + // Defensive re-check of can(): never write an invalid or duplicate + // declaration into the schema. Fragment sources have no of their + // own — writing to the synthetic wrapper would be lost on serialize. + if (parsed.wrapped) return EMPTY; + if (!isCompositionVariable(declaration)) return EMPTY; + if (findVariableDeclaration(parsed.document, declaration.id) !== undefined) return EMPTY; + if (!writeVariableDeclaration(parsed.document, declaration)) return EMPTY; + const path = variableDeclPath(declaration.id); + const result: MutationResult = { + forward: [patchAdd(path, declaration)], + inverse: [patchRemove(path)], + }; + // Same CSS compat channel every other variable op maintains — a composition + // CSS-bound to var(--id) must resolve regardless of which op set the value. + if (isScalar(declaration.default)) { + const css = cssCompatChange(parsed, declaration.id, String(declaration.default)); + if (css) { + result.forward.push(css.forward); + result.inverse.push(css.inverse); + } + } + return result; +} + +function handleUpdateVariableDeclaration( + parsed: ParsedDocument, + id: string, + declaration: CompositionVariable, +): MutationResult { + if (parsed.wrapped) return EMPTY; + if (!isCompositionVariable(declaration) || declaration.id !== id) return EMPTY; + const old = findVariableDeclaration(parsed.document, id); + if (old === undefined) return EMPTY; + writeVariableDeclaration(parsed.document, declaration); + const p = valueChange(variableDeclPath(id), old, declaration); + const result: MutationResult = { forward: [p.forward], inverse: [p.inverse] }; + + // Default changed → keep the CSS compat prop in sync (set for scalars, + // clear when the new default is object-valued font/image), and emit the + // paired /variables value patch so the T3 override-set's var.{id} entry + // agrees with the varDecl.{id} snapshot regardless of replay order. + const oldDefault = old.default; + const newDefault = declaration.default; + if (JSON.stringify(oldDefault) !== JSON.stringify(newDefault)) { + const valueP = valueChange(variablePath(id), oldDefault ?? null, newDefault); + result.forward.push(valueP.forward); + result.inverse.push(valueP.inverse); + const css = cssCompatChange(parsed, id, isScalar(newDefault) ? String(newDefault) : null); + if (css) { + result.forward.push(css.forward); + result.inverse.push(css.inverse); + } + } + return result; +} + +function handleRemoveVariableDeclaration(parsed: ParsedDocument, id: string): MutationResult { + if (parsed.wrapped) return EMPTY; + const old = findVariableDeclaration(parsed.document, id); + if (old === undefined) return EMPTY; + removeVariableDeclarationEntry(parsed.document, id); + const path = variableDeclPath(id); + const result: MutationResult = { + forward: [patchRemove(path)], + inverse: [patchAdd(path, old)], + }; + const css = cssCompatChange(parsed, id, null); + if (css) { + result.forward.push(css.forward); + result.inverse.push(css.inverse); + } + return result; +} + // ─── GSAP selector helpers ─────────────────────────────────────────────────── function selectorMatchesId(selector: string, id: HfId): boolean { @@ -1494,6 +1638,49 @@ export function validateOp(parsed: ParsedDocument, op: EditOp): CanResult { if (findRoot(parsed.document) === null) return canErr("E_NO_ROOT", "Composition root element not found."); return CAN_OK; + case "declareVariable": { + const fragmentErr = fragmentCompositionErr(parsed); + if (fragmentErr) return fragmentErr; + if (!isCompositionVariable(op.declaration)) return invalidDeclarationErr(); + if (findVariableDeclaration(parsed.document, op.declaration.id) !== undefined) + return canErr( + "E_DUPLICATE_VARIABLE", + `Variable "${op.declaration.id}" is already declared.`, + "Use updateVariableDeclaration to change it, or setVariableValue to change its default.", + ); + return CAN_OK; + } + case "updateVariableDeclaration": { + const fragmentErr = fragmentCompositionErr(parsed); + if (fragmentErr) return fragmentErr; + // Shape check FIRST — a null/non-object declaration must yield a + // CanResult, not a TypeError from the id access below. + if (!isCompositionVariable(op.declaration)) return invalidDeclarationErr(); + if (op.declaration.id !== op.id) + return canErr( + "E_INVALID_ARGS", + `declaration.id ("${op.declaration.id}") must match id ("${op.id}").`, + "Variable ids are immutable — rename via removeVariableDeclaration + declareVariable.", + ); + if (findVariableDeclaration(parsed.document, op.id) === undefined) + return canErr( + "E_VARIABLE_NOT_FOUND", + `Variable "${op.id}" is not declared.`, + "Check comp.getVariableDeclarations(), or add it with declareVariable.", + ); + return CAN_OK; + } + case "removeVariableDeclaration": { + const fragmentErr = fragmentCompositionErr(parsed); + if (fragmentErr) return fragmentErr; + if (findVariableDeclaration(parsed.document, op.id) === undefined) + return canErr( + "E_VARIABLE_NOT_FOUND", + `Variable "${op.id}" is not declared.`, + "Check comp.getVariableDeclarations().", + ); + return CAN_OK; + } case "setCompositionMetadata": case "setClassStyle": return CAN_OK; diff --git a/packages/sdk/src/engine/patches.ts b/packages/sdk/src/engine/patches.ts index 96c095602..46a76418f 100644 --- a/packages/sdk/src/engine/patches.ts +++ b/packages/sdk/src/engine/patches.ts @@ -8,7 +8,8 @@ * /elements/{hfId}/timing/{start|end|duration|trackIndex} ← end = computed absolute data-end * /elements/{hfId}/hold/{start|end|fill} * /elements/{hfId} ← whole subtree (removeElement) - * /variables/{variableId} + * /variables/{variableId} ← declaration's default value + * /variableDeclarations/{variableId} ← whole declaration object * /metadata/{width|height|duration} * /script/gsap ← GSAP inline script textContent * /style/css ←