feat(studio): bind selected element properties to variables

Ninth PR of the template-variables stack: the promote-a-property gesture.
Select an element on the canvas/timeline, open the Variables tab, and the
panel offers per-property bind actions.

- "Bind selected" card in the Variables panel, built from the selection:
  image/media source (img/video/audio), text, text color, background, and
  font. Each action declares a variable whose default is the element's
  CURRENT value (promoting never changes the render — computed rgb colors
  convert to hex, the first computed font family becomes the font default)
  and writes the declarative binding the runtime resolves: data-var-src /
  data-var-text attributes or `<prop>: var(--id)` styles. Declare + bind
  run as one batched schema edit (one undo step); binding to an
  already-declared id skips the declare and just binds.
- guarded to selections from the composition the session models — a
  selection in another source file never writes bindings into this one.
- core: extract readVariablesForElement into runtime/variableScope.ts,
  shared by color grading and the declarative bindings (was duplicated).
- fix(studio-server): buildSubCompositionHtml's extractElementAttrs
  rebuilt html/body attributes without HTML-escaping values, shredding
  quote-bearing attributes — data-composition-variables (a JSON array)
  came out as mangled bogus attributes, so getVariables() silently
  returned {} on every /preview/comp/* page (no declared defaults, no
  runtime bindings). Pre-existing bug surfaced by live-testing this
  feature; regression test added.

Verified end-to-end in a live session: select headline → Bind text color
→ declaration + var(--headline-color) written to disk → override in the
panel → runtime applies the custom prop and the element renders the
override.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
James
2026-07-09 13:31:03 -07:00
co-authored by Claude Fable 5
parent f7ee0768ae
commit 267b289bb8
7 changed files with 358 additions and 4 deletions
@@ -156,6 +156,13 @@ function extractTemplateInnerHtml(rawComp: string): string | null {
return template ? template.innerHTML : null;
}
/** Attribute values read from the DOM are decoded — re-escape on rebuild or
* quote-bearing values (data-composition-variables is a JSON array) shred
* the wrapper's markup into bogus attributes. */
function escapeAttrValue(value: string): string {
return value.replace(/&/g, "&amp;").replace(/"/g, "&quot;");
}
function extractElementAttrs(el: Element): string {
const parts: string[] = [];
for (let i = 0; i < el.attributes.length; i++) {
@@ -163,7 +170,7 @@ function extractElementAttrs(el: Element): string {
if (attr.value === "") {
parts.push(attr.name);
} else {
parts.push(`${attr.name}="${attr.value}"`);
parts.push(`${attr.name}="${escapeAttrValue(attr.value)}"`);
}
}
return parts.join(" ");
@@ -613,3 +613,27 @@ describe("preview ?variables= injection", () => {
expect(html).toContain('window.__hfVariables={"accent":"#f00"}');
});
});
describe("sub-composition preview attribute integrity", () => {
it("preserves quote-bearing html attributes (data-composition-variables JSON)", async () => {
const projectDir = createProjectDir();
const decls = JSON.stringify([
{ id: "title", type: "string", label: "Title", default: "Hello" },
]);
writeFileSync(
join(projectDir, "card.html"),
`<!doctype html><html data-composition-variables='${decls}'><head></head><body><div class="clip" data-start="0" data-duration="2">x</div></body></html>`,
);
const app = new Hono();
registerPreviewRoutes(app, createAdapter(projectDir));
const res = await app.request("http://localhost/projects/demo/preview/comp/card.html");
expect(res.status).toBe(200);
const html = await res.text();
const attr = /data-composition-variables="([^"]*)"/.exec(html)?.[1] ?? "";
// Entities decode back to the exact declared JSON — a lost/shredded
// attribute here silently breaks getVariables() on the comp route.
const decoded = attr.replace(/&quot;/g, '"').replace(/&amp;/g, "&");
expect(JSON.parse(decoded)).toEqual(JSON.parse(decls));
});
});