From 9c6fcba1df203f0177d32e65ddbf60883cfbf672 Mon Sep 17 00:00:00 2001 From: Noa Virellia Date: Sat, 18 Apr 2026 12:48:14 +0800 Subject: [PATCH] docs: add M9 Polish design spec MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Covers three deliverables: light theme + SSR cookie toggle (form-action approach, transformPageChunk in hooks.server.ts), multi-stage Dockerfile + Caddyfile for adapter-node production deploy, and fixes for 6 failing E2E tests (option b: tests corrected to match existing code — wrong mock shapes, wrong Playwright role selectors, and tests for features that were never built). Co-Authored-By: Claude Sonnet 4.6 --- .../specs/2026-04-18-polish-design.md | 308 ++++++++++++++++++ 1 file changed, 308 insertions(+) create mode 100644 docs/superpowers/specs/2026-04-18-polish-design.md 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 `
` pointing at a dedicated endpoint. No `use:enhance`, no client store, no page reload side-effects beyond the intended navigation. + +### Files changed + +| File | Change | +|---|---| +| `src/app.html` | Fix `lang="en"` → `lang="zh-CN"` (polish). `data-theme="dark"` already present — used as replacement target by `transformPageChunk`. | +| `src/routes/layout.css` | Add `light` DaisyUI theme block (see palette below). | +| `src/hooks.server.ts` | After session bootstrap, read `theme` cookie and pass `transformPageChunk` option to `resolve()`. | +| `src/routes/+layout.server.ts` | Read `theme` cookie, include in returned data: `{ user: locals.user, theme }`. | +| `src/routes/+layout.svelte` | Remove hardcoded ``. DaisyUI theme CSS sets `color-scheme` per active theme — the meta tag is redundant and would be wrong for light mode. | +| `src/routes/theme/+server.ts` | New file. POST handler: read `next_theme` from body, set `theme` cookie, redirect to `Referer \|\| '/app/'`. | +| `src/routes/(app)/+layout.svelte` | Add sun/moon toggle button in `navbar-end` before avatar dropdown. Reads `data.theme`. | + +### Theme toggle endpoint + +`src/routes/theme/+server.ts`: +- Method: `POST` +- Body field: `next_theme` (`'dark' | 'light'`) +- Cookie: `name: 'theme'`, `path: '/app'`, `sameSite: 'lax'`, `secure` only in production (`!dev`), `maxAge: 60 * 60 * 24 * 365` +- Response: `303` redirect to `request.headers.get('referer') ?? '/app/'` + +### Toggle button (in `(app)/+layout.svelte`) + +```html + + + +
+``` + +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 `
` for 用户名. + +```ts +// before +await expect(page.getByText('普通用户')).toBeVisible(); + +// after +await expect(page.getByRole('main').getByText(loggedInUser.username)).toBeVisible(); +``` + +--- + +#### `tests/e2e/auth.spec.ts:12` — full magic-link flow + +**Root cause:** times out waiting for the user-menu button after the full dev-mode redirect chain. The `use:enhance` on the authorize form causes a client-side SvelteKit navigation to `/app/token?code=...`, which then redirects to `/app/`. Cookie propagation across this chain may not settle before Playwright proceeds. + +**Fix:** Add `await page.waitForLoadState('networkidle')` after the click and before the URL assertion to ensure the full redirect + render chain completes. If the cookie-propagation issue persists (i.e., `data.user` is still null after the chain and the page bounces to `/app/authorize`), scope the test down: remove the workbench assertions and instead assert only that the server redirected to `/app/magic-link-sent` when `dev === false`, or that the URL reaches `/app/` in dev mode without checking the navbar. + +Investigate first; minimal targeted fix preferred. + +--- + +#### `tests/e2e/admin-events.spec.ts:131` — agenda tab lists items + +**Root cause:** mock data uses `is_published: true/false`; the admin agenda page (`+page.svelte`) filters items by `status: 'pending' | 'approved' | 'rejected'`. Items without a `status` field never match any tab. + +**Fix:** Update mock items to include `status: 'pending'` so they appear in the default 待审核 tab. + +```ts +// before +{ agenda_id: 'ag1', name: '开幕式', is_published: true }, +{ agenda_id: 'ag2', name: '主题演讲', is_published: false } + +// after +{ agenda_id: 'ag1', name: '开幕式', status: 'pending', description: '' }, +{ agenda_id: 'ag2', name: '主题演讲', status: 'pending', description: '' } +``` + +--- + +#### `tests/e2e/admin-events.spec.ts:151` — agenda create submits form + +**Root cause:** the admin agenda page has no `新增` button. It only supports review (approve/reject) and edit of user-submitted items. + +**Fix:** Replace the test with one that exercises existing admin UI. A good replacement: verify that clicking 通过 on a pending item opens the approve dialog. + +```ts +test('approve button opens approve dialog', async ({ page, superAdminUser }) => { + void superAdminUser; + await overrideEventInfo(); + await overrideEventGuide(); + await mock.override('GET', '/agenda/list', { + status: 200, + body: { + status: 200, + data: [{ agenda_id: 'ag1', name: '开幕式', status: 'pending', description: '' }] + } + }); + await page.goto('/app/admin/events/adm1/agenda'); + await page.waitForLoadState('networkidle'); + await page.getByRole('button', { name: '通过' }).click(); + await expect(page.getByRole('heading', { name: '审核通过' })).toBeVisible(); +}); +``` + +--- + +#### `tests/e2e/admin-events.spec.ts:183` — attendance tab shows table rows + +**Root cause:** mock returns `data: [array]` but `+page.server.ts` casts the response to `{ data: { items: [...] } }` — the page reads `inner?.items ?? []`, which is `undefined` when `data` is an array. + +**Fix:** Update mock response to match the actual backend shape: + +```ts +// before +body: { status: 200, data: [ { attendance_id: 'att1', ... }, ... ] } + +// after +body: { status: 200, data: { items: [ { attendance_id: 'att1', ... }, ... ] } } +``` + +--- + +#### `tests/e2e/workbench.spec.ts:110` — attendee sees 立即签到 button + +**Root cause:** test uses `getByRole('link', { name: /立即签到/ })`; the element is a `bits-ui` `Dialog.Trigger` which renders as `