Skip to content
Merged
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
49 changes: 47 additions & 2 deletions packages/core/src/core.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -276,6 +283,8 @@ export const COMPOSITION_VARIABLE_TYPES = [
"color",
"boolean",
"enum",
"font",
"image",
] as const satisfies readonly CompositionVariableType[];

export interface CompositionVariableBase {
Expand Down Expand Up @@ -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 {
Expand All @@ -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;
Expand Down
2 changes: 1 addition & 1 deletion packages/core/src/lint/rules/composition.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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),
});
}
Expand Down
11 changes: 3 additions & 8 deletions packages/core/src/parsers/htmlParser.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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" },
]);
Expand All @@ -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");
});

Expand Down
9 changes: 8 additions & 1 deletion packages/core/src/parsers/htmlParser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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":
Expand All @@ -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;
}
Expand Down
29 changes: 29 additions & 0 deletions packages/core/src/runtime/validateVariables.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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":
Expand Down Expand Up @@ -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;
}
}
}

Expand Down
46 changes: 38 additions & 8 deletions packages/sdk/src/engine/apply-patches.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 ───────────────────────────────────────────────────────

/**
Expand Down Expand Up @@ -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;
}

Expand Down
Loading
Loading