This file is the single source of truth for AI assistants working in this repo. CLAUDE.md imports it for Claude Code; Codex and other AGENTS.md-aware tools read it directly. Edit conventions here, not in CLAUDE.md.
Quiver is an Arrow.js starter template with file-based routing, layouts, reactive state, composables, and a full testing setup. Stack: Arrow.js · Vite · Tailwind CSS v4 · Vitest · Playwright. The router is built on the Navigation API (modern browsers only).
src/
├── framework/ # Router, DI, store, app bootstrap — edit with care
├── pages/ # File-based routes — add your pages here
├── layouts/ # Page wrapper layouts — add layouts here
├── components/ # Reusable UI components — add components here
├── state/ # Global reactive state modules — add stores here
├── composables/ # Reusable logic functions — add composables here
├── utils/ # Pure helper functions (hmrState)
└── main.js # App entry point — provide() global DI keys here
tests/
├── framework/ # Unit tests for framework utilities (Vitest)
├── composables/ # Unit tests for composables (Vitest)
└── e2e/ # End-to-end tests (Playwright)
npm run dev # Start Vite dev server
npm test # Run unit tests once
npm run test:watch # Run unit tests in watch mode
npm run test:e2e # Run E2E tests (Playwright)
npm run typecheck # Type-check src/ via JSDoc (tsc --noEmit, checkJs)
npm run build # Production build
npm run docs:dev # Start docs site locally
npm run docs:build # Build docs site
Always run npm run typecheck && npm test && npm run test:e2e before completing a task.
The codebase is plain JavaScript typed via JSDoc, checked by tsc under checkJs + strict (see jsconfig.json). CI enforces a clean npm run typecheck. When adding code:
- Annotate exported functions with
@param/@returns; use@typedeffor shared shapes and@templatefor generics - Reuse existing typedefs via import syntax:
/** @typedef {import('../state/userState.js').User} User */ - Prefer a JSDoc cast
/** @type {X} */ (expr)over restructuring code to satisfy the checker - Do not convert files to TypeScript — the plain-JS + JSDoc setup is deliberate
These are non-obvious constraints. Violating them causes silent bugs or runtime errors.
-
Reactive slots must be arrow functions. Any
${}interpolation referencing state that can change must be() =>:- Correct:
html`<p>${() => user.name}</p>` - Wrong:
html`<p>${user.name}</p>`(renders once, never updates)
- Correct:
-
No HTML comments inside templates. Arrow.js uses HTML comment nodes as internal slot markers. Adding
<!-- -->insidehtml`...`throwsInvalid HTML position. Put comments outside the template literal. -
.disabledis not the DOM disabled property. Writing.disabled="${() => bool}"sets a literal attribute named.disabled— the button remains clickable. Usearia-disabled="true/false"plus CSS (opacity-50 cursor-not-allowed) instead. -
Use
.key()on components in loops. Without.key(uniqueId), Arrow re-creates DOM nodes on every state change:- Correct:
html`${() => items.map(i => Card({ i }).key(i.id))}`
- Correct:
File src/pages/path.js → route /path. Structure:
import { html } from '@arrow-js/core'
import { useMeta } from '../framework/index.js'
export const meta = { layout: 'menu', title: 'Page Title' }
function MyPage() {
useMeta({ title: 'Page Title' })
return html`<div>...</div>`
}
export default MyPage[param].js→ dynamic segment; read params withuseRoute().params()inside the functionnot-found.jshandles 404useMetaalso accepts functions for reactive values:useMeta({ title: () => `Team (${userState.users.length})` })— the router stops these watchers automatically on every navigation (seesrc/pages/users/[id].jsfor a working example)
beforeEach(fn) from src/framework/router.js registers a guard and returns an unregister function. The guard receives { from, to }; return false to cancel, a path string to redirect, anything else to proceed. Guards also run on the initial page load with from: null — on first load, false redirects to / (there is no previous page to stay on).
import { beforeEach } from './framework/router.js'
beforeEach(({ from, to }) => {
if (to.startsWith('/admin') && !authState.loggedIn) return '/login'
})Register guards in main.js before initRouter().
import { createStore } from '../framework/index.js'
export const myState = createStore((reactive) => {
const state = reactive({ items: [] })
return {
get items() { return state.items },
addItem(item) { state.items.push({ ...item, id: crypto.randomUUID() }) },
}
})Create at module scope; export as a singleton.
import { html } from '@arrow-js/core'
export function MyCard({ title }) {
return html`<div class="card"><h2>${title}</h2></div>`
}Import components directly from their files (e.g. import { MyCard } from '../components/MyCard.js') — there is no barrel file. Apply all Arrow.js rules above.
For local component state that should survive Vite hot reloads in dev, use hmrState(key, initialState) from src/utils/hmrState.js. The key must be unique per component instance on the page (derive it from a prop, e.g. `counter-${props.label}`) — duplicate keys silently link state between instances in dev mode.
Call these inside a page or component function, never at module scope:
useRoute()→{ path(), params(), status(), meta() }— reactive route accessorsuseRouter()→{ go(path), back(), forward() }— navigationuseForm(values, { validate, onSubmit })→{ form, handleSubmit, field(name) }— form state;validatemay be sync or async, return{}when valid or{ fieldName: 'message' }on errors; a thrownonSubmiterror is caught and written toform.messageuseFetch(url, options)→{ data(), loading(), error(), status(), refetch(), reset() }— HTTP with reactive state; options:{ immediate = true, transform, delay, ...fetchOptions }; refetch aborts the previous in-flight request. Use this for API calls — do not hand-roll fetch + loading/error state.useToast()→{ success(msg, opts), error(msg, opts), warning(msg, opts), info(msg, opts), dismiss(id) }— use this for user notifications — do not build ad-hoc banners. Options per call:{ duration, dismissible }
ToastContainer is already mounted by the built-in layouts — calling useToast() from any page or component just works. Global defaults via toastState.configure({ position, duration, dismissible }); positions: top-left, top-center, top-right, bottom-left, bottom-center, bottom-right.
meta.layout: 'menu' (sidebar + header) or 'basic' (centred card). Register new layouts in src/layouts/index.js.
Two orthogonal attributes on <html>: data-theme (default · mono · glass · retro · brutalist) and data-mode (light · dark), both driven by uiState and persisted to localStorage. Components use semantic tokens (bg-surface, text-fg, border-line, bg-brand, rounded-panel, shadow-panel) — never hard-code colors, or theme switching breaks. Per-theme utility overrides via variants: theme-glass:backdrop-blur-md, theme-brutalist:border-2, etc.
To add a theme: token override blocks (light + dark) in src/style.css, an optional @variant theme-<id> line, and an entry in the THEMES array in src/components/ThemeSelector.js. See the Theming guide or the /add-theme command.
provide(key, value) in main.js before createApp(); inject(key, fallback) anywhere. The context is app-global — last provide for a key wins. Built-in keys:
'app'—{ name, tagline }— used byMenuLayoutsidebar'currentUser'—{ name, email, avatar }— used byMenuLayoutheader
- Unit: Vitest in
tests/framework/andtests/composables/— test pure functions withdescribe/it - E2E: Playwright in
tests/e2e/— test real browser flows; usegetByRole,getByLabel,getByTestId - Both suites must pass before a task is complete
Fix bugs in src/framework/, improve tests, correct docs. Do not add app-specific features, backend/auth, or swap Arrow.js — those belong in a fork.