Spectre Shell Router
`@phcdevworks/spectre-shell-router` is the client-side router package of the Spectre system. It maps URL paths to lazy page modules, renders into a root element, and keeps navigation behavior framework-agnostic for Spectre-based applications.
This page is generated from the source repository’s README and ROADMAP on every build. Edit the source repository, not this file.
README
@phcdevworks/spectre-shell-router
@phcdevworks/spectre-shell-router is the client-side router package of the
Spectre system. It maps URL paths to lazy page modules, renders into a root
element, and keeps navigation behavior framework-agnostic for Spectre-based
applications.
Maintained by PHCDevworks. It is an independent peer
package with zero runtime dependencies, deliberately separate from
spectre-shell-signals, and is consumed by spectre-shell (which composes
it) and spectre-init (which scaffolds new apps against it).
Repository Snapshot
| Field | Value |
|---|---|
| Project team | project-shell |
| Repository role | Spectre client-side router |
| Package/artifact | @phcdevworks/spectre-shell-router |
| Current version/status | 1.2.0 |
Standard Workflow
- Read AGENTS.md, then the agent-specific guide for the task.
- Check TODO.md and ROADMAP.md for current scope.
- Make the smallest repo-local change that satisfies the task.
- Run
npm run checkwhen validation is required or practical. - Update docs and CHANGELOG.md only when behavior, public contracts, or release-relevant metadata changed.
Documentation Map
| Guide | Path |
|---|---|
| Agent rules | AGENTS.md |
| Claude Code | CLAUDE.md |
| Codex | CODEX.md |
| Copilot | COPILOT.md |
| Jules | JULES.md |
| Roadmap | ROADMAP.md |
| Todo | TODO.md |
| Changelog | CHANGELOG.md |
| Security | SECURITY.md |
Minimal browser router for Spectre applications. It maps URL paths to lazy page modules, renders into a root element, and keeps navigation behavior framework-agnostic.
Part of the PHCDevworks Spectre shell ecosystem — composable, zero-dependency packages for client-side shell applications.
Contributing | Changelog | Roadmap | Security Policy
When to use this package
- You need a framework-agnostic client-side router with zero runtime dependencies
- You want
:paramURL matching, query string access, and lazy page loading via dynamicimport() - You want a clean
render/destroylifecycle with race-condition safety built in - You need history or hash-based routing with optional navigation guards and scroll restoration
- You are building on top of a Spectre shell or a similar minimal shell pattern
When not to use this package
- You need a framework-integrated router (React Router, Vue Router, TanStack Router, etc.)
- You need server-side rendering or file-based routing
- You need application state management, persistent layouts, or design tokens
Capabilities
- Route matching with
:parampath segments. - Lazy page loading through route
loaderfunctions. - Browser History API integration for programmatic navigation and link interception.
- Optional hash-based routing mode.
- Route guards with cancellation and redirect support.
- Scroll restoration for push and browser back/forward navigation.
- Query string access through
URLSearchParams. - Page cleanup through optional
destroyhooks. - Named routes with
router.href()for safe path generation. - Per-route
metaand anafterNavigatehook for titles and a11y focus management. onNavigationStart/onNavigationEndhooks for loading state.router.subscribe()for reactive route state at the app layer.errorRouteandonErrorfor deterministic error handling instead of a silently cleared outlet.router.back(),router.forward(), androuter.replace()navigation history helpers.
Requirements
- Node.js
^22.12.0 || >=24.0.0
Install
npm install @phcdevworks/spectre-shell-router
Quick Start
import { Router, type Route } from '@phcdevworks/spectre-shell-router'
const routes: Route[] = [
{
path: '/',
loader: async () => ({
render({ root }) {
root.textContent = 'Home'
},
}),
},
{
path: '/docs/:slug',
loader: async () => ({
render({ params, query, root }) {
root.textContent = `Doc: ${params.slug}; tab=${query.get('tab') ?? 'intro'}`
},
}),
},
]
const root = document.querySelector<HTMLElement>('#app')
if (!root) {
throw new Error('Missing #app root element.')
}
const router = new Router(routes, root)
router.navigate('/docs/getting-started?tab=api')
// Clean up when the shell unmounts (SPA teardown, hot reload, etc.)
// router.destroy()
API
Types
type Route = {
path: string
name?: string // optional; enables router.href() lookup
meta?: Record<string, unknown> // optional; available on RouteContext.meta
loader: () => Promise<PageModule>
}
type NavigationContext = {
from: string | null
to: string
}
type RouterOptions = {
mode?: 'history' | 'hash'
scrollRestoration?: boolean
errorRoute?: string
beforeNavigate?: (
context: NavigationContext,
next: (redirect?: string) => void
) => void | Promise<void>
onNavigationStart?: (context: NavigationContext) => void
onNavigationEnd?: (context: NavigationContext) => void
afterNavigate?: (context: RouteContext) => void
onError?: (error: unknown, context: NavigationContext) => void
}
type PageModule = {
render: (ctx: RouteContext) => void
destroy?: () => void
}
type RouteContext = {
path: string
params: Record<string, string>
query: URLSearchParams
root: HTMLElement
meta?: Record<string, unknown>
}
type Unsubscribe = () => void
Router class
class Router {
constructor(routes: Route[], root: HTMLElement, options?: RouterOptions)
// Push a new path onto history and navigate to it.
navigate(path: string): void
// Navigate to path without adding a new history entry (uses replaceState).
replace(path: string): void
// Thin wrappers around history.back() / history.forward().
back(): void
forward(): void
// Build a path string from a named route and optional params.
// Throws if the name is unknown or a required :param segment has no matching key.
href(name: string, params?: Record<string, string>): string
// Subscribe to completed navigations. Fires with the current RouteContext
// after every render. Returns an unsubscribe function.
subscribe(callback: (context: RouteContext) => void): Unsubscribe
// Remove all event listeners, call destroy() on the current page, and release the root reference.
destroy(): void
}
router.href() examples:
// Static route
const routes: Route[] = [
{ name: 'home', path: '/', loader: ... },
{ name: 'user', path: '/users/:id', loader: ... },
]
const router = new Router(routes, root)
router.href('home') // '/'
router.href('user', { id: '42' }) // '/users/42'
Router options examples:
const router = new Router(routes, root, {
mode: 'hash',
scrollRestoration: true,
beforeNavigate({ to }, next) {
if (to === '/admin' && !sessionStorage.getItem('isAdmin')) {
next('/login')
return
}
next()
},
})
Ecosystem integration patterns
Document titles and a11y focus with meta + afterNavigate:
const routes: Route[] = [
{
path: '/',
meta: { title: 'Home' },
loader: async () => ({
render({ root }) {
root.textContent = 'Home'
},
}),
},
{
path: '/docs/:slug',
meta: { title: 'Docs' },
loader: async () => ({
render({ params, root }) {
root.textContent = `Doc: ${params.slug}`
},
}),
},
]
const router = new Router(routes, root, {
afterNavigate(context) {
if (context.meta?.title) {
document.title = `${context.meta.title} — My App`
}
// a11y: move focus to the root after each navigation so screen readers
// announce the new page.
context.root.setAttribute('tabindex', '-1')
context.root.focus()
},
})
Loading state with onNavigationStart / onNavigationEnd:
let navigating = false
const router = new Router(routes, root, {
onNavigationStart() {
navigating = true
// e.g. show a loading indicator
},
onNavigationEnd() {
navigating = false
// e.g. hide a loading indicator
},
})
At the app layer, spectre-shell-signals can wrap these two callbacks to expose a
reactive navigating$ signal that spectre-ui loading indicators subscribe to.
Reactive route state with router.subscribe():
import { signal } from '@phcdevworks/spectre-shell-signals'
const currentRoute = signal<RouteContext | null>(null)
const unsubscribe = router.subscribe((context) => {
currentRoute.set(context)
})
// Later, when the shell unmounts:
// unsubscribe()
router.subscribe() is the canonical bridge between the router and
spectre-shell-signals — wrap it once at the app layer to get a reactive
currentRoute signal that any component can read from.
Deterministic error handling with errorRoute / onError:
const routes: Route[] = [
{ path: '/', loader: async () => ({ render({ root }) { root.textContent = 'Home' } }) },
{
path: '/error',
loader: async () => ({ render({ root }) { root.textContent = 'Something went wrong.' } }),
},
]
const router = new Router(routes, root, {
errorRoute: '/error',
onError(error, context) {
console.error(`Navigation to "${context.to}" failed:`, error)
},
})
When a loader throws or no route matches, the router navigates to errorRoute
(via replace(), so the failing URL is not pushed onto history) instead of
silently clearing the outlet. onError fires first regardless of whether
errorRoute is configured, so apps can log or report errors without a redirect.
errorRoute must match an existing route path or the constructor throws.
Navigation history helpers:
router.replace('/login') // navigate without adding a history entry
router.back() // wraps history.back()
router.forward() // wraps history.forward()
Behavior
destroy()on the current page is always called before the nextrender()runs.- If a
loaderthrows:onErrorfires (if configured), then the router navigates toerrorRouteif configured and the failing path is not alreadyerrorRoute; otherwise the root is cleared and the navigation is abandoned. - If a faster navigation supersedes a pending one, the stale result is discarded (race-safe via monotonic counter).
- If no route matches:
onErrorfires (if configured), then the router navigates toerrorRouteif configured and the unmatched path is not alreadyerrorRoute; otherwise the root is cleared anddestroy()is called on the current page. - If
beforeNavigatedoes not callnext(), navigation is cancelled and the URL reverts to the current route. - If
beforeNavigatecallsnext('/path'), the router redirects to that path. - Same-domain
<a>clicks are intercepted automatically; modified clicks (ctrl, meta, shift, alt) and external links pass through. - In hash mode, router paths are stored after
#/androuter.href()returns hash-prefixed URLs.
Boundaries
This package owns client-side routing only. It does not own application bootstrapping, reactive state, persistence, layouts, design tokens, or server routing.
Development
npm install
npm run check
Useful scripts:
npm run typecheckvalidates TypeScript without emitting files.npm run lintruns ESLint.npm test -- --runruns the Vitest suite once.npm run buildemits declarations and JavaScript todist.npm run checkruns the full verification gate (typecheck + lint + build + test + check:ecosystem).
AI-agent coordination starts in AGENTS.md, with companion guidance in CLAUDE.md, CODEX.md, COPILOT.md, JULES.md, and .github/copilot-instructions.md.
Troubleshooting
| Problem | Likely cause | Fix |
|---|---|---|
npm run check fails on typecheck |
Type error in source or tests | Run npm run typecheck to isolate the error |
| Tests fail in CI but pass locally | Node version mismatch | CI runs Node 22 and 24; match your local version |
dist/ is missing after clone |
Build output is gitignored | Run npm run build |
| Link clicks are not intercepted | Link is cross-origin, has target, download, or rel="external" |
Expected behavior — only same-domain plain links are intercepted |
Contributing
See CONTRIBUTING.md. The gate is npm run check — all of typecheck, lint, build, tests, and check:ecosystem must pass before opening a pull request.
Release Notes
See CHANGELOG.md.
License
MIT. See LICENSE.
Roadmap
Spectre Shell Router Roadmap
@phcdevworks/spectre-shell-router is the URL routing layer for the Spectre shell ecosystem.
It owns URL resolution, route matching, param and query parsing, history management, navigation
primitives, and page lifecycle. It composes with the rest of the ecosystem — it does not own
signals, state, styling, or rendering beyond clearing the route outlet.
Ecosystem siblings:
| Package | Role |
|---|---|
@phcdevworks/spectre-shell-signals |
Reactive primitives (signal, computed, effect) |
@phcdevworks/spectre-tokens |
Design token authority |
@phcdevworks/spectre-ui |
Token-backed CSS recipes and component styling |
@phcdevworks/spectre-ui-astro |
Astro component layer built on spectre-ui |
The router is the navigation backbone. Phase 3 is about making it genuinely connectable to these packages — exposing enough surface that signals can observe route state, components can react to loading, and apps can manage metadata without building workarounds.
1. Phase 1 — Foundation — Delivered
All foundation work is complete as of v1.0.0.
What is in place
- Router class with hardened navigation behavior and monotonic
currentNavIdrace-condition guard. - Path matching with
:paramsegment extraction andURLSearchParamsquery parsing. - Browser History API integration (
pushState/popstate). - Page lifecycle enforcement —
destroy()always runs before the nextrender(). - Same-domain
<a>link interception; modified clicks (ctrl, meta, shift, alt) and external links pass through. - TypeScript strict mode throughout
src/index.ts. Zero runtime dependencies. - Vitest test suite across three files:
router.test.ts,reliability.test.ts,stress.test.ts. - CI pipeline running
npm run check(typecheck + lint + build + test) on Node 22 and 24 for every push and pull request. - NPM publishing setup with
prepublishOnlygate. - Multi-agent documentation:
AGENTS.md,CLAUDE.md,CODEX.md,JULES.md,COPILOT.md.
What will not change
src/index.tsremains the single source of truth. Zero runtime dependencies is a hard constraint — browser APIs only.- The
npm run checkgate is the release standard. All steps must pass before handoff. destroy()must always run beforerender(). This invariant is non-negotiable.- This package does not own application state, reactive primitives, styling, or SSR.
2. Phase 2 — Routing Contract — Delivered
All Phase 2 work is complete.
What was delivered
- Hash-based routing mode (
mode: 'hash') — paths stored after#/,router.href()returns hash-prefixed URLs. - Route guards via
beforeNavigate— async, cancellable, redirect-capable. Navigation reverts ifnext()is never called. - Scroll position restoration for push navigation and browser back/forward.
- Named routes with
router.href(name, params?)for safe path generation. Throws on unknown name or missing required:paramsegment. - Constructor options interface
RouterOptionsas a stable public contract.
3. Phase 3 — Ecosystem Integration (Complete)
The foundation is stable. Phase 3 focuses on making the router connectable to
spectre-shell-signals, spectre-ui, and application-level concerns like metadata and
analytics.
P0: Signals Bridge
Navigation subscription API
Expose router state reactively without the router owning a signal primitive.
- Add
router.subscribe(callback)— fires with the currentRouteContextafter each completed navigation, returns an unsubscribe function. - Zero new runtime dependencies.
spectre-shell-signalscan wrap this in asignal()at the app layer, keeping the router itself dependency-free while enabling full reactivity.
Navigating state hooks
Enable loading indicators and navigating$ signal support in spectre-ui.
- Add optional
onNavigationStartandonNavigationEndcallbacks to the constructor options. - Used at the app layer to drive
navigating$signals and loading indicators inspectre-uicomponents.
P1: Application Primitives
Per-route metadata
Support document title management, analytics tagging, and route-level guard data.
- Add optional
metaobject to route definitions. Typed but open-ended — apps define their own metadata shape. RouteContextexposesmetaso page modules and app code can read it at render time.
afterNavigate hook
Complete the navigation lifecycle surface for a11y, analytics, and side effects.
- Add optional
afterNavigate(context)to constructor options. - Fires after render completes; complements
beforeNavigate. - Enables a11y focus management, analytics events, and post-navigation side effects.
P2: Controlled Expansion
Nested routing
Design-only until a concrete application need is proven. Evaluate alongside
spectre-ui-astro layout patterns before committing to an API.
P3: API Documentation — Complete
These APIs shipped in v1.1.0 and are now documented in the README with copy-able examples.
Unblocked:
spectre-shell-routerPhase 4 P0/P1 (error routes, history helpers) — delivered, see belowspectre-initPhase 6 (lifecycle, title, loading state, plugin templates)spectre-shellP2.5 release (can ship once templates have stable examples)
meta + afterNavigate — document title and a11y pattern
Add a README example showing:
- A route definition with
meta: { title: string }. - An
afterNavigate(context)handler that readscontext.meta?.titleand setsdocument.title. - A note on a11y focus management as a secondary use case.
onNavigationStart / onNavigationEnd — loading state pattern
Add a README example showing:
- Both callbacks toggling a boolean.
- A note that these drive a
navigatingsignal at the app layer viaspectre-shell-signals.
router.subscribe() — signals bridge pattern
Add a README example showing:
- Wrapping
router.subscribe()in asignal()fromspectre-shell-signalsto expose a reactivecurrentRouteat the app layer. - This is the canonical pattern for reactive route state — one example is enough; the goal is a copy-able reference.
4. Phase 4 — Production Readiness
Phase 4 closes remaining gaps that surface in real production applications: explicit
error handling, navigation history helpers, and nested outlet support (promoted from
Phase 3 P2 when a concrete need is confirmed). P0 and P1 shipped in the 1.2.0 release
(2026-07-07); README/CHANGELOG documentation is complete.
P0: Error Routes — Delivered
Provide deterministic, user-visible error handling instead of silent root-clearing.
errorRouteoption onRouterOptions. When a loader throws or no route matches, the router navigates toerrorRoute(viareplace()) rather than silently clearing the outlet. Throws at construction time iferrorRoutedoes not match an existing route path.onError(error, context)callback for apps that want to handle errors programmatically without a redirect. Fires regardless of whethererrorRouteis configured.- Covered in
reliability.test.tsandrouter.test.ts.
P1: Navigation History Helpers — Delivered
Expose History API navigation as first-class router methods.
router.back()androuter.forward()wraphistory.back()andhistory.forward().router.replace(path)navigates topathwithout adding a new history entry (history.replaceState), with the same race-condition guard asnavigate().
P2: Nested Routing
Support child routes rendered inside parent outlet elements.
- Nested route definitions with a child
routesarray on parent route entries. - Parent page modules declare an inner outlet element; child routes render into it.
- Implement only when a concrete
spectre-ui-astrolayout pattern confirms the API shape.
Recommended Execution Order
Navigation subscription API✓Navigating state hooks✓Per-route metadata✓✓afterNavigatehookPhase 3 P3 API docs✓Error routes✓ — Phase 4 P0Navigation history helpers✓ — Phase 4 P1Release prep✓ — published as1.2.0(2026-07-07)- Nested routing ← only open item — only when a concrete app need is confirmed; Phase 4 P2
Explicitly Out of Scope
- Application state management — owned by
spectre-shell-signals - Rendering logic or DOM helpers beyond clearing the route outlet
- Framework-specific adapters (React, Vue, etc.)
- Styling or token definitions — owned by
spectre-tokens/spectre-ui - SSR / server-side routing