@joycostudio/studio 0.1.1 → 0.1.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/react.d.ts CHANGED
@@ -20,18 +20,24 @@ type Result<T> = Success<T> | Failure;
20
20
  * Lucide's core package (`import { Palette } from 'lucide'`) exports exactly this shape.
21
21
  */
22
22
  type ToolIcon = readonly (readonly [tag: string, attrs: Record<string, string | number | undefined>])[];
23
+ type ToolBase = {
24
+ /** Unique, URL-safe identifier. */
25
+ id: string;
26
+ title?: string;
27
+ /** Shown next to the tool in /studio. Data, not a component, so contracts stay loadable in Node. */
28
+ icon?: ToolIcon;
29
+ /** App route that renders this tool's result, shown next to the controls in /studio. */
30
+ preview?: string;
31
+ /** Use a bare iframe for tools that already own their canvas layout and headers. */
32
+ previewLayout?: 'canvas' | 'custom';
33
+ };
23
34
  /**
24
35
  * A tool's contract. Isomorphic on purpose: the runtime loads it in Node to
25
36
  * validate and persist, the app loads it in the browser to apply and debug.
26
37
  *
27
38
  * `TConfig` is the one type that is read, edited, applied, and persisted.
28
39
  */
29
- type ToolDefinition<TConfig extends object = object> = {
30
- /** Unique, URL-safe identifier. */
31
- id: string;
32
- title?: string;
33
- /** Shown next to the tool in /studio. Data, not a component, so contracts stay loadable in Node. */
34
- icon?: ToolIcon;
40
+ type ToolDefinition<TConfig extends object = object> = ToolBase & {
35
41
  /** Repo-relative JSON file the config persists to. Import that same file as `initial`. */
36
42
  file: string;
37
43
  /** Config used before the runtime answers (and in production). */
@@ -41,11 +47,18 @@ type ToolDefinition<TConfig extends object = object> = {
41
47
  * Doubles as the runtime validator: every control's bounds are enforced on write.
42
48
  */
43
49
  controls: (config: TConfig) => DebugSchema;
44
- /** App route that renders this tool's result, shown next to the controls in /studio. */
45
- preview?: string;
46
- /** Use a bare iframe for tools that already own their canvas layout and headers. */
47
- previewLayout?: 'canvas' | 'custom';
48
50
  };
51
+ /** A tool with nothing to tune: a sidebar entry for its preview, without an inspector or config file. */
52
+ type PreviewToolDefinition = ToolBase & {
53
+ preview: string;
54
+ file?: never;
55
+ initial?: never;
56
+ controls?: never;
57
+ };
58
+ /** A tool with a config. */
59
+ type AnyTool = ToolDefinition<any>;
60
+ /** Anything `defineTool` returns: the kind follows from whether it declares a config. */
61
+ type StudioTool = AnyTool | PreviewToolDefinition;
49
62
 
50
63
  /** A tool's live state as owned by the runtime. */
51
64
  type ToolState<TConfig = unknown> = {
@@ -89,19 +102,37 @@ type ToolHandle<TConfig extends object = any> = {
89
102
  controls(): DebugSchema;
90
103
  /** Xyz `Debuggable`: `debugManager.register(handle)` mounts this tool in the app's floating pane. */
91
104
  debug(): DebugFolder;
105
+ /** The config on disk: the runtime's saved value or, until one answers, the bundled `initial`. */
106
+ readonly saved: TConfig;
107
+ /** Dotted paths where the draft differs from `saved`. Tracked locally, so it works without a runtime too. */
108
+ readonly changes: string[];
109
+ /** The draft differs from `saved`. */
110
+ readonly dirty: boolean;
92
111
  /** True while `compare(true)` shows the saved config in place of the draft. */
93
112
  readonly comparing: boolean;
94
113
  /**
95
- * Temporarily show the saved config (true) or go back to the draft (false). Local only: nothing
114
+ * Temporarily show `saved` (true) or go back to the draft (false). Local only: nothing
96
115
  * reaches the runtime, and edits made while comparing are dropped when it ends.
97
116
  */
98
117
  compare(showSaved: boolean): void;
118
+ /** True when `undo()` has an edit made on this page to step back through. */
119
+ readonly canUndo: boolean;
120
+ readonly canRedo: boolean;
121
+ /**
122
+ * Step back through edits made on this page (a slider drag counts as one), then sync the result like any
123
+ * other edit. Only the leaves each edit changed are restored. Returns false with nothing to undo or while comparing.
124
+ */
125
+ undo(): boolean;
126
+ redo(): boolean;
99
127
  save(options?: {
100
128
  force?: boolean;
101
129
  }): Promise<Result<ToolState<TConfig>>>;
102
130
  discard(): Promise<Result<ToolState<TConfig>>>;
103
131
  };
104
132
  type Studio$1 = {
133
+ /** Every registered tool in order, including preview-only ones. */
134
+ readonly definitions: readonly StudioTool[];
135
+ /** A handle per tool with a config. Preview-only tools have none. */
105
136
  readonly tools: readonly ToolHandle[];
106
137
  tool<TConfig extends object>(definition: ToolDefinition<TConfig>): ToolHandle<TConfig>;
107
138
  readonly connection: Connection;
package/dist/react.js CHANGED
@@ -11,18 +11,38 @@ import {
11
11
  PlugZap,
12
12
  Unplug
13
13
  } from "lucide-react";
14
- import { useEffect as useEffect4, useRef as useRef3, useState as useState7 } from "react";
14
+ import { useEffect as useEffect5, useRef as useRef3, useState as useState8 } from "react";
15
15
 
16
16
  // package.json
17
- var version = "0.1.1";
17
+ var version = "0.1.3";
18
18
 
19
19
  // src/client/studio.ts
20
- import { button } from "@joycostudio/xyz/debug";
20
+ import {
21
+ button,
22
+ schemaValues
23
+ } from "@joycostudio/xyz/debug";
21
24
  var COMPARE_MESSAGE = "joyco-studio:compare";
22
25
  function postCompare(target, tool, showSaved) {
23
26
  const message = { type: COMPARE_MESSAGE, tool, showSaved };
24
27
  target?.postMessage(message, location.origin);
25
28
  }
29
+ function resetSlidersOnDoubleClick(handle) {
30
+ return (folder, { fields }) => {
31
+ const sliders = fields().filter(({ field }) => field.kind === "slider");
32
+ const onDoubleClick = (event) => {
33
+ if (handle.comparing || event.target.closest("input")) return;
34
+ const slider = sliders.find(({ blade }) => blade.element.contains(event.target));
35
+ if (slider?.field.kind !== "slider") return;
36
+ const saved = slider.path.reduce(
37
+ (values, key) => values?.[key],
38
+ schemaValues(handle.definition.controls(structuredClone(handle.saved)))
39
+ );
40
+ if (typeof saved === "number") slider.field.write(saved);
41
+ };
42
+ folder.element.addEventListener("dblclick", onDoubleClick);
43
+ return () => folder.element.removeEventListener("dblclick", onDoubleClick);
44
+ };
45
+ }
26
46
  var instances = globalThis.__joycoStudio ??= /* @__PURE__ */ new Map();
27
47
 
28
48
  // src/react/hooks.ts
@@ -49,16 +69,6 @@ import { bindDebugSchema } from "@joycostudio/xyz/debug";
49
69
  import { SquareSplitHorizontal } from "lucide-react";
50
70
  import { useEffect, useRef, useState } from "react";
51
71
 
52
- // src/core/diff.ts
53
- function changedPaths(before, after, path = "") {
54
- const nested = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
55
- if (nested(before) && nested(after)) {
56
- const keys = /* @__PURE__ */ new Set([...Object.keys(before), ...Object.keys(after)]);
57
- return [...keys].flatMap((key) => changedPaths(before[key], after[key], path ? `${path}.${key}` : key));
58
- }
59
- return JSON.stringify(before) === JSON.stringify(after) ? [] : [path];
60
- }
61
-
62
72
  // src/react/theme.ts
63
73
  var tweakpaneTheme = {
64
74
  "--tp-base-background-color": "transparent",
@@ -111,9 +121,9 @@ function ToolIcon({ icon = SlidersHorizontal, ...props }) {
111
121
  "aria-hidden": true,
112
122
  ...props,
113
123
  children: icon.map(([Tag, attrs], index) => {
114
- const Element2 = Tag;
124
+ const Element = Tag;
115
125
  return /* @__PURE__ */ jsx(
116
- Element2,
126
+ Element,
117
127
  {
118
128
  ...Object.fromEntries(Object.entries(attrs).map(([name, value]) => [camel(name), value]))
119
129
  },
@@ -310,9 +320,9 @@ function PanelFooter({ className, ...props }) {
310
320
  import { Fragment, jsx as jsx7, jsxs as jsxs2 } from "react/jsx-runtime";
311
321
  function describe(studio, handle) {
312
322
  const { state } = handle;
323
+ if (handle.comparing) return { tone: "text-sky-400", text: "Showing saved" };
313
324
  if (studio.connection.phase !== "connected") return { tone: "text-faint", text: "Not persisting" };
314
325
  if (!state) return { tone: "text-destructive", text: "Restart studio dev" };
315
- if (handle.comparing) return { tone: "text-sky-400", text: "Showing saved" };
316
326
  if (state.conflict) return { tone: "text-destructive", text: "Changed on disk" };
317
327
  if (state.dirty) return { tone: "text-warning", text: "Unsaved" };
318
328
  return { tone: "text-success", text: state.saved.hash ? "Saved" : "Not on disk yet" };
@@ -337,7 +347,10 @@ function Inspector({
337
347
  if (disposed || !container.current) return;
338
348
  const instance = new Pane({ container: container.current });
339
349
  pane.current = instance;
340
- const bound = bindDebugSchema(instance, { schema: () => handle.controls() });
350
+ const bound = bindDebugSchema(instance, {
351
+ schema: () => handle.controls(),
352
+ extend: resetSlidersOnDoubleClick(handle)
353
+ });
341
354
  const unsubscribe = handle.subscribe(() => bound.refresh());
342
355
  teardown = () => {
343
356
  unsubscribe();
@@ -367,7 +380,7 @@ function Inspector({
367
380
  const ConfigFile = fileHref ? "a" : "span";
368
381
  const status = describe(studio, handle);
369
382
  const editable2 = studio.connection.phase === "connected" && !!state?.dirty && !handle.comparing;
370
- const changes = (state?.dirty ? changedPaths(state.saved.value, state.draft.value) : []).map(
383
+ const changes = handle.changes.map(
371
384
  (path) => path.split(".").map((key) => key.charAt(0).toUpperCase() + key.slice(1)).join(" \u203A ")
372
385
  );
373
386
  return /* @__PURE__ */ jsxs2(Fragment, { children: [
@@ -424,7 +437,7 @@ function Inspector({
424
437
  size: "icon-sm",
425
438
  "aria-label": "Hold to compare with saved",
426
439
  "aria-pressed": handle.comparing,
427
- disabled: !state?.dirty,
440
+ disabled: !handle.dirty && !handle.comparing,
428
441
  className: "shrink-0 aria-pressed:bg-primary aria-pressed:text-primary-foreground",
429
442
  onPointerDown: (event) => {
430
443
  event.currentTarget.setPointerCapture(event.pointerId);
@@ -494,9 +507,7 @@ function usePinnedTools(studio) {
494
507
  };
495
508
  }, []);
496
509
  const pinned = new Set(pins);
497
- const tools = [...studio.tools].sort(
498
- (a, b) => Number(pinned.has(b.definition.id)) - Number(pinned.has(a.definition.id))
499
- );
510
+ const tools = [...studio.definitions].sort((a, b) => Number(pinned.has(b.id)) - Number(pinned.has(a.id)));
500
511
  const togglePin = (id) => {
501
512
  const next = pinned.has(id) ? pins.filter((pin) => pin !== id) : [...pins, id];
502
513
  try {
@@ -599,6 +610,25 @@ var PreviewCanvas = forwardRef3(function PreviewCanvas2({ title = "Preview", exp
599
610
  );
600
611
  });
601
612
 
613
+ // src/react/session-flag.ts
614
+ import { useEffect as useEffect3, useState as useState5 } from "react";
615
+ function useSessionFlag(key) {
616
+ const [value, setValue] = useState5(() => {
617
+ try {
618
+ return sessionStorage.getItem(key) === "true";
619
+ } catch {
620
+ return false;
621
+ }
622
+ });
623
+ useEffect3(() => {
624
+ try {
625
+ sessionStorage.setItem(key, String(value));
626
+ } catch {
627
+ }
628
+ }, [key, value]);
629
+ return [value, setValue];
630
+ }
631
+
602
632
  // src/react/ui/sidebar.tsx
603
633
  import * as React2 from "react";
604
634
  import { cva as cva2 } from "class-variance-authority";
@@ -1037,8 +1067,13 @@ function StudioFooter({ connection }) {
1037
1067
  }
1038
1068
  var SHORTCUTS = /* @__PURE__ */ new Set(["b", "s", "k"]);
1039
1069
  var COMPARE_KEY = "\\";
1040
- var editable = (target) => target instanceof Element && target.matches("input, textarea, select, [contenteditable]");
1041
- var isShortcut = (event) => (event.metaKey || event.ctrlKey) && SHORTCUTS.has(event.key.toLowerCase()) || event.key === COMPARE_KEY && !editable(event.target);
1070
+ var editable = (target) => !!target?.matches?.("input, textarea, select, [contenteditable]");
1071
+ var historyAction = (event) => {
1072
+ if (!(event.metaKey || event.ctrlKey) || event.altKey || editable(event.target)) return null;
1073
+ const key = event.key.toLowerCase();
1074
+ return key === "z" ? event.shiftKey ? "redo" : "undo" : key === "y" ? "redo" : null;
1075
+ };
1076
+ var isShortcut = (event) => (event.metaKey || event.ctrlKey) && SHORTCUTS.has(event.key.toLowerCase()) || event.key === COMPARE_KEY && !editable(event.target) || !!historyAction(event);
1042
1077
  function forwardShortcuts(frame) {
1043
1078
  const forward = (event) => {
1044
1079
  if (event.defaultPrevented || !isShortcut(event)) return;
@@ -1054,13 +1089,20 @@ function forwardShortcuts(frame) {
1054
1089
  function Studio({ studio, previewComponent, getConfigFileHref }) {
1055
1090
  useStudio(studio);
1056
1091
  const { tools, pinned, togglePin } = usePinnedTools(studio);
1057
- const [mounted, setMounted] = useState7(false);
1058
- const [panelsHidden, setPanelsHidden] = useState7(false);
1059
- const [selected, setSelected] = useState7(studio.tools[0]?.definition.id);
1092
+ const [mounted, setMounted] = useState8(false);
1093
+ const [sidebarHidden, setSidebarHidden] = useSessionFlag("joyco-studio:sidebar-hidden");
1094
+ const [inspectorHidden, setInspectorHidden] = useSessionFlag("joyco-studio:inspector-hidden");
1095
+ const [canvasExpanded, setCanvasExpanded] = useSessionFlag("joyco-studio:canvas-expanded");
1096
+ const [selected, setSelected] = useState8(studio.definitions[0]?.id);
1060
1097
  const preview = useRef3(null);
1098
+ const definition = studio.definitions.find((tool) => tool.id === selected);
1061
1099
  const handle = studio.tools.find((tool) => tool.definition.id === selected);
1062
- const Preview = previewComponent ?? (handle?.definition.previewLayout === "custom" ? "iframe" : PreviewCanvas);
1063
- useEffect4(() => {
1100
+ const Preview = previewComponent ?? (definition?.previewLayout === "custom" ? "iframe" : PreviewCanvas);
1101
+ const hidden = { left: sidebarHidden, right: inspectorHidden || !handle };
1102
+ const show = { left: () => setSidebarHidden(false), right: () => setInspectorHidden(false) };
1103
+ const layout = useRef3(hidden);
1104
+ layout.current = hidden;
1105
+ useEffect5(() => {
1064
1106
  const onKey = (event) => {
1065
1107
  if (event.defaultPrevented || event.isComposing || event.altKey || !(event.metaKey || event.ctrlKey)) return;
1066
1108
  if (event.key.toLowerCase() !== "b") return;
@@ -1071,7 +1113,9 @@ function Studio({ studio, previewComponent, getConfigFileHref }) {
1071
1113
  active.blur();
1072
1114
  preview.current?.focus();
1073
1115
  }
1074
- setPanelsHidden((hidden) => !hidden);
1116
+ const hide = !layout.current.left || !layout.current.right;
1117
+ setSidebarHidden(hide);
1118
+ setInspectorHidden(hide);
1075
1119
  };
1076
1120
  window.addEventListener("keydown", onKey);
1077
1121
  return () => window.removeEventListener("keydown", onKey);
@@ -1081,15 +1125,15 @@ function Studio({ studio, previewComponent, getConfigFileHref }) {
1081
1125
  handle.compare(showSaved);
1082
1126
  postCompare(preview.current?.contentWindow, handle.definition.id, showSaved);
1083
1127
  };
1084
- useEffect4(() => () => handle?.compare(false), [handle]);
1128
+ useEffect5(() => () => handle?.compare(false), [handle]);
1085
1129
  const current = useRef3({ handle, compare });
1086
1130
  current.current = { handle, compare };
1087
- useEffect4(() => {
1131
+ useEffect5(() => {
1088
1132
  const onKey = (event) => {
1089
1133
  if (event.key !== COMPARE_KEY || editable(event.target) || event.repeat) return;
1090
1134
  event.preventDefault();
1091
1135
  const { handle: handle2, compare: compare2 } = current.current;
1092
- compare2(event.type === "keydown" && !!handle2?.state?.dirty);
1136
+ compare2(event.type === "keydown" && !!handle2?.dirty);
1093
1137
  };
1094
1138
  const release = () => current.current.compare(false);
1095
1139
  addEventListener("keydown", onKey);
@@ -1102,10 +1146,20 @@ function Studio({ studio, previewComponent, getConfigFileHref }) {
1102
1146
  removeEventListener("blur", release);
1103
1147
  };
1104
1148
  }, []);
1105
- useEffect4(() => {
1149
+ useEffect5(() => {
1150
+ const onKey = (event) => {
1151
+ const action = event.defaultPrevented ? null : historyAction(event);
1152
+ if (!action) return;
1153
+ event.preventDefault();
1154
+ current.current.handle?.[action]();
1155
+ };
1156
+ addEventListener("keydown", onKey);
1157
+ return () => removeEventListener("keydown", onKey);
1158
+ }, []);
1159
+ useEffect5(() => {
1106
1160
  const fromHash = () => {
1107
1161
  const id = decodeURIComponent(location.hash.slice(1));
1108
- if (studio.tools.some((handle2) => handle2.definition.id === id)) setSelected(id);
1162
+ if (studio.definitions.some((tool) => tool.id === id)) setSelected(id);
1109
1163
  };
1110
1164
  setMounted(true);
1111
1165
  fromHash();
@@ -1118,22 +1172,25 @@ function Studio({ studio, previewComponent, getConfigFileHref }) {
1118
1172
  SidebarProvider,
1119
1173
  {
1120
1174
  defaultOpen: sidebarOpen,
1121
- "data-preview-layout": previewComponent ? "canvas" : handle?.definition.previewLayout ?? "canvas",
1175
+ "data-preview-layout": previewComponent ? "canvas" : definition?.previewLayout ?? "canvas",
1176
+ "data-sidebar-panel": hidden.left ? "hidden" : "shown",
1122
1177
  className: "studio-workspace relative h-svh min-h-0 overflow-hidden text-xs",
1123
1178
  children: [
1124
- handle?.definition.preview ? /* @__PURE__ */ jsx13(
1179
+ definition?.preview ? /* @__PURE__ */ jsx13(
1125
1180
  Preview,
1126
1181
  {
1127
1182
  "data-slot": "studio-preview",
1128
1183
  ref: preview,
1129
- src: handle.definition.preview,
1130
- title: handle.definition.title ?? handle.definition.id,
1131
- onLoad: (event) => forwardShortcuts(event.currentTarget)
1184
+ src: definition.preview,
1185
+ title: definition.title ?? definition.id,
1186
+ onLoad: (event) => forwardShortcuts(event.currentTarget),
1187
+ ...Preview === PreviewCanvas && { expanded: canvasExpanded, onExpandedChange: setCanvasExpanded }
1132
1188
  },
1133
- handle.definition.id
1134
- ) : /* @__PURE__ */ jsx13("div", { className: "absolute inset-0 grid place-items-center text-faint", children: handle ? "This tool has no preview" : "No tools registered" }),
1135
- panelsHidden && ["left", "right"].map((side) => {
1189
+ definition.id
1190
+ ) : /* @__PURE__ */ jsx13("div", { className: "absolute inset-0 grid place-items-center text-faint", children: definition ? "This tool has no preview" : "No tools registered" }),
1191
+ ["left", "right"].filter((side) => hidden[side] && (side === "left" || handle)).map((side) => {
1136
1192
  const Chevron = side === "left" ? ChevronRight : ChevronLeft;
1193
+ const label = side === "left" ? "Show tools" : "Show inspector";
1137
1194
  return /* @__PURE__ */ jsx13(
1138
1195
  "div",
1139
1196
  {
@@ -1147,8 +1204,9 @@ function Studio({ studio, previewComponent, getConfigFileHref }) {
1147
1204
  sideOffset: 8,
1148
1205
  className: "flex items-center gap-2",
1149
1206
  content: /* @__PURE__ */ jsxs7(Fragment2, { children: [
1150
- "Show Studio panels",
1207
+ label,
1151
1208
  " ",
1209
+ /* @__PURE__ */ jsx13("span", { className: "text-faint", children: "\xB7 both" }),
1152
1210
  /* @__PURE__ */ jsxs7(KbdGroup, { children: [
1153
1211
  /* @__PURE__ */ jsx13(Kbd, { children: "\u2318/Ctrl" }),
1154
1212
  /* @__PURE__ */ jsx13(Kbd, { children: "B" })
@@ -1160,8 +1218,8 @@ function Studio({ studio, previewComponent, getConfigFileHref }) {
1160
1218
  "data-slot": "studio-panel-reveal",
1161
1219
  variant: "ghost",
1162
1220
  size: "icon",
1163
- "aria-label": "Show Studio panels",
1164
- onClick: () => setPanelsHidden(false),
1221
+ "aria-label": label,
1222
+ onClick: show[side],
1165
1223
  className: cn(
1166
1224
  "absolute top-1/2 h-16 w-6 -translate-y-1/2 cursor-pointer px-0 text-sidebar-foreground opacity-0 transition-[translate,opacity] duration-200 ease-out hover:bg-sidebar/95 hover:text-foreground focus:translate-x-0 focus:opacity-100 group-hover/edge:translate-x-0 group-hover/edge:opacity-100 motion-reduce:transition-none [@media(hover:none)]:translate-x-0 [@media(hover:none)]:opacity-100",
1167
1225
  side === "left" ? "left-1 -translate-x-[calc(100%+0.25rem)]" : "right-1 translate-x-[calc(100%+0.25rem)]"
@@ -1180,11 +1238,11 @@ function Studio({ studio, previewComponent, getConfigFileHref }) {
1180
1238
  {
1181
1239
  collapsible: "icon",
1182
1240
  variant: "floating",
1183
- inert: panelsHidden,
1184
- "aria-hidden": panelsHidden,
1241
+ inert: hidden.left,
1242
+ "aria-hidden": hidden.left,
1185
1243
  className: cn(
1186
1244
  "transition-[left,right,width,translate] duration-200 ease-out motion-reduce:transition-none",
1187
- panelsHidden && "-translate-x-[calc(100%+1rem)]"
1245
+ hidden.left && "-translate-x-[calc(100%+1rem)]"
1188
1246
  ),
1189
1247
  children: [
1190
1248
  /* @__PURE__ */ jsx13(SidebarHeader, { className: "h-10 flex-row items-center overflow-hidden py-0 pr-0 pl-4 transition-[padding] duration-200 ease-out group-data-[collapsible=icon]:pl-[calc(1.5rem-0.875rem*352/144/2)] motion-reduce:transition-none", children: /* @__PURE__ */ jsxs7("span", { className: "flex items-center gap-2.5 text-foreground", children: [
@@ -1195,27 +1253,28 @@ function Studio({ studio, previewComponent, getConfigFileHref }) {
1195
1253
  version
1196
1254
  ] })
1197
1255
  ] }) }),
1198
- /* @__PURE__ */ jsx13(SidebarContent, { children: /* @__PURE__ */ jsx13(SidebarGroup, { children: /* @__PURE__ */ jsx13(SidebarMenu, { children: tools.map(({ definition, state }) => {
1199
- const title = definition.title ?? definition.id;
1200
- const isPinned = pinned.has(definition.id);
1256
+ /* @__PURE__ */ jsx13(SidebarContent, { children: /* @__PURE__ */ jsx13(SidebarGroup, { children: /* @__PURE__ */ jsx13(SidebarMenu, { children: tools.map((tool) => {
1257
+ const title = tool.title ?? tool.id;
1258
+ const isPinned = pinned.has(tool.id);
1259
+ const { state, dirty } = studio.tools.find((handle2) => handle2.definition.id === tool.id) ?? {};
1201
1260
  const pinLabel = `${isPinned ? "Unpin" : "Pin"} ${title}`;
1202
1261
  return /* @__PURE__ */ jsxs7(SidebarMenuItem, { children: [
1203
1262
  /* @__PURE__ */ jsxs7(
1204
1263
  SidebarMenuButton,
1205
1264
  {
1206
- isActive: definition.id === selected,
1265
+ isActive: tool.id === selected,
1207
1266
  tooltip: title,
1208
- onClick: () => location.hash = definition.id,
1267
+ onClick: () => location.hash = tool.id,
1209
1268
  children: [
1210
1269
  /* @__PURE__ */ jsxs7("span", { className: "relative", children: [
1211
- /* @__PURE__ */ jsx13(ToolIcon, { icon: definition.icon, className: "size-4" }),
1212
- state?.dirty && /* @__PURE__ */ jsx13(
1270
+ /* @__PURE__ */ jsx13(ToolIcon, { icon: tool.icon, className: "size-4" }),
1271
+ dirty && /* @__PURE__ */ jsx13(
1213
1272
  "span",
1214
1273
  {
1215
- "aria-label": state.conflict ? "Changed on disk" : "Unsaved",
1274
+ "aria-label": state?.conflict ? "Changed on disk" : "Unsaved",
1216
1275
  className: cn(
1217
1276
  "absolute -top-0.5 -right-0.5 size-1.5 rounded-full",
1218
- state.conflict ? "bg-destructive" : "bg-warning"
1277
+ state?.conflict ? "bg-destructive" : "bg-warning"
1219
1278
  )
1220
1279
  }
1221
1280
  )
@@ -1231,7 +1290,7 @@ function Studio({ studio, previewComponent, getConfigFileHref }) {
1231
1290
  "aria-label": pinLabel,
1232
1291
  "aria-pressed": isPinned,
1233
1292
  showOnHover: !isPinned,
1234
- onClick: () => togglePin(definition.id),
1293
+ onClick: () => togglePin(tool.id),
1235
1294
  className: cn(
1236
1295
  "cursor-pointer [&>svg]:size-3 [@media(hover:none)]:opacity-100",
1237
1296
  isPinned && "text-sidebar-primary"
@@ -1239,7 +1298,7 @@ function Studio({ studio, previewComponent, getConfigFileHref }) {
1239
1298
  children: /* @__PURE__ */ jsx13(Pin2, { "aria-hidden": true, fill: isPinned ? "currentColor" : "none" })
1240
1299
  }
1241
1300
  ) })
1242
- ] }, definition.id);
1301
+ ] }, tool.id);
1243
1302
  }) }) }) }),
1244
1303
  /* @__PURE__ */ jsx13(StudioFooter, { connection: studio.connection })
1245
1304
  ]
@@ -1249,11 +1308,11 @@ function Studio({ studio, previewComponent, getConfigFileHref }) {
1249
1308
  "aside",
1250
1309
  {
1251
1310
  "data-slot": "studio-inspector",
1252
- inert: panelsHidden,
1253
- "aria-hidden": panelsHidden,
1311
+ inert: hidden.right,
1312
+ "aria-hidden": hidden.right,
1254
1313
  className: cn(
1255
1314
  "absolute inset-y-2 right-2 z-10 flex w-(--studio-inspector-width) flex-col gap-1 p-1 transition-transform duration-200 ease-out motion-reduce:transition-none",
1256
- panelsHidden && "translate-x-[calc(100%+1rem)]"
1315
+ hidden.right && "translate-x-[calc(100%+1rem)]"
1257
1316
  ),
1258
1317
  children: /* @__PURE__ */ jsx13(
1259
1318
  Inspector,
@@ -1275,7 +1334,7 @@ function Studio({ studio, previewComponent, getConfigFileHref }) {
1275
1334
  // src/react/command-menu.tsx
1276
1335
  import { Pin as Pin3 } from "lucide-react";
1277
1336
  import { Dialog } from "radix-ui";
1278
- import { useEffect as useEffect5, useRef as useRef4, useState as useState8 } from "react";
1337
+ import { useEffect as useEffect6, useRef as useRef4, useState as useState9 } from "react";
1279
1338
 
1280
1339
  // src/react/ui/command.tsx
1281
1340
  import * as React3 from "react";
@@ -1343,10 +1402,10 @@ function CommandItem({ className, ...props }) {
1343
1402
  import { jsx as jsx15, jsxs as jsxs9 } from "react/jsx-runtime";
1344
1403
  function StudioCommandMenu({ studio, studioPath = "/studio", navigate }) {
1345
1404
  const { tools, pinned } = usePinnedTools(studio);
1346
- const [open, setOpen] = useState8(false);
1405
+ const [open, setOpen] = useState9(false);
1347
1406
  const opener = useRef4(null);
1348
1407
  const input = useRef4(null);
1349
- useEffect5(() => {
1408
+ useEffect6(() => {
1350
1409
  if (window.frameElement?.getAttribute("data-slot") === "studio-preview") return;
1351
1410
  const onKey = (event) => {
1352
1411
  if (event.defaultPrevented || event.isComposing || event.altKey || !(event.metaKey || event.ctrlKey)) return;
@@ -1397,8 +1456,8 @@ function StudioCommandMenu({ studio, studioPath = "/studio", navigate }) {
1397
1456
  /* @__PURE__ */ jsxs9(Command, { label: "Studio tools", loop: true, vimBindings: false, children: [
1398
1457
  /* @__PURE__ */ jsx15(CommandInput, { ref: input, placeholder: "Search tools\u2026" }),
1399
1458
  /* @__PURE__ */ jsxs9(CommandList, { children: [
1400
- /* @__PURE__ */ jsx15(CommandEmpty, { children: studio.tools.length ? "No tools found." : "No tools registered yet." }),
1401
- tools.map(({ definition: tool }) => /* @__PURE__ */ jsxs9(
1459
+ /* @__PURE__ */ jsx15(CommandEmpty, { children: studio.definitions.length ? "No tools found." : "No tools registered yet." }),
1460
+ tools.map((tool) => /* @__PURE__ */ jsxs9(
1402
1461
  CommandItem,
1403
1462
  {
1404
1463
  value: tool.id,