From fa33555b080f06c869530de32ea91396b1a450ed Mon Sep 17 00:00:00 2001 From: Vance Ingalls Date: Thu, 18 Jun 2026 15:05:29 -0700 Subject: [PATCH] =?UTF-8?q?feat(sdk):=20ws-b=20variables/brand=20=E2=80=94?= =?UTF-8?q?=20object-valued=20font/image=20+=20B1=20JSON=20model?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit B1 — setVariableValue now drives the runtime JSON model (data-composition-variables) so preview == render. CSS custom prop is kept as a secondary compat write for compositions that CSS-bind directly to --{id}. B2 — object-valued font ({name, source}) and image ({url}) variable types added core-to-SDK. Object values write to the JSON model only; scalars write both model + explicit CSS style patches. Explicit style-path patches in forward/inverse ensure apply-patches.ts handles each path type purely (model vs CSS), so inverse patches restore exact pre-call state without ambiguity. Changed files: packages/core/src/core.types.ts — font/image to CompositionVariableType + interfaces packages/core/src/lint/rules/composition.ts — accept font/image in lint message packages/core/src/parsers/htmlParser.ts — validate font/image variable declarations packages/core/src/parsers/htmlParser.test.ts — tests for new variable types packages/core/src/runtime/validateVariables.ts — checkType for font/image packages/sdk/src/types.ts — FontValue/ImageValue; widen OverrideSet + EditOp packages/sdk/src/index.ts — re-export FontValue/ImageValue packages/sdk/src/session.ts — widen setVariableValue signature packages/sdk/src/engine/patches.ts — valueChange helper for object-valued patches packages/sdk/src/engine/mutate.ts — handleSetVariableValue: B1+B2 with explicit CSS patches packages/sdk/src/engine/apply-patches.ts — variable case: model-only (CSS via explicit patch) packages/sdk/src/engine/mutate.test.ts — B1+B2 round-trip + inverse tests Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/core/src/core.types.ts | 49 +++++- packages/core/src/lint/rules/composition.ts | 2 +- packages/core/src/parsers/htmlParser.test.ts | 11 +- packages/core/src/parsers/htmlParser.ts | 9 +- .../core/src/runtime/validateVariables.ts | 29 ++++ packages/sdk/src/engine/apply-patches.ts | 46 +++++- packages/sdk/src/engine/mutate.test.ts | 152 +++++++++++++++++- packages/sdk/src/engine/mutate.ts | 114 ++++++++++++- packages/sdk/src/engine/patches.ts | 15 ++ packages/sdk/src/index.ts | 2 + packages/sdk/src/session.ts | 12 +- packages/sdk/src/types.ts | 32 +++- 12 files changed, 441 insertions(+), 32 deletions(-) diff --git a/packages/core/src/core.types.ts b/packages/core/src/core.types.ts index 14ad5eb28..42d4229e0 100644 --- a/packages/core/src/core.types.ts +++ b/packages/core/src/core.types.ts @@ -262,7 +262,14 @@ export interface TimelineCompositionElement extends TimelineElementBase { } // Composition Variable Types -export type CompositionVariableType = "string" | "number" | "color" | "boolean" | "enum"; +export type CompositionVariableType = + | "string" + | "number" + | "color" + | "boolean" + | "enum" + | "font" + | "image"; /** * Runtime list of every valid `CompositionVariableType`. Use this anywhere @@ -276,6 +283,8 @@ export const COMPOSITION_VARIABLE_TYPES = [ "color", "boolean", "enum", + "font", + "image", ] as const satisfies readonly CompositionVariableType[]; export interface CompositionVariableBase { @@ -304,6 +313,8 @@ export interface NumberVariable extends CompositionVariableBase { export interface ColorVariable extends CompositionVariableBase { type: "color"; default: string; + /** Brand role identifier, e.g. "color:primary". */ + brandRole?: string; } export interface BooleanVariable extends CompositionVariableBase { @@ -317,12 +328,46 @@ export interface EnumVariable extends CompositionVariableBase { options: { value: string; label: string }[]; } +/** + * Font variable — value is a `{name, source}` object (object-valued; LOCKED §7). + * `default` is the fallback font-family name string. + * `source` is the font stylesheet URL (e.g. Google Fonts CSS). + * `default_name` / `default_source` are the CSS-level fallbacks when the + * brand font is absent. + */ +export interface FontVariable extends CompositionVariableBase { + type: "font"; + /** Fallback font-family name, e.g. "Inter". */ + default: string; + /** Font stylesheet URL (e.g. Google Fonts CSS link). */ + source?: string; + /** CSS font-family name to use when source is unavailable, e.g. "sans-serif". */ + default_name?: string; + /** Fallback font stylesheet URL (empty string = system font). */ + default_source?: string; +} + +/** + * Image variable — value is a `{url, …}` object (object-valued; LOCKED §7). + * `default` is the fallback image URL string. + * `brandRole` is an optional semantic label, e.g. "logo:primary". + */ +export interface ImageVariable extends CompositionVariableBase { + type: "image"; + /** Fallback image URL. */ + default: string; + /** Brand role identifier, e.g. "logo:primary". */ + brandRole?: string; +} + export type CompositionVariable = | StringVariable | NumberVariable | ColorVariable | BooleanVariable - | EnumVariable; + | EnumVariable + | FontVariable + | ImageVariable; export interface CompositionSpec { id: string; diff --git a/packages/core/src/lint/rules/composition.ts b/packages/core/src/lint/rules/composition.ts index ef39202e8..3c6039f5b 100644 --- a/packages/core/src/lint/rules/composition.ts +++ b/packages/core/src/lint/rules/composition.ts @@ -507,7 +507,7 @@ export const compositionRules: Array<(ctx: LintContext) => HyperframeLintFinding findings.push({ code: "invalid_composition_variables_declaration", severity: "error", - message: `data-composition-variables entry [${i}] is missing or has invalid: ${missing.join(", ")}. Type must be one of string, number, color, boolean, enum.`, + message: `data-composition-variables entry [${i}] is missing or has invalid: ${missing.join(", ")}. Type must be one of string, number, color, boolean, enum, font, image.`, snippet: truncateSnippet(htmlTag.raw), }); } diff --git a/packages/core/src/parsers/htmlParser.test.ts b/packages/core/src/parsers/htmlParser.test.ts index cd7543109..547c493bc 100644 --- a/packages/core/src/parsers/htmlParser.test.ts +++ b/packages/core/src/parsers/htmlParser.test.ts @@ -666,13 +666,9 @@ describe("extractCompositionMetadata", () => { expect(meta.variables[1].type).toBe("number"); }); - // T9 — CompositionVariable font/image parse (spec for R1). - // These tests are intentionally red until R1 adds "font" and "image" to - // CompositionVariableType and updates parseCompositionVariables accordingly. - // Currently failing (spec): tests 1, 2, 3 — filter rejects unknown types. - // Currently passing (baseline): test 4 — unknown type graceful rejection already works. + // T9 — CompositionVariable font/image parse (WS-B R1 implemented). - it.fails("[spec] parses a font variable (type: font) with name and source", () => { + it("parses a font variable (type: font) with name and source", () => { const variables = JSON.stringify([ { id: "brand-font-primary", @@ -699,7 +695,7 @@ describe("extractCompositionMetadata", () => { expect((v as Record)?.default_source).toBe(""); }); - it.fails("[spec] parses an image variable with brandRole logo:primary", () => { + it("parses an image variable with brandRole logo:primary", () => { const variables = JSON.stringify([ { id: "brand-logo", type: "image", label: "Logo", default: "", brandRole: "logo:primary" }, ]); @@ -710,7 +706,6 @@ describe("extractCompositionMetadata", () => { const v = meta.variables.find((x) => x.id === "brand-logo"); expect(v).toBeDefined(); expect(v?.type).toBe("image"); - // TODO(R1): remove cast once ImageVariable.brandRole is typed expect((v as Record)?.brandRole).toBe("logo:primary"); }); diff --git a/packages/core/src/parsers/htmlParser.ts b/packages/core/src/parsers/htmlParser.ts index 5a9082544..c454e33b7 100644 --- a/packages/core/src/parsers/htmlParser.ts +++ b/packages/core/src/parsers/htmlParser.ts @@ -760,7 +760,8 @@ function parseCompositionVariables(htmlEl: Element): CompositionVariable[] { return parsed.filter((v): v is CompositionVariable => { if (typeof v !== "object" || v === null) return false; if (typeof v.id !== "string" || typeof v.label !== "string") return false; - if (!["string", "number", "color", "boolean", "enum"].includes(v.type)) return false; + if (!["string", "number", "color", "boolean", "enum", "font", "image"].includes(v.type)) + return false; switch (v.type) { case "string": @@ -773,6 +774,12 @@ function parseCompositionVariables(htmlEl: Element): CompositionVariable[] { return typeof v.default === "boolean"; case "enum": return typeof v.default === "string" && Array.isArray(v.options); + case "font": + // default is the font-family name string; extra metadata fields are optional + return typeof v.default === "string"; + case "image": + // default is the fallback image URL string; extra metadata fields are optional + return typeof v.default === "string"; default: return false; } diff --git a/packages/core/src/runtime/validateVariables.ts b/packages/core/src/runtime/validateVariables.ts index 5ef2e9f09..821722762 100644 --- a/packages/core/src/runtime/validateVariables.ts +++ b/packages/core/src/runtime/validateVariables.ts @@ -32,6 +32,11 @@ export function validateVariables( return issues; } +function isPlainObject(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +// fallow-ignore-next-line complexity function checkType(value: unknown, decl: CompositionVariable): VariableValidationIssue | null { switch (decl.type) { case "string": @@ -80,6 +85,30 @@ function checkType(value: unknown, decl: CompositionVariable): VariableValidatio } return null; } + case "font": { + // Font value is an object {name: string, source: string} OR a fallback string. + if (!isPlainObject(value) && typeof value !== "string") { + return { + kind: "type-mismatch", + variableId: decl.id, + expected: "font (object {name, source} or string)", + actual: jsTypeOf(value), + }; + } + return null; + } + case "image": { + // Image value is an object {url: string} OR a fallback string. + if (!isPlainObject(value) && typeof value !== "string") { + return { + kind: "type-mismatch", + variableId: decl.id, + expected: "image (object {url} or string)", + actual: jsTypeOf(value), + }; + } + return null; + } } } diff --git a/packages/sdk/src/engine/apply-patches.ts b/packages/sdk/src/engine/apply-patches.ts index 9bff69e78..7c6db6f83 100644 --- a/packages/sdk/src/engine/apply-patches.ts +++ b/packages/sdk/src/engine/apply-patches.ts @@ -76,6 +76,38 @@ function parsePath(path: string): ParsedPath | null { return null; } +// ─── Variable JSON model helper ─────────────────────────────────────────────── + +type VariableDecl = { id: string; default: unknown; [key: string]: unknown }; + +/** + * Apply a variable value to `data-composition-variables` on + * `document.documentElement`. When `newDefault` is null (remove op), + * the variable's `default` is left unchanged (we never erase the schema; + * only the override is removed). When `newDefault` is a value, the matching + * declaration's `default` is updated in-place. No-ops gracefully when the + * attribute or declaration is absent. + */ +function applyVariableDefault(document: Document, id: string, newDefault: unknown): void { + const htmlEl = (document as Document & { documentElement?: Element }).documentElement; + if (!htmlEl) return; + const raw = htmlEl.getAttribute("data-composition-variables"); + if (!raw) return; + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch { + return; + } + if (!Array.isArray(parsed)) return; + const arr = parsed as VariableDecl[]; + const idx = arr.findIndex((v) => typeof v === "object" && v !== null && v.id === id); + if (idx < 0) return; + if (newDefault === null) return; // remove op: leave schema default unchanged + arr[idx] = { ...arr[idx]!, default: newDefault }; + htmlEl.setAttribute("data-composition-variables", JSON.stringify(arr)); +} + // ─── Patch application ─────────────────────────────────────────────────────── /** @@ -195,14 +227,12 @@ function applyOne(parsed: ParsedDocument, patch: JsonPatchOp, p: ParsedPath): vo } case "variable": { - const root = findRoot(parsed.document); - if (!root || !p.id) return; - const cssVar = `--${p.id}`; - if (patch.op === "remove") { - setElementStyles(root, { [cssVar]: null }); - } else { - setElementStyles(root, { [cssVar]: String(patch.value) }); - } + if (!p.id) return; + // B1: update the JSON model (data-composition-variables) so + // getVariables() returns the correct value in both preview and render. + // CSS compat is handled by explicit style-path patches emitted by mutate.ts, + // so we do NOT write CSS here — the style case above handles those patches. + applyVariableDefault(parsed.document, p.id, patch.op === "remove" ? null : patch.value); break; } diff --git a/packages/sdk/src/engine/mutate.test.ts b/packages/sdk/src/engine/mutate.test.ts index 4334787c4..b9d29ce05 100644 --- a/packages/sdk/src/engine/mutate.test.ts +++ b/packages/sdk/src/engine/mutate.test.ts @@ -31,6 +31,40 @@ function fresh() { return parseMutable(BASE_HTML); } +/** Full HTML fixture with data-composition-variables for B1/B2 tests. */ +const VARIABLES_HTML = ` + + +
+
+`; + +function freshWithVars() { + return parseMutable(VARIABLES_HTML); +} + +/** Read the default value for a variable id from the parsed document. */ +function readVarDefault(parsed: ReturnType, id: string): unknown { + const raw = parsed.document.documentElement?.getAttribute("data-composition-variables"); + if (!raw) return undefined; + const arr = JSON.parse(raw) as Array<{ id: string; default: unknown }>; + return arr.find((v) => v.id === id)?.default; +} + // ─── setStyle ──────────────────────────────────────────────────────────────── describe("setStyle", () => { @@ -369,7 +403,7 @@ describe("setElementStyles key normalization", () => { // ─── setVariableValue ───────────────────────────────────────────────────────── describe("setVariableValue", () => { - it("sets CSS custom property on root element", () => { + it("sets CSS custom property on root element (fragment doc — compat)", () => { const parsed = fresh(); const result = applyOp(parsed, { type: "setVariableValue", @@ -385,6 +419,122 @@ describe("setVariableValue", () => { it("override-set key maps correctly", () => { expect(pathToKey("/variables/brand-color-primary")).toBe("var.brand-color-primary"); }); + + // B1 — drives the JSON model (data-composition-variables) + + it("B1: scalar color round-trips through override-set and runtime JSON model", () => { + const parsed = freshWithVars(); + const before = serializeDocument(parsed); + const result = applyOp(parsed, { + type: "setVariableValue", + id: "brand-color-primary", + value: "#ff0000", + }); + expect(result.forward[0]?.path).toBe("/variables/brand-color-primary"); + expect(result.forward[0]?.value).toBe("#ff0000"); + // JSON model updated + expect(readVarDefault(parsed, "brand-color-primary")).toBe("#ff0000"); + // CSS compat prop also written + const root = parsed.document.querySelector("[data-hf-root]"); + expect(root?.getAttribute("style")).toContain("--brand-color-primary: #ff0000"); + // inverse restores + applyPatchesToDocument(parsed, result.inverse); + expect(serializeDocument(parsed)).toBe(before); + }); + + it("B1: scalar inverse patch restores prior value (replace → old value)", () => { + const parsed = freshWithVars(); + // Set once + applyOp(parsed, { type: "setVariableValue", id: "brand-color-primary", value: "#ff0000" }); + const snap = serializeDocument(parsed); + // Set again + const result2 = applyOp(parsed, { + type: "setVariableValue", + id: "brand-color-primary", + value: "#00ff00", + }); + expect(readVarDefault(parsed, "brand-color-primary")).toBe("#00ff00"); + applyPatchesToDocument(parsed, result2.inverse); + expect(serializeDocument(parsed)).toBe(snap); + }); + + // B2 — object-valued font variable + + it("B2: font {name,source} object round-trips through JSON model (no CSS prop)", () => { + const parsed = freshWithVars(); + const fontValue = { name: "Roboto", source: "https://fonts.googleapis.com/css2?family=Roboto" }; + const result = applyOp(parsed, { + type: "setVariableValue", + id: "brand-font", + value: fontValue, + }); + expect(result.forward[0]?.path).toBe("/variables/brand-font"); + expect(result.forward[0]?.value).toEqual(fontValue); + // JSON model updated + expect(readVarDefault(parsed, "brand-font")).toEqual(fontValue); + // NO CSS custom prop for object values + const root = parsed.document.querySelector("[data-hf-root]"); + const style = root?.getAttribute("style") ?? ""; + expect(style).not.toContain("--brand-font"); + // override-set key holds the object (one var.{id} key, no sub-key explosion) + expect(pathToKey("/variables/brand-font")).toBe("var.brand-font"); + }); + + it("B2: font inverse restores prior default (object → object)", () => { + const parsed = freshWithVars(); + const before = serializeDocument(parsed); + const fontValue = { name: "Roboto", source: "https://fonts.googleapis.com/css2?family=Roboto" }; + const result = applyOp(parsed, { + type: "setVariableValue", + id: "brand-font", + value: fontValue, + }); + expect(readVarDefault(parsed, "brand-font")).toEqual(fontValue); + applyPatchesToDocument(parsed, result.inverse); + expect(serializeDocument(parsed)).toBe(before); + }); + + it("B2: image {url} object round-trips through JSON model (no CSS prop)", () => { + const parsed = freshWithVars(); + const imgValue = { url: "https://example.com/brand-logo.png" }; + const result = applyOp(parsed, { + type: "setVariableValue", + id: "brand-logo", + value: imgValue, + }); + expect(result.forward[0]?.path).toBe("/variables/brand-logo"); + expect(result.forward[0]?.value).toEqual(imgValue); + expect(readVarDefault(parsed, "brand-logo")).toEqual(imgValue); + const root = parsed.document.querySelector("[data-hf-root]"); + expect(root?.getAttribute("style") ?? "").not.toContain("--brand-logo"); + }); + + it("B2: image inverse restores prior default", () => { + const parsed = freshWithVars(); + const before = serializeDocument(parsed); + const result = applyOp(parsed, { + type: "setVariableValue", + id: "brand-logo", + value: { url: "https://example.com/new-logo.png" }, + }); + applyPatchesToDocument(parsed, result.inverse); + expect(serializeDocument(parsed)).toBe(before); + }); + + it("B1/batch: multiple setVariableValue calls fold to independent overrides", () => { + const parsed = freshWithVars(); + applyOp(parsed, { type: "setVariableValue", id: "brand-color-primary", value: "#ff0000" }); + applyOp(parsed, { + type: "setVariableValue", + id: "brand-font", + value: { name: "Roboto", source: "https://fonts.googleapis.com/css2?family=Roboto" }, + }); + expect(readVarDefault(parsed, "brand-color-primary")).toBe("#ff0000"); + expect(readVarDefault(parsed, "brand-font")).toEqual({ + name: "Roboto", + source: "https://fonts.googleapis.com/css2?family=Roboto", + }); + }); }); // ─── setCompositionMetadata ─────────────────────────────────────────────────── diff --git a/packages/sdk/src/engine/mutate.ts b/packages/sdk/src/engine/mutate.ts index 6091a7a19..242065006 100644 --- a/packages/sdk/src/engine/mutate.ts +++ b/packages/sdk/src/engine/mutate.ts @@ -7,7 +7,15 @@ * Phase 3b (parser-backed) will add setClassStyle + 7 GSAP ops as additional handlers. */ -import type { CanResult, EditOp, GsapTweenSpec, HfId, JsonPatchOp } from "../types.js"; +import type { + CanResult, + EditOp, + FontValue, + GsapTweenSpec, + HfId, + ImageValue, + JsonPatchOp, +} from "../types.js"; import type { ParsedDocument } from "./model.js"; import { resolveScoped, @@ -37,6 +45,7 @@ import { styleSheetPath, scalarChange, scalarDelete, + valueChange, patchAdd, patchRemove, } from "./patches.js"; @@ -680,23 +689,116 @@ function handleSetCompositionMetadata( return result; } +// ─── Variable JSON model helpers ───────────────────────────────────────────── + +type VariableDecl = { id: string; default: unknown; [key: string]: unknown }; + +/** + * Read the current `default` value for a variable id from + * `document.documentElement`'s `data-composition-variables` attribute. + * Returns undefined when the attribute is absent, the JSON is invalid, + * or no entry matches the given id. + */ +function readVariableDefault(document: Document, id: string): unknown { + const htmlEl = (document as Document & { documentElement?: Element }).documentElement; + if (!htmlEl) return undefined; + const raw = htmlEl.getAttribute("data-composition-variables"); + if (!raw) return undefined; + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch { + return undefined; + } + if (!Array.isArray(parsed)) return undefined; + const entry = (parsed as unknown[]).find( + (v): v is VariableDecl => typeof v === "object" && v !== null && (v as VariableDecl).id === id, + ); + return entry?.default; +} + +/** + * Upsert a variable's `default` in `data-composition-variables` on + * `document.documentElement`. No-ops when the attribute is absent or + * contains no declaration for the given id (we never auto-add declarations + * for undeclared variables — keep the schema authoritative). + * Returns true when the attribute was updated. + */ +function writeVariableDefault(document: Document, id: string, newDefault: unknown): boolean { + const htmlEl = (document as Document & { documentElement?: Element }).documentElement; + if (!htmlEl) return false; + const raw = htmlEl.getAttribute("data-composition-variables"); + if (!raw) return false; + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch { + return false; + } + if (!Array.isArray(parsed)) return false; + const arr = parsed as VariableDecl[]; + const idx = arr.findIndex((v) => typeof v === "object" && v !== null && v.id === id); + if (idx < 0) return false; // variable not declared — don't auto-add + arr[idx] = { ...arr[idx]!, default: newDefault }; + htmlEl.setAttribute("data-composition-variables", JSON.stringify(arr)); + return true; +} + +/** + * True when the value is a FontValue or ImageValue object + * (object-valued; must NOT be written as a CSS custom property). + */ +function isObjectVariableValue( + value: string | number | boolean | FontValue | ImageValue, +): value is FontValue | ImageValue { + return typeof value === "object" && value !== null; +} + function handleSetVariableValue( parsed: ParsedDocument, id: string, - value: string | number | boolean, + value: string | number | boolean | FontValue | ImageValue, ): MutationResult { const root = findRoot(parsed.document); if (!root) return EMPTY; + const modelPath = variablePath(id); + const oldVarDefault = readVariableDefault(parsed.document, id); + + if (isObjectVariableValue(value)) { + // Object values (font / image): write to JSON model only — objects are not + // valid CSS custom property values (LOCKED §7). + writeVariableDefault(parsed.document, id, value); + const p = valueChange(modelPath, oldVarDefault ?? null, value); + return { forward: [p.forward], inverse: [p.inverse] }; + } + + // Scalar values: update the JSON model (B1 — drives the runtime) and also + // keep the CSS custom prop as secondary / compat for compositions that + // CSS-bind directly to --{id}. const cssVar = `--${id}`; + const rootId = root.getAttribute("data-hf-id"); const oldStyles = getElementStyles(root); - const oldValue = oldStyles[cssVar] ?? null; + const oldCssValue = oldStyles[cssVar] ?? null; const newVal = String(value); setElementStyles(root, { [cssVar]: newVal }); + writeVariableDefault(parsed.document, id, value); + + // Emit explicit patches for both the JSON model (canonical) and the CSS compat + // prop. Keeping them separate means apply-patches.ts can handle each path type + // purely (variable path → model only; style path → CSS only), so inverse patches + // correctly restore the exact pre-call state without CSS-side-effect ambiguity. + const modelP = valueChange(modelPath, oldVarDefault ?? null, value); + const forward: JsonPatchOp[] = [modelP.forward]; + const inverse: JsonPatchOp[] = [modelP.inverse]; + + if (rootId) { + const cssPatch = scalarChange(stylePath(rootId, cssVar), oldCssValue, newVal); + forward.push(cssPatch.forward); + inverse.push(cssPatch.inverse); + } - const path = variablePath(id); - const p = scalarChange(path, oldValue, newVal); - return { forward: [p.forward], inverse: [p.inverse] }; + return { forward, inverse }; } // ─── GSAP selector helpers ─────────────────────────────────────────────────── diff --git a/packages/sdk/src/engine/patches.ts b/packages/sdk/src/engine/patches.ts index 8cfd4afca..96c095602 100644 --- a/packages/sdk/src/engine/patches.ts +++ b/packages/sdk/src/engine/patches.ts @@ -212,6 +212,21 @@ export function scalarChange( return { forward, inverse }; } +/** + * Emit forward (replace or add) + inverse (replace or remove) for any JSON-serializable value. + * Use instead of scalarChange when the value may be an object (e.g. font/image variable). + * The old value is captured whole — no sub-key diffing. + */ +export function valueChange( + path: string, + oldValue: unknown, + newValue: unknown, +): { forward: JsonPatchOp; inverse: JsonPatchOp } { + const forward = oldValue == null ? patchAdd(path, newValue) : patchReplace(path, newValue); + const inverse = oldValue == null ? patchRemove(path) : patchReplace(path, oldValue); + return { forward, inverse }; +} + /** Emit forward remove + inverse add for a deletion. */ export function scalarDelete( path: string, diff --git a/packages/sdk/src/index.ts b/packages/sdk/src/index.ts index 72eefc9e3..8875dcd73 100644 --- a/packages/sdk/src/index.ts +++ b/packages/sdk/src/index.ts @@ -4,6 +4,8 @@ export type { OverrideSet, EditOp, ElasticHold, + FontValue, + ImageValue, GsapTweenSpec, HfId, JsonPatchOp, diff --git a/packages/sdk/src/session.ts b/packages/sdk/src/session.ts index b8d897f97..b74e59f5a 100644 --- a/packages/sdk/src/session.ts +++ b/packages/sdk/src/session.ts @@ -14,8 +14,10 @@ import type { EditOp, ElementSnapshot, FindQuery, + FontValue, GsapTweenSpec, HfId, + ImageValue, JsonPatchOp, OverrideSet, PatchEvent, @@ -133,7 +135,7 @@ class CompositionImpl implements Composition { this.dispatch({ type: "removeElement", target: id }); } - setVariableValue(id: string, value: string | number | boolean): void { + setVariableValue(id: string, value: string | number | boolean | FontValue | ImageValue): void { this.dispatch({ type: "setVariableValue", id, value }); } @@ -269,7 +271,9 @@ class CompositionImpl implements Composition { const key = pathToKey(p.path); if (key !== null) { this.overrides[key] = - p.op === "remove" ? null : (p.value as string | number | boolean | null); + p.op === "remove" + ? null + : (p.value as string | number | boolean | Record | null); } } @@ -453,7 +457,9 @@ class CompositionImpl implements Composition { const key = pathToKey(p.path); if (key !== null) { this.overrides[key] = - p.op === "remove" ? null : (p.value as string | number | boolean | null); + p.op === "remove" + ? null + : (p.value as string | number | boolean | Record | null); } } diff --git a/packages/sdk/src/types.ts b/packages/sdk/src/types.ts index 70cc2792f..261101585 100644 --- a/packages/sdk/src/types.ts +++ b/packages/sdk/src/types.ts @@ -49,8 +49,14 @@ export interface SdkDocument { * Sparse map of `hfId.prop.path → value` overrides layered on top of the base template. * null value = removal marker (element or property deleted by user). * Examples: { "hf-x7k2.style.fontSize": "96px", "hf-y3a1.text": "Hello", "hf-z5k2": null } + * + * Font and image variable overrides store their object values under the var.{id} key: + * { "var.brand-font": { name: "Roboto", source: "https://fonts.googleapis.com/…" } } */ -export type OverrideSet = Record; +export type OverrideSet = Record< + string, + string | number | boolean | Record | null +>; // ─── can() result ───────────────────────────────────────────────────────────── @@ -89,7 +95,11 @@ export type EditOp = } | { type: "setClassStyle"; selector: string; styles: Record } | { type: "setCompositionMetadata"; width?: number; height?: number; duration?: number } - | { type: "setVariableValue"; id: string; value: string | number | boolean } + | { + type: "setVariableValue"; + id: string; + value: string | number | boolean | FontValue | ImageValue; + } | { type: "addGsapTween"; target: HfId; tween: GsapTweenSpec } | { type: "setGsapTween"; animationId: string; properties: Partial } | { @@ -178,6 +188,24 @@ export interface ElasticHold { fill: "freeze" | "loop"; } +/** + * Object value for a `font` variable (LOCKED §7 — object-valued, never a CSS string). + * `name` is the CSS font-family value; `source` is the stylesheet URL to load. + */ +export interface FontValue { + name: string; + source: string; +} + +/** + * Object value for an `image` variable (LOCKED §7 — object-valued, never a CSS string). + * `url` is the image src; additional fields (alt, fit, etc.) are forward-compatible. + */ +export interface ImageValue { + url: string; + [key: string]: unknown; +} + export interface GsapTweenSpec { method: "from" | "to" | "fromTo" | "set"; position?: number | string;