Full-screen editor frame - outline, live canvas and inspector - for survey, slide and page builders
Tags: component layout editor
Studio is the frame for anything people build: surveys, slide decks, forms, pages. It replaces the "record page with a form bolted on" pattern with one learnable layout:
┌ StudioBar: ← Title · Saved [Build | Settings | Share] ⚠ 2 Publish ⋯ ┐
├ Outline ──────┬ Canvas ───────────────────────┬ Inspector ────────────────┤
│ ⠿ 1 Question │ the real thing, as people see │ settings for the │
│ ⠿ 2 Question │ it - click to select, │ selection only │
│ + Add │ "+" between items to insert │ │
└───────────────┴───────────────────────────────┴───────────────────────────┘The rules it encodes:
- One frame, no page scroll. Outline, canvas and inspector scroll on their own.
- Modes, not fake rows. Building lives in Build; configuring (settings, audience, sharing) lives in its own modes rendered as a
StudioPage. - The canvas is the preview. No separate form column and preview column to map between.
- One add path (
InsertPalette), one reorder path (drag handle + keyboard), one save signal (SaveStatus), one issues list (IssuesButton). - Responsive by default. Below 1280px the outline becomes a left sheet; below 768px the inspector becomes a bottom sheet that opens when something is picked.
See the full survey-builder example at /studio-demo.
Import
<script>
import {
Studio,
StudioBar,
StudioOutline,
StudioCanvas,
StudioCanvasItem,
StudioInspector,
StudioInspectorSection,
StudioPage,
SaveStatus,
IssuesButton,
InsertPalette,
} from '@classic-homes/theme-svelte';
</script>Basic Usage
<script>
let questions = $state(loadQuestions());
let selected = $state(questions[0].id);
let mode = $state('build');
let adding = $state(false);
</script>
{#snippet bar()}
<StudioBar
bind:title
backHref="/surveys"
backLabel="Surveys"
bind:mode
modes={[
{ value: 'build', label: 'Build', icon: 'edit' },
{ value: 'settings', label: 'Settings', icon: 'settings' },
]}
menuItems={[{ label: 'Delete survey', icon: 'trash', destructive: true, onSelect: remove }]}
>
{#snippet status()}<SaveStatus state={saveState} onRetry={save} />{/snippet}
{#snippet issues()}<IssuesButton {issues} bind:open={issuesOpen} onSelect={jump} />{/snippet}
{#snippet actions()}<Button size="sm" onclick={publish}>Publish</Button>{/snippet}
</StudioBar>
{/snippet}
{#snippet rail()}
<StudioOutline
items={questions.map((q) => ({ id: q.id, title: q.title, subtitle: q.typeLabel }))}
bind:selected
onReorder={move}
onAdd={() => (adding = true)}
addLabel="Add question"
label="Questions"
/>
{/snippet}
{#snippet canvas()}
<StudioCanvas deviceToggle>
{#each questions as q, i (q.id)}
<StudioCanvasItem
label="Question {i + 1}"
selected={q.id === selected}
onSelect={() => (selected = q.id)}
onInsert={() => (adding = true)}
>
<QuestionPreview question={q} />
</StudioCanvasItem>
{/each}
</StudioCanvas>
{/snippet}
{#snippet inspector()}
<StudioInspector title="Question 1" description="Single choice">
<StudioInspectorSection><!-- content fields --></StudioInspectorSection>
<StudioInspectorSection title="Advanced" collapsible
><!-- rarely used --></StudioInspectorSection
>
</StudioInspector>
{/snippet}
{#if mode === 'build'}
<Studio {bar} {rail} {canvas} {inspector} railLabel="Questions" />
{:else}
<Studio {bar} canvas={settingsPage} />
{/if}
<InsertPalette bind:open={adding} groups={questionTypes} onSelect={add} title="Add a question" />React
The same parts ship in @classic-homes/theme-react with React conventions: snippets become ReactNode props (bar, rail, canvas, inspector, status, actions...), bind: values become controlled props with on…Change callbacks (mode/onModeChange, railOpen/onRailOpenChange, open/onOpenChange), thumbnail becomes renderThumbnail, and parts read the layout with useStudio() instead of getStudioContext(). Icons are ReactNodes rather than icon names. See /studio in the React example app.
Props
Studio
| Prop | Type | Default | Description |
|---|---|---|---|
bar |
Snippet |
required | Top bar, normally a StudioBar (required) |
canvas |
Snippet |
required | Main area: a StudioCanvas or StudioPage (required) |
rail |
Snippet |
- | Left column, normally a StudioOutline |
inspector |
Snippet |
- | Right column, normally a StudioInspector |
banner |
Snippet |
- | One slot under the bar for conflicts and blocking decisions |
railLabel |
string |
'Outline' |
Name of the rail and its sheet button |
inspectorLabel |
string |
'Properties' |
Name of the inspector and its sheet button |
railOpen |
boolean |
false |
Rail sheet open below 1280px (bindable) |
inspectorOpen |
boolean |
false |
Inspector sheet open below 768px (bindable) |
StudioBar
| Prop | Type | Default | Description |
|---|---|---|---|
title |
string |
'' |
Document title, edited in place (bindable) |
onTitleChange |
(title: string) => void |
- | Called on blur/Enter; Escape reverts |
titleReadonly |
boolean |
false |
Show the title as text |
subtitle |
string |
- | e.g. "Survey · 5 questions · Draft" |
backHref |
string |
- | Back link |
modes |
SegmentedControlOption[] |
- | Build / Settings / Share... |
mode |
string |
first mode | Active mode (bindable) |
status |
Snippet |
- | Normally a SaveStatus |
issues |
Snippet |
- | Normally an IssuesButton |
actions |
Snippet |
- | Primary action (Publish, Present) |
menuItems |
(DropdownMenuItem | DropdownMenuGroup)[] |
- | Everything else: export, history, delete |
StudioOutline
| Prop | Type | Default | Description |
|---|---|---|---|
items |
StudioOutlineEntry[] |
- | { id, title, subtitle?, icon?, issues?, muted?, badge? } |
selected |
string | null |
null |
Selected id (bindable) |
onSelect |
(id) => void |
- | Row picked |
onReorder |
(from, to) => void |
- | Enables drag handles and Move up/down |
menuItems |
(entry, index) => DropdownMenuItem[] |
- | Row menu (duplicate, delete...) |
onAdd |
() => void |
- | Shows the add button |
thumbnail |
Snippet<[entry, index]> |
- | Preview per row (slides) |
StudioCanvas / StudioCanvasItem
| Prop | Type | Default | Description |
|---|---|---|---|
deviceToggle |
boolean |
false |
Desktop/Phone switch (hidden on phones) |
device |
'desktop' | 'phone' |
'desktop' |
Bindable |
maxWidth |
string |
'48rem' |
Frame width on desktop |
onBackgroundClick |
() => void |
- | Click outside any item, usually clears selection |
label (item) |
string |
- | e.g. "Question 2" (required) |
selected (item) |
boolean |
false |
Ring + toolbar |
toolbar (item) |
Snippet |
- | Quick actions on the selected item |
onInsert (item) |
() => void |
- | "+" below the item |
invalid (item) |
boolean |
false |
Dashed amber outline |
SaveStatus / IssuesButton / InsertPalette
| Component | Key props |
|---|---|
SaveStatus |
state: 'saved' | 'saving' | 'unsaved' | 'error' | 'offline' | 'conflict' | 'invalid' | 'readonly', detail?, onRetry? |
IssuesButton |
issues: { id, message, severity?, location? }[], onSelect, open (bindable) |
InsertPalette |
groups: { label, items: { id, label, description?, icon?, keywords? }[] }[], onSelect, thumbnail? |
Keyboard Navigation
- Outline:
ArrowUp/ArrowDown/Home/Endmove between rows;Enterselects - Drag handle:
Space/Enterpicks up, arrows move,Space/Enterdrops,Escapecancels;Alt+Arrowmoves one step - Canvas: tabbing into an item selects it; items without focusable content get a hidden "Edit …" button
- Title:
Entercommits,Escapereverts - Insert palette: type to filter,
Enteradds the first match, arrows move between entries
Accessibility
- Rail and inspector are labelled
asidelandmarks; the canvas is a labelledsection. - Every reorder is announced (
moved to position 3 of 5). - Issue counts are text, never colour alone;
SaveStatusonly announces problems, not every save. - Problems are a dashed amber outline so they never look like the (brand-red) selection ring.
Related Components
- Sheet - The drawers the Studio uses below its breakpoints
- SortableList - The reorder behaviour behind the outline
- SegmentedControl - Mode switch in the bar