@joycostudio/studio
v0.1.1
Published
Plug project-specific tools into a /studio route, a floating debug pane, and MCP agents.
Keywords
Readme
@joycostudio/studio
Plug project-specific tools into a /studio route, the app's floating debug pane, and MCP agents, with one contract per tool.
For contributor instructions on stable releases, betas, and commit previews, see the release workflow guide.
A tool's controls are an Xyz debug schema (Tweakpane). That same schema:
- renders the inspector in
/studio, - mounts in the app's floating pane (
?debug/ Option+D) through Xyz'sDebugManager, - validates every write in the runtime (slider bounds, select options, types, and the shape of
initial).
1. Define a tool
Tool files are loaded by the runtime in Node too, so keep them free of DOM/three imports and @/ aliases.
// src/tools/grading.ts
import { defineTool } from '@joycostudio/studio'
import { property, select, slider } from '@joycostudio/xyz/debug'
import initial from './grading.json'
export const grading = defineTool({
id: 'color-grading',
file: 'src/tools/grading.json', // where saves go, repo-relative
initial, // the same file, imported: production ships the saved config
preview: '/', // route shown next to the inspector in /studio
controls: (config) => ({
exposure: slider(property(config, 'exposure'), { min: 0, max: 3 }),
tonemap: select(property(config, 'tonemap'), { ACES: 'aces', Filmic: 'filmic' }),
}),
})2. Register tools with the runtime
// studio.config.ts (its directory is the project root)
import { defineConfig } from '@joycostudio/studio'
import { grading } from './src/tools/grading'
export default defineConfig({ tools: [grading] }){ "scripts": { "dev": "studio dev -- next dev" } }studio dev starts the WebSocket bridge on ws://127.0.0.1:5179 and the HTTP MCP endpoint on
http://127.0.0.1:5179/mcp (--port changes both). It stops when the wrapped command exits.
3. Use it in the app
// src/studio.ts
import { createStudio } from '@joycostudio/studio/client'
export const studio = createStudio({
tools: [grading],
runtime: process.env.NODE_ENV === 'development' && 'ws://127.0.0.1:5179',
})const tool = studio.tool(grading)
apply(tool.config) // a live object: read it in your render loop…
tool.subscribe(apply) // …or react to every change, local or remoteLive edits also work without studio dev, including in production. Studio shares configs between
same-origin previews and tabs in the same browser using BroadcastChannel. Peers match by tool ID,
config file, and runtime URL (or no runtime); a newly opened or reloaded preview receives the current
config from an open peer. Nothing is persisted: closing every page resets these edits to the shipped
config. Save, Discard, and MCP still require a runtime. If a configured runtime disconnects, browser
sync continues; reconnecting restores the runtime's draft.
Floating debug pane, with the same controls (plus save/discard when the runtime is up):
import { everyFrame } from '@joycostudio/studio/client'
import { DebugManager } from '@joycostudio/xyz/debug'
import { Pane } from 'tweakpane'
const debug = new DebugManager({ createPane: () => new Pane(), binding: { registerFrame: everyFrame } })
for (const tool of studio.tools) debug.register(tool)The /studio route:
// app/studio/page.tsx
'use client'
import { Studio } from '@joycostudio/studio/react'
import { studio } from '@/studio'
export default function StudioPage() {
return <Studio studio={studio} />
}Use the pin button beside a sidebar tool to keep it at the top. Pinned tools also appear first in the Command/Ctrl+K menu. Pins are saved in this browser for the current origin and shared across tabs; unpinning restores registration order. Pinning a tool keeps the current preview selected.
In Studio, Command+B (Mac) or Ctrl+B slides both side panels out of view; press it again to bring them back. This also works from the same-origin preview iframe. The sidebar's collapse/expand button controls its width separately, and that choice is preserved when hiding and showing the panels. Move the pointer near either screen edge to reveal a small tab; click it to bring both panels back. The tabs also appear on keyboard focus and stay visible on touch devices. With reduced motion enabled, panels toggle instantly.
Previews use the shared PreviewCanvas: Reload and the tool title sit on the left, with Expand and
header pinning on the right. Expand/Contain keeps the iframe and its scene mounted. Headers start
pinned; enabling auto-hide overlays the header and hides it after three seconds away. Hover, keyboard
focus, and open menus keep it visible, and touch devices always show the controls. Contained previews
fit between the side panels, with the inspector stacked below on narrow screens.
Shared canvas and panel components
Import these from @joycostudio/studio/react with @joycostudio/studio/styles.css:
| Component | Use |
| ----------------------------------------------------------------------------- | -------------------------------------------------------------------------------------- |
| StudioSurface, StudioToolbar | Shared inset card and compact action row, including button states. |
| CanvasToolbar | Pinned/auto-hide header; label, optional expanded/onToggle, and child controls. |
| PreviewCanvas | Iframe panel with reload, expansion, and a toolbar slot replacing the title. |
| StudioSelect, StudioSelectItem | Labeled Radix select with Studio field and portaled menu styling. |
| StudioInput | Standard input with logical padding (ps-*/pe-*) for icon-bearing fields. |
| StudioPanel, StudioPanelHeader, StudioPanelContent, StudioPanelFooter | Floating panel surface, inset sections, and scrolling body used by Studio's inspector. |
PreviewCanvas handles expansion itself, or accepts controlled expanded/onExpandedChange props.
Native iframe props (including className, onLoad, and title) and its ref reach the actual iframe;
onReload runs before an explicit reload. StudioSurface and StudioPanel support asChild to reuse
their appearance on semantic elements without an extra wrapper. Panel layout is caller-owned.
The package owns the styles and interactions extracted from the BZRP Studio workspace, including 6px toolbar padding, 4px gaps, 8px padding on title text, and continuous sidebar header/footer surfaces. Consumers can replace local copies of the surface, toolbar, preview, select, and input components with these imports. Module graphs, tracing, and scene-specific controls remain host-owned.
Host preview and source-link extensions
Studio accepts previewComponent and getConfigFileHref, so integrations no longer need to patch
the built package. Keep a custom preview component at module scope and forward its iframe ref and
props; Studio uses them for keyboard shortcuts, comparison, and tool-switch lifecycle:
import { forwardRef, type ComponentPropsWithoutRef } from 'react'
import { PreviewCanvas, Studio } from '@joycostudio/studio/react'
const ProjectPreview = forwardRef<HTMLIFrameElement, ComponentPropsWithoutRef<'iframe'>>(
(props, ref) => <PreviewCanvas {...props} ref={ref} />,
)
<Studio
studio={studio}
previewComponent={ProjectPreview}
getConfigFileHref={(file) => new URL(file, sourceBaseUrl).href}
/>The source base URL belongs to the host: it can point to a source browser or a development-only
vscode://file/ URL for the current checkout. Without the callback, config paths remain plain text.
See the playground's /studio/components route for controlled expansion, custom toolbar fields,
and source links in use.
For a tool that already renders its own panels and headers, set previewLayout: 'custom' in its
defineTool definition. Studio uses a bare iframe fitted to the available panel area, avoiding
route-specific checks such as /studio/modules. A supplied previewComponent takes precedence.
Use previewComponent="iframe" to retain the original full-window preview behavior for all tools.
When migrating the BZRP integration, remove its custom-preview/config-link pnpm patch, use these exports, and remove the generic shell/toolbar CSS that Studio now supplies. Keep application-specific module runtime expansion, source indexing, and session-local inspector adapters in the host.
Jump to tools from anywhere
Mount StudioCommandMenu once in the host app's root layout so it stays available on every route, including /studio:
// src/command-menu.tsx
'use client'
import '@joycostudio/studio/command-menu.css'
import { StudioCommandMenu } from '@joycostudio/studio/react'
import { studio } from './studio'
export function CommandMenu() {
return <StudioCommandMenu studio={studio} />
}Render <CommandMenu /> alongside the layout's {children}. The host owns mounting it, just like the floating debug pane.
Its stylesheet is scoped to the menu and can be imported globally without applying Studio's page styles to the host app.
Press Command+K (Mac) or Ctrl+K to open or close it, type a tool name or ID to search, use ↑/↓ and Enter to
select, or Escape to dismiss. Clicking a result also opens it. Results link to /studio#tool-id; switching tools from
inside Studio changes the hash without reloading the page. The shortcut also works while focus is in Studio's same-origin
preview iframe, opening the menu above Studio rather than inside the preview.
Pass studioPath="/custom/studio" for another route. For client-side navigation with a host router, pass its navigation
function, for example <StudioCommandMenu studio={studio} navigate={router.push} /> with Next.js useRouter().
Without a router callback, navigation from another route uses the browser. The menu works without a runtime connection.
To run its browser tests, start the playground and run
STUDIO_TEST_APP_URL=http://localhost:3000 pnpm --filter @joycostudio/studio exec vitest run test/command-menu.test.ts.
Install Chromium first with pnpm exec studio install-browser if needed.
4. Connect an MCP agent
Start the app with studio dev -- <your dev command> as above. Add a Streamable HTTP MCP server to your agent with
this URL:
http://127.0.0.1:5179/mcpUse the URL printed by the CLI when running on a different port. Each project's runtime needs its own port when several
projects run at once. The endpoint is local to the machine running Studio; a remote/cloud agent cannot reach it through
its own localhost.
The MCP endpoint shares the running app's drafts. It supports both the 2025 MCP initialization flow and the 2026 MCP discovery flow, with no separate runtime or MCP process to launch.
| MCP tool | Arguments | Result |
| ------------------------ | ---------------------------------------------- | ---------------------------------------------------------------- |
| studio_list_tools | none | Registered IDs, titles, file paths, and preview routes |
| studio_get_tool | tool | Complete draft, revision, saved value, dirty and conflict status |
| studio_update_tool | tool, revision, value | Validated draft; all connected previews update immediately |
| studio_save_tool | tool, revision, optional force | Draft persisted to its registered JSON file |
| studio_discard_tool | tool, revision | Draft reset to the runtime's latest saved value |
| studio_capture_preview | tool, optional revision, width, height | PNG image and draft revision from a fresh preview page |
Read before editing, copy data.draft.value, change the intended fields, then pass the complete config and
data.draft.revision to studio_update_tool. Saving is separate. Use the new revision returned by the update to save or
discard. If the playground or another agent edits in between, a stale_revision error tells you to read again and reconcile
the changes. force bypasses a disk conflict, never a stale revision.
Every tool returns the runtime's { ok: true, data } or { ok: false, error, message? } as structured content and JSON text.
Runtime failures also set MCP's isError, so invalid configs, unknown tools, stale revisions, and disk conflicts remain
visible to the agent. Expected failures leave the draft intact.
Preview images
Set the running app's URL once in studio.config.ts (or override it with studio dev --app-url <url>):
export default defineConfig({ tools: [grading], appUrl: 'http://localhost:3000' })Install Chromium once with pnpm exec studio install-browser. Then call studio_capture_preview with a tool ID.
The existing preview route must use the Studio client connected to this runtime. No capture code or readiness hooks
are needed in tool definitions, HTML components, or canvas renderers.
Capture starts a shared headless Chromium browser on first use and opens an isolated page for each request. It reads the
live unsaved draft, waits for the client to receive that revision, then waits for fonts, current HTML images, and two
animation frames. CSS animations are disabled for the screenshot. This is automatic settling, not a guarantee that
arbitrary async work, delayed UI, or WebGL assets have finished. A draft change during capture returns stale_revision
instead of an image. Simultaneous captures return capture_busy; retry after the active capture finishes.
The result includes a PNG as MCP image content and metadata with tool, revision, url, width, height, and
settling: 'automatic'. The viewport defaults to 1200×800 at 1× scale; width accepts 320–2560 and height accepts 240–1600.
The page starts fresh: it does not inherit your open tab's login, scroll, or interaction state. Preview routes and
top-level navigation must stay on the configured app origin. Pages that never connect or settle time out.
For the browser integration tests, run pnpm exec studio install-browser and
pnpm --filter @joycostudio/studio test:capture. The regular test suite runs setup validation without requiring Chromium.
The server binds to 127.0.0.1 by default and the MCP endpoint checks localhost Host headers and the same browser Origin
allowlist as the WebSocket bridge. Native agent clients do not need an Origin header. This is a local development endpoint
without authentication; do not expose it through a public proxy.
How it fits together
- Runtime (
studio dev) is the only file writer. It validates every update, keeps a draft revision per tool, writes atomically, refuses paths outside the project (symlinks included), only accepts localhost browser origins, and watches files for external edits (an external edit over unsaved work becomes aconflictthat needs discard or force save). - Bridge: WebSocket (
tools.list,tool.get,tool.update,tool.save,tool.discard+statebroadcasts) and HTTP MCP on the same port./studio, every app tab, and MCP all go through these same runtime operations. - Without a runtime (production or offline), browser messages synchronize live configs between
same-origin Studio pages, previews, and app tabs. Subscriptions and Tweakpane controls still update;
nothing persists. Browsers without
BroadcastChannelretain same-page tweaking.
Run pnpm --filter @joycostudio/studio test:browser to verify runtime-free pane edits, iframe/tab
sync, preview reloads, and runtime disconnect/reconnect in Chromium. Install Chromium first with
pnpm exec studio install-browser if needed.
