CONVENTIONS
How we build on this stack. The goal is one consistent, reusable, scalable way to do each thing — so new code (and new sessions) extend the grain instead of fighting it. When a rule and the surrounding code disagree, the surrounding code wins until this doc 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
Four concerns, one direction of dependency (each layer builds only on those below):
batch/ the no-build hypermedia substrate (render, http, assets, catalog, platform)
└─ grain/ the design system + optional AI-interaction layer (default theme lives here)
├─ mill/ the Markdown→GRAIN CMS (a reusable layer above grain; built)
└─ tjakoen.github.io/ THE app + composition root — a custom BATCH+GRAIN site that uses
MILL for content; holds domain components, routes, pages, server.ts
(project/ the AI-assistant product — PAUSED since 2026-07-05, a docs-only archive)Hard rules (enforced by review; verified in the audit):
batch/imports nothing fromgrain/(or the app). It's the substrate; it must extract cleanly.grain/imports nothing frombatch/. It depends only on theOpChannelport
(grain/ai/contract.ts) — never a concrete substrate. It ships its own default theme.
tjakoen.github.io/wires the graph. Cross-layer dependencies are declared as **constructor/factory
params, and the only place the layers meet is tjakoen.github.io/server.ts** (the composition root).
- New design-system work goes in
grain/by default (it's reusable). Only obviously
app-specific things (a one-off page layout, a domain component like task-card) live in the app (tjakoen.github.io/). Test: "would another product on GRAIN want this?" → yes = grain, no = the app.
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 bar to defend is the dependencies block in package.json: keep third-party runtime deps at zero (today only bun itself). 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; only write JS when none does (in this stack the sole such case is the /intent dispatcher). 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, 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 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
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 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 grain/components/<layer>/<name>/ (design system) or tjakoen.github.io/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/render 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/render)
| 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).
Use var(--token). Raw px only for true hairlines/offsets (1px, 2px outline).
- Two layers in
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). Run: bun run test (unit + integration), bun run test:e2e (browser), bun run test:all (both).
| 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 | tjakoen.github.io/e2e/*.e2e.ts | 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 (this is what makes the repo split clean, §10): unit tests are colocated in batch/grain/project; the door integration test lives in tjakoen.github.io/src/routes/ (it exercises the app's composition); e2e lives in tjakoen.github.io/e2e/ because it drives the product. batch/grain carry only their own unit tests; a grain demo harness would get its own e2e when grain is extracted.
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 (
tjakoen.github.io/e2e/visual.e2e.ts): 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
flashop (UI feedback),
not an exception; failed writes roll back to a flash + ok:false decision.
- 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 component: make the dir + the 3–4 files (§4 checklist) in
grain/(ortjakoen.github.io/
if domain-only); use tokens (§5); express AI-mode via the shared idiom; add a .md example; if it accepts actions, declare data-kind/data-accepts. Add a unit/e2e test if it has behavior.
- An action/verb: extend
ActionName+ACTIONS(§3), handle it in the reasoner, reference
it via the registry; add a unit test (reasoner) + integration test (door path).
- A page: add
tjakoen.github.io/pages/<name>.html, link the three GRAIN sheets +/components.css,
give acting regions a data-surface; e2e-test any new interaction.
- A theme tweak: edit
grain/styles/variables.csstoken values (or a project override
sheet) — never per-component.
10. On extraction (the future repo split)
The three dirs are headed for three repos: batch (a published substrate package), grain (a design-system package on a substrate), project (the product, on grain). The boundaries (§1) are kept clean so the split is a copy, not a rewrite. What goes where:
| Repo | Takes | Tests it carries |
|---|---|---|
| batch | batch/** + its __fixtures__/ | its colocated *.test.ts (no app, no e2e) |
| grain | grain/** (AI layer, components, default theme, fonts, islands, the catalog) | its colocated *.test.ts; adds its own e2e against a minimal demo harness |
| tjakoen.github.io (the app) | tjakoen.github.io/** incl. tjakoen.github.io/e2e/ + a copy of playwright.config.ts | its *.test.ts, the door *.integration.test.ts, and the e2e suite |
| project (paused) | project/** — docs-only archive (PROJECT-PLAN.md, docs/MVP.md); no code/tests until the product resumes | — |
Monorepo-level tooling (package.json, tsconfig.json, playwright.config.ts, bun.lock, .gitignore) is split/copied per repo. What changes on extraction (and only this — the code doesn't):
tjakoen.github.io/config.tspaths —./grain/components,./grain/styles,./grain/fontsbecome
resolved package paths (or stay relative if vendored). The static/serving wiring follows.
- Cross-layer imports —
../batch/*and../grain/*become package imports
(@org/batch, @org/grain). Nothing else: batch imports nothing inward, and grain already depends only on the OpChannel port + the binding-vocabulary contract.
playwright.config.tsmoves into the project repo; itswebServer.commandsimplifies to
bun server.ts (cwd becomes the repo root, so config.ts's relative roots still resolve).
- The drift guard + manifest harvest keep working unchanged.
Keep this true as you build: if a new cross-layer dependency can't be expressed as "project → grain → (port) ← batch", it's a smell — add a port, don't reach across.