# Web Frontend QA Report

## 1. Executive Summary

**Overall health: Solid at the contract/logic layer, blocked at the browser layer.**

- **Areas tested:** 15 (12 functional+API areas, 2 full-LLM E2E areas, 1 cross-route browser smoke pass).
- **Backend contract + component logic:** Largely sound. Happy paths, validation (422), error envelopes (404/409), snake_case alignment, optimistic-update/rollback, and a11y structure mostly check out across all areas.
- **Full LLM E2E that genuinely ran:** write-sse (real Kimi draft stream, 1261 tokens), toolbox-generators (all 12 generators + 3 ingest flows), style (16-dim fingerprint + refine), chain (API + awaiting/resume seam verified against real jobs).
- **What could NOT be verified (environmental):** The running Next dev server had a corrupted `.next` build cache — every `/projects/[id]/*` SSR route returned HTTP 500 ("Cannot find module openapi-fetch@0.13.8.js"), and client JS chunks 404'd so React never hydrated. This blocked ALL in-browser/visual/keyboard/a11y verification. Root cause: stale build + two concurrent `next dev` processes; not a source defect. Fix: kill duplicate server, `rm -rf apps/web/.next`, restart.

**Issue counts (de-duplicated): 23 total**
- **CRITICAL: 2** (1 functional, 1 build-cache infra)
- **HIGH: 4** (1 functional, 3 infra — same root cause)
- **MEDIUM: 6**
- **LOW: 11**

The two functional CRITICAL/HIGH items are real and independent of the build issue. The build-cache 500 is a single environmental root cause counted once as CRITICAL (smoke pass) and re-observed as HIGH across multiple areas.

## 2. All Recorded Errors (de-duplicated, CRITICAL → LOW)

| Severity | Area / Component | Symptom | Repro |
|---|---|---|---|
| CRITICAL | write-sse — `routers/projects.py::stream_draft` | POST /draft for a NON-EXISTENT project returns 200 + a full LLM-generated chapter instead of 404 — burns a paid LLM call; sibling endpoints correctly 404 | `curl -X POST /projects/0000…0000/chapters/7/draft -H 'Accept: text/event-stream'` → 200 + token/done |
| CRITICAL | Build cache — `apps/web/.next` (all 10 project routes + hydration) | Corrupt `.next`: server bundles require missing `vendor-chunks/openapi-fetch@0.13.8.js` → all `/projects/[id]/*` routes 500; client JS chunks 404 → React never hydrates → entire app non-interactive | `curl localhost:3000/projects/{PID}/write` → 500; type valid title in wizard → 下一步 stays disabled |
| HIGH | ProjectWizard.tsx (StepPremise + StepProtagonist) | Step 3 (立意) and Step 4 (主角/金手指) bind to the SAME `form.premise` field — step-4 input silently overwrites step-3; both cannot be saved | /projects/new → type 立意 (step3) → type 主角 (step4) → submit → only step-4 text persists |
| HIGH | Build-cache 500 (re-observed in foreshadow / codex / skills / review / nav / chain) | Same missing-openapi-fetch vendor chunk 500s every `/projects/[id]/*` route, blocking browser QA | `curl localhost:3000/projects/{PID}/{route}` → 500 |
| HIGH | GET /skills backend registry | Skills registry empty (`{"skills":[]}`); loads only from DB `skills` table with no seed rows and no POST/seed endpoint → SkillsPage permanently shows "暂无已注册技能" | `curl localhost:8000/skills` → `{"skills":[]}` |
| HIGH | env — duplicate `next dev` processes | Two concurrent `next dev` on same `apps/web` clobber shared `.next` → likely root cause of the corrupt build | `ps aux \| grep 'next dev'` → two PIDs |
| HIGH | rules — `routers/rules.py` + `rule_repo.py` | POST rule to non-existent project returns 500 (unhandled FK violation) instead of 404 | `curl -X POST /projects/{random-uuid}/rules -d '{"level":"project","content":"x"}'` → 500 |
| HIGH | ReviewReport.tsx + style.md prompt + review_node | One-click 回炉 can target wrong/empty paragraph: style-auditor LLM segment idx and frontend `split(/\n{2,}/)` paragraph index use unrelated splitting schemes (no shared contract) | Draft with single-newline segments → style segments idx>0 → 回炉 refines wrong/empty para |
| MEDIUM | OutlineEditor.tsx + server.ts fetchOutline | 卷号 volume selector is misleading: GET ignores volume filter, editor always renders ALL volumes; picker only affects POST generate | Change 卷号 to 2 → all volumes still listed |
| MEDIUM | useOutline.ts:44-49 | 422 validation errors read `apiError.error.code`, but 422 uses `{detail:[...]}` → degrades to generic "OUTLINE_FAILED" message | POST /outline volume=0 → generic error, validation cause lost |
| MEDIUM | settings PUT /settings/providers + ProvidersSettings | PUT accepts arbitrary/unknown tier+provider (no enum/whitelist), upsert-only with no DELETE → bogus tier_routing row persists invisibly, pollutes gateway config | `PUT {tier_routing:[{tier:'bogus',…}]}` → 200, persists, unremovable via API |
| MEDIUM | backend char persist/read + CodexPage.tsx | Character relations silently dropped on persist/read (GET returns `relations:[]` despite ingest); codex list never renders relations anyway | POST character with relations → GET → `relations:[]` |
| MEDIUM | backend POST /projects/{id}/characters | Character ingest non-idempotent: re-ingesting same name creates duplicate row → duplicate chips (UI dedup only session-vs-persisted) | POST same card twice → GET count 2 |
| MEDIUM | foreshadow PATCH to_status (backend) | Invalid `to_status` value returns 500 INTERNAL instead of 422 VALIDATION; `to_status` is a free string, not enum-validated | `PATCH /foreshadow/FS-01 -d '{"to_status":"BOGUS"}'` → 500 |
| MEDIUM | ChainPage.tsx:61 + ChainAdjudication.tsx:102 | Chain run/resume failures call `friendlyError(undefined)` — discard error code/message; 503 LLM_UNAVAILABLE loses its "go to settings" action link | Trigger run with no provider → generic "出错了" toast, no settings link |
| MEDIUM | generation.py list_rules (GET rules) | GET rules for non-existent project returns 200 `{"rules":[]}` instead of 404, masking bad project ids | `curl /projects/{random-uuid}/rules` → 200 empty |
| LOW | rules / templates / foreshadow / projects / codex (CRUD gaps) | No DELETE/edit endpoint for rules; no DELETE /projects; whitespace-only content accepted server-side (min_length before trim) on rules/projects/foreshadow → unremovable junk rows | `curl -X POST .../rules -d '{"content":"   "}'` → 201 |
| LOW | useDraftStream.ts (pre-stream error) | Pre-stream error envelope: extracts code+message but drops `error.request_id` → weakens end-to-end greppability | Force 503 before stream → surfaced error has no request_id |
| LOW | FastAPI 422 envelope inconsistency (write-sse, outline, accept, injection) | 422 errors return raw FastAPI `{detail:[...]}` instead of project `{error:{code,message,request_id}}` envelope → no request_id, no code mapping | `curl /projects/{PID}/chapters/abc/injection` → 422 `{detail:…}` |
| LOW | apps/api accept endpoint (useAccept.ts) | Accept 422 (empty final_text) uses raw FastAPI detail shape, not standard envelope (subset of above, accept-specific) | POST accept `{final_text:'',decisions:[]}` → 422 raw detail |
| LOW | ProvidersSettings.tsx testConnection | Test-connection collapses distinct 404 "needs key" vs 503 "probe failed" into one generic toast | Click test on unconfigured vs unreachable provider → identical toast |
| LOW | ProjectWizard.tsx submit + cards.ts + useGenerator | Create/generate failures show generic toast; backend validation detail / specific message discarded | Force a 422 → generic toast, backend reason lost |
| LOW | CommandPalette.tsx | Incomplete combobox a11y: input not `role=combobox`, no `aria-activedescendant`; `<li role=option>` has no `id` → AT won't announce highlighted command | Open ⌘K, ArrowDown with screen reader → no announcement |
| LOW | Toast.tsx | Error toasts use polite live region (`aria-live=polite`/`role=status`) instead of assertive → failures don't interrupt SR | `show('…','error')` → announced politely |
| LOW | RulesPage.tsx | Rule content textarea has no accessible label (placeholder only); list uses array index as React key (forced by GET not returning rule id) | Inspect 新增规则 form |
| LOW | TemplateFiller.tsx:23-40 | After transient GET /templates failure, shows misleading "暂无模板" empty state and never retries on reopen (useEffect guards on `templates===null`, but failure sets `[]`) | Open filler while API down → toast + empty; restore API, reopen → still empty |
| LOW | style refine_segment (routers/style.py) | Refine endpoint never validates chapter existence; any chapter_no returns 200 | `POST .../chapters/99/refine -d '{"segment":"x"}'` → 200 |
| LOW | TemplatesManager + backend | No max-length cap on title/body (5000-char title accepted, rendered inline unbounded); backend silently ignores unknown request fields | `POST /templates` long title → 201 |
| LOW | RegisterForm.tsx (toInt) | Non-integer numeric input (e.g. 'abc', '1.5') silently dropped with no validation feedback | Type 'abc' in 埋设章, submit → planted_at omitted silently |
| LOW | toolbox empty ingest | Empty ingest payload (`world_entities:[]`) returns 201 no-op instead of 4xx (client-guarded so UI-unreachable) | `POST .../glossary/ingest -d '{"world_entities":[]}'` → 201 |

*Several minor chain/style frontend timing edges (poll progress frozen 0→100, possible stale done-edge re-fire, no re-refine on identical segment text, ChainAdjudication 409 stuck panel, redundant poll.reset effect re-run) are noted in raw data as LOW/needs-browser and folded here to avoid noise.*

## 3. Per-Area Status

| Area | Status | Coverage note |
|---|---|---|
| projects-list+create | issues-found (1 HIGH) | API fully exercised via curl; wizard click-through needs-browser |
| outline-editor | issues-found (2 MED) | API + logic verified; read-only generate-only (no PUT by design); ⚑badge path dead vs real data |
| rules | issues-found (1 HIGH, 3 MED) | Full API curl + component read; a11y/SR needs-browser |
| foreshadow-board | issues-found (1 HIGH infra, 1 MED) | API fully exercised; board UI blocked by build 500 |
| templates | pass (3 LOW only) | CRUD fully verified per contract; known blank-field HIGH confirmed fixed |
| settings-providers | issues-found (1 MED) | API + OAuth contracts verified; no plaintext leak; live OAuth completion not run |
| codex (设定库) | issues-found (2 MED) | API contract verified; in-browser blocked by build 500 |
| skills | issues-found (2 HIGH) | Logic correct but registry empty + route 500; populated-state untested |
| review-display | issues-found (1 HIGH) | API + all normalizers verified; visual panels blocked by build 500 |
| nav-shell+command | issues-found (1 MED, 1 LOW a11y) | All routes/commands map correctly; 28 unit tests pass; browser blocked |
| write-sse (LLM E2E) | issues-found (1 CRITICAL) | Real Kimi stream ran end-to-end; non-empty injection path untestable (no chars/entities) |
| toolbox-generators (LLM E2E) | pass (2 LOW only) | All 12 generators + 3 ingest flows ran real Kimi; 38 unit tests pass |
| style (LLM E2E) | issues-found (1 HIGH) | Full fingerprint+refine ran real Kimi; drift idx-alignment is contract analysis |
| chain (LLM E2E) | issues-found (1 MED) | API + awaiting/resume seam verified vs real jobs; 12 unit tests; browser blocked |
| browser smoke (all 14 routes) | issues-found (2 CRITICAL infra) | 4 top-level routes SSR 200; 10 project routes 500; zero hydration anywhere |

## 4. Notable Gaps / Could Not Be Verified

- **All in-browser / visual / keyboard / screen-reader verification** — blocked by the corrupt `.next` build (project routes 500, no hydration). Every a11y finding (combobox, toast severity, textarea label) is source-confirmed only; visual rendering of kanban, beat bars, annotated-text highlights, accept-gate disabled state, typewriter stream, and optimistic-update rendering is unverified.
- **Skills populated-state** — registry is empty with no seed/write endpoint, so builtin/custom/community grouping + tier/genre/reads-writes badges were never rendered with real data (logic verified by code + unit tests only).
- **Style one-click 回炉 idx-alignment (HIGH)** — could not be deterministically triggered: existing PID has no fingerprint, so the style auditor degrades to score=100/segments=[]. Finding is from code/contract analysis; needs a learned fingerprint + a write+review run to confirm live.
- **write-sse non-empty injection panel** — the test PID has zero characters and zero world_entities, so `selected[]`/author_pin reason badge path is structurally untestable on this data.
- **Live 503 / LLM_UNAVAILABLE paths** — providers are configured, so 503 branches (outline, chain, settings test, draft pre-stream) could not be forced; verified by code reading only.
- **Foreshadow ⚑ / 可回收 badge** — all 30 chapters of the existing PID have empty `foreshadow_windows`, so this render path is exercised only by unit tests.
- **409 CONFLICT_UNRESOLVED ingest path (toolbox)** — could not trigger a real continuity conflict on throwaway data; adjudication logic verified by reading only.
- **Cleanup residue:** No DELETE /projects endpoint exists (405), so ~8 throwaway QA projects created during mutation testing remain in the DB. One chain job (project 887af2d4) is still running. The existing rich PID (71b3725e…) data was never modified.