A Board is a typed config created with defineBoard(). The default
export is a React component the engine mounts inside
<BoardRegistry>. The engine ships two Layouts — gallery and
tree — and a defineLayout() escape hatch for custom
arrangements. Boards live in app/forkshop/ in your project.
defineBoard()
import { defineBoard, forkshopIcons } from "@forkshop/engine"
export default defineBoard({
id: "ui-components",
label: "UI Components",
icon: forkshopIcons.components,
match: (s) => s.kind === "section" && s.sectionId === "ui-components",
layout: "gallery",
layoutOptions: { columns: 3, rowGap: 24, columnGap: 24 },
useEntries: () => {
const primitives = useDiscoveredPrimitives(forkshopConfig.ui ?? {})
return primitives.map((p) => ({
id: p.slug,
label: p.name,
node: {
id: `primitive:${p.slug}`,
kind: "inline-react" as const,
x: 0, y: 0, width: 320, height: 200,
render: () => <p.Component />,
},
}))
},
})
| Field | Notes |
|----------------------|--------------------------------------------------------------------------------|
| id | Required. Unique across the registry. |
| label | Sidebar header text. Omit for detail Boards that match by selection guard. |
| icon | Sidebar icon. Use forkshopIcons.<name> or any ForkshopIconComponent. |
| match | Required. (s: ForkshopSelection) => boolean — when the Board activates. |
| layout | Required. "gallery", "tree", or a custom Layout object from defineLayout(). |
| layoutOptions | Options passed to the Layout. Typed against the Layout's TOptions. |
| useEntries | Required. Hook returning the entries the Layout renders. |
| useSidebarChildren | Hook returning sidebar entries shown under the Board's header. |
match is the engine's selection router. Combine it with the typed
guards — isPrimitiveSelection, isBlockSelection,
isPageSelection, isSectionSelection, isCustomSelection —
instead of hand-rolled discriminator checks.
Gallery layout
A flat collection of Nodes laid out in a grid or stack. Entries
auto-flow across the column count when they omit row/column;
explicit coordinates override.
import { Gallery } from "@forkshop/engine"
<Gallery entries={entries} columns={3} rowGap={24} columnGap={24} />
| Prop | Notes |
|--------------------|--------------------------------------------------------------------------------|
| entries | Required. Array of { label?, node, row?, column? }. Cell key is node.id. |
| layout | "grid" or "stack". Hint for default gaps and viewport width. |
| columns | Column count for auto-flow. Default 2. |
| rowGap | Pixels between rows. Default 24 (or layout-specific). |
| columnGap | Pixels between columns. Default 24 (or layout-specific). |
| rulers | Show pixel rulers along the canvas edges. |
| rulerUnit | "px" or "rem". Default "px". |
| fitContent | When true, tiles shrink to their content height. |
| nodePositions | From useForkshopPositions() — enables drag-to-reposition. |
| onPositionChange | Companion setter from useForkshopPositions(). |
| selectedId | Currently selected Node id; the engine wires this in BoardRegistry. |
| onSelectChange | Called on tile selection. |
Inside defineBoard() you pass these via layoutOptions. Reach for
the raw <Gallery> only when you need to compose the Layout
yourself — e.g. inside a withBoardMeta escape hatch.
Pair with useForkshopPositions({ boardId }) to persist drag
positions across sessions:
const { nodePositions, onPositionChange } = useForkshopPositions({
boardId: "ui-components",
})
boardId namespaces positions within the shared positions.json
file so multiple Boards with overlapping Node ids don't clobber each
other.
Tree layout
A hierarchical board derived from URL paths. A TreeEntry with
path: "/about/team" is rendered as a child of one with
path: "/about". There is no children array — the structure
falls out of the paths themselves.
import { Tree } from "@forkshop/engine"
<Tree entries={entries} />
Each TreeEntry is { id, label?, path, node }. Useful for
sitemap-shaped Boards and content hierarchies.
Design system Board
Compose ColorGraph, TypographyShowcase, and PrimitivesGrid
inside a gallery Board with columns: 1. useDesignTokens()
reads :root CSS vars at runtime by default.
"use client"
import {
defineBoard,
ColorGraph,
TypographyShowcase,
PrimitivesGrid,
useDesignTokens,
forkshopIcons,
} from "@forkshop/engine"
import { forkshopConfig } from "./forkshop.config"
export default defineBoard({
id: "design-system",
label: "Design System",
icon: forkshopIcons.components,
match: (s) => s.kind === "section" && s.sectionId === "design-system",
layout: "gallery",
layoutOptions: { columns: 1, rowGap: 32 },
useEntries: () => {
const tokens = useDesignTokens()
return [
{
id: "colors",
node: {
id: "ds:colors",
kind: "inline-react" as const,
x: 0, y: 0, width: 1200, height: 600,
render: () => <ColorGraph tokens={tokens} />,
},
},
{
id: "typography",
node: {
id: "ds:typography",
kind: "inline-react" as const,
x: 0, y: 0, width: 1200, height: 400,
render: () => <TypographyShowcase tokens={tokens} />,
},
},
{
id: "primitives",
node: {
id: "ds:primitives",
kind: "inline-react" as const,
x: 0, y: 0, width: 1200, height: 800,
render: () => <PrimitivesGrid ui={forkshopConfig.ui ?? {}} />,
},
},
]
},
})
useDesignTokens({ source }) accepts "auto" (default — reads
:root CSS vars after mount) or { tailwindConfig } for Tailwind
v3 projects whose tokens aren't emitted as CSS variables.
Responsive-frames Board
responsiveFrameEntries(path, options) returns LayoutEntry[] of
iframe-route Nodes — one per viewport width. Drop them into a
gallery Board with columns: 3 to render the frames side-by-side.
"use client"
import {
defineBoard,
responsiveFrameEntries,
useSelection,
isPageSelection,
} from "@forkshop/engine"
import { forkshopConfig } from "./forkshop.config"
export default defineBoard({
id: "single-page",
match: isPageSelection,
layout: "gallery",
layoutOptions: { columns: 3, rowGap: 24, columnGap: 24 },
useEntries: () => {
const selection = useSelection()
if (!isPageSelection(selection)) return []
const route = forkshopConfig.sitemap.routes.find(
(r) => r.path === selection.path,
)
return responsiveFrameEntries(selection.path, {
viewports: [1440, 768, 375],
sourceFile: route?.sourceFile,
})
},
})
| Option | Notes |
|--------------|----------------------------------------------------------------|
| viewports | Pixel widths. Default [1440, 768, 375]. |
| sourceFile | Path to the TSX file backing the route. Required for live editing. |
Live text edits in one frame mirror to its siblings as you type;
⌘↵ writes the change to sourceFile. See
Canvas editing.
<BoardRegistry>
Mount every Board through one registry in app/forkshop/page.tsx.
The engine handles selection routing, sidebar rendering, canvas, and
positions wiring — no per-selection switch statement.
"use client"
import { BoardRegistry } from "@forkshop/engine"
import { forkshopConfig } from "./forkshop.config"
import DesignSystemBoard from "./design-system-board"
import UIComponentsBoard from "./ui-components-board"
import SinglePageBoard from "./single-page-board"
export default function ForkshopPage() {
return (
<BoardRegistry
config={forkshopConfig}
boards={[DesignSystemBoard, UIComponentsBoard, SinglePageBoard]}
/>
)
}
Order in boards={[…]} controls sidebar header order. Detail
Boards that match by selection guard (e.g. isPageSelection,
isPrimitiveSelection) have no sidebar header — their position is
cosmetic.
Custom Layouts
Author one with defineLayout() and register it in
forkshopConfig.layouts. Boards then reference the Layout object
directly via layout: <layout>. See
Extending Forkshop for the full API.
Raw-component escape hatch
When defineBoard() doesn't fit — e.g. a Board that owns its own
canvas — wrap a raw React component with withBoardMeta:
import { withBoardMeta, ForkshopCanvas, Gallery } from "@forkshop/engine"
function ExoticBoard() {
return <ForkshopCanvas>{/* … */}</ForkshopCanvas>
}
export default withBoardMeta(ExoticBoard, {
id: "exotic",
label: "Exotic",
match: (s) => s.kind === "section" && s.sectionId === "exotic",
})
withBoardMeta skips the engine's layout machinery — you own
canvas and positions wiring. Reach for it when the
useEntries/Layout model doesn't fit.