diff --git a/docs/superpowers/specs/2026-04-18-polish-design.md b/docs/superpowers/specs/2026-04-18-polish-design.md new file mode 100644 index 0000000..40156ca --- /dev/null +++ b/docs/superpowers/specs/2026-04-18-polish-design.md @@ -0,0 +1,308 @@ +# M9 Polish — Design Spec + +> Audience: implementation sub-agents and future maintainers. Covers the three deliverables for the final milestone: light-theme toggle, production container + Caddy, and E2E test fixes. + +## Scope + +| Deliverable | In scope | Out of scope | +|---|---|---| +| Light theme + toggle | DaisyUI light theme, SSR cookie, navbar button | Per-user persistence on backend, animate transitions | +| Container + Caddy | `Dockerfile` (multi-stage), `Caddyfile` (reverse proxy) | TLS cert management, docker-compose, CI/CD pipeline | +| E2E test fixes | Fix 6 failing tests to match existing code | Adding missing page features (permission label, admin-create-agenda), Storybook, Lighthouse | + +--- + +## Part 1 — Light theme + toggle + +### Architecture + +Theme preference is stored in a `theme` cookie. The server reads it in `hooks.server.ts` and rewrites the `data-theme` attribute on `` before the page HTML is sent to the browser. No client-side JavaScript, no FOUC. + +``` +Request → hooks.server.ts + 1. read theme cookie (default 'dark') + 2. resolve(event, { transformPageChunk({ html }) { + return html.replace('data-theme="dark"', `data-theme="${theme}"`) + }}) + → HTML with correct data-theme arrives at browser on first byte +``` + +The toggle is a plain `
+``` + +Place it between the brand link area and the avatar dropdown in `navbar-end`. + +### Light theme palette + +Added as a second `@plugin 'daisyui/theme'` block in `layout.css`. Hue family matches the existing dark theme (hue ~252–253) for brand continuity. + +```css +@plugin 'daisyui/theme' { + name: 'light'; + color-scheme: 'light'; + --color-base-100: oklch(97% 0.008 252); + --color-base-200: oklch(93% 0.012 253); + --color-base-300: oklch(88% 0.015 253); + --color-base-content: oklch(14% 0.020 252); + --color-primary: oklch(0.5502 0.1193 263.8209); + --color-primary-content: oklch(0.9816 0.0017 247.839); + --color-secondary: oklch(0.7499 0.0898 239.3977); + --color-secondary-content: oklch(0.2621 0.0095 248.1897); + --color-accent: oklch(0.9417 0.0052 247.879); + --color-accent-content: oklch(0.2621 0.0095 248.1897); + --color-neutral: oklch(88% 0.010 264); + --color-neutral-content: oklch(20% 0.020 264); + --color-info: oklch(74% 0.16 232.661); + --color-info-content: oklch(29% 0.066 243.157); + --color-success: oklch(76% 0.177 163.223); + --color-success-content: oklch(37% 0.077 168.94); + --color-warning: oklch(82% 0.189 84.429); + --color-warning-content: oklch(41% 0.112 45.904); + --color-error: oklch(71% 0.194 13.428); + --color-error-content: oklch(27% 0.105 12.094); + --radius-selector: 1rem; + --radius-field: 0.25rem; + --radius-box: 0.5rem; + --size-selector: 0.25rem; + --size-field: 0.25rem; + --border: 1px; + --depth: 0; + --noise: 0; +} +``` + +### `hooks.server.ts` change + +```ts +export const handle: Handle = async ({ event, resolve }) => { + // ... existing session bootstrap unchanged ... + + const theme = (event.cookies.get('theme') as 'dark' | 'light') ?? 'dark'; + return resolve(event, { + transformPageChunk({ html }) { + return html.replace('data-theme="dark"', `data-theme="${theme}"`); + } + }); +}; +``` + +### E2E test coverage + +Add a test in a new `tests/e2e/theme.spec.ts` that: +1. Starts unauthenticated, verifies default `data-theme="dark"` on ``. +2. POSTs to `/app/theme` with `next_theme=light`, follows redirect, verifies `data-theme="light"`. +3. Verifies the cookie is set with the correct value. + +--- + +## Part 2 — Container + Caddy + +### `Dockerfile` + +Multi-stage build. The builder stage installs all dependencies and runs `pnpm build`. The runtime stage copies only the built output + `package.json` (needed for `node build` entry point resolution). + +```dockerfile +FROM node:22-alpine AS builder +RUN corepack enable && corepack prepare pnpm@latest --activate +WORKDIR /srv +COPY pnpm-lock.yaml pnpm-workspace.yaml package.json ./ +RUN pnpm install --frozen-lockfile +COPY . . +RUN pnpm build + +FROM node:22-alpine AS runtime +WORKDIR /srv +ENV NODE_ENV=production +ENV PORT=3000 +ENV HOST=0.0.0.0 +COPY --from=builder /srv/build ./build +COPY --from=builder /srv/package.json ./ +EXPOSE 3000 +CMD ["node", "build"] +``` + +`HOST=0.0.0.0` is required — adapter-node defaults to `localhost` which only listens on loopback inside a container. Static assets are included in `build/client/` by adapter-node and served by the Node process. + +### `Caddyfile` + +Minimal production-ready reverse proxy. TLS termination is handled externally (load balancer or a wrapping Caddy config with a real domain). This file is the inner config. + +``` +:80 { + encode gzip zstd + reverse_proxy localhost:3000 +} +``` + +Caddy provides compression. All routing — including the `/app/` base path and static assets — is handled by the Node server. No static file serving in Caddy needed. + +### `.dockerignore` + +Create `Dockerfile` alongside a `.dockerignore` to keep build context lean: + +``` +node_modules +build +.svelte-kit +test-results +*.md +.env* +``` + +--- + +## Part 3 — E2E test fixes + +All fixes follow **option b**: tests are updated to match what the code actually does. No new page features are added. + +### Fix inventory + +#### `tests/e2e/profile.spec.ts:4` — own profile renders in view mode + +**Root cause:** test asserts `普通用户` text; `ProfileCard` does not render the permission level label. + +**Fix:** Remove the `普通用户` assertion. Replace with an assertion on `loggedInUser.username`, which ProfileCard does render in the `