LayoutManager
Save, load, rename, duplicate, delete, import, and export named workspace layouts. You own the list, storage, and workspace callbacks.
import { useState } from "react"
import { WORKSPACE_PERSISTENCE_BOUNDARIES, type WorkspaceLayout } from "@/lib/workspace-layout"
import { LayoutManager, type LayoutTemplate } from "@/components/ui/layout-manager"
// A snapshot captured from a workspace with one Book panel.
const BOOK: WorkspaceLayout = {
version: 1,
kind: "tradecn-workspace",
dockview: {
grid: {
root: { type: "branch", data: [{ type: "leaf", data: { views: ["book-1"], activeView: "book-1", id: "1" }, size: 478 }], size: 115 },
width: 478, height: 115, orientation: "HORIZONTAL",
},
panels: { "book-1": { id: "book-1", contentComponent: "tradecn-panel", tabComponent: "props.defaultTabComponent", title: "Book" } },
activeGroup: "1",
},
panels: { "book-1": { kind: "book", title: "Book", state: { symbol: "ZN" } } },
boundaries: WORKSPACE_PERSISTENCE_BOUNDARIES,
}
export default function LayoutManagerDemo() {
const [templates, setTemplates] = useState<LayoutTemplate[]>([{ id: "t-1", name: "Treasury book", layout: BOOK, savedAt: Date.parse("2026-09-22T14:00:00Z") }])
const [current, setCurrent] = useState(BOOK)
const [activeId, setActiveId] = useState<string | null>(null)
const [message, setMessage] = useState("Current snapshot: one ZN book.")
return (
<div className="w-[36rem] max-w-full space-y-3 text-xs lining-nums tabular-nums">
<div role="region" aria-label="Layout controls" tabIndex={0} className="overflow-x-auto">
<LayoutManager
className="min-w-96"
templates={templates}
onTemplatesChange={(next) => {
const previous = templates.find((template) => template.id === activeId)
const active = next.find((template) => template.id === activeId)
const layout = JSON.stringify(active?.layout)
if (!active || (layout !== JSON.stringify(previous?.layout) && layout !== JSON.stringify(current))) setActiveId(null)
setTemplates(next)
}}
current={current}
kinds={["book"]}
activeId={activeId}
onLoad={(layout, template) => {
setCurrent(layout)
setActiveId(template.id)
setMessage(`Selected ${template.name}.`)
}}
/>
</div>
<p role="status" className="text-muted-foreground">{message}</p>
</div>
)
}
Installation
pnpm dlx shadcn@latest add tradecn/ui/layout-manager#v1.4.13
npx shadcn@latest add tradecn/ui/layout-manager#v1.4.13
yarn dlx shadcn@latest add tradecn/ui/layout-manager#v1.4.13
bunx --bun shadcn@latest add tradecn/ui/layout-manager#v1.4.13
-
Install the dependencies:
pnpm add cnnpm install cnyarn add cnbun add cn -
Add the shadcn components it composes:
pnpm dlx shadcn@latest add badge button inputnpx shadcn@latest add badge button inputyarn dlx shadcn@latest add badge button inputbunx --bun shadcn@latest add badge button input -
Copy the files into your project:
components/ui/ layout-manager.tsx import { cn } from "cn" import { useEffect, useRef, useState } from "react" import { Badge } from "@/components/ui/badge" import { Button } from "@/components/ui/button" import { Input } from "@/components/ui/input" import { readSlot, setSlot, type Preferences } from "@/lib/preferences" import { parseWorkspaceLayout, unknownPanelKinds, type WorkspaceLayout } from "@/lib/workspace-layout" // Named layouts for a workspace. The workspace hands its layout back and stores nothing; a desk needs // templates: save the current layout under a name, load one, rename, duplicate, delete with an // ask-again, import and export the JSON, and go back to the default. The templates are a list the // consumer keeps, in a `preferences` slot or wherever else, and every change here is a new list handed // to `onTemplatesChange`. Loading is a request to the consumer, who owns the workspace's api; a layout // asking for a panel kind the workspace does not have is said before it opens. export interface LayoutTemplate { id: string name: string layout: WorkspaceLayout /** When it was saved, ms since the epoch. */ savedAt: number } export const LAYOUT_TEMPLATES_SLOT = "layouts" export const LAYOUT_TEMPLATES_VERSION = 1 export interface LayoutManagerLabels { title: string /** The save field's placeholder and name. */ saveName: string save: string /** Said under the field when a name is taken. `{name}`. */ taken: string load: string loadAnyway: string rename: string duplicate: string /** The name of a copy. `{name}`. */ copyOf: string delete: string deleteAnyway: string export: string import: string /** The import field's placeholder. */ pasteLayout: string add: string /** Said when the pasted text is not a layout. */ notALayout: string reset: string /** `{n}` panels. */ panels: string /** `{kinds}` the kinds a layout asks for that the workspace does not have. */ unknownKinds: string empty: string /** Said of the loaded template. */ active: string } export const DEFAULT_LAYOUT_MANAGER_LABELS: LayoutManagerLabels = { title: "Layouts", saveName: "Layout name", save: "Save current", taken: "A layout named {name} exists. Save replaces it.", load: "Load", loadAnyway: "Load anyway?", rename: "Rename", duplicate: "Duplicate", copyOf: "Copy of {name}", delete: "Delete", deleteAnyway: "Delete?", export: "Export", import: "Import", pasteLayout: "Paste a layout's JSON", add: "Add", notALayout: "That is not a workspace layout.", reset: "Reset to default", panels: "{n} panels", unknownKinds: "Needs {kinds}", empty: "No saved layouts. Save the current one under a name.", active: "loaded", } const fill = (template: string, values: Record<string, string>) => template.replace(/\{(\w+)\}/g, (_, key: string) => values[key] ?? "") let clockFormat: Intl.DateTimeFormat | null = null const localTime = (ms: number) => (clockFormat ??= new Intl.DateTimeFormat(undefined, { month: "short", day: "numeric", hour: "2-digit", minute: "2-digit", hourCycle: "h23" })).format(ms) /** `t-1`, then `t-2`: the lowest number not taken. */ function nextId(taken: Iterable<string>): string { const used = new Set(taken) let n = 1 while (used.has(`t-${n}`)) n += 1 return `t-${n}` } /** A stored list read back, taking nothing on trust: a template whose layout does not parse is dropped, and what comes back is a copy. */ export function parseLayoutTemplates(value: unknown): LayoutTemplate[] { let raw = value if (typeof raw === "string") { try { raw = JSON.parse(raw) } catch { return [] } } const list = Array.isArray(raw) ? raw : typeof raw === "object" && raw !== null && Array.isArray((raw as { templates?: unknown }).templates) ? (raw as { templates: unknown[] }).templates : [] const out: LayoutTemplate[] = [] for (const entry of list) { if (typeof entry !== "object" || entry === null) continue const t = entry as Record<string, unknown> const layout = parseWorkspaceLayout(t.layout) if (!layout || typeof t.id !== "string" || !t.id || typeof t.name !== "string" || !t.name) continue out.push({ id: t.id, name: t.name, layout, savedAt: typeof t.savedAt === "number" ? t.savedAt : 0 }) } return out } /** The templates in a preferences envelope's slot, `layouts` by default. */ export function readLayoutTemplates(prefs: Preferences, slot: string = LAYOUT_TEMPLATES_SLOT): LayoutTemplate[] { return parseLayoutTemplates(readSlot(prefs, slot)) } /** The envelope with the templates written to the slot. */ export function writeLayoutTemplates(prefs: Preferences, templates: readonly LayoutTemplate[], slot: string = LAYOUT_TEMPLATES_SLOT): Preferences { return setSlot(prefs, slot, { version: LAYOUT_TEMPLATES_VERSION, templates }, LAYOUT_TEMPLATES_VERSION) } /** The list with `layout` saved under `name`: a new template, or the one already named that replaced. */ export function saveTemplate(templates: readonly LayoutTemplate[], name: string, layout: WorkspaceLayout, savedAt: number): LayoutTemplate[] { const trimmed = name.trim() const existing = templates.find((t) => t.name === trimmed) if (existing) return templates.map((t) => (t === existing ? { ...t, layout, savedAt } : t)) return [...templates, { id: nextId(templates.map((t) => t.id)), name: trimmed, layout, savedAt }] } export function renameTemplate(templates: readonly LayoutTemplate[], id: string, name: string): LayoutTemplate[] { const trimmed = name.trim() if (!trimmed) return [...templates] return templates.map((t) => (t.id === id ? { ...t, name: trimmed } : t)) } /** A copy beside the original, named `Copy of <name>` unless a name is given. */ export function duplicateTemplate(templates: readonly LayoutTemplate[], id: string, savedAt: number, name?: string, labels: LayoutManagerLabels = DEFAULT_LAYOUT_MANAGER_LABELS): LayoutTemplate[] { const i = templates.findIndex((t) => t.id === id) if (i < 0) return [...templates] const source = templates[i]! const copy: LayoutTemplate = { id: nextId(templates.map((t) => t.id)), name: name?.trim() || fill(labels.copyOf, { name: source.name }), layout: parseWorkspaceLayout(source.layout) ?? source.layout, savedAt } return [...templates.slice(0, i + 1), copy, ...templates.slice(i + 1)] } export function deleteTemplate(templates: readonly LayoutTemplate[], id: string): LayoutTemplate[] { return templates.filter((t) => t.id !== id) } /** The layout as JSON text, for a file or the clipboard. */ export function exportTemplate(template: LayoutTemplate, indent = 2): string { return JSON.stringify(template.layout, null, indent) } /** The list with a pasted layout added under `name`, or null when the text is not a layout. */ export function importTemplate(templates: readonly LayoutTemplate[], text: string, name: string, savedAt: number): LayoutTemplate[] | null { const layout = parseWorkspaceLayout(text) if (!layout) return null return saveTemplate(templates, name, layout, savedAt) } export interface LayoutManagerProps { templates: readonly LayoutTemplate[] /** Every change is a whole new list: save it where you keep it, a preferences slot for one. */ onTemplatesChange: (templates: LayoutTemplate[]) => void /** The workspace's layout as it last handed it over. What `Save current` captures; null disables the button. */ current?: WorkspaceLayout | null /** The trader wants this layout open: hand it to the workspace's `api.load`. */ onLoad: (layout: WorkspaceLayout, template: LayoutTemplate) => void /** The workspace's panel kinds. A template asking for others is said before it opens, and Load asks again. */ kinds?: Iterable<string> /** The template that is open, marked in the list. */ activeId?: string | null /** Given a template's JSON text when its Export is pressed. No button without it. */ onExport?: (text: string, template: LayoutTemplate) => void /** Reset to default: seed the workspace again. No button without it. */ onReset?: () => void /** The clock, for tests. */ now?: () => number labels?: Partial<LayoutManagerLabels> className?: string } export function LayoutManager({ templates, onTemplatesChange, current = null, onLoad, kinds, activeId = null, onExport, onReset, now, labels: labelsProp, className }: LayoutManagerProps) { const labels = { ...DEFAULT_LAYOUT_MANAGER_LABELS, ...labelsProp } const clock = now ?? Date.now const known = [...(kinds ?? [])] const [saveName, setSaveName] = useState("") const [renaming, setRenaming] = useState<{ id: string; name: string } | null>(null) // The ask-again: the id whose destructive or doubtful action was pressed once. Any other press withdraws it. const [asking, setAsking] = useState<{ id: string; action: "delete" | "load" } | null>(null) const [importing, setImporting] = useState(false) const [importText, setImportText] = useState("") const [importName, setImportName] = useState("") const [importProblem, setImportProblem] = useState<string | null>(null) const latest = useRef({ onTemplatesChange, onLoad, onExport, onReset }) useEffect(() => { latest.current = { onTemplatesChange, onLoad, onExport, onReset } }) const change = (next: LayoutTemplate[]) => latest.current.onTemplatesChange(next) const nameTaken = templates.some((t) => t.name === saveName.trim()) const save = () => { if (!current || !saveName.trim()) return change(saveTemplate(templates, saveName, current, clock())) setSaveName("") } const load = (template: LayoutTemplate) => { const missing = known.length ? unknownPanelKinds(template.layout, known) : [] if (missing.length && !(asking?.id === template.id && asking.action === "load")) { setAsking({ id: template.id, action: "load" }) return } setAsking(null) latest.current.onLoad(template.layout, template) } const remove = (template: LayoutTemplate) => { if (!(asking?.id === template.id && asking.action === "delete")) { setAsking({ id: template.id, action: "delete" }) return } setAsking(null) change(deleteTemplate(templates, template.id)) } const commitRename = () => { if (!renaming) return change(renameTemplate(templates, renaming.id, renaming.name)) setRenaming(null) } const add = () => { const next = importTemplate(templates, importText, importName.trim() || `Imported ${localTime(clock())}`, clock()) if (!next) return setImportProblem(labels.notALayout) change(next) setImportText("") setImportName("") setImportProblem(null) setImporting(false) } return ( <section data-slot="tradecn-layout-manager" aria-label={labels.title} className={cn("flex flex-col gap-2 text-xs lining-nums tabular-nums", className)}> <div className="flex items-center gap-1"> <Input value={saveName} aria-label={labels.saveName} placeholder={labels.saveName} autoComplete="off" spellCheck={false} className="h-7 flex-1 rounded-sm px-2 py-0 text-xs md:text-xs" onChange={(event) => setSaveName(event.target.value)} onKeyDown={(event) => { if (event.key === "Enter") save() }} /> <Button type="button" variant="outline" size="sm" className="h-7 px-2 text-xs" disabled={!current || !saveName.trim()} data-layout-save="" onClick={save}> {labels.save} </Button> {onReset && ( <Button type="button" variant="ghost" size="sm" className="h-7 px-2 text-xs" data-layout-reset="" onClick={() => latest.current.onReset?.()}> {labels.reset} </Button> )} <Button type="button" variant="ghost" size="sm" className="h-7 px-2 text-xs" aria-expanded={importing} data-layout-import="" onClick={() => setImporting((v) => !v)}> {labels.import} </Button> </div> {nameTaken && ( <p data-layout-taken="" className="text-muted-foreground"> {fill(labels.taken, { name: saveName.trim() })} </p> )} {importing && ( <div className="flex flex-col gap-1 rounded-md border border-border p-2"> <textarea value={importText} aria-label={labels.pasteLayout} placeholder={labels.pasteLayout} spellCheck={false} rows={4} className="w-full rounded-sm border border-input bg-background px-2 py-1 font-(family-name:--tradecn-font-mono) text-xs outline-none focus-visible:ring-2 focus-visible:ring-ring/40" onChange={(event) => { setImportText(event.target.value) setImportProblem(null) }} /> <div className="flex items-center gap-1"> <Input value={importName} aria-label={labels.saveName} placeholder={labels.saveName} autoComplete="off" spellCheck={false} className="h-7 flex-1 rounded-sm px-2 py-0 text-xs md:text-xs" onChange={(event) => setImportName(event.target.value)} /> <Button type="button" variant="outline" size="sm" className="h-7 px-2 text-xs" disabled={!importText.trim()} data-layout-add="" onClick={add}> {labels.add} </Button> </div> {importProblem && ( <p role="alert" data-layout-import-problem="" className="text-destructive"> {importProblem} </p> )} </div> )} {templates.length === 0 ? ( <p className="text-muted-foreground">{labels.empty}</p> ) : ( <ul className="flex flex-col divide-y divide-border rounded-md border border-border"> {templates.map((template) => { const missing = known.length ? unknownPanelKinds(template.layout, known) : [] const count = Object.keys(template.layout.panels).length const active = template.id === activeId const askingHere = asking?.id === template.id ? asking.action : null return ( <li key={template.id} data-layout-template={template.id} data-active={active || undefined} data-unknown-kinds={missing.length || undefined} className="flex flex-wrap items-center gap-x-2 gap-y-1 px-2 py-1.5"> {renaming?.id === template.id ? ( <Input value={renaming.name} aria-label={`${labels.rename}: ${template.name}`} autoFocus autoComplete="off" spellCheck={false} className="h-6 w-40 rounded-sm px-1.5 py-0 text-xs md:text-xs" onChange={(event) => setRenaming({ id: template.id, name: event.target.value })} onBlur={commitRename} onKeyDown={(event) => { if (event.key === "Enter") commitRename() if (event.key === "Escape") setRenaming(null) }} /> ) : ( <span className="min-w-0 truncate font-medium" data-layout-name=""> {template.name} </span> )} {active && ( <Badge variant="outline" className="h-4 px-1 text-xs" data-layout-active=""> {labels.active} </Badge> )} <span className="text-muted-foreground" data-layout-panels={count}> {fill(labels.panels, { n: String(count) })} </span> {template.savedAt > 0 && ( <time dateTime={new Date(template.savedAt).toISOString()} className="text-muted-foreground"> {localTime(template.savedAt)} </time> )} {missing.length > 0 && ( <Badge variant="outline" className="h-4 px-1 text-xs text-stale" data-layout-unknown={missing.join(" ")}> {fill(labels.unknownKinds, { kinds: missing.join(", ") })} </Badge> )} <span className="ml-auto flex items-center gap-0.5"> <Button type="button" variant={askingHere === "load" ? "default" : "outline"} size="sm" className="h-6 px-2 text-xs" data-layout-load="" onClick={() => load(template)}> {askingHere === "load" ? labels.loadAnyway : labels.load} </Button> <Button type="button" variant="ghost" size="sm" className="h-6 px-2 text-xs" aria-label={`${labels.rename}: ${template.name}`} onClick={() => setRenaming({ id: template.id, name: template.name })}> {labels.rename} </Button> <Button type="button" variant="ghost" size="sm" className="h-6 px-2 text-xs" aria-label={`${labels.duplicate}: ${template.name}`} onClick={() => change(duplicateTemplate(templates, template.id, clock(), undefined, labels))}> {labels.duplicate} </Button> {onExport && ( <Button type="button" variant="ghost" size="sm" className="h-6 px-2 text-xs" aria-label={`${labels.export}: ${template.name}`} onClick={() => latest.current.onExport?.(exportTemplate(template), template)}> {labels.export} </Button> )} <Button type="button" variant={askingHere === "delete" ? "destructive" : "ghost"} size="sm" className="h-6 px-2 text-xs" aria-label={`${askingHere === "delete" ? labels.deleteAnyway : labels.delete}: ${template.name}`} data-layout-delete={askingHere === "delete" ? "asking" : ""} onClick={() => remove(template)}> {askingHere === "delete" ? labels.deleteAnyway : labels.delete} </Button> </span> </li> ) })} </ul> )} </section> ) }lib/workspace-layout.ts // What a workspace saves, and what it leaves out on purpose. // // A layout is the dock arrangement plus one small record per panel: its kind, its title, and the // JSON the panel asked to keep, such as a symbol, a link group, or a view setting. It is meant to be // reused: saved as a template, handed to a colleague, restored next quarter. That only works when // nothing tied to a session is in it, so the boundaries travel in the payload and say, as data, what // the writer put in and what it kept out. // // Storage is the consumer's. Nothing here touches localStorage. A stored layout comes back through // `parseWorkspaceLayout`, which takes nothing on trust. export const WORKSPACE_LAYOUT_KIND = "tradecn-workspace" export const WORKSPACE_LAYOUT_VERSION = 1 export const WORKSPACE_PERSISTENCE_BOUNDARIES = { /** In the layout. `onLayoutChange` hands over a fresh one when any of these changes. */ autosave: ["dock-arrangement", "floating-and-popout-positions", "panel-kinds", "panel-titles", "panel-state"], /** Belongs to one workspace and is not in the layout. Store it beside the layout, keyed by panel id. */ workspaceScoped: ["grid-column-state", "selection", "scroll-position", "ticket-drafts"], /** Belongs to the person, whichever workspace is open. */ globalScoped: ["hotkey-remaps", "palette-recents", "theme", "instrument-conventions"], /** Stored by nobody. It comes from the server every time. */ excluded: ["market-data", "orders-and-status", "positions", "feed-health", "link-group-symbols"], } as const export type WorkspaceBoundary = keyof typeof WORKSPACE_PERSISTENCE_BOUNDARIES export type WorkspacePersistenceBoundaries = { readonly [K in WorkspaceBoundary]: readonly string[] } const BOUNDARY_KEYS = Object.keys(WORKSPACE_PERSISTENCE_BOUNDARIES) as WorkspaceBoundary[] export type WorkspaceJson = string | number | boolean | null | WorkspaceJson[] | { [key: string]: WorkspaceJson } /** What a panel keeps across a reload. Small and JSON: a symbol, a link group, a view setting. */ export type WorkspacePanelState = { [key: string]: WorkspaceJson } export interface WorkspacePanelRecord { /** Which of the workspace's `panels` draws it, and the hotkey scope `panel:<kind>`. */ kind: string title: string state: WorkspacePanelState } export interface WorkspaceLayout { version: typeof WORKSPACE_LAYOUT_VERSION kind: typeof WORKSPACE_LAYOUT_KIND /** The dock's own serialized form. Opaque here: read `panels` instead. */ dockview: WorkspaceDockLayout /** One record per panel id in `dockview`. */ panels: Record<string, WorkspacePanelRecord> /** What the writer included and what it kept out, at the time it wrote. */ boundaries: WorkspacePersistenceBoundaries } /** As much of the dock's format as this file checks. */ export interface WorkspaceDockLayout { grid: { root: unknown; [key: string]: unknown } panels: Record<string, unknown> [key: string]: unknown } function isObject(value: unknown): value is Record<string, unknown> { return typeof value === "object" && value !== null && !Array.isArray(value) } /** The value as JSON would store it: functions and undefined are gone, and anything that cannot be stored is an empty state. */ export function toPanelState(value: unknown): WorkspacePanelState { if (!isObject(value)) return {} try { const stored: unknown = JSON.parse(JSON.stringify(value)) return isObject(stored) ? (stored as WorkspacePanelState) : {} } catch { return {} } } function readBoundaries(value: unknown): WorkspacePersistenceBoundaries { if (!isObject(value)) return WORKSPACE_PERSISTENCE_BOUNDARIES const out = {} as Record<WorkspaceBoundary, readonly string[]> for (const key of BOUNDARY_KEYS) { const list = value[key] if (!Array.isArray(list) || !list.every((entry) => typeof entry === "string")) return WORKSPACE_PERSISTENCE_BOUNDARIES out[key] = [...(list as string[])] } return out } /** * A stored layout, checked. Takes the object or the JSON text of one, and returns null for anything * that is not a version 1 tradecn workspace whose every docked panel has a record saying what kind * it is. A record with no panel in the dock is dropped. What comes back is a copy. */ export function parseWorkspaceLayout(value: unknown): WorkspaceLayout | null { let raw = value if (typeof raw === "string") { try { raw = JSON.parse(raw) } catch { return null } } if (!isObject(raw) || raw.kind !== WORKSPACE_LAYOUT_KIND || raw.version !== WORKSPACE_LAYOUT_VERSION) return null const dock = raw.dockview if (!isObject(dock) || !isObject(dock.grid) || !("root" in dock.grid) || !isObject(dock.panels) || !isObject(raw.panels)) return null const panels: Record<string, WorkspacePanelRecord> = {} for (const id of Object.keys(dock.panels)) { const record = raw.panels[id] if (!isObject(record) || typeof record.kind !== "string" || !record.kind) return null panels[id] = { kind: record.kind, title: typeof record.title === "string" && record.title ? record.title : record.kind, state: toPanelState(record.state) } } let dockview: WorkspaceDockLayout try { dockview = JSON.parse(JSON.stringify(dock)) as WorkspaceDockLayout } catch { return null } return { version: WORKSPACE_LAYOUT_VERSION, kind: WORKSPACE_LAYOUT_KIND, dockview, panels, boundaries: readBoundaries(raw.boundaries) } } /** The kinds a layout asks for that are not among `kinds`. The workspace draws a placeholder for each; this is for saying so up front. */ export function unknownPanelKinds(layout: WorkspaceLayout, kinds: Iterable<string>): string[] { const known = new Set(kinds) return [...new Set(Object.values(layout.panels).map((record) => record.kind))].filter((kind) => !known.has(kind)).sort() } /** `book-1`, then `book-2`: the lowest number not taken. */ export function nextPanelId(kind: string, taken: Iterable<string>): string { const used = new Set(taken) let n = 1 while (used.has(`${kind}-${n}`)) n += 1 return `${kind}-${n}` } export type WorkspacePanelStatePatch = { [key: string]: WorkspaceJson | undefined } export interface WorkspacePanelStore { /** The same object until this panel changes, so it is safe as a `useSyncExternalStore` snapshot. */ get(id: string): WorkspacePanelRecord | undefined /** Adds the panel, or replaces its record. */ set(id: string, record: WorkspacePanelRecord): void setTitle(id: string, title: string): void /** Merges a patch into the panel's state. A key set to undefined is removed. A patch that changes nothing tells nobody. */ setState(id: string, patch: WorkspacePanelStatePatch | ((state: WorkspacePanelState) => WorkspacePanelStatePatch)): void delete(id: string): void /** Every record at once, for a layout being loaded. */ replace(records: Record<string, WorkspacePanelRecord>): void ids(): string[] /** Copies, for writing out. */ snapshot(): Record<string, WorkspacePanelRecord> subscribe(cb: () => void): () => void } export function createWorkspacePanelStore(): WorkspacePanelStore { let records = new Map<string, WorkspacePanelRecord>() const listeners = new Set<() => void>() const emit = () => { for (const cb of [...listeners]) cb() } const clean = (record: WorkspacePanelRecord): WorkspacePanelRecord => ({ kind: record.kind, title: record.title || record.kind, state: toPanelState(record.state) }) return { get: (id) => records.get(id), set(id, record) { records.set(id, clean(record)) emit() }, setTitle(id, title) { const record = records.get(id) if (!record || !title || record.title === title) return records.set(id, { ...record, title }) emit() }, setState(id, patch) { const record = records.get(id) if (!record) return const next = toPanelState({ ...record.state, ...(typeof patch === "function" ? patch(record.state) : patch) }) if (JSON.stringify(next) === JSON.stringify(record.state)) return records.set(id, { ...record, state: next }) emit() }, delete(id) { if (records.delete(id)) emit() }, replace(next) { records = new Map(Object.entries(next).map(([id, record]) => [id, clean(record)])) emit() }, ids: () => [...records.keys()], snapshot: () => Object.fromEntries([...records].map(([id, record]) => [id, { ...record, state: toPanelState(record.state) }])), subscribe(cb) { listeners.add(cb) return () => { listeners.delete(cb) } }, } } export interface Debounced { (): void /** Run now if a run is waiting. */ flush(): void cancel(): void pending(): boolean } /** One call `ms` after the last of a burst: a sash drag reports a change on every pointer move. */ export function debounce(fn: () => void, ms: number): Debounced { let timer: ReturnType<typeof setTimeout> | null = null const run = () => { timer = null fn() } const debounced = (() => { if (timer) clearTimeout(timer) timer = setTimeout(run, ms) }) as Debounced debounced.flush = () => { if (!timer) return clearTimeout(timer) run() } debounced.cancel = () => { if (timer) clearTimeout(timer) timer = null } debounced.pending = () => timer !== null return debounced }lib/preferences.ts // The envelope everything a trader or a desk may change without a build travels in: a workspace // layout, a grid's column state, hotkey overrides, rules, a threshold. Each is a slot with a version // and a JSON value, and the envelope says in its payload which slots may leave as a desk template, // which belong to one person, and which never leave a session, in the language the workspace layout's // boundaries already use. The items keep their controlled props; the consumer wires a slot to a prop. // This is not a store and not a provider, and where the envelope lives (localStorage, a file in a // desktop shell, a server) is the consumer's. Nothing here touches storage, and a stored envelope comes // back through `parsePreferences`, which takes nothing on trust. export const PREFERENCES_MARK = "preferences" export const PREFERENCES_VERSION = 1 export type PreferencesJson = string | number | boolean | null | PreferencesJson[] | { [key: string]: PreferencesJson } /** A slot: the shape's version, as the item that owns the shape numbers it, and the value as JSON. */ export interface PreferenceSlot { version: number value: PreferencesJson } /** * Where a slot may go. `template`: shareable as a desk template, handed to a colleague, restored next * quarter. `user`: belongs to the person, whichever desk they sit at. `session`: never leaves a * session, so an export drops it whatever else it keeps. */ export type PreferenceBoundary = "template" | "user" | "session" export type PreferenceBoundaries = { readonly [K in PreferenceBoundary]: readonly string[] } export const PREFERENCE_BOUNDARIES: readonly PreferenceBoundary[] = ["template", "user", "session"] /** A slot named under no boundary is the person's. */ export const DEFAULT_BOUNDARY: PreferenceBoundary = "user" export interface Preferences { tradecn: typeof PREFERENCES_MARK version: typeof PREFERENCES_VERSION slots: Record<string, PreferenceSlot> /** Which slots go where, by name, at the time the envelope was written. */ boundaries: PreferenceBoundaries } /** How a consumer brings an older slot up to the shape it reads today. */ export interface PreferenceMigrator { /** The version the consumer's code reads. */ version: number /** The value at `from`, returned at `version`; null drops the slot. Called only for a slot older than `version`. */ migrate: (value: PreferencesJson, from: number) => PreferencesJson | null } export type PreferenceMigrators = Record<string, PreferenceMigrator> function isObject(value: unknown): value is Record<string, unknown> { return typeof value === "object" && value !== null && !Array.isArray(value) } /** The value as JSON stores it: functions and undefined gone, a copy, or null when it cannot be stored. */ export function toJson(value: unknown): PreferencesJson | null { if (value === undefined) return null try { const stored: unknown = JSON.parse(JSON.stringify(value)) return stored === undefined ? null : (stored as PreferencesJson) } catch { return null } } function sameJson(a: unknown, b: unknown): boolean { return JSON.stringify(a) === JSON.stringify(b) } function readBoundaries(value: unknown): PreferenceBoundaries { const out: Record<PreferenceBoundary, string[]> = { template: [], user: [], session: [] } if (!isObject(value)) return out for (const key of PREFERENCE_BOUNDARIES) { const list = value[key] if (Array.isArray(list)) out[key] = list.filter((entry): entry is string => typeof entry === "string") } return out } /** An empty envelope, with the boundaries you name. */ export function createPreferences(boundaries: Partial<{ [K in PreferenceBoundary]: readonly string[] }> = {}): Preferences { return { tradecn: PREFERENCES_MARK, version: PREFERENCES_VERSION, slots: {}, boundaries: { template: [...(boundaries.template ?? [])], user: [...(boundaries.user ?? [])], session: [...(boundaries.session ?? [])] }, } } /** * A stored envelope, checked. Takes the object or the JSON text of one, and returns null for anything * that is not a version 1 tradecn preferences envelope. A slot that is not an object with a whole-number * version and a JSON value is dropped, the rest are kept, and what comes back is a copy. */ export function parsePreferences(value: unknown): Preferences | null { let raw = value if (typeof raw === "string") { try { raw = JSON.parse(raw) } catch { return null } } if (!isObject(raw) || raw.tradecn !== PREFERENCES_MARK || raw.version !== PREFERENCES_VERSION || !isObject(raw.slots)) return null const slots: Record<string, PreferenceSlot> = {} for (const [name, slot] of Object.entries(raw.slots)) { if (!isObject(slot) || typeof slot.version !== "number" || !Number.isInteger(slot.version) || slot.version < 0 || !("value" in slot)) continue const stored = toJson(slot.value) if (stored === null && slot.value !== null) continue slots[name] = { version: slot.version, value: stored } } return { tradecn: PREFERENCES_MARK, version: PREFERENCES_VERSION, slots, boundaries: readBoundaries(raw.boundaries) } } /** The slot, or undefined. */ export function getSlot(prefs: Preferences, name: string): PreferenceSlot | undefined { return prefs.slots[name] } /** * The envelope with the slot set. The value is stored as JSON; `version` is the given one, else the * slot's own, else 1. A value and version that change nothing hand back the same envelope, so a * subscriber or an autosave comparing by identity sees no change. */ export function setSlot(prefs: Preferences, name: string, value: unknown, version?: number): Preferences { const stored = toJson(value) if (stored === null && value !== null) return prefs const current = prefs.slots[name] const next: PreferenceSlot = { version: version ?? current?.version ?? 1, value: stored } if (current && current.version === next.version && sameJson(current.value, next.value)) return prefs return { ...prefs, slots: { ...prefs.slots, [name]: next } } } /** The envelope without the slot; the same envelope when there was none. */ export function removeSlot(prefs: Preferences, name: string): Preferences { if (!(name in prefs.slots)) return prefs const slots = { ...prefs.slots } delete slots[name] return { ...prefs, slots } } /** The boundary a slot is named under, `user` when it is under none. */ export function boundaryOf(prefs: Preferences, name: string): PreferenceBoundary { for (const key of PREFERENCE_BOUNDARIES) if (prefs.boundaries[key].includes(name)) return key return DEFAULT_BOUNDARY } /** The envelope with the slot named under one boundary and no other. */ export function withBoundary(prefs: Preferences, name: string, boundary: PreferenceBoundary): Preferences { if (boundaryOf(prefs, name) === boundary && prefs.boundaries[boundary].includes(name)) return prefs const boundaries = { template: [...prefs.boundaries.template], user: [...prefs.boundaries.user], session: [...prefs.boundaries.session] } for (const key of PREFERENCE_BOUNDARIES) boundaries[key] = boundaries[key].filter((entry) => entry !== name) boundaries[boundary].push(name) return { ...prefs, boundaries } } export interface PreferencesDiff { /** In `b` and not in `a`. */ added: string[] /** In `a` and not in `b`. */ removed: string[] /** In both with a different version or value. */ changed: string[] /** In both and the same. */ same: string[] } /** Which slots differ between two envelopes, by name, for a support view or a save prompt. */ export function diffPreferences(a: Preferences, b: Preferences): PreferencesDiff { const out: PreferencesDiff = { added: [], removed: [], changed: [], same: [] } const names = new Set([...Object.keys(a.slots), ...Object.keys(b.slots)]) for (const name of [...names].sort()) { const x = a.slots[name] const y = b.slots[name] if (x && !y) out.removed.push(name) else if (!x && y) out.added.push(name) else if (x && y) (x.version === y.version && sameJson(x.value, y.value) ? out.same : out.changed).push(name) } return out } /** The slots allowed out under a boundary: a template carries `template` slots only; the person's export carries `template` and `user`; nothing carries `session`. */ export function exportableSlots(prefs: Preferences, boundary: "template" | "user" = "user"): string[] { return Object.keys(prefs.slots).filter((name) => { const where = boundaryOf(prefs, name) return where === "template" || (boundary === "user" && where === "user") }) } /** * The envelope as JSON text, for a file or a clipboard. `template` keeps the slots shareable as a desk * template; `user` (the default) keeps those and the person's own. A `session` slot never goes out. * The boundaries travel with it, so the reader knows what it holds. */ export function exportPreferences(prefs: Preferences, options: { boundary?: "template" | "user"; indent?: number } = {}): string { const keep = new Set(exportableSlots(prefs, options.boundary ?? "user")) const slots = Object.fromEntries(Object.entries(prefs.slots).filter(([name]) => keep.has(name))) const out: Preferences = { tradecn: PREFERENCES_MARK, version: PREFERENCES_VERSION, slots, boundaries: prefs.boundaries } return JSON.stringify(out, null, options.indent ?? 2) } /** * Another envelope's slots merged into this one: the incoming slot wins where both have one, under the * boundary allowed in (`user` by default, `template` for a desk template), and never a `session` slot. * The boundaries the incoming envelope names for the slots it brings are taken; the rest stay. Null when * the text is not an envelope. */ export function importPreferences(target: Preferences, incoming: unknown, options: { boundary?: "template" | "user" } = {}): Preferences | null { const parsed = parsePreferences(incoming) if (!parsed) return null const allowed = new Set(exportableSlots(parsed, options.boundary ?? "user")) let out = target for (const [name, slot] of Object.entries(parsed.slots)) { if (!allowed.has(name)) continue out = setSlot(out, name, slot.value, slot.version) out = withBoundary(out, name, boundaryOf(parsed, name)) } return out } /** * Every slot brought up to the version its migrator reads: a slot older than `migrator.version` goes * through `migrate`, one it returns null for is dropped, a slot with no migrator or already current is * left alone, and a slot newer than the migrator's version is left alone too, since a newer build wrote * it. The same envelope comes back when nothing moved. */ export function migratePreferences(prefs: Preferences, migrators: PreferenceMigrators): Preferences { let out = prefs for (const [name, migrator] of Object.entries(migrators)) { const slot = prefs.slots[name] if (!slot || slot.version >= migrator.version) continue const value = migrator.migrate(slot.value, slot.version) out = value === null ? removeSlot(out, name) : setSlot(out, name, value, migrator.version) } return out } /** * One slot's value at the version the consumer reads, through its migrator when it is older, or * undefined when the slot is missing, newer than the migrator reads, or dropped by the migration. The * envelope itself is not changed; call `migratePreferences` to write the result back. */ export function readSlot<V = PreferencesJson>(prefs: Preferences, name: string, migrator?: PreferenceMigrator): V | undefined { const slot = prefs.slots[name] if (!slot) return undefined if (!migrator) return slot.value as V if (slot.version > migrator.version) return undefined if (slot.version === migrator.version) return slot.value as V const value = migrator.migrate(slot.value, slot.version) return value === null ? undefined : (value as V) } -
Add the tokens to your stylesheet:
:root { --stale: oklch(0.52 0.13 75); --tradecn-font-accessible: 'Atkinson Hyperlegible Next', 'Atkinson Hyperlegible', ui-sans-serif, system-ui, sans-serif; --tradecn-font-accessible-mono: 'Atkinson Hyperlegible Mono', ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; --tradecn-font-mono: 'JetBrains Mono', ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; } .dark { --stale: oklch(0.8 0.15 80); --tradecn-font-accessible: 'Atkinson Hyperlegible Next', 'Atkinson Hyperlegible', ui-sans-serif, system-ui, sans-serif; --tradecn-font-accessible-mono: 'Atkinson Hyperlegible Mono', ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; --tradecn-font-mono: 'JetBrains Mono', ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; } -
Append this to your stylesheet:
:root[data-accessibility="hyperlegible"], [data-accessibility="hyperlegible"] { --tradecn-font-sans: var(--tradecn-font-accessible); --tradecn-font-mono: var(--tradecn-font-accessible-mono); }
Usage
import { useState } from "react"
import { WORKSPACE_PERSISTENCE_BOUNDARIES, type WorkspaceLayout } from "@/lib/workspace-layout"
import { LayoutManager, type LayoutTemplate } from "@/components/ui/layout-manager"
// A snapshot captured from a workspace with one Book panel.
const BOOK: WorkspaceLayout = {
version: 1,
kind: "tradecn-workspace",
dockview: {
grid: {
root: { type: "branch", data: [{ type: "leaf", data: { views: ["book-1"], activeView: "book-1", id: "1" }, size: 478 }], size: 115 },
width: 478, height: 115, orientation: "HORIZONTAL",
},
panels: { "book-1": { id: "book-1", contentComponent: "tradecn-panel", tabComponent: "props.defaultTabComponent", title: "Book" } },
activeGroup: "1",
},
panels: { "book-1": { kind: "book", title: "Book", state: { symbol: "ZN" } } },
boundaries: WORKSPACE_PERSISTENCE_BOUNDARIES,
}
function SavedLayouts() {
const [templates, setTemplates] = useState<LayoutTemplate[]>([{ id: "t-1", name: "Treasury book", layout: BOOK, savedAt: Date.parse("2026-09-22T14:00:00Z") }])
const [current, setCurrent] = useState(BOOK)
const [activeId, setActiveId] = useState<string | null>(null)
const [message, setMessage] = useState("Current snapshot: one ZN book.")
return (
<div className="w-[36rem] max-w-full space-y-3 text-xs lining-nums tabular-nums">
<div role="region" aria-label="Layout controls" tabIndex={0} className="overflow-x-auto">
<LayoutManager
className="min-w-96"
templates={templates}
onTemplatesChange={(next) => {
const previous = templates.find((template) => template.id === activeId)
const active = next.find((template) => template.id === activeId)
const layout = JSON.stringify(active?.layout)
if (!active || (layout !== JSON.stringify(previous?.layout) && layout !== JSON.stringify(current))) setActiveId(null)
setTemplates(next)
}}
current={current}
kinds={["book"]}
activeId={activeId}
onLoad={(layout, template) => {
setCurrent(layout)
setActiveId(template.id)
setMessage(`Selected ${template.name}.`)
}}
/>
</div>
<p role="status" className="text-muted-foreground">{message}</p>
</div>
)
}
Start with a controlled list and the current layout. This example uses a real one-panel snapshot, so save, rename, duplicate, delete, and import work without mounting a workspace. Load selects a snapshot in local state and reports its name. The workspace example below restores the panels themselves.
Give a new name to Save current, or use an existing name to replace that template. The loaded marker survives renames and saves of the current snapshot; replacing its layout through import clears the marker unless the JSON matches the previous or current snapshot. The opening template has a fixed sample date; new saves use the current time. At narrow widths, the manager's controls scroll horizontally.
Saving and restoring a workspace
Install workspace as well; it supplies the Workspace and Panel components used here. Change a symbol or add the equities book, then save a named layout. Change the workspace again and load that template to restore its arrangement and panel state. Reset to default restores the initial ZN book and keeps the saved list.
The template list is written to this browser under tradecn-layout-manager-example, in a preferences slot assigned to the template boundary. Reloading restores the list; choose Load to restore a workspace. A storage read failure or invalid preferences envelope starts an empty list with a status message. A failed write keeps edits in memory and reports that they were not saved. This is local example storage; your application chooses its own storage.
Export shows the layout JSON in a read-only field. Copy it into Import and give it a name to add or replace a template; import does not load it. The example takes a fresh current snapshot on ready, load, and reset, while ordinary workspace edits arrive through onLayoutChange.
import { useState } from "react"
import { createPreferences, parsePreferences, withBoundary } from "@/lib/preferences"
import type { WorkspaceLayout } from "@/lib/workspace-layout"
import { LayoutManager, readLayoutTemplates, writeLayoutTemplates } from "@/components/ui/layout-manager"
import { PanelContent } from "@/components/ui/panel"
import { Workspace, useWorkspacePanel, type WorkspaceApi } from "@/components/ui/workspace"
const STORAGE_KEY = "tradecn-layout-manager-example"
function Book() {
const panel = useWorkspacePanel()
return (
<PanelContent className="p-3">
<label className="flex flex-col gap-2 text-xs">
Symbol for {panel.title}
<input className="min-w-0 rounded border border-input bg-background px-2 py-1" value={String(panel.state.symbol ?? "")} onChange={(event) => panel.setState({ symbol: event.target.value })} />
</label>
</PanelContent>
)
}
const PANELS = { book: Book }
function seed(api: WorkspaceApi) {
api.addPanel({ kind: "book", id: "book-1", title: "Treasuries", state: { symbol: "ZN" }, focus: false })
}
export default function LayoutManagerWorkspaceDemo() {
const [api, setApi] = useState<WorkspaceApi | null>(null)
const [current, setCurrent] = useState<WorkspaceLayout | null>(null)
const [activeId, setActiveId] = useState<string | null>(null)
const [exported, setExported] = useState("")
const [initial] = useState(() => {
const empty = createPreferences({ template: ["layouts"] })
try {
const stored = localStorage.getItem(STORAGE_KEY)
if (stored === null) return { prefs: empty, message: "Save a template to keep it in this browser." }
const prefs = parsePreferences(stored)
return prefs
? { prefs: withBoundary(prefs, "layouts", "template"), message: "Saved template list restored." }
: { prefs: empty, message: "Stored templates could not be read. Starting with an empty list." }
} catch {
return { prefs: empty, message: "Browser storage could not be read. Starting with an empty template list." }
}
})
const [prefs, setPrefs] = useState(initial.prefs)
const [message, setMessage] = useState(initial.message)
const templates = readLayoutTemplates(prefs)
return (
<>
<div data-demo-controls className="flex flex-wrap gap-2 text-xs">
<button type="button" className="rounded border px-2 py-1 disabled:opacity-50" disabled={!api} onClick={() => {
if (!api) return
api.addPanel({ kind: "book", id: "equities", title: "Equities", state: { symbol: "ES" }, position: { direction: "right" }, focus: false })
setCurrent(api.toLayout())
}}>Add equities book</button>
</div>
<div className="w-[42rem] max-w-full space-y-3 text-xs lining-nums tabular-nums">
<Workspace
className="h-56 rounded-md border border-border"
panels={PANELS}
seed={seed}
onReady={(next) => { setApi(next); setCurrent(next.toLayout()) }}
onLayoutChange={setCurrent}
onLayoutError={() => setMessage("Could not capture or restore the workspace.")}
watermark="No panels. Reset to default to start again."
/>
<div role="region" aria-label="Workspace layout controls" tabIndex={0} className="overflow-x-auto">
<LayoutManager
className="min-w-[32rem]"
templates={templates}
onTemplatesChange={(nextTemplates) => {
const previous = templates.find((template) => template.id === activeId)
const active = nextTemplates.find((template) => template.id === activeId)
const layout = JSON.stringify(active?.layout)
if (!active || (layout !== JSON.stringify(previous?.layout) && layout !== JSON.stringify(current))) setActiveId(null)
const next = writeLayoutTemplates(prefs, nextTemplates)
setPrefs(next)
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(next))
setMessage("Template list saved in this browser.")
} catch {
setMessage("Template list changed, but browser storage is unavailable.")
}
}}
current={current}
kinds={Object.keys(PANELS)}
activeId={activeId}
onLoad={(layout, template) => {
if (!api) return
const loaded = api.load(layout)
setCurrent(api.toLayout())
setActiveId(loaded ? template.id : null)
setMessage(loaded ? `Loaded ${template.name}.` : `Could not load ${template.name}.`)
}}
onReset={() => {
if (!api) return
api.clear()
seed(api)
setCurrent(api.toLayout())
setActiveId(null)
setMessage("Default workspace restored. Saved templates are unchanged.")
}}
onExport={(text, template) => { setExported(text); setMessage(`Exported ${template.name}.`) }}
/>
</div>
<p role="status" className="text-muted-foreground">{message}</p>
{exported && <label className="flex flex-col gap-2">Exported layout JSON<textarea readOnly value={exported} rows={5} className="w-full rounded border border-input bg-background p-2 font-(family-name:--tradecn-font-mono) text-xs" /></label>}
</div>
</>
)
}
API Reference
Props
LayoutManagerProps accepts these inputs:
| Prop | Type | Default | Purpose |
|---|---|---|---|
templates |
readonly LayoutTemplate[] |
Required | Controlled template list. |
onTemplatesChange |
(templates: LayoutTemplate[]) => void |
Required | Receives the whole list after an edit. |
current |
WorkspaceLayout | null |
null |
Layout captured by Save current. |
onLoad |
(layout: WorkspaceLayout, template: LayoutTemplate) => void |
Required | Requests a workspace load. |
kinds |
Iterable<string> |
Omitted | Nonempty known kinds enable missing-kind warnings and load confirmation. |
activeId |
string | null |
null |
Marks a row as loaded; the caller updates it. |
onExport |
(text: string, template: LayoutTemplate) => void |
Omitted | Enables each row's Export button. |
onReset |
() => void |
Omitted | Enables Reset to default. |
now |
() => number |
Date.now |
Clock in milliseconds since the epoch. |
labels |
Partial<LayoutManagerLabels> |
DEFAULT_LAYOUT_MANAGER_LABELS |
Label overrides. |
className |
string |
Omitted | Outer region classes. |
Template fields
All LayoutTemplate fields are required. Keep ids unique when supplying your own list.
| Field | Type | Purpose |
|---|---|---|
id |
string |
Row identity for actions and activeId. Generated ids use the lowest available t-1, t-2, and so on. |
name |
string |
Display name and save/import replacement key. |
layout |
WorkspaceLayout |
Workspace arrangement and panel records. |
savedAt |
number |
Milliseconds since the epoch. Positive values display a date/time in the browser's locale and time zone. |
The list is yours
workspace emits layouts through onLayoutChange and stores nothing. Accept each new list from onTemplatesChange into templates; the manager keeps only transient form and confirmation state.
readLayoutTemplates and writeLayoutTemplates use a preferences slot named layouts by default. Writing returns an envelope with slot version 1 and value { version: 1, templates }; it neither persists the envelope nor assigns a boundary. Put the slot under template with createPreferences or withBoundary to include it in desk-template exports. An unclassified slot defaults to user.
Preferences exports select whole slots. A layout's boundaries describe its contents; they do not remove fields from a layout export.
Save, load, and the default
Save current and Enter in the save field capture current under the trimmed name. Both require a layout and a nonblank name. Save clears the field after emitting the list.
Names match exactly, including case. A matching name shows a replacement warning; saving replaces the first matching template's layout and timestamp while keeping its id and position. A new name appends a template. Save stores the supplied layout reference, so treat layouts as immutable.
Load calls onLoad(layout, template); call the workspace's api.load there. The manager does not update activeId or verify that loading succeeded. Reset to default calls onReset, where you can clear and reseed the workspace.
Refresh current from api.toLayout() after loading: the workspace does not emit onLayoutChange merely because a layout was restored. The example also snapshots on ready and after reset, so Save captures those changes immediately. Ordinary workspace edits arrive through its debounced onLayoutChange.
A kind the workspace does not have
Pass a nonempty kinds iterable to show a badge for unknown panel kinds, sorted and deduplicated by unknownPanelKinds. The first Load press becomes Load anyway?; the second calls onLoad. The workspace draws placeholders for missing kinds. Omitting kinds or passing an empty iterable skips both the badge and confirmation.
Rename, duplicate, delete
Rename edits inline. Enter or blur commits a trimmed name; Escape cancels. A blank name leaves the template unchanged. Renaming preserves the timestamp and allows a name already in use.
Duplicate inserts a new id and timestamp immediately after the source, named Copy of <name>. It copies a valid layout through parseWorkspaceLayout; if parsing fails, the helper falls back to the source layout reference. Duplicate names are allowed.
Delete becomes Delete? on the first press and removes the row on the second. Delete and missing-kind Load share one pending confirmation: another Delete or warning Load replaces it, and completing a load or delete clears it. Other clicks, including Rename, Duplicate, and clicks outside the manager, do not cancel it.
Import and export
Import toggles fields for a layout's JSON and an optional name. Add requires nonblank JSON and parses it with parseWorkspaceLayout. Invalid input shows an alert and leaves the list unchanged. Editing the JSON clears the alert; success clears and closes the form.
Import uses the same name-replacement rule as Save, but has no taken-name warning or confirmation. A blank name becomes Imported <local date/time>; that generated name can also collide. Import does not load the layout.
Export calls onExport with JSON for the template's layout only, without its id, name, or timestamp. The caller handles the file, clipboard, or message.
Helpers
These helpers are exported from @/components/ui/layout-manager. Here templates means readonly LayoutTemplate[], prefs means Preferences, layout means WorkspaceLayout, and savedAt means a timestamp in milliseconds.
| Helper | Return type | Behavior |
|---|---|---|
parseLayoutTemplates(value: unknown) |
LayoutTemplate[] |
Reads an array or { templates: [...] }, including JSON text. Drops invalid entries and copies accepted layouts. |
readLayoutTemplates(prefs, slot = "layouts") |
LayoutTemplate[] |
Parses the slot's value; a missing slot gives []. |
writeLayoutTemplates(prefs, templates, slot = "layouts") |
Preferences |
Writes a JSON copy at LAYOUT_TEMPLATES_VERSION (1); the default slot is LAYOUT_TEMPLATES_SLOT. |
saveTemplate(templates, name: string, layout, savedAt: number) |
LayoutTemplate[] |
Trims the name, then replaces the first match or appends. Unlike the UI, accepts a blank name. |
renameTemplate(templates, id: string, name: string) |
LayoutTemplate[] |
Renames matching ids; blank names leave entries unchanged. |
duplicateTemplate(templates, id: string, savedAt: number, name?: string, labels = DEFAULT_LAYOUT_MANAGER_LABELS) |
LayoutTemplate[] |
Inserts a copy after the first matching id; a missing id leaves entries unchanged. A nonblank custom name overrides labels.copyOf. |
deleteTemplate(templates, id: string) |
LayoutTemplate[] |
Removes all matching ids. |
exportTemplate(template: LayoutTemplate, indent = 2) |
string |
Serializes template.layout with the given indentation. |
importTemplate(templates, text: string, name: string, savedAt: number) |
LayoutTemplate[] | null |
Parses the layout and calls saveTemplate; returns null for invalid input. |
Parsing requires nonempty string ids/names and layouts accepted by parseWorkspaceLayout. Missing or nonnumeric savedAt becomes 0; ids and names are not deduplicated or trimmed. Neither the wrapper's version nor the preference slot's version is checked by these readers. Invalid JSON or an unsupported list shape returns [].
List-editing helpers return new arrays, retaining unchanged entries. They do not show warnings or request confirmation; custom menus and hotkeys supply that behavior.
Labels
labels overrides the region title, fields, buttons, confirmations, copy name, replacement warning, import error, panel count, unknown-kinds badge, empty message, and loaded badge. taken and copyOf interpolate {name}, panels uses {n}, and unknownKinds uses {kinds}. The generated Imported prefix and date/time formatting are not label overrides.
The title names the outer region. Rows expose data-layout-template=<id> and active rows have data-active="true". Rename, Duplicate, Delete, and Export accessible names include the template name; the import error uses role="alert".
What it does not do
The manager never calls the workspace API or writes storage. Stored/imported layouts pass through parseWorkspaceLayout, while a Load click forwards the supplied template as-is. The workspace's api.load parses again and returns false if parsing or dock restoration fails.
Tokens
The install adds the stale token if you do not have it; the unknown-kinds badge draws from it.
Built on shadcn's badge, button, and input.
This page is docs/layout-manager.md at v1.4.13.