Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions .claude/skills/adding-a-feature/references/page.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,10 @@ Takes fully resolved data as props, wrapped in the layout:
`name` sets `data-page` on `<body>`, which is what dispatches the client script in step 6. Any
form that POSTs needs `<CsrfField token={csrfToken} />`.

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`

Expand Down
7 changes: 3 additions & 4 deletions .claude/skills/writing-tests/references/client.md
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand All @@ -49,8 +48,8 @@ render(<ProjectSearch projects={[{ id: 1, title: "Test" }]} />, 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.
Expand Down
14 changes: 14 additions & 0 deletions .github/pull_request_template.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
## What & why

<!-- The change, and the problem it solves. Link the issue or runbook section if there is one. -->

## Checks CI can't run

<!-- Delete the lines that don't apply. -->

- [ ] 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
30 changes: 18 additions & 12 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
15 changes: 3 additions & 12 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

7 changes: 2 additions & 5 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
}
1 change: 0 additions & 1 deletion src/client/components/project-search.test.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
/** @jsxImportSource preact */
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
import { render } from "preact";
import { ProjectSearch } from "./project-search";
Expand Down
1 change: 0 additions & 1 deletion src/client/components/project-search.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
/** @jsxImportSource preact */
import { useEffect, useState } from "preact/hooks";

interface ProjectItem {
Expand Down
4 changes: 3 additions & 1 deletion src/server/components/badge.tsx
Original file line number Diff line number Diff line change
@@ -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) => (
Expand Down
2 changes: 1 addition & 1 deletion src/server/components/captcha-widget.test.tsx
Original file line number Diff line number Diff line change
@@ -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 = {
Expand Down
2 changes: 1 addition & 1 deletion src/server/components/captcha-widget.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { JSX } from "react";
import type { JSX } from "preact";
import { getAssetUrl } from "../services/assets";
import {
CAPTCHA_SOLUTION_FIELD,
Expand Down
2 changes: 1 addition & 1 deletion src/server/components/csrf-field.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { JSX } from "react";
import type { JSX } from "preact";

interface CsrfFieldProps {
token: string | null;
Expand Down
4 changes: 3 additions & 1 deletion src/server/components/data-table.tsx
Original file line number Diff line number Diff line change
@@ -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
Expand Down
4 changes: 3 additions & 1 deletion src/server/components/flash.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import type { ComponentChildren } from "preact";

interface FlashProps {
type: "success" | "error" | "warning";
children: React.ReactNode;
children: ComponentChildren;
}

const CLASS_NAMES = {
Expand Down
4 changes: 3 additions & 1 deletion src/server/components/form-field.tsx
Original file line number Diff line number Diff line change
@@ -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) => (
Expand Down
8 changes: 4 additions & 4 deletions src/server/components/layouts.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type React from "react";
import type { ComponentChildren } from "preact";

import { getAssetUrl } from "../services/assets";
import {
Expand Down Expand Up @@ -73,7 +73,7 @@ function HeadMeta({
interface LayoutProps {
title: string;
name: string;
children: React.ReactNode;
children: ComponentChildren;
user?: User | null;
csrfToken?: string;
description?: string;
Expand Down Expand Up @@ -180,7 +180,7 @@ function SiteFooter() {

interface ErrorLayoutProps {
title: string;
children: React.ReactNode;
children: ComponentChildren;
nav?: boolean;
}

Expand Down Expand Up @@ -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;
Expand Down
6 changes: 3 additions & 3 deletions src/server/components/logo.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"
>
<path d="M21 8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16Z" />
Expand Down
2 changes: 1 addition & 1 deletion src/server/controllers/auth/login.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,7 @@ describe("Login Controller", () => {
const html = await response.text();

expect(html).toContain("Check your email!");
expect(html).toContain("We&#x27;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 () => {
Expand Down
12 changes: 6 additions & 6 deletions src/server/templates/home.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"
>
<path d="M12 3q1 4 4 6.5t3 5.5a1 1 0 0 1-14 0 5 5 0 0 1 1-3 1 1 0 0 0 5 0c0-2-1.5-3-1.5-5q0-2 2.5-4" />
Expand Down Expand Up @@ -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"
>
<path d="M12 21V7" />
Expand Down
2 changes: 1 addition & 1 deletion src/server/templates/projects.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand Down
4 changes: 2 additions & 2 deletions src/server/utils/errors.tsx
Original file line number Diff line number Diff line change
@@ -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
Expand Down
4 changes: 2 additions & 2 deletions src/server/utils/response.ts
Original file line number Diff line number Diff line change
@@ -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
Expand Down
2 changes: 1 addition & 1 deletion tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
"target": "ESNext",
"module": "ESNext",
"jsx": "react-jsx",
"jsxImportSource": "react",
"jsxImportSource": "preact",
"moduleResolution": "Node",
"strict": true,
"noUnusedLocals": true,
Expand Down