CONVENTIONS
Conventions for BATCH itself and for apps composed from the published stack packages. BATCH is a standalone library. The portfolio repository is the composition root that runs it with GRAIN and MILL. When a rule and the surrounding code disagree, the surrounding code wins until this document is updated; keep them in sync.
Companion docs:PHILOSOPHY.md(the why),ARCHITECTURE.md(the substrate),GRAIN.md(the design system + AI layer),AI-INTERFACE.md(the contract),DESIGN-SYSTEM.md(the visual identity),grain/README.md(usage).
1. Layers & boundaries
The stack is split across package repositories. BATCH provides the substrate. GRAIN owns the design system and its interaction contract, without importing BATCH. MILL renders Markdown through GRAIN. PROOF and CRUMB add plan-board and guided-tour features. The portfolio app brings these packages together at its composition root.
| Package or app | Responsibility | Current source |
|---|---|---|
| BATCH | Server-rendered composition, HTTP helpers, static export, and audit tools | BATCH repository |
| GRAIN | Design system, themes, components, and the human/AI interaction contract | GRAIN repository |
| MILL | Markdown content routes and rendering | @tjakoen/mill in the GRAIN repository |
| PROOF | Read-only plan board | @tjakoen/proof in the GRAIN repository |
| CRUMB | Guided tours and review walkthroughs | @tjakoen/crumb in the GRAIN repository |
| Portfolio | Application pages, content, and the composition root | Portfolio repository |
Hard rules:
- BATCH imports nothing from GRAIN or a consuming app. It stays usable as a substrate on its own.
- GRAIN imports nothing from BATCH. It defines the
OpChannelport and lets the host supply a compatible implementation. - A consuming app wires packages at its composition root. The portfolio does this in
src/server.ts. - Reusable design-system work belongs in GRAIN. App-specific pages and domain components belong in the consuming application.
See the package installation guide for current package relationships, including PROOF's pinned public BATCH dependency.
A consuming product re-skins by overriding token slots in its own sheet linked after GRAIN's three (variables.css → global.css → grain.css) — never by editing components.
What "no-build / native-first" governs (and what it doesn't). The constraint is about the product's runtime, not the dev toolbox. It means exactly two things: (1) no build step — Bun runs the TypeScript directly, no bundler/transpiler between source and server; (2) native-first — the product ships (near-)zero framework JS to the browser (the bun run audit numbers are the proof). It does not mean "zero dependencies." Two things are always fair game and are not violations: platform builtins (fs, path, node:fs/promises — provided by Bun; batch reads files with them throughout) and devDependencies used by tooling that never ships to the client (@playwright/test drives the e2e tests, bun run shots, and bun run audit — it measures the product from the outside, it isn't part of it). The BATCH package itself keeps third-party runtime dependencies out of its dependencies block. An app can depend on published stack packages while keeping its browser runtime native-first. A dev tool importing Playwright, or the substrate importing fs, is the stack working as intended.
Native-first is a positive rule, not just an absence of framework JS. It means: prefer the platform's own primitive over reimplementing it. A <dialog> over a JS modal; <details> over a JS accordion; the View Transitions API over a JS page-animation lib; plain <a> + CSS over a JS tab/router; native constraint validation over JS form validators; the Popover API and CSS anchor positioning over a floating-UI library; :has() / :focus-within / color-mix() / @starting-style / container queries / scroll-driven animations over style-computing JS. The test when reaching for JS: does a browser primitive already do this? If yes, use it. Keep JavaScript for behavior that needs it, such as the /intent dispatcher, terminal, command palette, theme persistence, tabs, and guided demos. These small feature islands are not a client-framework runtime. GRAIN's running inventory of which primitives are in use is in grain/docs/GRAIN.md ("What GRAIN gives you"); page transitions are worked in ARCHITECTURE §11.3.
2. TypeScript
- Factories, not classes, for wiring:
createX(deps)/makeX(opts)returning a small object of closures (e.g.createInteractionLayer,makeStubReasoner,createStream). Classes are reserved for port implementations (InMemoryItemRepository implements ItemRepository), domain error types (HttpError), and plain service aggregators. - Depend on interfaces, not implementations. Every cross-layer seam is a named
interface(OpChannel,Reasoner,ReasonTools,ItemRepository,Runtime,Stream). Inject the concrete thing at the composition root. - Erasable TypeScript only (
erasableSyntaxOnly+verbatimModuleSyntax): noenum, nonamespace, no parameter-properties. Model closed sets as a union + a const registry (seeActionName/ACTIONS). Useimport typefor type-only imports. any/as/!are allowed only where genuinely necessary (the generic render engine's data tree; a cast right after a runtimetypeofcheck). Never to silence a real type.- One header comment per file:
// <path> — one line on what it is (+ a doc ref if useful).
3. Vocabularies — single source of truth (NO magic strings)
The rule: no magic strings. Any string referenced in more than one place is a vocabulary — define it once and reference the definition; never re-type the literal. Server/TS code uses a const or a const registry (union + object); a browser module that can't import TS uses a single named-const block at the top of the file (e.g. the theming attributes/values/control names in packages/grain/scripts/theme.js — ATTR/SCHEME/CTRL/KEY). If you're typing the same string twice, that's the smell.
The only literals allowed are the cross-layer ones a static file genuinely can't import — HTML attributes, CSS selectors, and browser JS. Those must still be (a) single-sourced on the JS side and (b) validated at boot by a drift guard in server.ts so a typo fails loudly, not silently. Two such guards exist today: the action vocabulary (harvested data-action/data-accepts checked against ACTIONS) and the theming vocabulary (theme flavors referenced in markup checked against the [data-theme="…"] blocks in variables.css). Add a guard whenever you add a cross-layer vocabulary.
The action vocabulary
packages/grain/ai/contract.ts is the SSOT for everything addressable/operable:
SurfaceKind— the closed set of surface kinds; build addresses withsurface(kind, id?), never by hand-concatenating strings.ActionName+ACTIONS— the closed verb registry ({ depth, accepts: SurfaceKind[] }). Control signals get a named constant (e.g.STOP_ACTION), not a literal.- A human click and an AI decision both become the same
Intent, enter the one door (/intent→interaction-layer.ts), and return asRenderOps addressed to surfaces. There is no privileged AI→DOM back channel.
Adding a verb: add it to ActionName + ACTIONS (with its accepts), handle it in the reasoner, and reference it through the registry in TS. String literals are acceptable only in HTML attributes (data-action, data-accepts) and in browser JS that can't import the contract (the dispatcher) — both are validated server-side by the drift guard in server.ts.
4. Components
Each component is a self-contained directory under packages/grain/components/<layer>/<name>/ (design system) or view/components/<layer>/<name>/ (the app's domain components), where layer ∈ atoms / molecules / organisms.
New-component checklist
- [ ]
<name>.html— the template (binding vocabulary below). Header comment:<!-- <layer>/<name> — … -->. - [ ]
<name>.css— component-owned styles. Header comment naming the component + its rule. - [ ]
<name>.md— the catalog doc (Human view):# Name, prose,## Section+ fenced HTML examples. - [ ]
<name>.ai.md— only if the component has AI-mode behavior distinct enough to need its own panel (else the catalog grain-flips the Human view automatically). - [ ] If the component is an addressable surface that accepts actions, declare
data-kind data-accepts="verb …"on its root (harvested into the AI manifest; e.g.mail-row).
CSS-only components (layout / pattern)
Some components have no .html template — they're a class + docs (.css + .md), composed by hand rather than data-bound. This is deliberate for layout shells and patterns (app-shell, side-rail, tab-bar, chat-log) and data-driven atoms rendered as raw markup (b-badge, b-list). The checklist's .html is required only for components BATCH's render package expands as a tag. If a CSS-only component depends on parent context to work (e.g. chat-message needs a chat-log's flex column for its align-self), state that requirement in its .md — an unstated layout dependency is a silent-failure trap.
Class naming
- One root class per component, variants as attributes, not extra classes (
.btn[data-variant="soft"], never.btn.soft). The component owns its styling. - Child elements: BEM (
.card__title) when there are real sub-parts; a single semantic class is fine for trivial ones. Be consistent within a component.
Attribute taxonomy (keep these distinct)
| Attribute | Meaning | Examples |
|---|---|---|
data-variant | presentation choice | soft, outline, sm, lg |
data-status | semantic/domain state | active, archived, success, danger |
data-state | transient UI state | error, loading |
data-commit | grade = commit state (AI/in-transit) | pending |
data-grade | provenance grade (usually on an ancestor) | grain, smooth, accent |
Template / binding vocabulary (interpreted by BATCH's render package)
| Form | Means |
|---|---|
slot-tag prop-as="…" | polymorphic element (becomes as); for atoms that render different tags |
prop-attr-X="prop" | config prop → HTML attribute X |
prop-text="prop" | config prop → element text |
data-field="path" · data="path" | bind text / scope a child from the data object ("." = self) |
data-bind-X="path" | bind attribute X from data (e.g. data-bind-data-surface="surface") |
each="path" | repeat once per array item |
data-kind + data-accepts | manifest declaration (AI capabilities) |
AI-mode (grade-as-signal) — one idiom everywhere
A component reads "AI / in-transit" via [data-commit="pending"] (live) and [data-grade="grain"] (static/ancestor) — never a bespoke indicator. Express it the component's own way, but keyed off those two:
- text → grain font (inherited via
--type-font, free); - controls/tags → dashed "terminal" edge (
b-button,b-input,b-badge); - cards → dimmed + dashed outline (
task-card,work-card); - the actively-working control adds the blinking caret (
b-button).
The control lifecycle (one rule for any operator — human or AI). A control the AI operates enters data-commit="pending" the moment it's used and HOLDS it until that action's output commits, then releases to the clean/human state — it does not flash and clear. pending is the whole "working" span, not the click instant. The real dispatcher implements this via pendingTriggers → clearTrigger(target) on the committed op (ai-dispatch.js, AI-INTERFACE.md §5); any client-side driver or demo must follow the same lifecycle (don't hand-roll a bespoke "running" state). Nested actions each hold their own control (a run's trigger stays pending for the whole run while each sub-control holds for its own action). See DESIGN-SYSTEM.md §3, AI-INTERFACE.md §5, and the memory grade-as-signal-decisions.
5. CSS & tokens
- Token-first. No hardcoded colors, ever (zero
#hex/rgb()in component CSS — audited). Usevar(--token). Rawpxonly for true hairlines/offsets (1px,2pxoutline). - Two layers in GRAIN's
packages/grain/styles/variables.css: primitives (palette, scale, grades) → semantic aliases (--color-*,--type-font,--ai-veil,--ai-focus-move). Components read only the semantic aliases; re-theming repoints them in one place. - GRAIN's three page-level sheets are linked in order (
variables→global→grain); per-component CSS + the AI module (ai.css) are bundled into/components.css. - The self-contained islands (
cmdk.js) may hardcode token fallbacks (var(--ink, #1C1B17)) so they drop onto any page — keep fallbacks matching the tokens. - Respect
prefers-reduced-motionfor every animation/transition.
6. Testing — three tiers, write them as you build
Testing is part of the architecture, not an afterthought. Every feature should land with the appropriate tier(s). From the portfolio root, run bun test for unit and integration tests, bun run test:e2e for the browser suite, or bun run test:all for both. Package repositories keep their own test commands in their README files.
| Tier | Runner | Files | What it covers |
|---|---|---|---|
| Unit | bun test | *.test.ts (colocated) | one module in isolation; deps faked. Pure logic, the reasoner, render engine, services, parsing. |
| Integration | bun test | *.integration.test.ts (colocated) | several real modules together over HTTP/SSE, no browser. The door → reasoner → push path, routes, manifest. |
| E2E | playwright test | e2e/*.e2e.ts in the portfolio repository | a real browser against the running app. The client dispatcher (click/Enter → /intent → SSE → DOM), the spotlight, the <dialog> palette, interrupts, auto-scroll, view transitions. |
Tests travel with the code they test: BATCH and the GRAIN-family packages carry tests beside their source. The portfolio keeps application unit and integration tests under src/ and its browser suite under e2e/. Package tests verify a layer on its own; the portfolio suite verifies that the installed packages work together in the running app.
Conventions
- Split by extension so the runners never collide: Bun owns
*.test.ts(incl.*.integration.test.ts); Playwright owns*.e2e.ts(configured inplaywright.config.ts). - Unit/integration use fakes/doubles for ports (see
fakeStream(),fakeTools()patterns) andthinkMs: 0to skip the reasoner's pacing delays. Fixtures live in__fixtures__/. - E2E asserts user-visible outcomes via roles/
data-surface, not internals; the PlaywrightwebServerboots the app inproduction(no hot-reload noise). First browser run needsbunx playwright install chromium. - Coverage bar: anything with branching logic gets a unit test; any new route/door path gets an integration test; any new client-JS interaction gets an e2e test. Don't ship an untested new path — the client dispatcher and reasoner are load-bearing.
- Assert the EFFECT, not the marker. A test that only checks an attribute flipped, a class was added, or an element exists — but never that the user-visible result changed — passes green while the feature is inert. That is how the terminal-expand knob shipped dead: the e2e asserted
toHaveAttribute("data-console-expanded", "")(the marker) while nothing on screen grew (grain lesson 9). For any toggle/animation/layout change, assert the observable consequence — geometry, visibility, text, or count actually changed (e.g. main collapses to ~0 and the console fills the space), not that the switch was set. If a mechanism consumes a token/attr, the test must measure the motion or layout it produces. See AUDIT check 12 for the matching mechanical guard. - Visual regression baseline (
e2e/visual.e2e.tsin the portfolio repository): the behavior specs assert what a screen does; they don't catch a shifted margin, a dropped border, or a broken grid.toHaveScreenshotpins the pixels of the key static screens (welcome,/grain,/batch,/catalog,/about) so a silent visual regression fails loudly. Baselines are committed (visual.e2e.ts-snapshots/, per-OS) and CSS animation is frozen at capture; only deterministic screens qualify (mid-run/typing states are non-deterministic — leave those tobun run shots). After an intentional visual change, re-bless:bun run test:e2e visual --update-snapshots.
7. Errors & observability
- Substrate/domain: throw typed errors (
HttpError);jsonError()logs server-side and returns a safe message — never leak internals to the client. - The AI door: invalid intents are rejected at the door with a
flashop (UI feedback), not an exception; failed writes roll back to aflash+ok:falsedecision. - htmx fragments return a friendly error fragment with
200so the swap still happens; JSON routes use the proper status. - Log with a tagged prefix (
console.error("[interaction-layer]", e)); user-facing copy stays plain and reassuring ("Couldn't complete that — left it as it was.").
8. Naming & files
- Files & directories: kebab-case. Component dir name = its tag (
b-button,task-card). - One file = one concern; colocate a module's test next to it.
- Headers everywhere (§2). Section dividers in CSS:
/* ---- label ---- */. - Commit messages: imperative subject, a short body explaining why. No AI attribution trailers (the AI-use receipt lives in the README badge + footer, not commit metadata).
9. Quick "add a …" recipes
- A reusable component: add it to the GRAIN package in the GRAIN repository. An app-specific component belongs in that application's component directory. Use the GRAIN tokens and add tests for behavior the component owns.
- An action or verb: extend GRAIN's contract and reasoner, then cover the interaction in the package tests and the host app's integration path.
- A portfolio page: add it under
view/pages/in the portfolio repository, link the GRAIN stylesheets and component bundle, give operable regions adata-surface, and cover new interactions with a browser test. - A theme change: edit GRAIN's default theme in the GRAIN repository, or override its tokens in the consuming app when the change is specific to that app.
10. Package boundaries in current work
The package split is complete. Make changes in the repository that owns the package or application being changed, and test that boundary there.
| Repository | Owns | Verification |
|---|---|---|
| BATCH | The substrate package, including rendering, HTTP helpers, export, audit, and its unit tests | Run bun test and bun run check in the BATCH checkout. |
| GRAIN | GRAIN, MILL, PROOF, CRUMB, their package tests, and the workspace-only MCP server | Run the workspace checks from that repository. |
| Portfolio | The composition root, site routes, domain components, content, and browser tests | Run the portfolio typecheck, unit suite, and Playwright suite. |
The application owns the integration between packages. A shared feature that belongs in a package should be implemented and released by that package's maintainers, then consumed by the app through its package interface. Avoid restoring the old monorepo layout through copied source folders or relative imports across repository boundaries.