fix(sdk): keep font and image declarations after object edits (#6281)

* fix(sdk): preserve font and image declarations after object edits

* fix(studio): preserve variable object defaults when editing declarations

* fix(studio): show font and image values in bind warnings
This commit is contained in:
Akshar Patel
2026-10-25 23:01:32 +00:00
committed by GitHub
parent 6c053bc8e7
commit 04df17cccb
20 changed files with 555 additions and 65 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.0 KiB

+4 -4
View File
@@ -398,7 +398,7 @@ interface EnumVariable extends CompositionVariableBase {
interface FontVariable extends CompositionVariableBase {
type: "font";
default: string;
default: string | FontValue;
source?: string;
default_name?: string;
default_source?: string;
@@ -406,7 +406,7 @@ interface FontVariable extends CompositionVariableBase {
interface ImageVariable extends CompositionVariableBase {
type: "image";
default: string;
default: string | ImageValue;
brandRole?: string;
}
@@ -432,8 +432,8 @@ type CompositionVariable =
Human-readable name for a variables panel UI.
</ResponseField>
<ResponseField name="default" type="string | number | boolean">
The variable's fallback value. `font` and `image` variants store their fallback as a plain string here (a font-family name / image URL) — the richer `FontValue`/`ImageValue` object shapes are only used as `setVariableValue`'s runtime argument, not as `default`'s stored type.
<ResponseField name="default" type="string | number | boolean | FontValue | ImageValue">
The variable's default value. `font` accepts a font-family string or `FontValue`; `image` accepts a URL string or `ImageValue`. `setVariableValue` persists the whole value here, including font source and image metadata. Declaration reads, validation, and save/reopen preserve these objects.
</ResponseField>
<ResponseField name="brandRole" type="string (color, image)">
+4
View File
@@ -21,6 +21,8 @@ The quickest path starts from the canvas:
3. Bind the text, media source, text color, background, or font property.
4. Create a new variable or choose a compatible existing one.
Before binding to an existing variable, Studio shows its saved default, using the font name or image URL for object values.
For a value that is not tied to the selected element, click **Add variable**. Studio supports string, number, color, boolean, enum, font, and image variables. Number variables can include minimum, maximum, and step values; enums can define their allowed options.
Name the input by meaning—`customerName`, `headline`, or `brandColor`—rather than by its position in the design.
@@ -31,6 +33,8 @@ Change a value in the Variables panel to preview that version immediately. The p
The panel tells you whether you are previewing defaults or custom values. Use **Reset** to clear all preview overrides. When a scalar preview should become the new default, use **Set default**.
Use **Edit** to change a variable's label, description, or saved default. For font and image objects, **Default** edits the font name or image URL. Saving keeps the font source, image alt text and fit, and any other stored metadata, including when you change only the label or description.
<Note>
Variables from other compositions are listed separately. You can edit their declarations, but
their values are not live-previewed in the active composition.
@@ -116,6 +116,25 @@ describe("validateVariables", () => {
expect(validateVariables({ hero: "https://x/y.png" }, DECLS)).toEqual([]);
});
it("validates image metadata using the declaration object shape", () => {
expect(
validateVariables({ hero: { url: "logo.svg", alt: "Brand", fit: "contain" } }, DECLS),
).toEqual([]);
for (const value of [
{ url: "logo.svg", alt: 3 },
{ url: "logo.svg", fit: "stretch" },
]) {
expect(validateVariables({ hero: value }, DECLS)).toEqual([
{
kind: "type-mismatch",
variableId: "hero",
expected: "image object {url: string}",
actual: "object with invalid alt/fit",
},
]);
}
});
it("flags an image object missing a string url", () => {
expect(validateVariables({ hero: { foo: 42 } }, DECLS)).toEqual([
{
@@ -1,3 +1,4 @@
import { isFontValue, isImageValue } from "@hyperframes/parsers/composition";
import type { CompositionVariable } from "../core.types";
export type VariableValidationIssue =
@@ -96,10 +97,7 @@ function checkType(value: unknown, decl: CompositionVariable): VariableValidatio
actual: jsTypeOf(value),
};
}
// Object form: require the discriminant fields so a malformed brand-kit
// value ({name: 42} / {}) is caught here rather than surfacing as a bogus
// font-family at render time.
if (typeof value.name !== "string" || typeof value.source !== "string") {
if (!isFontValue(value)) {
return {
kind: "type-mismatch",
variableId: decl.id,
@@ -120,13 +118,15 @@ function checkType(value: unknown, decl: CompositionVariable): VariableValidatio
actual: jsTypeOf(value),
};
}
// Object form: require the discriminant field.
if (typeof value.url !== "string") {
if (!isImageValue(value)) {
return {
kind: "type-mismatch",
variableId: decl.id,
expected: "image object {url: string}",
actual: "object missing string url",
actual:
typeof value.url !== "string"
? "object missing string url"
: "object with invalid alt/fit",
};
}
return null;
+2
View File
@@ -16,7 +16,9 @@ export type {
BooleanVariable,
EnumVariable,
FontVariable,
FontValue,
ImageVariable,
ImageValue,
} from "@hyperframes/parsers/composition";
export {
COMPOSITION_VARIABLE_TYPES,
@@ -1280,6 +1280,8 @@ describe("composition rules", () => {
{ id: "setting", type: "color", label: "Setting", default: 3 },
{ id: "setting", type: "font", label: "Setting", default: 3 },
{ id: "setting", type: "image", label: "Setting", default: 3 },
{ id: "setting", type: "font", label: "Setting", default: { name: "Brand" } },
{ id: "setting", type: "image", label: "Setting", default: { url: 3 } },
{ id: "setting", type: "enum", label: "Setting", default: 3, options: [] },
{ id: "setting", type: "enum", label: "Setting", default: "light" },
{ id: "setting", type: "enum", label: "Setting", default: "light", options: "light" },
@@ -1318,6 +1320,18 @@ describe("composition rules", () => {
{ id: "setting", type: "color", label: "Setting", default: "#fff" },
{ id: "setting", type: "font", label: "Setting", default: "Arial" },
{ id: "setting", type: "image", label: "Setting", default: "assets/a.png" },
{
id: "setting",
type: "font",
label: "Setting",
default: { name: "Brand", source: "assets/brand.css" },
},
{
id: "setting",
type: "image",
label: "Setting",
default: { url: "assets/a.png", alt: "Brand", fit: "contain" },
},
{ id: "setting", type: "enum", label: "Setting", default: "light", options: [] },
])("preserves parser-admitted declaration %j", async (declaration) => {
const element = document.createElement("div");
@@ -1426,6 +1440,30 @@ describe("composition rules", () => {
const CODE = "unloadable_media_variable_default";
const find = (r: { findings: { code: string }[] }) => r.findings.find((f) => f.code === CODE);
it.each(["file:///abs/logo.svg", "javascript:alert(1)", "data:text/html,hello"])(
"rejects an unsafe URL inside an image object default: %s",
async (url) => {
const declarations = [
{
id: "logo",
type: "image",
label: "Logo",
default: { url, alt: "Logo", fit: "contain" },
},
];
const html = `<html data-composition-variables='${JSON.stringify(declarations)}'><body><div data-composition-id="x"></div></body></html>`;
const result = await lintHyperframeHtml(html);
expect(
result.findings.filter((finding) => finding.code === "unloadable_media_variable_default"),
).toHaveLength(1);
expect(
result.findings.some(
(finding) => finding.code === "invalid_composition_variables_declaration",
),
).toBe(false);
},
);
it("errors on an image variable defaulting to a file:// URL", async () => {
const html = `<html data-composition-variables='[{"id":"bg","type":"image","label":"BG","default":"file:///abs/assets/blue.png"}]'><body><img data-composition-id="x" src="assets/red.png" data-var-src="bg"></body></html>`;
const finding = find(await lintHyperframeHtml(html));
+7 -5
View File
@@ -686,17 +686,19 @@ function variablesDeclarationFindings(
severity: "error",
message: `data-composition-variables entry [${i}] has a default that does not match its type, or an enum without an options array.`,
fixHint:
"Use number defaults for number variables, boolean defaults for boolean variables, and string defaults for other types. Enum variables also require an options array.",
"Use number defaults for number variables, boolean defaults for boolean variables, and string defaults for other types. Font/image also accept font {name, source} or image {url, alt?, fit?} objects. Enum variables also require an options array.",
snippet: truncateSnippet(tag.raw),
});
continue;
}
const id = String(e.id);
const id = e.id;
const mediaUrl =
e.type === "image" && typeof e.default !== "string" ? e.default.url : e.default;
if (
(e.type === "image" || varSrcIds.has(id)) &&
typeof e.default === "string" &&
e.default.length > 0 &&
!isSafeMediaUrl(e.default)
typeof mediaUrl === "string" &&
mediaUrl.length > 0 &&
!isSafeMediaUrl(mediaUrl)
) {
findings.push({
code: "unloadable_media_variable_default",
+2
View File
@@ -13,6 +13,8 @@ export { decodeUrlPathVariants } from "./utils/urlPath.js";
export {
parseCompositionVariables,
isCompositionVariable,
isFontValue,
isImageValue,
isScalarVariableValue,
isSafeMediaUrl,
} from "./compositionVariables.js";
@@ -0,0 +1,53 @@
import { describe, it, expect } from "vitest";
import { isCompositionVariable, parseCompositionVariables } from "./compositionVariables.js";
import type { CompositionVariable } from "./types.js";
describe("font/image declaration defaults", () => {
it("reads string fallbacks and typed objects without losing metadata", () => {
const declarations: CompositionVariable[] = [
{
id: "font",
type: "font",
label: "Font",
default: { name: "Brand", source: "brand.css" },
default_name: "serif",
},
{
id: "image",
type: "image",
label: "Image",
default: { url: "logo.svg", alt: "Logo", fit: "contain" },
brandRole: "logo:primary",
},
{ id: "font-fallback", type: "font", label: "Fallback", default: "serif" },
{ id: "image-fallback", type: "image", label: "Fallback", default: "logo.svg" },
];
const element = document.createElement("div");
element.setAttribute("data-composition-variables", JSON.stringify(declarations));
expect(parseCompositionVariables(element)).toEqual(declarations);
});
it.each([
["font", {}],
["font", { name: "Brand" }],
["font", { name: 1, source: "" }],
["font", []],
["font", null],
["image", {}],
["image", { url: 1 }],
["image", { url: "logo.svg", alt: 1 }],
["image", { url: "logo.svg", fit: "stretch" }],
["image", []],
["image", null],
["string", { url: "logo.svg" }],
["color", { name: "red", source: "" }],
["number", "2"],
["boolean", "true"],
])("rejects a malformed %s default %j", (type, value) => {
const declaration = { id: "invalid", label: "Invalid", type, default: value };
expect(isCompositionVariable(declaration)).toBe(false);
const element = document.createElement("div");
element.setAttribute("data-composition-variables", JSON.stringify([declaration]));
expect(parseCompositionVariables(element)).toEqual([]);
});
});
+28 -7
View File
@@ -5,13 +5,14 @@
* linkedom/Node HTML-parser machinery from the main entry.
*/
import type { CompositionVariable, CompositionVariableType } from "./types.js";
import type {
CompositionVariable,
CompositionVariableType,
FontValue,
ImageValue,
} from "./types.js";
/**
* Required typeof for each variable type's `default`. For font the default is
* the font-family name string; for image it is the fallback URL string —
* extra metadata fields on both are optional and not validated here.
*/
/** Scalar defaults; font/image also accept their typed object values. */
const DEFAULT_TYPEOF: Record<CompositionVariableType, "string" | "number" | "boolean"> = {
string: "string",
number: "number",
@@ -33,7 +34,25 @@ export function isScalarVariableValue(value: unknown): value is string | number
}
function isRecord(v: unknown): v is Record<string, unknown> {
return typeof v === "object" && v !== null;
return typeof v === "object" && v !== null && !Array.isArray(v);
}
export function isFontValue(value: unknown): value is FontValue {
return isRecord(value) && typeof value.name === "string" && typeof value.source === "string";
}
export function isImageValue(value: unknown): value is ImageValue {
return (
isRecord(value) &&
typeof value.url === "string" &&
(value.alt === undefined || typeof value.alt === "string") &&
(value.fit === undefined ||
value.fit === "cover" ||
value.fit === "contain" ||
value.fit === "fill" ||
value.fit === "none" ||
value.fit === "scale-down")
);
}
function isVariableType(t: unknown): t is CompositionVariableType {
@@ -50,6 +69,8 @@ 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;
if (v.type === "font") return typeof v.default === "string" || isFontValue(v.default);
if (v.type === "image") return typeof v.default === "string" || isImageValue(v.default);
if (typeof v.default !== DEFAULT_TYPEOF[v.type]) return false;
if (v.type === "enum" && !Array.isArray(v.options)) return false;
return true;
+15 -16
View File
@@ -280,17 +280,21 @@ 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 FontValue {
name: string;
source: string;
}
export interface ImageValue {
url: string;
alt?: string;
fit?: "cover" | "contain" | "fill" | "none" | "scale-down";
}
export interface FontVariable extends CompositionVariableBase {
type: "font";
/** Fallback font-family name, e.g. "Inter". */
default: string;
/** Font-family fallback or a persisted font value. */
default: string | FontValue;
/** 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". */
@@ -299,15 +303,10 @@ export interface FontVariable extends CompositionVariableBase {
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;
/** Image URL fallback or a persisted image value. */
default: string | ImageValue;
/** Brand role identifier, e.g. "logo:primary". */
brandRole?: string;
}
@@ -0,0 +1,129 @@
import { describe, it, expect } from "vitest";
import { openComposition, createMemoryAdapter } from "./index.js";
import type { FontValue, ImageValue, JsonPatchOp } from "./index.js";
const FONT: FontValue = { name: "Brand Sans", source: "assets/brand.css" };
const IMAGE: ImageValue = { url: "assets/logo.svg", alt: "Brand logo", fit: "contain" };
const DECLARATIONS = [
{ id: "font", type: "font", label: "Font", default: "serif", source: "fallback.css" },
{ id: "logo", type: "image", label: "Logo", default: "fallback.svg", brandRole: "logo:primary" },
];
const HTML = `<!doctype html><html data-composition-variables='${JSON.stringify(DECLARATIONS)}'><body><div data-hf-root data-hf-id="hf-stage" data-duration="2"></div></body></html>`;
const OBJECT_DECLARATIONS = [
{ ...DECLARATIONS[0], default: FONT },
{ ...DECLARATIONS[1], default: IMAGE },
];
describe("font/image object defaults", () => {
it("keeps documented object edits declared and validated after saving and reopening", async () => {
const persist = createMemoryAdapter();
const comp = await openComposition(HTML, { persist });
comp.setVariableValue("font", FONT);
comp.setVariableValue("logo", IMAGE);
expect(comp.getVariableDeclarations()).toEqual(OBJECT_DECLARATIONS);
expect(comp.listVariables()).toEqual(OBJECT_DECLARATIONS);
expect(comp.validateVariableValues({ font: FONT, logo: IMAGE })).toEqual([]);
expect(comp.getVariableValue("font", { base: true })).toBe("serif");
await comp.flush();
const saved = await persist.read("composition.html");
expect(saved).toBe(comp.serialize());
if (saved === undefined) throw new Error("Composition was not saved");
const reopened = await openComposition(saved);
expect(reopened.getVariableDeclarations()).toEqual(OBJECT_DECLARATIONS);
expect(reopened.getVariableValues()).toEqual({ font: FONT, logo: IMAGE });
expect(reopened.validateVariableValues({ font: FONT, logo: IMAGE })).toEqual([]);
comp.dispose();
reopened.dispose();
});
it("preserves declarations and CSS compatibility across string/object edits and undo/redo", async () => {
const comp = await openComposition(HTML, { coalesceMs: 0 });
comp.batch(() => {
comp.setVariableValue("font", "monospace");
comp.setVariableValue("logo", "first.svg");
});
comp.batch(() => {
comp.setVariableValue("font", FONT);
comp.setVariableValue("logo", IMAGE);
});
expect(comp.getVariableDeclarations()).toEqual(OBJECT_DECLARATIONS);
expect(comp.getElement("hf-stage")?.inlineStyles["--font"]).toBeUndefined();
expect(comp.getElement("hf-stage")?.inlineStyles["--logo"]).toBeUndefined();
comp.undo();
expect(comp.getVariableValues()).toEqual({ font: "monospace", logo: "first.svg" });
expect(comp.getElement("hf-stage")?.inlineStyles["--font"]).toBe("monospace");
comp.redo();
expect(comp.getVariableDeclarations()).toEqual(OBJECT_DECLARATIONS);
comp.batch(() => {
comp.setVariableValue("font", "sans-serif");
comp.setVariableValue("logo", "last.svg");
});
expect(comp.getVariableDeclarations().map((declaration) => declaration.default)).toEqual([
"sans-serif",
"last.svg",
]);
expect(comp.getElement("hf-stage")?.inlineStyles["--font"]).toBe("sans-serif");
expect(comp.getElement("hf-stage")?.inlineStyles["--logo"]).toBe("last.svg");
comp.undo();
expect(comp.getVariableDeclarations()).toEqual(OBJECT_DECLARATIONS);
comp.dispose();
});
it("replays whole objects through overrides and forward/inverse patches", async () => {
const comp = await openComposition(HTML, { overrides: {} });
const forward: JsonPatchOp[] = [];
const inverse: JsonPatchOp[] = [];
comp.on("patch", (event) => {
forward.push(...event.patches);
inverse.unshift(...event.inversePatches);
});
comp.setVariableValue("font", FONT);
comp.setVariableValue("logo", IMAGE);
const replayed = await openComposition(HTML, { overrides: comp.getOverrides() });
expect(replayed.getVariableDeclarations()).toEqual(OBJECT_DECLARATIONS);
expect(replayed.getVariableValue("font", { base: true })).toBe("serif");
const patched = await openComposition(HTML);
patched.applyPatches(forward);
expect(patched.getVariableDeclarations()).toEqual(OBJECT_DECLARATIONS);
patched.applyPatches(inverse);
expect(patched.getVariableDeclarations()).toEqual(DECLARATIONS);
comp.dispose();
replayed.dispose();
patched.dispose();
});
it("restores object defaults when a batch rolls back", async () => {
const comp = await openComposition(HTML, {
overrides: { "var.font": FONT, "var.logo": IMAGE },
});
expect(() =>
comp.batch(() => {
comp.setVariableValue("font", "serif");
comp.setVariableValue("logo", "other.svg");
throw new Error("abort");
}),
).toThrow("abort");
expect(comp.getVariableDeclarations()).toEqual(OBJECT_DECLARATIONS);
expect(comp.getOverrides()).toEqual({ "var.font": FONT, "var.logo": IMAGE });
comp.dispose();
});
it("accepts object declarations without creating undeclared variables", async () => {
const comp = await openComposition(HTML);
const declaration = {
id: "other-font",
type: "font",
label: "Other font",
default: FONT,
} as const;
expect(comp.can({ type: "declareVariable", declaration })).toEqual({ ok: true });
comp.declareVariable(declaration);
expect(comp.getVariableDeclarations()).toContainEqual(declaration);
comp.setVariableValue("missing", IMAGE);
expect(comp.getVariableValue("missing")).toBeUndefined();
expect(comp.validateVariableValues({ missing: IMAGE })).toEqual([
{ kind: "undeclared", variableId: "missing" },
]);
comp.dispose();
});
});
+7 -21
View File
@@ -1,4 +1,9 @@
import type { CompositionVariable, VariableValidationIssue } from "@hyperframes/core/variables";
import type {
CompositionVariable,
FontValue,
ImageValue,
VariableValidationIssue,
} from "@hyperframes/core/variables";
/**
* Cross-referenced variable usage for a whole composition: the per-script
@@ -270,26 +275,7 @@ 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. Add explicit optional fields here as consumers need them —
* an open `[key: string]: unknown` index signature was dropped because it let any
* `{url}`-shaped object through and swallowed key typos.
*/
export interface ImageValue {
url: string;
alt?: string;
fit?: "cover" | "contain" | "fill" | "none" | "scale-down";
}
export type { FontValue, ImageValue } from "@hyperframes/core/variables";
export interface GsapTweenSpec {
method: "from" | "to" | "fromTo" | "set";
@@ -0,0 +1,105 @@
// @vitest-environment happy-dom
import { act } from "react";
import { createRoot } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { openComposition } from "@hyperframes/sdk";
import type { CompositionVariable } from "@hyperframes/sdk";
import type { DomEditSelection } from "../editor/domEditingTypes";
import { VariablesBindElement } from "./VariablesBindElement";
beforeEach(() => vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true));
afterEach(() => vi.unstubAllGlobals());
const cases = [
{
declaration: {
id: "selected-font",
label: "Brand font",
type: "font",
default: { name: "Brand Sans", source: "assets/brand.css" },
},
expected: "Brand Sans",
},
{
declaration: {
id: "selected",
label: "Brand logo",
type: "image",
default: { url: "assets/logo.svg", alt: "Brand logo", fit: "contain" },
},
expected: "assets/logo.svg",
},
{
declaration: { id: "selected-font", label: "Font", type: "font", default: "serif" },
expected: "serif",
},
{
declaration: { id: "selected", label: "Logo", type: "image", default: "fallback.svg" },
expected: "fallback.svg",
},
] satisfies { declaration: CompositionVariable; expected: string }[];
describe("VariablesBindElement existing defaults", () => {
it.each(cases)(
"shows $expected when binding to a $declaration.type",
async ({ declaration, expected }) => {
const session = await openComposition(
'<html><body><img id="selected" data-hf-id="hf-selected" src="own.svg"></body></html>',
);
session.declareVariable(declaration);
const selection: DomEditSelection = {
hfId: "hf-selected",
element: document.createElement("img"),
label: "Selected image",
tagName: "IMG",
sourceFile: "index.html",
compositionPath: "index.html",
isCompositionHost: false,
isInsideLockedComposition: false,
boundingBox: { x: 0, y: 0, width: 100, height: 100 },
textContent: null,
dataAttributes: {},
inlineStyles: {},
computedStyles: {},
textFields: [],
capabilities: {
canSelect: true,
canEditStyles: true,
canCrop: false,
canMove: false,
canResize: false,
canApplyManualOffset: false,
canApplyManualSize: false,
canApplyManualRotation: false,
},
};
const host = document.createElement("div");
document.body.append(host);
const root = createRoot(host);
const onBind = vi.fn();
try {
await act(async () => {
root.render(
<VariablesBindElement selection={selection} sdkSession={session} onBind={onBind} />,
);
});
const label = declaration.type === "font" ? "Font" : "Image source";
const action = [...host.querySelectorAll("button")].find((button) =>
button.textContent?.startsWith(label),
);
if (!action) throw new Error(`Missing ${label} bind action`);
await act(async () => action.click());
const warning = host.querySelector("p.text-warning-ink");
expect(warning?.textContent).toContain(`"${declaration.id}" already exists.`);
expect(warning?.querySelector("span")?.textContent).toBe(` (${expected})`);
expect(onBind).not.toHaveBeenCalled();
expect(session.getVariableDeclarations()).toEqual([declaration]);
} finally {
await act(async () => root.unmount());
host.remove();
session.dispose();
}
},
);
});
@@ -13,7 +13,7 @@ import { useMemo, useState } from "react";
import type { Composition, CompositionVariable } from "@hyperframes/sdk";
import type { DomEditSelection } from "../editor/domEditingTypes";
import { VARIABLES_INPUT_CLASS } from "./VariablesValueControls";
import { variableDefaultText, VARIABLES_INPUT_CLASS } from "./VariablesValueControls";
// <source> is deliberately excluded: rewriting a <source> child's src after
// the parent media element ran resource selection is a spec no-op.
@@ -218,7 +218,7 @@ export function VariablesBindElement({
<p className="text-[9px] leading-snug text-warning-ink">
"{trimmedId}" already exists. This element will use its current value
{existingDecl.default !== undefined && (
<span className="font-mono"> ({String(existingDecl.default)})</span>
<span className="font-mono"> ({variableDefaultText(existingDecl)})</span>
)}
, not the element's own, so binding won't change "{trimmedId}".
</p>
@@ -5,6 +5,7 @@
*/
import { describe, it, expect } from "vitest";
import { createMemoryAdapter, openComposition } from "@hyperframes/sdk";
import {
mergeDeclarationEdit,
declarationFromDraft,
@@ -13,6 +14,32 @@ import {
} from "./VariablesDeclarationForm.js";
import type { CompositionVariable } from "@hyperframes/core/variables";
const fontDefault = {
name: "monospace",
source: "assets/brand.css",
vendor: { familyId: "brand", weights: [400, 700] },
};
const imageDefault = {
url: "assets/logo.svg",
alt: "Brand logo",
fit: "contain" as const,
vendor: { assetId: "logo", crop: { x: 0, y: 1 } },
};
const objectDeclarations = [
{
id: "font",
type: "font",
label: "Brand font",
default: fontDefault,
},
{
id: "logo",
type: "image",
label: "Brand logo",
default: imageDefault,
},
] satisfies CompositionVariable[];
describe("mergeDeclarationEdit", () => {
it("preserves unmodeled keys on a same-type edit", () => {
// The form owns id/label/type/default/description/min/max/step/options; every
@@ -64,6 +91,85 @@ describe("mergeDeclarationEdit", () => {
});
describe("declarationFromDraft", () => {
for (const original of objectDeclarations) {
it.each(["label", "description"])(
`preserves the ${original.type} object on a %s-only edit through save/reopen`,
async (field) => {
const draft = draftFromDeclaration(original);
expect(draft.defaultRaw).toBe(
original.type === "font" ? original.default.name : original.default.url,
);
const edited = declarationFromDraft({ ...draft, [field]: "Updated metadata" });
if (typeof edited === "string") throw new Error(edited);
const merged = mergeDeclarationEdit(original, edited);
expect(merged).toEqual({ ...original, [field]: "Updated metadata" });
const persist = createMemoryAdapter();
const session = await openComposition("<html><body></body></html>", {
persist,
coalesceMs: 0,
});
session.declareVariable(original);
session.updateVariableDeclaration(original.id, merged);
await session.flush();
const reopened = await openComposition(await persist.read("composition.html"));
try {
expect(reopened.getVariableValue(original.id)).toEqual(original.default);
expect(reopened.getVariableDeclarations()).toEqual([merged]);
} finally {
reopened.dispose();
session.dispose();
}
},
);
it(`edits only the primary ${original.type} value`, () => {
const edited = declarationFromDraft({
...draftFromDeclaration(original),
defaultRaw: "replacement",
});
if (typeof edited === "string") throw new Error(edited);
expect(mergeDeclarationEdit(original, edited).default).toEqual({
...original.default,
...(original.type === "font" ? { name: "replacement" } : { url: "replacement" }),
});
});
it(`drops the ${original.type} object when changing type`, () => {
const edited = declarationFromDraft({
...draftFromDeclaration(original),
type: original.type === "font" ? "image" : "font",
defaultRaw: "replacement",
});
if (typeof edited === "string") throw new Error(edited);
expect(mergeDeclarationEdit(original, edited)).toEqual({
id: original.id,
label: original.label,
type: original.type === "font" ? "image" : "font",
default: "replacement",
});
});
}
it.each(["font", "image"] as const)("keeps %s string defaults scalar", (type) => {
const original: CompositionVariable = {
id: "brand",
label: "Brand",
type,
default: "fallback",
};
expect(declarationFromDraft(draftFromDeclaration(original))).toEqual(original);
expect(
declarationFromDraft({
...EMPTY_DRAFT,
id: "brand",
label: "Brand",
type,
defaultRaw: "fallback",
}),
).toEqual(original);
});
it("requires a non-empty id", () => {
expect(declarationFromDraft({ ...EMPTY_DRAFT, id: " " })).toBe("Variable id is required.");
});
@@ -6,7 +6,7 @@
import { useState } from "react";
import type { CompositionVariable, CompositionVariableType } from "@hyperframes/sdk";
import { VARIABLES_INPUT_CLASS } from "./VariablesValueControls";
import { variableDefaultText, VARIABLES_INPUT_CLASS } from "./VariablesValueControls";
const VARIABLE_TYPES: CompositionVariableType[] = [
"string",
@@ -19,6 +19,7 @@ const VARIABLE_TYPES: CompositionVariableType[] = [
];
export interface DeclarationDraft {
original?: CompositionVariable;
id: string;
label: string;
type: CompositionVariableType;
@@ -48,10 +49,11 @@ export function draftFromDeclaration(decl: CompositionVariable): DeclarationDraf
const numeric = decl.type === "number" ? decl : null;
return {
...EMPTY_DRAFT,
original: decl,
id: decl.id,
label: decl.label,
type: decl.type,
defaultRaw: String(decl.default),
defaultRaw: variableDefaultText(decl),
description: decl.description ?? "",
min: numeric?.min !== undefined ? String(numeric.min) : "",
max: numeric?.max !== undefined ? String(numeric.max) : "",
@@ -153,8 +155,24 @@ export function declarationFromDraft(draft: DeclarationDraft): CompositionVariab
return { ...base, type: "boolean", default: draft.defaultRaw.trim() === "true" };
case "enum":
return enumDeclFromDraft(base, draft);
case "font": {
const value = draft.original?.type === "font" ? draft.original.default : undefined;
return {
...base,
type: "font",
default:
typeof value === "object" ? { ...value, name: draft.defaultRaw } : draft.defaultRaw,
};
}
case "image": {
const value = draft.original?.type === "image" ? draft.original.default : undefined;
return {
...base,
type: "image",
default: typeof value === "object" ? { ...value, url: draft.defaultRaw } : draft.defaultRaw,
};
}
default:
// string / color / font / image — string default, verbatim.
return { ...base, type: draft.type, default: draft.defaultRaw };
}
}
@@ -16,6 +16,12 @@ import type {
export const VARIABLES_INPUT_CLASS =
"w-full bg-neutral-900 border border-neutral-800 rounded-sm px-2 py-1 text-[10px] text-neutral-200 focus:outline-hidden focus:border-neutral-700";
export function variableDefaultText(decl: CompositionVariable): string {
if (decl.type === "font" && typeof decl.default === "object") return decl.default.name;
if (decl.type === "image" && typeof decl.default === "object") return decl.default.url;
return String(decl.default);
}
/** Text input that drafts locally and commits on blur/Enter. */
function DraftTextInput({
value,