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 from grain/ (or the app). It's the substrate; it must extract cleanly.
  • grain/ imports nothing from batch/. It depends only on the OpChannel port

(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.cssglobal.cssgrain.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): 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 grain/scripts/theme.jsATTR/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 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

(/intentinteraction-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.mdonly 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)

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/render)

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 pendingTriggersclearTrigger(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 (variablesglobalgrain);

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). Run: bun run test (unit + integration), bun run test:e2e (browser), bun run test:all (both).

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 testtjakoen.github.io/e2e/*.e2e.tsa 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 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 component: make the dir + the 3–4 files (§4 checklist) in grain/ (or tjakoen.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.css token 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:

RepoTakesTests it carries
batchbatch/** + its __fixtures__/its colocated *.test.ts (no app, no e2e)
graingrain/** (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.tsits *.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.ts paths./grain/components, ./grain/styles, ./grain/fonts become

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.ts moves into the project repo; its webServer.command simplifies 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.