Skip to content

Commit 04310c9

Browse files
committed
feat(studio): variables inspector panel with live preview values
1 parent 7631965 commit 04310c9

12 files changed

Lines changed: 1311 additions & 59 deletions

packages/sdk/src/index.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,14 @@ export { ORIGIN_APPLY_PATCHES, ORIGIN_LOCAL } from "./types.js";
2727
export type {
2828
CompositionVariable,
2929
CompositionVariableType,
30+
CompositionVariableBase,
31+
StringVariable,
32+
NumberVariable,
33+
ColorVariable,
34+
BooleanVariable,
35+
EnumVariable,
36+
FontVariable,
37+
ImageVariable,
3038
VariableValidationIssue,
3139
VariableUsageScan,
3240
} from "@hyperframes/core/variables";
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
import { Tooltip } from "./ui";
2+
3+
/** Tab-bar button for the right inspector panel header. */
4+
export function PanelTabButton({
5+
label,
6+
tooltip,
7+
active,
8+
onClick,
9+
}: {
10+
label: string;
11+
tooltip: string;
12+
active: boolean;
13+
onClick: () => void;
14+
}) {
15+
return (
16+
<Tooltip label={tooltip} side="bottom">
17+
<button
18+
type="button"
19+
onClick={onClick}
20+
aria-pressed={active}
21+
className={`h-8 rounded-xl px-3 text-[11px] font-medium transition-colors active:scale-[0.98] ${
22+
active
23+
? "bg-neutral-800 text-white"
24+
: "text-neutral-500 hover:bg-neutral-800/70 hover:text-neutral-200"
25+
}`}
26+
>
27+
{label}
28+
</button>
29+
</Tooltip>
30+
);
31+
}

packages/studio/src/components/StudioRightPanel.tsx

Lines changed: 39 additions & 57 deletions
Original file line numberDiff line numberDiff line change
@@ -7,14 +7,15 @@ import {
77
type MutableRefObject,
88
type PointerEvent as ReactPointerEvent,
99
} from "react";
10-
import { Tooltip } from "./ui";
1110
import { PropertyPanel } from "./editor/PropertyPanel";
1211
import { LayersPanel } from "./editor/LayersPanel";
1312
import { CaptionPropertyPanel } from "../captions/components/CaptionPropertyPanel";
1413
import { BlockParamsPanel } from "./editor/BlockParamsPanel";
1514
import { RenderQueue } from "./renders/RenderQueue";
1615
import { SlideshowPanel } from "./panels/SlideshowPanel";
1716
import type { SceneInfo } from "./panels/SlideshowPanel";
17+
import { VariablesPanel } from "./panels/VariablesPanel";
18+
import { PanelTabButton } from "./PanelTabButton";
1819
import type { RenderJob } from "./renders/useRenderQueue";
1920
import type { BlockParam } from "@hyperframes/core/registry";
2021
import type { IframeWindow } from "../player/lib/playbackTypes";
@@ -461,64 +462,38 @@ export function StudioRightPanel({
461462
<div className="flex min-w-0 items-center gap-1 overflow-hidden border-b border-neutral-800 px-3 py-2">
462463
{STUDIO_INSPECTOR_PANELS_ENABLED && (
463464
<>
464-
<Tooltip label="Element styles and properties" side="bottom">
465-
<button
466-
type="button"
467-
onClick={() => handleInspectorPaneButtonClick("design")}
468-
aria-pressed={designPaneOpen}
469-
className={`h-8 rounded-xl px-3 text-[11px] font-medium transition-colors active:scale-[0.98] ${
470-
designPaneOpen
471-
? "bg-neutral-800 text-white"
472-
: "text-neutral-500 hover:bg-neutral-800/70 hover:text-neutral-200"
473-
}`}
474-
>
475-
Design
476-
</button>
477-
</Tooltip>
478-
<Tooltip label="Composition layer stack" side="bottom">
479-
<button
480-
type="button"
481-
onClick={() => handleInspectorPaneButtonClick("layers")}
482-
aria-pressed={layersPaneOpen}
483-
className={`h-8 rounded-xl px-3 text-[11px] font-medium transition-colors active:scale-[0.98] ${
484-
layersPaneOpen
485-
? "bg-neutral-800 text-white"
486-
: "text-neutral-500 hover:bg-neutral-800/70 hover:text-neutral-200"
487-
}`}
488-
>
489-
Layers
490-
</button>
491-
</Tooltip>
465+
<PanelTabButton
466+
label="Design"
467+
tooltip="Element styles and properties"
468+
active={designPaneOpen}
469+
onClick={() => handleInspectorPaneButtonClick("design")}
470+
/>
471+
<PanelTabButton
472+
label="Layers"
473+
tooltip="Composition layer stack"
474+
active={layersPaneOpen}
475+
onClick={() => handleInspectorPaneButtonClick("layers")}
476+
/>
492477
</>
493478
)}
494-
<Tooltip label="Render queue and exports" side="bottom">
495-
<button
496-
type="button"
497-
onClick={() => setRightPanelTab("renders")}
498-
aria-pressed={rightPanelTab === "renders"}
499-
className={`h-8 rounded-xl px-3 text-[11px] font-medium transition-colors active:scale-[0.98] ${
500-
rightPanelTab === "renders"
501-
? "bg-neutral-800 text-white"
502-
: "text-neutral-500 hover:bg-neutral-800/70 hover:text-neutral-200"
503-
}`}
504-
>
505-
{renderJobs.length > 0 ? `Renders (${renderJobs.length})` : "Renders"}
506-
</button>
507-
</Tooltip>
508-
<Tooltip label="Slideshow branching editor" side="bottom">
509-
<button
510-
type="button"
511-
onClick={() => setRightPanelTab("slideshow")}
512-
aria-pressed={rightPanelTab === "slideshow"}
513-
className={`h-8 rounded-xl px-3 text-[11px] font-medium transition-colors active:scale-[0.98] ${
514-
rightPanelTab === "slideshow"
515-
? "bg-neutral-800 text-white"
516-
: "text-neutral-500 hover:bg-neutral-800/70 hover:text-neutral-200"
517-
}`}
518-
>
519-
Slideshow
520-
</button>
521-
</Tooltip>
479+
<PanelTabButton
480+
label={renderJobs.length > 0 ? `Renders (${renderJobs.length})` : "Renders"}
481+
tooltip="Render queue and exports"
482+
active={rightPanelTab === "renders"}
483+
onClick={() => setRightPanelTab("renders")}
484+
/>
485+
<PanelTabButton
486+
label="Slideshow"
487+
tooltip="Slideshow branching editor"
488+
active={rightPanelTab === "slideshow"}
489+
onClick={() => setRightPanelTab("slideshow")}
490+
/>
491+
<PanelTabButton
492+
label="Variables"
493+
tooltip="Template variables — declare, preview with values"
494+
active={rightPanelTab === "variables"}
495+
onClick={() => setRightPanelTab("variables")}
496+
/>
522497
</div>
523498
<div className="min-h-0 min-w-0 flex-1 overflow-hidden">
524499
{rightPanelTab === "block-params" && activeBlockParams ? (
@@ -535,6 +510,13 @@ export function StudioRightPanel({
535510
onPersist={onPersistSlideshow}
536511
onPersistNotes={onPersistSlideshowNotes}
537512
/>
513+
) : rightPanelTab === "variables" ? (
514+
<VariablesPanel
515+
sdkSession={sdkSession}
516+
reloadPreview={reloadPreview}
517+
domEditSaveTimestampRef={domEditSaveTimestampRef}
518+
recordEdit={recordEdit}
519+
/>
538520
) : layersPaneOpen && designPaneOpen ? (
539521
<div ref={splitContainerRef} className="flex h-full min-h-0 min-w-0 flex-col">
540522
<div
Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
/**
2+
* Pure form-logic guards for the Variables declaration form:
3+
* mergeDeclarationEdit (unmodeled-key passthrough on same-type edits) and
4+
* declarationFromDraft (per-type parsing + validation).
5+
*/
6+
7+
import { describe, it, expect } from "vitest";
8+
import {
9+
mergeDeclarationEdit,
10+
declarationFromDraft,
11+
draftFromDeclaration,
12+
EMPTY_DRAFT,
13+
} from "./VariablesDeclarationForm.js";
14+
import type { CompositionVariable } from "@hyperframes/core/variables";
15+
16+
describe("mergeDeclarationEdit", () => {
17+
it("preserves unmodeled keys on a same-type edit", () => {
18+
// The form owns id/label/type/default/description/min/max/step/options; every
19+
// other key (source, brandRole, unit, maxLength, …) must ride through a Save.
20+
const original = {
21+
id: "brand",
22+
type: "font",
23+
label: "Brand font",
24+
default: "Inter",
25+
source: "brand-kit",
26+
default_name: "Inter",
27+
brandRole: "heading",
28+
} as unknown as CompositionVariable;
29+
const edited = {
30+
id: "brand",
31+
type: "font",
32+
label: "Brand heading font",
33+
default: "Inter",
34+
} as CompositionVariable;
35+
36+
const merged = mergeDeclarationEdit(original, edited) as Record<string, unknown>;
37+
expect(merged.label).toBe("Brand heading font");
38+
expect(merged.source).toBe("brand-kit");
39+
expect(merged.default_name).toBe("Inter");
40+
expect(merged.brandRole).toBe("heading");
41+
});
42+
43+
it("drops old type-specific metadata when the type changes", () => {
44+
const original = {
45+
id: "count",
46+
type: "number",
47+
label: "Count",
48+
default: 3,
49+
min: 0,
50+
max: 10,
51+
} as CompositionVariable;
52+
const edited = {
53+
id: "count",
54+
type: "string",
55+
label: "Count",
56+
default: "3",
57+
} as CompositionVariable;
58+
59+
const merged = mergeDeclarationEdit(original, edited) as Record<string, unknown>;
60+
expect(merged).toEqual(edited);
61+
expect(merged.min).toBeUndefined();
62+
expect(merged.max).toBeUndefined();
63+
});
64+
});
65+
66+
describe("declarationFromDraft", () => {
67+
it("requires a non-empty id", () => {
68+
expect(declarationFromDraft({ ...EMPTY_DRAFT, id: " " })).toBe("Variable id is required.");
69+
});
70+
71+
it("parses a string variable verbatim and defaults label to id", () => {
72+
const decl = declarationFromDraft({ ...EMPTY_DRAFT, id: "title", defaultRaw: "Hello" });
73+
expect(decl).toEqual({ id: "title", label: "title", type: "string", default: "Hello" });
74+
});
75+
76+
it("rejects a non-numeric number default", () => {
77+
expect(
78+
declarationFromDraft({ ...EMPTY_DRAFT, id: "n", type: "number", defaultRaw: "abc" }),
79+
).toBe("Default must be a number.");
80+
});
81+
82+
it("parses a number variable with min/max/step and drops blank constraints", () => {
83+
const decl = declarationFromDraft({
84+
...EMPTY_DRAFT,
85+
id: "n",
86+
type: "number",
87+
defaultRaw: "5",
88+
min: "0",
89+
max: "",
90+
step: "0.5",
91+
});
92+
expect(decl).toEqual({ id: "n", label: "n", type: "number", default: 5, min: 0, step: 0.5 });
93+
});
94+
95+
it("rejects an enum with no options and an off-list default", () => {
96+
expect(declarationFromDraft({ ...EMPTY_DRAFT, id: "e", type: "enum", optionsRaw: "" })).toBe(
97+
"Enum needs at least one option (one per line, value:Label).",
98+
);
99+
expect(
100+
declarationFromDraft({
101+
...EMPTY_DRAFT,
102+
id: "e",
103+
type: "enum",
104+
optionsRaw: "wide:Wide\ntall:Tall",
105+
defaultRaw: "square",
106+
}),
107+
).toBe("Default must be one of the options.");
108+
});
109+
110+
it("parses a boolean from the 'true' sentinel", () => {
111+
expect(
112+
declarationFromDraft({ ...EMPTY_DRAFT, id: "b", type: "boolean", defaultRaw: "true" }),
113+
).toMatchObject({
114+
type: "boolean",
115+
default: true,
116+
});
117+
expect(
118+
declarationFromDraft({ ...EMPTY_DRAFT, id: "b", type: "boolean", defaultRaw: "" }),
119+
).toMatchObject({
120+
default: false,
121+
});
122+
});
123+
124+
it("round-trips a declaration through draftFromDeclaration → declarationFromDraft", () => {
125+
const original: CompositionVariable = {
126+
id: "count",
127+
type: "number",
128+
label: "Count",
129+
default: 3,
130+
min: 0,
131+
max: 10,
132+
};
133+
expect(declarationFromDraft(draftFromDeclaration(original))).toEqual(original);
134+
});
135+
});

0 commit comments

Comments
 (0)