Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/core/src/variables.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ export type {
export {
COMPOSITION_VARIABLE_TYPES,
parseCompositionVariables,
isCompositionVariable,
isScalarVariableValue,
} from "@hyperframes/parsers/composition";

Expand Down
6 changes: 5 additions & 1 deletion packages/parsers/src/composition.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,4 +10,8 @@ export {
resolveAliasDisplayName,
} from "./fontAliases.js";
export { decodeUrlPathVariants } from "./utils/urlPath.js";
export { parseCompositionVariables } from "./compositionVariables.js";
export {
parseCompositionVariables,
isCompositionVariable,
isScalarVariableValue,
} from "./compositionVariables.js";
8 changes: 7 additions & 1 deletion packages/parsers/src/compositionVariables.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
34 changes: 33 additions & 1 deletion packages/sdk/src/engine/apply-patches.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,21 @@ import {
setStyleSheet,
} from "./model.js";
import { keyToPath, stylePath } from "./patches.js";
import { writeVariableDefault, clearVariableDefault } from "./variableModel.js";
import {
writeVariableDefault,
clearVariableDefault,
writeVariableDeclaration,
removeVariableDeclarationEntry,
} from "./variableModel.js";

function isRawDeclarationEntry(value: unknown): value is { id: string } & Record<string, unknown> {
return (
typeof value === "object" &&
value !== null &&
!Array.isArray(value) &&
typeof (value as { id?: unknown }).id === "string"
);
}

// ─── Path parser ────────────────────────────────────────────────────────────

Expand All @@ -32,6 +46,7 @@ interface ParsedPath {
| "hold"
| "element"
| "variable"
| "variableDeclaration"
| "metadata"
| "script"
| "stylesheet";
Expand Down Expand Up @@ -65,6 +80,9 @@ function parsePath(path: string): ParsedPath | null {
const elemM = /^\/elements\/([^/]+)$/.exec(path);
if (elemM) return { type: "element", id: elemM[1] };

const varDeclM = /^\/variableDeclarations\/(.+)$/.exec(path);
if (varDeclM) return { type: "variableDeclaration", id: varDeclM[1] };

const varM = /^\/variables\/(.+)$/.exec(path);
if (varM) return { type: "variable", id: varM[1] };

Expand Down Expand Up @@ -229,6 +247,20 @@ function applyOne(parsed: ParsedDocument, patch: JsonPatchOp, p: ParsedPath): vo
break;
}

case "variableDeclaration": {
if (!p.id) return;
if (patch.op === "remove") {
removeVariableDeclarationEntry(parsed.document, p.id);
} else if (isRawDeclarationEntry(patch.value)) {
// Replay is faithful, not strict: inverse patches capture raw entries
// (loose hand-authored declarations included) and undo must restore
// them verbatim — gating on isCompositionVariable here would make
// undo of a remove/update on a loose entry silently no-op.
writeVariableDeclaration(parsed.document, patch.value);
}
break;
}

case "variable": {
if (!p.id) return;
// B1: update the JSON model (data-composition-variables) so
Expand Down
189 changes: 188 additions & 1 deletion packages/sdk/src/engine/mutate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ import {
holdPath,
elementPath,
variablePath,
variableDeclPath,
metaPath,
gsapScriptPath,
styleSheetPath,
Expand Down Expand Up @@ -76,7 +77,18 @@ import {
unrollDynamicAnimations,
} from "@hyperframes/core/gsap-writer-acorn";
import { deriveKeyframeBackfillDefaults } from "./keyframeBackfill.js";
import { readVariableDefault, writeVariableDefault } from "./variableModel.js";
import {
readVariableDefault,
writeVariableDefault,
findVariableDeclaration,
writeVariableDeclaration,
removeVariableDeclarationEntry,
} from "./variableModel.js";
import {
isCompositionVariable,
isScalarVariableValue as isScalar,
} from "@hyperframes/core/variables";
import type { CompositionVariable } from "@hyperframes/core/variables";
import {
URI_BEARING_ATTRS,
DANGEROUS_URI_SCHEMES,
Expand Down Expand Up @@ -288,6 +300,12 @@ export function applyOp(parsed: ParsedDocument, op: EditOp): MutationResult {
return handleSetCompositionMetadata(parsed, op);
case "setVariableValue":
return handleSetVariableValue(parsed, op.id, op.value);
case "declareVariable":
return handleDeclareVariable(parsed, op.declaration);
case "updateVariableDeclaration":
return handleUpdateVariableDeclaration(parsed, op.id, op.declaration);
case "removeVariableDeclaration":
return handleRemoveVariableDeclaration(parsed, op.id);
case "setClassStyle":
return handleSetClassStyle(parsed, op.selector, op.styles);
case "addLabel":
Expand Down Expand Up @@ -885,6 +903,132 @@ function handleSetVariableValue(
return { forward, inverse };
}

/**
* Keep the `--{id}` CSS compat custom property on the root in sync with a
* scalar default (same secondary channel handleSetVariableValue maintains).
* Pass null to clear. Returns the patch pair, or null when there is no root
* or nothing to change.
*/
function cssCompatChange(
parsed: ParsedDocument,
id: string,
newVal: string | null,
): { forward: JsonPatchOp; inverse: JsonPatchOp } | null {
const root = findRoot(parsed.document);
const rootId = root?.getAttribute("data-hf-id");
if (!root || !rootId) return null;
const cssVar = `--${id}`;
const oldCssValue = getElementStyles(root)[cssVar] ?? null;
if (newVal !== null) {
if (oldCssValue === newVal) return null;
setElementStyles(root, { [cssVar]: newVal });
return scalarChange(stylePath(rootId, cssVar), oldCssValue, newVal);
}
if (oldCssValue === null) return null;
setElementStyles(root, { [cssVar]: null });
return scalarDelete(stylePath(rootId, cssVar), oldCssValue);
}

/**
* Declaration ops require a real `<html>` in the source: fragment inputs get
* a synthetic wrapper that serialize() strips, so a declaration written there
* would silently vanish on save.
*/
function fragmentCompositionErr(parsed: ParsedDocument): CanResult | null {
if (!parsed.wrapped) return null;
return canErr(
"E_FRAGMENT_COMPOSITION",
"Fragment compositions cannot carry variable declarations.",
"data-composition-variables lives on the <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[].",
);
}

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 (findVariableDeclaration(parsed.document, declaration.id) !== undefined) return EMPTY;
if (!writeVariableDeclaration(parsed.document, declaration)) return EMPTY;
const path = variableDeclPath(declaration.id);
const result: MutationResult = {
forward: [patchAdd(path, declaration)],
inverse: [patchRemove(path)],
};
// Same CSS compat channel every other variable op maintains — a composition
// CSS-bound to var(--id) must resolve regardless of which op set the value.
if (isScalar(declaration.default)) {
const css = cssCompatChange(parsed, declaration.id, String(declaration.default));
if (css) {
result.forward.push(css.forward);
result.inverse.push(css.inverse);
}
}
return result;
}

function handleUpdateVariableDeclaration(
parsed: ParsedDocument,
id: string,
declaration: CompositionVariable,
): MutationResult {
if (parsed.wrapped) return EMPTY;
if (!isCompositionVariable(declaration) || declaration.id !== id) return EMPTY;
const old = findVariableDeclaration(parsed.document, id);
if (old === undefined) return EMPTY;
writeVariableDeclaration(parsed.document, declaration);
const p = valueChange(variableDeclPath(id), old, declaration);
const result: MutationResult = { forward: [p.forward], inverse: [p.inverse] };

// Default changed → keep the CSS compat prop in sync (set for scalars,
// clear when the new default is object-valued font/image), and emit the
// paired /variables value patch so the T3 override-set's var.{id} entry
// agrees with the varDecl.{id} snapshot regardless of replay order.
const oldDefault = old.default;
const newDefault = declaration.default;
if (JSON.stringify(oldDefault) !== JSON.stringify(newDefault)) {
const valueP = valueChange(variablePath(id), oldDefault ?? null, newDefault);
result.forward.push(valueP.forward);
result.inverse.push(valueP.inverse);
const css = cssCompatChange(parsed, id, isScalar(newDefault) ? String(newDefault) : null);
if (css) {
result.forward.push(css.forward);
result.inverse.push(css.inverse);
}
}
return result;
}

function handleRemoveVariableDeclaration(parsed: ParsedDocument, id: string): MutationResult {
if (parsed.wrapped) return EMPTY;
const old = findVariableDeclaration(parsed.document, id);
if (old === undefined) return EMPTY;
removeVariableDeclarationEntry(parsed.document, id);
const path = variableDeclPath(id);
const result: MutationResult = {
forward: [patchRemove(path)],
inverse: [patchAdd(path, old)],
};
const css = cssCompatChange(parsed, id, null);
if (css) {
result.forward.push(css.forward);
result.inverse.push(css.inverse);
}
return result;
}

// ─── GSAP selector helpers ───────────────────────────────────────────────────

function selectorMatchesId(selector: string, id: HfId): boolean {
Expand Down Expand Up @@ -1494,6 +1638,49 @@ export function validateOp(parsed: ParsedDocument, op: EditOp): CanResult {
if (findRoot(parsed.document) === null)
return canErr("E_NO_ROOT", "Composition root element not found.");
return CAN_OK;
case "declareVariable": {
const fragmentErr = fragmentCompositionErr(parsed);
if (fragmentErr) return fragmentErr;
if (!isCompositionVariable(op.declaration)) return invalidDeclarationErr();
if (findVariableDeclaration(parsed.document, op.declaration.id) !== undefined)
return canErr(
"E_DUPLICATE_VARIABLE",
`Variable "${op.declaration.id}" is already declared.`,
"Use updateVariableDeclaration to change it, or setVariableValue to change its default.",
);
return CAN_OK;
}
case "updateVariableDeclaration": {
const fragmentErr = fragmentCompositionErr(parsed);
if (fragmentErr) return fragmentErr;
// Shape check FIRST — a null/non-object declaration must yield a
// CanResult, not a TypeError from the id access below.
if (!isCompositionVariable(op.declaration)) return invalidDeclarationErr();
if (op.declaration.id !== op.id)
return canErr(
"E_INVALID_ARGS",
`declaration.id ("${op.declaration.id}") must match id ("${op.id}").`,
"Variable ids are immutable — rename via removeVariableDeclaration + declareVariable.",
);
if (findVariableDeclaration(parsed.document, op.id) === undefined)
return canErr(
"E_VARIABLE_NOT_FOUND",
`Variable "${op.id}" is not declared.`,
"Check comp.getVariableDeclarations(), or add it with declareVariable.",
);
return CAN_OK;
}
case "removeVariableDeclaration": {
const fragmentErr = fragmentCompositionErr(parsed);
if (fragmentErr) return fragmentErr;
if (findVariableDeclaration(parsed.document, op.id) === undefined)
return canErr(
"E_VARIABLE_NOT_FOUND",
`Variable "${op.id}" is not declared.`,
"Check comp.getVariableDeclarations().",
);
return CAN_OK;
}
case "setCompositionMetadata":
case "setClassStyle":
return CAN_OK;
Expand Down
18 changes: 17 additions & 1 deletion packages/sdk/src/engine/patches.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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"
Expand Down Expand Up @@ -75,6 +77,11 @@ export function variablePath(id: string): string {
return `/variables/${id}`;
}

/** Whole-declaration path — distinct from /variables/{id}, which is the default *value*. */
export function variableDeclPath(id: string): string {
return `/variableDeclarations/${id}`;
}

export function metaPath(field: "width" | "height" | "duration"): string {
return `/metadata/${field}`;
}
Expand Down Expand Up @@ -120,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]}`;
Expand All @@ -141,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]);
Expand All @@ -161,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]);

Expand Down
Loading
Loading