mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 12:54:29 +00:00
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)
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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<string, unknown>)?.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<string, unknown>)?.brandRole).toBe("logo:primary");
|
||||
});
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -32,6 +32,11 @@ export function validateVariables(
|
||||
return issues;
|
||||
}
|
||||
|
||||
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user