diff --git a/.claude/skills/adding-a-feature/references/page.md b/.claude/skills/adding-a-feature/references/page.md
index b07415b..9a1b0b3 100644
--- a/.claude/skills/adding-a-feature/references/page.md
+++ b/.claude/skills/adding-a-feature/references/page.md
@@ -20,8 +20,10 @@ Takes fully resolved data as props, wrapped in the layout:
`name` sets `data-page` on `
`, which is what dispatches the client script in step 6. Any
form that POSTs needs ``.
-This compiles with React's JSX runtime and renders once on the server — it never hydrates. Don't
-reach for `useState` here.
+This renders once on the server and never hydrates, so don't reach for `useState` here — it's the
+same Preact runtime the islands use, but the output is a string. Write SVG attributes in kebab-case
+(`stroke-width`, not `strokeWidth`); Preact passes camelCase through verbatim and the browser
+ignores it.
## 3. Controller — `src/server/controllers/app/dashboard.tsx`
diff --git a/.claude/skills/writing-tests/references/client.md b/.claude/skills/writing-tests/references/client.md
index d3f8f49..9a19965 100644
--- a/.claude/skills/writing-tests/references/client.md
+++ b/.claude/skills/writing-tests/references/client.md
@@ -39,8 +39,7 @@ The fixture has to match what the server actually renders — the same ids, clas
Render into a container and assert on the output:
-```ts
-/** @jsxImportSource preact */
+```tsx
import { render } from "preact";
const container = document.createElement("div");
@@ -49,8 +48,8 @@ render(, container);
expect(container.textContent).toContain("Test");
```
-The `/** @jsxImportSource preact */` pragma on line 1 is required — without it the file compiles
-against React's runtime and the render fails.
+Preact is the project-wide JSX runtime, so no pragma is needed. The file must be `.tsx` for the
+JSX to compile.
Islands here reach outside their own tree (`ProjectSearch` toggles rows in the server-rendered
table by id), so the fixture usually needs that surrounding markup in `document.body` too.
diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md
new file mode 100644
index 0000000..fdb205e
--- /dev/null
+++ b/.github/pull_request_template.md
@@ -0,0 +1,14 @@
+## What & why
+
+
+
+## Checks CI can't run
+
+
+
+- [ ] Verified in the browser — CI has none (screenshots welcome for UI changes)
+- [ ] New third-party script has a CSP entry, an SRI `integrity` hash, and a `preconnect` in `layouts.tsx`
+- [ ] New page is registered in `client/main.ts` and its CSS `@import`ed in `client/style.css`
+- [ ] New table is added to `cleanupTestData` in `test-utils/helpers.ts`
+- [ ] Changed headers, cookies, metadata, or email delivery → the matching `runbooks/` doc is updated
+- [ ] Renamed or deleted files that `START_PROMPT.md` lists → that file still matches reality
diff --git a/CLAUDE.md b/CLAUDE.md
index f180128..cbc9c51 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -17,18 +17,24 @@ spec for everything else. This file is for the things you can't learn by reading
## Gotchas
-### Two JSX runtimes, no hydration
-
-Server templates and `src/server/components/` compile with React's runtime (`jsxImportSource: react`
-in `tsconfig.json`) and render once through `renderToString()` in `src/server/utils/response.ts`.
-None of it hydrates — there is no React on the client, and `useState` in a server component does
-nothing.
-
-Client interactivity is Preact islands. A client component opts in per-file with a
-`/** @jsxImportSource preact */` pragma on line 1, and the page script mounts it with
-`render()` from `preact`. Preact is marked `--external` in the build scripts and resolved at
-runtime from the import map in `src/server/components/layouts.tsx`, so the version pinned there
-must stay in step with `package.json`.
+### One JSX runtime, two execution models
+
+Everything compiles with Preact (`jsxImportSource: preact` in `tsconfig.json`) — there is no React
+in this project. What differs is *when* the JSX runs, and the `src/server/` vs `src/client/` split
+is the signal:
+
+- **`src/server/`** renders once through `renderToString()` from `preact-render-to-string` and ships
+ as HTML. It never hydrates, so `useState` in a server template does nothing.
+- **`src/client/`** mounts into the live DOM with `render()` from `preact` and is fully interactive.
+
+`preact` is a runtime **dependency**, not a devDependency — the server imports its JSX runtime, so a
+production install without it won't boot. The client bundle marks it `--external` and resolves it
+from the import map in `src/server/components/layouts.tsx`, so the version pinned there must stay in
+step with `package.json`.
+
+**Write SVG attributes in kebab-case** (`stroke-width`, not `strokeWidth`). Preact passes camelCase
+attribute names through verbatim, and the HTML parser doesn't recognise `strokeWidth` — the stroke
+silently renders at the default width. React used to rewrite these; nothing does now.
No Web Components. Shadow DOM and custom-element lifecycles need browser infrastructure to test;
pure functions and Preact islands are both testable under `bun:test`.
diff --git a/README.md b/README.md
index 3470118..f4f09f2 100644
--- a/README.md
+++ b/README.md
@@ -108,7 +108,7 @@ Run the full suite: `bun run test`
### Frontend
-- **React JSX as a template engine** — server-side only, no client-side React, no virtual DOM, no hydration
+- **Preact JSX as a template engine** — server-rendered to a string, no hydration, no client-side framework runtime on the page by default. One JSX runtime across server and client, so there's no second React toolchain to reason about
- **Bun CSS bundler** with `@import` resolution, CSS nesting, and minification — no external CSS tooling needed
- **Opt-in interactivity** — sprinkle in any client-side framework per page (ships with a Preact island example loaded via CDN import map)
- **Page lifecycle system** — `registerPage()` / `PageController` pattern with `init()` and `cleanup()` for per-page JS
diff --git a/bun.lock b/bun.lock
index a2e7417..8d8a9a8 100644
--- a/bun.lock
+++ b/bun.lock
@@ -5,19 +5,16 @@
"": {
"name": "billet",
"dependencies": {
+ "preact": "^10.28.4",
+ "preact-render-to-string": "^6.7.0",
"resend": "^6.9.4",
},
"devDependencies": {
"@biomejs/biome": "2.0.5",
"@happy-dom/global-registrator": "^20.8.3",
"@types/bun": "1.3.0",
- "@types/react": "^19.1.12",
- "@types/react-dom": "19.1.9",
"happy-dom": "^20.8.3",
"husky": "9.1.7",
- "preact": "^10.28.4",
- "react": "^19.1.1",
- "react-dom": "^19.1.1",
"typescript": "^5.9.3",
},
},
@@ -51,8 +48,6 @@
"@types/react": ["@types/react@19.1.12", "", { "dependencies": { "csstype": "^3.0.2" } }, "sha512-cMoR+FoAf/Jyq6+Df2/Z41jISvGZZ2eTlnsaJRptmZ76Caldwy1odD4xTr/gNV9VLj0AWgg/nmkevIyUfIIq5w=="],
- "@types/react-dom": ["@types/react-dom@19.1.9", "", { "peerDependencies": { "@types/react": "^19.0.0" } }, "sha512-qXRuZaOsAdXKFyOhRBg6Lqqc0yay13vN7KrIg4L7N4aaHN68ma9OK3NE1BoDFgFOTfM7zg+3/8+2n8rLUH3OKQ=="],
-
"@types/whatwg-mimetype": ["@types/whatwg-mimetype@3.0.2", "", {}, "sha512-c2AKvDT8ToxLIOUlN51gTiHXflsfIFisS4pO7pDPoKouJCESkhZnEy623gwP9laCy5lnLDAw1vAzu2vM2YLOrA=="],
"@types/ws": ["@types/ws@8.18.1", "", { "dependencies": { "@types/node": "*" } }, "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg=="],
@@ -73,14 +68,10 @@
"preact": ["preact@10.28.4", "", {}, "sha512-uKFfOHWuSNpRFVTnljsCluEFq57OKT+0QdOiQo8XWnQ/pSvg7OpX5eNOejELXJMWy+BwM2nobz0FkvzmnpCNsQ=="],
- "react": ["react@19.1.1", "", {}, "sha512-w8nqGImo45dmMIfljjMwOGtbmC/mk4CMYhWIicdSflH91J9TyCyczcPFXJzrZ/ZXcgGRFeP6BU0BEJTw6tZdfQ=="],
-
- "react-dom": ["react-dom@19.1.1", "", { "dependencies": { "scheduler": "^0.26.0" }, "peerDependencies": { "react": "^19.1.1" } }, "sha512-Dlq/5LAZgF0Gaz6yiqZCf6VCcZs1ghAJyrsu84Q/GT0gV+mCxbfmKNoGRKBYMJ8IEdGPqu49YWXD02GCknEDkw=="],
+ "preact-render-to-string": ["preact-render-to-string@6.7.0", "", { "peerDependencies": { "preact": ">=10 || >= 11.0.0-0" } }, "sha512-Z4WR8fmLMRpdYqJ9i7vrlXSsSrxVJydwrkEXHapexfARbWfGb7vGcnvNQnIzN0cXciMVOlz/XLoiMCi9gUsy9Q=="],
"resend": ["resend@6.9.4", "", { "dependencies": { "postal-mime": "2.7.3", "svix": "1.86.0" }, "peerDependencies": { "@react-email/render": "*" }, "optionalPeers": ["@react-email/render"] }, "sha512-/M3dsJzu5OgozqVsA4Psd/1L7EdePgOIIxClas453GOQYFG3VHc2ZyCHZFlvqsc9aZCCd2BJRRqZgWC8D9c7/g=="],
- "scheduler": ["scheduler@0.26.0", "", {}, "sha512-NlHwttCI/l5gCPR3D1nNXtWABUmBwvZpEQiD4IXSbIDq8BzLIK/7Ir5gTFSGZDUu37K5cMNp0hFtzO38sC7gWA=="],
-
"standardwebhooks": ["standardwebhooks@1.0.0", "", { "dependencies": { "@stablelib/base64": "^1.0.0", "fast-sha256": "^1.3.0" } }, "sha512-BbHGOQK9olHPMvQNHWul6MYlrRTAOKn03rOe4A8O3CLWhNf4YHBqq2HJKKC+sfqpxiBY52pNeesD6jIiLDz8jg=="],
"svix": ["svix@1.86.0", "", { "dependencies": { "standardwebhooks": "1.0.0", "uuid": "^10.0.0" } }, "sha512-/HTvXwjLJe1l/MsLXAO1ddCYxElJk4eNR4DzOjDOEmGrPN/3BtBE8perGwMAaJ2sT5T172VkBYzmHcjUfM1JRQ=="],
diff --git a/package.json b/package.json
index 0690d39..5bc39b3 100644
--- a/package.json
+++ b/package.json
@@ -31,16 +31,13 @@
"@biomejs/biome": "2.0.5",
"@happy-dom/global-registrator": "^20.8.3",
"@types/bun": "1.3.0",
- "@types/react": "^19.1.12",
- "@types/react-dom": "19.1.9",
"happy-dom": "^20.8.3",
"husky": "9.1.7",
- "preact": "^10.28.4",
- "react": "^19.1.1",
- "react-dom": "^19.1.1",
"typescript": "^5.9.3"
},
"dependencies": {
+ "preact": "^10.28.4",
+ "preact-render-to-string": "^6.7.0",
"resend": "^6.9.4"
}
}
diff --git a/src/client/components/project-search.test.tsx b/src/client/components/project-search.test.tsx
index 94f60a4..324b2c0 100644
--- a/src/client/components/project-search.test.tsx
+++ b/src/client/components/project-search.test.tsx
@@ -1,4 +1,3 @@
-/** @jsxImportSource preact */
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
import { render } from "preact";
import { ProjectSearch } from "./project-search";
diff --git a/src/client/components/project-search.tsx b/src/client/components/project-search.tsx
index 90b774d..6a8115c 100644
--- a/src/client/components/project-search.tsx
+++ b/src/client/components/project-search.tsx
@@ -1,4 +1,3 @@
-/** @jsxImportSource preact */
import { useEffect, useState } from "preact/hooks";
interface ProjectItem {
diff --git a/src/server/components/badge.tsx b/src/server/components/badge.tsx
index e027051..55ec085 100644
--- a/src/server/components/badge.tsx
+++ b/src/server/components/badge.tsx
@@ -1,6 +1,8 @@
+import type { ComponentChildren } from "preact";
+
interface BadgeProps {
variant: "admin" | "user";
- children: React.ReactNode;
+ children: ComponentChildren;
}
export const Badge = ({ variant, children }: BadgeProps) => (
diff --git a/src/server/components/captcha-widget.test.tsx b/src/server/components/captcha-widget.test.tsx
index b265113..d34ca68 100644
--- a/src/server/components/captcha-widget.test.tsx
+++ b/src/server/components/captcha-widget.test.tsx
@@ -1,5 +1,5 @@
import { describe, expect, test } from "bun:test";
-import { renderToString } from "react-dom/server";
+import { renderToString } from "preact-render-to-string";
import { CaptchaWidget } from "./captcha-widget";
const challenge = {
diff --git a/src/server/components/captcha-widget.tsx b/src/server/components/captcha-widget.tsx
index de0c801..8990f99 100644
--- a/src/server/components/captcha-widget.tsx
+++ b/src/server/components/captcha-widget.tsx
@@ -1,4 +1,4 @@
-import type { JSX } from "react";
+import type { JSX } from "preact";
import { getAssetUrl } from "../services/assets";
import {
CAPTCHA_SOLUTION_FIELD,
diff --git a/src/server/components/csrf-field.tsx b/src/server/components/csrf-field.tsx
index 2bdb489..046f37c 100644
--- a/src/server/components/csrf-field.tsx
+++ b/src/server/components/csrf-field.tsx
@@ -1,4 +1,4 @@
-import type { JSX } from "react";
+import type { JSX } from "preact";
interface CsrfFieldProps {
token: string | null;
diff --git a/src/server/components/data-table.tsx b/src/server/components/data-table.tsx
index c0bdceb..9ab10bb 100644
--- a/src/server/components/data-table.tsx
+++ b/src/server/components/data-table.tsx
@@ -1,5 +1,7 @@
+import type { ComponentChildren } from "preact";
+
interface DataTableProps {
- children: React.ReactNode;
+ children: ComponentChildren;
className?: string;
// A caption names the table for screen readers. It's visually hidden by
// default (pages usually have a visible heading already); pass
diff --git a/src/server/components/flash.tsx b/src/server/components/flash.tsx
index 9e8ee47..9ffa1c7 100644
--- a/src/server/components/flash.tsx
+++ b/src/server/components/flash.tsx
@@ -1,6 +1,8 @@
+import type { ComponentChildren } from "preact";
+
interface FlashProps {
type: "success" | "error" | "warning";
- children: React.ReactNode;
+ children: ComponentChildren;
}
const CLASS_NAMES = {
diff --git a/src/server/components/form-field.tsx b/src/server/components/form-field.tsx
index 4a57e80..fb5e33a 100644
--- a/src/server/components/form-field.tsx
+++ b/src/server/components/form-field.tsx
@@ -1,7 +1,9 @@
+import type { ComponentChildren } from "preact";
+
interface FormFieldProps {
label: string;
id: string;
- children: React.ReactNode;
+ children: ComponentChildren;
}
export const FormField = ({ label, id, children }: FormFieldProps) => (
diff --git a/src/server/components/layouts.tsx b/src/server/components/layouts.tsx
index c060f71..ced2430 100644
--- a/src/server/components/layouts.tsx
+++ b/src/server/components/layouts.tsx
@@ -1,4 +1,4 @@
-import type React from "react";
+import type { ComponentChildren } from "preact";
import { getAssetUrl } from "../services/assets";
import {
@@ -73,7 +73,7 @@ function HeadMeta({
interface LayoutProps {
title: string;
name: string;
- children: React.ReactNode;
+ children: ComponentChildren;
user?: User | null;
csrfToken?: string;
description?: string;
@@ -180,7 +180,7 @@ function SiteFooter() {
interface ErrorLayoutProps {
title: string;
- children: React.ReactNode;
+ children: ComponentChildren;
nav?: boolean;
}
@@ -213,7 +213,7 @@ export function ErrorLayout({ title, children, nav = true }: ErrorLayoutProps) {
interface BaseLayoutProps {
title: string;
- children: React.ReactNode;
+ children: ComponentChildren;
description?: string;
canonicalPath?: string;
noindex?: boolean;
diff --git a/src/server/components/logo.tsx b/src/server/components/logo.tsx
index dc9295b..a2dc668 100644
--- a/src/server/components/logo.tsx
+++ b/src/server/components/logo.tsx
@@ -7,9 +7,9 @@ export function Logo() {
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
- strokeWidth="2"
- strokeLinecap="round"
- strokeLinejoin="round"
+ stroke-width="2"
+ stroke-linecap="round"
+ stroke-linejoin="round"
aria-hidden="true"
>
diff --git a/src/server/controllers/auth/login.test.ts b/src/server/controllers/auth/login.test.ts
index ef07dd6..0127ae4 100644
--- a/src/server/controllers/auth/login.test.ts
+++ b/src/server/controllers/auth/login.test.ts
@@ -98,7 +98,7 @@ describe("Login Controller", () => {
const html = await response.text();
expect(html).toContain("Check your email!");
- expect(html).toContain("We've sent you a magic link");
+ expect(html).toContain("We've sent you a magic link");
});
test("shows error message when error is provided", async () => {
diff --git a/src/server/templates/home.tsx b/src/server/templates/home.tsx
index f9b640b..0b11383 100644
--- a/src/server/templates/home.tsx
+++ b/src/server/templates/home.tsx
@@ -31,9 +31,9 @@ export const Home = ({ user, csrfToken }: HomeProps) => (
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
- strokeWidth="2"
- strokeLinecap="round"
- strokeLinejoin="round"
+ stroke-width="2"
+ stroke-linecap="round"
+ stroke-linejoin="round"
aria-hidden="true"
>
@@ -201,9 +201,9 @@ export const Home = ({ user, csrfToken }: HomeProps) => (
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
- strokeWidth="2"
- strokeLinecap="round"
- strokeLinejoin="round"
+ stroke-width="2"
+ stroke-linecap="round"
+ stroke-linejoin="round"
aria-hidden="true"
>
diff --git a/src/server/templates/projects.tsx b/src/server/templates/projects.tsx
index b2f2943..ef6f1ba 100644
--- a/src/server/templates/projects.tsx
+++ b/src/server/templates/projects.tsx
@@ -1,4 +1,4 @@
-import type { JSX } from "react";
+import type { JSX } from "preact";
import { CsrfField } from "../components/csrf-field";
import { DataTable } from "../components/data-table";
import { Flash } from "../components/flash";
diff --git a/src/server/utils/errors.tsx b/src/server/utils/errors.tsx
index 0a5c663..0a008b9 100644
--- a/src/server/utils/errors.tsx
+++ b/src/server/utils/errors.tsx
@@ -1,5 +1,5 @@
-import type { JSX } from "react";
-import { renderToString } from "react-dom/server";
+import type { JSX } from "preact";
+import { renderToString } from "preact-render-to-string";
import { ErrorPage } from "../templates/error";
// Renders an error template to a bare HTML Response. Callers pass it through
diff --git a/src/server/utils/response.ts b/src/server/utils/response.ts
index 178077e..abf87a8 100644
--- a/src/server/utils/response.ts
+++ b/src/server/utils/response.ts
@@ -1,5 +1,5 @@
-import type { JSX } from "react";
-import { renderToString } from "react-dom/server";
+import type { JSX } from "preact";
+import { renderToString } from "preact-render-to-string";
// Security headers are applied centrally to every response — see
// `secureRoutes` and the `fetch` fallback in main.ts — so producers here only
diff --git a/tsconfig.json b/tsconfig.json
index f15873e..1ec4d3f 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -3,7 +3,7 @@
"target": "ESNext",
"module": "ESNext",
"jsx": "react-jsx",
- "jsxImportSource": "react",
+ "jsxImportSource": "preact",
"moduleResolution": "Node",
"strict": true,
"noUnusedLocals": true,