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..ad880443 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 --watch script/preview.ts",
"deploy": "wrangler deploy"
},
"dependencies": {
diff --git a/apps/auth/script/preview.ts b/apps/auth/script/preview.ts
new file mode 100644
index 00000000..07c4290a
--- /dev/null
+++ b/apps/auth/script/preview.ts
@@ -0,0 +1,264 @@
+/**
+ * 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.
+ *
+ * `bun --watch` restarts this process when anything it imports changes, which
+ * includes `css.ts`, `render.tsx` and `base.tsx`. 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
', `${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) => + `
${s.id}` + ) + .join(''); + + return new Response( + `
+