diff --git a/docs/images/variable-object-edit/after.png b/docs/images/variable-object-edit/after.png new file mode 100644 index 0000000000..beae5f6c9e Binary files /dev/null and b/docs/images/variable-object-edit/after.png differ diff --git a/docs/images/variable-object-edit/before.png b/docs/images/variable-object-edit/before.png new file mode 100644 index 0000000000..74ecf08d76 Binary files /dev/null and b/docs/images/variable-object-edit/before.png differ diff --git a/docs/sdk/reference/types.mdx b/docs/sdk/reference/types.mdx index 689f9db43f..d0a4496e40 100644 --- a/docs/sdk/reference/types.mdx +++ b/docs/sdk/reference/types.mdx @@ -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. - - 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. + + 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. diff --git a/docs/studio/variables.mdx b/docs/studio/variables.mdx index e33b0ccb0b..5a2ad5f754 100644 --- a/docs/studio/variables.mdx +++ b/docs/studio/variables.mdx @@ -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. + Variables from other compositions are listed separately. You can edit their declarations, but their values are not live-previewed in the active composition. diff --git a/packages/core/src/runtime/validateVariables.test.ts b/packages/core/src/runtime/validateVariables.test.ts index 218c189fd0..c273761359 100644 --- a/packages/core/src/runtime/validateVariables.test.ts +++ b/packages/core/src/runtime/validateVariables.test.ts @@ -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([ { diff --git a/packages/core/src/runtime/validateVariables.ts b/packages/core/src/runtime/validateVariables.ts index b8775f82e9..0c07453ae4 100644 --- a/packages/core/src/runtime/validateVariables.ts +++ b/packages/core/src/runtime/validateVariables.ts @@ -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; diff --git a/packages/core/src/variables.ts b/packages/core/src/variables.ts index 662be9df55..8710be3f6c 100644 --- a/packages/core/src/variables.ts +++ b/packages/core/src/variables.ts @@ -16,7 +16,9 @@ export type { BooleanVariable, EnumVariable, FontVariable, + FontValue, ImageVariable, + ImageValue, } from "@hyperframes/parsers/composition"; export { COMPOSITION_VARIABLE_TYPES, diff --git a/packages/lint/src/rules/composition.test.ts b/packages/lint/src/rules/composition.test.ts index 76b666cccd..6f06f4dc4b 100644 --- a/packages/lint/src/rules/composition.test.ts +++ b/packages/lint/src/rules/composition.test.ts @@ -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 = `
`; + 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 = ``; const finding = find(await lintHyperframeHtml(html)); diff --git a/packages/lint/src/rules/composition.ts b/packages/lint/src/rules/composition.ts index d19636b25b..f4801dd78b 100644 --- a/packages/lint/src/rules/composition.ts +++ b/packages/lint/src/rules/composition.ts @@ -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", diff --git a/packages/parsers/src/composition.ts b/packages/parsers/src/composition.ts index 0078f15fe2..23da71ea16 100644 --- a/packages/parsers/src/composition.ts +++ b/packages/parsers/src/composition.ts @@ -13,6 +13,8 @@ export { decodeUrlPathVariants } from "./utils/urlPath.js"; export { parseCompositionVariables, isCompositionVariable, + isFontValue, + isImageValue, isScalarVariableValue, isSafeMediaUrl, } from "./compositionVariables.js"; diff --git a/packages/parsers/src/compositionVariables.test.ts b/packages/parsers/src/compositionVariables.test.ts new file mode 100644 index 0000000000..c347a44207 --- /dev/null +++ b/packages/parsers/src/compositionVariables.test.ts @@ -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([]); + }); +}); diff --git a/packages/parsers/src/compositionVariables.ts b/packages/parsers/src/compositionVariables.ts index 66fc7ecd8f..fc863becf6 100644 --- a/packages/parsers/src/compositionVariables.ts +++ b/packages/parsers/src/compositionVariables.ts @@ -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 = { string: "string", number: "number", @@ -33,7 +34,25 @@ export function isScalarVariableValue(value: unknown): value is string | number } function isRecord(v: unknown): v is Record { - 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; diff --git a/packages/parsers/src/types.ts b/packages/parsers/src/types.ts index 493cda018e..354fc7bb6a 100644 --- a/packages/parsers/src/types.ts +++ b/packages/parsers/src/types.ts @@ -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; } diff --git a/packages/sdk/src/session.variableobjects.test.ts b/packages/sdk/src/session.variableobjects.test.ts new file mode 100644 index 0000000000..a5c4500dd5 --- /dev/null +++ b/packages/sdk/src/session.variableobjects.test.ts @@ -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 = `
`; +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(); + }); +}); diff --git a/packages/sdk/src/types.ts b/packages/sdk/src/types.ts index d5c92d0d20..8eba6514d7 100644 --- a/packages/sdk/src/types.ts +++ b/packages/sdk/src/types.ts @@ -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"; diff --git a/packages/studio/src/components/panels/VariablesBindElement.test.tsx b/packages/studio/src/components/panels/VariablesBindElement.test.tsx new file mode 100644 index 0000000000..42f26ce593 --- /dev/null +++ b/packages/studio/src/components/panels/VariablesBindElement.test.tsx @@ -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( + '', + ); + 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( + , + ); + }); + 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(); + } + }, + ); +}); diff --git a/packages/studio/src/components/panels/VariablesBindElement.tsx b/packages/studio/src/components/panels/VariablesBindElement.tsx index cdd15b758d..44db03153b 100644 --- a/packages/studio/src/components/panels/VariablesBindElement.tsx +++ b/packages/studio/src/components/panels/VariablesBindElement.tsx @@ -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"; // is deliberately excluded: rewriting a child's src after // the parent media element ran resource selection is a spec no-op. @@ -218,7 +218,7 @@ export function VariablesBindElement({

"{trimmedId}" already exists. This element will use its current value {existingDecl.default !== undefined && ( - ({String(existingDecl.default)}) + ({variableDefaultText(existingDecl)}) )} , not the element's own, so binding won't change "{trimmedId}".

diff --git a/packages/studio/src/components/panels/VariablesDeclarationForm.test.ts b/packages/studio/src/components/panels/VariablesDeclarationForm.test.ts index bad213a707..d1ee45a03d 100644 --- a/packages/studio/src/components/panels/VariablesDeclarationForm.test.ts +++ b/packages/studio/src/components/panels/VariablesDeclarationForm.test.ts @@ -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("", { + 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."); }); diff --git a/packages/studio/src/components/panels/VariablesDeclarationForm.tsx b/packages/studio/src/components/panels/VariablesDeclarationForm.tsx index 92e224cb3a..ee9e318f0c 100644 --- a/packages/studio/src/components/panels/VariablesDeclarationForm.tsx +++ b/packages/studio/src/components/panels/VariablesDeclarationForm.tsx @@ -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 }; } } diff --git a/packages/studio/src/components/panels/VariablesValueControls.tsx b/packages/studio/src/components/panels/VariablesValueControls.tsx index 3a69506bb1..4fce8ae97b 100644 --- a/packages/studio/src/components/panels/VariablesValueControls.tsx +++ b/packages/studio/src/components/panels/VariablesValueControls.tsx @@ -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,