+
+ Bind selected: {selection.label}
+
+ {active ? (
+
+
+
setIdDraft(e.target.value)}
+ onKeyDown={(e) => e.key === "Escape" && setActiveKey(null)}
+ className={`${VARIABLES_INPUT_CLASS} font-mono`}
+ />
+
+
+
+
+
+ ) : (
+
+ {actions.map((action) => (
+
+ ))}
+
+ )}
+
+ );
+}
diff --git a/packages/studio/src/components/panels/VariablesPanel.tsx b/packages/studio/src/components/panels/VariablesPanel.tsx
index 5d781c5957..9b78ad30d2 100644
--- a/packages/studio/src/components/panels/VariablesPanel.tsx
+++ b/packages/studio/src/components/panels/VariablesPanel.tsx
@@ -7,7 +7,9 @@ import type {
} from "@hyperframes/sdk";
import type { EditHistoryKind } from "../../utils/editHistory";
import { useStudioPlaybackContext, useStudioShellContext } from "../../contexts/StudioContext";
+import { useDomEditContext } from "../../contexts/DomEditContext";
import { useFileManagerContext } from "../../contexts/FileManagerContext";
+import { VariablesBindElement, type BindAction, applyBind } from "./VariablesBindElement";
import { useVariablesPersist } from "../../hooks/useVariablesPersist";
import { usePreviewVariablesStore } from "../../hooks/previewVariablesStore";
import {
@@ -274,6 +276,7 @@ export const VariablesPanel = memo(function VariablesPanel({
const { activeCompPath, showToast } = useStudioShellContext();
const { refreshKey } = useStudioPlaybackContext();
const { readProjectFile, writeProjectFile } = useFileManagerContext();
+ const { domEditSelection } = useDomEditContext();
const previewValues = usePreviewVariablesStore((s) => s.values);
const setPreviewValues = usePreviewVariablesStore((s) => s.setValues);
@@ -427,6 +430,38 @@ export const VariablesPanel = memo(function VariablesPanel({
reloadPreview();
}, [setPreviewValues, reloadPreview]);
+ const handleBind = useCallback(
+ // Guard chain (session, selection, type-compat) — one branch per guard.
+ // fallow-ignore-next-line complexity
+ (action: BindAction, id: string) => {
+ if (!sdkSession || !domEditSelection?.hfId) return;
+ // Binding to an existing variable is allowed, but only when the types
+ // agree — wiring a color style to a string variable silently breaks
+ // the element's styling.
+ const existing = sdkSession.getVariableDeclarations().find((d) => d.id === id);
+ const wanted = action.declaration(id).type;
+ if (existing && existing.type !== wanted) {
+ showToast(
+ `"${id}" is already a ${existing.type} variable — pick another id for this ${wanted} binding`,
+ "error",
+ );
+ return;
+ }
+ const hfId = domEditSelection.hfId;
+ void runSchemaEdit(`Bind ${action.label.toLowerCase()} to "${id}"`, (s) =>
+ applyBind(s, hfId, action, id),
+ );
+ },
+ [sdkSession, domEditSelection, runSchemaEdit, showToast],
+ );
+
+ // The bind gesture targets the composition the session models — a selection
+ // from another source file must not write bindings into this one.
+ const bindableSelection =
+ domEditSelection?.hfId && domEditSelection.sourceFile === (activeCompPath ?? "index.html")
+ ? domEditSelection
+ : null;
+
if (!sdkSession) {
return (
@@ -442,6 +477,14 @@ export const VariablesPanel = memo(function VariablesPanel({
onReset={resetPreview}
/>
+ {bindableSelection && (
+
+ )}
{declarations.length === 0 && !addOpen && EMPTY_STATE}
{/* fallow-ignore-next-line complexity */}
diff --git a/packages/studio/src/hooks/useDomSelection.ts b/packages/studio/src/hooks/useDomSelection.ts
index 72892dd744..8a6efa5ce0 100644
--- a/packages/studio/src/hooks/useDomSelection.ts
+++ b/packages/studio/src/hooks/useDomSelection.ts
@@ -126,6 +126,8 @@ export function useDomSelection({
// ── Refs ──
+ const rightPanelTabRef = useRef(rightPanelTab);
+ rightPanelTabRef.current = rightPanelTab;
const domEditSelectionRef = useRef(domEditSelection);
const domEditGroupSelectionsRef = useRef(domEditGroupSelections);
const domEditHoverSelectionRef = useRef(domEditHoverSelection);
@@ -205,7 +207,11 @@ export function useDomSelection({
if (nextSelection) {
if (options?.revealPanel !== false) {
setRightCollapsed(false);
- setRightPanelTab("design");
+ // Keep the Variables tab in place — selecting elements is part of
+ // the bind flow there; yanking to Design would lose the context.
+ if (rightPanelTabRef.current !== "variables") {
+ setRightPanelTab("design");
+ }
}
const nextSelectedTimelineId =
findMatchingTimelineElementId(nextSelection, timelineElements) ??
diff --git a/packages/studio/src/hooks/useStudioContextValue.ts b/packages/studio/src/hooks/useStudioContextValue.ts
index a07fec30af..a8c5d0e0b8 100644
--- a/packages/studio/src/hooks/useStudioContextValue.ts
+++ b/packages/studio/src/hooks/useStudioContextValue.ts
@@ -97,7 +97,12 @@ export function useInspectorState(
STUDIO_INSPECTOR_PANELS_ENABLED && !rightCollapsed && inspectorPanelActive,
// Keep the selection box + motion path drawn even when the Inspector is
// collapsed — closing the panel shouldn't visually deselect the element.
- shouldShowSelectedDomBounds: inspectorPanelActive && !isPlaying && !isGestureRecording,
+ // The Variables tab also works against the canvas selection (bind card),
+ // so the selection outline stays visible there too.
+ shouldShowSelectedDomBounds:
+ (inspectorPanelActive || rightPanelTab === "variables") &&
+ !isPlaying &&
+ !isGestureRecording,
};
}, [rightPanelTab, rightInspectorPanes, rightCollapsed, isPlaying, isGestureRecording]);
}