Skip to content

Commit 52b7e1d

Browse files
jrusso1020claude
andcommitted
feat(sdk): variable declaration edit ops (declare/update/remove)
Second PR of the template-variables Studio stack — the SDK write surface. The SDK could edit an existing declaration's default (setVariableValue) but had no way to create, reshape, or delete declarations. These ops complete the schema-authoring surface so Studio and embedders build on one contract. - new EditOps + typed Composition wrappers: - declareVariable(declaration) — creates data-composition-variables when absent; refuses duplicates and invalid shapes - updateVariableDeclaration(id, declaration) — wholesale replace; id is immutable (rename = remove + declare); keeps the --{id} CSS compat custom prop in sync when the default changes, mirroring setVariableValue - removeVariableDeclaration(id) — drops the attribute with the last entry and clears the CSS compat prop - patch grammar: /variableDeclarations/{id} ↔ override key varDecl.{id} (distinct from /variables/{id}, which stays the default *value* path); apply-patches replays declaration patches for T3 host undo - can() validation: E_DUPLICATE_VARIABLE, E_VARIABLE_NOT_FOUND, E_INVALID_ARGS, and E_FRAGMENT_COMPOSITION — fragment sources have no <html> to carry the schema, so declaring would silently vanish on serialize; the ops refuse instead - parsers: export isCompositionVariable (the parse filter predicate) so writers validate with the exact same rules readers filter by Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 5a63475 commit 52b7e1d

10 files changed

Lines changed: 564 additions & 5 deletions

File tree

packages/core/src/variables.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ export type {
2121
export {
2222
COMPOSITION_VARIABLE_TYPES,
2323
parseCompositionVariables,
24+
isCompositionVariable,
2425
isScalarVariableValue,
2526
} from "@hyperframes/parsers/composition";
2627

packages/parsers/src/composition.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,4 +10,8 @@ export {
1010
resolveAliasDisplayName,
1111
} from "./fontAliases.js";
1212
export { decodeUrlPathVariants } from "./utils/urlPath.js";
13-
export { parseCompositionVariables, isScalarVariableValue } from "./compositionVariables.js";
13+
export {
14+
parseCompositionVariables,
15+
isCompositionVariable,
16+
isScalarVariableValue,
17+
} from "./compositionVariables.js";

packages/parsers/src/compositionVariables.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,13 @@ function isVariableType(t: unknown): t is CompositionVariableType {
4040
return typeof t === "string" && t in DEFAULT_TYPEOF;
4141
}
4242

43-
function isCompositionVariable(v: unknown): v is CompositionVariable {
43+
/**
44+
* True when the value is a structurally valid variable declaration: id, label,
45+
* a known type, a default matching that type, and options[] for enums. The
46+
* same predicate parseCompositionVariables filters with — exported so writers
47+
* (SDK declaration ops, Studio forms) can validate before persisting.
48+
*/
49+
export function isCompositionVariable(v: unknown): v is CompositionVariable {
4450
if (!isRecord(v)) return false;
4551
if (typeof v.id !== "string" || typeof v.label !== "string") return false;
4652
if (!isVariableType(v.type)) return false;

packages/sdk/src/engine/apply-patches.ts

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,21 @@ import {
1919
setStyleSheet,
2020
} from "./model.js";
2121
import { keyToPath, stylePath } from "./patches.js";
22-
import { writeVariableDefault, clearVariableDefault } from "./variableModel.js";
22+
import {
23+
writeVariableDefault,
24+
clearVariableDefault,
25+
writeVariableDeclaration,
26+
removeVariableDeclarationEntry,
27+
} from "./variableModel.js";
28+
29+
function isRawDeclarationEntry(value: unknown): value is { id: string } & Record<string, unknown> {
30+
return (
31+
typeof value === "object" &&
32+
value !== null &&
33+
!Array.isArray(value) &&
34+
typeof (value as { id?: unknown }).id === "string"
35+
);
36+
}
2337

2438
// ─── Path parser ────────────────────────────────────────────────────────────
2539

@@ -32,6 +46,7 @@ interface ParsedPath {
3246
| "hold"
3347
| "element"
3448
| "variable"
49+
| "variableDeclaration"
3550
| "metadata"
3651
| "script"
3752
| "stylesheet";
@@ -65,6 +80,9 @@ function parsePath(path: string): ParsedPath | null {
6580
const elemM = /^\/elements\/([^/]+)$/.exec(path);
6681
if (elemM) return { type: "element", id: elemM[1] };
6782

83+
const varDeclM = /^\/variableDeclarations\/(.+)$/.exec(path);
84+
if (varDeclM) return { type: "variableDeclaration", id: varDeclM[1] };
85+
6886
const varM = /^\/variables\/(.+)$/.exec(path);
6987
if (varM) return { type: "variable", id: varM[1] };
7088

@@ -229,6 +247,20 @@ function applyOne(parsed: ParsedDocument, patch: JsonPatchOp, p: ParsedPath): vo
229247
break;
230248
}
231249

250+
case "variableDeclaration": {
251+
if (!p.id) return;
252+
if (patch.op === "remove") {
253+
removeVariableDeclarationEntry(parsed.document, p.id);
254+
} else if (isRawDeclarationEntry(patch.value)) {
255+
// Replay is faithful, not strict: inverse patches capture raw entries
256+
// (loose hand-authored declarations included) and undo must restore
257+
// them verbatim — gating on isCompositionVariable here would make
258+
// undo of a remove/update on a loose entry silently no-op.
259+
writeVariableDeclaration(parsed.document, patch.value);
260+
}
261+
break;
262+
}
263+
232264
case "variable": {
233265
if (!p.id) return;
234266
// B1: update the JSON model (data-composition-variables) so

packages/sdk/src/engine/mutate.ts

Lines changed: 188 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ import {
4040
holdPath,
4141
elementPath,
4242
variablePath,
43+
variableDeclPath,
4344
metaPath,
4445
gsapScriptPath,
4546
styleSheetPath,
@@ -76,7 +77,18 @@ import {
7677
unrollDynamicAnimations,
7778
} from "@hyperframes/core/gsap-writer-acorn";
7879
import { deriveKeyframeBackfillDefaults } from "./keyframeBackfill.js";
79-
import { readVariableDefault, writeVariableDefault } from "./variableModel.js";
80+
import {
81+
readVariableDefault,
82+
writeVariableDefault,
83+
findVariableDeclaration,
84+
writeVariableDeclaration,
85+
removeVariableDeclarationEntry,
86+
} from "./variableModel.js";
87+
import {
88+
isCompositionVariable,
89+
isScalarVariableValue as isScalar,
90+
} from "@hyperframes/core/variables";
91+
import type { CompositionVariable } from "@hyperframes/core/variables";
8092
import {
8193
URI_BEARING_ATTRS,
8294
DANGEROUS_URI_SCHEMES,
@@ -288,6 +300,12 @@ export function applyOp(parsed: ParsedDocument, op: EditOp): MutationResult {
288300
return handleSetCompositionMetadata(parsed, op);
289301
case "setVariableValue":
290302
return handleSetVariableValue(parsed, op.id, op.value);
303+
case "declareVariable":
304+
return handleDeclareVariable(parsed, op.declaration);
305+
case "updateVariableDeclaration":
306+
return handleUpdateVariableDeclaration(parsed, op.id, op.declaration);
307+
case "removeVariableDeclaration":
308+
return handleRemoveVariableDeclaration(parsed, op.id);
291309
case "setClassStyle":
292310
return handleSetClassStyle(parsed, op.selector, op.styles);
293311
case "addLabel":
@@ -885,6 +903,132 @@ function handleSetVariableValue(
885903
return { forward, inverse };
886904
}
887905

906+
/**
907+
* Keep the `--{id}` CSS compat custom property on the root in sync with a
908+
* scalar default (same secondary channel handleSetVariableValue maintains).
909+
* Pass null to clear. Returns the patch pair, or null when there is no root
910+
* or nothing to change.
911+
*/
912+
function cssCompatChange(
913+
parsed: ParsedDocument,
914+
id: string,
915+
newVal: string | null,
916+
): { forward: JsonPatchOp; inverse: JsonPatchOp } | null {
917+
const root = findRoot(parsed.document);
918+
const rootId = root?.getAttribute("data-hf-id");
919+
if (!root || !rootId) return null;
920+
const cssVar = `--${id}`;
921+
const oldCssValue = getElementStyles(root)[cssVar] ?? null;
922+
if (newVal !== null) {
923+
if (oldCssValue === newVal) return null;
924+
setElementStyles(root, { [cssVar]: newVal });
925+
return scalarChange(stylePath(rootId, cssVar), oldCssValue, newVal);
926+
}
927+
if (oldCssValue === null) return null;
928+
setElementStyles(root, { [cssVar]: null });
929+
return scalarDelete(stylePath(rootId, cssVar), oldCssValue);
930+
}
931+
932+
/**
933+
* Declaration ops require a real `<html>` in the source: fragment inputs get
934+
* a synthetic wrapper that serialize() strips, so a declaration written there
935+
* would silently vanish on save.
936+
*/
937+
function fragmentCompositionErr(parsed: ParsedDocument): CanResult | null {
938+
if (!parsed.wrapped) return null;
939+
return canErr(
940+
"E_FRAGMENT_COMPOSITION",
941+
"Fragment compositions cannot carry variable declarations.",
942+
"data-composition-variables lives on the <html> element — convert the composition to a full HTML document first.",
943+
);
944+
}
945+
946+
function invalidDeclarationErr(): CanResult {
947+
return canErr(
948+
"E_INVALID_ARGS",
949+
"Not a valid variable declaration.",
950+
"Requires id, label, type (string|number|color|boolean|enum|font|image), and a default matching the type; enum also requires options[].",
951+
);
952+
}
953+
954+
function handleDeclareVariable(
955+
parsed: ParsedDocument,
956+
declaration: CompositionVariable,
957+
): MutationResult {
958+
// Defensive re-check of can(): never write an invalid or duplicate
959+
// declaration into the schema. Fragment sources have no <html> of their
960+
// own — writing to the synthetic wrapper would be lost on serialize.
961+
if (parsed.wrapped) return EMPTY;
962+
if (!isCompositionVariable(declaration)) return EMPTY;
963+
if (findVariableDeclaration(parsed.document, declaration.id) !== undefined) return EMPTY;
964+
if (!writeVariableDeclaration(parsed.document, declaration)) return EMPTY;
965+
const path = variableDeclPath(declaration.id);
966+
const result: MutationResult = {
967+
forward: [patchAdd(path, declaration)],
968+
inverse: [patchRemove(path)],
969+
};
970+
// Same CSS compat channel every other variable op maintains — a composition
971+
// CSS-bound to var(--id) must resolve regardless of which op set the value.
972+
if (isScalar(declaration.default)) {
973+
const css = cssCompatChange(parsed, declaration.id, String(declaration.default));
974+
if (css) {
975+
result.forward.push(css.forward);
976+
result.inverse.push(css.inverse);
977+
}
978+
}
979+
return result;
980+
}
981+
982+
function handleUpdateVariableDeclaration(
983+
parsed: ParsedDocument,
984+
id: string,
985+
declaration: CompositionVariable,
986+
): MutationResult {
987+
if (parsed.wrapped) return EMPTY;
988+
if (!isCompositionVariable(declaration) || declaration.id !== id) return EMPTY;
989+
const old = findVariableDeclaration(parsed.document, id);
990+
if (old === undefined) return EMPTY;
991+
writeVariableDeclaration(parsed.document, declaration);
992+
const p = valueChange(variableDeclPath(id), old, declaration);
993+
const result: MutationResult = { forward: [p.forward], inverse: [p.inverse] };
994+
995+
// Default changed → keep the CSS compat prop in sync (set for scalars,
996+
// clear when the new default is object-valued font/image), and emit the
997+
// paired /variables value patch so the T3 override-set's var.{id} entry
998+
// agrees with the varDecl.{id} snapshot regardless of replay order.
999+
const oldDefault = old.default;
1000+
const newDefault = declaration.default;
1001+
if (JSON.stringify(oldDefault) !== JSON.stringify(newDefault)) {
1002+
const valueP = valueChange(variablePath(id), oldDefault ?? null, newDefault);
1003+
result.forward.push(valueP.forward);
1004+
result.inverse.push(valueP.inverse);
1005+
const css = cssCompatChange(parsed, id, isScalar(newDefault) ? String(newDefault) : null);
1006+
if (css) {
1007+
result.forward.push(css.forward);
1008+
result.inverse.push(css.inverse);
1009+
}
1010+
}
1011+
return result;
1012+
}
1013+
1014+
function handleRemoveVariableDeclaration(parsed: ParsedDocument, id: string): MutationResult {
1015+
if (parsed.wrapped) return EMPTY;
1016+
const old = findVariableDeclaration(parsed.document, id);
1017+
if (old === undefined) return EMPTY;
1018+
removeVariableDeclarationEntry(parsed.document, id);
1019+
const path = variableDeclPath(id);
1020+
const result: MutationResult = {
1021+
forward: [patchRemove(path)],
1022+
inverse: [patchAdd(path, old)],
1023+
};
1024+
const css = cssCompatChange(parsed, id, null);
1025+
if (css) {
1026+
result.forward.push(css.forward);
1027+
result.inverse.push(css.inverse);
1028+
}
1029+
return result;
1030+
}
1031+
8881032
// ─── GSAP selector helpers ───────────────────────────────────────────────────
8891033

8901034
function selectorMatchesId(selector: string, id: HfId): boolean {
@@ -1494,6 +1638,49 @@ export function validateOp(parsed: ParsedDocument, op: EditOp): CanResult {
14941638
if (findRoot(parsed.document) === null)
14951639
return canErr("E_NO_ROOT", "Composition root element not found.");
14961640
return CAN_OK;
1641+
case "declareVariable": {
1642+
const fragmentErr = fragmentCompositionErr(parsed);
1643+
if (fragmentErr) return fragmentErr;
1644+
if (!isCompositionVariable(op.declaration)) return invalidDeclarationErr();
1645+
if (findVariableDeclaration(parsed.document, op.declaration.id) !== undefined)
1646+
return canErr(
1647+
"E_DUPLICATE_VARIABLE",
1648+
`Variable "${op.declaration.id}" is already declared.`,
1649+
"Use updateVariableDeclaration to change it, or setVariableValue to change its default.",
1650+
);
1651+
return CAN_OK;
1652+
}
1653+
case "updateVariableDeclaration": {
1654+
const fragmentErr = fragmentCompositionErr(parsed);
1655+
if (fragmentErr) return fragmentErr;
1656+
// Shape check FIRST — a null/non-object declaration must yield a
1657+
// CanResult, not a TypeError from the id access below.
1658+
if (!isCompositionVariable(op.declaration)) return invalidDeclarationErr();
1659+
if (op.declaration.id !== op.id)
1660+
return canErr(
1661+
"E_INVALID_ARGS",
1662+
`declaration.id ("${op.declaration.id}") must match id ("${op.id}").`,
1663+
"Variable ids are immutable — rename via removeVariableDeclaration + declareVariable.",
1664+
);
1665+
if (findVariableDeclaration(parsed.document, op.id) === undefined)
1666+
return canErr(
1667+
"E_VARIABLE_NOT_FOUND",
1668+
`Variable "${op.id}" is not declared.`,
1669+
"Check comp.getVariableDeclarations(), or add it with declareVariable.",
1670+
);
1671+
return CAN_OK;
1672+
}
1673+
case "removeVariableDeclaration": {
1674+
const fragmentErr = fragmentCompositionErr(parsed);
1675+
if (fragmentErr) return fragmentErr;
1676+
if (findVariableDeclaration(parsed.document, op.id) === undefined)
1677+
return canErr(
1678+
"E_VARIABLE_NOT_FOUND",
1679+
`Variable "${op.id}" is not declared.`,
1680+
"Check comp.getVariableDeclarations().",
1681+
);
1682+
return CAN_OK;
1683+
}
14971684
case "setCompositionMetadata":
14981685
case "setClassStyle":
14991686
return CAN_OK;

packages/sdk/src/engine/patches.ts

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,8 @@
88
* /elements/{hfId}/timing/{start|end|duration|trackIndex} ← end = computed absolute data-end
99
* /elements/{hfId}/hold/{start|end|fill}
1010
* /elements/{hfId} ← whole subtree (removeElement)
11-
* /variables/{variableId}
11+
* /variables/{variableId} ← declaration's default value
12+
* /variableDeclarations/{variableId} ← whole declaration object
1213
* /metadata/{width|height|duration}
1314
* /script/gsap ← GSAP inline script textContent
1415
* /style/css ← <style> element textContent
@@ -21,6 +22,7 @@
2122
* /elements/hf-x/hold/start → "hf-x.hold.start"
2223
* /elements/hf-x → "hf-x" (null = removal marker)
2324
* /variables/brand-color-primary → "var.brand-color-primary"
25+
* /variableDeclarations/brand-color-primary → "varDecl.brand-color-primary"
2426
* /metadata/width → "meta.width"
2527
* /script/gsap → "script.gsap"
2628
* /style/css → "style.css"
@@ -75,6 +77,11 @@ export function variablePath(id: string): string {
7577
return `/variables/${id}`;
7678
}
7779

80+
/** Whole-declaration path — distinct from /variables/{id}, which is the default *value*. */
81+
export function variableDeclPath(id: string): string {
82+
return `/variableDeclarations/${id}`;
83+
}
84+
7885
export function metaPath(field: "width" | "height" | "duration"): string {
7986
return `/metadata/${field}`;
8087
}
@@ -120,6 +127,10 @@ export function pathToKey(path: string): string | null {
120127
const elemMatch = /^\/elements\/([^/]+)$/.exec(path);
121128
if (elemMatch) return decodePathSegment(elemMatch[1]!);
122129

130+
// /variableDeclarations/{id} → "varDecl.{id}" (checked before /variables/)
131+
const varDeclMatch = /^\/variableDeclarations\/(.+)$/.exec(path);
132+
if (varDeclMatch) return `varDecl.${varDeclMatch[1]}`;
133+
123134
// /variables/{id} → "var.{id}"
124135
const varMatch = /^\/variables\/(.+)$/.exec(path);
125136
if (varMatch) return `var.${varMatch[1]}`;
@@ -141,6 +152,8 @@ export function pathToKey(path: string): string | null {
141152
* Inverse of pathToKey — maps an override-set key back to its RFC 6902 path.
142153
* Used to replay a stored override-set onto a fresh base document (T3 init).
143154
*/
155+
// Exhaustive key-family dispatcher — same shape as apply-patches.ts parsePath.
156+
// fallow-ignore-next-line complexity
144157
export function keyToPath(key: string): string | null {
145158
const style = /^([^.]+)\.style\.(.+)$/.exec(key);
146159
if (style?.[1] && style[2]) return stylePath(style[1], style[2]);
@@ -161,6 +174,9 @@ export function keyToPath(key: string): string | null {
161174
const hold = /^([^.]+)\.hold\.(start|end|fill)$/.exec(key);
162175
if (hold?.[1]) return holdPath(hold[1], hold[2] as "start" | "end" | "fill");
163176

177+
const varDecl = /^varDecl\.(.+)$/.exec(key);
178+
if (varDecl?.[1]) return variableDeclPath(varDecl[1]);
179+
164180
const variable = /^var\.(.+)$/.exec(key);
165181
if (variable?.[1]) return variablePath(variable[1]);
166182

0 commit comments

Comments
 (0)