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,