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.