feat(studio): promote to variable from the design panel

This commit is contained in:
James
2026-07-09 13:31:03 -07:00
parent 267b289bb8
commit 7c144ecc30
9 changed files with 647 additions and 80 deletions
@@ -0,0 +1,105 @@
/**
* Wraps a Design-panel property control with the promote-to-variable gesture.
* When a control can be promoted it shows a visible "◇ var" button; clicking it
* declares a variable (default = current value, so the render is unchanged) and
* binds this property to it. Once bound, the button is replaced by a "◆ {id}"
* chip and edits route to the variable's default (edit-in-place). Controls that
* aren't eligible (or render outside a promote context, or are disabled by the
* caller) pass through untouched. Uses a render-prop so each control keeps its
* own value/onCommit shape.
*/
import { useEffect } from "react";
import {
useVariablePromoteChannel,
type PromoteChannel,
} from "../../contexts/VariablePromoteContext";
interface RenderArgs {
/** When bound, the variable's default to display; otherwise undefined. */
value?: string;
/** When bound, routes commits to the variable default; otherwise undefined. */
onCommit?: (value: string) => void;
bound: boolean;
}
export function PromotableControl({
channel,
enabled = true,
children,
}: {
channel: PromoteChannel;
/**
* Caller-side gate. Text-section controls only promote when the edited field
* is the selected element's OWN text (source "self"); binding a child/text-
* node field would target a different element than the control edits.
*/
enabled?: boolean;
children: (args: RenderArgs) => React.ReactNode;
}) {
const promote = useVariablePromoteChannel(channel);
// A binding attribute (`data-var-*` / `var(--id)`) pointing at a declaration
// that no longer exists renders as a plain unbound control — a silent
// fallback that leaves a dev wondering why "their binding isn't showing".
// Surface it in the console so the dangling reference is discoverable.
const danglingId =
enabled && promote && promote.boundId != null && promote.declaration == null
? promote.boundId
: null;
useEffect(() => {
if (danglingId != null) {
console.warn(
`[hyperframes] Control is bound to variable "${danglingId}", but no such declaration exists. The element still carries the binding on disk — re-declare the variable or unbind the element.`,
);
}
}, [danglingId]);
if (!promote || !enabled) return <>{children({ bound: false })}</>;
// A binding whose declaration was removed elsewhere is dangling: don't show
// it as an editable bound control (setDefault would silently no-op) — let it
// fall back to a plain, re-promotable control.
const bound = promote.boundId != null && promote.declaration != null;
const canPromote = promote.action != null && !bound;
const defaultValue = promote.declaration?.default;
const rendered = children(
bound
? {
// Only string defaults render inline; a FontValue/ImageValue object
// falls back to the element's real value instead of "[object Object]".
value: typeof defaultValue === "string" ? defaultValue : undefined,
onCommit: promote.setDefault,
bound: true,
}
: { bound: false },
);
return (
<div className={`relative ${bound ? "rounded-lg ring-1 ring-studio-accent/40" : ""}`}>
{rendered}
{bound && (
<span
className="pointer-events-none absolute right-1.5 top-0 z-10 inline-flex max-w-[60%] items-center gap-1 truncate rounded bg-studio-accent/20 px-1 py-px font-mono text-[8px] font-medium text-studio-accent"
title={`Bound to variable "${promote.boundId}"`}
>
{promote.boundId}
</span>
)}
{canPromote && (
<button
type="button"
title="Make this a variable"
onClick={(e) => {
e.stopPropagation();
promote.promote();
}}
className="absolute right-1.5 top-0 z-10 inline-flex items-center gap-1 rounded bg-neutral-800/80 px-1 py-px font-mono text-[8px] font-medium text-neutral-400 opacity-70 transition-colors hover:bg-studio-accent/20 hover:text-studio-accent hover:opacity-100"
>
var
</button>
)}
</div>
);
}
@@ -6,6 +6,7 @@ import { FIELD, LABEL, normalizeTextMetricValue, RESPONSIVE_GRID } from "./prope
import { MetricField, Section, SelectField } from "./propertyPanelPrimitives";
import { ColorField } from "./propertyPanelColor";
import { FontFamilyField } from "./propertyPanelFont";
import { PromotableControl } from "./PromotableControl";
/* ------------------------------------------------------------------ */
/* Text helpers (used only by text section components) */
@@ -280,20 +281,31 @@ function TextFieldEditor({
</button>
)}
</div>
<TextAreaField
key={field.key}
label="Content"
value={field.value}
disabled={false}
autoFocus={showRemove}
onCommit={(next) => onSetText(next, field.key)}
/>
<ColorField
label="Text color"
value={getTextFieldColor(field, styles)}
disabled={false}
onCommit={(next) => onSetTextFieldStyle(field.key, "color", next)}
/>
<PromotableControl channel={{ kind: "text" }} enabled={field.source === "self"}>
{({ value, onCommit }) => (
<TextAreaField
key={field.key}
label="Content"
value={value ?? field.value}
disabled={false}
autoFocus={showRemove}
onCommit={onCommit ?? ((next) => onSetText(next, field.key))}
/>
)}
</PromotableControl>
<PromotableControl
channel={{ kind: "style", prop: "color" }}
enabled={field.source === "self"}
>
{({ value, onCommit }) => (
<ColorField
label="Text color"
value={value ?? getTextFieldColor(field, styles)}
disabled={false}
onCommit={onCommit ?? ((next) => onSetTextFieldStyle(field.key, "color", next))}
/>
)}
</PromotableControl>
<div className={RESPONSIVE_GRID}>
<MetricField
label="Size"
@@ -309,13 +321,22 @@ function TextFieldEditor({
onCommit={(next) => onSetTextFieldStyle(field.key, "font-weight", next)}
/>
</div>
<FontFamilyField
value={field.computedStyles["font-family"] || styles["font-family"] || "inherit"}
disabled={false}
importedFonts={fontAssets}
onImportFonts={onImportFonts}
onCommit={(next) => onSetTextFieldStyle(field.key, "font-family", next)}
/>
<PromotableControl
channel={{ kind: "style", prop: "font-family" }}
enabled={field.source === "self"}
>
{({ value, onCommit }) => (
<FontFamilyField
value={
value ?? (field.computedStyles["font-family"] || styles["font-family"] || "inherit")
}
disabled={false}
importedFonts={fontAssets}
onImportFonts={onImportFonts}
onCommit={onCommit ?? ((next) => onSetTextFieldStyle(field.key, "font-family", next))}
/>
)}
</PromotableControl>
<AdvancedTextControls
field={field}
inheritedStyles={styles}