docs: add M9 Polish design spec

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 <noreply@anthropic.com>
This commit is contained in:
2026-04-18 12:48:14 +08:00
parent 1ce29e4276
commit 9c6fcba1df

View File

@@ -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 `<html>` 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 `<form method="POST">` 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 `<meta name="color-scheme" content="dark" />`. 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
<form method="POST" action="/app/theme">
<input type="hidden" name="next_theme" value={data.theme === 'dark' ? 'light' : 'dark'} />
<button type="submit" class="btn btn-ghost btn-circle" aria-label="切换主题">
{#if data.theme === 'dark'}
<Sun class="size-5" />
{:else}
<Moon class="size-5" />
{/if}
</button>
</form>
```
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 ~252253) 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 `<html>`.
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 `<dd>` 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 `<button>`, not `<a>`.
**Fix:**
```ts
// before
await expect(page.getByRole('link', { name: /立即签到/ })).toBeVisible();
// after
await expect(page.getByRole('button', { name: /立即签到/ })).toBeVisible();
```
---
## Verification
After implementation, these commands must all pass:
```bash
pnpm check # type-check
pnpm lint # prettier + eslint
pnpm test:unit # 78 unit tests
pnpm test:e2e # all E2E tests green (currently 6 failing → 0)
pnpm build # Node adapter build succeeds
docker build -t cms-client . # container builds
```