116 lines
20 KiB
Markdown
116 lines
20 KiB
Markdown
# cms-client-svelte — Rewrite Overview
|
||
|
||
> Synthesis of the rewrite's milestone plan, cross-cutting architecture, and conventions. Primary audience: future agentic sessions spawning specs / plans for milestones 2–7. Per-milestone specs and plans live alongside this file in `specs/` and `plans/`; this overview is the map.
|
||
|
||
## Status
|
||
|
||
| # | Milestone | Status | Spec | Plan | Branch |
|
||
| --- | ------------------- | ---------- | ------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------- | ------------ |
|
||
| 1 | Foundation | ✅ shipped | [2026-04-14-foundation-design.md](specs/2026-04-14-foundation-design.md) | [2026-04-14-foundation.md](plans/2026-04-14-foundation.md) | `foundation` |
|
||
| 1.5 | Test infrastructure | ✅ shipped | [2026-04-14-test-infrastructure-design.md](specs/2026-04-14-test-infrastructure-design.md) | [2026-04-14-test-infrastructure.md](plans/2026-04-14-test-infrastructure.md) | `foundation` |
|
||
| 2 | Profile | ✅ shipped | [2026-04-14-profile-design.md](specs/2026-04-14-profile-design.md) | [2026-04-14-profile.md](plans/2026-04-14-profile.md) | `foundation` |
|
||
| 3 | Events (user) | ✅ shipped | [2026-04-14-events-design.md](specs/2026-04-14-events-design.md) | [2026-04-14-events.md](plans/2026-04-14-events.md) | `main` |
|
||
| 4 | Admin events | ✅ shipped | [2026-04-15-admin-events-design.md](specs/2026-04-15-admin-events-design.md) | [2026-04-15-admin-events.md](plans/2026-04-15-admin-events.md) | `main` |
|
||
| 5 | Admin users & stats | ✅ shipped | [2026-04-15-admin-users-stats-design.md](specs/2026-04-15-admin-users-stats-design.md) | [2026-04-15-admin-users-stats.md](plans/2026-04-15-admin-users-stats.md) | `main` |
|
||
| 6 | Check-in / QR | ✅ shipped | [2026-04-16-checkin-qr-design.md](specs/2026-04-16-checkin-qr-design.md) | [2026-04-16-checkin-qr.md](plans/2026-04-16-checkin-qr.md) | `main` |
|
||
| 7 | User agenda submit | ✅ shipped | [2026-04-16-user-agenda-design.md](specs/2026-04-16-user-agenda-design.md) | [2026-04-16-user-agenda.md](plans/2026-04-16-user-agenda.md) | `main` |
|
||
| 8 | Workbench | ✅ shipped | [2026-04-16-workbench-design.md](specs/2026-04-16-workbench-design.md) | [2026-04-16-workbench.md](plans/2026-04-16-workbench.md) | `main` |
|
||
| 9 | Polish | ✅ shipped | [2026-04-18-polish-design.md](specs/2026-04-18-polish-design.md) | [2026-04-18-polish.md](plans/2026-04-18-polish.md) | `develop` |
|
||
|
||
Each milestone: **brainstorm → spec (`specs/YYYY-MM-DD-<topic>-design.md`) → plan (`plans/YYYY-MM-DD-<topic>.md`) → subagent-driven execution → merge**. See "Milestone workflow" at the bottom.
|
||
|
||
## Milestone roadmap
|
||
|
||
**M1 — Foundation (shipped).** SvelteKit 2 + Svelte 5 (runes) app skeleton under base path `/app/`. Magic-link OAuth auth with httpOnly cookies and reactive 401 refresh. DaisyUI 5 drawer shell (icon-only collapsible sidebar, avatar dropdown with logout). Generated fetch SDK (hey-api) at `src/lib/api/`. Unified SDK error handling via `option-t`. Vitest (session helpers) + Playwright (auth smoke) rails. Out of scope: feature routes, Storybook, container/Caddy updates.
|
||
|
||
**M1.5 — Test infrastructure (shipped).** Programmable mock backend (Hono on `:4010`) with override-only semantics — no defaults, no state, no backend semantics. Vite proxy switches to the mock when `NODE_ENV=test` and strips `/app/api/v1` so overrides target operation paths. Test-side typed helpers (`mock.override`, `mock.requests`, `mock.clear`); custom Playwright `test` fixture auto-clears mock state and exposes a cookie-injection `loggedInUser` fixture. M1's auth smoke migrated; full magic-link pipeline verified end-to-end against the mock. Out of scope: backend-like state in the mock, openapi-driven defaults (Prism evaluated and rejected), per-worker mock isolation.
|
||
|
||
**M2 — Profile (shipped).** `/profile/[userId]` is the canonical URL for every profile (own + other); `/profile` is a bare convenience redirect to `/profile/<own_user_id>`. View shows avatar + permission badge + meta rows (email/username/public-flag) + copy-profile-link button; edit (self only) mutates username/nickname/subtitle/avatar/allow_public via a superforms-bound form. Introduces the three-face type system (Fraunces display, IBM Plex Sans body, IBM Plex Mono identifiers) with Noto SC fallbacks; M1 auth pages + navbar brand retrofitted. Out of scope (still): bio editing (→ M8), admin cross-edit (never), avatar upload.
|
||
|
||
**M3 — Events (user-facing) (shipped).** `/events` list with tab filter (all / joined), `/events/[eventId]` detail with hero, markdown description, attendance guide, and sticky sidebar. Join flow branches on `enable_kyc`: simple confirm dialog (`JoinDialog`) or five-stage KYC dialog (`KycDialog`, `createKycState` runes factory with `$effect` polling loop). `EventCard` three-zone layout with per-card KYC isolation. `POST /kyc-status` server proxy for token-safe KYC polling. Server actions `?/join` and `?/kycSession` with `Buffer`-based base64 codec. 9 E2E tests. Out of scope: `/joined-events` route, agenda, check-in QR, pagination, admin CRUD.
|
||
|
||
**M4 — Admin events.** `/admin/events` list + CRUD, `/admin/events/new`, `/admin/events/[eventId]` edit + agenda + attendance + stats subpages. Permission gate: `PARTY_EVENT_HOLDER` (Lv30) and up; `OFFICIAL_ADMIN` (Lv40) sees all events, others see their own. Markdown editor for event body (replace `@uiw/react-md-editor`; candidates: `bytemd`, `marked` + preview pane). DnD for agenda reordering (replace `@dnd-kit/*`; candidates: `svelte-dnd-action`). Out of scope: user/permissions admin (M5).
|
||
|
||
**M5 — Admin users & stats (shipped).** `/admin/users` list with sort, filter, pagination, and inline permission-level edit (gated by editor level — Lv40 assigns ≤Lv30, Lv50 assigns all); server-side validation of assignable levels. `/admin/stats` global dashboard with total-user count and event table. Route group `(admin-lv40)` guards both pages at Lv40+. `getAssignableLevels` helper in `$lib/permissions.ts`. 9 E2E tests covering list, inline edit, and permission-gate matrix. Out of scope: event-specific stats (M4), charts/visualizations (deferred).
|
||
|
||
**M6 — Check-in / QR (shipped).** `/checkin` staff scanner page (Lv20+, `(staff-lv20)` route group) with `@zxing/browser` camera viewfinder and 6-box `OtpInput.svelte` manual fallback; `?/submit` form action calls `POST /event/checkin/submit`. Attendee QR dialog on event detail page: fetches code via server proxy routes, renders QR with `qrcode` package, polls every 3 s, auto-closes on check-in. "扫码签到" sidebar entry for Lv20+. 7 E2E tests. Out of scope: attendance list (M4), per-event scanner scoping, scan history.
|
||
|
||
**M7 — User agenda submission (shipped).** Two surfaces added to `/events/[eventId]` with no new routes. (a) **Submission workflow:** "提交议程" button (visible only when joined, agenda not published, event not started) opens a superforms dialog with `name` (max 255) + `description` (required); submitted items appear in a "我的议程" sidebar card with status badges (待审核 / 已通过 / 已拒绝) and edit button for pending items. (b) **Published schedule:** once `is_agenda_published = true`, an "活动议程" timeline card renders in the main content column, visible to any user; descriptions rendered from base64 markdown via `marked`. Blocking rules (published, started, 5-pending) enforced both client-side (`canSubmit` derived) and server-side in `?/submitAgenda`. `?/editAgenda` skips re-checks; backend enforces ownership. Load extended with parallel `getEventGuide` + `getAgendaMyList` fan-out (joined-only) and sequential `getAgendaSchedule` (published-only). 12 E2E tests. Out of scope: admin review / approval / scheduling (M4 shipped), per-event backend quota changes.
|
||
|
||
**M8 — Workbench (工作台) (shipped).** Replace the Foundation placeholder at `/` with the full four-card dashboard. Also adds bio editing to the profile form (missed in M2). Data comes from two server `load` calls: `GET /user/info` (already available via `event.locals.user`) and `GET /event/list` (paginated, offset 0, same SDK as M3). Four cards: (a) **Welcome** — personalised greeting (`nickname ?? username`), joined-event count, quick links to `/events` and `/profile`; (b) **Current event** — finds the ongoing or nearest upcoming joined event, shows name/times/check-in status, "立即签到" shortcut for staff-eligible users; (c) **Upcoming schedule** — next 3 joined future events sorted by `start_time`, status badges (待开始 / 进行中 / 已签到), each links to `/events/[eventId]`; (d) **Profile completeness** — progress bar + checklist of 4 fields (nickname, subtitle, avatar, bio), CTA to `/profile`. All event derivation (ongoing detection, upcoming slice, completion %) happens in `+page.server.ts` `load`; no client-side fetch. E2E tests cover empty states and data-populated paths. NixOS snowflake watermark (bottom-right, low opacity) matching reference design. Out of scope: infinite-scroll pagination on the events feed, real-time check-in status updates.
|
||
|
||
**M9 — Polish (shipped).** Light/dark theme toggle via SSR cookie (`theme` cookie, `POST /app/theme` action, DaisyUI `data-theme`). Production Containerfile (Node 22 slim, multi-stage build) + Caddyfile (reverse-proxy to `:3000`, forward `/app/api/*` to backend, serve static assets). `.containerignore` added. All 78 unit tests and 77 E2E tests pass. Two residual E2E failures fixed during verification: auth test missing `GET /event/list` override, profile test strict-mode username ambiguity. Out of scope: performance audits, `API_BASE_URL` env redesign.
|
||
|
||
## Cross-cutting architecture (invariants)
|
||
|
||
Every feature milestone inherits these from M1 — **do not redesign them per-feature**:
|
||
|
||
- **SSR-first.** All data fetching in `+page.server.ts` `load` or form `actions`. No client-side `fetch` to the API. `+page.svelte` renders server-provided data.
|
||
- **`createApiClient(event)` from `$lib/server/api`** is the only way to talk to the backend. It injects `Authorization: Bearer <event.locals.accessToken>`, binds to `event.fetch`, installs the 401 refresh interceptor, and uses `${event.url.origin}/app/api/v1` as baseUrl.
|
||
- **Reactive refresh, one code path.** 401 interceptor in `createApiClient` calls `refreshSingleFlight` from `$lib/server/session`. Single-flight collapses concurrent refreshes per-process, keyed on the refresh-token string. No proactive JWT decoding, no duplicate refresh logic anywhere.
|
||
- **`hooks.server.ts` bootstraps the session.** Reads `access_token` cookie → calls `getUserInfo` through the API client (transparently refreshes if stale) → sets `event.locals.user` / `event.locals.accessToken`. Every request passes through it.
|
||
- **`(app)` route group is the authed space.** Its `+layout.server.ts` redirects anonymous users to `/app/authorize?redirect_to=…`. Every new feature route except `/authorize`, `/token`, `/magic-link-sent`, `/logout` lives under `(app)/`.
|
||
- **Cookies are httpOnly, `path=/app`, `sameSite=lax`, `secure`.** Always use `setSessionCookies` / `clearSessionCookies` / `setOAuthStateCookie` from `$lib/server/session`.
|
||
- **Unified SDK error handling via `option-t`.** Every hey-api call goes through `callSdk` (form actions, returns `Result<T, NormalizedError>`) or `loadSdk` (loads, throws SvelteKit `error()` on failure). Both from `$lib/server/errors`. Never hand-roll `try/catch` around an SDK call or read `.data` / `.error` directly.
|
||
- **Base path `/app/`.** Use `base` from `$app/paths` in templates; hardcoded `/app/...` is fine in server-side `redirect()`.
|
||
- **Generated SDK is untouchable.** `src/lib/api/**` is `pnpm gen`'s output; prettier + eslint ignore it. Never hand-edit. Regenerate with `pnpm gen` (requires VPN to `10.0.0.10:8000`).
|
||
- **Forms: `sveltekit-superforms` + Zod.** Schemas in `src/lib/schemas/`. Server: `superValidate(request, zod(schema))`. Client: `superForm(data.form, { dataType: 'form' })`.
|
||
- **E2E tests run against a programmable mock backend** (`scripts/mock-server.ts`, `tests/e2e/helpers/`). Vite proxy auto-flips when `NODE_ENV=test` and strips the `/app/api/v1` prefix so test overrides target operation paths directly. Tests never hit the real backend — see `CLAUDE.md` "Writing E2E tests" and `docs/superpowers/specs/2026-04-14-test-infrastructure-design.md`.
|
||
- **Three-face typography (Fraunces / IBM Plex Sans / IBM Plex Mono)** with Noto SC fallbacks. Utilities: `font-display` / `font-sans` / `font-mono`. Page titles + meta labels use `font-display` italic; identifiers (email, username, backend keys) use `font-mono`; body is `font-sans`. Introduced in M2; `/authorize` and `/magic-link-sent` retrofitted to match.
|
||
|
||
## Conventions
|
||
|
||
- **Imports:** `$lib/...` for `src/lib/`, `$lib/api` for the generated SDK, `$lib/server/...` for server-only modules. Nothing under `$lib/server/` may be imported from `.svelte` client code.
|
||
- **Commits:** conventional prefix (`feat:` / `fix:` / `chore:` / `docs:` / `refactor:` / `test:`). Include `Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>` trailer when Claude authored the change.
|
||
- **Type safety:** no `any` without a cast-comment explaining why. Prefer `unknown` + narrowing. `pnpm check` must pass before every commit.
|
||
- **Svelte 5 runes mode** is enforced project-wide (`svelte.config.js`). Use `$state`, `$derived`, `$props`, `$effect`. No Svelte 4 `export let` / `$:`.
|
||
- **DaisyUI 5 idioms used across milestones:**
|
||
- Layouts: `hero bg-base-200 min-h-svh` + `hero-content` for full-screen centered (auth pages, error).
|
||
- Cards: `card card-border bg-base-100 shadow-xl` + `card-body` + `card-title` + `card-actions`.
|
||
- Forms: `fieldset fieldset` > `legend fieldset-legend` + composite `<label class="input">` (icon child + `<input class="grow">`); conditional `input-error` class; `fieldset-label text-error` for hints.
|
||
- Alerts: `alert alert-{info,error,success} alert-soft`.
|
||
- Buttons: `btn btn-primary btn-block` for submit; `btn btn-ghost btn-circle` for icon buttons; inline `span.loading.loading-spinner loading-sm` during submission.
|
||
- Drawer shell: `drawer lg:drawer-open` with `is-drawer-close:` / `is-drawer-open:` variants for icon-only collapse; `is-drawer-close:tooltip is-drawer-close:tooltip-right` for collapsed-state labels.
|
||
- Navbar: `navbar-start` / `navbar-end` regions; `dropdown dropdown-end` + `avatar avatar-placeholder` for user menu.
|
||
- **Icons:** `@lucide/svelte`. Dynamic render: `<item.icon class="size-4" />`.
|
||
- **Interactive primitives:** `bits-ui` for dropdowns, dialogs, selects, tabs, tooltips, etc. — DaisyUI CSS classes layered on top.
|
||
- **Theme:** supports both `dark` and `light` DaisyUI themes since M9. `data-theme` on `<html>` is SSR-set from the `theme` cookie (default `dark`). Toggle button in the navbar posts to `POST /app/theme` action; palette defined in `src/routes/layout.css`.
|
||
- **Internationalization:** UI copy is zh-CN (matching the React project). Keep error messages short and native-feeling; avoid mixing English unless it's a proper noun.
|
||
|
||
## Attention points (gotchas surfaced during M1)
|
||
|
||
- **Hyphenated Zod keys break superforms types.** `'cf-turnstile-response'` required `as unknown as ZodObjectType` cast. Prefer camelCase keys (`turnstileToken`) and rename at the FormData layer if necessary; rename the existing cast-case in a future pass.
|
||
- **Playwright webServer must be `pnpm dev`, not `pnpm build && preview`.** `dev === true` activates the Turnstile hidden-input bypass in `/authorize`. Production mode loads Cloudflare's script and breaks e2e.
|
||
- **`createClient` is re-exported from `$lib/api/client`, NOT `$lib/api/client.gen`.** The `.gen.ts` file only exports the singleton.
|
||
- **`getUserInfo`, `postAuthMagic`, etc. are standalone functions** — hey-api does not attach them to the client instance. Call as `getUserInfo({ client: api })`.
|
||
- **Node `fetch` rejects relative URLs.** `event.fetch` wraps that for same-origin relatives, but hey-api internals pass URLs to the underlying fetch in a way that loses origin-resolution. `baseUrl` must be absolute: `${event.url.origin}/app/api/v1`.
|
||
- **Parallel SDK calls in one `load` depend on single-flight refresh.** If you fan out `Promise.all([api.a(), api.b()])` and the access token is stale, both 401 at once; single-flight collapses them into one `/auth/refresh` call. Without it, the second call would race against a rotated refresh token. The backend's grace-use window is an integration assumption (below); single-flight is the defense-in-depth.
|
||
- **Svelte 5 diagnostic lag.** IDE often reports stale "Cannot find module './$types'" or "Binding element implicitly has any type" after `svelte-kit sync` runs. `pnpm check` is authoritative — if it passes, ignore the editor squiggles.
|
||
- **Short access token lifetime (~15s)** means most requests trigger a refresh. Don't build UI that expects <5ms server roundtrips; backend is on internal network but refresh is still an extra hop.
|
||
- **Turnstile dev bypass** uses the literal string `turnstile_token` as a hidden input when `import { dev } from '$app/environment'` is `true`. Backend must accept this in dev mode. Do not regress this for production.
|
||
|
||
## Backend integration assumptions
|
||
|
||
- Backend at `http://10.0.0.10:8000`, reachable only via internal network / VPN. Vite dev proxy mounts it at `/app/api/*`; Caddy does the same in prod.
|
||
- Access token is a JWT issued by `POST /auth/token` with `exp` claim (~15s).
|
||
- Refresh-token rotation **tolerates one grace use** of the previous refresh token to cover multi-tab / parallel-request races (single-flight within a process handles the common case).
|
||
- Response envelope: `{ status: UtilsRespStatus, data: T }` — hey-api unwraps `.data` automatically.
|
||
- Error envelope: `{ status: number, msg: string, data?: unknown }`. `normalizeSdkError` extracts `msg`.
|
||
|
||
## Milestone workflow
|
||
|
||
For each new milestone:
|
||
|
||
1. **Brainstorm** via `superpowers:brainstorming` skill. Produce a design spec at `docs/superpowers/specs/YYYY-MM-DD-<topic>-design.md`. Use the Foundation spec as the template for structure, not content — re-derive the feature details for the new milestone.
|
||
2. **Get user approval on the spec** before writing the plan.
|
||
3. **Plan** via `superpowers:writing-plans` skill. Produce an implementation plan at `docs/superpowers/plans/YYYY-MM-DD-<topic>.md`. Include: task list with bite-sized steps, TDD where applicable, exact code for each step, verification commands, commit messages. Context7 for any new library docs.
|
||
4. **Execute** via `superpowers:subagent-driven-development` skill (recommended) or `superpowers:executing-plans`. One implementer subagent per task; spec reviewer + code-quality reviewer between tasks.
|
||
5. **Update this overview** after each milestone ships: flip the status row, link the spec and plan, and add any new attention points that emerged.
|
||
6. **Branch strategy:** feature milestones on a branch named after the milestone (e.g., `profile`, `events`, `admin-events`). Merge to `main` after the milestone verifies.
|
||
|
||
Canonical references when in doubt:
|
||
|
||
- `../CLAUDE.md` — repo-level conventions (loaded into every Claude session).
|
||
- `~/.claude/CLAUDE.md` — user's milestone discipline.
|
||
- `specs/2026-04-14-foundation-design.md` + `plans/2026-04-14-foundation.md` — template shape for new milestones.
|