Development Guide

Prerequisites

  • Bun (latest) – Electrobun’s runtime; provides native TypeScript execution and fast package management
  • Electrobun installed globally or via the project

Commands

Prefer the bun run script wrappers over calling bunx vitest / bunx vite directly – the wrappers use the workspace-pinned tool versions and avoid silent version drift. Run bun run verify before opening a PR.

bun install           # Install all dependencies

bun run dev:hmr       # Recommended: Vite HMR + Electrobun running concurrently
bun run dev           # Alternative: Electrobun with file watching (restarts on changes)
bun run start         # One-shot: Vite build then Electrobun dev (no watching)
bun run hmr           # Vite dev server only, port 5173 (desktop package; used internally by dev:hmr)
bun run build:canary  # Production build (canary channel)

Tests

bun run test          # Full test suite across all workspaces
bun run test:desktop  # Desktop package only (faster)
bun run test:file path/to/file.test.ts  # Run a single test file
bun run test:typecheck # tsc --noEmit
bun run test:build    # Production build (catches import/CSS issues unit tests miss)
bun run test:lint     # Biome lint (matches CI)
bun run verify        # test + typecheck + build + lint -- the PR-readiness check

The desktop package also provides test:bun (Bun-native event-server tests), test:e2e (Playwright), and test:a11y (Playwright accessibility). Run these from packages/desktop (or via bun run --cwd packages/desktop <script>).

Why dev:hmr is Preferred

bun run dev:hmr runs the Vite dev server (port 5173, with Hot Module Replacement) and Electrobun concurrently. On startup the Bun main process probes the dev server: if it is up, the app loads from http://localhost:5173; otherwise it falls back to the bundled views://mainview/index.html. This gives instant CSS/component feedback without restarting the app.

bun run dev (no HMR) watches files and restarts the Bun process, but does not provide instant CSS/component updates. Use it when testing main-process changes.

Test Environment

Tests use Vitest with environment-specific configuration (see packages/desktop/vitest.config.ts):

  • Unit tests (tests/unit/) run in the node environment by default.
  • UI tests (tests/unit/ui/) run in jsdom for DOM access (matched via environmentMatchGlobs).
  • Benchmarks (tests/bench/) run in node via bunx vitest bench (run from the desktop package).
  • @testing-library/react and @testing-library/jest-dom are available for component testing.

The benchmark suite (tests/bench/perf.bench.ts) uses a schema generator (generateLargeSchema(300)) to build deterministic 300+ node trees and asserts that updateNodeStatus never changes the dataKey value – the invariant that status updates must not trigger tree re-layout.

Sample Schemas

  • samples/hello-world.json – Minimal schema (4 nodes, all 4 statuses)
  • samples/getting-started.json – Rich schema (15 nodes, 4 depth levels, markdown notes, metadata)

In the Vite dev server (HMR mode without Electrobun), the Open button loads getting-started.json as a fallback. The WelcomeScreen offers links to load both samples directly.

The electrobun/view Unavailability Issue

electrobun/view is injected by the Electrobun runtime and is not a regular npm package – it does not exist in the Vite dev server (standalone HMR) or in the test environment (vitest with jsdom). Any code that imports it eagerly will crash there. Use a lazy import wrapped in try/catch so the app still works in dev/test:

try {
  const { electroview } = await import("../rpc");
  rpcSend = (payload) => electroview.rpc.request.logMessage(payload);
} catch {
  // electrobun/view unavailable outside the runtime -- fall back gracefully
}

Follow this pattern whenever new code uses electroview.

Testing the In-App Updater Locally

Set ROADRAVEN_UPDATE_BASE_URL=http://127.0.0.1:8765 (or any local server URL) before running bun run build:canary. electrobun.config.ts reads it: when set, the canary channel’s release config points its baseUrl at that URL instead of GitHub Releases, and turns off delta-patch generation, so every local build is a full bundle rather than a diff against a previous one.

Build and install a “previous” version, then bump the version and build again with the same env var set, and serve the second build’s packages/desktop/artifacts/ directory from that URL with any static file server. The installed canary fetches canary-win-x64-update.json from there on its launch check and, if it points at a newer version, downloads canary-win-x64-RoadRaven-canary.tar.zst.

Run the installed canary itself – not bun run dev / dev:hmr, which never check for updates – and watch the flow on the update card, Preferences › About or the status-bar pill: the launch check, the download, and Restart to update. See Logging for where the session’s log ends up across the restart.

How to Add a New Component

  1. Create the component file in packages/desktop/src/mainview/components/.

  2. Use only --rv-* tokens via Tailwind classes – no hardcoded colors (a CI grep check enforces this):

    // Good
    <div className="bg-rv-bg-surface text-rv-text-primary border border-rv-border">Content</div>
    
    // Bad -- hardcoded colors fail the CI grep check
    <div className="bg-[#1b1b1c] text-[#e0e0e0]">Content</div>
    
  3. If you need a new token, follow Design System – How to Add a New Token.

  4. Add the component to the app shell in App.tsx or the appropriate parent.

  5. Write tests in tests/unit/ui/ using @testing-library/react (these get the jsdom environment automatically).

How to Add a New RPC Endpoint

A three-file change; TypeScript enforces consistency across all three.

  1. shared/types.ts – Define the contract:

    // Inside RoadmapRPCType.bun.requests:
    myEndpoint: {
      params: { input: string };
      response: { output: number };
    };
    
  2. packages/desktop/src/bun/index.ts – Add the handler:

    // Inside BrowserView.defineRPC handlers.requests:
    myEndpoint: ({ input }) => ({ output: input.length }),
    
  3. Call from webview code:

    import { electroview } from "../rpc";
    const result = await electroview.rpc.request.myEndpoint({ input: "hello" });
    

See RPC and IPC for the full contract reference and data flow diagrams.

How to Add a New Logger Category

Create a logger with getLogger() using an array-based category. No extra configuration is needed – loggers inherit from their parent category.

// Webview side (packages/desktop/src/mainview/logging/logger.ts)
export const myFeatureLogger = getLogger(["webview", "my-feature"]);

// Bun side (packages/desktop/src/bun/logging.ts)
export const myFeatureLogger = getLogger(["bun", "my-feature"]);

See Logging for details.

Keyboard Shortcuts

The canvas keyboard layer lives in hooks/useKeyboardRouter.ts. The router runs in capture phase and stands down when a Radix dialog or context menu is open, or when a text input / CodeMirror editor is focused. File verbs come from lib/fileCommands.ts — the single registry (label, shortcut, enablement, action) that the File menu, the keyboard router and the sidebar all read, so a verb cannot drift between surfaces. Node search matches titles by default; the notes toggle inside the search box widens it to notes and is persisted (AppSettings.searchInNotes).

Global / file

Shortcut Action
Ctrl+N New roadmap
Ctrl+O Open…
Ctrl+S Save (flush now)
Ctrl+Shift+S Save As…
Ctrl+B Toggle sidebar
Ctrl+, Preferences
Ctrl+F Focus node search
Ctrl+Shift+L Toggle event log

Ctrl is ⌘ on macOS. Ctrl+S, Ctrl+Shift+S, Ctrl+, and Ctrl+F also fire while a text input or CodeMirror editor has the caret; Ctrl+N, Ctrl+O, Ctrl+B and Ctrl+Shift+L respect the input-focused guard.

Canvas (focused node)

Shortcut Action
Arrow ← / Arrow → (TB) Move focus between siblings
Arrow ↑ / Arrow ↓ (LR) Move focus between siblings
Arrow ↓ (TB) / Arrow → (LR) Enter first child (descend)
Arrow ↑ (TB) / Arrow ← (LR) Return to parent
Space Select focused node (open / refresh side panel)
Enter Add child + open inline rename on the new node
Tab Add sibling below + open inline rename
Shift+Enter Add sibling above + open inline rename
F2 Inline rename on focused node
Del / Backspace Delete focused node (confirmation dialog if it has children)
Ctrl+D Duplicate focused node + subtree, then open rename on the copy
Ctrl+C Copy node + subtree to clipboard as JSON (unless text is selected – then the browser copies the selection)
Ctrl+V Paste clipboard subtree under focused node
Ctrl+Arrow ↑ Move focused node up among its siblings
Ctrl+Arrow ↓ Move focused node down among its siblings
Escape Cancel inline rename, or deselect node
F6 Toggle focus between canvas and side panel

Arrow-key axes follow the layout orientation: in TB (top-bottom) layout siblings are horizontal and children flow downward; in LR (left-right) layout siblings are vertical and children flow rightward. Ctrl+C / Ctrl+V are context-aware – when a text input, <textarea>, contentEditable, or CodeMirror (.cm-editor) is focused, they fall through to the browser’s native text copy/paste instead of the node clipboard. Ctrl+C also falls through whenever any text is selected in the document (for example notes in the side panel), so the selection is what lands on the clipboard.

Side panel

Shortcut Action Context
e Enter edit mode Side panel open, no text input focused, not already editing
Escape Cancel title edit / exit edit mode Side panel in edit mode

Edit mode can also be entered by clicking the title field or the [E] pencil button in the panel header. Node cards are keyboard-accessible (role="button", tabIndex={0}, Enter / Space handlers); the dashed focus ring uses a keyboard-nav-active class on <body> so it shows only during keyboard navigation, not after a mouse click.

Outline (sidebar)

Shortcut Action
Arrow ↑ / Arrow ↓ Move between rows
Arrow → Expand the row, or move to its first child if already expanded
Arrow ← Collapse the row, or move to its parent if already collapsed
Home / End First / last row
Enter / Space Select the node and reveal it on the canvas

While an outline row holds DOM focus ([data-outline-tree]) the canvas router stands down for node-navigation keys, so the arrows above act on the outline rather than the canvas; the Ctrl shortcuts in the Global / file table keep working from there.

Project Conventions

Convention Rule Why
Token prefix All CSS custom properties start with --rv- Namespace isolation from Tailwind internals and third-party CSS
Hardcoded colors Not allowed in components; CI grep enforces this Ensures theme switching works for every component
RPC contract Defined once in shared/types.ts, imported by both processes Compile-time safety across the process boundary
Logging Use LogTape loggers, not console.log Structured output, file persistence, category filtering
Test location tests/unit/ for unit tests, tests/unit/ui/ for component tests Environment matching: node for logic, jsdom for components
Formatter Biome (not Prettier) Faster; linting + formatting in one tool
Package scope @roadraven/ Consistent npm namespace for publishable packages
App version Single source is packages/desktop/package.json (read by electrobun.config.ts, src/bun/appVersion.ts and the renderer’s __APP_VERSION__ define); bun scripts/bump-version.ts X.Y.Z propagates it to the other package.json files and the plugin/marketplace pins One literal to bump; footer, About and the Setup Wizard mismatch check cannot drift

RoadRaven is source-available under FSL-1.1-MIT. License.

This site uses Just the Docs, a documentation theme for Jekyll.