@ai-matrx/canvas
v0.10.81
Published
The AI Matrx canvas: a docked right-hand column with split panes, tabs, full screen, a kind registry, Redux-compatible state and persistence.
Readme
@ai-matrx/canvas
A docked right-hand canvas column with split panes, tabs, expand, identity-based deduplication, persisted layout and ONE pane chrome. Core state is framework-independent (it runs in an Electron main process); React components ship through ./react.
import "@ai-matrx/canvas/styles.css";
import "@ai-matrx/canvas/tokens.css"; // follows a design-system theme; else built-in light/dark values
import { CanvasProvider, CanvasFrame, CanvasToggle, registerCanvasKind, defineCanvasKind } from "@ai-matrx/canvas/react";
registerCanvasKind(defineCanvasKind({ id: "note", label: "Note", icon: NoteIcon, load: () => import("./NoteView") }));
<CanvasProvider>
<CanvasFrame><App header={<CanvasToggle />} /></CanvasFrame>
</CanvasProvider>NoteIcon, NoteView and App are your host components. A Redux host passes store={bindCanvasToReduxStore(store, (s) => s.canvasHost)} after mounting canvasReducer. Hosts own identity, navigation, placement and token values; the package owns layout, pane/tab behavior, chrome and restoration. Item data must be plain JSON; failures are reported through onError.
Entries: core (.), React (./react), structural CSS (./styles.css) and default theme values (./tokens.css).
A kind
defineCanvasKind<{ sessionKey: string }>({
id: "terminal",
label: "Terminal",
icon: TerminalIcon,
load: () => import("./TerminalView"),
restore: "session", // closing detaches; a restore or a move reattaches
onAttach: ({ sessionKey, reason }) => attach(sessionKey, reason),
onDetach: ({ sessionKey }) => detach(sessionKey), // never kill here
HeaderLeading: NewTerminalButton, // "+ new terminal", a browser bar…
HeaderAction: ClearButton,
unavailable: () => (backendUp() ? null : "Terminal · next release"),
});body: "native" makes the body a placeholder whose frame the host receives (below).
Bodies never dismount
Every open item's body is rendered once, in the column's body layer, and placed over its pane by measured rect. Switching tabs hides it; splitting, moving a tab between panes, dragging the width, expanding and putting the canvas away only re-place or hide it. React state, focus and scroll survive everything but closing the tab. A body mounts the first time it is shown. isVisible tells it whether it is on screen (pause polling while false); unmountWhenHidden: true opts a genuinely heavy body out.
Content that knows its shape
function GraphView({ presentation }: CanvasKindProps) { // or useCanvasPresentation() anywhere inside the body
return <Graph direction={preferredFlowDirection(presentation) === "vertical" ? "TB" : "LR"} />;
}presentation = { host: "canvas", width, height, orientation: "portrait" | "landscape" | "square", isNarrow, isFullscreen, paneCount }, live. useCanvasPresentation() returns it to any nested component and null outside the canvas. When you have a lot of vertical space and little horizontal space, adapt: lay graphs, timelines and card rows out top to bottom, stack side-by-side columns, shorten toolbars.
Tabs, New tab and Back
Every pane header has "+" (a launcher tab). Choosing in the launcher NAVIGATES that tab in place, so the header offers Back. Do the same from a kind:
canvas.navigate(item.id, { kind: "doc", key: docId, data: { docId } }); // records history; Back returns
canvas.back(item.id);
canvas.forward(item.id); // after Back: the same live body Back leftNavigating to something already open elsewhere focuses it there (a launcher tab closes; any other tab keeps what it shows). History is plain JSON and persists. A lone tab shown as a title keeps its own close.
Back never throws a body away. The newest CANVAS_KEEP_ALIVE_DEPTH (5) steps Back can reach, and the 5 most recent contents Back stepped away from (item.kept), keep their bodies mounted and hidden. Back returns the SAME live body; choosing a content Back left (Quick Chat again from the launcher) revives its body — a typed draft is still there. Older steps still work but mount fresh. Closing the tab disposes every kept body.
Forward. After Back the header also shows Forward (selectCanvasCanGoForward); it returns the same live body Back left. A navigate somewhere new ends the forward trail. Shortcut data canvas.back / canvas.forward (⌘[ / ⌘]) is for a desktop host; browsers own those keys.
The strip (Chrome's model). Tabs share the strip evenly — 12rem each with room — and shrink together to one readable floor, icon + ~10 letters + "…" (CANVAS_TAB_MIN_WIDTH, 120px); a cut name always ends in an ellipsis and every tab's tooltip is its full name. Past the floor the strip draws only the tabs that fit WHOLE, a run around the current tab that moves as little as possible (fitCanvasTabWindow), and "All tabs (N)" lists every tab and shows how many are out of the strip — a tab is never sliced at an edge (JetBrains' single-row tabs; a scrolling strip, as in VS Code or Firefox, leaves half-tabs at both edges). The arrow keys walk every tab. A tab never shrinks to an icon sliver and nothing in the header overlaps another control.
Splits. A split canvas keeps ONE window-wide tab strip above its panes (CanvasWindowTabs), never one per pane: every pane's tabs are in it, the panes' visible tabs are drawn as ONE joined tab (the focused pane's half strongest; planCanvasTabStrip), and its "+" opens in the focused pane. Each pane header keeps its own Back/Forward, title, kind controls and menu. A side-by-side split whose panes would be narrower than paneMinWidth (web: CANVAS_PANE_MIN_WIDTH, 280px) renders stacked under the same one strip; the tree is unchanged. Every pane keeps paneMinHeight (CANVAS_PANE_MIN_HEIGHT, 200px): a split draws no child shorter (fitCanvasSplitSizes), and a new split that cannot give both panes their minimum goes the other axis, or the new tab joins the focused pane (fitCanvasSplit; the pane menu offers only splits that fit).
Phones (CANVAS_PHONE_MAX_WIDTH, 767px): the column presents ONE pane — the focused one when it holds a tab, else the first that does (selectCanvasPresentedPaneId) — whose strip carries every pane's tabs; choosing a tab from another pane presents that pane. The pane menu offers no Split. The layout tree is untouched, so a wider screen gets its split back.
One pane menu
A pane has ONE menu: the kind's menuItems, then what the body offers right now, then the host's itemActions, then Split / Pop out / Close. A body adds its own actions — never a second overflow button in the header:
useCanvasMenuItems(item.id, [{ id: "next", label: "Next record", onSelect: goNext }]);A kind's HeaderAction sits on the solid pane header: draw tap-target buttons with TapTargetButtonTransparent (glass only floats).
A page's own launcher entries
useCanvasLauncherEntry({ kind: "documents", key: conversationId, data: { conversationId }, title: "This chat's documents" });Shown first ("On this page") while the calling component is mounted.
Where a new item opens
defineCanvasKind({ id: "notifications", preferredTarget: "split-down", /* … */ });While the canvas already shows something, a NEW item of the kind opens below (the layout rules' split share, 70/30) or beside; a pane already showing the kind takes it. An explicit target wins. open and toggleKind honor it.
Controls inside the pane header, and "no canvas here"
A kind's HeaderAction can be KindHeaderSlot; its body then draws controls into the header with
<KindHeaderPortal itemId={item.id}> (context is kept; useKindPaneHeaderWidth reports the header width).
Wrap an immersive viewer in <CanvasUnavailableBoundary>: inside it useCanvasIsPresented() is false.
A header's tool icons
<CanvasKindIcons kinds={["terminal", "review", { kind: "browser", label: "Browser" }]} variant="glass" />Each icon toggles or focuses its kind and is pressed while its item is in front.
Motion
The column animates its width on --mxc-motion-duration / --mxc-motion-ease (tokens.css: 600ms, cubic-bezier(0.4, 0, 0.2, 1)), with its content keeping its width so it slides. A host that reflows beside it moves in lockstep on the same pair:
.app-shell { right: var(--canvas-w); transition: right var(--mxc-motion-duration) var(--mxc-motion-ease); }
:root[data-mxc-resizing] .app-shell { transition: none; } /* the edge is being dragged */
@media (prefers-reduced-motion: reduce) { .app-shell { transition: none; } }<CanvasFrame> needs nothing: its main area is a flex sibling and follows the column every frame.
A toolbar row under the header
A kind's Toolbar renders between the pane header and its body for the active
item (a browser bar). It is DOM, and a native body's frame is measured below it.
Toolbar icons: toggle or focus a kind
const terminal = useCanvasKindToggle({ kind: "terminal" });
<button aria-pressed={terminal.isVisible} onClick={terminal.toggle}>…</button>
// outside React (Electron main, a menu handler):
toggleKind(controller, { kind: "terminal" }); // absent → open · behind → focus · in front → closewhenVisible: "hide" puts the canvas away instead of closing the tab.
Chrome for panels a host frames itself
CanvasPanel (title or header, leading, actions, renderMenu, onToggleExpand, onClose), CanvasPanelEmpty, CanvasHeaderButton, CanvasTab, BrowserTabs, BrowserBar — the same components every canvas pane draws with. They render outside a provider too.
The macOS look
<CanvasProvider look="mac"> draws every pane as a section of a Mac window instead of a card: the
column flush to the window's edge behind one hairline, panes split by hairlines, and each pane's
header the unified toolbar's band (--mxc-toolbar-h, default 52px) — the active tab's title centered
in bold with its kind icon as the proxy icon (Terminal, Finder), or the kind's Toolbar in its place
(Safari's address bar). "+" sits with the trailing controls; two or more tabs get macOS's tab bar
under the band. A host that puts the header under its own window buttons sets
--mxc-header-inset-start on the column. Default look="card" keeps the Claude desktop / Codex cards.
Layout rules
<CanvasProvider layoutRules={CANVAS_DESKTOP_LAYOUT_RULES} regionWidth={windowWidth - railAndSidebar}>| Rule | Web (default) | Desktop |
|---|---|---|
| defaultWidth | 640 | { fraction: 0.36 } |
| minWidth | 360 | 360 |
| centreMinWidth (the canvas shrinks first) | 600 | 520 |
| stackSplit (split down: first pane's share) | 0.7 | 0.7 |
| sideSplit | 0.5 | 0.5 |
| paneMinWidth (narrower side-by-side panes stack) | 280 | 360 |
| paneMinHeight (no pane shorter; a split that cannot fit goes the other way or opens as a tab) | 200 | 200 |
Expand is one flag per window; it fills whatever the host placed the column in (useCanvasColumnWidth() returns null).
Electron
One store in the main process; every window's renderer is a client.
// main
import { createCanvasStore, createCanvasController, createKeyValueCanvasPersistence, isCanvasAction,
CANVAS_DESKTOP_LAYOUT_RULES, CANVAS_SHORTCUTS, runCanvasCommand } from "@ai-matrx/canvas";
const store = createCanvasStore();
const canvas = createCanvasController({
store,
persistence: createKeyValueCanvasPersistence({ get: (k) => kv.get(k), set: (k, v) => kv.set(k, v) }),
layoutRules: CANVAS_DESKTOP_LAYOUT_RULES,
isRestorable: (kind) => kind !== "browser", // main has no kind registry: name the kinds that never come back
});
canvas.start(); // hydrates, then autosaves
ipcMain.handle("canvas:get", () => store.getState());
ipcMain.handle("canvas:dispatch", (_e, action: unknown) => { if (isCanvasAction(action)) store.dispatch(action); });
store.subscribe(() => { for (const w of BrowserWindow.getAllWindows()) w.webContents.send("canvas:snapshot", store.getState()); });
// Shortcuts as data: bind them where keys inside native views still arrive.
for (const s of CANVAS_SHORTCUTS) { /* menu accelerator or before-input-event → */ runCanvasCommand(canvas.forWindow(windowIdOf(win)), s.id); }
// A BrowserWindow that closes: its tabs go home.
win.on("closed", () => canvas.closeWindow(windowIdOf(win)));// renderer (one per window)
import { createRemoteCanvasStore } from "@ai-matrx/canvas";
const store = createRemoteCanvasStore({
initialSnapshot: await ipcRenderer.invoke("canvas:get"),
dispatch: (action) => ipcRenderer.invoke("canvas:dispatch", action),
subscribe: (onSnapshot) => {
const on = (_e: unknown, s: CanvasState) => onSnapshot(s);
ipcRenderer.on("canvas:snapshot", on);
return () => ipcRenderer.off("canvas:snapshot", on);
},
});
<CanvasProvider
store={store} // remote: the main process hydrates and saves
windowId={myWindowId} // "main" for the first window
layoutRules={CANVAS_DESKTOP_LAYOUT_RULES}
regionWidth={rightOfSidebar}
hotkeys={false} // main binds CANVAS_SHORTCUTS
onNativeBodyFrame={(itemId, rect, { visible }) => ipc.send("view:place", itemId, visible ? rect : null)}
showMenu={(entries, anchor) => ipc.invoke("menu:popup", entries, anchor)} // Menu.popup → chosen id
overlay={{ show: (el) => { ipc.send("overlay:show", el); return () => ipc.send("overlay:hide", el); } }}
popOut={(item) => ipc.invoke("canvas:pop-out", item.id)} // → { windowId } of a new BrowserWindow
>
<CanvasColumn /> {/* a pop-out window renders <CanvasColumn placement="fill" /> */}
</CanvasProvider>Rects are window CSS pixels with devicePixelRatio, reported in the frame the layout changed (layout effects + ResizeObserver). Call useCanvasNativeBodies().remeasure() per animation frame while the host animates its own layout. visible: false means package DOM floats over the view (a DOM menu or a tab drag): hide it so the DOM is seen and receives the drop.
Web pop-out
popOut={async (item) => { const id = `pop-${item.id}`; openFloatingPanel(id); return { windowId: id }; }}
// the floating panel:
<CanvasWindowScope windowId={id}><CanvasColumn placement="fill" /></CanvasWindowScope>When a pop-out window disappears from state.windows (docked back, last tab closed), close its panel.
MIT.
