Files
hyperframes/packages/sdk/src/engine/patches.ts
T
Vance Ingalls d0e520dbd9 feat(sdk): ws-b variables/brand — object-valued font/image + B1 JSON model (#1569)
## WS-B — variables / brand, object-valued (end-to-end)

Part of the AI Studio (Pacific) SDK integration. **Base of the SDK-hotspot stack** (`main → ws-b → ws-c → ws-d → ws-3c → ws-3f`).

### Problem
The variable system was split-brained: SDK `setVariableValue` wrote a `--{id}` CSS custom prop, while the runtime `getVariables()` read a separate JSON model (`data-composition-variables` / `__hfVariables`). The two never connected, and there was no `--brand-*` convention. Variables were scalar-only.

### What this does
- **B1 — one source of truth.** `setVariableValue` now drives the runtime variable model (`data-composition-variables` / `__hfVariables`), with CSS compatibility emitted as explicit `stylePath`-based patches alongside the model patch. A brand kit is a variables JSON; a batch of `setVariableValue` re-skins in one frame.
- **B2 — object-valued variables.** The `CompositionVariable` union extends from scalar-only to typed objects: `font` (`{name, source}`) and `image` (`{url, …}`), end-to-end (core union → SDK op → runtime merge). Colors stay scalar (per §7 LOCKED decision).

### Implementation notes
CSS compatibility was moved out of `apply-patches.ts` (where it was incorrectly writing CSS props as a side-effect of model patches, breaking inverse/undo) and into explicit patches emitted in `mutate.ts`. Forward emits `[modelPatch, cssPatch]` for scalars; inverse correctly generates `patchRemove` for the CSS prop when there was no prior CSS prop. Font/image variables never become CSS props.

### Files (12 changed, +441 −32)
- `packages/core`: `core.types.ts`, `lint/rules/composition.ts`, `parsers/htmlParser.ts` (+test), `runtime/validateVariables.ts`
- `packages/sdk`: `engine/mutate.ts` (+test), `engine/apply-patches.ts`, `engine/patches.ts`, `index.ts`, `types.ts`

### Gates
- `bun run build` 
- `bun test` SDK 304/0  · `validateVariables.test.ts` 13/0 
- `bunx oxlint` 0/0  · `bunx oxfmt --check` 
- `fallow audit --gate new-only`  (complexity inherited only)

> The +8 new `htmlParser.test.ts` font/image tests fail under the pre-existing `DOMParser is not defined` happy-dom limitation (main already carries 425 such failures) — not a logic bug; the pure runtime logic is covered by `validateVariables.test.ts`.

### Deferred
Brand-kit picker UI and `batch(setVariableValue × N)` wiring are Pacific-side; per-composition variable scoping beyond `__hfVariablesByComp`.

🤖 Generated with [Claude Code](https://claude.com/claude-code)
2026-06-18 23:03:52 -07:00

240 lines
9.4 KiB
TypeScript

/**
* RFC 6902 patch path grammar (F2) and override-set key mapping (F2 item 7).
*
* Path grammar:
* /elements/{hfId}/inlineStyles/{camelCaseProp}
* /elements/{hfId}/text
* /elements/{hfId}/attributes/{name}
* /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}
* /metadata/{width|height|duration}
* /script/gsap ← GSAP inline script textContent
* /style/css ← <style> element textContent
*
* Override-set key mapping:
* /elements/hf-x/inlineStyles/fontSize → "hf-x.style.fontSize"
* /elements/hf-x/text → "hf-x.text"
* /elements/hf-x/attributes/src → "hf-x.attr.src"
* /elements/hf-x/timing/start → "hf-x.timing.start"
* /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"
* /metadata/width → "meta.width"
* /script/gsap → "script.gsap"
* /style/css → "style.css"
*/
import type { JsonPatchOp, PatchEvent } from "../types.js";
// ─── Path builders ────────────────────────────────────────────────────────────
/**
* RFC 6902 JSON Pointer escaping for an hf-id (bare or scoped).
* Scoped ids contain "/" which must be encoded as "~1" in a path segment.
* "~" must be encoded as "~0" first (order matters per RFC 6902 §3).
*/
function escapeIdForPath(id: string): string {
return id.replace(/~/g, "~0").replace(/\//g, "~1");
}
/** Decode a path segment that may contain RFC 6902-escaped characters back to an hf-id. */
function decodePathSegment(segment: string): string {
// RFC 6902 §3: unescape ~1 → /, then ~0 → ~ (reverse order)
return segment.replace(/~1/g, "/").replace(/~0/g, "~");
}
export function stylePath(id: string, prop: string): string {
return `/elements/${escapeIdForPath(id)}/inlineStyles/${prop}`;
}
export function textPath(id: string): string {
return `/elements/${escapeIdForPath(id)}/text`;
}
export function attrPath(id: string, name: string): string {
// RFC 6902 JSON Pointer: ~ → ~0, / → ~1
const escapedName = name.replace(/~/g, "~0").replace(/\//g, "~1");
return `/elements/${escapeIdForPath(id)}/attributes/${escapedName}`;
}
export function timingPath(id: string, field: "start" | "end" | "duration" | "trackIndex"): string {
return `/elements/${escapeIdForPath(id)}/timing/${field}`;
}
export function holdPath(id: string, field: "start" | "end" | "fill"): string {
return `/elements/${escapeIdForPath(id)}/hold/${field}`;
}
export function elementPath(id: string): string {
return `/elements/${escapeIdForPath(id)}`;
}
export function variablePath(id: string): string {
return `/variables/${id}`;
}
export function metaPath(field: "width" | "height" | "duration"): string {
return `/metadata/${field}`;
}
export function gsapScriptPath(): string {
return "/script/gsap";
}
export function styleSheetPath(): string {
return "/style/css";
}
// ─── Override-set key mapping ─────────────────────────────────────────────────
/**
* Maps an RFC 6902 patch path to its override-set key.
* Returns null for paths that don't correspond to override-set entries.
*/
export function pathToKey(path: string): string | null {
// /elements/{id}/inlineStyles/{prop} → "{id}.style.{prop}"
// id segment may contain ~1 (RFC 6902-escaped "/") for scoped ids
const styleMatch = /^\/elements\/([^/]+)\/inlineStyles\/(.+)$/.exec(path);
if (styleMatch) return `${decodePathSegment(styleMatch[1]!)}.style.${styleMatch[2]}`;
// /elements/{id}/text → "{id}.text"
const textMatch = /^\/elements\/([^/]+)\/text$/.exec(path);
if (textMatch) return `${decodePathSegment(textMatch[1]!)}.text`;
// /elements/{id}/attributes/{name} → "{id}.attr.{name}"
const attrMatch = /^\/elements\/([^/]+)\/attributes\/(.+)$/.exec(path);
if (attrMatch) return `${decodePathSegment(attrMatch[1]!)}.attr.${attrMatch[2]}`;
// /elements/{id}/timing/{field} → "{id}.timing.{field}"
// Note: field "end" maps to the computed data-end attribute value.
const timingMatch = /^\/elements\/([^/]+)\/timing\/(.+)$/.exec(path);
if (timingMatch) return `${decodePathSegment(timingMatch[1]!)}.timing.${timingMatch[2]}`;
// /elements/{id}/hold/{field} → "{id}.hold.{field}"
const holdMatch = /^\/elements\/([^/]+)\/hold\/(.+)$/.exec(path);
if (holdMatch) return `${decodePathSegment(holdMatch[1]!)}.hold.${holdMatch[2]}`;
// /elements/{id} (whole element) → "{id}"
const elemMatch = /^\/elements\/([^/]+)$/.exec(path);
if (elemMatch) return decodePathSegment(elemMatch[1]!);
// /variables/{id} → "var.{id}"
const varMatch = /^\/variables\/(.+)$/.exec(path);
if (varMatch) return `var.${varMatch[1]}`;
// /metadata/{field} → "meta.{field}"
const metaMatch = /^\/metadata\/(.+)$/.exec(path);
if (metaMatch) return `meta.${metaMatch[1]}`;
// /script/gsap → "script.gsap"
if (path === "/script/gsap") return "script.gsap";
// /style/css → "style.css"
if (path === "/style/css") return "style.css";
return 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).
*/
export function keyToPath(key: string): string | null {
const style = /^([^.]+)\.style\.(.+)$/.exec(key);
if (style?.[1] && style[2]) return stylePath(style[1], style[2]);
const text = /^([^.]+)\.text$/.exec(key);
if (text?.[1]) return textPath(text[1]);
const attr = /^([^.]+)\.attr\.(.+)$/.exec(key);
// The attr name segment in the key is already RFC 6902-encoded (pathToKey stored it verbatim).
// The id may be a scoped id (contains "/") so we must escape it, but must NOT re-escape
// the already-encoded attr segment. Reconstruct manually.
if (attr?.[1] && attr[2]) return `/elements/${escapeIdForPath(attr[1])}/attributes/${attr[2]}`;
const timing = /^([^.]+)\.timing\.(start|end|duration|trackIndex)$/.exec(key);
if (timing?.[1])
return timingPath(timing[1], timing[2] as "start" | "end" | "duration" | "trackIndex");
const hold = /^([^.]+)\.hold\.(start|end|fill)$/.exec(key);
if (hold?.[1]) return holdPath(hold[1], hold[2] as "start" | "end" | "fill");
const variable = /^var\.(.+)$/.exec(key);
if (variable?.[1]) return variablePath(variable[1]);
const meta = /^meta\.(width|height|duration)$/.exec(key);
if (meta) return metaPath(meta[1] as "width" | "height" | "duration");
if (key === "script.gsap") return gsapScriptPath();
if (key === "style.css") return styleSheetPath();
// Bare element id — removal marker key.
if (!key.includes(".")) return elementPath(key);
return null;
}
// ─── Patch event builder ──────────────────────────────────────────────────────
export function buildPatchEvent(
forward: readonly JsonPatchOp[],
inverse: readonly JsonPatchOp[],
origin: unknown,
opTypes: readonly string[],
): PatchEvent {
return { formatVersion: 1, patches: forward, inversePatches: inverse, origin, opTypes };
}
// ─── Replace/add/remove helpers ───────────────────────────────────────────────
function patchReplace(path: string, value: unknown): JsonPatchOp {
return { op: "replace", path, value };
}
export function patchAdd(path: string, value: unknown): JsonPatchOp {
return { op: "add", path, value };
}
export function patchRemove(path: string): JsonPatchOp {
return { op: "remove", path };
}
/** Emit forward (replace or add) + inverse (replace or remove) for a scalar change. */
export function scalarChange(
path: string,
oldValue: string | number | boolean | null | undefined,
newValue: string | number | boolean,
): { forward: JsonPatchOp; inverse: JsonPatchOp } {
const forward = oldValue == null ? patchAdd(path, newValue) : patchReplace(path, newValue);
const inverse = oldValue == null ? patchRemove(path) : patchReplace(path, oldValue ?? null);
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,
oldValue: string | number | boolean,
): { forward: JsonPatchOp; inverse: JsonPatchOp } {
return {
forward: patchRemove(path),
inverse: patchAdd(path, oldValue),
};
}