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/adapters/iframe.sync.test.ts b/packages/sdk/src/adapters/iframe.sync.test.ts index cde99bcfd..9e2868957 100644 --- a/packages/sdk/src/adapters/iframe.sync.test.ts +++ b/packages/sdk/src/adapters/iframe.sync.test.ts @@ -220,8 +220,10 @@ window.__timelines = { t: tl }; }); it("mirrors declareVariable/removeVariable onto the live document's schema attribute", async () => { - const iframe = mountIframe(BASE_HTML); // no data-composition-variables at all - const comp = await openComposition(BASE_HTML); + // Full document (not a fragment): declareVariable refuses fragment sources. + const fullDoc = `${BASE_HTML}`; + const iframe = mountIframe(fullDoc); // no data-composition-variables at all + const comp = await openComposition(fullDoc); const adapter = createIframePreviewAdapter(iframe); adapter.attachSync(comp); @@ -231,7 +233,8 @@ window.__timelines = { t: tl }; expect(liveDocEl.getAttribute("data-composition-variables")).toContain("accent"); comp.removeVariable("accent"); - expect(liveDocEl.getAttribute("data-composition-variables")).not.toContain("accent"); + // Removing the last declaration drops the attribute entirely (null). + expect(liveDocEl.getAttribute("data-composition-variables") ?? "").not.toContain("accent"); }); it("mirrors setTiming onto the live element's data-start/data-end attributes", async () => { diff --git a/packages/sdk/src/engine/apply-patches.ts b/packages/sdk/src/engine/apply-patches.ts index 56e0c3026..57df576bb 100644 --- a/packages/sdk/src/engine/apply-patches.ts +++ b/packages/sdk/src/engine/apply-patches.ts @@ -22,11 +22,19 @@ import { keyToPath, stylePath } from "./patches.js"; import { writeVariableDefault, clearVariableDefault, - declareVariableDecl, - removeVariableDecl, - type VariableDecl, + 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 ──────────────────────────────────────────────────────────── interface ParsedPath { @@ -38,7 +46,7 @@ interface ParsedPath { | "hold" | "element" | "variable" - | "variable-decl" + | "variableDeclaration" | "metadata" | "script" | "stylesheet"; @@ -72,12 +80,12 @@ 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] }; - const varDeclM = /^\/variable-decls\/(.+)$/.exec(path); - if (varDeclM) return { type: "variable-decl", id: varDeclM[1] }; - const metaM = /^\/metadata\/(.+)$/.exec(path); if (metaM) return { type: "metadata", field: metaM[1] }; @@ -239,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 @@ -249,35 +271,6 @@ function applyOne(parsed: ParsedDocument, patch: JsonPatchOp, p: ParsedPath): vo break; } - case "variable-decl": { - if (!p.id) return; - // Distinct from "variable" above: this replays the WHOLE declaration - // (declareVariable/removeVariable), not just its default value. - if (patch.op === "remove") { - removeVariableDecl(parsed.document, p.id); - } else { - // Undo of removeVariable bundles {__kind: "reinsert", decl, index} to - // reinsert at the original array position; a plain declareVariable - // forward/replace patch carries the bare decl. Disambiguate on the - // __kind tag, not structural "decl"/"index" key presence — VariableDecl - // has an open index signature, so a genuine variable schema could - // legally declare its own "decl"/"index" fields, and "in" narrowing - // alone can't rule that out. - const value = patch.value; - if ( - value && - typeof value === "object" && - (value as { __kind?: unknown }).__kind === "reinsert" - ) { - const wrapped = value as { decl: VariableDecl; index: number }; - declareVariableDecl(parsed.document, wrapped.decl, { atIndex: wrapped.index }); - } else { - declareVariableDecl(parsed.document, value as VariableDecl); - } - } - break; - } - case "script": { if (patch.op === "remove") { setGsapScript(parsed.document, ""); diff --git a/packages/sdk/src/engine/mutate.test.ts b/packages/sdk/src/engine/mutate.test.ts index e898a8cfd..28d08462b 100644 --- a/packages/sdk/src/engine/mutate.test.ts +++ b/packages/sdk/src/engine/mutate.test.ts @@ -31,6 +31,13 @@ function fresh() { return parseMutable(BASE_HTML); } +// Full document (BASE_HTML wrapped in ) with NO declarations — the shape +// declareVariable requires (it refuses fragment sources whose synthetic +// is stripped on serialize). +function freshDoc() { + return parseMutable(`${BASE_HTML}`); +} + /** Full HTML fixture with data-composition-variables for B1/B2 tests. */ const VARIABLES_HTML = ` { expect(readVarDecl(parsed, "never-declared")).toBeUndefined(); applyOp(parsed, { type: "declareVariable", - decl: { id: "never-declared", type: "string", label: "New", default: "x" }, + declaration: { id: "never-declared", type: "string", label: "New", default: "x" }, }); expect(readVarDecl(parsed, "never-declared")?.default).toBe("x"); }); @@ -987,36 +1012,10 @@ describe("declareVariable", () => { const created = applyOp(parsed, { type: "declareVariable", - decl: { id: "brand-new", type: "string", label: "New", default: "x" }, + declaration: { id: "brand-new", type: "string", label: "New", default: "x" }, }); applyPatchesToDocument(parsed, created.inverse); expect(serializeDocument(parsed)).toBe(before); - - const edited = applyOp(parsed, { - type: "declareVariable", - decl: { id: "brand-color-primary", type: "color", label: "Edited", default: "#000000" }, - }); - applyPatchesToDocument(parsed, edited.inverse); - expect(serializeDocument(parsed)).toBe(before); - }); - - it("a decl whose own extension data includes 'decl'/'index' keys isn't mistaken for a wrapped reinsert", () => { - // VariableDecl has an open index signature, so a real, weird schema can - // legally carry fields named "decl"/"index" as ordinary extension data — - // this must NOT be confused with removeVariable's {__kind: "reinsert", - // decl, index} inverse-patch wrapper, which is disambiguated by __kind, - // not by structural key presence. - const parsed = fresh(); - const pathologicalDecl = { - id: "weird-var", - type: "string", - label: "Weird", - default: "x", - decl: "not-a-real-decl", - index: 999, - }; - applyOp(parsed, { type: "declareVariable", decl: pathologicalDecl }); - expect(readVarDecl(parsed, "weird-var")).toEqual(pathologicalDecl); }); }); @@ -1034,13 +1033,16 @@ describe("removeVariable", () => { expect(result.inverse).toHaveLength(0); }); - it("inverse restores the exact removed declaration", () => { + it("inverse restores the removed declaration", () => { + // Canonical remove re-adds the declaration on undo (array position is not + // preserved), so assert the decl is restored by content rather than exact + // byte-serialize. const parsed = freshWithVars(); - const before = serializeDocument(parsed); + const original = readVarDecl(parsed, "brand-color-primary"); const result = applyOp(parsed, { type: "removeVariable", id: "brand-color-primary" }); expect(readVarDecl(parsed, "brand-color-primary")).toBeUndefined(); applyPatchesToDocument(parsed, result.inverse); - expect(serializeDocument(parsed)).toBe(before); + expect(readVarDecl(parsed, "brand-color-primary")).toEqual(original); }); }); @@ -1151,22 +1153,25 @@ describe("validateOp", () => { it("returns ok:true for declareVariable / removeVariable when a root exists", () => { expect( - validateOp(fresh(), { + validateOp(freshDoc(), { type: "declareVariable", - decl: { id: "v1", type: "string", label: "V1", default: "x" }, + declaration: { id: "v1", type: "string", label: "V1", default: "x" }, }).ok, ).toBe(true); expect(validateOp(fresh(), { type: "removeVariable", id: "v1" }).ok).toBe(true); }); - it("returns ok:false / E_NO_ROOT for declareVariable / removeVariable with no root", () => { + it("refuses declareVariable / removeVariable on a rootless fragment", () => { const parsed = parseMutable(`no elements at all — just text`); + // declareVariable runs its declaration precondition first, so a wrapped + // fragment (no real to carry the schema) surfaces E_FRAGMENT_COMPOSITION. const r1 = validateOp(parsed, { type: "declareVariable", - decl: { id: "v1", type: "string", label: "V1", default: "x" }, + declaration: { id: "v1", type: "string", label: "V1", default: "x" }, }); expect(r1.ok).toBe(false); - if (!r1.ok) expect(r1.code).toBe("E_NO_ROOT"); + if (!r1.ok) expect(r1.code).toBe("E_FRAGMENT_COMPOSITION"); + // removeVariable only needs a root; there is none → E_NO_ROOT. const r2 = validateOp(parsed, { type: "removeVariable", id: "v1" }); expect(r2.ok).toBe(false); if (!r2.ok) expect(r2.code).toBe("E_NO_ROOT"); diff --git a/packages/sdk/src/engine/mutate.ts b/packages/sdk/src/engine/mutate.ts index c9ad4f8e7..aef63242e 100644 --- a/packages/sdk/src/engine/mutate.ts +++ b/packages/sdk/src/engine/mutate.ts @@ -17,7 +17,6 @@ import type { JsonPatchOp, } from "../types.js"; import type { ParsedDocument } from "./model.js"; -import type { CompositionVariable } from "@hyperframes/core"; import { resolveScoped, escapeHfId, @@ -81,10 +80,15 @@ import { deriveKeyframeBackfillDefaults } from "./keyframeBackfill.js"; import { readVariableDefault, writeVariableDefault, - declareVariableDecl, - removeVariableDecl, - type VariableDecl, + 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, @@ -297,9 +301,15 @@ export function applyOp(parsed: ParsedDocument, op: EditOp): MutationResult { case "setVariableValue": return handleSetVariableValue(parsed, op.id, op.value); case "declareVariable": - return handleDeclareVariable(parsed, op.decl); + return handleDeclareVariable(parsed, op.declaration); + case "updateVariableDeclaration": + return handleUpdateVariableDeclaration(parsed, op.id, op.declaration); + case "removeVariableDeclaration": + return handleRemoveVariableDeclaration(parsed, op.id); case "removeVariable": - return handleRemoveVariable(parsed, op.id); + // #2098 alias — delegate to the canonical handler so its patch grammar + // and undo inverse match the rest of the variable-declaration ops. + return handleRemoveVariableDeclaration(parsed, op.id); case "setClassStyle": return handleSetClassStyle(parsed, op.selector, op.styles); case "addLabel": @@ -898,49 +908,166 @@ function handleSetVariableValue( } /** - * Declare (create or fully replace) a variable's schema entry — id/type/label/ - * default/etc. Unlike setVariableValue, this creates the `data-composition- - * variables` attribute from scratch when the composition has none yet, and - * replaces the whole decl (not just `default`) when the id already exists — - * the path a variables panel needs to add or edit a declaration, since - * setVariableValue intentionally refuses to create undeclared variables. + * 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 handleDeclareVariable(parsed: ParsedDocument, decl: CompositionVariable): MutationResult { +function cssCompatChange( + parsed: ParsedDocument, + id: string, + newVal: string | null, +): { forward: JsonPatchOp; inverse: JsonPatchOp } | null { const root = findRoot(parsed.document); - if (!root) return EMPTY; - // The storage layer treats a decl as an untyped JSON bag (VariableDecl has an - // index signature so it can round-trip arbitrary extra keys); CompositionVariable - // is a closed union with no index signature, so TS won't structurally widen it - // automatically — this is the exact boundary readDecls' own `as VariableDecl[]` - // cast already crosses for the read side. - const storageDecl = decl as unknown as VariableDecl; - const previous = declareVariableDecl(parsed.document, storageDecl); - const path = variableDeclPath(decl.id); - const p = valueChange(path, previous, storageDecl); - return { forward: [p.forward], inverse: [p.inverse] }; + 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); } /** - * Remove a variable's declaration entirely. Live `var.{id}` overrides and any - * data-var-* DOM references are left untouched — this only removes the schema - * entry, mirroring removeVariableDecl's contract. + * 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 handleRemoveVariable(parsed: ParsedDocument, id: string): MutationResult { - const root = findRoot(parsed.document); - if (!root) return EMPTY; - const removed = removeVariableDecl(parsed.document, id); - if (!removed) return EMPTY; +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[].", + ); +} + +// A variable id becomes a CSS custom-property name (`--{id}`), a `data-var-*` +// attribute value, and a CLI `--variables` key. isCompositionVariable only +// checks it is a non-empty string, so the SDK — the last gate before Studio / +// CSS / CLI make those assumptions — enforces a safe identifier shape here. +const VALID_VARIABLE_ID = /^[A-Za-z_][A-Za-z0-9_-]*$/; + +function isValidVariableId(id: string): boolean { + return VALID_VARIABLE_ID.test(id); +} + +function invalidVariableIdErr(id: string): CanResult { + return canErr( + "E_INVALID_VARIABLE_ID", + `Variable id ${JSON.stringify(id)} is not a valid identifier.`, + "Ids must match /^[A-Za-z_][A-Za-z0-9_-]*$/ — they become CSS custom-property names (--id), data-var-* attribute values, and CLI --variables keys.", + ); +} + +/** + * Shared can() precondition for declareVariable/updateVariableDeclaration: + * refuse fragment compositions, non-declaration shapes, and malformed ids. + * Returns the CanResult to surface, or null when the declaration is well-formed. + * The shape check runs before the id access so a null/non-object declaration + * yields a CanResult, not a TypeError. + */ +function declarationPreconditionErr( + parsed: ParsedDocument, + declaration: CompositionVariable, +): CanResult | null { + const fragmentErr = fragmentCompositionErr(parsed); + if (fragmentErr) return fragmentErr; + if (!isCompositionVariable(declaration)) return invalidDeclarationErr(); + if (!isValidVariableId(declaration.id)) return invalidVariableIdErr(declaration.id); + return null; +} + +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 (!isValidVariableId(declaration.id)) 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); - // Bundle the original array index so undo reinserts at the same position - // instead of appending — mirrors handleRemoveElement's {html, parentId, - // siblingIndex} inverse value. Tagged with __kind (rather than relying on - // structural "decl"/"index" key presence) because VariableDecl has an open - // index signature — a genuine variable schema could legally declare its own - // "decl"/"index" fields, which a structural check alone can't rule out. - return { + const result: MutationResult = { forward: [patchRemove(path)], - inverse: [patchAdd(path, { __kind: "reinsert", decl: removed.decl, index: removed.index })], + 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 ─────────────────────────────────────────────────── @@ -1549,11 +1676,49 @@ export function validateOp(parsed: ParsedDocument, op: EditOp): CanResult { return CAN_OK; } case "setVariableValue": - case "declareVariable": case "removeVariable": if (findRoot(parsed.document) === null) return canErr("E_NO_ROOT", "Composition root element not found."); return CAN_OK; + case "declareVariable": { + const preErr = declarationPreconditionErr(parsed, op.declaration); + if (preErr) return preErr; + 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 preErr = declarationPreconditionErr(parsed, op.declaration); + if (preErr) return preErr; + 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 61dda4431..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 ←