- README.md is now the product/repo front door (architecture, repo map, local dev, test tiers, delivery flow, doc map). Engine positioning, VedAstro/Codex setup and the oracle/benchmark command reference move verbatim to docs/engine/README.md, docs/engine/vedastro-gateway.md and docs/benchmark/README.md. Capability badges realigned with the registry (91/78/8/0); tests/test_readme_badges.py was red on staging. - AGENTS.md: Part A (environment truth, delivery, worktrees, record placement, bug workflow, growth freeze, frontend red lines, privacy, pre-work check, test tiers) and Part B (reading-rigor constraints). GitHub issue-tracker/triage boilerplate removed: GitHub is a read-only mirror. All strings locked by tests/ are preserved. - CLAUDE.md added: roles, three working modes, task-brief sections, acceptance criteria, session discipline; imports AGENTS.md. - 50 tracked TASK-*/PROGRESS-* files and 3 never-committed briefs move to docs/tasks/ with an index; REPO_LAYOUT.md merged into README. Docs-only change (no gated path touched). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0193vBv6w5MV2cifdTUu9H5P
292 lines
17 KiB
Markdown
292 lines
17 KiB
Markdown
# 任务书 · 前端优化九条的收尾(2026-08-29)
|
||
|
||
复核对象:`origin/staging` @ `3198fb6b`("perf(frontend): split chat streaming, load Inter, isolate admin CSS")。
|
||
|
||
上一轮交付基本属实,我逐条实测过:`tsc --noEmit` exit 0、`eslint` 0 error、`next build` exit 0、路由模式(`/` `○ Static`、`/login` `/admin/**` 仍 `ƒ`)、admin 路由确实不再引用含 `.message-list` 的 216 KB globals chunk、`public/` 只剩 logo、Inter 走 `next/font` 并生成独立 `@font-face` chunk、星盘库 effect 已合并成单 effect 三分支。**本轮不重做这些,只收三个尾巴。**
|
||
|
||
---
|
||
|
||
## 硬红线
|
||
|
||
1. **不得修改任何既有测试断言。** 本轮三条任务都不需要改断言 —— 如果你发现"必须改断言才能过",那是方案错了,停下来登记 `BLOCKED.md`,不要动断言。
|
||
2. **不得让 `error.tsx` / `not-found.tsx` 重新 import `globals.css` 或 `site-styles`。** 这两页与 admin 共享根布局段,一旦 import,admin 每条路由会重新背上约 33 KB gzip —— 上一轮 −32 KB 的成果会当场归零。`tests/site-style-isolation-contract.test.ts` 已锁这条,它必须保持绿灯且不被修改。
|
||
3. 不得手写 `useCallback` / `useMemo`。
|
||
4. 不得为迎合 React Compiler 改写代码;本轮不重开 React Compiler(理由见 `BLOCKED.md` 2026-08-17 记录)。
|
||
5. 推 staging 前必须 `./node_modules/.bin/tsc --noEmit` 通过(BUG-409 防复发)。**不要用 `npx tsc`**,本仓库环境下会装到空包 `tsc@2.0.4`。
|
||
6. 测试数不得低于基线 **2205**,且 `fail=0`、`skipped=0`(在有 Docker 的环境里跑)。
|
||
7. 不得改 `.gitea/workflows/**`。不得在有未提交改动的工作树上切分支。不得自行把 staging 提升到 main。
|
||
|
||
让步顺序:功能与测试不回归 > 可验证的修复 > 代码整洁。
|
||
|
||
## 开工前置
|
||
|
||
```bash
|
||
git fetch origin --prune
|
||
git worktree add -b codex/frontend-followup-20260829 \
|
||
../.worktrees/frontend-followup-20260829 origin/staging
|
||
```
|
||
|
||
基线必须是 `origin/staging`,不是任何本地 ref。读 `pre_work_error_ledger.md`,跑 `scripts/pre_work_check.py`,读 `frontend/AGENTS.md`(Next.js 版本与你训练数据不同,写代码前先看 `node_modules/next/dist/docs/`)。改前先在 `docs/BUG_HISTORY.md` 里检索是否有同类记录。
|
||
|
||
---
|
||
|
||
## 任务 A(P0)· 根 404 / 错误页现在完全没有样式
|
||
|
||
### 事实
|
||
|
||
`src/app/not-found.tsx` 和 `src/app/error.tsx` 的 markup 是纯 Tailwind 工具类,但上一轮把 globals 从根布局段摘掉后,这两页**一条 CSS 都不加载**。构建产物实证:
|
||
|
||
```
|
||
.next/server/app/_not-found.html
|
||
→ 只引用 3b67syn08d_z3.css(2,146 B,纯 Inter @font-face)
|
||
`min-h-svh` / `text-muted-foreground` 的定义只在 0grkt280n67e_.css(216 KB),该页不加载它
|
||
```
|
||
|
||
用户看到的是浏览器默认裸 HTML:不居中、无配色、无间距,`<Button>` 塌成一行光秃秃的链接文字。这是真实的用户可见回归,不是可接受的取舍。
|
||
|
||
`BLOCKED.md` 把唯一出路写成"必须另开一条不与 admin 共享的布局链"—— **这个判断是错的**,请一并订正该条记录。
|
||
|
||
### 做法
|
||
|
||
**照抄仓库里已有的同类模式**,不要发明新方案:`src/app/global-error.tsx` 和 `src/app/forbidden.tsx` 早就是内联 `style={{...}}` 写法,正是为了应付同一个约束。
|
||
|
||
具体:
|
||
|
||
- 模块级常量沿用 `global-error.tsx` 的形式:`const canvas = "var(--color-canvas, #fbfaf7)"` 等。**注意:这两页上 globals 不加载,CSS 变量取不到值,实际生效的是括号里的 fallback。** 这是预期行为,fallback 值必须和 `globals.css` 里的 token 真实值一致(`--color-canvas: #fbfaf7`、`--color-ink: #1d1d1f`、`--color-ink-secondary: #5f5f59`、`--color-border: #d8d6cf`、`--color-action: #85432f`、`--color-danger: #9a2f2f`),改前去 `globals.css` 核一遍,不要照抄我这里的数字。
|
||
- 移除 `import { Button } from "@/components/ui/button"` —— 它是 Tailwind 组件,在这两页上等于无样式。改用原生 `<button>`(error 页的"重试")和 `<Link>`("返回对话"),各自带内联 style。`next/link` 保留,别退化成 `<a href>`,会丢客户端导航。
|
||
- `error.tsx` 的 `TriangleAlert` 可以留,但把 `className="size-8 text-destructive"` 换成 lucide 的 props:`size={32}` + `color={danger}`,`aria-hidden="true"` 保留。
|
||
- `error.tsx` 保留 `"use client"` 和现有的 `useEffect(() => console.error(...))`,不要动错误上报逻辑。
|
||
|
||
### 无障碍合同(`frontend/DESIGN.md`,不得降级)
|
||
|
||
- 可点区域 `minHeight: 44px`、`minWidth: 88px`
|
||
- `error.tsx` 的错误块保留 `role="alert"`
|
||
- focus-visible 可见描边:沿用 `global-error.tsx` 的做法,页面内嵌一个作用域 `<style>` 写 `.xxx-action:focus-visible { outline: 3px solid ...; outline-offset: 2px; }`
|
||
- 文案一字不改
|
||
|
||
### 验收(必须逐条给出证据,不接受"看起来好了")
|
||
|
||
```bash
|
||
./node_modules/.bin/next build
|
||
|
||
# 1) 根 404 的 body 里不得再出现 Tailwind 工具类
|
||
python3 -c "
|
||
import re;h=open('.next/server/app/_not-found.html').read()
|
||
print(re.search(r'<body.*?</body>',h,re.S).group(0)[:1200])"
|
||
|
||
# 2) admin 仍不得引用 globals chunk(这条是 P0 的守门条件)
|
||
grep -rho '[a-z0-9_-]\{8,\}\.css' .next/server/app/admin/ | sort -u
|
||
# 期望:只有 antd/admin 那个 chunk + Inter chunk 两个,
|
||
# 绝不能出现含 .message-list 的那个 216 KB chunk
|
||
grep -l 'message-list' $(find .next/static -name '*.css')
|
||
|
||
# 3) 站点路由 CSS 不变
|
||
grep -o 'rel="stylesheet"[^>]*href="[^"]*"' .next/server/app/index.html
|
||
```
|
||
|
||
另外:`next start` 后人工看一眼 `/一个不存在的路径`,确认是居中的暖白页而不是裸 HTML,截图或描述写进 PROGRESS。
|
||
|
||
### 测试
|
||
|
||
`tests/site-style-isolation-contract.test.ts` 的既有断言**保持原样不动**(它锁的正是"这两页不许 import globals",我们的方案让它继续为真)。在同文件**新增**断言:这两页不含 Tailwind 工具类特征(例如不出现 `className="flex`、`min-h-svh`、`text-muted-foreground`),且不再 import `@/components/ui/button`。
|
||
|
||
### 建档
|
||
|
||
`docs/BUG_HISTORY.md` 追加一条(用户可见回归,必须建档)。**编号前先重新确认远端最大号**(当前本地最大 BUG-430),别抢号:
|
||
|
||
```bash
|
||
git fetch origin --prune && git show origin/staging:docs/BUG_HISTORY.md | grep -o '^## BUG-[0-9]*' | sort -t- -k2 -n | tail -3
|
||
```
|
||
|
||
防复发条写清楚:根布局段(`error.tsx` / `not-found.tsx` / `forbidden.tsx` / `global-error.tsx`)的页面只能用内联样式,不得依赖任何工具类或 `globals.css`。
|
||
|
||
### 止损
|
||
|
||
改动超过 250 行,或发现必须动布局链 / 移动 `app/page.tsx` 才能完成 —— 停下,把实际卡点写进 `BLOCKED.md`,不要硬推。
|
||
|
||
---
|
||
|
||
## 任务 B(P1)· 一条时区依赖的脆测试
|
||
|
||
### 事实
|
||
|
||
`frontend/tests/onboarding-presentation.test.ts:43` 用绝对时刻构造:
|
||
|
||
```ts
|
||
const noon = new Date("2026-07-19T12:00:00+08:00");
|
||
```
|
||
|
||
而 `src/lib/onboarding-client.ts:112` 读的是 `now.getHours()`(**运行机器的本地时区**)。在 UTC 环境下该时刻是 04:00,落进"夜深了"分支,断言 `中午好,周宁。` 必红。
|
||
|
||
实测:`TZ=Asia/Shanghai` → 3 pass / 0 fail;默认 UTC → 1 fail。跟本轮和上一轮改动都无关,是既有问题。
|
||
|
||
### 做法
|
||
|
||
改成本地时区构造 —— **同一个测试文件末尾已经在用这个正确写法**(`new Date(2026, 6, 19, hour)`),照它改:
|
||
|
||
```ts
|
||
const noon = new Date(2026, 6, 19, 12);
|
||
```
|
||
|
||
**断言的期望值一个字都不许改**(`中午好,周宁。` / `现在最想理清哪件事?` 等全部原样保留)。这不是改断言,是修夹具构造。
|
||
|
||
同文件第 27 行的 `new Date("2026-07-19T08:00:00+08:00")` 只被断言了名字、不涉及时段,可改可不改;若改,断言同样不许动。
|
||
|
||
顺手扫一遍是否还有同类:
|
||
|
||
```bash
|
||
grep -rn '+08:00' frontend/tests/ | grep -iv 'iso\|timestamp\|askedAt\|resolvedAt'
|
||
```
|
||
凡是"绝对时刻 + 断言时段文案"的组合都按同一方式修;只做时间戳比较的不要动。
|
||
|
||
### 验收
|
||
|
||
```bash
|
||
TZ=UTC ./node_modules/.bin/tsx --test tests/onboarding-presentation.test.ts
|
||
TZ=Asia/Shanghai ./node_modules/.bin/tsx --test tests/onboarding-presentation.test.ts
|
||
TZ=America/New_York ./node_modules/.bin/tsx --test tests/onboarding-presentation.test.ts
|
||
```
|
||
三个时区必须全绿。不建档 BUG_HISTORY(测试夹具问题,非产品缺陷),在 PROGRESS 里记一句即可。
|
||
|
||
---
|
||
|
||
## 任务 C(P2,可止损)· 绕开 readonly 的类型夹具
|
||
|
||
### 事实
|
||
|
||
上一轮为了让 `tsc` 变绿,在两个测试里把直接赋值改成了 `Object.assign` 绕 readonly:
|
||
|
||
```ts
|
||
// frontend/tests/rectification-answer-choice.test.ts
|
||
Object.assign(refreshed, { latestResult: { ... } });
|
||
Object.assign(refreshed.conversationSummary, { activeFocus: { ... } });
|
||
```
|
||
|
||
行为没变,但 `Object.assign` 会把真实的类型不匹配一起吞掉,将来这里改坏了 `tsc` 也不会报。
|
||
|
||
### 做法
|
||
|
||
按真实类型修:让夹具**一次性构造出正确形状的对象**,而不是先解析再改字段。例如把 `parseV9CaseDossier(...)` 的结果解构后重建一个新对象,或给局部变量一个显式的可变结构类型。
|
||
|
||
- 禁止 `as any` / `@ts-expect-error` / `@ts-ignore`
|
||
- 禁止改任何断言
|
||
- 禁止为此修改 `src/**` 的类型定义(那是产品类型,不该为测试让路)
|
||
|
||
### 止损
|
||
|
||
如果不改断言、不动产品类型就做不到,**保留现状**,只在 `Object.assign` 上方加一行注释说明为什么(哪个字段是 readonly、为什么夹具需要改它),并在 `BLOCKED.md` 记一条。这条不值得为它冒回归风险。
|
||
|
||
---
|
||
|
||
## 任务 D(P1)· 等待动效有七套,同一个视觉被实现了三遍
|
||
|
||
### 事实
|
||
|
||
全仓(不含 admin,antd 是独立设计系统,不在范围)现有等待动效清单,均已实测:
|
||
|
||
| 视觉 | keyframes | 时长 / 曲线 | 出现位置 |
|
||
| --- | --- | --- | --- |
|
||
| 轨道环 | `app-loading-orbit` | 1.4s linear | `AppLoadingIndicator` → `page.tsx:3707` 载入账户、`page.tsx:4103` 正在准备问题、`birth-time-assessment-overlay` |
|
||
| 打字光标 | `onboarding-caret` | 700ms steps(1,end) | `globals.css:657` 引导语打字流 |
|
||
| 文字微光 | `agent-activity-shimmer` | 1.6s linear + `background-clip:text` | `globals.css:322` Agent 活动状态 |
|
||
| 圆圈旋转 ① | `birth-place-spin` | **0.8s** linear | `globals.css:369` ← `birth-place-picker.tsx:291` `<LoaderCircle className="is-spinning">` |
|
||
| 圆圈旋转 ② | `consultation-run-spin` | **0.8s** linear | `globals.css:999` ← `consultation-run-timeline.tsx:60` `<LoaderCircle>` |
|
||
| 圆圈旋转 ③ | Tailwind `animate-spin` | **1s** linear | `personal-report-page.tsx:209`、`personal-report-center.tsx:78/168/178`、`reports/[reportId]/loading.tsx:7` |
|
||
| 透明度呼吸 | `daily-starlanguage-pending` | 1.6s ease-in-out | `globals.css:1780` ← 今日星语卡 `aria-busy` |
|
||
|
||
**①②③ 是同一个视觉(旋转的 lucide `LoaderCircle`)、三套实现、两种时长。**
|
||
|
||
用户说的"初始化结束有三种加载动画"能在 `page.tsx` 同一段 render 里定位到,是连着出现的三种:
|
||
|
||
- `page.tsx:4028` `<OnboardingChatMessage streaming>` → 打字光标闪
|
||
- `page.tsx:4100-4103` `onboardingPending` → 轨道环"正在准备问题"
|
||
- `page.tsx:4123` 附近 `aria-busy={dailyStarlanguageBusy}` → 今日星语卡开始透明度呼吸
|
||
|
||
三个不同节奏(700ms / 1.4s / 1.6s)、三种不同形态,在几秒内依次闪过。这是用户投诉的直接来源。
|
||
|
||
另有死代码:`globals.css:395` 的 `@keyframes pulse` 全仓 0 引用。
|
||
|
||
### 做法
|
||
|
||
**目标不是"全部统一成一个"**,而是按"等待的语义"分层,最多留三类,同类必须同实现:
|
||
|
||
1. **整页 / 整块阻塞等待**(载入账户、正在准备问题、生时评估浮层)→ 继续用轨道环 `AppLoadingIndicator`。**保持现状不动**,这是品牌感的来源。
|
||
2. **行内 / 局部等待**(出生地、运行时间线、个人报告)→ **收敛成唯一一个 `<InlineSpinner>` 组件**:一套 keyframes、一个时长、一处尺寸约定。上表 ①②③ 全部改用它,删掉 `birth-place-spin` 和 `consultation-run-spin` 两套 keyframes,清掉所有 `animate-spin` 类名。
|
||
3. **流式生成中**(打字光标、文字微光)→ **保持**。它们表达的是"内容正在到达",语义与"在等待"不同,不要一并统一掉。
|
||
|
||
透明度呼吸(今日星语卡)归入第 2 类的语义但形态不同 —— 由你判断:要么并入 `InlineSpinner`,要么保留但必须说明它为什么不能是 spinner。
|
||
|
||
**初始化结束那一刻,不得让用户连续看到三种形态。** 轨道环消失后今日星语卡立刻开始呼吸,等于换了个动效继续等。要求二选一:让星语的首次拉取在轨道环期间就完成(轨道环消失时卡片已是终态),或卡片首次渲染直接给静态占位文案、不加动效。改完必须按帧说明用户实际看到几种。
|
||
|
||
### 约束
|
||
|
||
- 不得引入任何第三方动画库
|
||
- 不得修改 `AppLoadingIndicator` 的 DOM 结构(`birth-time-assessment-overlay` 依赖它)
|
||
- 所有等待容器现有的 `role="status"` / `aria-live="polite"` / `aria-busy` 一律保留,不得因为换组件丢掉
|
||
- **reduced-motion 要特别处理**:`globals.css:443` 有全局 `*` 规则 `animation-iteration-count: 1 !important`,旋转类动效在该模式下转一圈就停 —— 视觉上像卡死。新 `InlineSpinner` 在 `prefers-reduced-motion: reduce` 下必须退化成静态但语义明确的形态(静止圆点 + 文案),不要留一个停在半途的圆弧
|
||
- 顺手删 `globals.css:395` 的死 `@keyframes pulse`
|
||
- admin 的 antd `<Spin>` 不在范围,不要动
|
||
|
||
### 交付前必须写进 `frontend/DESIGN.md`
|
||
|
||
新增一节"等待与加载",把三类词汇表写死:每一类叫什么、用在什么语义、用哪个组件、时长多少、reduced-motion 下退化成什么。**这一步不能省** —— 不写进 DESIGN.md,下一个人还会再造第四套。
|
||
|
||
### 验收
|
||
|
||
```bash
|
||
# 行内旋转应只剩一处实现
|
||
grep -rn 'animate-spin' frontend/src/ # 期望 0 命中
|
||
grep -n 'birth-place-spin\|consultation-run-spin' frontend/src/app/globals.css # 期望 0
|
||
grep -c '@keyframes' frontend/src/app/globals.css # 应比现在的 13 少
|
||
```
|
||
|
||
外加:
|
||
|
||
- 改后动效清单表(每处等待 → 归哪一类 → keyframes → 时长),对照上面那张表
|
||
- 完整走一遍初始化:新账号 → 填姓名 → 填出生资料 → 初始化结束 → 落到首页。**按帧描述用户依次看到几种动效**,录屏更好。这是本条唯一真正的验收标准
|
||
- 出生地选择器、运行时间线、个人报告三处各自截图或描述,确认视觉一致
|
||
- 系统开启"减少动态效果"后再走一遍,确认没有停住的半圈
|
||
|
||
不建 `BUG_HISTORY`(这是设计收敛,不是缺陷),在 PROGRESS 里单列一节。
|
||
|
||
### 止损
|
||
|
||
收敛行内 spinner 若需改动超过 8 个文件,或需要触碰生时校正外壳 —— 只做"三个圆圈旋转合一 + DESIGN.md 词汇表",初始化流程的时序调整登记 `BLOCKED.md` 留下一轮。
|
||
|
||
|
||
---
|
||
|
||
## 不在本轮范围
|
||
|
||
- **`page.tsx:1209` 的 `visibleSessions`**(每次 render 重算 filter+filter+sort):没有基准证明它是热点,且红线禁止手写 `useMemo`。留到下一轮,届时先出基准。
|
||
- **深色主题**:产品决策,等人拍板,不得自行开工。
|
||
- **继续拆 `Home`**:上一轮只切了第一刀(`useState` 61→60),剩余切片单独立项。
|
||
- **打字光标与文字微光的形态本身**:任务 D 只收敛"等待"类,"内容正在到达"类不动。
|
||
- **`web/*.html`**:确认不删,Python API 仍在用。
|
||
|
||
---
|
||
|
||
## 收尾
|
||
|
||
- PROGRESS 文件名写 `PROGRESS-frontend-followup-20260829.md`。**不要写成 `PROGRESS.md`** —— 根目录已有受版本控制的 `progress.md`,大小写不敏感文件系统上会互相覆盖。
|
||
- 订正 `BLOCKED.md` 里"`error.tsx`/`not-found.tsx` 只能另开布局链"那条 —— 实际用内联样式即可,两全。
|
||
- 三条任务合成**一次** staging 推送(staging push 会触发全量构建+部署,没有路径过滤,不要分多次推)。
|
||
- 推送后核对 `https://staging.jyotisha.chat/api/health` 的 `.deployment.gitCommit` 等于新 SHA。
|
||
- 不自行提升 main。
|
||
|
||
## 交付物清单
|
||
|
||
1. `src/app/not-found.tsx`、`src/app/error.tsx` 改为内联样式,无 Tailwind 依赖
|
||
2. `_not-found.html` body 无工具类的构建产物证据
|
||
3. admin 路由 CSS chunk 清单证据(仍为 antd/admin + Inter 两个)
|
||
4. `site-style-isolation-contract.test.ts` **新增**断言(既有断言未改)
|
||
5. `onboarding-presentation.test.ts` 三时区全绿证据
|
||
6. 任务 C:修好,或保留 + 注释 + BLOCKED 记录
|
||
7. `InlineSpinner` 组件,①②③ 三处旋转全部改用它,`animate-spin` 与两套旧 keyframes 清零
|
||
8. `frontend/DESIGN.md` 新增"等待与加载"一节(三类词汇表 + reduced-motion 退化约定)
|
||
9. 初始化全流程按帧的动效说明(含 reduced-motion 一遍)
|
||
10. `docs/BUG_HISTORY.md` 新条目(任务 A,编号已对远端确认)
|
||
11. `BLOCKED.md` 订正(任务 A 那条判断有误)
|
||
12. `PROGRESS-frontend-followup-20260829.md`
|
||
13. `tsc --noEmit` / `eslint` / `tsx --test`(≥2205,fail=0,skipped=0)/ `next build` 四条命令的实际输出
|