Walker Panel Development Guide
Reference for adding a new panel type to the Walker host. The Zine panel
(src/panels/zine/) is the reference implementation. The VN panel
(src/panels/vn/) is the earlier one from which the pattern was derived.
File Structure
Every panel type lives under src/panels/<kind>/ and contributes one module
to src/state/:
src/
canvas/
socketTypes.ts ← add new SocketTypeTag values here
panels/
<kind>/
<kind>Types.ts ← TypeScript mirrors of server SSE event shapes
<kind>Core.ts ← synchronous state machine, no stores
<kind>Sockets.ts ← PanelSocketMap declaration
<kind>Panel.ts ← DOM panel class + PanelDescriptor export
state/
<kind>HostStore.ts ← Zustand vanilla store (wraps Core)
<kind>SurfaceStore.ts ← derived display store (subscribes to HostStore)
1. Types (<kind>Types.ts)
Mirror the server's SSE JSON payloads as TypeScript interfaces. Keep these pure types — no logic, no imports from stores or core.
// Server SSE payload shapes
export interface KindSessionStartEvent { ... }
export interface KindNavigateEvent { ... }
export interface KindSessionEndEvent { ... }
// Walker-internal state shape
export interface KindCoreState {
activeId?: string;
// ... current navigation position
ended: boolean;
}
2. Core (<kind>Core.ts)
Synchronous, pure-logic class. No DOM, no Zustand, no async.
export type KindUpdateListener = (state: KindCoreState) => void;
export class KindCore {
private _state: KindCoreState = { ended: false };
private readonly _listeners = new Set<KindUpdateListener>();
onUpdate(listener: KindUpdateListener): () => void {
this._listeners.add(listener);
return () => this._listeners.delete(listener);
}
startSession(event: KindSessionStartEvent): void {
// set active state from event fields
this._notify();
}
navigate(event: KindNavigateEvent): void { ... }
endSession(event: KindSessionEndEvent): void { ... }
reset(): void { this._state = { ended: false }; this._notify(); }
dispose(): void { this._listeners.clear(); }
// Getters
get state(): KindCoreState { return { ...this._state }; }
private _notify(): void {
this._listeners.forEach(l => l(this._state));
}
}
3. Host Store (<kind>HostStore.ts)
Zustand vanilla store (createStore from zustand/vanilla). Wraps Core
and adds:
- Navigation intent queue (user wants to move; server will confirm)
applyServer*methods (called by transport when SSE events arrive)consumeSubmissions()(called by transport to drain the outbound queue)
import { createStore } from "zustand/vanilla";
import { KindCore, KindCoreState } from "../panels/<kind>/<kind>Core";
export interface KindSubmission { kind: "next" | "prev" | "goTo"; payload?: string; }
export interface KindHostStoreState {
core: KindCoreState;
submissions: KindSubmission[];
goNext(): { ok: boolean; error?: string };
goPrev(): { ok: boolean; error?: string };
goTo(id: string): { ok: boolean; error?: string };
consumeSubmissions(): KindSubmission[];
applyServerSessionStart(value: unknown): void;
applyServerNavigate(value: unknown): void;
applyServerSessionEnd(value: unknown): void;
reset(): void;
}
const core = new KindCore();
export const kindHostStore = createStore<KindHostStoreState>((set, get) => ({
core: core.state,
submissions: [],
goNext() {
// validate state, push submission
set(s => ({ submissions: [...s.submissions, { kind: "next" }] }));
return { ok: true };
},
// ...
consumeSubmissions() {
const subs = get().submissions;
set({ submissions: [] });
return subs;
},
applyServerSessionStart(value: unknown) {
const event = value as KindSessionStartEvent;
core.startSession(event);
set({ core: core.state });
},
applyServerNavigate(value: unknown) { ... },
applyServerSessionEnd(value: unknown) { ... },
reset() {
core.reset();
set({ core: core.state, submissions: [] });
},
}));
4. Surface Store (<kind>SurfaceStore.ts)
Thin derived store that the panel renderer reads. Subscribe to the host store in module scope so the subscription is always live:
import { createStore } from "zustand/vanilla";
import { kindHostStore } from "./<kind>HostStore";
export interface KindSurfaceStoreState {
activeId?: string;
title?: string;
status: "idle" | "active" | "ended";
show: boolean;
hide: boolean;
goNext(): { ok: boolean; error?: string };
goPrev(): { ok: boolean; error?: string };
goTo(id: string): { ok: boolean; error?: string };
reset(): void;
}
export const kindSurfaceStore = createStore<KindSurfaceStoreState>(
(set, get) => ({
activeId: undefined,
title: undefined,
status: "idle",
show: false,
hide: false,
goNext() {
return kindHostStore.getState().goNext();
},
goPrev() {
return kindHostStore.getState().goPrev();
},
goTo(id) {
return kindHostStore.getState().goTo(id);
},
reset() {
kindHostStore.getState().reset();
},
}),
);
// Wire subscription once at module init
kindHostStore.subscribe((hostState) => {
kindSurfaceStore.setState({
activeId: hostState.core.activeId,
title: hostState.core.title,
status: hostState.core.ended
? "ended"
: hostState.core.activeId
? "active"
: "idle",
show: !hostState.core.ended,
hide: hostState.core.ended,
});
});
5. Panel (<kind>Panel.ts)
DOM rendering class. Follow this pattern:
import type { PanelDescriptor } from "../../canvas/panelRegistry";
import {
kindSurfaceStore,
KindSurfaceStoreState,
} from "../../state/<kind>SurfaceStore";
const STYLE_ID = "<kind>-panel-styles";
function injectStyles(doc: Document): void {
if (doc.getElementById(STYLE_ID)) return;
const style = doc.createElement("style");
style.id = STYLE_ID;
style.textContent = `
.<kind>-panel { ... }
/* ... */
`;
doc.head.appendChild(style);
}
export class KindPanel {
readonly element: HTMLElement;
private readonly _unsub: () => void;
constructor() {
this.element = document.createElement("div");
this.element.className = "<kind>-panel";
injectStyles(document);
this._unsub = kindSurfaceStore.subscribe((s) => this._render(s));
this._render(kindSurfaceStore.getState());
}
private _render(state: KindSurfaceStoreState): void {
if (state.hide) {
this.element.style.display = "none";
return;
}
this.element.style.display = "";
this.element.innerHTML = `...`;
// Wire local event listeners after innerHTML
this.element.querySelector(".next-btn")?.addEventListener("click", () => {
state.goNext();
});
}
dispose(): void {
this._unsub();
}
}
export const kindDescriptor: PanelDescriptor = {
id: "<kind>",
displayName: "Kind Display Name",
description: "One sentence describing what this panel shows.",
factory(_params: Record<string, unknown>) {
const panel = new KindPanel();
return {
element: panel.element,
dispose() {
panel.dispose();
},
};
},
};
6. Socket Declarations (<kind>Sockets.ts)
First add new type tag literals to src/canvas/socketTypes.ts:
// socketTypes.ts
export type SocketTypeTag =
| "vn_scene"
| "vn_navigate"
| ...
| "<kind>_spec" // ← new
| "<kind>_navigate" // ← new
| "<kind>_outcome"; // ← new
Then declare the panel's sockets:
import type { PanelSocketMap } from "../../canvas/socketTypes";
export const KIND_SOCKETS: PanelSocketMap = {
panel_id: "<kind>_host",
inputs: {
session_spec: {
id: "session_spec",
label: "Session Spec",
accepts: ["<kind>_spec"],
description: "Full spec event delivered on session start",
},
navigate_event: {
id: "navigate_event",
label: "Navigate",
accepts: ["<kind>_navigate"],
description: "Position update from server",
},
},
outputs: {
navigation_intent: {
id: "navigation_intent",
label: "Navigation Intent",
produces: ["<kind>_navigate"],
description: "User navigation requests (prev/next/goTo)",
},
session_outcome: {
id: "session_outcome",
label: "Session Outcome",
produces: ["<kind>_outcome"],
description: "Outcome emitted when a session ends",
},
},
};
7. Register the Panel
In the Walker app entry point (or dock setup file):
import { registry } from "./canvas/panelRegistry";
import { kindDescriptor } from "./panels/<kind>/KindPanel";
registry.register(kindDescriptor);
Transport Wiring
The SSE transport layer looks up applyServer* methods on stores by name.
Ensure method names follow the convention exactly:
| SSE event name | Store method called |
|---|---|
<kind>.session.start.v1 |
kindHostStore.getState().applyServerSessionStart(payload) |
<kind>.navigate.v1 |
kindHostStore.getState().applyServerNavigate(payload) |
<kind>.session.end.v1 |
kindHostStore.getState().applyServerSessionEnd(payload) |
This mapping lives in the transport SSE handler. If you add new event names, you must also register handlers there.
Development Workflow
# Terminal 1 — start the kind server
cd plantangenet
cargo run --bin <kind>-server -- --bind 127.0.0.1:<port>
# Terminal 2 — start Walker dev server
cd walker
npm run dev
# Open Walker at http://localhost:5173
# Navigate to the kind panel; it auto-connects via SSE
# Load a spec via curl
curl -s -X POST http://127.0.0.1:<port>/<kind>/load \
-H 'Content-Type: application/json' \
-d '{"yaml": "..."}' | jq .
Conventions Summary
| Concern | Convention |
|---|---|
| State management | Zustand vanilla (createStore) — no React |
| Store singleton | Module-scope singleton (one host store, one surface store per kind) |
| Subscription wiring | Surface store subscribes to host store at module init, not in component |
| Terminal state | Panel hides itself when state.hide === true |
| SSE apply methods | Named applyServer<EventName> on host store |
| Outbound queue | Host store holds submissions[]; drained by transport via consumeSubmissions() |
| CSS injection | injectStyles(document) guards with getElementById(STYLE_ID) to avoid double-inject |
| Panel lifecycle | dispose() on panel class, () => void from store subscribe() |