feat(sdk): variable declaration edit ops (declare/update/remove) (#2047)

This commit is contained in:
James Russo
2026-07-09 13:29:24 -07:00
committed by GitHub
parent fcbd4cb0f6
commit 839881a98e
13 changed files with 664 additions and 204 deletions
+1
View File
@@ -21,6 +21,7 @@ export type {
export {
COMPOSITION_VARIABLE_TYPES,
parseCompositionVariables,
isCompositionVariable,
isScalarVariableValue,
} from "@hyperframes/parsers/composition";
+5 -1
View File
@@ -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";
+7 -1
View File
@@ -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;
@@ -220,8 +220,10 @@ window.__timelines = { t: tl };</script>
});
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 = `<!DOCTYPE html><html><body>${BASE_HTML}</body></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 };</script>
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 () => {
+29 -36
View File
@@ -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<string, unknown> {
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, "");
+47 -42
View File
@@ -31,6 +31,13 @@ function fresh() {
return parseMutable(BASE_HTML);
}
// Full document (BASE_HTML wrapped in <html>) with NO declarations — the shape
// declareVariable requires (it refuses fragment sources whose synthetic <html>
// is stripped on serialize).
function freshDoc() {
return parseMutable(`<!DOCTYPE html><html><body>${BASE_HTML}</body></html>`);
}
/** Full HTML fixture with data-composition-variables for B1/B2 tests. */
const VARIABLES_HTML = `<!DOCTYPE html>
<html data-composition-id="c1" data-composition-duration="5" data-composition-variables='${JSON.stringify(
@@ -930,11 +937,11 @@ function readVarDecl(
describe("declareVariable", () => {
it("creates the data-composition-variables attribute from scratch when absent", () => {
const parsed = fresh(); // BASE_HTML has no data-composition-variables at all
const parsed = freshDoc(); // full doc, no data-composition-variables at all
expect(parsed.document.documentElement?.getAttribute("data-composition-variables")).toBeNull();
applyOp(parsed, {
type: "declareVariable",
decl: { id: "brand-title", type: "string", label: "Title", default: "Hello" },
declaration: { id: "brand-title", type: "string", label: "Title", default: "Hello" },
});
expect(readVarDecl(parsed, "brand-title")).toEqual({
id: "brand-title",
@@ -948,17 +955,35 @@ describe("declareVariable", () => {
const parsed = freshWithVars();
applyOp(parsed, {
type: "declareVariable",
decl: { id: "brand-tagline", type: "string", label: "Tagline", default: "Ship it" },
declaration: { id: "brand-tagline", type: "string", label: "Tagline", default: "Ship it" },
});
expect(readVarDecl(parsed, "brand-color-primary")).toBeDefined(); // untouched
expect(readVarDecl(parsed, "brand-tagline")?.default).toBe("Ship it");
});
it("replaces the WHOLE existing decl (not just default) when the id already exists", () => {
it("declareVariable no-ops on an existing id; updateVariableDeclaration replaces the whole decl", () => {
const parsed = freshWithVars();
// Canonical semantics: declareVariable creates only — re-declaring an existing
// id is a no-op; updateVariableDeclaration is the path that replaces a decl.
applyOp(parsed, {
type: "declareVariable",
decl: { id: "brand-color-primary", type: "color", label: "Renamed", default: "#00ff00" },
declaration: {
id: "brand-color-primary",
type: "color",
label: "Ignored",
default: "#111111",
},
});
expect(readVarDecl(parsed, "brand-color-primary")?.label).not.toBe("Ignored");
applyOp(parsed, {
type: "updateVariableDeclaration",
id: "brand-color-primary",
declaration: {
id: "brand-color-primary",
type: "color",
label: "Renamed",
default: "#00ff00",
},
});
const decl = readVarDecl(parsed, "brand-color-primary");
expect(decl?.label).toBe("Renamed");
@@ -966,7 +991,7 @@ describe("declareVariable", () => {
});
it("succeeds where setVariableValue would refuse — creating an undeclared variable", () => {
const parsed = fresh();
const parsed = freshDoc();
// setVariableValue on an undeclared id still writes the --{id} CSS compat
// prop unconditionally (for CSS-only compositions with no JSON schema at
// all) — but the JSON model write itself no-ops, per writeVariableDefault's
@@ -976,7 +1001,7 @@ describe("declareVariable", () => {
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 <html> 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");
+208 -43
View File
@@ -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 `<html>` 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;
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 {
forward: [patchRemove(path)],
inverse: [patchAdd(path, { __kind: "reinsert", decl: removed.decl, index: removed.index })],
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 <html> 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 <html> 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);
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 ───────────────────────────────────────────────────
@@ -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;
+14 -3
View File
@@ -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 <style> element textContent
@@ -21,6 +22,7 @@
* /elements/hf-x/hold/start "hf-x.hold.start"
* /elements/hf-x "hf-x" (null = removal marker)
* /variables/brand-color-primary "var.brand-color-primary"
* /variableDeclarations/brand-color-primary "varDecl.brand-color-primary"
* /metadata/width "meta.width"
* /script/gsap "script.gsap"
* /style/css "style.css"
@@ -75,9 +77,9 @@ export function variablePath(id: string): string {
return `/variables/${id}`;
}
/** Distinct from variablePath — that's the `default` field only; this is the whole decl. */
/** Whole-declaration path — distinct from /variables/{id}, which is the default *value*. */
export function variableDeclPath(id: string): string {
return `/variable-decls/${id}`;
return `/variableDeclarations/${id}`;
}
export function metaPath(field: "width" | "height" | "duration"): string {
@@ -125,6 +127,10 @@ export function pathToKey(path: string): string | null {
const elemMatch = /^\/elements\/([^/]+)$/.exec(path);
if (elemMatch) return decodePathSegment(elemMatch[1]!);
// /variableDeclarations/{id} → "varDecl.{id}" (checked before /variables/)
const varDeclMatch = /^\/variableDeclarations\/(.+)$/.exec(path);
if (varDeclMatch) return `varDecl.${varDeclMatch[1]}`;
// /variables/{id} → "var.{id}"
const varMatch = /^\/variables\/(.+)$/.exec(path);
if (varMatch) return `var.${varMatch[1]}`;
@@ -146,6 +152,8 @@ export function pathToKey(path: string): string | null {
* Inverse of pathToKey maps an override-set key back to its RFC 6902 path.
* Used to replay a stored override-set onto a fresh base document (T3 init).
*/
// Exhaustive key-family dispatcher — same shape as apply-patches.ts parsePath.
// fallow-ignore-next-line complexity
export function keyToPath(key: string): string | null {
const style = /^([^.]+)\.style\.(.+)$/.exec(key);
if (style?.[1] && style[2]) return stylePath(style[1], style[2]);
@@ -166,6 +174,9 @@ export function keyToPath(key: string): string | null {
const hold = /^([^.]+)\.hold\.(start|end|fill)$/.exec(key);
if (hold?.[1]) return holdPath(hold[1], hold[2] as "start" | "end" | "fill");
const varDecl = /^varDecl\.(.+)$/.exec(key);
if (varDecl?.[1]) return variableDeclPath(varDecl[1]);
const variable = /^var\.(.+)$/.exec(key);
if (variable?.[1]) return variablePath(variable[1]);
+65 -57
View File
@@ -51,6 +51,68 @@ export function readVariableDeclarations(document: Document): CompositionVariabl
return parseCompositionVariables(htmlEl);
}
/**
* Find the raw declaration entry for a variable id, verbatim (unvalidated).
* Returns undefined when the attribute is absent, the JSON is invalid, or no
* entry matches the id.
*/
export function findVariableDeclaration(document: Document, id: string): VariableDecl | undefined {
const decls = readDecls(document);
if (!decls) return undefined;
const idx = indexOfId(decls.arr, id);
return idx < 0 ? undefined : decls.arr[idx];
}
/**
* Upsert a whole variable declaration by its id. Creates the
* `data-composition-variables` attribute when absent; replaces an unparseable
* attribute with a fresh single-entry array (the prior content was invisible
* to every reader anyway). Returns false only when the document has no root
* element to carry the attribute.
*
* Accepts raw (unvalidated) entries as well as typed declarations: the patch
* REPLAY path must faithfully restore whatever entry the inverse patch
* captured including loose hand-authored declarations the strict parser
* would drop or undo silently diverges from history.
*/
export function writeVariableDeclaration(
document: Document,
declaration: CompositionVariable | ({ id: string } & Record<string, unknown>),
): boolean {
const htmlEl = getHtmlEl(document);
if (!htmlEl) return false;
const decls = readDecls(document);
const arr = decls?.arr ?? [];
const idx = indexOfId(arr, declaration.id);
const entry: VariableDecl = { ...declaration };
if (idx < 0) {
arr.push(entry);
} else {
arr[idx] = entry;
}
(decls?.htmlEl ?? htmlEl).setAttribute("data-composition-variables", JSON.stringify(arr));
return true;
}
/**
* Remove a variable declaration by id. Drops the whole attribute when the
* last declaration is removed (an empty `[]` is noise in authored HTML).
* No-ops (returns false) when the attribute or the entry is absent.
*/
export function removeVariableDeclarationEntry(document: Document, id: string): boolean {
const decls = readDecls(document);
if (!decls) return false;
const idx = indexOfId(decls.arr, id);
if (idx < 0) return false;
decls.arr.splice(idx, 1);
if (decls.arr.length === 0) {
decls.htmlEl.removeAttribute("data-composition-variables");
} else {
decls.htmlEl.setAttribute("data-composition-variables", JSON.stringify(decls.arr));
}
return true;
}
/**
* Read the current `default` value for a variable id. Returns undefined when
* the attribute is absent, the JSON is invalid, or no entry matches the id.
@@ -101,60 +163,6 @@ export function listVariableDecls(document: Document): VariableDecl[] {
return readDecls(document)?.arr ?? [];
}
/**
* Upsert a full variable declaration (id/type/label/default/), unlike
* writeVariableDefault which only ever touches the `default` field of an
* ALREADY-declared variable and refuses to create new ones. This is the
* "let someone add a variable" path a declarations panel needs creates the
* `data-composition-variables` attribute from scratch when absent.
*
* Replaces the whole existing decl when `decl.id` is already declared (so
* editing a variable's type/label/options goes through the same call as
* creating one). Returns the previous decl (for inverse-patch capture) or
* null when this was a fresh create.
*/
export function declareVariableDecl(
document: Document,
decl: VariableDecl,
opts?: { atIndex?: number },
): VariableDecl | null {
const htmlEl = getHtmlEl(document);
if (!htmlEl) return null;
const existing = readDecls(document);
const arr = existing?.arr ?? [];
const idx = indexOfId(arr, decl.id);
const previous = idx < 0 ? null : arr[idx]!;
if (idx >= 0) {
arr[idx] = decl; // edit in place — position is already preserved
} else if (opts?.atIndex !== undefined) {
// Undo of removeVariable: reinsert at the exact index it was removed
// from, so a remove-then-undo round-trips the array order, not just
// set-membership (mirrors handleRemoveElement's siblingIndex).
arr.splice(opts.atIndex, 0, decl);
} else {
arr.push(decl); // a genuinely new declaration goes to the end of the list
}
htmlEl.setAttribute("data-composition-variables", JSON.stringify(arr));
return previous;
}
/**
* Remove a variable's declaration entirely (not just its default the whole
* schema entry). Live `var.{id}` overrides and any data-var-* DOM references
* are left untouched; removing the declaration doesn't reach into either.
* Returns the removed decl AND its array index (for inverse-patch capture, so
* undo can reinsert at the original position mirrors handleRemoveElement's
* siblingIndex), or null when the attribute/decl was absent.
*/
export function removeVariableDecl(
document: Document,
id: string,
): { decl: VariableDecl; index: number } | null {
const decls = readDecls(document);
if (!decls) return null;
const idx = indexOfId(decls.arr, id);
if (idx < 0) return null;
const [removed] = decls.arr.splice(idx, 1);
decls.htmlEl.setAttribute("data-composition-variables", JSON.stringify(decls.arr));
return removed ? { decl: removed, index: idx } : null;
}
// #2098's declareVariableDecl / removeVariableDecl removed in the #2098
// reconciliation — the canonical writeVariableDeclaration / removeVariableDeclarationEntry
// above are the single source; the edit ops route through those.
+3 -1
View File
@@ -597,7 +597,9 @@ describe("variable declarations (Composition API)", () => {
});
it("declareVariable can create where setVariableValue's model write silently no-ops", async () => {
const comp = await openComposition(BASE_HTML); // no data-composition-variables at all
// Full document (not a fragment): declareVariable refuses fragment sources,
// whose synthetic <html> is stripped on serialize. No declarations yet.
const comp = await openComposition(`<!DOCTYPE html><html><body>${BASE_HTML}</body></html>`);
comp.setVariableValue("never-declared", "x");
expect(comp.getVariableValue("never-declared")).toBeUndefined();
comp.declareVariable({ id: "never-declared", type: "string", label: "New", default: "x" });
+16 -6
View File
@@ -157,7 +157,7 @@ class CompositionImpl implements Composition {
this.dispatch({ type: "setVariableValue", id, value });
}
// ── #2098 CRUD surface (kept; superseded by the richer API below in #2047+) ──
// ── #2098 CRUD conveniences (coexist with the canonical surface below) ──
getVariableValue(id: string): string | number | boolean | FontValue | ImageValue | undefined {
// readVariableDefault genuinely can't narrow beyond unknown — the schema
// isn't validated at read time — so the cast lives here at the SDK
@@ -178,15 +178,25 @@ class CompositionImpl implements Composition {
return listVariableDecls(this.parsed.document) as unknown as CompositionVariable[];
}
declareVariable(decl: CompositionVariable): void {
this.dispatch({ type: "declareVariable", decl });
}
removeVariable(id: string): void {
this.dispatch({ type: "removeVariable", id });
}
// ── Canonical read surface (this stack) ──
// ── Canonical declaration edit ops (this stack) ──
// declareVariable unified onto the {declaration} op payload (#2098 used
// {decl}); the method signature is identical, so #2098 callers are unaffected.
declareVariable(declaration: CompositionVariable): void {
this.dispatch({ type: "declareVariable", declaration });
}
updateVariableDeclaration(id: string, declaration: CompositionVariable): void {
this.dispatch({ type: "updateVariableDeclaration", id, declaration });
}
removeVariableDeclaration(id: string): void {
this.dispatch({ type: "removeVariableDeclaration", id });
}
getVariableDeclarations(): CompositionVariable[] {
return readVariableDeclarations(this.parsed.document);
}
@@ -0,0 +1,232 @@
/**
* Variable declaration edit ops: declareVariable / updateVariableDeclaration /
* removeVariableDeclaration. Covers dispatch semantics, can() validation,
* CSS compat sync, patch grammar, and undo round-trips.
*/
import { describe, it, expect } from "vitest";
import { openComposition } from "./session.js";
import { variableDeclPath, pathToKey, keyToPath } from "./engine/patches.js";
import type { CompositionVariable } from "@hyperframes/core/variables";
const TITLE_DECL: CompositionVariable = {
id: "title",
type: "string",
label: "Title",
default: "Hello",
};
const COUNT_DECL: CompositionVariable = {
id: "count",
type: "number",
label: "Count",
default: 3,
min: 0,
max: 10,
};
const BARE_HTML = `
<div data-hf-id="hf-stage" data-hf-root style="width: 1280px; height: 720px" data-duration="5">
<h1 data-hf-id="hf-title" data-start="0" data-end="3">Hello</h1>
</div>
`.trim();
const DECLARED_HTML = `<!DOCTYPE html>
<html data-composition-variables='${JSON.stringify([TITLE_DECL, COUNT_DECL])}'>
<body>${BARE_HTML}</body>
</html>`;
const UNDECLARED_HTML = `<!DOCTYPE html>
<html>
<body>${BARE_HTML}</body>
</html>`;
describe("declareVariable", () => {
it("creates the attribute on a composition with no declarations", async () => {
const comp = await openComposition(UNDECLARED_HTML);
expect(comp.getVariableDeclarations()).toEqual([]);
comp.declareVariable(TITLE_DECL);
expect(comp.getVariableDeclarations()).toEqual([TITLE_DECL]);
expect(comp.serialize()).toContain("data-composition-variables");
});
it("appends to existing declarations and survives serialize round-trip", async () => {
const comp = await openComposition(DECLARED_HTML);
comp.declareVariable({ id: "dark", type: "boolean", label: "Dark", default: false });
expect(comp.getVariableDeclarations().map((d) => d.id)).toEqual(["title", "count", "dark"]);
const reopened = await openComposition(comp.serialize());
expect(reopened.getVariableDeclarations().map((d) => d.id)).toEqual(["title", "count", "dark"]);
});
it("no-ops on duplicate ids and can() reports E_DUPLICATE_VARIABLE", async () => {
const comp = await openComposition(DECLARED_HTML);
const dup = comp.can({ type: "declareVariable", declaration: TITLE_DECL });
expect(dup).toMatchObject({ ok: false, code: "E_DUPLICATE_VARIABLE" });
comp.declareVariable({ ...TITLE_DECL, default: "clobbered" });
expect(comp.getVariableDeclarations().find((d) => d.id === "title")?.default).toBe("Hello");
});
it("rejects structurally invalid declarations", async () => {
const comp = await openComposition(UNDECLARED_HTML);
const invalid = {
id: "broken",
type: "number",
label: "Broken",
default: "not-a-number",
} as unknown as CompositionVariable;
expect(comp.can({ type: "declareVariable", declaration: invalid })).toMatchObject({
ok: false,
code: "E_INVALID_ARGS",
});
comp.declareVariable(invalid);
expect(comp.getVariableDeclarations()).toEqual([]);
});
it("rejects ids that are not valid CSS/attribute identifiers", async () => {
const comp = await openComposition(UNDECLARED_HTML);
for (const id of ["", " ", "has space", "1leading", "dot.id", "sla/sh", 'quo"te']) {
expect(
comp.can({ type: "declareVariable", declaration: { ...TITLE_DECL, id } }),
).toMatchObject({ ok: false, code: "E_INVALID_VARIABLE_ID" });
comp.declareVariable({ ...TITLE_DECL, id });
}
expect(comp.getVariableDeclarations()).toEqual([]);
// Sanity: a valid id with the allowed charset still declares.
comp.declareVariable({ ...TITLE_DECL, id: "brand_color-2" });
expect(comp.getVariableDeclarations().map((d) => d.id)).toEqual(["brand_color-2"]);
});
it("undo removes the declaration again", async () => {
const comp = await openComposition(UNDECLARED_HTML);
comp.declareVariable(TITLE_DECL);
comp.undo();
expect(comp.getVariableDeclarations()).toEqual([]);
expect(comp.serialize()).not.toContain("data-composition-variables");
comp.redo();
expect(comp.getVariableDeclarations()).toEqual([TITLE_DECL]);
});
it("refuses fragment compositions (no <html> to carry the schema)", async () => {
const comp = await openComposition(BARE_HTML);
expect(comp.can({ type: "declareVariable", declaration: TITLE_DECL })).toMatchObject({
ok: false,
code: "E_FRAGMENT_COMPOSITION",
});
comp.declareVariable(TITLE_DECL);
// Nothing written, nothing lost on serialize.
expect(comp.getVariableDeclarations()).toEqual([]);
expect(comp.serialize()).not.toContain("data-composition-variables");
});
});
describe("updateVariableDeclaration", () => {
it("replaces the declaration wholesale", async () => {
const comp = await openComposition(DECLARED_HTML);
comp.updateVariableDeclaration("count", { ...COUNT_DECL, label: "Item count", max: 20 });
const decl = comp.getVariableDeclarations().find((d) => d.id === "count");
expect(decl).toMatchObject({ label: "Item count", max: 20, default: 3 });
});
it("syncs the CSS compat prop when a scalar default changes", async () => {
const comp = await openComposition(DECLARED_HTML);
comp.updateVariableDeclaration("count", { ...COUNT_DECL, default: 7 });
const root = comp.getElements().find((e) => e.id === "hf-stage");
expect(root?.inlineStyles["--count"]).toBe("7");
});
it("keeps CSS untouched when the default is unchanged", async () => {
const comp = await openComposition(DECLARED_HTML);
comp.updateVariableDeclaration("count", { ...COUNT_DECL, label: "Renamed only" });
const root = comp.getElements().find((e) => e.id === "hf-stage");
expect(root?.inlineStyles["--count"]).toBeUndefined();
});
it("validates id immutability and existence via can()", async () => {
const comp = await openComposition(DECLARED_HTML);
expect(
comp.can({ type: "updateVariableDeclaration", id: "count", declaration: TITLE_DECL }),
).toMatchObject({ ok: false, code: "E_INVALID_ARGS" });
expect(
comp.can({
type: "updateVariableDeclaration",
id: "ghost",
declaration: { ...TITLE_DECL, id: "ghost" },
}),
).toMatchObject({ ok: false, code: "E_VARIABLE_NOT_FOUND" });
});
it("undo restores the previous declaration", async () => {
const comp = await openComposition(DECLARED_HTML);
comp.updateVariableDeclaration("title", { ...TITLE_DECL, label: "Headline" });
comp.undo();
expect(comp.getVariableDeclarations().find((d) => d.id === "title")?.label).toBe("Title");
});
});
describe("removeVariableDeclaration", () => {
it("removes the entry and drops the attribute with the last one", async () => {
const comp = await openComposition(DECLARED_HTML);
comp.removeVariableDeclaration("count");
expect(comp.getVariableDeclarations().map((d) => d.id)).toEqual(["title"]);
comp.removeVariableDeclaration("title");
expect(comp.getVariableDeclarations()).toEqual([]);
expect(comp.serialize()).not.toContain("data-composition-variables");
});
it("clears the CSS compat prop and undo restores declaration + CSS", async () => {
const comp = await openComposition(DECLARED_HTML);
comp.setVariableValue("count", 5);
const rootBefore = comp.getElements().find((e) => e.id === "hf-stage");
expect(rootBefore?.inlineStyles["--count"]).toBe("5");
comp.removeVariableDeclaration("count");
const rootAfter = comp.getElements().find((e) => e.id === "hf-stage");
expect(rootAfter?.inlineStyles["--count"]).toBeUndefined();
expect(comp.getVariableDeclarations().map((d) => d.id)).toEqual(["title"]);
comp.undo();
const rootRestored = comp.getElements().find((e) => e.id === "hf-stage");
expect(rootRestored?.inlineStyles["--count"]).toBe("5");
expect(comp.getVariableDeclarations().find((d) => d.id === "count")?.default).toBe(5);
});
it("no-ops on unknown ids and can() reports E_VARIABLE_NOT_FOUND", async () => {
const comp = await openComposition(DECLARED_HTML);
expect(comp.can({ type: "removeVariableDeclaration", id: "ghost" })).toMatchObject({
ok: false,
code: "E_VARIABLE_NOT_FOUND",
});
comp.removeVariableDeclaration("ghost");
expect(comp.getVariableDeclarations().map((d) => d.id)).toEqual(["title", "count"]);
});
});
describe("patch grammar", () => {
it("maps /variableDeclarations/{id} ↔ varDecl.{id} without colliding with /variables/", () => {
const path = variableDeclPath("brand-color");
expect(path).toBe("/variableDeclarations/brand-color");
expect(pathToKey(path)).toBe("varDecl.brand-color");
expect(keyToPath("varDecl.brand-color")).toBe(path);
// The value path family must stay untouched.
expect(pathToKey("/variables/brand-color")).toBe("var.brand-color");
expect(keyToPath("var.brand-color")).toBe("/variables/brand-color");
});
it("emits declaration patches on dispatch", async () => {
const comp = await openComposition(UNDECLARED_HTML);
const events: string[] = [];
comp.on("patch", (e) => {
for (const p of e.patches) events.push(`${p.op} ${p.path}`);
});
comp.declareVariable(TITLE_DECL);
comp.removeVariableDeclaration("title");
// Declaration ops also maintain the --{id} CSS compat prop (scalar defaults).
expect(events).toEqual([
"add /variableDeclarations/title",
"add /elements/hf-stage/inlineStyles/--title",
"remove /variableDeclarations/title",
"remove /elements/hf-stage/inlineStyles/--title",
]);
});
});
+31 -11
View File
@@ -112,12 +112,15 @@ export type EditOp =
}
| { type: "setClassStyle"; selector: string; styles: Record<string, string | null> }
| { type: "setCompositionMetadata"; width?: number; height?: number; duration?: number }
| { type: "declareVariable"; declaration: CompositionVariable }
| { type: "updateVariableDeclaration"; id: string; declaration: CompositionVariable }
| { type: "removeVariableDeclaration"; id: string }
| {
type: "setVariableValue";
id: string;
value: string | number | boolean | FontValue | ImageValue;
}
| { type: "declareVariable"; decl: CompositionVariable }
// #2098 alias op — remove-by-id, kept for its shipped session.removeVariable().
| { type: "removeVariable"; id: string }
| { type: "addGsapTween"; target: HfId; tween: GsapTweenSpec }
| { type: "setGsapTween"; animationId: string; properties: Partial<GsapTweenSpec> }
@@ -409,19 +412,36 @@ export interface Composition {
*/
addElement(parent: HfId | null, index: number, html: string): HfId;
setVariableValue(id: string, value: string | number | boolean | FontValue | ImageValue): void;
/** Current `default` value for a declared variable, or undefined if undeclared/unset. */
getVariableValue(id: string): string | number | boolean | FontValue | ImageValue | undefined;
/** Every declared variable's full schema (id/type/label/default/…), or [] when none. */
listVariables(): CompositionVariable[];
/**
* Create a new variable declaration, or fully replace an existing one (type/
* label/default/etc, not just the value use setVariableValue for that).
* The path setVariableValue can't take: it refuses to create undeclared
* variables by design, keeping the schema authoritative.
* Current `default` value for a declared variable, or undefined if
* undeclared/unset. Convenience over getVariableValues() (kept from #2098).
*/
declareVariable(decl: CompositionVariable): void;
/** Remove a variable's declaration. Live `var.{id}` overrides are untouched. */
getVariableValue(id: string): string | number | boolean | FontValue | ImageValue | undefined;
/**
* Every declared variable's full schema (id/type/label/default/), or [] when
* none. Alias of getVariableDeclarations() (kept from #2098's surface).
*/
listVariables(): CompositionVariable[];
/** Remove a variable's declaration — alias of removeVariableDeclaration(). */
removeVariable(id: string): void;
/**
* Declare a new variable in `data-composition-variables`. No-ops when the
* id is already declared (see can() for the E_DUPLICATE_VARIABLE check);
* creates the attribute when the composition has none yet.
*/
declareVariable(declaration: CompositionVariable): void;
/**
* Replace an existing declaration wholesale (label, type, constraints,
* default the id itself is immutable; rename = remove + declare). When
* the default changes to/from a scalar, the `--{id}` CSS compat custom
* property on the root is kept in sync, mirroring setVariableValue.
*/
updateVariableDeclaration(id: string, declaration: CompositionVariable): void;
/**
* Remove a declaration (and the last one removes the whole attribute).
* Also clears the `--{id}` CSS compat custom property if present.
*/
removeVariableDeclaration(id: string): void;
/**
* Read the typed variable declarations from `data-composition-variables`
* (the canonical schema same filter the render pipeline uses; malformed