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 ←