- 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
17 KiB
任务书 · 前端优化九条的收尾(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 三分支。本轮不重做这些,只收三个尾巴。
硬红线
- 不得修改任何既有测试断言。 本轮三条任务都不需要改断言 —— 如果你发现"必须改断言才能过",那是方案错了,停下来登记
BLOCKED.md,不要动断言。 - 不得让
error.tsx/not-found.tsx重新 importglobals.css或site-styles。 这两页与 admin 共享根布局段,一旦 import,admin 每条路由会重新背上约 33 KB gzip —— 上一轮 −32 KB 的成果会当场归零。tests/site-style-isolation-contract.test.ts已锁这条,它必须保持绿灯且不被修改。 - 不得手写
useCallback/useMemo。 - 不得为迎合 React Compiler 改写代码;本轮不重开 React Compiler(理由见
BLOCKED.md2026-08-17 记录)。 - 推 staging 前必须
./node_modules/.bin/tsc --noEmit通过(BUG-409 防复发)。不要用npx tsc,本仓库环境下会装到空包tsc@2.0.4。 - 测试数不得低于基线 2205,且
fail=0、skipped=0(在有 Docker 的环境里跑)。 - 不得改
.gitea/workflows/**。不得在有未提交改动的工作树上切分支。不得自行把 staging 提升到 main。
让步顺序:功能与测试不回归 > 可验证的修复 > 代码整洁。
开工前置
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; } - 文案一字不改
验收(必须逐条给出证据,不接受"看起来好了")
./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),别抢号:
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 用绝对时刻构造:
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)),照它改:
const noon = new Date(2026, 6, 19, 12);
断言的期望值一个字都不许改(中午好,周宁。 / 现在最想理清哪件事? 等全部原样保留)。这不是改断言,是修夹具构造。
同文件第 27 行的 new Date("2026-07-19T08:00:00+08:00") 只被断言了名字、不涉及时段,可改可不改;若改,断言同样不许动。
顺手扫一遍是否还有同类:
grep -rn '+08:00' frontend/tests/ | grep -iv 'iso\|timestamp\|askedAt\|resolvedAt'
凡是"绝对时刻 + 断言时段文案"的组合都按同一方式修;只做时间戳比较的不要动。
验收
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:
// 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-4103onboardingPending→ 轨道环"正在准备问题"page.tsx:4123附近aria-busy={dailyStarlanguageBusy}→ 今日星语卡开始透明度呼吸
三个不同节奏(700ms / 1.4s / 1.6s)、三种不同形态,在几秒内依次闪过。这是用户投诉的直接来源。
另有死代码:globals.css:395 的 @keyframes pulse 全仓 0 引用。
做法
目标不是"全部统一成一个",而是按"等待的语义"分层,最多留三类,同类必须同实现:
- 整页 / 整块阻塞等待(载入账户、正在准备问题、生时评估浮层)→ 继续用轨道环
AppLoadingIndicator。保持现状不动,这是品牌感的来源。 - 行内 / 局部等待(出生地、运行时间线、个人报告)→ 收敛成唯一一个
<InlineSpinner>组件:一套 keyframes、一个时长、一处尺寸约定。上表 ①②③ 全部改用它,删掉birth-place-spin和consultation-run-spin两套 keyframes,清掉所有animate-spin类名。 - 流式生成中(打字光标、文字微光)→ 保持。它们表达的是"内容正在到达",语义与"在等待"不同,不要一并统一掉。
透明度呼吸(今日星语卡)归入第 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,下一个人还会再造第四套。
验收
# 行内旋转应只剩一处实现
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:上一轮只切了第一刀(useState61→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。
交付物清单
src/app/not-found.tsx、src/app/error.tsx改为内联样式,无 Tailwind 依赖_not-found.htmlbody 无工具类的构建产物证据- admin 路由 CSS chunk 清单证据(仍为 antd/admin + Inter 两个)
site-style-isolation-contract.test.ts新增断言(既有断言未改)onboarding-presentation.test.ts三时区全绿证据- 任务 C:修好,或保留 + 注释 + BLOCKED 记录
InlineSpinner组件,①②③ 三处旋转全部改用它,animate-spin与两套旧 keyframes 清零frontend/DESIGN.md新增"等待与加载"一节(三类词汇表 + reduced-motion 退化约定)- 初始化全流程按帧的动效说明(含 reduced-motion 一遍)
docs/BUG_HISTORY.md新条目(任务 A,编号已对远端确认)BLOCKED.md订正(任务 A 那条判断有误)PROGRESS-frontend-followup-20260829.mdtsc --noEmit/eslint/tsx --test(≥2205,fail=0,skipped=0)/next build四条命令的实际输出