BATCH — Reference Architecture (Single Source of Truth)
BATCH = Bun · Addressable · TypeScript · CSS · Htmx — a no-build, server-rendered hypermedia stack. Reusable design-system component tags use the b- prefix (<b-button>); your app's own components keep semantic names (item-card, app-header).
Status: Living document · Runtime: Bun 1.3.14. Backend certified 2026-06-26 (§14.1); the frontend layer — pages, sitemap, the b- component set, self-closing/prop-text — added and audited 2026-06-27 (§14.4). (The component catalog since moved UP to GRAIN — its Human/AI grade toggle is grain's vocabulary — so it's no longer BATCH's; see grain/catalog.) The AI interaction layer (server-push over SSE, the one /intent door, render ops, grade-as-signal) and the Sourdough design-system retheme were added and audited 2026-06-30 (§17, §14.5), then the repo was reorganized into a monorepo the same day (§3). The code in batch/ grain/ project/ is the source of truth.
The concerns in this repo (monorepo).batch/is the stack this document describes (the reusable no-build hypermedia substrate).grain/is GRAIN, the AI-interaction design system built on BATCH (docs/GRAIN.md).project/is the product (a personal AI assistant) + its skin;tjakoen.github.io/server.tsis the one place those three meet. No Bun workspaces — plain relative imports, onepackage.json+tsconfig. Each is headed for its own repo once proven; the boundary is kept clean (batch/imports nothing fromgrain//project/, verified). Two further resident concerns build on the stack:tjakoen.github.io/(a personal site — temporary resident, moving to its own repo) and, planned,MILL/— a reusable markdown→GRAIN CMS that sits abovegrain(depends ongrain+batch, never the reverse; an extension of neither). MILL's core is framework-agnostic (a Markdown→components engine driven by a render adapter); its default adapter emits GRAIN + serves on BATCH. The portfolio uses MILL to manage its markdown content — its notes/blog and the rendered BATCH/GRAIN docs — but is otherwise a custom BATCH + GRAIN app. Seemill/PLAN.md(canonical) +tjakoen.github.io/docs/architecture/PLAN.md(consumer view). Product docs are underproject/docs/; the doc map isDOCS.mdat the repo root.
Every code block here has been run on Bun 1.3.14. For the final revision the entire backend was assembled exactly as specified and certified: tsc typechecks green under erasableSyntaxOnly + verbatimModuleSyntax (TS 6.0), the full test suite passes, and the server serves /ui (HTML) and /api (JSON) correctly. See §14.1 for the audit log.
What BATCH gives you (capabilities)
The capabilities, tiered — headliners first, then the useful-but-quieter ones. Nothing here is buried. This list is the single source (the README and the /batch landing page teaser it); add or drop a capability → update this list (../../CLAUDE.md alignment table → AUDIT.md check 11).
Hero — the reasons BATCH exists:
- No build step. Bun runs the TypeScript directly and the composition engine renders components
server-side — no bundler, no transpile step, no client framework, no template dialect between source and server. It can even serve .ts to the browser transpiled on request (§19, client-safe modules), so even client code has no build. Near-zero framework JS ships.
- Static export = a projection of the running server.
bun run export(§18) crawls the live
server and freezes it to static files — never a second renderer, so the export can't drift from the app. It also freezes a client-module graph (moduleEntries — each entry's relative import graph, transpiled at export). Content pages export; operable /intent+SSE surfaces are inert by design unless a page opts into the client-side door (§19.3), which runs the same vocabulary fully in-browser on the static copy.
Also — useful features, deliberately listed:
- The composition engine — a small binding vocabulary (
data-field/each/slot-tag/props) that
turns component tags into HTML, zero third-party runtime deps (§2, §9).
- A generic SSE push hub (
http/stream.ts) — opaque-payload server push; satisfies GRAIN's
OpChannel port structurally without BATCH knowing anything about RenderOps (§17).
- The component catalog now lives in GRAIN (
grain/catalog), not BATCH — its Human/AI grade
toggle is grain's grade-as-signal vocabulary. It reads the filesystem directly and imports nothing from batch; the composition root passes it the page-nav routes. (§13a is retained for the Storybook-style generation technique it still uses; ownership is grain's.)
- Sitemap + SEO/AEO from one source — one page list feeds
/catalog,/sitemap.xml,/robots.txt,
and an /llms.txt AI-facing index (the AEO counterpart to the sitemap; §11.4).
- A framework-generic audit engine — perf + SEO/AEO baselines via Playwright, vocabulary-agnostic
so it can bench any app/framework, not just this one (batch/audit).
0. Principles
- One file, three roles. A component's
.htmlis the designer's mockup, the
production template, and the documentation of its own bindings.
- No build step. No client framework. No template dialect.
- Standards first, native second, library last. Prefer web standards; then
runtime-native APIs; add a controlled library only when neither suffices.
- Dependency inversion at every seam. Services depend on interfaces; the
database, the runtime, and the data source are all injected adapters.
- Clean code, SOC, DRY. Each module has one reason to change. Mapping logic
lives in one place. Layers point inward (transport → application → domain).
0.5 Rationale & trade-offs (the whys, honestly)
This records why the choices were made and what they cost — so future decisions are made with eyes open, not by inertia.
The values behind these choices — the beliefs, stated crisply — live in PHILOSOPHY.md. This section is the engineering rationale and the costs.Why a hypermedia / server-rendered-fragment stack. Avoids a client framework, a build step, and client/server state-sync bugs; yields a standards-based stack that should age slowly. Cost: rich client interactions (drag-and-drop, optimistic UI, offline) are genuinely harder with htmx than with a client framework. When a feature wants those, expect to fight the grain or add a small island of JS. Accepted because the target (a personal dashboard / second-brain surfaces) is mostly request→render→swap.
Why a custom composition engine instead of a templating library — and is it a maintenance trap? Measured, not assumed: the entire engine is ~180 non-blank lines with zero runtime dependencies (only Node-builtin fs/path plus native globals: HTMLRewriter, Response, Bun.file). Crucially, the one part that is genuinely dangerous to own — HTML escaping — is delegated to the native parser (setInnerContent), not hand-rolled; verified that a <img onerror> payload in data renders inert. So the "native-first to minimize maintenance" thesis holds: you are not maintaining a real templating engine, you are maintaining ~180 lines whose scariest job is done by the platform. Caveat the platform does NOT cover: HTML escaping neutralizes markup but not URL schemes — a data-driven javascript:/data: value in an href/src survives escaping. So URL-valued bound attributes go through an explicit scheme allowlist (safeAttr, §9), the one XSS class the native parser won't catch for you. The honest residual burden:
- The regex-based polymorphic-tag swap / prop substitution in
applyProps
(~12 lines) is the only hand-rolled string manipulation of HTML. It operates only on author-controlled templates, never on user data (verified data cannot inject through it), and prop values are quote-escaped — so the risk is authoring mistakes, not injection. Still, it's the one spot to keep simple.
- Single-vendor coupling to Bun's
HTMLRewriter. Mitigated: it's the
Cloudflare Workers API (multiple implementations exist) and isolated to one file. If Bun changes it, you adapt one module.
Why this much structure (domain/data/services/view/routes, ports everywhere) for a solo project. This is enterprise-grade layering, and that is a real tension with "build for yourself / don't over-engineer." The trade is taken deliberately: the stated goal is longevity and clean swapping (storage, runtime), and the ports have already been exercised (in-memory → SQL → REST behind one interface). Cost: more indirection than a quick solo build needs. Mitigation rule: if the structure ever feels heavy while moving fast, collapse a port (inline the adapter, keep the interface) — the ports are the first thing to give.
Why a single polymorphic b-text / b-button instead of many named atoms. A preference, not a law: it trades discoverability (lots of named atoms) for DRY (one configurable atom). Stated as a choice, not the only correct answer.
Known assumptions not yet stress-tested. The engine is verified at small scale; recursion depth, very large lists, and concurrent renders are unproven at the scale "scale to any size" implies. Progressive enhancement is partial: with JS disabled, /ui endpoints return fragments, not full pages. Neither blocks the plan; both are named so they aren't discovered as surprises.
Why pages aren't openable as bare file:// files — and why that's fine. This came up and was decided deliberately. You can have any two of: (1) author pages with custom component tags (<b-button>), (2) open them as bare files with no server, (3) no build step. Never all three — it's a web-platform limit, not a design weakness. Rendering a custom tag needs its template at render time, and there are only three places that can live: the server expands it (needs a server, so not a bare file), the client fetches the .html (blocked on file:// — browsers forbid file-origin fetch), or the client has it inlined in JS (something inlined it → a build). No fourth option exists.
The resolution: the server is the no-build step. Bun runs .ts directly, the engine composes on every request, hot-reload watches files — edit and refresh, no compile, no toolchain. A build and a server are two ways to do the same job (source → rendered HTML); we put it in the server. So pages are previewed on localhost (instant, no build), and individual component .html files still open as standalone mockups (default content, CSS applies) for design work. We keep no-build + custom tags and give up only "open the composed page as a literal file" — which, since htmx data can't load from file:// anyway, would never have been a working page, only a static mockup. A static export/prerender remains available later as an opt-in if shareable static files are ever needed; it does not affect the no-build dev loop.
1. Runtime & Language decision
1.1 The choice
Three things were tangled together — build step, language, runtime. They have different answers.
Build step is solved on both Bun and Node 24+/25+: native TypeScript type stripping runs .ts directly, no transpile. So "no build" does not require Bun.
The deciding factor is native server-side HTML parsing, which the composition engine needs every request:
| Runtime | TS, no build | Native HTML parse/rewrite | Native SQLite |
|---|---|---|---|
| Bun | yes | yes — HTMLRewriter (lol-html) | yes (bun:sqlite) |
| Node 24+ | yes (type-strip) | no — needs a library | yes (recent) |
| Deno | yes | no — needs deno-dom | yes |
Node and Deno would force an HTML-parsing dependency (cheerio/parse5/linkedom/ deno-dom) — a larger violation of "library last" than using Bun's built-in. So:
Decision: Bun runtime + erasable-only TypeScript + a thin platform port. The port isolates every Bun-specific call so the runtime is a swappable seam, not a lock-in. HTMLRewriter itself is the Cloudflare Workers API (multiple implementations exist), the lowest-risk of the Bun APIs.1.2 Erasable-only TypeScript rules
Native type stripping only removes syntax; it never emits runtime code. Stay inside the erasable subset:
- No
enum, nonamespace, no parameter properties. Useas constobjects
or union types (we use "active" | "archived", already erasable). Constructors declare fields explicitly (private x: T; constructor(x: T) { this.x = x; }), never the constructor(private x: T) shorthand — that shorthand is not erasable and breaks both the CI gate below and Node's strip-only loader.
import type { X }for type-only imports (otherwise stripping breaks them).- Relative imports include the extension:
import { x } from "./x.ts". tsconfig.jsonis for checking only. Bun runs the files;tsc --noEmit
in CI is the type-check. Recommended flags: erasableSyntaxOnly, verbatimModuleSyntax, module: nodenext, target: esnext, allowImportingTsExtensions (required — the .ts-extension imports below make tsc error TS5097 without it; harmless under noEmit).
Required project setup (both verified necessary):
package.jsonmust contain"type": "module". Without it the.tsfiles
resolve as CommonJS and every ESM import / import.meta is rejected by tsc.
- TypeScript ≥ 5.8 —
erasableSyntaxOnlywas introduced in 5.8 (earlier
versions error "Unknown compiler option").
1.3 The platform port (the runtime seam)
Only these files name Bun: server.ts (calls Bun.serve), framework/platform/bun-runtime.ts, framework/platform/watch.ts, and framework/render/render.ts (which uses HTMLRewriter). Everything else is runtime-blind. Serving uses Bun's native router directly (§11); the port covers file access, which the static handler needs.
// /framework/platform/runtime.ts — the port (interface)
export interface Runtime {
readFile(path: string): Promise<string>;
fileExists(path: string): Promise<boolean>;
}// /framework/platform/bun-runtime.ts — the only Bun adapter for file access
import type { Runtime } from "./runtime.ts";
export const bunRuntime: Runtime = {
readFile(path) { return Bun.file(path).text(); },
fileExists(path) { return Bun.file(path).exists(); },
};To move off Bun later you write one node-runtime.ts, swap Bun.serve for the new runtime's server in server.ts, and swap HTMLRewriter in render.ts for a vendored parser. A handful of files — not a rewrite.
2. The binding vocabulary (the entire language)
The whole templating language is a handful of attributes, in two groups: data binding (move data into elements) and component config (configure a component where it's used — polymorphic element, variants). Data binding lives inside atoms; composition and config live where you compose. There is nothing else to learn.
Data binding
| Marker | Where | Meaning | Example |
|---|---|---|---|
data-field="path" | inside atoms | Set the element's text to path (escaped) | <span data-field="label">x</span> |
data-bind-<attr>="path" | inside atoms | Set HTML attribute <attr> to path | <img data-bind-src="avatarUrl"> |
<component> | where you compose | Compose a child component (hyphenated tag) | <b-avatar data="assignee"> |
each="path" | on a component tag | Render once per array element of path | <item-card each="items"> |
data="path" | on a component tag | Render once with the path slice (default: whole current scope) | <b-avatar data="assignee"> |
Component config (props)
Any literal attribute on a component tag (other than data/each) is a prop handed to that component. The component opts in to a prop with one of:
| Marker | In the component | Meaning | Example use |
|---|---|---|---|
<slot-tag prop-as="fallback"> | the polymorphic root | Render as the element named by the as prop (else fallback) | <b-text as="h2"> |
prop-attr-<x>="propName" | any element | Set attribute <x> from the literal prop propName | <b-button variant="solid"> |
prop-text="propName" | any element | Set the element's text from the literal prop propName (escaped) | <b-button label="Add"> |
Guardrail (important): config only ever places a tag name or a static attribute value. It never branches on data and never contains logic. Anything conditional belongs in the service, which decides which component/props to use and hands the template finished data. This keeps templates logic-free even as components gain flexibility (polymorphism, variants, states).
Resolution rules
pathis a dot path resolved against the current component's data scope
(assignee.avatarUrl). Each component renders in its own scope, so the same short path means different things at different layers — no collisions.
path="."(or empty) means the scope value itself — an atom handed a
primitive renders it directly (<b-text data="title"> + data-field=".").
- A path that doesn't exist is a dev signal (
warn/throw); an explicitnull
renders empty and is not flagged (intentional blank).
eachanddataare mutually exclusive on a tag;eachwins if both appear.- Config and data binding coexist on one element — e.g. a button can take a
literal variant (config → class) and a data-bind-hx-post (data → attribute).
Layering — why it stays clean The verbose binding (data-bind-*, data-field) is written once, inside each atom. Pages and organisms only use component tags with data/each and the occasional config prop. So composition reads as semantic HTML:
<!-- organism: clean — component tags + data/each + config props -->
<section class="item-list">
<b-text as="h2" class="heading-2" data="title"></b-text>
<item-card each="items"></item-card>
</section>Conventions
- Component tags may self-close.
<b-input name="x" label="X" />works even
though HTML forbids self-closing custom elements — the engine normalizes <comp …/> to <comp …></comp> before parsing (§9). Use it for childless components; keep explicit close tags only where you pass example children.
- Boolean props are bare. A valueless attribute (
<b-input required />)
passes an empty-string prop, and prop-attr-required emits a bare required (not required=""). Absent prop → attribute dropped entirely.
- Folder = component; filename = tag name, hyphenated, globally unique
(atoms/item-card/item-card.html → <item-card>). Each component folder holds its .html (template/mockup/docs) and its .css next to it — one place per component, fully self-contained. The renderer discovers templates by recursing the tree, so nesting depth is free; only the hyphenated filename matters.
- Keep example children inside a component tag so the raw file is still a
mockup; the server discards them and substitutes the real render.
data-bind-classreplacesclass— put base classes in the bound value, or
pair a static class with a bound modifier.
- Styling is two layers, both CSS, no engine involvement. (1) The
design system lives in /frontend/styles: variables.css (:root tokens) and global.css (reset + page layout). (2) Component CSS is co-located in each component's folder and references the tokens. The framework concatenates all component CSS into one cached /components.css bundle (createStyleBundle, §3) — co-location for authoring, a single request for the browser, still no build step. An atom stays dumb (<span data-field="text">); a design-token class does the visual work. Headings still emit the correct h1–h6 element for accessibility.
- One class per element; variants are attributes, not stacked classes. This is
vanilla CSS, not a utility framework — the co-located CSS file is where a component's styling lives, so an element wears a single class (.btn) and its variations are attributes the CSS targets (.btn[data-variant="soft"], .btn[data-size="lg"], .btn[data-status="danger"]). No btn btn--solid btn--lg soup. Component config props (§2 props) map straight to these attributes, so a polymorphic atom stays one class with a few knobs.
- Tokens are two-layered: primitives → semantic.
variables.cssdefines raw
palette/scale primitives (--indigo-600, --text-lg, --space-4) and semantic aliases that point at them (--color-primary: var(--indigo-600)). Components reference only semantic tokens, so retheming is a one-line primitive edit that cascades everywhere downstream (§13a verifies this).
- Each component carries its own catalog doc. A co-located
<name>.md
documents every state/variant as html fences; the framework renders them into a live /catalog (§13a). To make pseudo-states (:hover/:focus-visible/ :active) viewable without interaction, the CSS pairs each with a parallel forced selector on the same single class (.btn:hover, .btn[data-force~="hover"]).
Example component files
<!-- atoms/b-text/b-text.html — ONE polymorphic typography atom -->
<!-- `as` chooses the element (accessibility); `class` is a CSS token; text via self-path -->
<slot-tag prop-as="span" prop-attr-class="class" data-field=".">Example text</slot-tag>/* atoms/b-text/b-text.css — co-located styles, reference design tokens */
.heading-2 { font-size: 1.1rem; color: var(--color-muted); margin: var(--space-6) 0 var(--space-3); }<!-- atoms/b-button/b-button.html — variant via config prop, action via data binding -->
<button prop-attr-class="variant" data-bind-hx-post="action" data-field="label">Button</button><!-- atoms/b-avatar/b-avatar.html -->
<img class="avatar" data-bind-src="avatarUrl" data-bind-alt="name"
src="/example-avatar.png" alt="Jane Doe"><!-- atoms/b-badge/b-badge.html -->
<span class="badge" data-status="active" data-bind-data-status="tone" data-field="label">active</span><!-- molecules/item-card/item-card.html -->
<article class="item-card" data-bind-id="id">
<b-text as="h3" class="card-title" data="name"></b-text>
<b-badge data="status"><span class="badge">active</span></b-badge>
<!-- data-driven action button: raw markup is fine INSIDE a component (it owns its
markup); variant/size are static, label/action come from the item's data -->
<button class="btn" data-size="sm" data-variant="outline"
data-bind-hx-post="archive.action" data-field="archive.label">Archive</button>
</article><!-- organisms/item-list/item-list.html -->
<section class="item-list">
<b-text as="h2" class="heading-2" data="title"></b-text>
<item-card each="items">
<article class="item-card">example card for the mockup</article>
</item-card>
</section>A single b-text atom now serves every heading, paragraph, and inline label: as="h2"/as="p"/as="a" picks the semantic element, a token class styles it, data feeds the content. Buttons are one b-button with variant choosing the class — primary, secondary, danger — while real behaviour (hx-post) is a data binding. Full atomic flexibility, zero template logic.
Verified output
Rendering item-list (two items) produced this real output — polymorphic h2 and h3 emitted, the button's variant attribute and data-bound hx-post coexisting, every component tag and config marker resolved away:
<section class="item-list">
<h2 class="heading-2" data-field=".">My Tasks</h2>
<article class="item-card" data-bind-id="id" id="ITM-1">
<h3 class="card-title" data-field=".">Fix auth</h3>
<span class="badge" data-status="active" data-bind-data-status="tone" data-field="label">Active</span>
<button class="btn" data-size="sm" data-variant="outline"
data-bind-hx-post="archive.action" hx-target="closest .item-card" hx-swap="outerHTML"
data-field="archive.label" hx-post="/ui/items/ITM-1/archive">Archive</button>
</article>
<!-- second card identical shape with ITM-2 data -->
</section>3. Directory structure — framework vs app
On-disk layout (2026-06-30): a monorepo. The singlepoc/framework | app | frontendtree below was reorganized into three top-level concerns:batch/(everything/frameworkdescribes — the substrate),grain/(the AI design system —ai/*, theb-*atoms,scripts/*,styles/grain.css), andproject/(domain/data/services/routes/view, the product's components + pages + skin, andserver.tsthe composition root). Update (2026-07-05): the composition root folded intotjakoen.github.io/— the app now wires the layers and holdsserver.ts;project/is a paused docs-only archive. The framework's component scanners take multiple component roots (grain/components+tjakoen.github.io/components). The tree below still describes BATCH's internals + the conceptual framework/app/frontend split; seedocs/GRAIN.mdfor the concern mapping.
Split by stability and ownership, not by layer. /framework is the reusable engine ("BATCH") — you touch it rarely and it knows nothing about any particular app. /app is what you build daily. /frontend is your components. The test the split passes (verified): delete /app and /framework still compiles and is reusable for a different project — the framework has zero imports from the app.
/project
├── /framework # the reusable engine — touch rarely, no app knowledge
│ ├── /render
│ │ └── render.ts # createRenderer() — the generic composition engine
│ ├── /platform # runtime seam
│ │ ├── runtime.ts # Runtime PORT (file access)
│ │ ├── bun-runtime.ts # Bun file adapter
│ │ └── watch.ts # dev hot-reload watcher
│ ├── /http # generic transport helpers
│ │ ├── validate.ts # requireString / HttpError
│ │ ├── errors.ts # jsonError
│ │ ├── static.ts # makeStatic(rt, root) — root injected; binary types (woff2…)
│ │ ├── pages.ts # makePageServer — flat-file pages; head+body inject seams (§17)
│ │ ├── stream.ts # createStream() — generic per-session SSE push hub (§17)
│ │ └── sitemap.ts # createSitemap — pages/ tree → routes / xml
│ ├── /render
│ │ ├── render.ts # (above) the composition engine
│ │ └── accepts.ts # harvest data-kind/data-accepts → AI manifest source (§17)
│ ├── /assets style-bundle.ts # co-located component .css → /components.css
│ └── /catalog catalog.ts # .md + .ai.md two-view docs + grouped nav + search (§13a)
│
├── /app # what you build — touch daily
│ ├── /domain item.ts # models — ZERO dependencies
│ ├── /data # storage behind a port (no DB hardwired)
│ │ ├── item-repository.ts # ItemRepository PORT
│ │ ├── in-memory-item-repository.ts # placeholder impl (wired by default)
│ │ ├── sql-item-repository.ts # optional: any SQL DB via SqlClient
│ │ ├── rest-item-repository.ts # optional: consume another API
│ │ ├── sql-client.ts # SqlClient PORT
│ │ ├── /dto item-row.ts item-api-dto.ts
│ │ └── /mappers item-mapper.ts
│ ├── /services item-service.ts item-views.ts
│ ├── /ai # the AI interaction layer (§17, docs/AI-INTERFACE.md)
│ │ ├── contract.ts # SSOT: ActionName/SurfaceKind unions + ACTIONS registry
│ │ ├── reasoner.ts # Reasoner boundary (Model seam) + stub
│ │ ├── interaction-layer.ts # the ONE door: validate → decide → push render ops
│ │ └── manifest.ts # the AI's per-screen instruction manual
│ ├── /view # this app's view wiring
│ │ ├── renderer.ts # createRenderer() configured w/ app config
│ │ └── components.ts # named components (ItemCard, LoopCard…)
│ ├── /routes routes.ts ai-routes.ts # buildRoutes(): /ui + /api · buildAiRoutes(): /intent /stream /ai/manifest
│ └── config.ts # env-driven dev/prod switches
│
├── /frontend # standards-only, no build (your components)
│ ├── /components # one FOLDER per component: .html + .css (+ .md catalog doc)
│ │ ├── /atoms # b- = reusable design-system primitives
│ │ │ ├── /b-text b-text.html b-text.css
│ │ │ ├── /b-button b-button.html b-button.css b-button.md # .md → /catalog (§13a)
│ │ │ ├── /b-input b-input.html b-input.css b-input.md # forms compose these
│ │ │ └── /b-badge b-badge.html b-badge.css
│ │ ├── /molecules/item-card item-card.html item-card.css
│ │ └── /organisms
│ │ ├── /item-list item-list.html item-list.css
│ │ └── /app-header app-header.html app-header.css # shared nav, composed by pages
│ ├── /styles # the DESIGN SYSTEM (global, not per-component)
│ │ ├── variables.css # tokens + Redaction @font-face + grade atom (§2, §17)
│ │ └── global.css # reset + paper grain layer + masthead + caret
│ ├── /fonts redaction-{400,35,50,70}-400.woff2 # self-hosted grade families (DESIGN-SYSTEM §3)
│ ├── /scripts # the only client JS — small islands
│ │ ├── ai-dispatch.js # dispatcher: clicks → /intent, applies SSE render ops (§17)
│ │ └── cmdk.js # global ⌘K command palette (search)
│ ├── /pages # flat files; folders only to group subpages
│ │ └── index.html home.html about.html # "/" (the hero desk) · "/home" · "/about"
│ └── /vendor htmx.min.js # vendored, not a CDN
│
├── server.ts # composition root — the ONLY place framework + app meet
└── dev.shThe framework also has /framework/assets/style-bundle.ts — createStyleBundle(componentsDir) walks the component folders, concatenates every co-located .css into one cached bundle, and exposes refresh() for hot reload. The composition root serves it at /components.css. So a page links GRAIN's three page-level sheets then the component bundle: styles/variables.css (tokens) → styles/global.css (base/skin) → styles/grain.css (grade mechanism) → /components.css (per-component styles + the AI module). No build step — the bundle is read once and cached, dropped on any .css/.html change (§12a).
Dependency direction: app/routes → app/services → app/domain, with app/data and framework/platform implementing ports at the edges, and app/view calling framework/render. /framework never imports /app (verified). The domain imports nothing. server.ts is the sole wiring point and belongs to neither side. No database is wired in — the in-memory repository is the default (§6).
4. Architecture diagrams
5. Domain (zero dependencies)
// /app/domain/item.ts
export interface Item {
id: string;
name: string;
description: string;
status: "active" | "archived"; // union, not enum (erasable)
createdAt: Date;
updatedAt: Date;
}
// Input for creation — the fields a caller supplies.
export type NewItem = Omit<Item, "id" | "createdAt" | "updatedAt">;6. Data layer — ports, placeholder, and pluggable storage
Storage is deliberately abstract. The service depends on the ItemRepository port, never on a database. The app runs today on an in-memory placeholder with zero dependencies; a real database is a later swap of one adapter at the composition root. "Any database" is not a slogan here — the repository genuinely never names an engine.
6.1 The port (what the service depends on)
// /app/data/item-repository.ts
import type { Item, NewItem } from "../domain/item.ts";
export interface ItemRepository {
list(): Promise<Item[]>;
findById(id: string): Promise<Item | null>;
create(input: NewItem): Promise<Item>;
update(id: string, patch: Partial<NewItem>): Promise<Item>;
remove(id: string): Promise<void>;
}6.2 In-memory repository (the wired-in placeholder) — VERIFIED
Zero dependencies, no schema, no migrations. This is what the app uses now so the architecture can be built and exercised before any database exists.
// /app/data/in-memory-item-repository.ts
import type { Item, NewItem } from "../domain/item.ts";
import type { ItemRepository } from "./item-repository.ts";
export class InMemoryItemRepository implements ItemRepository {
private rows = new Map<string, Item>();
private seq = 0;
constructor(seed: Item[] = []) { for (const r of seed) this.rows.set(r.id, r); }
async list() { return [...this.rows.values()].sort((a, b) => +b.createdAt - +a.createdAt); }
async findById(id: string) { return this.rows.get(id) ?? null; }
async create(input: NewItem) {
const now = new Date();
const item: Item = { ...input, id: `ITM-${++this.seq}`, createdAt: now, updatedAt: now };
this.rows.set(item.id, item);
return item;
}
async update(id: string, patch: Partial<NewItem>) {
const cur = this.rows.get(id);
if (!cur) throw new Error("Item not found");
const next = { ...cur, ...patch, updatedAt: new Date() };
this.rows.set(id, next);
return next;
}
async remove(id: string) { this.rows.delete(id); }
}Verified end-to-end through the running server: list ordered, create returns a typed Item, partial update preserves untouched fields, find/remove work.
6.3 Swapping in a real database (any engine)
When you want persistence, implement ItemRepository for your store — nothing else in the codebase changes. Two reference shapes:
SQL (any Postgres-dialect driver), via a thin SqlClient port. The repo speaks standard SQL ($1 placeholders, (sql, params), { rows }), so the same queries run on PGlite (embedded, for local dev) or node-postgres (a real server) — you implement SqlClient once for whichever you pick.
// /app/data/sql-client.ts — implement this for your DB (PGlite, pg, …)
export interface SqlResult<T> { rows: T[]; }
export interface SqlClient {
query<T>(sql: string, params?: unknown[]): Promise<SqlResult<T>>;
exec(sql: string): Promise<void>;
}// /app/data/sql-item-repository.ts — the same port, backed by SQL
import type { Item, NewItem } from "../domain/item.ts";
import type { ItemRepository } from "./item-repository.ts";
import type { SqlClient } from "./sql-client.ts";
import type { ItemRow } from "./dto/item-row.ts";
import { rowToItem } from "./mappers/item-mapper.ts";
export class SqlItemRepository implements ItemRepository {
private sql: SqlClient;
constructor(sql: SqlClient) { this.sql = sql; } // explicit field (erasable)
async list() {
const { rows } = await this.sql.query<ItemRow>(`SELECT * FROM items ORDER BY created_at DESC`);
return rows.map(rowToItem);
}
async findById(id: string) {
const { rows } = await this.sql.query<ItemRow>(`SELECT * FROM items WHERE id = $1`, [id]);
return rows[0] ? rowToItem(rows[0]) : null;
}
async create(input: NewItem) {
const { rows } = await this.sql.query<ItemRow>(
`INSERT INTO items (id, name, description, status, created_at, updated_at)
VALUES (gen_random_uuid(), $1, $2, $3, now(), now()) RETURNING *`,
[input.name, input.description, input.status]);
return rowToItem(rows[0]);
}
async update(id: string, patch: Partial<NewItem>) {
const { rows } = await this.sql.query<ItemRow>(
`UPDATE items SET name = COALESCE($2,name), description = COALESCE($3,description),
status = COALESCE($4,status), updated_at = now() WHERE id = $1 RETURNING *`,
[id, patch.name ?? null, patch.description ?? null, patch.status ?? null]);
if (!rows[0]) throw new Error("Item not found");
return rowToItem(rows[0]);
}
async remove(id: string) { await this.sql.query(`DELETE FROM items WHERE id = $1`, [id]); }
}REST (consume another service's API). Same port, different source.
// /app/data/rest-item-repository.ts
import type { Item, NewItem } from "../domain/item.ts";
import type { ItemRepository } from "./item-repository.ts";
import type { ItemApiDto } from "./dto/item-api-dto.ts";
import { apiDtoToItem } from "./mappers/item-mapper.ts";
export class RestItemRepository implements ItemRepository {
private baseUrl: string;
constructor(baseUrl: string) { this.baseUrl = baseUrl; } // explicit field (erasable)
async list() {
const body = await (await fetch(`${this.baseUrl}/items`)).json() as { items: ItemApiDto[] };
return body.items.map(apiDtoToItem);
}
async findById(id: string) {
const res = await fetch(`${this.baseUrl}/items/${id}`);
return res.ok ? apiDtoToItem(await res.json() as ItemApiDto) : null;
}
async create(input: NewItem) {
return apiDtoToItem(await (await fetch(`${this.baseUrl}/items`,
{ method: "POST", body: JSON.stringify(input) })).json() as ItemApiDto);
}
async update(id: string, patch: Partial<NewItem>) {
return apiDtoToItem(await (await fetch(`${this.baseUrl}/items/${id}`,
{ method: "PATCH", body: JSON.stringify(patch) })).json() as ItemApiDto);
}
async remove(id: string) { await fetch(`${this.baseUrl}/items/${id}`, { method: "DELETE" }); }
}6.4 DTOs and mappers (per source, one place each)
Each source has its own DTO — a SQL row is snake_case with raw strings; a REST payload is camelCase ISO strings; neither equals the domain Item. They are not made to inherit Item on purpose: the differences (Date vs string, union vs raw string) are real, and the mapper is the single source of truth for how each source relates to the domain. DRY lives in the mapper, not in a shared base type. (The in-memory repo needs no DTO — it already holds Item.)
// /app/data/dto/item-row.ts (SQL row shape)
export interface ItemRow {
id: string; name: string; description: string;
status: string; created_at: string | Date; updated_at: string | Date;
}
// /app/data/dto/item-api-dto.ts (REST payload shape)
export interface ItemApiDto {
id: string; name: string; description: string;
status: string; createdAt: string; updatedAt: string;
}// /app/data/mappers/item-mapper.ts — pure functions, the only place shapes meet
import type { Item } from "../../domain/item.ts";
import type { ItemRow } from "../dto/item-row.ts";
import type { ItemApiDto } from "../dto/item-api-dto.ts";
export const rowToItem = (r: ItemRow): Item => ({
id: r.id, name: r.name, description: r.description,
status: r.status as Item["status"],
createdAt: new Date(r.created_at), updatedAt: new Date(r.updated_at),
});
export const apiDtoToItem = (d: ItemApiDto): Item => ({
id: d.id, name: d.name, description: d.description,
status: d.status as Item["status"],
createdAt: new Date(d.createdAt), updatedAt: new Date(d.updatedAt),
});7. Platform adapters (runtime seam)
Platform holds only runtime adapters now — there is no database here by design (storage is a ItemRepository you choose, §6.3). The Bun-specific calls live in one file.
// /framework/platform/runtime.ts — the port
export interface Runtime {
readFile(path: string): Promise<string>;
fileExists(path: string): Promise<boolean>;
}
// /framework/platform/bun-runtime.ts — the only Bun file/serve adapter
import type { Runtime } from "./runtime.ts";
export const bunRuntime: Runtime = {
readFile: (p) => Bun.file(p).text(),
fileExists: (p) => Bun.file(p).exists(),
};(Serving moves to Bun's native router in §11/§12; the hot-reload watcher in §12a also lives here.)
8. Services + view models (application layer)
The service owns the verbs. Its methods return domain objects so a route can render them to HTML (for /ui) or serialize them to JSON (for /api) without the service being coupled to either. It also offers card-view helpers as a convenience for the /ui read paths. (Earlier this returned view models from mutations too, which made POST /api/items emit a different shape than GET /api/items — a representation leak now removed.)
// /app/services/item-views.ts — view models live with the app layer
import type { Item } from "../domain/item.ts";
export interface ItemCardView {
id: string;
name: string;
detailUrl: string;
status: { label: string; tone: "active" | "archived" }; // tone → b-badge attribute
archive: { label: string; action: string }; // button label + hx-post target
}
export const toItemCardView = (item: Item): ItemCardView => ({
id: item.id,
name: item.name,
detailUrl: `/items/${item.id}`,
status: item.status === "active"
? { label: "Active", tone: "active" }
: { label: "Archived", tone: "archived" },
archive: item.status === "active"
? { label: "Archive", action: `/ui/items/${item.id}/archive` }
: { label: "Archived", action: "" }, // empty action → engine omits hx-post (inert)
});// /app/services/item-service.ts
import type { Item } from "../domain/item.ts";
import type { ItemRepository } from "../data/item-repository.ts";
import { type ItemCardView, toItemCardView } from "./item-views.ts";
export class ItemService {
private items: ItemRepository;
constructor(items: ItemRepository) { this.items = items; } // explicit field (erasable)
// domain-returning — the basis for BOTH representations
listItems(): Promise<Item[]> { return this.items.list(); }
getItem(id: string): Promise<Item | null> { return this.items.findById(id); }
createItem(name: string, description: string): Promise<Item> {
return this.items.create({ name, description, status: "active" });
}
archiveItem(id: string): Promise<Item> { return this.items.update(id, { status: "archived" }); }
deleteItem(id: string): Promise<void> { return this.items.remove(id); }
// view-returning convenience for /ui reads
async listCardViews(): Promise<ItemCardView[]> {
return (await this.items.list()).map(toItemCardView);
}
async getCardView(id: string): Promise<ItemCardView> {
const item = await this.items.findById(id);
if (!item) throw new Error("Item not found");
return toItemCardView(item);
}
}9. Composition engine (VERIFIED)
HTMLRewriter is native to Bun. Templates are cached. Two passes: bind this scope's leaves, then expand children — so a parent never re-binds a child. This is the exact code that produced §2's output, plus two improvements:
refresh()rebuilds the registry and drops the cache — the hook the dev
watcher calls on file change (hot reload, §14).
missingmode turns silent binding mistakes into a dev signal. A path that
does not exist is reported (warn or throw); an explicit null is left alone as an intentional empty. Prod runs ignore. (Verified: a typo'd data-field or data-bind-* throws in throw mode; label: null does not.)
- Per-attribute binding — each template's
data-bind-<attr>names are
discovered once (cached) and matched with a presence selector ([data-bind-src]). No attribute mini-DSL to parse.
- URL-scheme guard (
safeAttr) — text escaping does not neutralize a
data-driven javascript:/data: value in href/src/action/…; those attrs go through a scheme allowlist (http/https/mailto/tel/relative/anchor), anything else dropped. The one XSS class the native parser can't catch.
- Empty bound attributes are omitted. A
data-bind-*that resolves to""
(or absent) emits no attribute — so a view model can make a control inert by supplying an empty value (e.g. an archived card's button gets no hx-post, rather than an hx-post="" that htmx would fire against the current URL).
- Deterministic dates —
format()emitstoISOString(), not host-locale
toLocaleDateString(), so HTML output is stable across environments.
renderPage(html, data?)runs the same two-passtransformon an arbitrary
HTML document (not a registered template), expanding any component tags it contains. This is what lets pages compose components (§11.2). The factory returns { render, renderPage, refresh }; transform is the shared core so a page and a component bind/expand identically.
- Self-closing normalization.
transformfirst rewrites<comp …/>→
<comp …></comp> for every registered component name, so authors can self-close childless component tags despite HTML's custom-element rule. prop-attr emits bare boolean attributes for empty-string props (required, not required="").
The renderer is a factory so configuration is injected, not global:
// /framework/render/render.ts
import { readdirSync } from "fs";
import { join } from "path";
export type MissingMode = "ignore" | "warn" | "throw";
export interface RenderConfig { componentsDir: string | string[]; missing: MissingMode; }
interface Resolved { found: boolean; value: unknown; }
function resolvePath(obj: any, path: string): Resolved {
if (path === "" || path === ".") return { found: true, value: obj }; // self: the scope itself
let cur = obj;
for (const key of path.split(".")) {
if (cur == null || !Object.hasOwn(Object(cur), key)) return { found: false, value: undefined };
cur = cur[key]; // own-prop only: no __proto__/constructor reach
}
return { found: true, value: cur }; // found:true even when value is null
}
function format(v: unknown): string {
if (v == null) return "";
if (v instanceof Date) return v.toISOString(); // deterministic across host locale/tz
return String(v);
}
// Attributes whose VALUE is a URL — a data-driven `javascript:` / `data:` scheme
// is an XSS vector that HTML-quote-escaping does NOT neutralize. Block it.
const URL_ATTRS = new Set(["href", "src", "action", "formaction", "xlink:href", "poster", "background", "ping"]);
const SAFE_URL = /^(?:https?:|mailto:|tel:|\/|\.\/|\.\.\/|#|\?)/i;
function safeAttr(attr: string, value: string): string {
if (!URL_ATTRS.has(attr.toLowerCase())) return value;
const trimmed = value.trim();
if (trimmed === "" || SAFE_URL.test(trimmed)) return value;
return ""; // unknown/unsafe scheme (javascript:, data:, vbscript:, …) → drop
}
export function createRenderer(config: RenderConfig) {
const registry = new Map<string, string>();
const cache = new Map<string, { html: string; bindAttrs: string[] }>();
let names: string[] = [];
function discover(dir: string) {
for (const e of readdirSync(dir, { withFileTypes: true })) {
const full = join(dir, e.name);
if (e.isDirectory()) discover(full);
else if (e.name.endsWith(".html")) {
const n = e.name.slice(0, -5);
if (n.includes("-")) registry.set(n, full); // hyphenated = component
}
}
}
function refresh() { registry.clear(); cache.clear(); discover(config.componentsDir); names = [...registry.keys()]; }
function onMissing(component: string, path: string) {
if (config.missing === "ignore") return;
const msg = `[render] unknown binding "${path}" in <${component}>`;
if (config.missing === "throw") throw new Error(msg);
console.warn(msg);
}
async function template(name: string): Promise<{ html: string; bindAttrs: string[] }> {
const hit = cache.get(name);
if (hit) return hit;
const path = registry.get(name);
if (!path) throw new Error(`Component not found: <${name}>`);
const html = await Bun.file(path).text(); // platform seam (see §1.3)
// discover which data-bind-<attr> this template uses (cached with the template)
const bindAttrs = [...new Set([...html.matchAll(/data-bind-([\w-]+)=/g)].map(m => m[1]))];
const tpl = { html, bindAttrs };
cache.set(name, tpl);
return tpl;
}
// PASS 0 — resolve config (the literal props a component was used with).
// <slot-tag prop-as="span"> → swap the polymorphic root's TAG NAME to props.as
// prop-attr-<x>="propName" → set attribute <x> from the literal prop value
// Guardrail: config only places a tag name or a static attribute value. Never logic.
function applyProps(html: string, props: Record<string, string>): string {
const slot = html.match(/<slot-tag\b[^>]*?\bprop-as="([^"]*)"/);
if (slot) {
const tag = (props["as"] ?? slot[1] ?? "span").replace(/[^a-zA-Z0-9-]/g, "");
html = html.replace(/<slot-tag\b/g, `<${tag}`)
.replace(/<\/slot-tag>/g, `</${tag}>`)
.replace(/\sprop-as="[^"]*"/g, "");
}
return html.replace(/\sprop-attr-([\w-]+)="([^"]*)"/g, (_m, attr, propName) => {
const v = props[propName];
return v == null ? "" : ` ${attr}="${v.replace(/"/g, """)}"`;
});
}
async function render(name: string, data: any, props: Record<string, string> = {}): Promise<string> {
const { html: rawTpl, bindAttrs } = await template(name);
const tpl = applyProps(rawTpl, props); // PASS 0 — config
const r = (p: string) => resolvePath(data, p);
// PASS 1 — text via data-field, attributes via one handler per data-bind-<attr>.
let rw = new HTMLRewriter().on("[data-field]", {
element(el) {
const path = el.getAttribute("data-field")!;
const res = r(path);
if (!res.found) onMissing(name, path);
el.setInnerContent(format(res.value));
},
});
for (const attr of bindAttrs) {
rw = rw.on(`[data-bind-${attr}]`, {
element(el) {
const path = el.getAttribute(`data-bind-${attr}`)!;
const res = r(path);
if (!res.found) onMissing(name, path);
el.setAttribute(attr, safeAttr(attr, format(res.value))); // scheme guard for URL attrs
},
});
}
let html = await rw.transform(new Response(tpl)).text();
// PASS 2 — expand every known component tag. Collect sync, resolve async, splice.
// each="path" → render once per array element
// data="path" → render once with that slice (default: whole data object)
// any other literal attribute → a config prop handed to the child (PASS 0)
const jobs: Array<Promise<string>> = [];
let rw2 = new HTMLRewriter();
for (const comp of names) {
rw2 = rw2.on(comp, {
element(el) {
const eachPath = el.getAttribute("each");
const dataPath = el.getAttribute("data");
const props: Record<string, string> = {};
for (const [n, v] of el.attributes) if (n !== "each" && n !== "data") props[n] = v;
const idx = jobs.length;
if (eachPath != null) {
const eachRes = r(eachPath);
if (!eachRes.found) onMissing(name, eachPath); // typo'd each= is a dev signal, not silent ""
const arr = eachRes.value;
jobs.push(Array.isArray(arr)
? Promise.all(arr.map(d => render(comp, d, props))).then(a => a.join(""))
: Promise.resolve("")); // found-but-null/empty → intentional blank
} else {
const slice = dataPath != null ? r(dataPath).value : data;
jobs.push(render(comp, slice, props));
}
el.replace(`<!--slot:${idx}-->`, { html: true });
},
});
}
html = await rw2.transform(new Response(html)).text();
const parts = await Promise.all(jobs);
// function replacement: child HTML may contain $& / $` / $' / $$ — a string
// replacement would treat those as patterns and corrupt the output.
parts.forEach((p, i) => { html = html.replace(`<!--slot:${i}-->`, () => p); });
return html;
}
refresh();
return { render, refresh };
}// /app/view/renderer.ts — the app's renderer, framework factory + app config
import { createRenderer } from "../../framework/render/render.ts";
import { config } from "../config.ts";
export const { render, refresh } = createRenderer({
componentsDir: config.componentsDir,
missing: config.missingBindings, // "warn" in dev, "ignore" in prod
});10. Components (thin wrappers)
// /app/view/components.ts
import { render } from "./renderer.ts";
import type { ItemCardView } from "../services/item-views.ts";
export const ItemCard = (view: ItemCardView) => render("item-card", view);
export const ItemList = (data: { title: string; items: ItemCardView[] }) =>
render("item-list", data);
export const EmptyState = () => render("empty-state", {});11. HTTP layer — native Bun router, two representations
There is no hand-rolled matcher. Bun.serve ships a routes object with path-param extraction and per-method dispatch (verified), so the runtime does the routing. A route is a thin presentation adapter over the service:
/ui/*→ HTML fragments for htmx, viarender()/api/*→ JSON for programmatic consumers, viaResponse.json()
Both call the same ItemService; logic is never duplicated. Response.json() serializes Date → ISO strings automatically, so domain objects go out as clean JSON with no mapping code (verified).
// /framework/http/validate.ts — tiny input guard (kept; real value)
export class HttpError extends Error {
status: number;
constructor(status: number, message: string) { super(message); this.status = status; }
}
export function requireString(v: unknown, field: string): string {
if (typeof v !== "string" || v.trim() === "") throw new HttpError(400, `Missing field: ${field}`);
return v;
}// /framework/http/errors.ts — never leak internals
import { HttpError } from "./validate.ts";
export function jsonError(err: unknown): Response {
if (err instanceof HttpError) return Response.json({ error: err.message }, { status: err.status });
console.error(err); // detail stays server-side
return Response.json({ error: "Internal Server Error" }, { status: 500 });
}// /app/routes/routes.ts — build the native routes object from the service
import type { ItemService } from "../services/item-service.ts";
import { ItemCard, ItemList, EmptyState } from "../view/components.ts";
import { requireString, HttpError } from "../../framework/http/validate.ts";
import { toItemCardView } from "../services/item-views.ts";
import { jsonError } from "../../framework/http/errors.ts";
const htmlFragment = (s: string, status = 200) =>
new Response(s, { status, headers: { "Content-Type": "text/html; charset=utf-8" } });
// htmx posts application/x-www-form-urlencoded (or multipart) by DEFAULT — never JSON.
// Reading req.json() on a htmx form throws. /ui reads the body as form fields.
const formFields = async (req: Request): Promise<Record<string, unknown>> => {
const fd = await req.formData();
return Object.fromEntries(fd.entries());
};
export function buildRoutes(service: ItemService) {
return {
// ---- /ui : HTML fragments (htmx targets these; bodies are form-encoded) ----
"/ui/items": {
GET: async () => {
const views = await service.listCardViews();
return htmlFragment(views.length ? await ItemList({ title: "Items", items: views }) : await EmptyState());
},
POST: async (req: Request) => {
try {
const b = await formFields(req);
const item = await service.createItem(requireString(b.name, "name"), requireString(b.description, "description"));
return htmlFragment(await ItemCard(toItemCardView(item))); // map domain → view
} catch (e) {
// htmx swaps 2xx by default; 4xx needs hx-swap config, so keep 200 with an error fragment.
if (e instanceof HttpError) return htmlFragment(`<p class="error">${e.message}</p>`);
console.error(e); return htmlFragment(`<p class="error">Something went wrong.</p>`);
}
},
},
"/ui/items/:id": {
GET: async (req: Request & { params: { id: string } }) =>
htmlFragment(await ItemCard(await service.getCardView(req.params.id))),
},
// ---- /api : JSON (other consumers; same service, one consistent shape) ----
"/api/items": {
GET: async () => Response.json(await service.listItems()),
POST: async (req: Request) => {
try {
const b = await req.json() as Record<string, unknown>;
if (b == null || typeof b !== "object") throw new HttpError(400, "Body must be a JSON object");
const item = await service.createItem(requireString(b.name, "name"), requireString(b.description, "description"));
return Response.json(item, { status: 201 }); // domain shape — same as GET
} catch (e) { return jsonError(e); }
},
},
"/api/items/:id": {
GET: async (req: Request & { params: { id: string } }) => {
const item = await service.getItem(req.params.id);
return item ? Response.json(item) : Response.json({ error: "not found" }, { status: 404 });
},
DELETE: async (req: Request & { params: { id: string } }) => {
await service.deleteItem(req.params.id);
return new Response(null, { status: 204 });
},
},
};
}Adding a JSON mirror of any /ui route (or vice-versa) is a few lines — the service already returns the data; only the presentation differs.
// /framework/http/static.ts — generic static serving; root is INJECTED (no app path baked in)
import type { Runtime } from "../platform/runtime.ts";
import { join, normalize, resolve, sep } from "path";
export function makeStatic(rt: Runtime, root: string) {
const ROOT = resolve(root); // absolute → traversal guard is reliable
return async (pathname: string): Promise<Response> => {
const rel = pathname === "/" ? "/index.html" : pathname;
const path = resolve(normalize(join(ROOT, rel)));
// separator-aware containment: "/p/frontend-secret" must NOT pass for ROOT "/p/frontend"
if (path !== ROOT && !path.startsWith(ROOT + sep)) return new Response("Forbidden", { status: 403 });
if (!(await rt.fileExists(path))) return new Response("Not found", { status: 404 });
const type = path.endsWith(".css") ? "text/css" : path.endsWith(".js") ? "text/javascript" : "text/html";
return new Response(await rt.readFile(path), { headers: { "Content-Type": type } });
};
}Note:makeStatictakesrootas a parameter rather than hardcoding the frontend path. That keeps it generic framework code — the app supplies its ownfrontendDirat the composition root. (An earlier version hardcoded the path viaimport.meta.dir, which coupled framework to app layout and broke the traversal guard once moved; fixed and verified.) The containment check is separator-aware (startsWith(ROOT + sep)) — a barestartsWith(ROOT)leaks sibling dirs sharing the prefix (/p/frontend-secretvs/p/frontend).makeStaticstays generic — it just maps/→/index.htmland serves underroot. The folder-per-page convention (clean URL →pages/<path>/index.html) lives in a thin wrapper, §11.2, so the page layout never leaks into the framework.
11.1 Component CSS bundle (co-located styles, one request)
Component styles live next to their templates (§2/§3). The framework gathers them into a single cached bundle so authoring stays co-located while the browser makes one request — no build step.
// /framework/assets/style-bundle.ts
import { readdirSync } from "fs";
import { join } from "path";
export function createStyleBundle(rt: Runtime, componentsDir: string | string[]) {
let cache: string | null = null;
function collect(dir: string, out: string[]) {
for (const e of readdirSync(dir, { withFileTypes: true })) {
const full = join(dir, e.name);
if (e.isDirectory()) collect(full, out);
else if (e.name.endsWith(".css")) out.push(full);
}
}
async function css(): Promise<string> {
if (cache != null) return cache;
const files: string[] = [];
collect(componentsDir, files);
files.sort(); // deterministic order across hosts
const parts = await Promise.all(files.map(f =>
Bun.file(f).text().then(s => `/* ${f} */\n${s}`)));
cache = parts.join("\n");
return cache;
}
function refresh() { cache = null; } // hook the dev watcher calls
return { css, refresh };
}GRAIN's three page-level sheets (styles/variables.css, styles/global.css, styles/grain.css) are plain static files served by makeStatic from grain/styles; only the per-component CSS (+ the AI module) is bundled.
11.2 Pages — flat files, folders only to group subpages
A page is a flat file; folders appear only when a page has subpages, where index.html is that folder's main page. Don't nest a folder for a page that has no children. The URL mirrors the tree:
| URL | serves |
|---|---|
/ | pages/index.html — the entrance |
/home | pages/home.html — a flat page |
/about | pages/about.html |
/profile | pages/profile/index.html — a page with subpages → folder |
/profile/settings | pages/profile/settings.html |
// /framework/http/pages.ts — page routing over makeStatic
import { extname, join } from "path";
import type { Runtime } from "../platform/runtime.ts";
import { makeStatic } from "./static.ts";
type RenderPage = (html: string) => Promise<string>;
export function makePageServer(rt: Runtime, pagesRoot: string, renderPage?: RenderPage) {
const serve = makeStatic(rt, pagesRoot); // traversal guard applies under pagesRoot
return async (pathname: string): Promise<Response> => {
const isPage = !extname(pathname);
let rel = pathname;
if (isPage) {
if (pathname === "/") rel = "/index.html";
else {
const clean = pathname.replace(/\/$/, "");
const flat = `${clean}.html`; // /home → /home.html
rel = (await rt.fileExists(join(pagesRoot, flat))) ? flat : `${clean}/index.html`;
}
}
const res = await serve(rel);
if (isPage && renderPage && res.status === 200) {
return new Response(await renderPage(await res.text()), { headers: res.headers });
}
return res;
};
}The rule: a path with an extension is a file (served as-is); a path without one is a page — <path>.html if it exists, else <path>/index.html. Global assets (/styles, /vendor) are served from the frontend root by makeStatic; the composition root routes between the two (§12).
Pages are rendered through the composition engine (renderPage, §9), so a page composes atomic component tags rather than repeating raw, class-laden markup — this is the whole point of having a component layer. A page stays clean and semantic; the styling/markup detail lives inside each component. (Raw HTML inside a component's own .html is fine — the component owns its markup; the rule is that pages compose components.)
Composition extends to forms: a native <form> (semantic structure, allowed on a page) is built from atomic field components, so it's reusable, not a one-off. And because the stack leans on htmx + CSS for behaviour, the little JS that remains can be a small <script> after the UI — that's fine; what we avoid is hx-on handlers scattered through the markup, not a single script block.
<!-- pages/home.html — composes components; a real <form> from atoms; minimal JS after the UI -->
<body>
<main class="container">
<app-header active="items"></app-header>
<h1>Items</h1>
<form class="add-form" hx-post="/ui/items" hx-swap="none">
<b-input name="name" label="Name" required />
<b-input name="description" label="Description" required />
<b-button label="Add" type="submit" />
</form>
<div id="list" hx-get="/ui/items" hx-trigger="load, refresh">Loading…</div>
</main>
<script>
document.addEventListener("htmx:afterRequest", (e) => {
const form = e.target;
if (!form.matches?.(".add-form") || !e.detail.successful) return;
form.reset();
htmx.trigger("#list", "refresh");
});
</script>
</body>11.4 Sitemap & SEO (one source, three uses)
The page routes are derived once from the pages/ tree (createSitemap) and reused: the catalog's Pages nav (§13a), /sitemap.xml (search engines), and /robots.txt (which points at the sitemap). Add a page → it shows up in all three with no extra wiring.
// /framework/http/sitemap.ts (shape) — pages/ tree → clean routes (+ any extras)
// index.html → "/" home.html → "/home" profile/settings.html → "/profile/settings"
export function createSitemap(pagesRoot: string, extraRoutes: () => string[] = () => []) {
// routes(): string[] xml(origin): string refresh(): void
return { routes, xml, refresh };
}extraRoutes lets the composition root add routes that don't come from the pages tree — e.g. a content engine's collections (MILL's /notes/:slug pages) — so the sitemap stays the single list of everything the server actually serves. Batch stays ignorant of who provides them.
/llms.txt — the AEO counterpart. Where /sitemap.xml + /robots.txt target search engines, /llms.txt (the llmstxt.org convention) targets AI crawlers: a Markdown index of what this stack is and where the canonical docs live. batch/http/llms.ts owns only the format — it renders a generic {title, summary, sections} structure and knows nothing of the content, exactly like createSitemap knows nothing of who supplies extraRoutes. The composition root supplies the curated links (tjakoen.github.io/llms.ts, a projection of DOCS.md, never a fork). Relative link paths are absolutized against the request origin — the same idiom sitemap.xml/robots.txt use — so the static export's origin-rewrite bakes in the deploy URL. This is AI-operable ≈ AI-answerable made literal: the stack that lets an AI operate the UI also hands an AI crawler the map to answer about it.
11.3 Page transitions (native, cross-document)
Because navigation between pages is a real full-page load (plain <a href>, not a client router), the native CSS cross-document View Transitions API gives animated navigation for free — one declaration in the shared design-system CSS, no JS, no library. It degrades gracefully: browsers without support just do an instant navigation.
/* styles/global.css — applies to every page (loaded everywhere) */
@view-transition { navigation: auto; } /* animate same-origin nav */
@media (prefers-reduced-motion: reduce) {
@view-transition { navigation: none; } /* honour the user setting */
}
::view-transition-old(root),
::view-transition-new(root) { animation-duration: 0.25s; }
/* A shared element keeps its identity across the navigation (morphs, not fades). */
.app-header { view-transition-name: app-header; }This is why the page convention (§11.2) matters: keeping navigation as standard document loads — rather than swapping innerHTML with a router — is what lets the platform animate transitions natively. htmx still drives intra-page updates (/ui/* fragment swaps); the browser drives inter-page transitions.
Page transitions are one instance of a broader stance (CONVENTIONS §1): prefer the platform's own primitive over a JS reimplementation. Elsewhere in the stack that's <dialog> (modals, the ⌘K palette, the interrupt confirm — free focus-trap, ::backdrop, top-layer), <details> (disclosures), native form constraint validation, plain <a> + CSS for tabs/nav, and CSS :has() / :focus-within / color-mix() / @starting-style for behavior and theming — so "near-zero framework JS" holds by construction. GRAIN's full running inventory is in grain/docs/GRAIN.md ("What GRAIN gives you").
12. Server — composition root (the only wiring)
// /server.ts — the ONLY place /framework and /app meet
import { config } from "./app/config.ts";
import { bunRuntime } from "./framework/platform/bun-runtime.ts";
import { watchComponents } from "./framework/platform/watch.ts";
import { makeStatic } from "./framework/http/static.ts";
import { makePageServer } from "./framework/http/pages.ts";
import { createStyleBundle } from "./framework/assets/style-bundle.ts";
import { InMemoryItemRepository } from "./app/data/in-memory-item-repository.ts";
// swap the line above for a real ItemRepository when you have a DB, e.g.:
// import { SqlItemRepository } from "./app/data/sql-item-repository.ts";
import { ItemService } from "./app/services/item-service.ts";
import { renderPage, refresh } from "./app/view/renderer.ts";
import { buildRoutes } from "./app/routes/routes.ts";
// --- wire the graph here, and ONLY here ---
const repo = new InMemoryItemRepository(); // placeholder storage (§6.2)
const service = new ItemService(repo);
const serveAsset = makeStatic(bunRuntime, config.frontendDir); // global assets: /styles, /vendor
const servePage = makePageServer(bunRuntime, config.pagesDir, renderPage); // pages, rendered through the engine (§11.2)
const styles = createStyleBundle(config.componentsDir); // co-located component CSS → one bundle (§11.1)
// hot reload drops BOTH caches — templates and the CSS bundle
if (config.hotReload) watchComponents(config.componentsDir, () => { refresh(); styles.refresh(); }); // §12a
Bun.serve({
port: config.port,
routes: {
...buildRoutes(service), // native router (path params, methods)
"/components.css": async () =>
new Response(await styles.css(), { headers: { "Content-Type": "text/css" } }),
},
fetch(req) {
const p = new URL(req.url).pathname;
// global assets live at the frontend root; everything else is a page (or its co-located asset)
if (p.startsWith("/styles/") || p.startsWith("/vendor/")) return serveAsset(p);
return servePage(p);
},
});
console.log(`Running on http://localhost:${config.port} (${config.isDev ? "dev" : "prod"})`);The database is a single swap: replace InMemoryItemRepository with a real ItemRepository (SQL/REST). Nothing else in the codebase changes.
12a. Developer experience — config & hot reload (VERIFIED)
One config module keys every dev/prod difference off the environment, so no feature is toggled by scattering if (dev) through the code.
// /app/config.ts
const isDev = (Bun.env.NODE_ENV ?? "development") !== "production";
export const config = {
isDev,
port: Number(Bun.env.PORT ?? 3000),
componentsDir: "./frontend/components",
frontendDir: "./frontend", // root for global assets: /styles, /vendor (§11)
pagesDir: "./frontend/pages", // root for the folder-per-page tree (§11.2)
missingBindings: (isDev ? "warn" : "ignore") as "ignore" | "warn" | "throw",
hotReload: isDev,
};Hot reload is a recursive fs.watch (verified to fire on nested .html edits in Bun on Linux) that drops the renderer's template cache and the CSS bundle cache via refresh(). Component-template and co-located .css edits appear on the next request with no restart; the design-system sheets in styles/ are read per request, so they show on plain browser reload already.
// /framework/platform/watch.ts
import { watch } from "fs";
// Fires on .html (templates) and .css (co-located component styles) edits.
export function watchComponents(dir: string, onChange: () => void): void {
let timer: ReturnType<typeof setTimeout> | null = null;
watch(dir, { recursive: true }, (_event, file) => {
const name = file ? String(file) : "";
if (!name.endsWith(".html") && !name.endsWith(".css")) return;
if (timer) clearTimeout(timer);
timer = setTimeout(() => { onChange(); console.log(`↻ reloaded components (${name})`); }, 50);
});
}Run dev with bun --hot backend/server.ts (reloads server code) — the watcher above covers the frontend .html that Bun's --hot does not track.
13. Testing
// /app/data/in-memory-item-repository.test.ts — the placeholder, real CRUD (no DB)
import { expect, test } from "bun:test";
import { InMemoryItemRepository } from "./in-memory-item-repository.ts";
test("create + partial update preserves untouched fields", async () => {
const repo = new InMemoryItemRepository();
const a = await repo.create({ name: "First", description: "alpha", status: "active" });
const updated = await repo.update(a.id, { status: "archived" });
expect(updated.status).toBe("archived");
expect(updated.name).toBe("First"); // untouched fields kept
expect(await repo.findById("nope")).toBeNull();
});// /framework/render/render.test.ts — nesting, each, scope isolation, XSS (all passed)
import { expect, test } from "bun:test";
import { createRenderer } from "./render.ts";
const r = createRenderer({ componentsDir: "./frontend/components", missing: "ignore" });
const view = (over: Record<string, unknown> = {}) => ({
id: "X1", name: "Plain", detailUrl: "/x1",
status: { label: "Active", tone: "active" },
archive: { label: "Archive", action: "/ui/items/X1/archive" },
...over,
});
test("escapes hostile text", async () => {
const out = await r.render("item-list",
{ title: "T", items: [view({ name: "<script>alert(1)</script>" })] });
expect(out).toContain("<script>");
expect(out).not.toContain("<script>alert");
});
test("drops javascript: scheme in a data-bound URL attribute", async () => {
// item-card's archive button binds hx-post from data — the scheme guard applies there
const bad = await r.render("item-card", {
id: "X", name: "n", status: { tone: "active", label: "A" },
archive: { action: "javascript:alert(1)", label: "go" },
});
expect(bad).not.toContain("javascript:"); // unsafe scheme stripped to empty
});
test("child HTML with $ sequences splices verbatim (no $&/$$ corruption)", async () => {
const out = await r.render("item-list",
{ title: "Pay $5 & up", items: [view({ name: "$& $$ $1" })] });
expect(out).toContain("$& $$ $1"); // literal, not pattern-substituted
});
// Contract test: strict mode turns a template/data mismatch into a failing test.
test("strict mode catches a binding the data does not provide", async () => {
const strict = createRenderer({ componentsDir: "./frontend/components", missing: "throw" });
await expect(strict.render("b-badge", { lbel: "typo", cssClass: "b" }))
.rejects.toThrow(/unknown binding "label"/);
});// /app/routes/routes.test.ts — native Bun router, /ui (HTML) + /api (JSON). VERIFIED
import { expect, test, beforeAll, afterAll } from "bun:test";
import { InMemoryItemRepository } from "../data/in-memory-item-repository.ts";
import { ItemService } from "../services/item-service.ts";
import { buildRoutes } from "./routes.ts";
let server: ReturnType<typeof Bun.serve>; let base: string;
beforeAll(() => {
const service = new ItemService(new InMemoryItemRepository());
server = Bun.serve({ port: 0, routes: buildRoutes(service), fetch: () => new Response("404", { status: 404 }) });
base = `http://localhost:${server.port}`;
});
afterAll(() => server.stop());
test("/ui/items returns an HTML fragment", async () => {
const res = await fetch(`${base}/ui/items`);
expect(res.headers.get("content-type")).toContain("text/html");
});
test("/api/items returns JSON", async () => {
const res = await fetch(`${base}/api/items`);
expect(res.headers.get("content-type")).toContain("application/json");
expect(Array.isArray(await res.json())).toBe(true);
});
test("/api POST validates (400) then creates (201)", async () => {
expect((await fetch(`${base}/api/items`, { method:"POST", body:"{}" })).status).toBe(400);
const ok = await fetch(`${base}/api/items`, { method:"POST", body: JSON.stringify({ name:"X", description:"d" }) });
expect(ok.status).toBe(201);
});
test("/ui POST accepts htmx form encoding (not JSON)", async () => {
const res = await fetch(`${base}/ui/items`, {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({ name: "X", description: "d" }),
});
expect(res.status).toBe(200);
expect(res.headers.get("content-type")).toContain("text/html");
});
test("native :id param + 404", async () => {
expect((await fetch(`${base}/api/items/nope`)).status).toBe(404);
});Verified by running the assembled server (in-memory repo → service → native routes): /ui HTML and /api JSON off one service, POST validation → 400, create → 201, native :id params, fallback 404.
13a. Component catalog (no-build design-system reference)
A Storybook-style reference at /catalog, generated server-side from each component's co-located <name>.md — no React, no Storybook, no build, zero runtime dependencies (a ~40-line line parser, not a markdown library). Every documented state renders live above its copyable source. The left side-nav has two sections: Pages (the site map from createSitemap, §11.4 — doubling as in-app navigation) and Components (anchors to each documented component).
Extended 2026-06-30 (§17.4): components now pair<name>.md(Human) with an optional<name>.ai.md(AI) toggled per component; the Components nav is grouped by atomic layer as collapsible dropdowns with a search filter; and prose renders inline markdown. The base mechanism below is unchanged.
Authoring stays trivial — three steps, all inside the component's own folder:
- Add the component's CSS to its
.css(one class; variants as attributes; a
parallel data-force selector for each pseudo-state).
- Add
<name>.md:# Name,## Group,### Panel, then one`htmlfence
per state/variant. The ### heading labels the panel.
- Save, refresh
/catalog— it appears with a side-nav entry.
// /framework/catalog/catalog.ts (shape)
export function createCatalog(rt: Runtime, componentsDir: string | string[], sitemap?: Sitemap) {
// findDocs(): recurse for *.md parseDoc(): #→name, ##→group, ###→label, ```html→panel
// html(): live = inject fence verbatim (author-controlled, not user data);
// source = HTML-escaped in <pre><code> + a clipboard Copy button.
// refresh(): drop cache (dev watcher also fires on .md, §12a).
return { html, refresh };
}The catalog links the real variables.css + global.css + /components.css, so panels render with production styles. Two properties fall out of the design:
- Forced pseudo-states. Because each pseudo-state has a parallel
data-force~="…" selector, :hover/:focus-visible/:active are viewable statically (the .md writes data-force="hover") — no interaction needed.
- Token cascade is provable. Components use only semantic tokens, which point
at primitives (§2). Change one primitive in variables.css and every catalog panel restyles — the catalog is the regression check for the token graph.
Because it is served (a /catalog route, not a static file), the file:// failure mode of static-file catalogs simply doesn't exist here.
14. Audit history & certification
14.1 Final audit (this revision) — assembled & run
The whole backend was assembled exactly as this document specifies, typechecked, served, and tested. Findings and their fixes:
| # | Sev | Finding | Fix | ||
|---|---|---|---|---|---|
| F1 | High | Four classes used constructor(private x: T) parameter properties — banned by §1.2 and rejected by erasableSyntaxOnly and Node's strip-only loader (Bun tolerated them, hiding the issue). | Explicit fields in SqlItemRepository, RestItemRepository, ItemService, HttpError. | ||
| F2 | High | The erasable/ESM model fails to typecheck without "type": "module" in package.json; this was unstated. | Documented as required setup (§1.2). | ||
| F3 | Med | erasableSyntaxOnly requires TypeScript ≥ 5.8; unstated. | Documented minimum version (§1.2). | ||
| F4 | High | GET /api/items returned the domain shape but POST /api/items returned a view model — the JSON API was internally inconsistent. | Service mutations now return domain Item; /ui maps to a view, /api serializes domain. GET/POST verified identical keys. | ||
| F5 | Low | static.ts used Bun-only import.meta.dir, a runtime-coupling leak outside the platform seam. | Switched to import.meta.dirname, then to an injected root param (F6). | ||
| F6 | Structural | Framework engine and example app lived in one tree, confusing "is this framework or my app?". Restructure to /framework + /app introduced a static-path regression (relative root broke the traversal guard → 403). | Split into /framework (zero app imports, verified) + /app + top-level server.ts; makeStatic(rt, root) resolves root to absolute. Re-typechecked and re-run green. | ||
| F7 | High | Traversal guard used path.startsWith(ROOT) — a sibling dir sharing the prefix (/p/frontend-secret vs /p/frontend) passed, reachable via ../. The prior "guard is reliable" claim was false. | Separator-aware check: `path === ROOT \ | \ | path.startsWith(ROOT + sep)` (§11). |
| F8 | High | Slot splice html.replace("<!--slot:i-->", p) used a string replacement, so child HTML containing $&/` $ `/$$` was pattern-substituted and corrupted. | Function replacement () => p skips substitution (§9). | ||
| F9 | High | /ui POST routes called req.json(), but htmx posts application/x-www-form-urlencoded by default → every form POST threw into the catch. The htmx write path never worked. | /ui reads req.formData() via formFields(); /api stays JSON (§11). | ||
| F10 | Med | data-bind-<attr> set URL attrs (href/src/…) straight from data; HTML escaping does not stop javascript:/data: schemes — an XSS vector once external data (REST repo) flows in. | safeAttr scheme allowlist on URL-valued attrs (§9). | ||
| F11 | Med | A typo'd each="..." rendered empty silently, contradicting the "no silent binding failures" guarantee. | each now calls onMissing when the path is not found (§9). | ||
| F12 | Low | resolvePath used key in Object(cur), reaching prototype keys (__proto__, constructor); format() used host-locale toLocaleDateString() (non-deterministic). | Object.hasOwn (own-prop only) + toISOString() (§9). |
Certification (verified by running, post-restructure): /framework imports nothing from /app (the reusability test passes); tsc -p is green under erasableSyntaxOnly + verbatimModuleSyntax (TS 6.0); the full test suite passes; the split server serves ui=200 api=200 static=200 post=201 badpost=400 traversal=403 404=404; and GET/POST /api/items return identical domain keys.
Scope of the prior certification (honest note). The earlier "certified" pass exercised typecheck, framework/app isolation, and a JSON-fetch test harness — it did not exercise real htmx form POSTs or probe the traversal guard with sibling-prefix paths, the two paths most likely to break in production. Both were broken (F7–F9) and are now fixed and covered by tests (§13). Treat "certified" as "passes the listed checks", not "audited exhaustively".
14.2 Prior audit (closed)
| Item | Fixed in |
|---|---|
C1 DOMParser absent in Bun | §9 — native HTMLRewriter (verified) |
| C2 template read per render | §9 — cache Map |
C3 ! on a Promise / null leak | §6 — RETURNING * / explicit not-found throw |
| C4 module-singleton DB | §12 — repository injected at composition root |
H1 .map(this.method) this-loss | §6.4 — mappers are free functions |
| H2 tangled renderer recursion | §9 — clean two-pass (verified) |
| H3 dates render as garbage | §9 format(); §8 view model formats status |
| H4 service/HTTP verb drift | §8/§11 — service owns verbs; routes call them |
| H5 brittle string routing | §11 — Bun native router (verified) |
| H6 no input validation | §11 — requireString() guard |
| H7 error message leak | §11 — generic client message, detail logged |
| M1 not actually atomic | §2/§9 — hyphenated-tag composition (verified) |
| M2 mockup ≠ runtime (1 vs N) | §2 — each + example fallback children |
M3 any at data ingress | §6.4 — typed DTOs, narrowed in mappers |
| M4 htmx from CDN | §3 — /frontend/vendor/htmx.min.js |
| M5 doc tests regex source | §13 — tests drive real routes/render |
| M6 server.ts god-function | §11/§12 — split /http/*, thin root |
14.3 Known open items (deferred by design, not defects)
- Auth / CSRF — required before exposing write endpoints publicly (§16).
- Persistence — in-memory placeholder by design; swap a real
ItemRepository. - Observability — APM/OTel auto-instrumentation is weak under Bun; add manual
spans if/when needed.
14.4 Frontend-layer audit (2026-06-27) — fixed
The frontend layer (pages, catalog, sitemap, the b- components, self-closing / prop-text) was audited after the backend certification. Findings and fixes:
| # | Sev | Finding | Fix |
|---|---|---|---|
| A1 | Med | Archived card's button rendered hx-post="" (empty action) and stayed clickable — htmx would POST to the current page URL. | Engine omits a bound attribute whose value is empty/absent (§9); archived button now has no hx-post. |
| A2 | Med | b-badge rendered two classes (badge badge-active) — the utility-soup the one-class rule forbids. | Single .badge + data-status attribute; view model supplies tone. |
| A3 | Low | GET /ui/items/:id and POST /ui/items/:id/archive threw on a missing id → HTTP 500. | Explicit existence check → 404 fragment. |
| A4 | Low | POST /api/items with malformed JSON → 500. | jsonBody() wraps parse → HttpError(400). |
| A5 | Low | Page server probed fileExists with a .. path (existence info-leak; content still 403'd). | Skip the flat-file probe when the path contains ... |
| A6 | Low | /ui error fragment interpolated HttpError.message unescaped. | escHtml() on the message. |
| A7 | Nit | Token drift (literal 1.1rem, 600, 0.55rem); expandSelfClosing rebuilt its RegExp per call; sitemap.xml unescaped; stale .is-* comment. | Tokenized; regex cached on refresh(); XML-escaped <loc>; comment fixed. |
Re-verified: tsc green (erasable, allowImportingTsExtensions), full suite passes, server serves entrance//home//about//catalog//sitemap.xml//robots.txt, pages expand b-* tags, archived button is inert, badge is single-class.
14.5 AI interaction layer + design-system audit (2026-06-30) — verified
Added the AI interaction layer (§17), the Sourdough retheme, the catalog upgrades, and the global ⌘K palette. Full audit findings:
| Area | Result |
|---|---|
| Framework purity | framework/ imports nothing from app/ — stream.ts + accepts.ts are generic (verified by grep). |
| Erasable TS | No enum, no parameter-properties; the AI vocabulary uses ActionName/SurfaceKind unions + a const registry (the erasable "enum"). tsc green. |
| SSOT | app/ai/contract.ts is the single source for verbs + surface kinds; view models are typed against it (action.name: ActionName), surfaces built via surface(). Manifest accepts are derived (harvest + registry inversion), never hand-typed; startup drift-guard clean. |
| Binary assets | makeStatic now types woff2/woff/svg; fonts served byte-exact (magic wOF2, exact length) from the composition root. |
| Routes | 14/14 smoke-tested 200 + correct content-type; /intent → 202 valid / 400 unknown verb; SSE confirm + rollback land over /stream. |
| Design system | Monochrome paper/ink applied app-wide via the semantic-token remap (no per-component edits); grade-as-signal real (Redaction 35/50/70 self-hosted); no stale --gray-* refs. |
| Tests | 15 pass / 0 fail (interaction-layer validation, single-writer, rollback, streaming). |
Open items (deferred by design, not defects): the reasoner is a stub behind the real Model seam; the Gate (triage → light/heavy routing) and heavy-path "thinking" UI are not yet wired (build-order step 3); the ⌘K palette indexes pages + components (tasks/ knowledge and intent-emitting commands are seams); SSE has no auth (single-user; multi-device deferred with conflict-resolution, docs/MVP.md).
15. Build order
Steps 1–4 are verified. The database is deferred on purpose — build the architecture on the in-memory placeholder, swap one adapter later.
16. Decisions locked
- Bun + erasable-only TypeScript + platform port. Native HTML parsing is the
reason; the port makes the runtime a one-file seam.
- Framework / app separation. The reusable engine lives in
/framework(zero
app imports, verified), the project in /app, and server.ts at the top wires them. You can delete /app and reuse /framework elsewhere.
- Storage is the
ItemRepositoryport — no database wired in. The in-memory
repository is the default; a real DB (SQL via SqlClient, or REST) is one swap at the composition root. Architecture first; pick the engine later.
- DTOs and mappers are separate, per source. No inheriting
Item; the mapper
is the single source of truth for how each source relates to the domain.
- Components are hyphenated tags, filename = tag name, expanded server-side.
- One folder per component; CSS co-located with its template. The design
system (tokens + base) lives in /frontend/styles; per-component CSS sits beside the .html and is concatenated into one cached /components.css bundle (createStyleBundle, §11.1) — co-location to author, one request to serve, no build step.
- Vanilla CSS, one class per element; variants are attributes. Not a utility
framework — the co-located .css owns the styling, so an element wears a single class and variations are attributes the CSS targets (.btn[data-variant="soft"]). Tokens are two-layered (primitives → semantic, §2); components use only semantic.
- Each component documents itself; the catalog is generated. A co-located
<name>.md (states/variants as html fences) feeds a server-generated /catalog — Storybook-style, no build, no deps, live render + copyable source (createCatalog, §13a). Pseudo-states shown statically via data-force.
- One folder per page; behaviour co-located, URL mirrors the tree.
pages/
is the page tree: pages/index.html is the entrance, every other page is a folder with index.html + index.js beside it, nested pages are subfolders (makePageServer, §11.2). Markup stays declarative (htmx attributes only); no inline event handlers — imperative glue goes in the page's index.js.
- Pages compose components; they are rendered through the engine. A page is
run through renderPage (§9), so it uses atomic component tags (<app-header>, a <form> of <b-input> + <b-button>) — never raw, class-laden markup. Raw HTML belongs inside a component's own .html (it owns its markup); the page layer stays clean and semantic.
- Pages are flat files; folders only group subpages.
pages/home.html→/home;
a page with children becomes a folder (profile/index.html + profile/settings.html). Minimal JS may live in a <script> after the UI — hx-on scattered in markup is what's avoided, not a single script block.
- One sitemap, three uses.
createSitemapderives routes from thepages/tree
for the catalog's Pages nav, /sitemap.xml, and /robots.txt (§11.4) — add a page, it appears in all three.
- Navigation is real document loads + native View Transitions. No client
router; inter-page nav animates via CSS cross-document view transitions (@view-transition { navigation: auto }, §11.3). htmx drives intra-page fragment swaps; the platform drives inter-page transitions. Reduced-motion honoured.
- Keep
data-field/data-bind-*markers in output. Field names are not a
secrecy boundary — keep non-public data out of the view model.
HTMLRewriter, not a parser library. Native, zero-dependency, verified.- Components take config props (any literal attribute beyond
data/each):
<slot-tag prop-as> swaps the element (polymorphism), prop-attr-<x> sets an attribute from a prop (variants). Guardrail: config places only a tag name or static value — never data logic. One b-text and one b-button cover all headings/text/links and all button variants.
- Native Bun router; a route is a thin presentation adapter.
/ui/*returns
HTML fragments (htmx); /api/* returns JSON (Response.json); both call the same service. No hand-rolled matcher, no duplicated logic.
- Logic in the service, not the template.
Built-for-myself trims (deliberately deferred)
These were cut to keep the architecture focused; re-add when a real need appears.
- No database / migrations — in-memory placeholder; swap a real repository in.
- No API-doc generator — add when the JSON API has external consumers.
- No auth / CSRF — required before exposing write endpoints publicly; out of
scope while building for yourself locally.
Verified capabilities
- Hot reload — recursive
fs.watch→renderer.refresh(); template edits
appear on the next request, no restart (§12a).
- No silent binding failures — strict/warn modes report a missing path while
leaving explicit null alone; throw makes a template/data mismatch a failing test (§9, §13).
- Native routing + dual representation —
/uiHTML and/apiJSON off one
service, POST validation (400) and create (201), :id params (§11, §13).
- Env-driven config — one module keys dev/prod differences (§12a).
17. AI interaction layer (GRAIN) & design-system signal (2026-06-30)
The AI-interaction layer has its own name — GRAIN — and its own doc (docs/GRAIN.md): a design system + framework an AI can operate. BATCH is its reference substrate (no-build hypermedia), but GRAIN is substrate-agnostic — grain/ imports nothing from batch/ (verified). It depends only on a small port (OpChannel — push(session, event, data)), which BATCH's SSE hub satisfies structurally; the implementation is injected by the composition root. Both are headed for separate repos once the product ships. This section records the BATCH-side additions that can back GRAIN (a substrate must provide an OpChannel, a renderer that understands the binding vocabulary, and a filesystem — see docs/GRAIN.md).
This section records the stack-level additions made while building the product. The product's own contracts are the SSOTs: how the AI drives the UI lives in docs/AI-INTERFACE.md; the visual identity in docs/DESIGN-SYSTEM.md. Here we document only the reusable mechanism and where it sits in the stack.
17.1 The one door + server push
The product inverts CRUD: the AI is the single writer, and a human click and an AI decision resolve to the same named action through one endpoint (POST /intent), never a privileged DOM back-channel. Because the AI acts on its own timeline (a reasoner takes seconds; background workers fire unprompted), the server must push to the page. Two pieces are reusable stack, the rest is app:
framework/http/stream.ts—createStream(): a generic per-session SSE hub
(subscribe(id) → Response, push(id, event, data), broadcast). Zero app knowledge; carries opaque JSON. This is the one structural addition to BATCH — htmx stays for client-initiated reads, SSE adds server-initiated push.
framework/render/accepts.ts—createAccepts(): harvestsdata-kind/
data-accepts off component .html so the AI's manifest is a projection of the real components and can't drift (the "one source, four uses" property — catalog, CSS, sitemap, and AI manifest). A startup drift-guard warns if a component declares a verb the backend doesn't allow.
The app layer (app/ai/*, docs/AI-INTERFACE.md) holds the closed vocabulary (contract.ts — ActionName/SurfaceKind unions + the ACTIONS registry, the single source of truth for verbs and surface kinds), the single-writer interaction layer, the reasoner boundary (a stub today, behind the real Model seam), and the /intent /stream /ai/manifest routes. The one client-JS island, frontend/scripts/ai-dispatch.js, turns clicks into intents and applies render ops by semantic surface address (data-surface) — never by tag or CSS class.
The dual write paths (ownership decides the mechanism). Mutations of AI-owned state go through the door; the one sanctioned direct-to-storage exception is category-1 user ground-truth (the knowledge base), which writes straight to a repository via plain htmx. There is no generic direct-write endpoint — the mechanism must match the data's ownership category, set at creation (see docs/MVP.md).17.2 Two reusable seams added to the stack
makePageServer(…, injectBeforeBodyEnd, injectBeforeHeadEnd)— appends a global
asset before </body> (deferred islands: the ⌘K palette, the theming controls) and, via the head seam, before </head> (render-BLOCKING bootstraps that must run before first paint — the theme pre-set that stops the default-theme flash) on every rendered page. The platform-wide-asset seams. A page shell built outside the page server (the catalog) accepts the same pair via createCatalog(…, { headEnd, bodyEnd }) — one source of global assets, no page drifts.
makeStaticbinary content-types — woff2/woff/svg are now typed correctly;
fonts are served byte-exact from the composition root (a text read would corrupt them), so self-hosted fonts need no CDN.
17.3 Grade as signal (design system ↔ interaction)
Provenance/commit-state is a cross-cutting concern carried by one inherited custom property, not per-component code (this is why atoms have almost no AI-specific markup). An ancestor sets data-grade / data-commit; the type atom reads --type-font; CSS inheritance distributes it. Self-hosted Redaction grades make the texture real: clean = human, grain (Redaction 50) = AI / in-transit. AI speech stays grain (provenance persists); a user's optimistic action settles to clean on commit. Non-text atoms express the same state their own way (a button grows a dashed "terminal" edge + block caret). See docs/DESIGN-SYSTEM.md §3 and docs/AI-INTERFACE.md §5.
17.4 Catalog upgrades (still no-build)
/catalog now: pairs <name>.md (Human) with optional <name>.ai.md (AI) and a per-component toggle swaps the view; groups the side-nav by atomic layer (atoms/molecules/organisms) as collapsible dropdowns with a search filter; and renders inline markdown (code, bold, links) in prose. The AI toggle re-grades only the live previews, never the catalog's own chrome.
17.5 Status
tsc green (erasable, verbatimModuleSyntax); full suite passes; /framework still imports nothing from /app (the new stream.ts/accepts.ts are generic); all routes serve (/, /about, /catalog, /components.css, /ai/manifest, /search.json, /sitemap.xml, /robots.txt, /fonts/*, /scripts/*); the /intent door returns 202 on a valid intent and 400 on an unknown verb; SSE confirms/rolls-back land over /stream. See §14.5.
18. Static export / prerender (opt-in — foreshadowed in §0.5)
An optional dist/ export, layered on top of the running server — never a second renderer. The server already produces final, component-expanded HTML; the exporter is a projection of it: boot the app, enumerate routes, fetch each, write the bytes. If it ever re-implements composition, it has become a build step and violated the stack's premise ("the server is the no-build step", §0.5). It stays a crawler + writer.
The moving parts already exist: createSitemap().routes() (§11.4) enumerates every page; renderPage (§9) emits plain HTML with all b-* tags expanded; /components.css, styles/*, /fonts/* and /scripts/* are static. Headless drive is proven (bun run shots).
The boundary (what is and isn't exportable)
The stack is request→render→swap, so an export freezes only the initial document. Draw the line explicitly or the export silently ships broken pages:
- Exportable — content pages. Pages whose HTML is complete server-side: the entrance,
editorial pages, and especially /catalog and /grain (the GRAIN showcase — fully-static output — live previews + copyable source, zero runtime data). These are the primary use case: a shareable, hostable design-system + component reference.
- NOT exportable — operable surfaces. Anything behind the one door:
/intent,
/stream (SSE), render ops (§17). These are dynamic by definition; a static copy has no backend to talk to. The AI loop cannot be a static file.
- In between — pages with htmx reads. A page with
hx-get="/ui/…" hx-trigger="load"
exports as a shell (Loading…) that XHRs a backend that isn't there. Handle via the two tiers below.
Two tiers
- Shell export (trivial). Walk
sitemap.routes()+/catalog+ the portfolio pages
(/, /grain, served from config.portfolioPagesDir, not in the sitemap), fetch each, write dist/<route>/index.html; copy the referenced assets plus the data routes islands fetch — /search.json (⌘K palette), /sitemap.xml, /robots.txt — which are not linked assets a crawler of href/src would find. Correct for pages with no dynamic reads. Absolute paths need a root-served host. Every ref is absolute (/styles, /scripts, /assets/sprite.svg); they resolve fine on a root host (custom domain, a user.github.io repo, most CDNs) but 404 on a project GitHub Pages site served under user.github.io/<repo>/ — a subpath. To ship under a subpath the exporter must rewrite absolute→relative (or inject <base> / honor a PUBLIC_BASE_PATH). Never file:// either (browsers forbid file-origin fetch, so htmx/asset loads fail — the web-platform limit §0.5 documents).
- True prerender (phase 2, optional). For each
hx-trigger="load"target, also fetch
its /ui/* fragment, inline it where the target sits, and strip the trigger — the page is then complete with no backend. This is the only tier that makes a data-backed page genuinely static.
The export disposition of a route (the four buckets)
The stack is dual-mode by design: the live server is primary (§12), and the export is a projection layered on top of it, never a replacement. So "can this app be static?" is not one answer — it's a per-route question, and every route falls into exactly one of four buckets. This is the rule an app with an API or a database needs before it exports.
The governing principle, stated once: a route can be frozen only if its bytes are identical for every visitor at build time. Everything below is a corollary of that.
| Bucket | What it is | Export behavior |
|---|---|---|
| Content | HTML complete server-side, same for everyone (editorial pages, /catalog, /grain) | Freezes verbatim. The primary case. |
| Operable | behind the one door — /intent + SSE (§17) | Excluded (a static copy has no backend) — unless its scenarios are service-free, in which case the page opts into the client-side door (§19.3) and runs the same vocabulary in-browser. That is how /grain's demo survives the freeze. |
| Snapshot | a data-backed read (hx-trigger="load" → /ui/*) whose data is the same at build time | Freezable via Tier 2 prerender: bake the fragment in once. A DB-backed page can be static as a build-time snapshot — explicitly not live data. (Tier 2 is designed, not yet built.) |
| Dynamic | per-request or per-user data, real writes, auth | Cannot be static. That route stays on a running server. |
So an app built with an API/DB is not "static or not" — it's a mix. Its content and snapshot routes export to a CDN; its genuinely-dynamic routes keep the Bun server. The honest end-state is a hybrid deploy from one codebase: frozen files where the bytes are invariant, a live server where they aren't. Two consequences worth making first-class (tracked in ROADMAP Track B.9): Tier 2 needs building before snapshot pages export complete instead of as Loading… shells, and a route should eventually declare its own bucket rather than the export caller hand-maintaining the operable / client-door sets (today they live in tjakoen.github.io/tools/export.ts).
Placement
A reusable batch/export capability (not a project-local script), exposed as bun run export. It boots a composition root, reads sitemap.routes() + a caller-supplied allowlist, fetches over localhost, copies config.assetDirs + config.fontsDir verbatim, and writes dist/. It lives in batch/ on purpose: crawl-sitemap→write-files is a pure substrate concern, so it must travel with the framework — then any BATCH site (the portfolio and its /grain showcase, and others later) gets Pages hosting from the one tool, per BATCH's extraction philosophy. No change to batch//grain//project runtime code — the export is an outside observer, so it can't regress the live path. (See tjakoen.github.io/docs/architecture/PLAN.md piece 1, which drives the first real use.)
Status — Tier 1 shipped (2026-07-04)
Built exactly as the audit is (§ generic-engine-in-batch): the framework-generic crawler+writer is batch/export/export.ts (exportSite(config) → ExportReport; imports only node:fs/node:path
- global
fetch, knows no vocabulary), and the app-specific caller istjakoen.github.io/tools/export.ts
(bun run export) — it spawns tjakoen.github.io/server.ts, derives the page allowlist from createSitemap() over both config.pagesDir and config.portfolioPagesDir (so /grain, /batch, /mill come along) plus /catalog, drops the operable set (/intent, /ai/manifest, and the retired /home), and passes config.assetDirs + config.fontsDir as asset mounts. The pure path/rewrite logic (the only branching) lives in batch/export/rewrite.ts with a colocated test.
What Tier 1 does:
- Pages →
dist/<route>/index.html(pretty dirs); generated data routes (/components.css,
/search.json, /sitemap.xml, /robots.txt) → written at their literal path; assets copied verbatim (binaries byte-for-byte; *.test.* skipped).
- The absolute-path/subpath problem is solved by rewriting every root-absolute ref
(href/src/action, CSS url(), JS import/fetch specifiers, JSON "url" fields) to a caller-supplied PUBLIC_BASE_PATH — the "rewrite absolute→relative" option above (kept absolute-under-a-prefix, which is what a subpath host needs; <base> can't help because absolute URLs ignore it). PUBLIC_ORIGIN swaps the crawl origin (localhost) into sitemap.xml/robots.txt; if unset, the export warns rather than baking in localhost silently. Default (no base path) targets a root host.
- The exportable boundary is enforced, not just documented. After writing, the engine scans every
exported page for internal links that resolve to nothing it wrote and warns, listing them — so operable surfaces excluded by the caller (/intent, /ai/manifest) surface as a confirmable list instead of shipping as silent dead links. (It also caught a pre-existing broken doc-example image in figure.md — since fixed to point at a served grain asset.)
Known Tier-1 limitations (honest): the export needs a server — opening dist/index.html via file:// renders unstyled because every asset ref is root-absolute (that's what hosts need; the export log says so); sitemap.xml still lists operable routes the static site doesn't include — it's the server's sitemap frozen as-is (projection, not re-render); and runtime-constructed URLs in JS (string concat) aren't base-path rewritten. Tier 2 (true prerender of hx-trigger="load" targets) remains deferred — build only when a data-backed page must be genuinely static.
19. No-build client modules & the client-side runtime (opt-in — foreshadowed in §0.5)
19.1 The primitive — "the server is the build step", now for the browser too
Bun already runs the stack's TypeScript on the server with no build step — transpile-on-execute. §0.5's premise ("the server is the no-build step") had one asymmetry: the browser still got hand-written .js islands, because a browser can't run .ts. batch/http/modules.ts (makeModuleServer) closes that gap: it serves any .ts module to the browser transpiled on request. GET /modules/grain/ai/contract.ts → browser-ready JS. Relative imports inside a module resolve by URL and are transpiled the same way, recursively — no import rewriting, because source uses explicit .ts extensions under verbatimModuleSyntax. It is ephemeral (no bundler, no artifact, no dependency graph on disk), which is why it is not a build step in the sense §0.5 forbids — it is the same transpile-on-demand philosophy, now symmetric across server and client.
This is feasible only because the stack is zero-runtime-dep (§ non-negotiables): there is no node_modules graph to resolve, so Bun.Transpiler (a builtin) is the entire toolchain.
The immediate, non-speculative win: today the islands can't import the real vocabulary, so they re-declare verbs as string literals kept honest only by the boot-time drift-guard warning (server.ts). With client modules, an island imports the real contract.ts — one source of truth reaches the browser, the drift-guard workaround retires. (Proven end-to-end: a browser imports the live contract.ts and reads ACTIONS with no build.)
19.2 THE CLIENT-SAFE BOUNDARY (communicate this — it is the whole safety story)
Serving code to the browser means the browser runs it and anyone can read it. A module is safe to ship through /modules only if all three hold. This boundary is load-bearing and must be stated wherever client-side mode is offered (docs, page UI, the tool that flags a module):
- No server-only imports — no
node:*,bun,fs,path, no third-party. **Enforced
mechanically: makeModuleServer scans imports (findServerOnlyImports) and, on any bare (non-relative) specifier, refuses the module — replacing it with a throwing stub whose message names the offending import, so the failure is loud in the browser console, never silent**.
- No secrets — API tokens, keys, credentials. Not auto-detectable; the developer's
responsibility. Keep secrets in server-only modules + env, never in anything reachable from a /modules root. Treat every client module's full source as public.
- No server-required behavior — real persistence, multi-user authority, live external APIs. If
the logic needs a server, its client-side form is a demo/replay, not the real thing.
In one line: /modules and the client-side runtime are for static-style pages and self-contained logic. Anything that needs a server or holds sensitive data stays server-side. This is exactly the same boundary §18 draws for the static export — client modules just move the line for logic the way export moves it for pages.
19.3 The client-side runtime (the door, in the browser) — opt-in, built
The door is already port-shaped: createInteractionLayer({ reasoner, stream, service… }) pushes RenderOps to an OpChannel port (batch's SSE hub satisfies it structurally, §17). So running the door in the browser is a wiring change, not a rewrite:
- Replace the SSE
OpChannelwith an in-process (loopback) channel that hands ops straight to the
dispatcher's applyOp.
- Replace
POST /intentwith a direct call to the in-browser door. - Run the reasoner (the stub, or an in-browser small model — see the lightweight-model track) and the
service (ephemeral / localStorage) client-side.
The result, for a static host like the portfolio: a human and an AI operate the same closed vocabulary through the same door, applied by the same dispatcher, with no backend — the thesis, made hostable. It stays opt-in because it ships framework JS (the door + reasoner + registry), which trades against the native-first byte budget (§0.5) — right for a showcase surface, wrong for content pages. Selecting server-door vs client-door is a composition-root choice (tjakoen.github.io/server.ts); batch only provides the mechanism (the module server + a generic loopback channel), knowing nothing of the door or the vocabulary.
Layering: the module server + loopback channel are batch (substrate, vocabulary-agnostic); the client-door wiring is grain/ai/* (it knows the door); the mode switch + which reasoner/state/surfaces is project/portfolio. Static export (§18) freezes the transpiled modules into dist/ (transpile- at-export), so an operable-static showcase ships as plain files.
Status: §19.1–19.3 built + tested (2026-07-04). The module server serves an all-.js browser-facing graph (a .js URL falls back to its .ts source; relative specifiers rewritten .ts→.js) — required because static hosts serve .ts files with a non-JS MIME type that browsers refuse for ES modules; dev and the frozen export use the same URLs. The client door itself is grain/ai/client-door.ts (the loopback OpChannel → the dispatcher's applyOp); the dispatcher's transport seam is <body data-ai-transport="client">; the export freezes the door's module graph via exportSite({ moduleEntries }) and the caller stamps the marker via transformPage (tjakoen.github.io/tools/export.ts). The live server path is unchanged — pages without the marker keep the server door (verified both ways).