Skip to content

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()