feat(studio): promote to variable from the design panel#2071
Conversation
0a18804 to
f5d8df9
Compare
miga-heygen
left a comment
There was a problem hiding this comment.
LGTM with nits — clean architecture, strong SSOT discipline, good test coverage.
The separation into pure helpers, context, render-prop wrapper, and provider is well-layered. buildBindActions/applyBind are reused from the Variables tab (no duplicated bind logic). The parseVarId nested-paren edge case degrades safely to "unbound" rather than a wrong id, and is explicitly tested.
Round-trip integration tests prove the write-read symmetry between applyBind and readBindingFrom, which is the critical invariant.
Nit: StudioRightPanel.tsx — the ternary domEditGroupSelections.length > 1 ? null : domEditSelection appears twice (once for the provider, once for PropertyPanel). Consider hoisting to a const for clarity. Not a correctness issue.
— Miga
miguel-heygen
left a comment
There was a problem hiding this comment.
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
left a comment
There was a problem hiding this comment.
Reviewed at f5d8df9. Stack context: studio-UI lane (#2050/#2051/#2052/#2055/#2071) — 5-of-12 in template-variables stack.
Layering on Miga's review — Miga covered the SSOT discipline, parseVarId nested-paren edge case, and the round-trip test invariant. One PR-body-vs-implementation mismatch worth pinning before merge.
🟠 Hold — PR body says "right-click", code implements click
The PR body:
Right-click an eligible Design-panel control → Make variable.
PromotableControl.tsx implements a visible ◇ var button beside the control with onClick={(e) => { e.stopPropagation(); promote.promote(); }} — a normal left-click, not onContextMenu. grep -n "onContextMenu\|contextmenu" packages/studio/src/** on this branch returns nothing. Either the body is wrong (implementation drifted from spec) or a right-click handler is missing.
Which matters because the PR body also says:
⚠️ Live browser UX (menu render, chip placement, inline-default persistence through preview) is pending a visual check — automated canvas-selection driving is unreliable in this repo, so this wants a human pass.
If the intent is right-click, then the click gesture is a spec-implementation gap that a human live-check would immediately catch. If the intent is click, the body needs a one-line correction (and "menu render" in the pending-check list makes less sense — there is no menu, only a button).
Suggested resolution: (a) confirm the intended gesture, (b) either update the body to match the button ("Click the ◇ var chip beside a Design-panel control to Make variable") or add the onContextMenu handler on the wrapper <div> and hide the visible button in favor of the menu. The visible-button design has one advantage: discoverability. Right-click is a hidden interaction most users won't try on a design surface.
🟡 Nits / questions
-
Silent dangling-binding fallback.
PromotableControl'sbound = promote.boundId != null && promote.declaration != null— if the binding attribute points to a removed declaration, the control silently falls back to unbound state. Correct as a failsafe, but the element still hasdata-var-text="oldId"orstyle="color: var(--oldId)"on disk. A dev debugging why "my binding isn't showing" won't get any hint. Consider: single dev-onlyconsole.warn(or a strip in the panel header when any dangling refs exist) so it's discoverable. -
buildBindActions+rgbToHexnewly exported fromVariablesBindElement.tsx. This PR flips them from module-private to public to consume fromcontexts/VariablePromoteContext.tsx. Fine near-term, but the panel-that-owns-the-helper being a UI file with a broader export surface creates a soft coupling: anyone touching the Variables-tab bind card has to remember the Design-panel promote consumer too. Consider extractingbuildBindActions+rgbToHex+applyBindintocontexts/variableBindingCore.ts(orpackages/studio/src/lib/) so both consumers depend on a neutral module. -
useVariablePromoteChannelkeyderivation —channel.kind === "style" ? \style:${channel.prop}` : channel.kindis used as the useMemo dep. If a caller ever rendersPromotableControlwith a dynamically-changing channelprop, the memo will correctly invalidate — good. But the actual promote/setDefault closures inside the memo close overctx(the whole context value), and the eslint-disable on that useMemo dep line means an unrelated context change (e.g. a schema edit updatingdeclarations) will *not* invalidate. That's fine for the bound/promote path (ctxis looked up fresh viauseContextat each render → useMemo re-runs when the parent context value changes → dep list gates whether to rebuild). Just worth eyeballing thatctxin the closures resolves to the CURRENT value on invocation, not the one captured when the memo last ran. Reading closer:ctxis a closed-over variable in the outer scope, so when the promote() runs, it uses whateverctxwas at the last useMemo run — since ctx invalidates the memo via the[ctx, key]` dep, this is fine. Non-issue on re-read. -
PromotableControl'senabledgate — comment says "text-section controls only promote when the edited field is the selected element's OWN text (sourceself); binding a child/text-node field would target a different element than the control edits." The gate is per-control, not per-channel — so a text control withfield.source === "child"getsenabled=falsefor BOTH the text bind AND the color/font style binds. That matches how the code is wired (each<PromotableControl>gates independently), but a child-field'scolororfont-familypromote might still be valid if the target IS the selected element (not the child). Worth verifying with the author whether the current gate is intentional (conservative) or over-broad. -
Author-flagged: "Live browser UX (menu render, chip placement, inline-default persistence through preview) is pending a visual check." Wants a human pass before merge — noting so it doesn't fall off the checklist.
🟢 Good
- Pure-helpers extraction (
variablePromoteHelpers.ts) is textbook — React-free, SDK-free, hand-testable. 11 unit tests + 4 round-trip integration tests actually exercise the write-read symmetry, which is the load-bearing invariant. If a future PR changesapplyBind's output format,readBindingFromwill fail the round-trip test even if unit tests pass. Good bug-pinning coverage. uniqueIdin the right-click promote path — good split from #2055's Variables-tab card. Auto-name always mints a fresh id (no accidental coupling of two elements to the same var); user-typed name in the card allows deliberate reuse (with type-compat check). Two paths, two different intents, both correct.DesignPanelPromoteProviderextraction keepsStudioRightPanel.tsxunder the 600-line cap.useVariablesPersistshared with the Variables tab means undo/preview-reload semantics are identical across both entry points — one behavior contract, two surfaces.parseVarIdnested-paren tolerance — the regex intentionally doesn't try to balance parens; nested-fallbackvar(--x, rgb(0,0,0))returns null, which surfaces as "unbound" rather than a wrong id. Explicit test for this edge case. This is exactly the fail-safe direction.
vanceingalls
left a comment
There was a problem hiding this comment.
R1 — hyperframes #2071 at f5d8df97
🟢 LGTM on the promote wiring, concurring with Miga; concur with Rames-D's 🟠 Hold on the PR-body vs code gesture mismatch — I flagged the same at my own read.
Verified independently:
- Contextual entry point.
PromotableControlatpackages/studio/src/components/PromotableControl.tsx:265-275wraps property controls with an inline "◇ var" button (unbound) or "◆ {id}" accent chip (bound). Positionedabsolute right-1.5 top-0over the control. The affordance isonClick, notonContextMenu— confirmed by grep at head (noonContextMenuhandler inpackages/studio/src/**). - Auto-name + type detection.
VariablePromoteContext.tsx:517-526callsuniqueId(suggestedId, declarations)to auto-mint. Type fromaction.declaration()— matches #2055's derivation. Current property value seeded as default. - Idempotency + dangling handling.
canPromote = promote.action != null && !boundat:238. Dangling declaration → falls back to unbound state at:234-236. - Bound-state display. Accent ring on wrapper (
ring-1 ring-studio-accent/40at:254) + "◆ {id}" chip overlay (:256-262) with tooltip. Underlying controls (ColorField,FontFamilyField) untouched. - Edit-in-place while bound.
PromotableControlpassesonCommit: promote.setDefaultwhen bound →session.setVariableValue(boundId, next)with color-to-hex conversion at:528-531. - Shared primitives with #2055. Imports
applyBind,buildBindActions,rgbToHexfromVariablesBindElement.tsx.variablePromoteHelpers.tsreads bindings written byapplyBind()— integration test proves round-trip. - -82 lines = pure wrapper indentation on
StudioRightPaneland render-prop addition onpropertyPanelSections. Controls fire originalonCommitwhen unbound. uniqueIdsidesteps the #2055 existing-id-silent-render-change hazard — auto-name always fresh. Good split between the two entry points' semantics.- CI green.
Concurring with Miga's LGTM.
Concurring with Rames-D's 🟠 Hold:
- PR body says "right-click", code implements click. Rames-D's read matches my own independent grep. Two failure modes on merge:
- If the intent was right-click: the click gesture is a spec-implementation gap that the human live-check named in the PR body ("pending a visual check") would immediately catch — but the visible button + PR-body "menu render" language pulls the reviewer's eye away from noticing.
- If the intent was click: the PR body needs a one-line correction ("Click the
◇ varchip beside a Design-panel control to Make variable"), and the "menu render" item in the pending-check list stops making sense (there's no menu, only a button).
- Concur on Rames-D's resolution ask: either (a) confirm and update PR body to match the button, or (b) add the
onContextMenuhandler on the wrapper<div>and hide the visible button. Preference: keep the visible button — right-click is a hidden interaction most users won't try on a design surface, so the button design's discoverability wins even though the body drifted. Update the body to match.
Unique observation (small):
O1 — Test count PR-body drift. PR body claims 15 unit + 4 round-trip; diff shows 17 it() blocks. Extra tests are fine; body should be updated to match. Same class as the gesture-description drift.
O2 — Deferred scope. Media src + background/fill deferred (asset-replace + polymorphic fill don't map to the render-prop wrapper). Unbind gesture deferred. Scope is honest.
R1 by Via
f5d8df9 to
842294f
Compare
064663e to
1f4e4c2
Compare
842294f to
8cc6c81
Compare
1f4e4c2 to
1705bdd
Compare
8cc6c81 to
738398a
Compare
59bc240 to
c094ba5
Compare
738398a to
b91fe5d
Compare
james-russo-rames-d-jusso
left a comment
There was a problem hiding this comment.
R2 at b91fe5d.
Prior R1 🟠 (PR body said "right-click", code implemented a visible click button — narrative-vs-code drift) resolved: the ◇ var / ◆ {id} chip pattern is now consistently described as a visible click button in the code's own docstring at PromotableControl.tsx:1-9, and James's team-thread update ("landed inline editing in the Design panel — that's your ◇ var / ◆ chip") aligns the external narrative. Body/code parity restored.
Note: the design conversation about the Variables tab vs. inline-editing is still open (Miguel's crowded-tab-row feedback, James's summoned-panel-doesn't-hold-up finding) — that's product direction, not a merge hold for this PR.
No residuals from my side. CI green.
vanceingalls
left a comment
There was a problem hiding this comment.
🟢 LGTM concurring with Rames-D — R2 at b91fe5d.
R1 🟠 hold (PR body said "right-click", code implemented a visible onClick button — narrative-vs-code drift) resolved via body-side fix with an honest annotation of the original design intent.
Verified at head:
- PR body now describes the visible
◇ var/◆ {id}chip and explicit click gesture; the earlier "right-click" phrasing is either gone or annotated as the original design that changed after review feedback. packages/studio/src/components/editor/PromotableControl.tsx:1-9— docstring describes the click affordance. Code unchanged (matches my R1 preference — keep the visible button for discoverability).
Rames-D's R2 notes the still-open product-direction question (Variables tab vs inline editing; Miguel's crowded-tab-row feedback; James's summoned-panel finding) — that's design conversation, not a merge hold on this PR.
O1 (test-count PR-body drift: claimed 15 unit + 4 round-trip; diff showed 17 it() blocks) resolved implicitly via the body rewrite.
No residuals from my side.
R2 by Via
b91fe5d to
d064750
Compare
c094ba5 to
1d4b6aa
Compare
d064750 to
be46d11
Compare
1d4b6aa to
5e1670d
Compare
vanceingalls
left a comment
There was a problem hiding this comment.
🟢 LGTM at R3 — d064750.
R2 was a copy-side fix; nothing code-level to preserve. R2 verdict stands.
R3 by Via
be46d11 to
f9f8fc7
Compare
5e1670d to
93364c9
Compare
f9f8fc7 to
7c144ec
Compare
93364c9 to
267b289
Compare

What
Adds a contextual promote-to-variable gesture to the Design panel, so an author can turn a property into a template variable without leaving the panel or switching to the Variables tab. This complements the Variables-tab bind-from-selection card (#2055) with a second, in-context entry point plus bound-state display.
UX
data-var-text/data-var-src, or<prop>: var(--id)). (Started as a right-click menu; changed to a visible pill after review feedback that the gesture was undiscoverable.){id}chip + accent ring, and editing it edits the variable's default in place (edit-in-place).buildBindActions/applyBind) — no new binding semantics, no new eligibility rules.How
VariablePromoteContext(provided at the right panel viaDesignPanelPromoteProvider) exposes per-channel promote / bound-state / set-default, backed by the same single-writeruseVariablesPersistpath the Variables tab uses (undo history + preview reload).PromotableControlwraps a control via render-prop to add the visible ◇ var pill, the bound ◆ chip, and edit-routing without touching the mature control components (ColorField,FontFamilyField, etc. are unchanged and still generic).variablePromoteHelpers.ts) are unit-tested; a round-trip integration test proves a bind written byapplyBindis detected byreadBindingFromon a real SDK session.StudioRightPanelstayed under the 600-line studio cap by extracting the provider wiring intoDesignPanelPromoteProvider.Scope / deferred follow-ups
srcand background / fill: their controls use an asset-replace flow / polymorphic (solid·gradient·image) fill model that doesn't map cleanly to the render-prop wrapper. The context already supports these channels — only the call-site wiring is deferred.Verification
bun run buildall pass.Stacked on #2055.