Files
Jyotisha/docs/tasks/TASK-frontend-followup-20260829.md
T
Jesse_ChenandClaude Fable 5.1 8db71aaf81 docs: product-level README, AGENTS.md split into code/reading parts, add CLAUDE.md, move task briefs to docs/tasks
- 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
2026-09-03 06:56:06 +00:00

17 KiB
Raw Blame History

任务书 · 前端优化九条的收尾(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.csssite-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=0skipped=0(在有 Docker 的环境里跑)。
  7. 不得改 .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.tsxsrc/app/error.tsx 的 markup 是纯 Tailwind 工具类,但上一轮把 globals 从根布局段摘掉后,这两页一条 CSS 都不加载。构建产物实证:

.next/server/app/_not-found.html
  → 只引用 3b67syn08d_z3.css2,146 B,纯 Inter @font-face
`min-h-svh` / `text-muted-foreground` 的定义只在 0grkt280n67e_.css216 KB),该页不加载它

用户看到的是浏览器默认裸 HTML:不居中、无配色、无间距,<Button> 塌成一行光秃秃的链接文字。这是真实的用户可见回归,不是可接受的取舍。

BLOCKED.md 把唯一出路写成"必须另开一条不与 admin 共享的布局链"—— 这个判断是错的,请一并订正该条记录。

做法

照抄仓库里已有的同类模式,不要发明新方案:src/app/global-error.tsxsrc/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.tsxTriangleAlert 可以留,但把 className="size-8 text-destructive" 换成 lucide 的 propssize={32} + color={danger}aria-hidden="true" 保留。
  • error.tsx 保留 "use client" 和现有的 useEffect(() => console.error(...)),不要动错误上报逻辑。

无障碍合同(frontend/DESIGN.md,不得降级)

  • 可点区域 minHeight: 44pxminWidth: 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="flexmin-h-svhtext-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 AppLoadingIndicatorpage.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:369birth-place-picker.tsx:291 <LoaderCircle className="is-spinning">
圆圈旋转 ② consultation-run-spin 0.8s linear globals.css:999consultation-run-timeline.tsx:60 <LoaderCircle>
圆圈旋转 ③ Tailwind animate-spin 1s linear personal-report-page.tsx:209personal-report-center.tsx:78/168/178reports/[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-spinconsultation-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,旋转类动效在该模式下转一圈就停 —— 视觉上像卡死。新 InlineSpinnerprefers-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:1209visibleSessions(每次 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.tsxsrc/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(≥2205fail=0skipped=0/ next build 四条命令的实际输出