diff --git a/apps/auth/README.md b/apps/auth/README.md
index eb34f58f..98f402a8 100644
--- a/apps/auth/README.md
+++ b/apps/auth/README.md
@@ -37,6 +37,8 @@ Hosts the OAuth issuer and the sign-in UI:
src/index.ts # The handler: issuer config, stores, success callback
src/server.ts # The same handler behind a listening socket
src/email.ts # Verification code delivery
+src/theme.ts # Title, favicon, brand colour and fonts
+script/preview.ts # Every screen state, for styling without a database
wrangler.jsonc # Worker configuration, one environment per stage
Dockerfile # The container, built from the repository root
test/
@@ -47,8 +49,14 @@ test/
```sh
bun run dev # under the Workers runtime, on :1337
bun run serve # as a plain process, on $PORT (default 1337)
+bun run preview # every sign-in screen state on :1338, reloading on save
```
+The preview needs no database or mail. It renders each screen the flow can show — every
+alert, every device-grant outcome — with the real renderer and theme, so styling
+`packages/auth/src/ui/` is edit, save, look. A screen added to the flow belongs in its `STATES`
+list too, or nobody will see it before it ships.
+
Its only stateful dependency is Postgres — as a `HYPERDRIVE` binding on Workers, or as
`DATABASE_URL` anywhere else — alongside the mail settings. Full list and deployment steps:
[`docs/deploy.md`](../../docs/deploy.md).
diff --git a/apps/auth/package.json b/apps/auth/package.json
index e19d85cb..28f7dcaa 100644
--- a/apps/auth/package.json
+++ b/apps/auth/package.json
@@ -4,6 +4,7 @@
"scripts": {
"dev": "wrangler dev",
"serve": "bun run src/server.ts",
+ "preview": "bun script/preview-watch.ts",
"deploy": "wrangler deploy"
},
"dependencies": {
diff --git a/apps/auth/script/preview-watch.ts b/apps/auth/script/preview-watch.ts
new file mode 100644
index 00000000..7578728e
--- /dev/null
+++ b/apps/auth/script/preview-watch.ts
@@ -0,0 +1,49 @@
+/**
+ * Runs `preview.ts`, and runs it again whenever the sign-in UI's source changes.
+ *
+ * Not `bun --watch`, which watches each imported file. Many editors save by
+ * writing a new file and renaming it over the old one, and a watch on a file
+ * does not follow the rename: the first such save reloads, and every one after
+ * it is silently ignored. A watch on the directory sees every save, however it
+ * was made.
+ */
+
+import { watch } from 'node:fs';
+import { join } from 'node:path';
+
+const ROOT = join(import.meta.dir, '..', '..', '..');
+const DIRS = [join(ROOT, 'packages/auth/src'), join(ROOT, 'apps/auth/src'), import.meta.dir];
+const SERVER = join(import.meta.dir, 'preview.ts');
+
+let child = start();
+let timer: ReturnType | undefined;
+
+function start() {
+ return Bun.spawn(['bun', SERVER], { stdio: ['inherit', 'inherit', 'inherit'] });
+}
+
+async function restart(file: string) {
+ child.kill();
+ // The port is free only once the old server has exited.
+ await child.exited;
+ console.log(`changed: ${file}`);
+ child = start();
+}
+
+for (const dir of DIRS) {
+ watch(dir, { recursive: true }, (_event, file) => {
+ if (!file || !/\.(ts|tsx)$/.test(file)) return;
+ // One save can be several events: a write, then a rename.
+ clearTimeout(timer);
+ timer = setTimeout(() => restart(file), 60);
+ });
+}
+
+// Ctrl-C reaches the whole process group, but a plain `kill` reaches only
+// this process, and would leave the server holding the port.
+for (const signal of ['SIGINT', 'SIGTERM', 'SIGHUP'] as const) {
+ process.on(signal, () => {
+ child.kill();
+ process.exit(0);
+ });
+}
diff --git a/apps/auth/script/preview.ts b/apps/auth/script/preview.ts
new file mode 100644
index 00000000..491e9f63
--- /dev/null
+++ b/apps/auth/script/preview.ts
@@ -0,0 +1,263 @@
+/**
+ * Every sign-in screen, side by side, reloading as you edit.
+ *
+ * bun run preview # from apps/auth, then open http://localhost:1338
+ *
+ * No database, no mail, no cookies. Each state is the real screen value — the
+ * code provider's own `request` called with a made-up state, and the issuer's
+ * screens from `ui/issuer.ts` — drawn by the real renderer with the real
+ * theme. What differs from production is only how the page was reached.
+ *
+ * `preview-watch.ts` restarts this process when the UI's source changes —
+ * the renderer, the stylesheet, the copy, the issuer's screens. Each page polls
+ * for the restart and reloads itself, so saving a file is the whole loop.
+ *
+ * A state that is missing here is a state nobody will look at before it
+ * ships. Adding a screen to the flow means adding it to `STATES`.
+ */
+
+import type { CodeProviderState } from '@nestri/auth/provider/code';
+import { CodeUI } from '@nestri/auth/ui/code';
+import { IssuerScreens } from '@nestri/auth/ui/issuer';
+import { MARK_CODE, MARK_GITHUB, MARK_GOOGLE } from '@nestri/auth/ui/mark';
+import { HtmlRenderer } from '@nestri/auth/ui/render';
+import type { Screen } from '@nestri/auth/ui/screen';
+
+import { THEME_NESTRI } from '../src/theme.js';
+
+const PORT = Number(process.env.PORT ?? 1338);
+const BOOT = crypto.randomUUID();
+const renderer = HtmlRenderer({ theme: THEME_NESTRI });
+
+// Matches `USER_CODE_LENGTH` in the issuer, which is local to it.
+const DEVICE_CODE_LENGTH = 8;
+
+const code = CodeUI({ sendCode: async () => {} });
+const req = new Request(`http://localhost:${PORT}/`);
+const email = 'someone@example.com';
+const sent = {
+ type: 'code',
+ code: '123456',
+ claims: { email },
+ flow: 'x',
+ expires: 0
+} satisfies CodeProviderState;
+
+interface State {
+ id: string;
+ group: string;
+ label: string;
+ screen: () => Promise | Screen;
+}
+
+const STATES: State[] = [
+ // Email code — what everyone sees.
+ {
+ id: 'email',
+ group: 'Email',
+ label: 'Enter email',
+ screen: () => code.request(req, { type: 'start' }, new FormData())
+ },
+ {
+ id: 'email-invalid',
+ group: 'Email',
+ label: 'Bad address',
+ screen: () =>
+ code.request(req, { type: 'start' }, new FormData(), {
+ type: 'invalid_claim',
+ key: 'email',
+ value: 'nope'
+ })
+ },
+ {
+ id: 'email-rate-limit',
+ group: 'Email',
+ label: 'Rate limited (email)',
+ screen: () =>
+ code.request(req, { type: 'start' }, new FormData(), {
+ type: 'rate_limit'
+ })
+ },
+ {
+ id: 'code',
+ group: 'Email',
+ label: 'Code sent',
+ screen: () => code.request(req, sent, new FormData())
+ },
+ {
+ id: 'code-resent',
+ group: 'Email',
+ label: 'Code resent',
+ screen: () => code.request(req, { ...sent, resend: true }, new FormData())
+ },
+ {
+ id: 'code-invalid',
+ group: 'Email',
+ label: 'Wrong code',
+ screen: () => code.request(req, sent, new FormData(), { type: 'invalid_code' })
+ },
+ {
+ id: 'code-rate-limit',
+ group: 'Email',
+ label: 'Rate limited (code)',
+ screen: () => code.request(req, sent, new FormData(), { type: 'rate_limit' })
+ },
+
+ // Device grant — a TV or a CLI signing in through a browser.
+ {
+ id: 'device-enter',
+ group: 'Device',
+ label: 'Enter device code',
+ screen: () => IssuerScreens.deviceEnter(DEVICE_CODE_LENGTH)
+ },
+ {
+ id: 'device-confirm',
+ group: 'Device',
+ label: 'Is this you?',
+ screen: () =>
+ IssuerScreens.deviceConfirm({
+ userCode: 'BCDF3467',
+ csrf: 'x'
+ })
+ },
+ {
+ id: 'device-approved',
+ group: 'Device',
+ label: 'Approved',
+ screen: () => IssuerScreens.deviceApproved()
+ },
+ {
+ id: 'device-denied',
+ group: 'Device',
+ label: 'Denied',
+ screen: () => IssuerScreens.deviceDenied()
+ },
+ {
+ id: 'device-invalid',
+ group: 'Device',
+ label: 'Code not valid',
+ screen: () => IssuerScreens.deviceInvalidCode()
+ },
+ {
+ id: 'device-too-many',
+ group: 'Device',
+ label: 'Too many tries',
+ screen: () => IssuerScreens.deviceTooManyTries()
+ },
+ {
+ id: 'device-expired',
+ group: 'Device',
+ label: 'Expired',
+ screen: () => IssuerScreens.deviceExpired()
+ },
+ {
+ id: 'device-answered',
+ group: 'Device',
+ label: 'Already answered',
+ screen: () => IssuerScreens.deviceAlreadyAnswered()
+ },
+ {
+ id: 'device-forged',
+ group: 'Device',
+ label: 'Forged form',
+ screen: () => IssuerScreens.deviceForgedForm()
+ },
+
+ // Reached only by accident in production, which is why they need looking at.
+ {
+ id: 'unknown-state',
+ group: 'Errors',
+ label: 'Sign-in expired',
+ screen: () => IssuerScreens.unknownState('The browser was in an unknown state.')
+ },
+ {
+ // Not shown today: with one provider the chooser redirects straight
+ // to it. Kept so a second provider does not arrive to an unstyled page.
+ id: 'choose',
+ group: 'Errors',
+ label: 'Provider chooser (unused)',
+ screen: () => ({
+ kind: 'choose',
+ options: [
+ { href: '#', label: 'Continue with Email', mark: MARK_CODE },
+ { href: '#', label: 'Continue with GitHub', mark: MARK_GITHUB },
+ { href: '#', label: 'Continue with Google', mark: MARK_GOOGLE }
+ ]
+ })
+ }
+];
+
+/** Polls for a restart of this process and reloads when it sees one. */
+const RELOAD = ``;
+
+async function page(state: State): Promise {
+ const res = renderer.render(await state.screen(), req);
+ const html = (await res.text()).replace('
+Auth preview
+
+
+
+saves reload every frame · click a title to open full size
+${tiles}
+${RELOAD}
', `${RELOAD}`);
+ // Always 200, so a 400 screen is not mistaken for a broken preview.
+ return new Response(html, {
+ headers: { 'Content-Type': 'text/html; charset=utf-8' }
+ });
+}
+
+function gallery(): Response {
+ const groups = [...new Set(STATES.map((s) => s.group))];
+ const tiles = groups
+ .map(
+ (group) =>
+ `