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 appResponsibilityCurrent source
BATCHServer-rendered composition, HTTP helpers, static export, and audit toolsBATCH repository
GRAINDesign system, themes, components, and the human/AI interaction contractGRAIN repository
MILLMarkdown content routes and rendering@tjakoen/mill in the GRAIN repository
PROOFRead-only plan board@tjakoen/proof in the GRAIN repository
CRUMBGuided tours and review walkthroughs@tjakoen/crumb in the GRAIN repository
PortfolioApplication pages, content, and the composition rootPortfolio 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 OpChannel port 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): no enum, no namespace, no parameter-properties. Model closed sets as a union + a const registry (see ActionName / ACTIONS). Use import type for type-only imports.
  • any / as / ! are allowed only where genuinely necessary (the generic render engine's data tree; a cast right after a runtime typeof check). 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 with surface(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 as RenderOps 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)

AttributeMeaningExamples
data-variantpresentation choicesoft, outline, sm, lg
data-statussemantic/domain stateactive, archived, success, danger
data-statetransient UI stateerror, loading
data-commitgrade = commit state (AI/in-transit)pending
data-gradeprovenance grade (usually on an ancestor)grain, smooth, accent

Template / binding vocabulary (interpreted by BATCH's render package)

FormMeans
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-acceptsmanifest 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). Use var(--token). Raw px only for true hairlines/offsets (1px, 2px outline).
  • 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-motion for 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.

TierRunnerFilesWhat it covers
Unitbun test*.test.ts (colocated)one module in isolation; deps faked. Pure logic, the reasoner, render engine, services, parsing.
Integrationbun test*.integration.test.ts (colocated)several real modules together over HTTP/SSE, no browser. The door → reasoner → push path, routes, manifest.
E2Eplaywright teste2e/*.e2e.ts in the portfolio repositorya 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 in playwright.config.ts).
  • Unit/integration use fakes/doubles for ports (see fakeStream(), fakeTools() patterns) and thinkMs: 0 to skip the reasoner's pacing delays. Fixtures live in __fixtures__/.
  • E2E asserts user-visible outcomes via roles/data-surface, not internals; the Playwright webServer boots the app in production (no hot-reload noise). First browser run needs bunx 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.ts in 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. toHaveScreenshot pins 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 to bun 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 flash op (UI feedback), not an exception; failed writes roll back to a flash + ok:false decision.
  • htmx fragments return a friendly error fragment with 200 so 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 a data-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.

RepositoryOwnsVerification
BATCHThe substrate package, including rendering, HTTP helpers, export, audit, and its unit testsRun bun test and bun run check in the BATCH checkout.
GRAINGRAIN, MILL, PROOF, CRUMB, their package tests, and the workspace-only MCP serverRun the workspace checks from that repository.
PortfolioThe composition root, site routes, domain components, content, and browser testsRun 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.