Skip to content

feat(sdk,studio): editable template sub-compositions + promote sub-comp element properties#2081

Open
jrusso1020 wants to merge 1 commit into
07-08-feat_studio_promote_to_variable_from_the_design_panelfrom
07-08-feat_editable_template_subcomps_and_subcomp_promote
Open

feat(sdk,studio): editable template sub-compositions + promote sub-comp element properties#2081
jrusso1020 wants to merge 1 commit into
07-08-feat_studio_promote_to_variable_from_the_design_panelfrom
07-08-feat_editable_template_subcomps_and_subcomp_promote

Conversation

@jrusso1020

@jrusso1020 jrusso1020 commented Jul 8, 2026

Copy link
Copy Markdown
Collaborator

What

Makes <template>-based sub-compositions first-class editable in the SDK, and retargets the Design-panel promote/bind so it operates on the sub-comp's own file — so you can promote a property of an element inside an inlined sub-comp and it becomes a variable on that reusable frame.

Fixes the gap found while live-testing #2071: promote (and the shipped #2055 "Bind selected" card) silently did nothing on sub-composition elements, because the SDK didn't model template comps (getElements() returned 0) and refused variable declarations on fragments (E_FRAGMENT_COMPOSITION).

SDK — template comps as first-class editable

  • isCompositionTemplate now recognizes the authored form <template id="X-template"><div data-composition-id>… (id on the wrapped root div), not only data-composition-id on the template element. This one predicate feeds hf-id minting and element enumeration; mirrored in querySelectorAllDeep so dispatch/resolution descend into the same templates. Plain clone-source templates (list items, particles) are still excluded (guarded by "wraps a [data-composition-id] root").
  • findRoot descends into a composition template to return the inner [data-composition-id] root (so value/metadata writes don't land on the <template> shell).
  • Declarations move to the composition root div for wrapped template/fragment comps. They have no <html> to hold data-composition-variables, and the synthetic one is stripped on serialize. A single declarationElement(document, wrapped) resolves <html> (full-doc) vs the root div (wrapped); the declaration model + every mutate / patch-replay / can() path routes through it. Declaring is now refused only when there is genuinely no root element.

Studio

  • DesignPanelPromoteProvider opens the SDK session and persists against selection.sourceFile (the sub-comp file), not the host activeCompPath — so a promoted variable's declaration + binding land in the frame's own file, applying to every instance.

Lint

  • The unknown_variable_binding rule now unions declared ids from the root div too (not just <html>), so a promoted data-var-* on a template comp isn't false-flagged as undeclared.

Verified

  • New SDK tests: authored-template enumeration, declaration round-trip through serialize + re-open, and plain-clone-template exclusion. The old "fragments refuse declarations" test is updated to the new contract, plus a rootless-fragment guard test.
  • Full suites green: sdk 440, lint 351, core 1135, studio promote 17.
  • End-to-end in the studio (screenshot attached in review): selecting .timer inside frames/02-problem.html now shows the ◇ var pill; clicking it writes data-composition-variables + data-var-text into the frame's own file and flips to the chip.

Deferred follow-up — producer render-path

The producer/inliner variable aggregation (inlineSubCompositions reads defaults from documentElement only) and a few secondary readers (extractCompositionMetadata → CLI --variables, runtime external-load compositionLoader, named-host innerHTML root-div preservation) still read declarations from <html> only. This only affects overriding a template-sub-comp variable at render time — the promote default equals the element's current value, so default render is unchanged. Deferred because it touches a 262-line critical-complexity inliner function and warrants an actual-render verification pass. Tracked with file:line anchors from the change map.

Stacked on #2071.

Variables tab — all variables, grouped by composition

Because promote writes a variable into the sub-comp's own file, that variable wouldn't show in the host-scoped Variables tab (reported while testing). Fixed: the Variables tab now lists the active composition's variables plus an "Other compositions" group per sub-comp file that declares any, each with edit / remove (VariablesOtherCompositions + useProjectCompositionVariables, reading declarations via openComposition(...).getVariableDeclarations() per file, editing via an ad-hoc per-file persist). So the panel reflects the whole template's variable surface.

Live-preview override of a sub-comp variable is the one deferred piece (values are per-composition-scope, tied to #2070's per-instance routing) — see + manage works now; host-level preview is unchanged.

jrusso1020 commented Jul 8, 2026

Copy link
Copy Markdown
Collaborator Author

@jrusso1020 jrusso1020 marked this pull request as ready for review July 8, 2026 21:39
@jrusso1020 jrusso1020 force-pushed the 07-08-feat_editable_template_subcomps_and_subcomp_promote branch 2 times, most recently from 223de02 to a8972ad Compare July 9, 2026 05:48

@miga-heygen miga-heygen left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM with nits — the declarationElement SSOT refactor is textbook-clean. Every call site that previously hardcoded document.documentElement or gated on parsed.wrapped now routes through the single resolver. The variableModel.ts signature change from Document to Element | null pushes resolution responsibility to the caller, keeping the model pure.

Stale lint fix hint (packages/lint/src/rules/composition.ts:671): the fixHint for unknown_variable_binding still says "Declare the variable on <html>: ...". After this PR, template sub-comps declare on the composition root div. The hint should read something like "Declare the variable in data-composition-variables on the composition root element" to avoid misleading sub-comp authors.

Sequential file reads (useProjectCompositionVariables.ts): readGroup calls are awaited in a for loop. For projects with many sub-comp HTML files, Promise.all would parallelize the reads. Non-blocking performance note.

Positive call-outs:

  • isCompositionTemplate correctly distinguishes Form A (data-composition-id on <template>) from Form B (on the wrapped root div) while excluding plain clone-source templates
  • findRoot fallback ordering is correct and doesn't break the existing resolution chain
  • fragmentCompositionErr inversion from "refuse wrapped" to "refuse when no declaration element" correctly allows template sub-comps while guarding bare rootless fragments

— Miga

@miguel-heygen miguel-heygen left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Stamped after Miga's stack review. GitHub state checked: mergeable, no red checks visible; #2046 base conflict still needs resolution separately.

@james-russo-rames-d-jusso james-russo-rames-d-jusso left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed at a8972ad. Stack context: integration cap #2081 — sits atop the 12-PR template-variables stack, composes with #2047 (edit ops), #2050 (inspector), #2054 (declarative bindings), #2071 (promote), #2084 (sub-comp defaults). Layering onto Miga's review; concerns below are cross-PR seams they didn't cover.

🔴 Blockers

None. The SDK refactor is clean, tests are focused, CI is green.

🟠 Hold — worth clarifying before land

1. data-var-* bindings + per-instance overrides on promoted sub-comp variables silently no-op at render UNTIL #2084 lands. The PR body flags this as a "deferred follow-up" and is correct that the default render is unchanged (element retains its inline value). But the composition seam is subtler than "affects only overriding":

  • CSS bindings work: htmlBundler.ts:1113 scans [data-composition-variables] broadly, so declarer rules from a root-div declaration DO get emitted (verified at HEAD).
  • data-var-* bindings and host overrides don't: inlineSubCompositions.ts:242 reads readVariableDefaults(compDoc.documentElement) only, so variablesByComp[runtimeCompId] is empty for template sub-comps → runtime data-var-text / data-var-src resolution on inlined sub-comp elements can't find the id, and host data-variable-values has nothing to override.

The screenshot flow in the PR body — promoting .timer text inside frames/02-problem.html and writing data-var-text — is exactly the case that would preview-fine but render-empty if 2081 landed alone. Since 2084 stacks directly on this PR, that's usually fine in a batch merge; the ask is don't land 2081 without 2084 in the same batch, and consider tightening the PR body from "affects overriding at render time" to "affects data-var-* binding resolution and overrides at render time — CSS-bound sub-comp vars still work."

2. Public docs stale, not touched by a dual-scope SDK+studio PR. The PR body claims dual scope but no docs/* files changed. Two spots that now teach the wrong contract:

  • docs/concepts/variables.mdx:10 — "Add data-composition-variables to the <html> root of any composition." No longer true for template sub-comps.
  • docs/concepts/variables.mdx:293 — the precedence table's "Declared defaults" row still says data-composition-variables on <html>.
  • docs/sdk/reference/composition.mdx:127declareVariable's error listing still says E_FRAGMENT_COMPOSITION — fragment sources have no <html> to carry the schema. After this PR, a fragment WITH a root element accepts declarations; the error only fires when there's genuinely no root.

Suggest a short section in variables.mdx — "Where declarations live" — noting the two carriers (<html> for full-document comps, composition root div for wrapped template/fragment comps), and updating the error listing.

3. E_FRAGMENT_COMPOSITION code kept but semantics narrowed. The error code name still says "fragment"; the code now fires only when there's no root element at all (a bare fragment). Any external caller matching on the code to say "add an <html>" will now give bad advice — the fix in the new world is "add a composition root element." Two options: rename to E_NO_ROOT_ELEMENT (breaking, do in a follow-up), or at least document the semantic shift in the composition reference. Same message string suggests the latter is fine for now (packages/sdk/src/engine/mutate.ts:930).

🟡 Nits and questions

Symmetry with #2071 — integration test gap for the sub-comp path. variablePromoteIntegration.test.ts still uses a full-document composition (<!DOCTYPE html><html><body>…) — round-trips applyBindreadBindingFrom. That covers the top-level flow (unchanged). What's not covered is the new sub-comp targeting: given a selection whose sourceFile differs from activeCompPath, verify the promote lands in sourceFile and not the host. A one-shot integration test that opens two files (host + sub-comp), simulates a selection.sourceFile = "sub.html" promote, and asserts the declaration ends up in sub.html's bytes (and the host's untouched) would close the SDK↔Studio wiring seam this PR introduces. The SDK-side tests (session.template.test.ts) prove the sub-comp declare/serialize round-trip; the Studio-side glue is where the wiring lives.

Two SDK sessions on the same file when targetPath === activeCompPath. With #2071's shape, DesignPanelPromoteProvider reused the top-level sdkSession prop. Now it opens its own via useSdkSession(projectId, targetPath, …). When no sub-comp element is selected, targetPath falls back to activeCompPath — so the promote provider opens a second linkedom document on the same file. Both sessions receive the file-change event on writes and both trigger openComposition reloads. Not a correctness bug (isSelfWriteEcho is content-identity based), but memory + reload cost doubles on the active-comp path. Was that an intentional trade for the simpler prop shape, or worth a useMemo fast path when targetPath === activeCompPath to reuse the caller's session?

useProjectCompositionVariables — deps include an unstable fileTree reference. The useEffect deps [fileTree, excludePath, readProjectFile, refreshKey] will re-fire on every render if useFileManagerContext() returns a fresh fileTree array reference (which context providers often do without deep memoization). The eslint-disable on the line above hints something was intentionally waved through — worth a comment explaining why, or a .join("|") derived key. Combined with Miga's serial-vs-parallel note: on a rapidly-refreshing panel with a mid-sized project, sequential openComposition per html file per render is O(n) I/O per render.

readGroup swallows all openComposition errors silently. Fine for panel resilience, but a real corruption bug (persist wrote invalid HTML) would just make a variable quietly vanish from the "Other compositions" section with no signal. Consider console.warn/telemetry on the catch branch — this is the exact class of failure the studio's observability wants to catch.

Undo-history label ambiguity. useEditVariablesInFile records "Update variable \"title-color\"" — no file path. Two sub-comps with the same variable id would produce indistinguishable history entries. Consider suffixing with the file basename (Update variable "title-color" (frames/02-problem.html)).

Substring guard + regex in the lint rule both match tokens in HTML comments. useProjectCompositionVariables.ts:24 and lint/composition.ts collectAllDeclaredVariableIds's <[a-zA-Z][^>]*\bdata-composition-variables\b[^>]*> — an occurrence inside <!-- --> would over-declare, but that only reduces false-positive lint findings and includes extra scanning targets. Safe direction, worth a code comment noting the intentional imprecision.

isCompositionTemplate child-scan is direct-children-only. for (const child of Array.from(el.children)) — the wrapped-root-div form is direct-child in the current scaffold, so this matches the shape. If a future scaffold nests the [data-composition-id] root under a wrapper <div> or <section>, this predicate silently regresses to "not a comp template" and enumeration/mutation stop reaching those elements. Docstring pins the current contract; keep an eye on it if scaffold changes.

🟢 What's good

  • declarationElement(document, wrapped) is the exact SSOT this refactor needed — every parsed.wrapped guard and hardcoded documentElement now routes through the resolver, and variableModel.ts signature moved to Element | null pushing resolution to the caller. Textbook.
  • New tests in session.template.test.ts are the right differentials: enumeration, round-trip through serialize (proves the root-div carrier survives), and the clone-source exclusion.
  • The updated session.variabledecls.test.ts splits "fragment with root (accepted)" from "fragment with no root (refused)" — the exact new contract.
  • Backward-compat argument in the PR body is sound: default = current value, so existing sub-comp render is unchanged at bit level; the extension is purely capability-expansion.
  • findRoot's new querySelectorAllDeep(document, "[data-composition-id]")[0] fallback keeps the resolution chain ordered so full-document comps hit the earlier branches; template comps hit the new one. Clean.
  • Very high-quality PR body: explicitly enumerates the deferred readers with file:line anchors, documents the current-value default rationale, and stacks the render-path fix as its own PR (#2084).

Review by Rames D Jusso

@vanceingalls vanceingalls left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

R1 — hyperframes #2081 at a8972ad1

🟢 LGTM on the SSOT refactor, concurring with Miga; concur with Rames-D's three 🟠 Holds — none block, but all worth landing before this becomes a stable public-API contract.

Verified independently:

  • Template recognition refined. isCompositionTemplate at packages/parsers/src/hfIds.ts now recognizes both <template data-composition-id> and <template id="X-template"><div data-composition-id>…. Plain clone-source templates correctly excluded.
  • declarationElement(document, wrapped) is textbook SSOT. Full-doc comps target documentElement; wrapped templates/fragments call findRoot()[data-composition-id] root div (the durable declaration holder). Round-trip test in session.template.test.ts locks in serialize preservation.
  • Model / patch / mutate paths. Every mutate/patch/can() route through declarationElement(), not hardcoded <html>. Grep-verified.
  • Lint rule expansion. unknown_variable_binding in composition.ts unions declared ids via collectAllDeclaredVariableIds() — a promoted data-var-* on a template-comp element no longer false-flagged.
  • Studio promote/bind retargeting. DesignPanelPromoteProvider.tsx opens the SDK session on selection?.sourceFile || activeCompPath || "index.html". Reusability invariant: a promoted prop on a sub-comp element is visible to any other parent that uses the same sub-comp.
  • Reusability inspector. VariablesOtherCompositions + useProjectCompositionVariables list per-file declarations via openComposition(...).getVariableDeclarations() across the file tree; substring guard skips clean files before full parse.
  • -123 lines = deletion of legacy "fragments refuse declarations" code path (updated to new contract).
  • CI green including preview-regression + player-perf.

Concurring with Miga's LGTM.

Concurring with Rames-D's 🟠 Holds:

  1. data-var-* bindings + host overrides silently no-op at render until #2084 lands. Rames-D's cross-PR analysis is right and worth reproducing here: htmlBundler.ts:1113 scans [data-composition-variables] broadly so CSS binding rules ARE emitted, but inlineSubCompositions.ts:242 reads readVariableDefaults(compDoc.documentElement) only — so variablesByComp[runtimeCompId] is empty for template sub-comps and runtime data-var-* resolution on inlined sub-comp elements can't find the id. The screenshot flow in the PR body (promote .timer inside frames/02-problem.html and write data-var-text) is exactly the case that would preview-fine but render-empty if #2081 landed alone. Ask: don't land #2081 without #2084 in the same batch, and tighten the PR body's "affects overriding at render time" phrasing to "affects data-var-* binding resolution AND overrides at render time — CSS-bound sub-comp vars still work."

  2. Docs stale. docs/concepts/variables.mdx:10 ("Add data-composition-variables to the <html> root of any composition"), :293 (precedence table's "Declared defaults" row), and docs/sdk/reference/composition.mdx:127 (declareVariable's error listing still cites <html> as required carrier). PR body claims dual SDK+studio scope but no docs/* changed. Concur on a short "Where declarations live" section covering the two carriers plus the error-listing update.

  3. E_FRAGMENT_COMPOSITION code name/semantics drift. Code fires only when there's no root element at all now, but the identifier still says "fragment." An external caller matching on the code to say "add an <html>" now gives bad advice — new-world fix is "add a composition root element." Concur: either rename to E_NO_ROOT_ELEMENT (breaking, do in follow-up) or document the semantic shift.

Concurring with Rames-D's nits — I'll add one small independent piece and defer to his coverage on the others:

O1 — Two SDK sessions on the same file when targetPath === activeCompPath. Rames-D flagged this and it's real: DesignPanelPromoteProvider opens a second linkedom document via useSdkSession on the same file when no sub-comp element is selected. Not a correctness bug (isSelfWriteEcho is content-identity based), but memory + reload cost doubles on the hot path. A useMemo fast-path when targetPath === activeCompPath (reuse the caller's session) would eliminate the doubling without complicating the sub-comp path. Small; keep the change surface tight.

O2 — Envelope-clean. Verified: no Co-Authored-By: Claude in commits, no 🤖 Generated with [Claude Code] in PR body. Consistent with #2071.

R1 by Via — concur with all three 🟠 Holds; ordering constraint on #2081/#2084 batch merge is the load-bearing takeaway.

@jrusso1020 jrusso1020 force-pushed the 07-08-feat_studio_promote_to_variable_from_the_design_panel branch from f5d8df9 to 842294f Compare July 9, 2026 06:48
@jrusso1020 jrusso1020 force-pushed the 07-08-feat_editable_template_subcomps_and_subcomp_promote branch from a8972ad to cf726f6 Compare July 9, 2026 06:49
@jrusso1020 jrusso1020 force-pushed the 07-08-feat_studio_promote_to_variable_from_the_design_panel branch from 842294f to 8cc6c81 Compare July 9, 2026 07:57
@jrusso1020 jrusso1020 force-pushed the 07-08-feat_editable_template_subcomps_and_subcomp_promote branch from cf726f6 to 5ed4778 Compare July 9, 2026 07:57
@jrusso1020

Copy link
Copy Markdown
Collaborator Author

Addressed R1 🟠 Holds (stale docs + E_FRAGMENT_COMPOSITION drift).

  • docs/concepts/variables.mdx: "Declaring Variables" and the precedence table now describe the dual declaration carrier — <html> for full-document comps, the [data-composition-id] root div for template/fragment comps.
  • docs/sdk/reference/composition.mdx: the declareVariable error listing now documents E_INVALID_VARIABLE_ID and notes that, despite its name, E_FRAGMENT_COMPOSITION now fires only when there is no composition root at all — the fix is "add a composition root element", not necessarily "add an <html>".
  • Ordering constraint (don't land without feat(core): aggregate template sub-comp variable defaults from the root div on the render path #2084) is respected: they merge as one stacked batch.

@jrusso1020 jrusso1020 force-pushed the 07-08-feat_editable_template_subcomps_and_subcomp_promote branch from 5ed4778 to 816a860 Compare July 9, 2026 13:17
@jrusso1020 jrusso1020 force-pushed the 07-08-feat_studio_promote_to_variable_from_the_design_panel branch from 8cc6c81 to 738398a Compare July 9, 2026 13:17
@jrusso1020

Copy link
Copy Markdown
Collaborator Author

Follow-up from the #2101 stack review (Miguel): updated the two spots still teaching <html>-only declarations — docs/concepts/compositions.mdx and the unknown_variable_binding lint fixHint now say "the composition root (<html>, or the [data-composition-id] root element for a template/fragment comp)".

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants