Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
eea90926a1 | ||
|
|
dc2f2a16bb | ||
|
|
6d097de95b | ||
|
|
1b508d5e9a | ||
|
|
77e3626a5c | ||
|
|
6ca28e060b | ||
|
|
73d3c99ccd | ||
|
|
662672c153 |
@@ -1,5 +1,17 @@
|
||||
# 印度占星 Skill 更新日志
|
||||
|
||||
## 2026-09-17 — 星盘页、星历页去掉三句说明
|
||||
|
||||
打开星盘后,Tab 行下面不再写「主盘直接算 · 分盘按需 · 不消耗点数」。基础信息里也不再写「下面是词条式释义,不是对你个人的判断。」星历页底部不再写「这一页是天象本身,不是对你的判断。」盘还是主盘先算、分盘点到才算、不扣点;行星卡仍是度数和星宿;星历仍只写天象事实,不是运势判断。Skill 版本不变。
|
||||
|
||||
## 2026-09-17 — 追问、反问、短句也会先取回本轮星盘再回答
|
||||
|
||||
「?」「你在说什么鬼」这类跟进不再整轮失败。每一轮都会先重新取回本轮星盘证据,再据此回答。Skill 版本不变。
|
||||
|
||||
## 2026-09-17 — 手机键盘收起后顶栏还在;积分块和「当前盘面」一样高
|
||||
|
||||
iPhone 上弹键盘再收起、或带着键盘刷新,整页会停在上移后的位置,顶栏点不到。现在键盘一收,页面回到顶,顶栏随时能点。顶栏右侧「当前盘面」和积分两枚芯片同一套尺寸,积分块不再又宽又扁。Skill 版本不变。
|
||||
|
||||
## 2026-09-17 — 不再问「你更接近哪一种相处方式」;灰掉的选项和「再说一件事」不会同屏
|
||||
|
||||
分盘风格题(「亲密关系里,你更接近哪一种相处方式?」这类)不再作为计分题出现。关系、事业、家人这些线只在引擎给得出带年份的事件时问「某年前后有没有…」,给不出就直接问下一条线或给结果,不再拿性格描述去淘汰候选时间。性格描述仍然出现在平局时的参考题和交付卡的性格列里。
|
||||
@@ -9,6 +21,9 @@
|
||||
如果你 2026-09-16 到 09-17 之间在校正里遇到过风格题,那一次的答案可能计错了分,不要采用当时的区间;补一件带年月的事触发重算,或新开一段校正。
|
||||
|
||||
Skill 版本不变。
|
||||
## 2026-09-17 — 手机上时间轴读数不再被裁,「跳到最新」不再压住选项
|
||||
|
||||
窄屏(375 / 390 宽)打开生时校正时,上方时间轴那一行「范围 · 宽度 · 代表分钟 · 已答 N 题」四项完整显示,不再出现「已…」这种被裁一半的字。为腾出宽度,手机上去掉了这条与头像列对齐的 44px 缩进,并把「已对照 N 件」这项收回交付卡(桌面不变;读屏软件在两种宽度下读到的内容都一样)。「跳到最新」胶囊与对话末条之间留出空隙,不再压在选项卡最后一行上。Skill 版本不变。
|
||||
|
||||
## 2026-09-16 — 正文念出的判别题必有卡;性格描述不再自相矛盾
|
||||
|
||||
|
||||
+100
-4
@@ -11162,14 +11162,14 @@
|
||||
|
||||
- 状态:resolved
|
||||
- 首次发现:2026-09-15
|
||||
- 最近更新:2026-09-15
|
||||
- 最近更新:2026-09-17
|
||||
- 影响面:`assembleChartView` 的 layer 参数、`chart-page-view.tsx` 眉标、服务端引擎结果缓存
|
||||
- 用户现象:打开星盘常失败;失败页上还写着「直接计算 · 打开即有 · 不消耗点数」。生产与 staging 重计算并发默认 2,开页并行打 `/api/western` 与 `/api/qizheng` 就会占满。
|
||||
- 触发条件:打开 `/chart`。同时有第二个人开星盘,或校正 / 咨询在跑,更容易 429。
|
||||
- 根因:进页无条件并行打两个 `HEAVY_COMPUTE_PATHS` 端点;`cache: "no-store"` 且无记忆;失败分支也渲染同一句速度承诺眉标。
|
||||
- 修复:开页只请求 `/api/chart`。西洋 / 七政点到对应 tab 才发,不再占开页配额。同一账户 + 出生资料指纹 + ayanamsa + `node_mode` 的引擎结果内存缓存 5 分钟,资料或岁差一变即换键。失败页删除眉标;成功眉标改为「主盘直接算 · 分盘按需 · 不消耗点数」。不放宽限流。
|
||||
- 验证:无 layers 时 `assembleChartView` 的引擎路径只有 `/api/chart`;失败页无 eyebrow;缓存键随 ayanamsa / node_mode 变化。
|
||||
- 防复发:开页不得并行打 `/api/western` 与 `/api/qizheng`。不得靠放宽 `HEAVY_COMPUTE_PATHS` 或提高 `JYOTISH_HEAVY_COMPUTE_CONCURRENCY` 来「解决」429。承诺速度的文案不写。
|
||||
- 修复:开页只请求 `/api/chart`。西洋 / 七政点到对应 tab 才发,不再占开页配额。同一账户 + 出生资料指纹 + ayanamsa + `node_mode` 的引擎结果内存缓存 5 分钟,资料或岁差一变即换键。失败页删除眉标。不放宽限流。2026-09-17 产品判定成功页「主盘直接算 · 分盘按需 · 不消耗点数」仍是过度提示,连同基础信息「下面是词条式释义,不是对你个人的判断。」一并去掉;按需加载与不扣点的行为不变,只是不再写在页上。
|
||||
- 验证:无 layers 时 `assembleChartView` 的引擎路径只有 `/api/chart`;成功页与失败页都无 eyebrow、无「不消耗点数」、无「词条式释义」;缓存键随 ayanamsa / node_mode 变化。
|
||||
- 防复发:开页不得并行打 `/api/western` 与 `/api/qizheng`。不得靠放宽 `HEAVY_COMPUTE_PATHS` 或提高 `JYOTISH_HEAVY_COMPUTE_CONCURRENCY` 来「解决」429。承诺速度、计费、词条边界的说明不写在星盘页上。`chart-page-view.test.tsx` 成功页与失败页都断言不得出现这些句子。
|
||||
- 相关记录:BUG-707、BUG-715、BUG-716、TASK-chart-page-blocking-open-20260915
|
||||
- 复发自:无
|
||||
- 修复版本:待发布
|
||||
@@ -11938,3 +11938,99 @@
|
||||
- 相关记录:BUG-915、BUG-675、BUG-674
|
||||
- 复发自:无
|
||||
- 修复版本:待发布
|
||||
|
||||
## BUG-918 | 校正时间轴读数在 375px 手机上被裁成「已…」
|
||||
|
||||
- 状态:resolved
|
||||
- 首次发现:2026-09-17
|
||||
- 最近更新:2026-09-17
|
||||
- 影响面:`frontend/src/app/globals.css` `.rectification-timeline` / `.rectification-timeline__readout` 的 `@media (max-width: 767px)` 段、`frontend/src/components/rectification-timeline.tsx`
|
||||
- 用户现象:iPhone 上常驻时间轴一行读作「04:48–05:07 20 分钟 代表分钟 04:53 已…」,第四项被省略号裁断,第五项完全不显示。
|
||||
- 触发条件:视口宽 375 CSS px(iPhone SE 2/3、13 mini)打开生时校正,Case 已有代表分钟、已答题数与已对照事件数(读数五项齐全)。
|
||||
- 根因:两条叠加。一是读数按「四项短文本」设计,`.rectification-timeline` 在 767px 以下仍取 `calc(var(--space-4) + var(--assistant-content-inset))`,即每侧 16 + 44 = 60px,375px 的可用内容宽只剩 255px;条不在消息列里,这 44px 头像沟槽在手机上买不到任何可见对齐。二是 cfb41daf 又给读数追加了第五项 `已对照 N 件`,把四项预算变成五项。按 13px 逐字保守估宽,五项加四个 12px 间隙需要约 383px,超出 255px 约 128px,于是 `__answered` 命中自身的 `text-overflow: ellipsis` 显示为「已…」,`__dated` 被条的 `overflow: hidden` 整项裁掉。
|
||||
- 修复:compact 段(`@media (max-width: 767px)`,与 `.is-compact` 的 768px 断点同源)内 `.rectification-timeline` 的 `padding-inline` 改为 `var(--space-4)` 单项;读数 `column-gap` 收到 `var(--space-2)`;`.rectification-timeline__dated` 在该段 `display: none`。桌面规则与条高(64 / 56px)一字未动,滚动锚定逻辑未动。组件仍无条件渲染全部五个 span,轴的 `aria-label` 仍由五项文本拼成,被隐藏的一项对辅助技术不丢失。
|
||||
- 验证:`frontend/tests/rectification-mobile-timeline-readout-20260917.test.tsx` 6 条(渲染断言五项文本与 `aria-label` 一致;按保守逐字宽度断言 375 与 390 下四项 + 3×8px 间隙约 292px ≤ 343px / 358px,并断言旧口径 383px > 255px;compact 段声明断言去 inset、收 gap、隐藏第五项、未放开 nowrap 与条高;桌面段断言仍带 inset 与 13px)。`frontend/tests/rectification-timeline-20260909.test.ts` 的移动端 `padding-inline` 断言同步改写并附原值 / 新值 / 原因。全套 `npm test` 3420 tests / 31 fail,失败清单与基线(3414 / 31,无 Docker 集)逐条相同;`tsc --noEmit` 0 错、`npm run lint` 0 error、`next build --webpack` 后 `/` 仍 `○ Static`、首屏 gzip 620106 → 620128 字节(+0.004%)。
|
||||
- 防复发:新增测试把「compact 段不得含 `--assistant-content-inset`」「第五项在 compact 下隐藏」与宽度预算写成断言;`globals.css` 与组件注释都改掉了原来「四项短文本在 375px 下还有余量」的错误说法,并写明再加一项必须重算预算而不是直接追加 span。
|
||||
- 相关记录:BUG-478(跳到最新与滚动锚定归属)、BUG-919(同一次真机走查的另一条)。另记一条口径冲突待产品裁决:`frontend/DESIGN.md` §10 的禁列表明确写「已对照 N 件经历」不上条、仍归交付卡,而 cfb41daf 把它加到了条上且未改 DESIGN;本轮只在手机上把它移出可见行,桌面上是否也撤掉未擅自决定。
|
||||
- 复发自:无
|
||||
- 修复版本:待发布
|
||||
|
||||
## BUG-919 | 「跳到最新」浮层压住选项卡最后一个可点选项
|
||||
|
||||
- 状态:resolved
|
||||
- 首次发现:2026-09-17
|
||||
- 最近更新:2026-09-17
|
||||
- 影响面:`frontend/src/app/globals.css` `.rectification-workspace__chat .conversation` / `.message-list` 的底部留白
|
||||
- 用户现象:iPhone 上「跳到最新」胶囊浮在选项卡最后一行上,选项被遮掉一半,正中间不好点。
|
||||
- 触发条件:校正对话滚到尾部附近、浮层可见(距底 > 96px)时,末条内容是一张多行选项卡。
|
||||
- 根因:浮层 `.jump-to-latest` 是 `position: absolute; bottom: 100%` 挂在 composer 顶边上,占据 composer 上方 44px 触控高 + 自身 `space-3` = 56px 的带。而 `.message-list` 的底部留白恰好等于 `--rectification-jump-clearance`(同样 56px),与浮层带严丝合缝,末条内容顶到浮层正下方;容器 `pointer-events: none` 只放行 44px 按钮本体,按钮正好落在选项行中央,于是「遮一半 + 中间点不动」。
|
||||
- 修复:`.message-list` 底部留白改为 `calc(var(--rectification-jump-clearance) + var(--space-3))`;`.conversation` 追加 `scroll-padding-block-end: var(--rectification-jump-clearance)`,让任何 scroll-into-view 也落在带之上。连同 `.conversation` 自身的 `space-4`,静止时末条下方共 84px(56px 浮层带 + 28px 空气)。浮层的位置、居中对齐与可见条件(BUG-478 的归属)一字未动,`--rectification-jump-clearance` 的取值不变(时间轴 56px 条高仍与它同源),`use-conversation-scroll-anchor` 未动。
|
||||
- 验证:`frontend/tests/rectification-mobile-timeline-readout-20260917.test.tsx` 两条声明断言(留白表达式与 `scroll-padding-block-end`;浮层 `absolute` / `bottom: 100%` / `justify-content: center` / `pointer-events: none` / 44px 与 `conversationAnchorThreshold = 96` 未变)。全套门禁数字同 BUG-918。真机走查(375 / 390 宽)留在 `docs/testing/rectification-mobile-timeline-readout-20260917.md`:本机无 Chrome、无真机。
|
||||
- 防复发:测试锁住「留白 = 浮层带 + space-3」这条表达式,任何把它改回等于浮层带的提交会红。CSS 注释写明了为什么不做到「任何滚动位置都不重叠」——那需要留白 ≥ `conversationAnchorThreshold`(96px) + 56px = 152px,在 667px 高的手机上等于凭空吃掉一整行选项,而浮层只在读者已经向上滚过该阈值后才出现。
|
||||
- 相关记录:BUG-478(两个聊天面统一用 `useConversationScrollAnchor` 与 `JumpToLatestButton`,并把浮层改成居中)、7a4360d8(同一现象 2026-08-25 的旧修法是把胶囊右对齐,已被 BUG-478 的居中统一取代,本轮未推翻它)、BUG-918
|
||||
- 复发自:无
|
||||
- 修复版本:待发布
|
||||
|
||||
## BUG-920 | iPhone 键盘收起 / 刷新后整页上移,顶栏点不到
|
||||
|
||||
- 状态:resolved
|
||||
- 首次发现:2026-09-17
|
||||
- 最近更新:2026-09-17
|
||||
- 影响面:`ViewportScrollLock`、`layout.tsx`
|
||||
- 用户现象:刷新或收起键盘后顶栏滚出屏幕,底部留下空白,点不到 tab。
|
||||
- 触发条件:iPhone Safari 上聚焦输入框弹出键盘,随后收起或带着偏移刷新。
|
||||
- 根因:iOS 不支持 `interactive-widget=resizes-content`,键盘弹出时滚动 `window`。`html, body { overflow: hidden }` 挡住用户拉回,reload 又按 `scrollRestoration=auto` 还原 `scrollY`。代码里原先没有 window 级复位。
|
||||
- 修复:挂载时 `history.scrollRestoration = "manual"` 并 `scrollTo(0,0)`。`visualViewport` resize/scroll、pageshow、orientationchange、focusout(300ms)在键盘已收且 `scrollY > 0` 时复位。键盘打开期间不动。
|
||||
- 验证:`frontend/tests/mobile-viewport-scroll-lock-20260917.test.ts`。真机待核。
|
||||
- 防复发:不得靠用户自己滚回去;键盘打开时不得抢 Safari 的露出输入框滚动。
|
||||
- 相关记录:BUG-918、BUG-919、BUG-921
|
||||
- 复发自:无
|
||||
- 修复版本:待发布
|
||||
|
||||
## BUG-921 | 顶栏积分块比「当前盘面」扁
|
||||
|
||||
- 状态:resolved
|
||||
- 首次发现:2026-09-17
|
||||
- 最近更新:2026-09-17
|
||||
- 影响面:`.credit-button`、`.chat-header-rectification .rectification-board-peek`、手机顶栏行高
|
||||
- 用户现象:手机顶栏右侧积分「✦ N」又宽又矮,和旁边「当前盘面」不成一套。
|
||||
- 触发条件:校正页顶栏同时出现盘面芯片与积分块。
|
||||
- 根因:`.chat-header-actions button` 把高度写成 32px,比 `.credit-button` 更具体,积分块被压到 32px 却仍强制 `min-width: 64px`、13px 字。盘面芯片用另一套 44px / 14px。两枚都塞在 46px 行里。
|
||||
- 修复:两枚共用 40px 高、14px tabular、同圆角同内边距;去掉积分 `min-width`。`::before` inset -2px 把热区扩到 44px。≤767px 顶栏行 `52px + safe-area`(原 64px 覆盖改为与任务书 52px 对齐)。
|
||||
- 验证:CSS 声明断言高度/字号/圆角一致、无 64px min-width;`touch-target-contract` 仍绿。
|
||||
- 防复发:顶栏两枚芯片必须走同一组声明,不得再各写一套高度。
|
||||
- 相关记录:BUG-695、BUG-920
|
||||
- 复发自:无
|
||||
- 修复版本:待发布
|
||||
|
||||
## BUG-922 | 咨询追问被提示词允许跳过排盘工具,整轮失败
|
||||
|
||||
- 状态:resolved
|
||||
- 首次发现:2026-09-17
|
||||
- 最近更新:2026-09-17
|
||||
- 影响面:`windowJyotishInstructions`、`jyotishInstructions`
|
||||
- 用户现象:申报时段会话连发短追问得不到回答,提示「Agent 未完成必要的方法与计算步骤,本次不会扣点」。
|
||||
- 触发条件:本命或申报时段模式下发「?」「你在说什么鬼」一类追问。
|
||||
- 根因:系统指令写「简单追问可复用已有 packet / context、不调工具」;`contractReady()` 却要求每次请求恰好一次成功排盘调用。跨请求没有可复用 packet(缓存只在单次请求内)。
|
||||
- 修复:两处指令改为每一轮(含短追问、澄清、抱怨)都必须先调用排盘工具,并写明计算只在本请求内有效。
|
||||
- 验证:`consultation-birth-time-mode.test.ts` 契约;既有 Agent 暴露工具测试仍绿。
|
||||
- 防复发:不得再写「follow-ups may use the existing」。
|
||||
- 相关记录:BUG-205、BUG-214、BUG-286、BUG-923
|
||||
- 复发自:无
|
||||
- 修复版本:待发布
|
||||
|
||||
## BUG-923 | 第 0 步未强制排盘工具,提示词与运行合同对不齐
|
||||
|
||||
- 状态:resolved
|
||||
- 首次发现:2026-09-17
|
||||
- 最近更新:2026-09-17
|
||||
- 影响面:`consultationNatalPrepareStep`、`consultationWindowPrepareStep`、`consult/route.ts` 申报时段分支
|
||||
- 用户现象:同 BUG-922。回执只有 skill 与 runtime-contract-retry,没有任何 tool 步骤。
|
||||
- 触发条件:同上。
|
||||
- 根因:提示词是软约束,`toolChoice: "auto"` 不能保证第 0 步调用排盘工具。申报时段路线甚至没有 `prepareStep`。
|
||||
- 修复:第 0 步 `activeTools` 仅排盘工具且 `toolChoice: "required"`;后续步骤 `"auto"`。窗口路线对称加 `prepareStep`,首次 stream 与合同 retry 使用它。`retryForAnswer` / 续写 / 分段仍不强制。未改 `contractReady()`。
|
||||
- 验证:natal / window prepareStep 单测;`route.ts` 源码契约。既有「失败后重试成功仍过门禁」「契约未绿文本丢弃」仍绿。
|
||||
- 防复发:本命与窗口第 0 步必须 `required`。思考模式供应商历史上拒 named/required,本仓 Mastra 类型接受 `'required'`;若线上某模型拒收,另立单,不得静默改回 auto 而不改提示词。
|
||||
- 相关记录:BUG-214、BUG-286、BUG-922
|
||||
- 复发自:无
|
||||
- 修复版本:待发布
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
# PROGRESS · 星盘页 / 星历页去掉三句过度提示(2026-09-17)
|
||||
|
||||
工作树:`.worktrees/chart-copy-trim-20260917`
|
||||
分支:`codex/chart-copy-trim-20260917`
|
||||
基线:`origin/staging` @ `dc2f2a16`
|
||||
本机 Windows。直接执行(产品口头:「把这个去掉」)。
|
||||
|
||||
Skill **未 bump**。未改 `scripts/**`、`deploy/**`、`frontend/src/app/page.tsx`。按需加载与不扣点的行为不变。
|
||||
|
||||
## 决策记录
|
||||
|
||||
产品 2026-09-17 判定这三句都是过度提示,不是对用户的判断或计费说明,从页面拿掉:
|
||||
|
||||
1. 星盘成功页眉标「主盘直接算 · 分盘按需 · 不消耗点数」(BUG-717 当时留在成功页)
|
||||
2. 基础信息「下面是词条式释义,不是对你个人的判断。」
|
||||
3. 星历页尾「这一页是天象本身,不是对你的判断。」
|
||||
|
||||
行星卡、大运双轨注、坐标系边界句、Lahiri 岁差说明都还在。
|
||||
|
||||
## 改了哪些文件
|
||||
|
||||
- `chart-page-view.tsx`:删 Tab 行下眉标
|
||||
- `chart-basics-tab.tsx`:删词条边界句
|
||||
- `chart-view-labels.ts` / `chart-view-contract.ts` / `chart-view-mapper.ts`:删 `eyebrow`、`SYMBOL_BOUNDARY` / `symbolBoundary`
|
||||
- `ephemeris-page.tsx` / `ephemeris-view.ts`:删页尾定性句
|
||||
- `globals.css`:删 `.chart-page-eyebrow`、`.ephemeris-outro`
|
||||
- 测试、`DESIGN.md`、`VOICE.md`、`CHANGELOG.md`、真人清单、BUG-717 跟进
|
||||
|
||||
## 测试
|
||||
|
||||
| 项 | 结果 |
|
||||
| --- | --- |
|
||||
| `tsc --noEmit` | 0 错 |
|
||||
| `npm run lint` | 0 error / 116 warning(与基线同,本轮文件 0 条) |
|
||||
| 定向 | `chart-page-view.test.tsx` + `ephemeris-page.test.tsx` + `chart-view-route.test.ts` **32 passed / 0 failed** |
|
||||
|
||||
无登录态、无 Chrome:浏览器走查留给 `docs/testing/chart-page-20260915.md`、`chart-page-open-20260915.md`、`ephemeris-page-20260915.md`。
|
||||
@@ -0,0 +1,33 @@
|
||||
# 进度 · 咨询追问轮必须先调排盘工具(2026-09-17)
|
||||
|
||||
基线:`origin/staging` @ `6d097de9`。
|
||||
分支:`codex/consult-followup-tool-contract-20260917`。
|
||||
BUG:922、923。Skill 未 bump。
|
||||
|
||||
## 做了什么
|
||||
|
||||
- **T1 / BUG-922**:删掉本命与窗口两处「简单追问可复用 existing packet/context」。`frontend/src` 已无 `follow-ups may use`。
|
||||
- **T2 / BUG-923**:`consultationNatalPrepareStep` 第 0 步 `toolChoice: "required"`。新增 `consultationWindowPrepareStep`,申报时段首次 stream 与合同 retry 走 `windowStreamOptions`。`retryForAnswer` / 续写 / 分段不带强制。
|
||||
- **prepareStep 类型**:`@mastra/core/dist/agent/types.d.ts` 的 `toolChoice` 为 `'auto' | 'none' | 'required' | { type: 'tool'; ... }`。本仓接受 `"required"`,未升级依赖。源码注释里曾写「思考模式供应商拒 named/required」;产品拍板每轮必调工具,仍上 `required`。若某模型线上拒收,另立单。
|
||||
- **T4 第一轮取证**(执行方无 VPS,留给产品/部署):
|
||||
|
||||
```bash
|
||||
cd /opt/jyotisha-production
|
||||
docker compose -f deploy/docker-compose.server.yml logs web --since 2026-09-17T00:00:00 \
|
||||
| grep agent-observability | grep '<该会话的 sessionId>' | head
|
||||
```
|
||||
|
||||
三条 `run.failed` 若都没有 `tool` 步骤,即本单同一根因;若第一轮有 `tool` 且 `status: failed`,另立单。
|
||||
|
||||
## 测试
|
||||
|
||||
| 项 | 结果 |
|
||||
| --- | --- |
|
||||
| `tsc --noEmit` | 0 错 |
|
||||
| 改动文件 eslint | 0 error |
|
||||
| `consultation-workflow-contract` + voice | **14 passed / 0 fail**(含 BUG-922/923 契约) |
|
||||
| `consultation-agentic-runtime` / `consultation-birth-time-mode` | 本机 Windows EPERM symlink(skill runtime alias),Linux CI 不受影响。断言已写在这两个文件里。 |
|
||||
| Python | 未改 |
|
||||
| 客户端 bundle | 未改,gzip 应为 0 |
|
||||
|
||||
T4 第一轮失败原因需 VPS `agent-observability` 日志,本机做不了。
|
||||
@@ -0,0 +1,23 @@
|
||||
# 进度 · 手机顶栏可点 + 积分块不扁(2026-09-17)
|
||||
|
||||
基线:`origin/staging` @ `77e3626a`。
|
||||
分支:`codex/mobile-viewport-scroll-lock-20260917`。
|
||||
BUG:920、921。Skill 未 bump。
|
||||
|
||||
## 做了什么
|
||||
|
||||
- **T1 / BUG-920**:`viewport-scroll-lock.ts` + `ViewportScrollLock` 挂在 `layout.tsx`,与 `StaleClientRecovery` 并列。`scrollRestoration=manual`,键盘收起后 `window.scrollTo(0,0)`。键盘打开(visualViewport 矮于 innerHeight−100)不动。`focusout` 按让步做 300ms 兜底。未改 `html, body overflow: hidden`,未动 `page.tsx` 状态。
|
||||
- **T3 / BUG-921**:`.chat-header-actions .credit-button` 与顶栏 `.rectification-board-peek` 共用 40px / 14px / 同圆角。积分去掉 `min-width: 64px`。`::before` −2px 扩到 44px 热区。≤767px 顶栏行从 `64px + safe-area` 收到 `52px + safe-area`(任务书按 46→52 写;手机原先已是 64px 覆盖,现与 52px 对齐)。
|
||||
- **T2**:BUG_HISTORY / CHANGELOG / DESIGN / 真机清单。
|
||||
|
||||
## 测试
|
||||
|
||||
| 项 | 结果 |
|
||||
| --- | --- |
|
||||
| `tsc --noEmit` | 0 错 |
|
||||
| 改动文件 eslint | 0 error |
|
||||
| 本单 + touch-target + 顶栏相关 | **84 passed / 0 fail**(含 BUG-695 触屏热区) |
|
||||
| Python | 未改,不跑快速门 |
|
||||
| `next build` | worktree junction,留给 Gitea |
|
||||
|
||||
无 iOS / Chrome,真机清单留给产品。
|
||||
@@ -0,0 +1,94 @@
|
||||
# 进度记录 · 校正页移动端:时间轴读数被裁、「跳到最新」压住选项(2026-09-17)
|
||||
|
||||
任务书:`docs/tasks/TASK-rectification-mobile-timeline-readout-20260917.md`
|
||||
分支:`codex/rectification-mobile-timeline-readout-20260917`,基线 `origin/staging` = `c32f81e7`(任务书写的 `8d7dfbf0` 已被 staging 超车,按指令取 `c32f81e7`)。
|
||||
BUG:918、919。纯前端样式,未跑 Python 预检(AGENTS §9 允许)。
|
||||
|
||||
未触碰(并行会话所有):`frontend/src/components/rectification-agentic-chat.tsx`、`frontend/src/lib/rectification-agentic/**`、Python、Skill。也未动时间轴固定高度(64 / 56px)与 `use-conversation-scroll-anchor`。
|
||||
|
||||
## 1. 做了什么
|
||||
|
||||
| 任务 | 文件 | 改动 |
|
||||
| --- | --- | --- |
|
||||
| T1 BUG-918 | `frontend/src/app/globals.css` | `@media (max-width: 767px)` 段:`.rectification-timeline` 的 `padding-inline` 由 `calc(var(--space-4) + var(--assistant-content-inset))` 改为 `var(--space-4)`;`.rectification-timeline__readout { column-gap: var(--space-2) }`;`.rectification-timeline__dated { display: none }`。桌面段未动。同时改掉基线注释里「四项短文本在 375px 下还有余量」这句被实证推翻的话。 |
|
||||
| T1 BUG-918 | `frontend/src/components/rectification-timeline.tsx` | 只加注释:五个 span 永远无条件渲染,由 CSS 决定窄屏显示几项;轴 `aria-label` 仍由五项拼成;再加第六项必须重算宽度预算。无行为改动。 |
|
||||
| T2 BUG-919 | `frontend/src/app/globals.css` | `.rectification-workspace__chat .message-list` 底部留白由 `var(--rectification-jump-clearance)` 改为 `calc(var(--rectification-jump-clearance) + var(--space-3))`;`.conversation` 追加 `scroll-padding-block-end: var(--rectification-jump-clearance)`。`--rectification-jump-clearance` 取值未变(时间轴 56px 条高仍与它同源,既有断言不受影响);浮层的位置、居中、可见条件未动。 |
|
||||
| T3 | `docs/BUG_HISTORY.md`、`CHANGELOG.md`、`frontend/DESIGN.md`、`docs/testing/…`、本文件 | 记录。 |
|
||||
|
||||
## 2. 宽度预算(T1 的算术)
|
||||
|
||||
jsdom / happy-dom 不在本套件里,`scrollWidth` 与 `clientWidth` 都量不到,所以这是**按逐字保守估宽的算术**,不是测量。每字宽度一律向上取整(数字取 0.6em,而 -apple-system / Helvetica 的 tabular 数字约 0.556em),font-size 13px。测试里 `readoutWidthPx` 就是这套系数。
|
||||
|
||||
单项估宽(px):`04:48–05:07` 77.5、`20 分钟` 45.2、`代表分钟 04:53` 91.1、`已答 6 题` 54.1、`已对照 3 件` 67.1。
|
||||
|
||||
| | 改前 | 改后 |
|
||||
| --- | --- | --- |
|
||||
| compact 每侧内边距 | 16 + 44 = 60px | 16px |
|
||||
| 375px 可用内容宽 | 255px | 343px |
|
||||
| 可见项数 / 列间距 | 5 项 / 12px | 4 项 / 8px |
|
||||
| 读数需要宽度 | 383.0px | 291.9px |
|
||||
| 375px 余量 | **−128.0px(被裁)** | **+51.1px** |
|
||||
| 390px 余量 | −113.0px | +66.1px |
|
||||
| 320px 余量 | −143.0px | −3.9px |
|
||||
|
||||
375 与 390 两个目标宽度都放得下。**320px 差约 4px**:按系统实际字宽(数字 0.556em)重算是 283.3px ≤ 288px,能放下;按上面的保守系数差一点点。真机 320px 若尾部被裁一点属已知边界,已写进 `docs/testing/` 清单第「已知边界」段,范围和代表分钟在任何情况下都不会被裁。
|
||||
|
||||
让步顺序:没有用到。任务书让步 1(只做去 inset + 缩 gap)算下来是 5 项 335.0 + 4×8 = 367.0px > 343px,仍然放不下,所以第五项必须离开可见行;采用了任务书 T1 里的「只保留一项」分支,而不是合并成「N 题 · N 件」——合并需要原始计数,而计数只以 `已答 6 题` / `已对照 3 件` 这种成品字符串到达组件,要么去改 `frontend/src/lib/rectification-timeline-scale.ts`(超出本单文件范围),要么在组件里正则抠数字(脆弱)。隐藏这一项另有 DESIGN 依据,见第 4 节。
|
||||
|
||||
## 3. 「跳到最新」的几何(T2)
|
||||
|
||||
浮层占 composer 上方 56px 的带:按钮 44px + 浮层自身 `padding-bottom: var(--space-3)`。基线 `.message-list` 底部留白恰好也是 56px,与带严丝合缝,末条内容顶到按钮正下方;容器 `pointer-events: none` 只放行 44px 按钮本体,而按钮居中,正落在选项行中央 → 「遮一半 + 中间点不动」。
|
||||
|
||||
改后:`.message-list` 68px + `.conversation` 16px = 静止时末条下方 84px = 56px 带 + 28px 空气。
|
||||
|
||||
**做不到、也没有假装做到的部分**:把「任何滚动位置都不重叠」做成保证,需要留白 ≥ `conversationAnchorThreshold`(96px) + 56px = 152px。推导:设末条内容底为 E、尾部留白 P、距底 d,浮层带覆盖内容坐标 `[E+P-d-56, E+P-d]`,与真实内容重叠 ⟺ `d > P - 56`;而浮层可见 ⟺ `d > 96`。所以要让「浮层刚出现的那一刻」不压内容就得 `P - 56 ≥ 96`。152px 在 667px 高的手机上等于凭空吃掉一整行选项,且浮层只在读者已经主动向上滚过 96px 后才出现,所以按 CSS 注释记录理由后不做。本轮保证的是:**贴底静止时(浮层隐藏)末条选项整行可点且与输入框有 28px 空隙**;浮层可见时它浮在已滚上去的内容上,与两个聊天面一致,点一下即回到底。
|
||||
|
||||
未改浮层可见条件(任务书要求),也未把胶囊改回右对齐——`tests/rectification-agentic-entry.test.ts` 锁着 `justify-content: center`(BUG-478 的统一决策),右对齐是 2026-08-25 `7a4360d8` 的旧修法,已被取代,不擅自推翻。
|
||||
|
||||
## 4. 顺带发现的口径冲突(未擅自裁决,需产品拍板)
|
||||
|
||||
`frontend/DESIGN.md` §10 的「以下一律不得加回来」表里明确写着「已对照 N 件经历」不上条、仍归交付卡,理由是它和「已答 N 题」不是同一个量。但 `cfb41daf`(2026-09-16,BUG-740~743)把 `datedEventLabel` 加到了读数上,且没有改 DESIGN。也就是说这第五项从进条那天起就与 DESIGN 的红线矛盾,而它正是压垮 375px 宽度预算的那一项。
|
||||
|
||||
本轮**只在 767px 以下把它移出可见行**(任务书授权范围内),桌面保留原状,并把这处冲突写进 BUG-918 的「相关记录」、DESIGN §10 和真机清单,等产品决定桌面是否也撤掉。没有单方面改桌面。
|
||||
|
||||
## 5. 测试
|
||||
|
||||
新增 `frontend/tests/rectification-mobile-timeline-readout-20260917.test.tsx`(6 条):
|
||||
|
||||
1. 渲染断言:五项文本按序全在 DOM,且轴 `aria-label` 含全部五项。
|
||||
2. 宽度预算断言:compact 可见四项 + 3×8px ≤ 343px(375)与 ≤ 358px(390);并反向断言旧口径 383px > 255px,把 Bug 的形状钉住。
|
||||
3. compact 段声明断言:`padding-inline: var(--space-4)` 且段内不含 `--assistant-content-inset`;`column-gap: var(--space-2)`;`__dated` `display: none`;段内不得出现 `__range` / `__width` / `__working`(这两个数字任何宽度都不许隐藏或缩);不得放开 `flex-wrap` / `white-space`;条高仍 56px。段的切取是按大括号配平取出那一个 `@media` 块,不是 `slice` 到文件尾。
|
||||
4. 桌面段断言:仍是 `calc(var(--space-8) + var(--assistant-content-inset))`,读数仍 `nowrap` + 13px。
|
||||
5. T2 断言:留白表达式、`scroll-padding-block-end`。
|
||||
6. 浮层未动断言:`absolute` / `bottom: 100%` / `justify-content: center` / `pointer-events: none` / 按钮 44px,以及 `conversationAnchorThreshold = 96` 与 anchor hook 里不出现 scroll-padding。
|
||||
|
||||
文件头写明了「本套件没有 DOM 实现,所以几何断言是算术不是测量,真机走查必做」。
|
||||
|
||||
改写的既有断言 1 条 —— `frontend/tests/rectification-timeline-20260909.test.ts`「the mobile bar stays within the 44px touch rhythm」:
|
||||
|
||||
- 原值:`assert.match(mobile, /padding-inline: calc\(var\(--space-4\) \+ var\(--assistant-content-inset\)\);/)`
|
||||
- 新值:`assert.match(mobile, /padding-inline: var\(--space-4\);/)`
|
||||
- 原因:BUG-918。这条断言锁的正是造成裁切的那 44px 头像沟槽。条不在消息列里,手机上对齐头像列没有可见收益;同一测试里桌面那条 inset 断言未动,`--rectification-jump-clearance` 断言未动。
|
||||
|
||||
没有弱化其它任何断言,没有删测试。
|
||||
|
||||
## 6. 门禁数字
|
||||
|
||||
| 项 | 基线(`c32f81e7`) | 改后 |
|
||||
| --- | --- | --- |
|
||||
| `./node_modules/.bin/tsc --noEmit` | — | **0 错** |
|
||||
| `npm run lint` | — | **0 error**(116 warning,与基线同为既有 `no-unused-vars`,未顺手修) |
|
||||
| `npm test` | 3414 tests / 31 fail | **3420 tests / 31 fail**(+6 新测试全绿) |
|
||||
| 失败清单 | 31 条无 Docker 集 | **逐条相同**(排序后 `diff` 无输出) |
|
||||
| `next build --webpack` | exit 0 | **exit 0** |
|
||||
| `/` 路由 | `○ Static` | **`○ Static`** |
|
||||
| 首屏 gzip | 620,106 B / 25 文件 | **620,128 B / 25 文件(+22 B, +0.004%)** |
|
||||
|
||||
- 失败清单 31 条全部是既有无 Docker / 无凭据集(database-*、staging sync、billing、ephemeris 401 等),与 `BLOCKED.md` 一致。
|
||||
- gzip 口径:把 `.next/server/app/index.html` 里引用到的每个 `/_next/static/*.js|css` 单独 `gzip -9` 后求和。唯一变化是首屏 CSS 分块 36,510 → 36,532 B。
|
||||
- Turbopack 拒绝软链 `node_modules`,按环境说明用 `--webpack`。
|
||||
|
||||
## 7. 环境缺口
|
||||
|
||||
- **无 Chrome、无真机**:375 / 390 宽下「读数四项完整」「胶囊不压选项」只能人工确认,清单在 `docs/testing/rectification-mobile-timeline-readout-20260917.md`(7 条 + 已知边界)。第 2 节的宽度是算术,不是真机测量;第 3 节的 28px 空隙同样没有真机截图。
|
||||
- **未部署**:本分支只推自己的分支,未推 staging,`deployment.gitCommit` 未核对。
|
||||
@@ -103,8 +103,9 @@
|
||||
| `TASK-rectification-collection-redesign-20260910.md` | `PROGRESS-rectification-collection-redesign-20260910.md` | 产品口径:用户先说、系统从已说的事锚定追问、不得用生日推年份、不得说「领域」、永远交付结果。无固定题数:收集问题池按信息价值排序问到训练门开;选择题问到收敛门开或增益见底;门关不出结果只出精确缺口句并保持开放;≥4 件才留 holdout;撤回 BUG-642 年份线索、BUG-646 单终态与「暂定采用」;Skill 10.0.23(BUG-646~648) | 待验收 | `dd8f35f7` |
|
||||
| `TASK-rectification-year-focus-overlay-20260916.md` | `PROGRESS-rectification-year-focus-overlay-20260916.md` | 真机:定向健康题选 A 后打字给年月,助手追问「是你本人做的吗」,追问句下面挂着录入卡(`set-focus` 同号覆盖年月焦点题干、`event_date_entry` 仍在,BUG-908);事件入账后年月焦点没关(BUG-909,回放定 resolve 还是计划层);答短句撞「本轮达到步骤上限」(BUG-910,host 前置接无年月短句,先 investigating);persisted 问题块无 `--assistant-content-inset`(BUG-911)。**产品追加拍板:「哪一类事 + 发生年月」录入卡整个删掉,年月阶段直接打字(推翻 precision-gate 单 T3 与 SKILL.md:84)**。主体默认本人不追问,Skill 10.0.29 | 已验收 | `6aabbe38`(BUG-908~911,Skill 10.0.29):tsc 0 / lint 0 error / npm test 3414 条 31 红与基线 05cf20fb 逐条相同 / `/` Static / 首屏 gzip 620,502→620,107(−0.06%)/ 快速门 Python 段 798 绿。两条小项(host 前置排在会话校验之前、`answer.host_year_entry` 未登记)并入 dead-d9 修复单 T2 |
|
||||
| `TASK-rectification-dead-d9-choice-20260916.md` | `PROGRESS-rectification-dead-d9-choice-20260916.md` | 真机(6aabbe38):删卡流程已通,但**P0** D9 感情题没有自己的探针,`stampChoiceSchemaWithProbe` 退到最高增益探针,盖上了 **D24 学业六星座对照探针**(BUG-375 (1) 复发):答「没有」按 D24 计分,04:48–04:59 收窄不可信;GET 投影又匹配不上 → 无卡(BUG-912);打字答「没有」后平局直接出交付卡待回放定性(BUG-913);`NAKSHATRA_TRAITS` 整对塞进一个 option,卡头把相反两极列成共同点(BUG-914)。**串行在 year-focus-overlay 验收之后** | **验收未通过(P0)** | `227a7571`+`8d7dfbf0`:不借探针、一列一句、BUG-913 定性均过;但自建探针只在盖戳时注入内存 state,生产答题路径读持久化 state → `stale_probe` 报错、GET 无卡、idle 立刻 superseded(生产路径回放实证)。修复单见下一行 |
|
||||
| `TASK-rectification-dead-d9-choice-fix-20260916.md` | `PROGRESS-rectification-dead-d9-choice-fix-20260916.md` | **验收修复单**:自建分盘探针照 `withNakshatraBoundaryProbe` 模式在每个读 state 的地方(盖戳 / 答题 / `previousInferenceFromReceipt` / GET 投影 / idle 过期判定 / 计划承接)从 receipt 确定性重建(BUG-915);测试改走生产路径、删手工塞探针的 fixture;顺带 host 前置挪到会话校验之后、登记 phase(BUG-916);死卡不得配 `collect_waiting` 占位(BUG-917);**产品决策 1c**:风格题默认不作全额计分(沿用 09-09 ±1 拍板),无事件探针就走下一条线。**staging 现状:风格题答不了,先跳过**。§1d 已记 **产品决策 (b)** | 已实现待验收 | `codex/rectification-dead-d9-choice-fix-20260916`(BUG-915~917,Skill 未 bump):`withOwnedDistinguishProbes` 五处重建(盖戳 / 点选答题 / 打字答题 / GET 投影 / idle 过期;未在 `previousInferenceFromReceipt` 出口统一注入——会把自建探针喂进 contrastPacket 与 eventProbes,按让步 1 逐点包装 + 源码契约测试);按 (b) 删掉风格计分题入口,分层判别题只在有带年份事件探针时出题;死卡改走 `unavailable` 修复出口。tsc 0 / lint 0 error 116 warning / npm test 3421 条 31 红与基线 `c32f81e7` 逐条相同 / `/` `○ Static` / 产物 JS gzip 1,496,339→1,496,408(+0.005%)/ pytest 63 绿、快速门 Python 段 798 绿 |
|
||||
| `TASK-rectification-mobile-timeline-readout-20260917.md` | `PROGRESS-rectification-mobile-timeline-readout-20260917.md` | 手机截图:时间轴读数第四项被裁成「已…」(nowrap + inset 内边距,BUG-918);「跳到最新」浮层压住选项 C(BUG-919)。灰卡与「再说一件」矛盾归修复单 BUG-915/917 | 待领取 | — |
|
||||
| `TASK-rectification-dead-d9-choice-fix-20260916.md` | `PROGRESS-rectification-dead-d9-choice-fix-20260916.md` | **验收修复单**:自建分盘探针照 `withNakshatraBoundaryProbe` 模式在每个读 state 的地方(盖戳 / 答题 / `previousInferenceFromReceipt` / GET 投影 / idle 过期判定 / 计划承接)从 receipt 确定性重建(BUG-915);测试改走生产路径、删手工塞探针的 fixture;顺带 host 前置挪到会话校验之后、登记 phase(BUG-916);死卡不得配 `collect_waiting` 占位(BUG-917);**产品决策 1c**:风格题默认不作全额计分(沿用 09-09 ±1 拍板),无事件探针就走下一条线。**staging 现状:风格题答不了,先跳过**。§1d 已记 **产品决策 (b)** | 已验收 | `5113d457`(BUG-915~917,产品拍板 (b) 风格题不计分)。Claude 独立验收:生产路径测试(receipt 不含自建探针)答题 applied / GET 出卡 / idle 不误伤;合并候选门禁 tsc 0 / lint 0 error / npm test 3427 条 31 红与基线逐条相同 / `/` Static / 首屏 gzip 620,107→620,193(+0.01%)/ pytest 63 绿 / 快速门 Python 798 绿。真机清单 `docs/testing/rectification-dead-d9-choice-fix-20260916.md` |
|
||||
| `TASK-rectification-mobile-timeline-readout-20260917.md` | `PROGRESS-rectification-mobile-timeline-readout-20260917.md` | 手机截图:时间轴读数第四项被裁成「已…」(nowrap + inset 内边距,BUG-918);「跳到最新」浮层压住选项 C(BUG-919)。灰卡与「再说一件」矛盾归修复单 BUG-915/917 | 已验收 | `e8e98bbd`(合入时重放为本分支提交,BUG-918/919)。时间轴相关 71 条 + 全量同上;手机上隐藏「已对照 N 件」符合 DESIGN §10;桌面是否也移除待产品拍板。真机清单 `docs/testing/rectification-mobile-timeline-readout-20260917.md` |
|
||||
| `TASK-mobile-viewport-scroll-lock-20260917.md` | `PROGRESS-mobile-viewport-scroll-lock-20260917.md` | iPhone 上键盘收起 / 刷新后整页上移、顶栏点不到(BUG-920):iOS 不支持 `interactive-widget`,键盘弹出时 Safari 滚动 window,`html/body overflow: hidden` 让用户拉不回来,reload 又还原 `scrollY`;代码里无任何 window 级复位。补 `ViewportScrollLock`(`scrollRestoration=manual` + `visualViewport` 复位)。与今天两单无关,既有缺陷。**T3 顶栏积分块「有点扁」(BUG-921)**:两枚芯片 44px 塞在 46px 顶栏里、字号/内边距/圆角各一套、积分块强制 64px 最小宽度;统一尺寸并把手机顶栏放到 52px | 待验收 | `codex/mobile-viewport-scroll-lock-20260917` |
|
||||
|
||||
### 聊天主链路与首页
|
||||
|
||||
@@ -128,6 +129,7 @@
|
||||
| `TASK-rectification-tiebreak-card-loss-20260915.md` | `PROGRESS-rectification-tiebreak-card-loss-20260915.md` | **P0**:点卡上「再答两道参考题」交付卡消失(BUG-706);按钮亮但选项建不出变成裸题(BUG-708);旁白写「相对支持度」并与卡上入口打架(BUG-709)。卡上入口删除,出卡前收集,有活题时卡留下、采用置灰 | 待验收 | `codex/rectification-p0-20260915` |
|
||||
| `TASK-rectification-p0-fix-20260915.md` | `PROGRESS-rectification-p0-fix-20260915.md` | **验收修复单**:`f51e494c` 六条缺陷全部实现且方式正确,但 `page.tsx` 从 1951 涨到 1964 行,撞了 `chart-view-route.test.ts` 的 `<= 1951` 上限(AGENTS.md §6 增长冻结)。全量 fail 32→33,就这一条。门禁红很可能是 staging 停在 `2d7698ea`、6 个提交未部署的原因。修法是把 BUG-705 的十来行接线搬出 page.tsx,不放宽上限 | 待验收 | `codex/rectification-p0-fix-20260915` |
|
||||
| `TASK-settings-dialog-size-and-nav-20260915.md` | — | **复发单**:设置弹窗四个分区尺寸仍随内容跳变(BUG-698,复发自 BUG-554——旧防复发只查「有没有写 height」,查不到「写了没生效」);首要嫌疑是 `.settings-modal` 的 `dvh` 没有 `vh` 回退,不支持时整条 `height` 作废退化成内容高度,需先复现确认。另按产品要求去掉分区菜单左侧强调条,并拆开与悬停共用的选中态 | 待领取 | `codex/settings-dialog-size-and-nav-20260915` |
|
||||
| `TASK-consult-followup-tool-contract-20260917.md` | `PROGRESS-consult-followup-tool-contract-20260917.md` | 真机:申报时段会话连发「?」「你在说什么鬼」都 `run.failed runtime_contract_incomplete`,回执无任何 `tool` 步骤。根因是 Agent 系统指令写明「简单追问可复用已有 packet / context、不调工具」,而 `contractReady()` 要求每次请求恰好一次成功排盘调用;「已有 packet」跨请求并不存在(缓存只在单次请求内)。本命与窗口两个 Agent 同构。**产品拍板方案 1**:每轮必调工具(BUG-922 删例外句 + BUG-923 第 0 步 `toolChoice: required`);否决「没调工具就走不扣点纯对话」。第一轮正经问题为何失败留 T4 取证(回执只在 web 容器日志) | 待验收 | `codex/consult-followup-tool-contract-20260917` |
|
||||
|
||||
### 个人报告
|
||||
|
||||
@@ -235,6 +237,7 @@
|
||||
| `TASK-ephemeris-page-20260915.md` | `PROGRESS-ephemeris-page-20260915.md` | **前端单**:P1 星历页,今日五要素 + 当日行运(相对本命宫位)+ 未来九十天换座与停滞,底部「带这天去提问」出口。页面不得出现任何运势判断。含实证缺陷:panchanga 写死 Lahiri 与账户 Raman 分裂(关联 BUG-703;本单标注为 BUG-707)。侧边栏入口由 chart-page 单交付。BUG 段 707–709 | 已验收(带修复单) | `d3a2c48b` |
|
||||
| `TASK-readonly-pages-fix-20260916.md` | `PROGRESS-readonly-pages-fix-20260916.md` | 三份只读页单的验收修复:**BUG-710** 七政 `ketu_mode`/`sidereal_mode` 收了请求却从不传给引擎,`calculation.ketu_mode` 回写请求值而非实际值(实测请求 descending-node 仍返回 apogee 盘,无警告);**BUG-711** 星历单断言 sidebar 不得含 `/ephemeris`,与星盘单按任务书添加的入口直接冲突,staging 现在是红的;**BUG-712** `ephemeris_events` golden 存全精度浮点跨机不稳,且 golden 缺失时自动重建。另附部署缺口:`deployment.gitCommit` 仍是 `2d7698ea`。BUG 段 710+ | 待验收 | `codex/readonly-pages-fix-20260916` |
|
||||
| `TASK-chart-page-blocking-open-20260915.md` | `PROGRESS-chart-page-blocking-open-20260915.md` | **P1**:星盘页开一次要等很久且常常只给一句「过一会儿再打开」。实测引擎五个调用合计 0.75 秒、mapper 13 种形态零抛出——瓶颈在 `/chart` 是动态路由 + 侧栏改成硬文档跳转,整页 SSR 等完 1 串 4 并才开始画,白屏最长 45 秒(BUG-716);`postEngine` 把 429/500/超时/坏 JSON 全碾成 `null` 且零日志,两种性质相反的故障共用一句文案(BUG-715);开页并行打两个重计算限流端点(配额 2)、无缓存,且「打开即有」印在失败页上(BUG-717)。**串行在 readonly-pages-fix 之后** | 待验收 | `codex/chart-page-blocking-open-20260915` |
|
||||
| — | `PROGRESS-chart-copy-trim-20260917.md` | 产品口头:星盘页去掉「主盘直接算 · 分盘按需 · 不消耗点数」和「下面是词条式释义,不是对你个人的判断。」;星历页去掉「这一页是天象本身,不是对你的判断。」按需加载与不扣点的行为不变。跟进 BUG-717 | 待验收 | `codex/chart-copy-trim-20260917` |
|
||||
| `TASK-rectification-title-repair-migration-20260915.md` | `PROGRESS-rectification-title-repair-migration-20260916.md` | BUG-699 / 704 的数据修补写成了 Node 脚本(要 `SCHEMA_DATABASE_URL`),但 `Migrate Staging Database` 只跑 `migrator` 应用 SQL 迁移、不执行任意脚本——产品没有任何按钮能修自己那批错名字的会话。脚本里本来就是纯 SQL,搬进一次性迁移即可复用现成按钮。生产停在 `7b620c7a`(无 `use-rectification-surface.ts`),where 自然匹配 0 行,是 no-op | 待验收(`d6c359b2`,分支 `codex/rectification-title-repair-migration-20260916`):迁移 `20260916020000_rectification_session_title_repair.sql`,SQL 由 `sed` 从脚本切出、合同测试比对逐字一致;幂等两层(迁移器记账 + where 改完即不再命中);顺带给 `db-migrate.mjs` 加 NOTICE 转发,否则 node-postgres 丢掉 NOTICE、产品在日志里一个数字都看不到。tsc 0 错、lint 0 error、`npm test` 3334/fail 31 对基线 3329/fail 31(+5 全绿)。**无 Docker/无本地 PG**:`test:db` 没跑、`db:migrate:check` 缺 `SCHEMA_DATABASE_URL`,记为 BLK-002 | `codex/rectification-title-repair-migration-20260916` |
|
||||
| `TASK-staging-dispatch-autofill-sha-20260915.md` | `PROGRESS-staging-dispatch-autofill-sha-20260915.md` | `Migrate Staging Database` 每次都要手抄 40 位 SHA,而那个值恰恰是「最新一个过门禁的 staging 提交」——机器能自己算,查询代码那一步里就有。改成留空自动解析、填了仍走原路径(回滚用),三条安全属性一条不丢。**产品 2026-09-15 明确授权修改该 workflow,执行方不得以 AGENTS.md §2.7 拒改**;生产两个按钮保持手填,那是护栏不是麻烦 | 待验收 | `codex/staging-dispatch-autofill-sha-20260915` |
|
||||
| `TASK-staging-auto-migrate-on-deploy-20260915.md` | `PROGRESS-staging-auto-migrate-on-deploy-20260915.md` | 门禁通过后自动先跑 staging 迁移再部署,不再手点(迁移幂等、无挂起时是 no-op,`db-migrate.mjs --check` 挂起返 3 可用于日志)。今天 `deploy-staging.yml` 完全不提迁移,忘点就让新代码跑在旧 schema 上且无人拦。**产品再次授权改 workflow,范围限 `backend-quality-gate.yml` 的 dispatch 段**;迁移失败必须阻断部署;回滚不自动迁移;生产完全不动。⚠️ 同轮必须把「迁移须对已部署代码向后兼容、破坏性变更拆两轮」写进 AGENTS.md §7.6 | 待验收 | `codex/staging-auto-migrate-on-deploy-20260915` |
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
# 任务书 · 咨询追问轮不调排盘工具即整轮失败:提示词与运行合同对齐(2026-09-17)
|
||||
|
||||
## 0. 基线
|
||||
|
||||
- 基线 commit:`1b508d5e`(`origin/staging` head)。
|
||||
- 分支:`codex/consult-followup-tool-contract-20260917`,`git worktree add -b codex/consult-followup-tool-contract-20260917 .worktrees/consult-followup-tool-contract-20260917 origin/staging`。
|
||||
- 范围:只有前端(`frontend/src/mastra/index.ts`、`frontend/src/mastra/consultation-tools.ts`、`frontend/src/app/api/consult/route.ts` 与对应测试)。不动 Python、不动迁移、不动 Skill、不动生时校正 Agent。
|
||||
- BUG 段:**BUG-922 起**(基线 `docs/BUG_HISTORY.md` 最大号 BUG-921,开工时再核对一次)。
|
||||
|
||||
## 1. 事故实证
|
||||
|
||||
产品负责人 2026-09-17 在 staging 一个申报出生时段(`declared_birth_window`)会话里连发三轮,三轮都没有得到回答。第三轮的事件流原样如下(已去掉会话与用户标识):
|
||||
|
||||
| 事件 | 内容 |
|
||||
| --- | --- |
|
||||
| 请求 | `consultationMode: declared_birth_window`,`theme: general`,`question: 你在说什么鬼`;服务端历史只有两条用户消息「请帮我看看我家庭关系的整体模式和特点」「?」,**没有任何助手消息** |
|
||||
| `skill.started` / `skill.completed` | `jyotish-vedic-astrology` |
|
||||
| `activity` | `phase: loading-method`,「正在补齐方法与计算步骤」 |
|
||||
| `run.failed` | `code: runtime_contract_incomplete`,「Agent 未完成必要的方法与计算步骤,本次不会扣点。」 |
|
||||
| 回执 `steps` | 只有两步:`skill` 与 `validation: runtime-contract-retry`,**没有任何 `tool` 步骤** |
|
||||
| 回执 `workflow` | `route: declared-birth-window`,`status: blocked`,`techniqueTruth: declared-window` |
|
||||
|
||||
代码定位(按符号):
|
||||
|
||||
- 门禁:`frontend/src/lib/stream-agent-response.ts` 的 `contractReady()`。`requireTool` 为真时要求 `consultationToolCompleted && consultationToolSuccessCount === 1`。第一轮不满足则调用 `options.retry()` 再跑一轮,仍不满足即 `throw new Error("runtime_contract_incomplete")`,模型已写出的文字全部丢弃(`consumeAttempt` 在契约未绿时不释放文本,见 BUG-286 与测试「text written before the contract completes is dropped」)。
|
||||
- 申报时段路线:`frontend/src/app/api/consult/route.ts` 中 `shouldRunDeclaredWindowWorkflow` 分支调用 `streamAgentResponse({ requireTool: true, retry, ... })`,`retry` 的追问原文是「运行合同不完整:本次尚未取得声明窗口计算结果。请调用 run-jyotish-window-consultation 完成计算,再据此回答」。
|
||||
- 窗口 Agent 系统指令:`frontend/src/mastra/index.ts` 的 `windowJyotishInstructions`,含这一句:**「For questions that need personal chart structure, call run-jyotish-window-consultation before answering. Simple conversational follow-ups may use the existing packet.」**
|
||||
- 本命 Agent 系统指令:同文件 `jyotishInstructions`(第 17 行起,含 `natalSpokenReportContract` 之后的第 6 行)含同构的一句:**「For questions that require a new chart claim, call run-jyotish-consultation before answering. Simple conversational follow-ups may use the existing context.」**
|
||||
- 工具选择:两条路线都是 `toolChoice: "auto"`。本命路线 `consultationNatalPrepareStep`(`frontend/src/mastra/consultation-tools.ts`)在第 0 步只把 `activeTools` 收窄到排盘工具,但没有强制调用;申报时段路线用的是不带 `prepareStep` 的 `streamOptions`。
|
||||
- 历史为什么只剩用户消息:用户消息在扣点预留 RPC 里就写库(`route.ts` 的 `p_question_message: { role: "user", text: visibleQuestion }`),运行失败不补助手消息。所以前两轮「家庭关系」「?」也都失败了。
|
||||
|
||||
## 2. 根因
|
||||
|
||||
**系统提示词与运行合同互相矛盾。** 提示词明确允许模型对「简单的对话式追问」不调排盘工具、复用「已有 packet / context」;服务端合同却要求每一次请求都必须恰好一次成功的排盘工具调用,否则整轮作废。像「?」「你在说什么鬼」这类追问,模型按提示词判断为对话式追问、直接用文字回答,门禁判定合同未完成;服务端追问一句后模型仍按同一套系统指令行事,第二轮也不调工具,于是 `runtime_contract_incomplete`。
|
||||
|
||||
提示词里的「existing packet / existing context」还是**事实错误**:排盘结果只在单次请求内缓存(`createWindowConsultationTools` / `createConsultationTools` 的闭包变量 `calculation`),跨请求根本没有可复用的 packet。模型信了这句话就会跳过工具。
|
||||
|
||||
这两句是 2026-08-21 提交 `9958e00a` 写进去的,门禁是 2026-08-17 提交 `7886629b` 定的,两者从未对齐。Bug 历史里 `runtime_contract_incomplete` 的既有记录(BUG-205 缓存拒绝、BUG-214 失败次数计入门禁、BUG-286 契约未绿文本丢弃)都不是这个原因,防复发措施均仍在,本单是新问题,不是复发。
|
||||
|
||||
第一轮「请帮我看看我家庭关系的整体模式和特点」是正经解盘问题,它为什么失败**本单不下结论**,见 T4。
|
||||
|
||||
## 3. 决策记录
|
||||
|
||||
- 产品负责人 2026-09-17 拍板 **方案 1**:删掉「简单追问可以不调工具」的例外,**每一轮回答前都必须调用排盘工具**。理由:工具在单次请求内有缓存、代价小;每轮都有当轮证据,符合「解盘必须有原始数据」红线。
|
||||
- **否决方案 2**(保留提示词、让没调工具的轮次走不扣点的纯对话回复):追问轮会没有星盘证据,与 AGENTS.md Part B 的证据要求冲突。
|
||||
- 不推翻任何既有红线:`contractReady()` 的「恰好一次成功调用」口径不放宽;BUG-214 允许失败尝试后重试成功的语义保持;BUG-286 契约未绿即丢弃文本的语义保持。
|
||||
- 生时校正 Agent(`agentic-rectification.ts`)不在本单范围,它有自己的合同。
|
||||
|
||||
## 4. 硬红线
|
||||
|
||||
1. 不改 `contractReady()`;不改 `retry` / `retryForAnswer` 的触发条件;不给失败轮扣点。
|
||||
2. `page.tsx` 与 `jyotish_api_server.py` 不动(AGENTS.md §6)。
|
||||
3. 既有测试断言不得静默弱化;改任何断言写「原值 / 新值 / 原因」三栏。
|
||||
4. 不顺手改 Skill 文件、不 bump Skill 版本(本单只改 Agent 系统指令与 SDK 调用参数)。
|
||||
5. Bug 历史与进度记录不得写入会话 ID、用户 ID、出生资料。
|
||||
|
||||
## 5. 任务分解
|
||||
|
||||
### T1 删掉两处「简单追问可跳过工具」例外(BUG-922)
|
||||
|
||||
- `windowJyotishInstructions`:把「For questions that need personal chart structure, call run-jyotish-window-consultation before answering. Simple conversational follow-ups may use the existing packet.」改为「Call run-jyotish-window-consultation before answering **every** turn, including short follow-ups, clarifications, and complaints; the packet is request-scoped and is never carried over from an earlier turn.」
|
||||
- `jyotishInstructions`:同样改「For questions that require a new chart claim… Simple conversational follow-ups may use the existing context.」为「Call run-jyotish-consultation before answering every turn, including short follow-ups; the calculation is request-scoped and is never carried over from an earlier turn.」
|
||||
- 全文再 grep 一次 `follow-ups may use`,确认 `frontend/src` 无残留。
|
||||
- 验收:
|
||||
- 在 `frontend/tests/consultation-birth-time-mode.test.ts`(它已经读窗口 Agent)或 `consultation-agentic-runtime.test.ts` 加契约:两段指令 `doesNotMatch(/follow-ups may use the existing/)` 且 `match(/before answering every turn/)`。
|
||||
- 既有「personal Agent exposes the Jyotish Skill and named server tool」等测试原样通过。
|
||||
|
||||
### T2 第 0 步强制调用排盘工具(BUG-923)
|
||||
|
||||
提示词只是软约束,门禁是硬约束,中间应该有一层确定性的 SDK 参数把二者钉在一起。
|
||||
|
||||
- `consultationNatalPrepareStep`:第 0 步在 `activeTools: [CONSULTATION_NATAL_CALC_TOOL_ID]` 之外把 `toolChoice` 改为 `"required"`;后续步骤保持 `"auto"`。
|
||||
- 申报时段路线:新增 `consultationWindowPrepareStep`(同文件、同形状:第 0 步 `activeTools: ["run-jyotish-window-consultation"]` + `toolChoice: "required"`),并在 `route.ts` 的申报时段分支把 `streamWithOverflowRetry(agent)` 与 `retry` 改为使用带该 `prepareStep` 的选项对象(照本命分支 `natalStreamOptions` 的写法建 `windowStreamOptions`)。
|
||||
- `retryForAnswer` / `continueAfterLength` / `composeSection` 不带强制(它们是在合同已绿之后跑的,本命分支的 `composeSection` 明确 `toolChoice: "none"`,不动)。
|
||||
- 开工先确认本仓 Mastra / AI SDK 版本的 `prepareStep` 接受 `toolChoice: "required"`(查 `node_modules/@mastra/core` 与 `ai` 的类型定义,写进进度记录)。
|
||||
- 验收:
|
||||
- `consultation-agentic-runtime.test.ts` 既有「natal first step exposes only the chart calculation tool」扩成断言第 0 步 `toolChoice === "required"`、第 1 步 `"auto"`;为窗口 `prepareStep` 加对称的一条。
|
||||
- 加一条 `route.ts` 源码契约:申报时段分支的 `agent.stream(` 调用与 `retry` 使用带 `prepareStep` 的选项(对齐 `consultation-stream-recovery.test.ts` 那类结构断言的写法)。
|
||||
- 已有的「a calculation that succeeds only after failed attempts still satisfies the contract」「text written before the contract completes is dropped」原样绿。
|
||||
|
||||
### T3 记录
|
||||
|
||||
- `docs/BUG_HISTORY.md` 新增 BUG-922、BUG-923:现象、触发条件(申报时段 / 本命模式下发短追问)、根因(提示词例外 vs 合同)、修复、验证、防复发(契约测试 + 第 0 步 required)、关联 BUG-205 / 214 / 286。
|
||||
- `CHANGELOG.md` 一条:追问、反问、短句也会先重新取回本轮星盘证据再回答,不再整轮失败。
|
||||
- `docs/tasks/PROGRESS-consult-followup-tool-contract-20260917.md`:测试数字、失败清单与基线逐条比对、gzip 前后、`prepareStep` 类型核对结果。
|
||||
|
||||
### T4 第一轮失败原因取证(不占 BUG 号,写进进度记录)
|
||||
|
||||
第一轮是正经解盘问题,不能用 T1 的根因解释。回执只写到 web 容器控制台(`frontend/src/lib/agent-observability.ts` 的 `console.info("[agent-observability]", …)`),不进数据库。执行方**做不了**(无 VPS 登录态),把下面的取证方法原样写进进度记录,留给产品负责人或部署侧:
|
||||
|
||||
```bash
|
||||
# 在 VPS 上,以 deploy 用户
|
||||
cd /opt/jyotisha-production
|
||||
docker compose -f deploy/docker-compose.server.yml logs web --since 2026-09-17T00:00:00 \
|
||||
| grep agent-observability | grep '<该会话的 sessionId>' | head
|
||||
```
|
||||
|
||||
三条 `run.failed` 各自的 `steps` 若都没有 `tool` 步骤,即同一根因、本单覆盖;若第一轮有 `tool` 步骤且 `status: failed`,另立单,不得并进本单。
|
||||
|
||||
## 6. 让步顺序
|
||||
|
||||
1. T2 若本仓 SDK 版本的 `prepareStep` 不接受 `toolChoice: "required"`(类型或运行时报错),退而只做 T1,并在进度记录与 `BLOCKED.md` 写明版本与报错原文;不得顺手升级依赖。
|
||||
2. T2 若 `required` 让本命第 0 步在 `generalDailyContext`(首页今日)路径误触发(那条路径 `requireTool: false`,用的是 `getGeneralJyotishAgent`,理论上不经过 `natalStreamOptions`),先用测试证明,再决定是否只对申报时段路线加 required。
|
||||
3. T1、T3 不可让步。
|
||||
|
||||
## 7. 开工前置命令
|
||||
|
||||
```bash
|
||||
git fetch origin --prune
|
||||
git worktree add -b codex/consult-followup-tool-contract-20260917 .worktrees/consult-followup-tool-contract-20260917 origin/staging
|
||||
cd .worktrees/consult-followup-tool-contract-20260917/frontend
|
||||
./node_modules/.bin/tsc --noEmit && npm run lint && npm test 2>&1 | tail -20 # 记下基线总数与失败清单
|
||||
grep -o "BUG-[0-9]\+" ../docs/BUG_HISTORY.md | sort -t- -k2 -n | tail -1 # 应为 BUG-921
|
||||
grep -rn "follow-ups may use" src # 应恰好 2 处
|
||||
```
|
||||
|
||||
验收口径:`tsc` 0 错;lint 0 error;`npm test` 失败清单与基线逐条一致(无 Docker 的那组不算新增)且总数不减;`next build` 后 `/` 仍 Static;首屏 gzip ±2%(本单不动客户端 bundle,应为 0 变化)。
|
||||
|
||||
## 8. 环境缺口(留给产品负责人真机)
|
||||
|
||||
部署后(先核对 `/api/health` 的 `deployment.gitCommit` 等于本单合入 staging 的 SHA)在申报时段模式的会话里:
|
||||
|
||||
1. 先问一个正经问题(例如「看看我事业上的整体特点」),应正常出回答。
|
||||
2. 紧接着发「?」,再发「你在说什么鬼」。两轮都应先出现「正在比较声明出生窗口内的稳定层」再出回答,**不得**出现「Agent 未完成必要的方法与计算步骤」。
|
||||
3. 换到本命模式(有出生分钟)的会话重复第 2 步,同样不得整轮失败。
|
||||
4. 同一会话刷新页面后,历史里应能看到助手回复,不再是只有用户消息。
|
||||
@@ -0,0 +1,62 @@
|
||||
# 任务书 · iPhone 上键盘收起 / 刷新后整页上移,顶栏点不到(2026-09-17)
|
||||
|
||||
## 0. 基线
|
||||
|
||||
- 基线 commit:`73d3c99c`(staging 已部署)。分支:`codex/mobile-viewport-scroll-lock-20260917`。
|
||||
- 纯前端;不动 `page.tsx`(新逻辑放 `frontend/src/components/` 的一个客户端组件,从 `layout.tsx` 挂载)。
|
||||
- BUG 段:**BUG-920 起**(基线最大 BUG-919)。
|
||||
|
||||
## 1. 事故实证(产品负责人 2026-09-17 09:34 iPhone Safari 截图,staging `73d3c99c`)
|
||||
|
||||
- 刷新校正页后,顶栏(侧栏按钮 / 「9月17日 · 生时校正」/ 当前盘面 / 点数)和时间轴都不在屏幕里,第一条可见内容是消息正文;输入框下面空出约 260px 的白底,浏览器工具栏之上什么都没有。产品原话:「点不到 tab 栏,没找到入口,整体上移了」。
|
||||
- 空白高度 ≈ 整页上移的距离:整个应用壳被 `window` 级滚动推上去了,不是壳内某个容器的问题。
|
||||
|
||||
## 2. 根因(代码可证部分 + iOS 行为推断,执行方需真机确认)
|
||||
|
||||
1. `globals.css:322` `html, body { height: 100%; overflow: hidden; }`,应用壳靠 `.chat-app` / `.chat-panel` 的 `height: 100%` 撑满;**没有任何代码处理 `window.scrollY`**:`git grep` `visualViewport` / `scrollRestoration` / `window.scrollTo` 在 `frontend/src` 0 命中(`use-billing-panel.ts:195` 的 `scrollIntoView` 是容器内滚动)。
|
||||
2. iOS Safari 不支持 `viewport` 的 `interactive-widget=resizes-content`(`layout.tsx:26` 只对 Chrome Android 生效)。iOS 上键盘弹出时**布局视口不变、Safari 直接滚动 window** 让聚焦的输入框露出来;`overflow: hidden` 挡不住这次程序滚动,却挡住了用户手指把页面拉回来。键盘收起后 `window.scrollY` 留在原地 → 顶栏在屏幕上方、底部空白。
|
||||
3. 刷新不能自愈:Safari 对同 URL 的 reload 默认恢复滚动位置(`history.scrollRestoration === "auto"`),`scrollY` 被原样还原,所以「刷新想测试」看到的还是上移后的页面。
|
||||
4. 与今天上线的两单无关:BUG-919 只改了 `.message-list` 底部留白和 `.conversation` 的 `scroll-padding-block-end`,都在滚动容器内部;BUG-918 只改时间轴内边距。这条是既有缺陷,今天因为在手机上连续输入才被看到。
|
||||
|
||||
## 3. 决策记录
|
||||
|
||||
- 产品:手机上任何时候顶栏都必须能点到;不接受「用户自己想办法滚回去」。
|
||||
- 不改 `html, body` 的 `overflow: hidden`(桌面与滚动锚定依赖它),只补 window 级复位。
|
||||
|
||||
## 4. 任务分解
|
||||
|
||||
### T1 window 级滚动复位(BUG-920)
|
||||
|
||||
- 新客户端组件 `frontend/src/components/viewport-scroll-lock.tsx`,在 `layout.tsx` 与 `StaleClientRecovery` 并列挂载,只在 `pointer: coarse` 或 `visualViewport` 存在时启用:
|
||||
- 挂载时 `history.scrollRestoration = "manual"`,并 `window.scrollTo(0, 0)`;
|
||||
- 监听 `window.visualViewport` 的 `resize` 与 `scroll`、`window` 的 `pageshow` / `orientationchange`、`document` 的 `focusout`:当 `visualViewport.height` 回到 ≥ `window.innerHeight - 1`(键盘已收)且 `window.scrollY > 0` 时 `window.scrollTo(0, 0)`;键盘打开期间(`visualViewport.height < innerHeight - 100`)不动,让 Safari 把输入框露出来。
|
||||
- 卸载时移除监听。不用 `setInterval`。
|
||||
- 验收:源码契约测试(挂载点、`scrollRestoration = "manual"`、监听事件集合、键盘打开期间不复位的判定);jsdom 下模拟 `visualViewport` 对象触发 resize 断言 `scrollTo(0,0)` 被调用一次;`tsc` / lint / `npm test` 失败清单同基线;`/` Static;gzip ±2%。
|
||||
|
||||
### T3 顶栏右侧两枚芯片同一套尺寸(BUG-921,产品 2026-09-17「积分块有点扁」)
|
||||
|
||||
- 实证:手机顶栏右侧「当前盘面」与积分「✦ 557」并排,积分块看起来又宽又矮。规则出处:`globals.css:885` `.credit-button` `min-height: 44px; min-width: 64px; padding: 0 var(--space-3); font-size: var(--type-caption)`(13px);`:2964-2973` 顶栏里的 `.rectification-board-peek` 同为 `min-height: 44px` 但 `padding: var(--space-2) var(--space-4)`、`strong` 用 `--type-body-sm`(14px)、圆角与边框另一套;两枚都塞在 `.chat-panel` 的 **46px** 顶栏行里(`:855-856`),上下各剩 1px,芯片没有呼吸空间,小字 + 强制 64px 最小宽度就显得扁。
|
||||
- 修法:顶栏芯片抽一个共用类(或让 `.credit-button` 与顶栏里的 `.rectification-board-peek` 共用同一组声明):同高、同圆角、同边框、同字号(14px,数字 tabular)、同内边距,`min-width` 去掉;手机(`.is-compact` / ≤767px)顶栏行从 46px 放到 52px,芯片视觉高 40px、点击热区仍 ≥44px(用透明 `::before` 扩热区或外层 padding,BUG-695 的触屏命中区规则不得退)。桌面若同样受益可一并做,写进 `DESIGN.md` 顶栏一节。
|
||||
- 验收:CSS 声明断言两枚芯片高度/字号/圆角一致、`.credit-button` 无 `min-width`;`chat-header` compact 高度断言;触屏热区契约测试(BUG-695 那条)仍绿;真机清单加一条「顶栏两枚芯片等高、不扁」。
|
||||
|
||||
### T2 记录
|
||||
|
||||
- BUG-921 同上记录。
|
||||
- BUG-920(状态按真机结果:本机无 iOS 只能写 `resolved` 附「真机待核」或 `investigating`,不得编)、CHANGELOG、`frontend/DESIGN.md` 移动端一节加一句「window 级滚动由 ViewportScrollLock 复位」、`docs/testing/mobile-viewport-scroll-lock-20260917.md`:iPhone Safari 上 (a) 点输入框弹键盘→收起→顶栏仍可点;(b) 弹键盘状态下刷新→顶栏在顶;(c) 横竖屏切换后无空白带;(d) Android Chrome 不受影响。
|
||||
|
||||
## 5. 让步顺序
|
||||
|
||||
1. 若 `focusout` 时机早于键盘收起动画导致复位被 Safari 再次覆盖,改为在 `visualViewport.resize` 上做,`focusout` 只做兜底延时 300ms 一次。
|
||||
2. T2 不可让步。
|
||||
|
||||
## 6. 开工前置命令
|
||||
|
||||
```bash
|
||||
git fetch origin --prune
|
||||
git worktree add -b codex/mobile-viewport-scroll-lock-20260917 .worktrees/mobile-viewport-scroll-lock-20260917 origin/staging
|
||||
cd .worktrees/mobile-viewport-scroll-lock-20260917/frontend && ./node_modules/.bin/tsc --noEmit && npm run lint && npm test 2>&1 | tail -20
|
||||
```
|
||||
|
||||
## 7. 环境缺口
|
||||
|
||||
- 本机无 iOS / Chrome;根因第 2、3 条是按 iOS Safari 已知行为推断,执行方与产品在真机上按 §4 T2 清单确认。产品临时绕过:键盘收起后把页面往下拉一下不会有效(overflow hidden),需**关掉标签页重新打开**(不是刷新)才能复位。
|
||||
@@ -16,11 +16,11 @@
|
||||
## 五个 Tab
|
||||
|
||||
6. 登录且出生资料齐全:点「星盘」后先看到外壳(返回、标题),不是白屏。主盘到达前可以出现「这一张盘还没拿到。」,不得出现 spinner、骨架或「正在加载」。
|
||||
7. 成功页眉标是「主盘直接算 · 分盘按需 · 不消耗点数」。失败页没有眉标。点数余额不应因打开此页而变。
|
||||
7. 成功页和失败页都没有眉标,没有「主盘直接算 · 分盘按需 · 不消耗点数」。点数余额不应因打开此页而变。
|
||||
8. Tab 顺序:星盘 / 基础信息 / 大运 / 西洋盘 / 七政四余。都能点。
|
||||
9. 星盘 Tab:D1–D30 chip 可换;盘面是北印菱形盘;右侧有行星表(星体 / 星座与度数 / 宫位 / 状态)。
|
||||
10. 桌面中宫是六行参数;把窗口收到手机宽度(≤767)后,中宫只剩岁差和交点两行,完整参数出现在盘下方。
|
||||
11. 基础信息:每颗星一张卡,含 D1、星宿与足、D9;顶部有「词条式释义,不是对你个人的判断」;没有运势句。
|
||||
11. 基础信息:每颗星一张卡,含 D1、星宿与足、D9;没有「词条式释义,不是对你个人的判断」;没有运势句。
|
||||
12. 大运:Vimshottari 与 Chara Dasha(kn_rao 变体)并列,当前段高亮。底部有「只有两条同时指向同一段时间才算证据」。不要出现 Narayana 这个名字。
|
||||
13. 西洋盘:若端点未上线,看到静态说明(回归黄道、不能换算),整页其余 Tab 仍可用。
|
||||
14. 七政四余:若端点未上线,静态说明里必须有三条——宿度自角宿起算、计都派别、庙旺未闭合。
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
|
||||
1. 登录后点侧栏「星盘」。**大约 1 秒内**能看到页面外壳:返回对话、标题「星盘」。不要整页白屏等到引擎全部算完。
|
||||
2. 主盘还没到时,等待句是「这一张盘还没拿到。」没有转圈、骨架或「正在加载」。
|
||||
3. 主盘到达后北印盘立刻可见。出生资料行出现。成功眉标是「主盘直接算 · 分盘按需 · 不消耗点数」,没有「打开即有」。
|
||||
3. 主盘到达后北印盘立刻可见。出生资料行出现。没有眉标,没有「打开即有」,没有「主盘直接算 · 分盘按需 · 不消耗点数」。
|
||||
|
||||
## 按需加载
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
|
||||
## 失败文案
|
||||
|
||||
9. 失败页没有眉标,没有「打开即有」,没有「过一会儿再打开」。
|
||||
9. 失败页没有眉标,没有「打开即有」,没有「过一会儿再打开」,也没有「不消耗点数」。
|
||||
10. 若刚好赶上引擎忙(429):「算盘的服务正忙,稍等几秒再打开就好。」稍等再开,有机会好。
|
||||
11. 其它失败:「这张盘算不出来,我们已经记录下来了。」不要暗示等一会儿就会好。
|
||||
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
2. **看:** 月相日或月宿至少有一项不同。
|
||||
3. **看:** 五要素下方有一句「这一段按 Lahiri 岁差算,和星盘页用的岁差不是同一套。」
|
||||
4. **看:** 当日行运段没有这句。
|
||||
5. **看:** 页面没有任何运势判断(没有「适合 / 不宜 / 运势」这类话)。
|
||||
5. **看:** 页面没有任何运势判断(没有「适合 / 不宜 / 运势」这类话)。底部没有「这一页是天象本身,不是对你的判断。」
|
||||
|
||||
## C. 事件列表降级形态
|
||||
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
# 真机清单 · 手机顶栏可点、芯片不扁(2026-09-17)
|
||||
|
||||
在 iPhone Safari 的 staging 上走。本机无 iOS,下列条目留给产品负责人。
|
||||
|
||||
1. 打开一段对话或生时校正,点输入框弹出键盘,再收起键盘:顶栏(侧栏按钮、标题、当前盘面、积分)仍在屏幕顶部,点得到。
|
||||
2. 键盘还开着时刷新同一 URL:顶栏仍在顶部,底部没有整页上移留下的空白带。
|
||||
3. 横屏再竖屏:没有空白带,顶栏可点。
|
||||
4. 顶栏右侧「当前盘面」与积分「✦ N」两枚芯片一样高、一样圆角,积分块不扁。
|
||||
5. Android Chrome:弹键盘、收起、刷新,顶栏行为与修之前一样,没有新的跳动。
|
||||
@@ -0,0 +1,18 @@
|
||||
# 真机清单 · 校正页移动端时间轴读数与「跳到最新」(2026-09-17)
|
||||
|
||||
在 staging 用受控账号、真机(不是桌面浏览器缩窗)走一遍生时校正。本机无 Chrome、无真机,以下全部做不了自动化,只能人工确认。不要把真实出生资料写进任何记录。
|
||||
|
||||
优先 375 CSS px(iPhone SE 2/3、13 mini),再复查 390(iPhone 12–16 基础款)。若手上只有 320 宽的老机(SE 1 代),第 2 条按「可接受被裁一点」看,见末尾说明。
|
||||
|
||||
1. **读数四项完整。** 进入校正、答到出现代表分钟之后,看上方那条时间轴第一行。应完整读出四项:`HH:MM–HH:MM`、`N 分钟`、`代表分钟 HH:MM`(或 `已采用 HH:MM`)、`已答 N 题`。**任何一项都不得出现省略号或半个字**,尤其不得再看到「已…」。
|
||||
2. **读数不换行、条不变高。** 这一行必须是一行。条的高度在整个流程里不得变化(56px),末尾不得出现第二行或把下面的刻度挤出去。横屏、放大字号(iOS 设置 → 显示与亮度 → 文字大小最大档)各看一次:这两种情况下允许尾部被裁,但**范围和代表分钟必须仍然完整可见**。
|
||||
3. **左边距与页面同源。** 读数和刻度的左起点应与页面左边距一致(约 16px),不再比正文多缩进一个头像位。桌面(宽 ≥ 768px)打开同一条校正,读数应仍然与助手正文左边线对齐,并且多出第五项「已对照 N 件」——手机上没有这一项是预期的。
|
||||
4. **读屏软件仍读到五项。** 打开 VoiceOver,聚焦到时间轴的轴本体(`role="img"`),朗读内容应包含范围、宽度、代表分钟、已答题数**和已对照件数**,即手机上看不见的那一项在朗读里仍然存在。
|
||||
5. **「跳到最新」不压选项。** 出现一张多行选项卡(判别题 A/B/C/D 或交付卡三列)之后,向上轻扫一段让「跳到最新」胶囊出现,再滚回底部。滚到底时:胶囊应消失,选项卡最后一行与输入框之间应有明显空隙(约 28px),最后一个选项**整行可点,中间也能点中**。
|
||||
6. **胶囊出现时仍能操作。** 胶囊可见(已向上滚过一段)时点它,应直接回到最新内容,且回到底之后立刻能点最后一个选项。胶囊本身仍是居中的胶囊、44px 高、有阴影——位置和出现条件本轮没有改,不应有变化。
|
||||
7. **回归面:普通对话不受影响。** 打开一段普通咨询对话(不是校正),向上滚出「跳到最新」,确认它的位置、外观和底部留白与改动前一致(本轮只改了校正面的留白)。
|
||||
|
||||
## 已知边界
|
||||
|
||||
- **320px 宽(iPhone SE 1 代 / iPhone 5s 一档)**:按逐字保守估宽,四项 + 间隙约 292px,可用约 288px,差约 4px。按系统实际字宽算是放得下的,但如果真机上「已答 N 题」尾部被裁一点,属已知边界而非回归——范围和代表分钟仍必须完整。看到就记进进度记录,不要当新 Bug 开单。
|
||||
- **「已对照 N 件」是否也该从桌面撤掉**:`frontend/DESIGN.md` §10 的禁列表说这一项归交付卡、不上条,而代码后来把它加到了条上。本轮只在手机上把它移出可见行,桌面保留原状,等产品裁决。见 `docs/BUG_HISTORY.md` BUG-918。
|
||||
+15
-9
@@ -226,7 +226,7 @@ Radii have two visual steps. Controls use 8px (`--radius-md`; `--radius-xs` and
|
||||
### Jump to latest
|
||||
|
||||
- **Structure:** one pill button (`JumpToLatestButton`) with a down arrow and the label “跳到最新”, shared by both chat surfaces.
|
||||
- **Placement:** hangs off the composer wrap's top edge (`.jump-to-latest`, absolute, `bottom: 100%`) so it never reflows the transcript or the composer.
|
||||
- **Placement:** hangs off the composer wrap's top edge (`.jump-to-latest`, absolute, `bottom: 100%`) so it never reflows the transcript or the composer. It occupies a 56px band above the composer — the 44px target plus the `space-3` the overlay holds under itself — and the transcript has to reserve that band **plus** one more `space-3` below its last item, or the last tappable row reads as covered and its centre is where the chip's only `pointer-events: auto` area sits (BUG-919). On the rectification surface that reservation is `.message-list { padding-bottom: calc(var(--rectification-jump-clearance) + var(--space-3)) }`, and `.conversation` carries the matching `scroll-padding-block-end`. Full non-overlap at every scroll position would cost `conversationAnchorThreshold` (96px) on top of the band, i.e. 152px of trailing void; that was rejected — the chip only appears once the reader has scrolled up past that threshold.
|
||||
- **Visibility:** shown when the reader is not anchored — more than 96px from the bottom after scrolling up — and hidden again once they return or press it. Sending a question re-anchors.
|
||||
- **Surface:** canvas fill, hairline border, `--shadow-elevated`; hover uses the warm card surface. No utility-class shadows.
|
||||
- **Accessibility:** a real button in document order with a visible label matching its accessible name, 44px target, and the focus ring; the icon is decorative.
|
||||
@@ -319,9 +319,11 @@ The birth-time rectification session is the consultation transcript plus a house
|
||||
|
||||
### Chat header
|
||||
|
||||
- One line, 46px. Sidebar trigger, session title, and the credit pill. It was 68px and carried a second line reading `分析对象:{盘名}`.
|
||||
- One line, 46px on desktop. Sidebar trigger, session title, and the credit pill. It was 68px and carried a second line reading `分析对象:{盘名}`.
|
||||
- The chart a session analyses is now a quiet `--color-ink-tertiary` chip beside the title (`.chat-header-chart`), and it drops out below 768px rather than wrapping the header to two rows. "分析对象" was internal vocabulary; the chart name alone carries the same fact.
|
||||
- With the composer strip gone as well, the two pieces of permanent chrome went from 68 + 148 to 46 + 124 px.
|
||||
- On viewports ≤767px the header row is 52px plus the safe-area inset. The credit pill and the rectification 「当前盘面」 chip share one size: 40px visual height, 14px tabular type, the same radius and padding, no 64px min-width. The 44px touch target is the transparent `::before` ring.
|
||||
- Window-level scroll is reset by `ViewportScrollLock` after the on-screen keyboard closes, so the header cannot be left above the screen.
|
||||
|
||||
### Navigation item
|
||||
|
||||
@@ -635,14 +637,18 @@ Admin 的 antd `<Spin>` 是独立设计系统,不在此表。
|
||||
|
||||
**二、条高固定,从首次渲染起就占位。** 这是正确性要求,不是视觉偏好:条在滚动容器外,高度一变就改 `clientHeight`,而没有任何观察者会因此重新跟随——贴底的读者会看着内容悄悄滑出视野底部且无法恢复。同理,Case 未就绪时渲染**等高的空骨架**(`data-state="pending"`),不得用 spinner、不得用「正在加载」文案、不得让条晚一步挂载。桌面 64px,767px 以下 56px;56px 等于既有的 `--rectification-jump-clearance`(44px 触控尺寸 + `space-3`),不新造常数。
|
||||
|
||||
读数和轴的水平内边距对齐助手列:桌面 `space-8 + --assistant-content-inset`,767px 以下 `space-4 + --assistant-content-inset`,与 `.message-list` 沟槽和头像让位相同。底边发丝线和背景仍拉满聊天列,不把条做成第二条滚动容器。
|
||||
读数和轴的水平内边距在桌面对齐助手列:`space-8 + --assistant-content-inset`,与 `.message-list` 沟槽和头像让位相同。**767px 以下只取 `space-4`,不加头像缩进**(BUG-918):条不在消息列里,手机上对齐头像列买不到任何可见收益,而那 44px 每侧要吃掉 375px 里的 88px,读数因此被裁。底边发丝线和背景仍拉满聊天列,不把条做成第二条滚动容器。
|
||||
|
||||
**三、条上只有两个元素**:轴本体(区间带 + 候选标记 + 刻度)与一行读数。读数四项、一行排完:
|
||||
**三、条上只有两个元素**:轴本体(区间带 + 候选标记 + 刻度)与一行读数。手机上读数四项、一行排完:
|
||||
|
||||
`05:07–05:09 · 3 分钟 · 代表分钟 05:08 · 已答 6 题`
|
||||
|
||||
**读数只增宽,不增高。** 条高仍是硬规定二的 64 / 56px,`.rectification-timeline__readout` 因此是 `flex-wrap: nowrap` + `white-space: nowrap`;真放不下时由条本身的 `overflow: hidden` 裁掉尾部,四项里「已答 N 题」排最后、最先被裁——它是背景信息,区间和代表分钟不是。
|
||||
|
||||
**一行是有宽度预算的,767px 以下必须按 375px 算,不是「应该放得下」。** BUG-918 的实证:13px 下四项 + 头像缩进已超 375px,第四项被自身 `text-overflow: ellipsis` 显示成「已…」。因此 compact 段(`@media (max-width: 767px)`,与 `.is-compact` 的 768px 断点同源)做三件事:`padding-inline` 去掉头像缩进、读数 `column-gap` 收到 `space-2`、`.rectification-timeline__dated` `display: none`。按逐字保守估宽,手机上四项 + 3×8px 间隙约 292px,375px 可用 343px、390px 可用 358px;320px 只余约 −4px,靠条自身 `overflow: hidden` 兜底。**再往条上加一项,先重算这笔预算,不是直接追加一个 span。** 组件永远无条件渲染全部 span,隐藏只在 CSS 里发生,轴的 `aria-label` 仍由全部标签拼成,读屏软件不因视口宽度丢内容。
|
||||
|
||||
**「已对照 N 件」在手机上不在可见行里,桌面上目前仍在——这是一处未裁决的口径冲突。** 下面的禁列表写明这一项归交付卡、不上条,而它后来(cfb41daf)被加到了条上且没有改本文件。BUG-918 只把它在手机上移出可见行;桌面是否也撤掉需要产品裁决,见 `docs/BUG_HISTORY.md` BUG-918。
|
||||
|
||||
**代表分钟上条是 D10(2026-09-16)的产品决定,推翻了「条上只有区间」的原设计。** 理由是窄屏:`.is-compact` 下右栏盘面整体变成默认关闭的 overlay,代表分钟此前只在 `.rectification-board__clock` 出现,于是用户最该一直看着的那个数字恰恰看不见。条上的取值必须与盘面标题行**同一次调用**——`workingRectificationTime(result)`,由 `rectification-agentic-chat.tsx` 算一次、两处共用,不允许第二条推导路径。已采用时写「已采用 HH:MM」,否则写「代表分钟 HH:MM」;**采用不是确认**,完整边界句仍只在交付 / 采用旁白里出现一次,条上不写、也不得暗示某一分钟已确认。
|
||||
|
||||
**条上不得出现收窄进度(「已从 30 分钟收到 7 分钟」)。** 开场窗口确实在线上,但没有任何投影把这个对比作为字段给出,而 VOICE.md 第 2 条要求进度数字来自服务端。前端自己减还会算错:轴是**当前**窗口且会放宽(BUG-572),相减会把一次放宽报成进度。这句话继续由服务端拼进旁白。缺的字段见 `BLOCKED.md`。
|
||||
@@ -726,7 +732,7 @@ Admin 的 antd `<Spin>` 是独立设计系统,不在此表。
|
||||
|
||||
### 结构
|
||||
|
||||
页名「星盘」与出生资料一行在 `SecondaryHeader` 的 46px 顶栏里(§ Secondary page shell)。顶栏之下第一件东西是 Tab 行,眉标「主盘直接算 · 分盘按需 · 不消耗点数」跟在 Tab 行下方作为一行静默说明。等待外壳和失败页只有原因/等待句,没有眉标。不得写「打开即有」或任何速度承诺。
|
||||
页名「星盘」与出生资料一行在 `SecondaryHeader` 的 46px 顶栏里(§ Secondary page shell)。顶栏之下第一件东西是 Tab 行。不写眉标,不写「打开即有」、不写「主盘直接算 · 分盘按需 · 不消耗点数」,也不承诺打开速度。等待外壳和失败页只有原因/等待句。
|
||||
|
||||
五个 Tab 按体系分:**星盘 / 基础信息 / 大运 / 西洋盘 / 七政四余**,画成**一条下划线式 Tab 行**:无边框、无卡面,当前项 2px 下划线 + 主文字色,整行一条 1px 底线。行内 `flex-wrap: nowrap` 且自身横向滚动,所以 375px 下它仍是一行,不折行、不换成第二排——底线画在滚动容器自己的 border 上,不会跟着内容滚走。五个等重的药丸按钮会读成五个可按的东西,Tab 行读成「一个控件 + 一个当前位置」。
|
||||
|
||||
@@ -756,7 +762,7 @@ Admin 的 antd `<Spin>` 是独立设计系统,不在此表。
|
||||
- 西洋盘:回归黄道,宫位 Placidus;右栏第一张是边界卡。端点未上线时 Tab 仍在,渲染静态说明,整页不失败。
|
||||
- 七政四余:宿度自角宿初度起算。必须同时出现计都派别与庙旺未闭合。端点未上线时同样只渲染静态说明。
|
||||
|
||||
「传统象征」是词条式释义,带边界句「不是对你个人的判断」,不得写成运势。大运 Tab 底部固定:「只有两条同时指向同一段时间才算证据,单轨命中要降一级来看。」Chara 的正式名称是 **Chara Dasha(kn_rao 变体)**,不要写成 Narayana。
|
||||
「传统象征」是词条,不得写成运势。页面上不另写「词条式释义,不是对你个人的判断」——行星卡本身已经是度数、星宿、D9,没有判断句。大运 Tab 底部固定:「只有两条同时指向同一段时间才算证据,单轨命中要降一级来看。」Chara 的正式名称是 **Chara Dasha(kn_rao 变体)**,不要写成 Narayana。
|
||||
|
||||
### 等待态
|
||||
|
||||
@@ -774,14 +780,14 @@ Admin 的 antd `<Spin>` 是独立设计系统,不在此表。
|
||||
|
||||
独立 route `/ephemeris`。不扣点、不调模型。页面挂在 `app/(secondary)` 共享外壳上(§ Secondary page shell):页名「星历」与「带这天去提问」在 46px 顶栏里,`.ephemeris-body` 是唯一滚动的东西。
|
||||
|
||||
- **结构:** 日期条;今日五要素;当日行运;未来九十天;收尾一句。顺序固定。
|
||||
- **结构:** 日期条;今日五要素;当日行运;未来九十天。顺序固定。页尾不另写定性句。
|
||||
- **日期条:** 居中的 `‹ 日期 ›`——日期是主语,两枚 44px 箭头按钮是它的把手。箭头字形 `aria-hidden`,可访问名仍是「前一天」「后一天」,读屏不丢方向。**「今天」只在非今日时出现**,作为日期下方一枚文字按钮;在今日时它整个不渲染,而不是渲染成一枚禁用按钮——灰掉的按钮仍要被读、仍要被 Tab 跳过。原本三个并排等宽按钮把「前一天 / 今天 / 后一天」读成三个平级动作,日期本身反而被挤出了这一组。
|
||||
- **三段同一套卡片:** 五要素 / 行运 / 未来九十天三个 `.ephemeris-section` 各是一张卡(1px 描边 + `--radius-lg` + `--color-canvas`),卡内是行。原本三段的**内容**各自画框而**段落**不画框,于是三段看起来是三种不同的东西。框归段落,段内只有行。
|
||||
- 切日期立刻换五要素和行运;没有 spinner、骨架或「正在加载」。数据未到时写静态句「这一天的五要素还没拿到。」「这一天的行运还没拿到。」
|
||||
- **五要素:** `dl` 网格保留:桌面五格一行(星期、月相日、月宿含第几足、瑜伽、迦那),格子是段落卡内的浅底块,不再各自描边。767px 以下收成五行,标签左、值右。Yoga / Karana 的吉凶只照搬引擎标签(吉 / 中 / 凶),不写成建议。卡片下固定一行:「这一段按 Lahiri 岁差算,和星盘页用的岁差不是同一套。」行运段不加这句。
|
||||
- **当日行运:** 九曜的星座、度数、相对本命宫位、顺逆。桌面表格,767px 以下每行改成带标签的堆叠。没有本命上升时,相对本命列为空,并写「还没有本命上升,相对本命这一列空着。」
|
||||
- **未来九十天:** 按日期升序只写事实(「某星 进入 某座」「某星 停滞转顺 · 度数」)。`/api/ephemeris_events` 未上线时整段静态说明:「换座和停滞还没接上。这一页其余部分不受影响。」不得让整页失败。
|
||||
- **出口:** 收尾句「这一页是天象本身,不是对你的判断。」原文不变,但不再是底部第四张卡——它是页尾一行三级色小字。一句话的卡片跟三张装数据的卡片等重是错的。主按钮「带这天去提问」在顶栏,把当前日期预填进对话草稿并回到 `/`。
|
||||
- **出口:** 主按钮「带这天去提问」在顶栏,把当前日期预填进对话草稿并回到 `/`。不写「这一页是天象本身,不是对你的判断。」
|
||||
- **等待:** 揭幕后不得再出现 spinner / 骨架 / 「正在加载」。未登录是静态「请先登录」加去登录,不是转圈。
|
||||
- **文案:** 这一页是天象本身。不得写运势结论,不得把三套坐标系换算或叠加。
|
||||
- **文案:** 不得写运势结论,不得把三套坐标系换算或叠加。不另印「这是天象本身」这类边界说明。
|
||||
|
||||
|
||||
@@ -63,7 +63,9 @@ Jyotisha 的可见文案是产品的一部分。正确性红线(真实性、
|
||||
| 吻合率低时模型追问要不要把误差放到 ±2 小时 | 按你说的经历,出生时间可能比家人记的偏得更多。放宽后再比一次? | 放宽卡由服务端出,选项写「前后半小时 / 一小时 / 两小时」,不用偏移、误差、置信度、概率。 |
|
||||
| 只知道傍晚时直接给分钟或采用卡 | 先切成三段(写起止钟点),比完再按分钟收。分不开就说「时段分不开,直接按分钟比」。 | 超过两小时的窗口先走子段,不在 4~6 小时上铺分钟卡。 |
|
||||
| 记下精确到日的事后不问日子从哪来 | 刚才那个日子是查过记录,还是凭记忆? | 只问日级、只问一次;不答就跳过。 |
|
||||
| 打开即有 / 直接计算 · 打开即有 · 不消耗点数 | 主盘直接算 · 分盘按需 · 不消耗点数 | 不承诺打开速度。失败页不写眉标。忙(429)说「算盘的服务正忙,稍等几秒再打开就好。」;其它失败说「这张盘算不出来,我们已经记录下来了。」不要说「过一会儿再打开」。 |
|
||||
| 打开即有 / 直接计算 · 打开即有 · 不消耗点数 / 主盘直接算 · 分盘按需 · 不消耗点数 | (不写) | 星盘页不写成本、速度、计费说明。失败页也不写。忙(429)说「算盘的服务正忙,稍等几秒再打开就好。」;其它失败说「这张盘算不出来,我们已经记录下来了。」不要说「过一会儿再打开」。 |
|
||||
| 下面是词条式释义,不是对你个人的判断。 | (不写) | 基础信息 Tab 只放行星卡。词条本身不得写成运势,但不另印这句边界说明。 |
|
||||
| 这一页是天象本身,不是对你的判断。 | (不写) | 星历页只放日期、五要素、行运、九十天事件。「带这天去提问」在顶栏。不另印定性句。 |
|
||||
|
||||
## 服务端探针 → Agent 题干
|
||||
|
||||
|
||||
@@ -48,6 +48,7 @@ import {
|
||||
consultationContinueGenerationSettings,
|
||||
consultationGenerationSettings,
|
||||
consultationNatalPrepareStep,
|
||||
consultationWindowPrepareStep,
|
||||
consultationSliceGenerationSettings,
|
||||
createConsultationAgentContext,
|
||||
createWindowConsultationAgentContext,
|
||||
@@ -896,14 +897,18 @@ export async function POST(request: Request) {
|
||||
...streamOptions,
|
||||
prepareStep: consultationNatalPrepareStep,
|
||||
};
|
||||
const windowStreamOptions = {
|
||||
...streamOptions,
|
||||
prepareStep: consultationWindowPrepareStep,
|
||||
};
|
||||
async function streamWithOverflowRetry(
|
||||
agent: {
|
||||
stream: (
|
||||
messages: typeof baseMessages,
|
||||
options: typeof streamOptions | typeof natalStreamOptions,
|
||||
options: typeof streamOptions | typeof natalStreamOptions | typeof windowStreamOptions,
|
||||
) => Promise<{ fullStream: AsyncIterable<unknown> | ReadableStream<unknown>; totalUsage: Promise<Usage> }>;
|
||||
},
|
||||
options: typeof streamOptions | typeof natalStreamOptions = streamOptions,
|
||||
options: typeof streamOptions | typeof natalStreamOptions | typeof windowStreamOptions = streamOptions,
|
||||
) {
|
||||
try {
|
||||
const result = await agent.stream(baseMessages, options);
|
||||
@@ -1029,7 +1034,7 @@ export async function POST(request: Request) {
|
||||
state,
|
||||
});
|
||||
const agent = getWindowJyotishAgent(selectedModel, agentContext);
|
||||
const result = await streamWithOverflowRetry(agent);
|
||||
const result = await streamWithOverflowRetry(agent, windowStreamOptions);
|
||||
const retry = async () => {
|
||||
const retried = await agent.stream([
|
||||
...baseMessages,
|
||||
@@ -1037,7 +1042,7 @@ export async function POST(request: Request) {
|
||||
role: "user" as const,
|
||||
content: "运行合同不完整:本次尚未取得声明窗口计算结果。请调用 run-jyotish-window-consultation 完成计算,再据此回答;不要在工具参数中添加出生分钟。",
|
||||
},
|
||||
], streamOptions);
|
||||
], windowStreamOptions);
|
||||
usages.push(retried.totalUsage);
|
||||
return retried.fullStream;
|
||||
};
|
||||
|
||||
+103
-27
@@ -882,7 +882,51 @@ button:disabled { cursor: default; opacity: .45; }
|
||||
content: "";
|
||||
}
|
||||
.chat-header-actions button > svg { width: 14px; height: 14px; }
|
||||
.credit-button { min-height: 44px; display: inline-flex; align-items: center; justify-content: center; gap: var(--space-2); padding: 0 var(--space-3); cursor: pointer; font-size: var(--type-caption); font-variant-numeric: tabular-nums; transition: background-color 120ms ease-out, transform 120ms ease-out; min-width: 64px; border: 1px solid var(--color-border); border-radius: var(--radius-md); background: var(--color-canvas-soft); color: var(--color-ink-secondary); font-weight: 500; }
|
||||
/* Header chips share one size. `.chat-header-actions button` is 32px and
|
||||
more specific than `.credit-button`, which is why the credit pill looked
|
||||
shorter than 「当前盘面」(BUG-921). */
|
||||
.chat-header-actions .credit-button,
|
||||
.chat-header-rectification .rectification-board-peek {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 40px;
|
||||
min-height: 40px;
|
||||
padding: 0 var(--space-3);
|
||||
gap: var(--space-2);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--color-canvas-soft);
|
||||
color: var(--color-ink-secondary);
|
||||
font-size: var(--type-body-sm);
|
||||
font-weight: 500;
|
||||
font-variant-numeric: tabular-nums;
|
||||
cursor: pointer;
|
||||
transition: background-color 120ms ease-out, transform 120ms ease-out;
|
||||
}
|
||||
.chat-header-actions .credit-button::before,
|
||||
.chat-header-rectification .rectification-board-peek::before {
|
||||
position: absolute;
|
||||
inset: -2px;
|
||||
content: "";
|
||||
}
|
||||
.credit-button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--space-2);
|
||||
padding: 0 var(--space-3);
|
||||
cursor: pointer;
|
||||
font-size: var(--type-body-sm);
|
||||
font-variant-numeric: tabular-nums;
|
||||
transition: background-color 120ms ease-out, transform 120ms ease-out;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--color-canvas-soft);
|
||||
color: var(--color-ink-secondary);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.conversation { min-width: 0; min-height: 0; overflow-x: hidden; overflow-y: auto; overscroll-behavior: contain; padding-bottom: var(--composer-reserve); background: var(--color-canvas); }
|
||||
/* Empty state. The greeting is bottom-aligned inside the conversation row and
|
||||
@@ -1892,8 +1936,9 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class
|
||||
[data-sidebar="sidebar"][data-mobile-open="true"] { visibility: visible; transform: translateX(0); }
|
||||
.sidebar-scrim { position: fixed; z-index: 11; inset: 0; display: block; border: 0; background: var(--color-scrim); opacity: 1; visibility: visible; }
|
||||
[data-sidebar="rail"] { display: none; }
|
||||
.chat-panel { grid-template-rows: calc(64px + env(safe-area-inset-top)) minmax(0, 1fr) auto; }
|
||||
.chat-panel.is-rectification { grid-template-rows: calc(64px + env(safe-area-inset-top)) minmax(0, 1fr); }
|
||||
.chat-panel { grid-template-rows: calc(52px + env(safe-area-inset-top)) minmax(0, 1fr) auto; }
|
||||
.chat-panel.is-rectification { grid-template-rows: calc(52px + env(safe-area-inset-top)) minmax(0, 1fr); }
|
||||
.chat-panel:has(.starter-balance) { grid-template-rows: calc(52px + env(safe-area-inset-top)) minmax(0, 1fr) auto minmax(0, 1fr); }
|
||||
.chat-header { gap: var(--space-3); padding: max(0px, env(safe-area-inset-top)) var(--space-3) 0; }
|
||||
.conversation.is-empty {
|
||||
display: block;
|
||||
@@ -1985,12 +2030,6 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class
|
||||
overscroll-behavior: contain;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
.chart-page-eyebrow {
|
||||
width: min(1100px, 100%);
|
||||
margin: 0 auto var(--space-6);
|
||||
color: var(--color-ink-tertiary);
|
||||
font-size: var(--type-caption);
|
||||
}
|
||||
.chart-page-body,
|
||||
.ephemeris-body,
|
||||
.report-center-body,
|
||||
@@ -2538,9 +2577,17 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class
|
||||
}
|
||||
/* One line, always. The bar's height is fixed on purpose (see above), so the
|
||||
readout may grow wider but never taller; wrapping would push the axis out of
|
||||
a box nothing is observing. Four short items clear 375px with room to spare,
|
||||
and the bar's own `overflow: hidden` clips rather than reflows if they ever
|
||||
do not. */
|
||||
a box nothing is observing. The bar's own `overflow: hidden` clips rather
|
||||
than reflows if the row ever does not fit.
|
||||
|
||||
This used to claim "four short items clear 375px with room to spare". They
|
||||
did not even at four, and a fifth was added later (BUG-918). At 13px, with
|
||||
the avatar-column inset, a 375px phone left 255px of content box: the four
|
||||
items needed about 304px, the five about 383px. So `已答 N 题` came through
|
||||
as `已…` and `已对照 N 件` never appeared at all. The compact block below is
|
||||
what makes the row fit — it drops the inset and the fifth item there, for
|
||||
about 292px against 343px. Anything added here has to be re-measured against
|
||||
that budget, not assumed to fit. */
|
||||
.rectification-timeline__readout {
|
||||
margin: 0;
|
||||
display: flex;
|
||||
@@ -2656,17 +2703,56 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class
|
||||
.rectification-workspace__chat {
|
||||
--rectification-timeline-height: 56px;
|
||||
}
|
||||
/* No `--assistant-content-inset` here (BUG-918). The bar is not in the
|
||||
message list, so on a phone the 44px avatar gutter buys no alignment the
|
||||
reader can see; it cost 88px of the 375px the readout has to fit into and
|
||||
the row got clipped instead. The page margin is the honest edge on a
|
||||
narrow viewport, and the axis reads wider for it. */
|
||||
.rectification-timeline {
|
||||
padding-inline: calc(var(--space-4) + var(--assistant-content-inset));
|
||||
padding-inline: var(--space-4);
|
||||
}
|
||||
/* Tighter column rhythm buys another 12px across three gaps; the row is a
|
||||
list of short figures, not prose, so space-2 still separates them. */
|
||||
.rectification-timeline__readout {
|
||||
column-gap: var(--space-2);
|
||||
}
|
||||
/* `已对照 N 件` is the one item DESIGN.md §10 never wanted on the bar — it
|
||||
belongs to the delivery card, and it is a different quantity from
|
||||
`已答 N 题` (events matched, not questions answered). It is the fifth item
|
||||
in a row budgeted for four, so on a phone it leaves the visual row. It
|
||||
stays in the DOM and in the axis `aria-label`, so assistive tech still
|
||||
reads it, and the range plus the working minute — the two figures the
|
||||
product requires to be visible at all times — never shorten. */
|
||||
.rectification-timeline__dated {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
/* `--rectification-jump-clearance` is the band the floating 跳到最新 chip
|
||||
occupies above the composer: its 44px touch target plus the `space-3` the
|
||||
overlay holds under itself. The transcript reserves that band **plus another
|
||||
`space-3`** (BUG-919). Reserving exactly the band left the last tappable
|
||||
option flush against the chip, so a choice card's final row read as covered
|
||||
and its centre — the only part of the row the chip's `pointer-events: auto`
|
||||
button actually intercepts — was hard to hit.
|
||||
|
||||
With `.conversation`'s own `space-4` the tail now keeps 84px of clear space:
|
||||
56px of chip band and 28px of air. Eliminating overlap in *every* scroll
|
||||
position would need `conversationAnchorThreshold` (96px) on top of the band,
|
||||
i.e. 152px of trailing void under the last message at rest — a whole option
|
||||
row of dead space on a 667px phone, and the chip only shows once the reader
|
||||
has scrolled up past that threshold anyway. The chip's own placement and
|
||||
visibility rule are unchanged (BUG-478 owns them). */
|
||||
.rectification-workspace__chat .conversation {
|
||||
--rectification-jump-clearance: calc(44px + var(--space-3));
|
||||
padding-bottom: var(--space-4);
|
||||
/* Anything scrolled into view lands above the chip band too. This is inert
|
||||
for `use-conversation-scroll-anchor`, which assigns `scrollTop` directly
|
||||
rather than calling a scroll-into-view API. */
|
||||
scroll-padding-block-end: var(--rectification-jump-clearance);
|
||||
}
|
||||
.rectification-workspace__chat .message-list {
|
||||
width: 100%;
|
||||
padding-bottom: var(--rectification-jump-clearance);
|
||||
padding-bottom: calc(var(--rectification-jump-clearance) + var(--space-3));
|
||||
}
|
||||
.conversation.is-rectification .message-actions {
|
||||
margin-inline-start: var(--assistant-content-inset);
|
||||
@@ -2915,15 +3001,10 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class
|
||||
display: none;
|
||||
}
|
||||
.chat-header-rectification .rectification-board-peek {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
width: auto;
|
||||
max-width: min(11.5rem, 42vw);
|
||||
min-height: 44px;
|
||||
margin: 0;
|
||||
padding: 0 var(--space-3);
|
||||
gap: var(--space-2);
|
||||
}
|
||||
.chat-header-rectification .rectification-board-peek strong,
|
||||
.chat-header-rectification .rectification-board-peek span {
|
||||
@@ -2932,6 +3013,8 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-size: inherit;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
@media (max-width: 480px) {
|
||||
.chat-header-rectification .rectification-board-peek span {
|
||||
@@ -4762,8 +4845,7 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class
|
||||
/* ============================================================
|
||||
Ephemeris page — facts only, own scroll (html/body overflow hidden)
|
||||
============================================================ */.ephemeris-hero,
|
||||
.ephemeris-section,
|
||||
.ephemeris-outro{
|
||||
.ephemeris-section {
|
||||
width: min(900px, 100%);
|
||||
margin-inline: auto;
|
||||
}
|
||||
@@ -4915,12 +4997,6 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class
|
||||
color: var(--color-ink-tertiary);
|
||||
font-size: var(--type-caption);
|
||||
}
|
||||
.ephemeris-outro {
|
||||
margin: 0;
|
||||
padding-bottom: var(--space-4);
|
||||
color: var(--color-ink-tertiary);
|
||||
font-size: var(--type-body-sm);
|
||||
}
|
||||
.ephemeris-ask { min-height: 44px; width: fit-content; }
|
||||
.ephemeris-message {
|
||||
display: flex;
|
||||
|
||||
@@ -3,6 +3,7 @@ import localFont from "next/font/local";
|
||||
import Script from "next/script";
|
||||
import { Toaster } from "@/components/ui/sonner";
|
||||
import { StaleClientRecovery } from "@/components/stale-client-recovery";
|
||||
import { ViewportScrollLock } from "@/components/viewport-scroll-lock";
|
||||
import { themePreferenceBootScript } from "@/lib/theme-preference";
|
||||
|
||||
// Vendored Inter latin variable (OFL). Do not switch back to next/font/google;
|
||||
@@ -53,6 +54,7 @@ export default function RootLayout({ children }: Readonly<{ children: React.Reac
|
||||
</head>
|
||||
<body>
|
||||
<StaleClientRecovery />
|
||||
<ViewportScrollLock />
|
||||
{children}
|
||||
<Toaster />
|
||||
</body>
|
||||
|
||||
@@ -4,7 +4,6 @@ import { formatDegree } from "./chart-format";
|
||||
export function ChartBasicsTab({ view }: { view: ChartViewOk }) {
|
||||
return (
|
||||
<div className="chart-page-basics">
|
||||
<p className="chart-page-boundary">{view.vedic.symbolBoundary}</p>
|
||||
<div className="chart-page-basics-grid">
|
||||
{view.vedic.planets.map((planet) => (
|
||||
<article key={planet.name} className="chart-page-planet-card">
|
||||
|
||||
@@ -89,11 +89,6 @@ export function ChartPageView({
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{/* BUG-717 copy: a cost and speed promise that also prints on the
|
||||
failure page. It used to sit in the hero as an eyebrow; the hero is
|
||||
gone, the promise is not. It reads under the tab row now, because
|
||||
the tab row is what the header hands off to. */}
|
||||
<p className="chart-page-eyebrow">{CHART_VIEW_COPY.eyebrow}</p>
|
||||
<section className="chart-page-panel" role="tabpanel">
|
||||
{tab === "vedic" ? (
|
||||
<ChartVedicTab
|
||||
|
||||
@@ -272,11 +272,6 @@ export function EphemerisView(props: {
|
||||
</ol>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* The closing note used to be a fourth boxed card competing with the
|
||||
three that carry data. The sentence is unchanged; it is a quiet line
|
||||
under the page now, and the 「带这天去提问」 action lives in the header. */}
|
||||
<p className="ephemeris-outro">{EPHEMERIS_COPY.footerNote}</p>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -24,6 +24,13 @@
|
||||
* must never change — see `.rectification-timeline` in globals.css. The readout
|
||||
* stays one line for that reason: it grew wider, never taller. An unready case
|
||||
* renders the same-height skeleton rather than nothing.
|
||||
*
|
||||
* One line has a width budget, and on a 375px phone it is spent by the fourth
|
||||
* item (BUG-918). Every span below is always rendered; globals.css decides how
|
||||
* many of them a narrow viewport shows, and it hides `__dated` there. The axis
|
||||
* `aria-label` below is assembled from all five labels regardless, so nothing a
|
||||
* screen reader gets depends on the viewport. Adding a sixth item means
|
||||
* re-measuring that budget, not appending a span.
|
||||
*/
|
||||
|
||||
"use client";
|
||||
@@ -46,6 +53,8 @@ export function RectificationTimeline({ view }: { view: RectificationTimelineVie
|
||||
{view.answeredLabel ? (
|
||||
<span className="rectification-timeline__answered">{view.answeredLabel}</span>
|
||||
) : null}
|
||||
{/* Hidden below 768px by globals.css, never omitted here: the label is
|
||||
part of the accessible axis description either way. */}
|
||||
{view.datedEventLabel ? (
|
||||
<span className="rectification-timeline__dated">{view.datedEventLabel}</span>
|
||||
) : null}
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
|
||||
import { installViewportScrollLock } from "@/lib/viewport-scroll-lock";
|
||||
|
||||
export function ViewportScrollLock() {
|
||||
useEffect(() => {
|
||||
return installViewportScrollLock(window as unknown as Parameters<typeof installViewportScrollLock>[0]);
|
||||
}, []);
|
||||
return null;
|
||||
}
|
||||
@@ -165,7 +165,6 @@ export const chartViewOkSchema = z.object({
|
||||
boundary: z.string().min(1),
|
||||
vargas: z.array(chartViewVargaSchema).min(1),
|
||||
planets: z.array(chartViewPlanetRowSchema).min(1),
|
||||
symbolBoundary: z.string().min(1),
|
||||
}),
|
||||
dasha: z.object({
|
||||
vimshottari: chartViewDashaTrackSchema,
|
||||
|
||||
@@ -140,8 +140,6 @@ export const ANTARDASHA_DERIVED_NOTE = "分运按标准比例从大运边界推
|
||||
|
||||
export const CHARA_ANTARDASHA_DATE_NOTE = "分运时长来自引擎的十二等分;起止日期按大运边界推算,不是引擎单独返回的日历字段。";
|
||||
|
||||
export const SYMBOL_BOUNDARY = "下面是词条式释义,不是对你个人的判断。";
|
||||
|
||||
export const WESTERN_UNAVAILABLE_COPY = "西洋盘这一栏要等回归黄道端点上线。现在还没有,先空着——不会拿印度盘的度数改写成回归黄道。";
|
||||
|
||||
export const QIZHENG_UNAVAILABLE_COPY = "七政四余这一栏要等宿度端点上线。现在还没有,先空着。";
|
||||
@@ -153,7 +151,6 @@ export const QIZHENG_RULES = [
|
||||
] as const;
|
||||
|
||||
export const CHART_VIEW_COPY = {
|
||||
eyebrow: "主盘直接算 · 分盘按需 · 不消耗点数",
|
||||
busy: "算盘的服务正忙,稍等几秒再打开就好。",
|
||||
unavailable: "这张盘算不出来,我们已经记录下来了。",
|
||||
waitingChart: "这一张盘还没拿到。",
|
||||
|
||||
@@ -10,7 +10,6 @@ import {
|
||||
PLANET_ORDER,
|
||||
QIZHENG_UNAVAILABLE_COPY,
|
||||
SIGN_ORDER,
|
||||
SYMBOL_BOUNDARY,
|
||||
VARGA_CHIP_ORDER,
|
||||
VIMSHOTTARI_ORDER,
|
||||
VIMSHOTTARI_YEARS,
|
||||
@@ -653,7 +652,6 @@ export function buildChartView(bundle: ChartViewEngineBundle): ChartViewOk {
|
||||
boundary: COORDINATE_BOUNDARY.vedic,
|
||||
vargas,
|
||||
planets: planetRows(chart, varga),
|
||||
symbolBoundary: SYMBOL_BOUNDARY,
|
||||
},
|
||||
dasha: {
|
||||
vimshottari,
|
||||
|
||||
@@ -20,7 +20,6 @@ export const EPHEMERIS_COPY = {
|
||||
natalMissing: "还没有本命上升,相对本命这一列空着。",
|
||||
eventsHeading: "未来九十天",
|
||||
eventsUnavailable: "换座和停滞还没接上。这一页其余部分不受影响。",
|
||||
footerNote: "这一页是天象本身,不是对你的判断。",
|
||||
ask: "带这天去提问",
|
||||
loginTitle: "请先登录",
|
||||
loginBody: "登录后即可查看星历。",
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
/**
|
||||
* iOS Safari scrolls `window` to keep a focused input in view. `html, body
|
||||
* { overflow: hidden }` then blocks the user from dragging back, and reload
|
||||
* restores that offset. Reset window scroll only after the keyboard is gone.
|
||||
*/
|
||||
|
||||
export const KEYBOARD_OPEN_GAP_PX = 100;
|
||||
export const KEYBOARD_CLOSED_SLACK_PX = 1;
|
||||
|
||||
export function isKeyboardOpen(visualHeight: number, innerHeight: number): boolean {
|
||||
return visualHeight < innerHeight - KEYBOARD_OPEN_GAP_PX;
|
||||
}
|
||||
|
||||
export function shouldResetWindowScroll(input: {
|
||||
scrollY: number;
|
||||
innerHeight: number;
|
||||
visualViewportHeight: number | null;
|
||||
}): boolean {
|
||||
if (input.scrollY <= 0) return false;
|
||||
if (input.visualViewportHeight == null) return true;
|
||||
if (isKeyboardOpen(input.visualViewportHeight, input.innerHeight)) return false;
|
||||
return input.visualViewportHeight >= input.innerHeight - KEYBOARD_CLOSED_SLACK_PX;
|
||||
}
|
||||
|
||||
type ViewportLike = {
|
||||
height: number;
|
||||
addEventListener: (type: "resize" | "scroll", listener: () => void) => void;
|
||||
removeEventListener: (type: "resize" | "scroll", listener: () => void) => void;
|
||||
};
|
||||
|
||||
export type ViewportScrollLockHost = {
|
||||
matchMedia?: (query: string) => { matches: boolean };
|
||||
visualViewport?: ViewportLike | null;
|
||||
innerHeight: number;
|
||||
scrollY: number;
|
||||
scrollTo: (x: number, y: number) => void;
|
||||
history: { scrollRestoration: string };
|
||||
addEventListener: (type: string, listener: () => void) => void;
|
||||
removeEventListener: (type: string, listener: () => void) => void;
|
||||
document: {
|
||||
addEventListener: (type: string, listener: () => void) => void;
|
||||
removeEventListener: (type: string, listener: () => void) => void;
|
||||
};
|
||||
setTimeout?: (fn: () => void, ms: number) => unknown;
|
||||
};
|
||||
|
||||
export function installViewportScrollLock(host: ViewportScrollLockHost): () => void {
|
||||
const coarse = host.matchMedia?.("(pointer: coarse)").matches === true;
|
||||
const viewport = host.visualViewport ?? null;
|
||||
if (!coarse && !viewport) return () => undefined;
|
||||
|
||||
host.history.scrollRestoration = "manual";
|
||||
host.scrollTo(0, 0);
|
||||
|
||||
const resetIfSettled = () => {
|
||||
if (!shouldResetWindowScroll({
|
||||
scrollY: host.scrollY,
|
||||
innerHeight: host.innerHeight,
|
||||
visualViewportHeight: viewport?.height ?? null,
|
||||
})) return;
|
||||
host.scrollTo(0, 0);
|
||||
};
|
||||
|
||||
const onPageShow = () => {
|
||||
host.scrollTo(0, 0);
|
||||
};
|
||||
const onFocusOut = () => {
|
||||
const later = host.setTimeout ?? ((fn: () => void) => fn());
|
||||
later(resetIfSettled, 300);
|
||||
};
|
||||
|
||||
viewport?.addEventListener("resize", resetIfSettled);
|
||||
viewport?.addEventListener("scroll", resetIfSettled);
|
||||
host.addEventListener("pageshow", onPageShow);
|
||||
host.addEventListener("orientationchange", resetIfSettled);
|
||||
host.document.addEventListener("focusout", onFocusOut);
|
||||
|
||||
return () => {
|
||||
viewport?.removeEventListener("resize", resetIfSettled);
|
||||
viewport?.removeEventListener("scroll", resetIfSettled);
|
||||
host.removeEventListener("pageshow", onPageShow);
|
||||
host.removeEventListener("orientationchange", resetIfSettled);
|
||||
host.document.removeEventListener("focusout", onFocusOut);
|
||||
};
|
||||
}
|
||||
@@ -57,12 +57,13 @@ export const AGENT_MAX_STEPS = 8;
|
||||
export const AGENT_TIMEOUT_MS = 110_000;
|
||||
export const AGENT_SLICE_MAX_STEPS = 1;
|
||||
export const CONSULTATION_NATAL_CALC_TOOL_ID = "run-jyotish-consultation";
|
||||
export const CONSULTATION_WINDOW_CALC_TOOL_ID = "run-jyotish-window-consultation";
|
||||
|
||||
/**
|
||||
* Thinking-mode providers reject named/required tool_choice. Restrict the first
|
||||
* natal model step to the chart tool and keep tool_choice auto so the run cannot
|
||||
* spend that step on skill_read or a spoken Level 2 draft before any evidence
|
||||
* exists. Later steps leave the rest of the bound tools available.
|
||||
* Step 0 must call the natal chart tool. Mastra/AI SDK types accept
|
||||
* toolChoice "required" (`@mastra/core` Agent.stream options:
|
||||
* `'auto' | 'none' | 'required'`). Later steps stay auto so the model can
|
||||
* write after the request-scoped calculation is in hand.
|
||||
*
|
||||
* Window and general agents must not share this hook: they do not own this tool.
|
||||
*/
|
||||
@@ -70,7 +71,18 @@ export function consultationNatalPrepareStep(input: { stepNumber: number }) {
|
||||
return input.stepNumber === 0
|
||||
? {
|
||||
activeTools: [CONSULTATION_NATAL_CALC_TOOL_ID],
|
||||
toolChoice: "required" as const,
|
||||
}
|
||||
: {
|
||||
toolChoice: "auto" as const,
|
||||
};
|
||||
}
|
||||
|
||||
export function consultationWindowPrepareStep(input: { stepNumber: number }) {
|
||||
return input.stepNumber === 0
|
||||
? {
|
||||
activeTools: [CONSULTATION_WINDOW_CALC_TOOL_ID],
|
||||
toolChoice: "required" as const,
|
||||
}
|
||||
: {
|
||||
toolChoice: "auto" as const,
|
||||
|
||||
@@ -20,7 +20,7 @@ ${natalSpokenReportContract}
|
||||
Write in Simplified Chinese: a heading-free spoken opener first (one conclusion, 2–3 short point-sentences of at most 30 characters each, one next-step; ≤ 400 characters), then the skill Level 2 report skeleton for natal domain questions. Markdown tables are required for raw structure, Yogas, and the Technique Audit Table.
|
||||
${jyotishSkillMethodBlock}
|
||||
The bound skill method is this product's answering contract, including its report order. Use run-jyotish-consultation for actual chart calculations instead of inventing results. 骨架不可省略,但必须以直接回应开场. Do not replace the skeleton with spoken-only chat.
|
||||
For questions that require a new chart claim, call run-jyotish-consultation before answering. Simple conversational follow-ups may use the existing context.
|
||||
Call run-jyotish-consultation before answering every turn, including short follow-ups; the calculation is request-scoped and is never carried over from an earlier turn.
|
||||
Select consultation domains only through the single ordered domains array of run-jyotish-consultation, whether the question covers one domain or several; omit it to accept the domain the server already selected. At most ${MAX_CONSULTATION_DOMAINS} domains may be requested in one run, because they are calculated one after another inside a fixed time budget: list every domain the question actually needs, in priority order. Do not drop a relevant domain to keep the plan short—the natal compute already ran the full technique spectrum, and omitting a domain omits that route's checklist from the answer. The server canonicalizes aliases, rejects unsupported/product domains, executes each accepted domain, and returns the actual domains in the tool context and receipt. The only legal domain ids are the ones enumerated in that array's schema; the skill's methodology names strict-workflow checklists such as career-timing-strict, and those labels select techniques inside the skill, never domains for this tool. A rejected domain plan is final for this run: correct the domains once, and never re-send the same call with extra parameters.
|
||||
The tool result's methodology field is the domain checklist for the routes that actually ran, quoted from the live skill. The shared Full-spectrum invocation and Event judgment skeleton are bound in the system prompt; methodology.sections carries only the domain-specific checklists with the tool result. Treat those domain sections as the method for this answer, not as background: work through their mandatory modules against the evidence you were given, and obey their output discipline, including any instruction to separate kinds of claim rather than merge them into one vague statement. Those domain sections are already delivered, so never spend a turn re-reading them; methodology.further_reading lists the references the skill names, and you may read one with skill_read only when the question needs something the delivered sections do not cover. When methodology.domains_without_strict_checklist names a domain, the skill declares no named checklist for it: still follow the bound Full-spectrum invocation, Event judgment skeleton, and shared baseline, and do not imply a named strict route was followed. When methodology is absent, follow the bound skill method above.
|
||||
The tool result always carries one top-level answer contract—status, evidence_contract, claim_cards, rectification—even when several domains ran. For a multi-domain plan that top level is the most restrictive merge of the executed domains, so obey it exactly as written and read consultations only for per-domain detail. Never treat an absent top-level field as permission to answer without a contract.
|
||||
@@ -176,7 +176,7 @@ export function getBirthTimeGuideAgent(model: ResolvedLanguageModel) {
|
||||
const windowJyotishInstructions = `You are the guide for a conversational Vedic astrology product.
|
||||
${productConversationVoice}
|
||||
This request has a declared birth window, not a single birth minute. Never invent 00:00, a period midpoint, noon, or any probe clock as the birth time. Probe clocks in the tool result are comparison samples only.
|
||||
For questions that need personal chart structure, call run-jyotish-window-consultation before answering. Simple conversational follow-ups may use the existing packet.
|
||||
Call run-jyotish-window-consultation before answering every turn, including short follow-ups, clarifications, and complaints; the packet is request-scoped and is never carried over from an earlier turn.
|
||||
Treat the tool result's answer_policy as a hard output contract:
|
||||
- can_answer_precise_timing is always false. Do not state a month, date, dasha boundary, or guaranteed timing outcome.
|
||||
- Answer only from stable_layers as personal structure that holds across the declared window.
|
||||
|
||||
@@ -83,11 +83,11 @@ test("the five tabs render and can each be selected", async () => {
|
||||
const view = await okView();
|
||||
for (const tab of CHART_VIEW_TABS) {
|
||||
const markup = withSidebarProvider(React.createElement(ChartPageView, { view, initialTab: tab.id }));
|
||||
// 原值: 直接计算 · 打开即有 · 不消耗点数
|
||||
// 新值: 主盘直接算 · 分盘按需 · 不消耗点数
|
||||
// 原因: BUG-717 「打开即有」是速度承诺,且印在失败页上
|
||||
assert.equal(markup.includes(CHART_VIEW_COPY.eyebrow), true);
|
||||
assert.doesNotMatch(markup, /打开即有/);
|
||||
// 原值: 成功页必须含「主盘直接算 · 分盘按需 · 不消耗点数」;基础信息含「词条式释义」
|
||||
// 新值: 成功页与失败页都不写这两句;基础信息只留行星卡
|
||||
// 原因: 产品 2026-09-17 判定为过度提示,计费/速度说明和「不是对你个人的判断」都不印在页上
|
||||
assert.doesNotMatch(markup, /chart-page-eyebrow|主盘直接算|分盘按需|不消耗点数|打开即有/);
|
||||
assert.doesNotMatch(markup, /词条式释义|不是对你个人的判断/);
|
||||
assert.match(markup, />星盘</);
|
||||
assert.match(markup, />基础信息</);
|
||||
assert.match(markup, />大运</);
|
||||
@@ -95,7 +95,10 @@ test("the five tabs render and can each be selected", async () => {
|
||||
assert.match(markup, />七政四余</);
|
||||
assert.doesNotMatch(markup, /正在加载|骨架|spinner/i);
|
||||
if (tab.id === "vedic") assert.match(markup, /D1/);
|
||||
if (tab.id === "basics") assert.match(markup, /词条式释义/);
|
||||
if (tab.id === "basics") {
|
||||
assert.match(markup, /本命/);
|
||||
assert.match(markup, /D9/);
|
||||
}
|
||||
if (tab.id === "dasha") {
|
||||
assert.match(markup, /Vimshottari/);
|
||||
assert.match(markup, /Chara Dasha(kn_rao 变体)/);
|
||||
@@ -187,6 +190,10 @@ test("the parameters are one key/value table, not a per-breakpoint duplicate", (
|
||||
".chart-page-center-extra",
|
||||
".chart-page-params-below",
|
||||
".chart-page-boundary-desktop",
|
||||
// 原值: 这一组不含 .chart-page-eyebrow
|
||||
// 新值: 成功页眉标已删,该类不得再有 CSS 规则
|
||||
// 原因: 产品 2026-09-17 去掉「主盘直接算 · 分盘按需 · 不消耗点数」
|
||||
".chart-page-eyebrow",
|
||||
]) {
|
||||
assert.equal(
|
||||
declaredSelectors(globalStyles).has(dead),
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
AGENT_TIMEOUT_MS,
|
||||
CONSULTATION_MAX_OUTPUT_TOKENS,
|
||||
CONSULTATION_NATAL_CALC_TOOL_ID,
|
||||
CONSULTATION_WINDOW_CALC_TOOL_ID,
|
||||
mergeConsultationAnswerPolicies,
|
||||
CONSULTATION_DOMAIN_WALL_CLOCK_MS,
|
||||
MAX_CONSULTATION_DOMAINS,
|
||||
@@ -12,6 +13,7 @@ import {
|
||||
canonicalDomainPlan,
|
||||
consultationGenerationSettings,
|
||||
consultationNatalPrepareStep,
|
||||
consultationWindowPrepareStep,
|
||||
consultationSliceGenerationSettings,
|
||||
consultationModelStepTelemetry,
|
||||
consultationStepBudgetReceipt,
|
||||
@@ -654,15 +656,28 @@ test("guided-topic entrypoint ignores a model domain rewrite instead of executin
|
||||
|
||||
test("natal first step exposes only the chart calculation tool", () => {
|
||||
assert.equal(CONSULTATION_NATAL_CALC_TOOL_ID, "run-jyotish-consultation");
|
||||
// 原值: toolChoice "auto" / 新值: 第 0 步 "required"、第 1 步仍 "auto"
|
||||
// 原因: BUG-923 每轮必须先调排盘工具,提示词例外已删
|
||||
assert.deepEqual(consultationNatalPrepareStep({ stepNumber: 0 }), {
|
||||
activeTools: ["run-jyotish-consultation"],
|
||||
toolChoice: "auto",
|
||||
toolChoice: "required",
|
||||
});
|
||||
assert.deepEqual(consultationNatalPrepareStep({ stepNumber: 1 }), {
|
||||
toolChoice: "auto",
|
||||
});
|
||||
});
|
||||
|
||||
test("window first step requires the window consultation tool", () => {
|
||||
assert.equal(CONSULTATION_WINDOW_CALC_TOOL_ID, "run-jyotish-window-consultation");
|
||||
assert.deepEqual(consultationWindowPrepareStep({ stepNumber: 0 }), {
|
||||
activeTools: ["run-jyotish-window-consultation"],
|
||||
toolChoice: "required",
|
||||
});
|
||||
assert.deepEqual(consultationWindowPrepareStep({ stepNumber: 1 }), {
|
||||
toolChoice: "auto",
|
||||
});
|
||||
});
|
||||
|
||||
test("matching the pinned theme does not record a plan override", async () => {
|
||||
const state = createConsultationRuntimeState();
|
||||
await createConsultationTools({
|
||||
|
||||
@@ -171,3 +171,10 @@ test("window agent instructions forbid probe clocks as a birth minute", () => {
|
||||
assert.match(windowFactory, /Never invent 00:00, a period midpoint, noon, or any probe clock as the birth time/);
|
||||
assert.doesNotMatch(windowFactory, /id: `jyotish-guide-/);
|
||||
});
|
||||
|
||||
test("natal and window agents must call the chart tool every turn (BUG-922)", () => {
|
||||
const mastra = readFileSync(new URL("../src/mastra/index.ts", import.meta.url), "utf8");
|
||||
assert.doesNotMatch(mastra, /follow-ups may use the existing/);
|
||||
assert.match(mastra, /Call run-jyotish-consultation before answering every turn/);
|
||||
assert.match(mastra, /Call run-jyotish-window-consultation before answering every turn/);
|
||||
});
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import test from "node:test";
|
||||
import {
|
||||
consultationNatalPrepareStep,
|
||||
consultationWindowPrepareStep,
|
||||
} from "../src/mastra/consultation-tools.ts";
|
||||
|
||||
const route = readFileSync(new URL("../src/app/api/consult/route.ts", import.meta.url), "utf8");
|
||||
const reportsRoute = readFileSync(new URL("../src/app/api/reports/route.ts", import.meta.url), "utf8");
|
||||
@@ -15,6 +19,29 @@ const stagingCompose = readFileSync(new URL("../../deploy/docker-compose.staging
|
||||
|
||||
|
||||
|
||||
test("natal and window instructions require a chart tool every turn (BUG-922)", () => {
|
||||
assert.doesNotMatch(mastra, /follow-ups may use the existing/);
|
||||
assert.match(mastra, /Call run-jyotish-consultation before answering every turn/);
|
||||
assert.match(mastra, /Call run-jyotish-window-consultation before answering every turn/);
|
||||
});
|
||||
|
||||
test("natal and window first steps require the chart tool (BUG-923)", () => {
|
||||
assert.deepEqual(consultationNatalPrepareStep({ stepNumber: 0 }), {
|
||||
activeTools: ["run-jyotish-consultation"],
|
||||
toolChoice: "required",
|
||||
});
|
||||
assert.deepEqual(consultationNatalPrepareStep({ stepNumber: 1 }), {
|
||||
toolChoice: "auto",
|
||||
});
|
||||
assert.deepEqual(consultationWindowPrepareStep({ stepNumber: 0 }), {
|
||||
activeTools: ["run-jyotish-window-consultation"],
|
||||
toolChoice: "required",
|
||||
});
|
||||
assert.deepEqual(consultationWindowPrepareStep({ stepNumber: 1 }), {
|
||||
toolChoice: "auto",
|
||||
});
|
||||
});
|
||||
|
||||
test("consultation plans are server-owned and bounded", () => {
|
||||
assert.match(plan, /consultationPlanSchema/);
|
||||
assert.match(plan, /requestedDomains/);
|
||||
@@ -93,14 +120,22 @@ test("consult streams reserve an answer budget and keep provider thinking on a s
|
||||
assert.match(route, /entrypoint: consultEntrypoint/);
|
||||
assert.match(route, /entrypoint: parsed\.data\.entrypoint/);
|
||||
assert.match(tools, /export function consultationNatalPrepareStep/);
|
||||
assert.match(tools, /export function consultationWindowPrepareStep/);
|
||||
assert.match(tools, /activeTools: \[CONSULTATION_NATAL_CALC_TOOL_ID\]/);
|
||||
assert.match(tools, /activeTools: \[CONSULTATION_WINDOW_CALC_TOOL_ID\]/);
|
||||
const sharedStreamOptions = route.slice(
|
||||
route.indexOf("const streamOptions = {"),
|
||||
route.indexOf("const natalStreamOptions = {"),
|
||||
);
|
||||
assert.doesNotMatch(sharedStreamOptions, /prepareStep/);
|
||||
assert.match(route, /prepareStep: consultationNatalPrepareStep/);
|
||||
assert.match(route, /prepareStep: consultationWindowPrepareStep/);
|
||||
assert.match(route, /streamWithOverflowRetry\(agent, natalStreamOptions\)/);
|
||||
assert.match(route, /streamWithOverflowRetry\(agent, windowStreamOptions\)/);
|
||||
assert.match(
|
||||
route,
|
||||
/请调用 run-jyotish-window-consultation 完成计算[\s\S]*?windowStreamOptions\)/,
|
||||
);
|
||||
assert.doesNotMatch(route, /先加载 Jyotish Skill/);
|
||||
assert.match(route, /pinsConsultationDomains\(consultEntrypoint\)/);
|
||||
assert.match(route, /调用时不要填写 domains,沿用服务器已选定的主题/);
|
||||
|
||||
@@ -155,7 +155,11 @@ test("mobile CSS turns five panchanga cards into one stacked card", () => {
|
||||
|
||||
test("copy stays factual: no fortune-telling, no spinner, ask prefills the date", () => {
|
||||
const markup = render(payloadFor("2026-09-15"));
|
||||
assert.match(markup, new RegExp(EPHEMERIS_COPY.footerNote));
|
||||
// 原值: 页尾必须含「这一页是天象本身,不是对你的判断。」
|
||||
// 新值: 页面不得出现该句
|
||||
// 原因: 产品 2026-09-17 判定为过度提示,与星盘页眉标同一类
|
||||
assert.doesNotMatch(markup, /天象本身|不是对你的判断/);
|
||||
assert.doesNotMatch(markup, /ephemeris-outro/);
|
||||
assert.match(markup, new RegExp(EPHEMERIS_COPY.ask));
|
||||
assert.match(markup, new RegExp(EPHEMERIS_COPY.previousDay));
|
||||
assert.match(markup, new RegExp(EPHEMERIS_COPY.nextDay));
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import test from "node:test";
|
||||
|
||||
import { cssDeclarations } from "./css-contract-test-support.ts";
|
||||
import {
|
||||
installViewportScrollLock,
|
||||
isKeyboardOpen,
|
||||
shouldResetWindowScroll,
|
||||
type ViewportScrollLockHost,
|
||||
} from "../src/lib/viewport-scroll-lock.ts";
|
||||
|
||||
const layout = readFileSync(new URL("../src/app/layout.tsx", import.meta.url), "utf8");
|
||||
const component = readFileSync(new URL("../src/components/viewport-scroll-lock.tsx", import.meta.url), "utf8");
|
||||
const lock = readFileSync(new URL("../src/lib/viewport-scroll-lock.ts", import.meta.url), "utf8");
|
||||
const styles = readFileSync(new URL("../src/app/globals.css", import.meta.url), "utf8");
|
||||
|
||||
test("ViewportScrollLock mounts beside StaleClientRecovery and uses the shared installer", () => {
|
||||
assert.match(layout, /import \{ ViewportScrollLock \} from "@\/components\/viewport-scroll-lock"/);
|
||||
assert.match(layout, /<StaleClientRecovery \/>\s*<ViewportScrollLock \/>/);
|
||||
assert.match(component, /installViewportScrollLock/);
|
||||
assert.match(lock, /history\.scrollRestoration = "manual"/);
|
||||
assert.match(lock, /addEventListener\("resize"/);
|
||||
assert.match(lock, /addEventListener\("scroll"/);
|
||||
assert.match(lock, /addEventListener\("pageshow"/);
|
||||
assert.match(lock, /addEventListener\("orientationchange"/);
|
||||
assert.match(lock, /addEventListener\("focusout"/);
|
||||
assert.match(lock, /KEYBOARD_OPEN_GAP_PX = 100/);
|
||||
assert.doesNotMatch(lock, /setInterval/);
|
||||
});
|
||||
|
||||
test("keyboard-open window offsets are left alone", () => {
|
||||
assert.equal(isKeyboardOpen(680, 800), true);
|
||||
assert.equal(isKeyboardOpen(799, 800), false);
|
||||
assert.equal(shouldResetWindowScroll({
|
||||
scrollY: 260,
|
||||
innerHeight: 800,
|
||||
visualViewportHeight: 680,
|
||||
}), false);
|
||||
assert.equal(shouldResetWindowScroll({
|
||||
scrollY: 260,
|
||||
innerHeight: 800,
|
||||
visualViewportHeight: 800,
|
||||
}), true);
|
||||
assert.equal(shouldResetWindowScroll({
|
||||
scrollY: 0,
|
||||
innerHeight: 800,
|
||||
visualViewportHeight: 800,
|
||||
}), false);
|
||||
});
|
||||
|
||||
test("visualViewport resize resets window scroll once the keyboard has closed", () => {
|
||||
const calls: Array<[number, number]> = [];
|
||||
const viewportListeners = new Map<string, () => void>();
|
||||
const host: ViewportScrollLockHost = {
|
||||
innerHeight: 800,
|
||||
scrollY: 260,
|
||||
scrollTo(x, y) {
|
||||
host.scrollY = y;
|
||||
calls.push([x, y]);
|
||||
},
|
||||
history: { scrollRestoration: "auto" },
|
||||
visualViewport: {
|
||||
height: 800,
|
||||
addEventListener(type, listener) {
|
||||
viewportListeners.set(type, listener);
|
||||
},
|
||||
removeEventListener(type) {
|
||||
viewportListeners.delete(type);
|
||||
},
|
||||
},
|
||||
matchMedia: () => ({ matches: true }),
|
||||
addEventListener() {},
|
||||
removeEventListener() {},
|
||||
document: {
|
||||
addEventListener() {},
|
||||
removeEventListener() {},
|
||||
},
|
||||
};
|
||||
const uninstall = installViewportScrollLock(host);
|
||||
assert.equal(host.history.scrollRestoration, "manual");
|
||||
assert.deepEqual(calls, [[0, 0]]);
|
||||
host.scrollY = 180;
|
||||
host.visualViewport!.height = 680;
|
||||
viewportListeners.get("resize")?.();
|
||||
assert.equal(calls.length, 1);
|
||||
host.visualViewport!.height = 800;
|
||||
viewportListeners.get("resize")?.();
|
||||
assert.deepEqual(calls.at(-1), [0, 0]);
|
||||
assert.equal(calls.length, 2);
|
||||
uninstall();
|
||||
});
|
||||
|
||||
test("header chips share height, type size, and radius, and drop the 64px min-width", () => {
|
||||
const shared = cssDeclarations(
|
||||
".chat-header-actions .credit-button,\n.chat-header-rectification .rectification-board-peek",
|
||||
styles,
|
||||
);
|
||||
assert.match(shared, /height:\s*40px/);
|
||||
assert.match(shared, /min-height:\s*40px/);
|
||||
assert.match(shared, /font-size:\s*var\(--type-body-sm\)/);
|
||||
assert.match(shared, /border-radius:\s*var\(--radius-md\)/);
|
||||
assert.match(shared, /font-variant-numeric:\s*tabular-nums/);
|
||||
assert.doesNotMatch(shared, /min-width:/);
|
||||
const credit = cssDeclarations(".credit-button", styles);
|
||||
assert.doesNotMatch(credit, /min-width:\s*64px/);
|
||||
const hit = cssDeclarations(
|
||||
".chat-header-actions .credit-button::before,\n.chat-header-rectification .rectification-board-peek::before",
|
||||
styles,
|
||||
);
|
||||
assert.match(hit, /inset:\s*-2px/);
|
||||
});
|
||||
|
||||
test("compact chat header is 52px plus the safe-area inset", () => {
|
||||
assert.match(
|
||||
styles,
|
||||
/\.chat-panel \{ grid-template-rows: calc\(52px \+ env\(safe-area-inset-top\)\) minmax\(0, 1fr\) auto; \}/,
|
||||
);
|
||||
assert.match(
|
||||
styles,
|
||||
/\.chat-panel\.is-rectification \{ grid-template-rows: calc\(52px \+ env\(safe-area-inset-top\)\) minmax\(0, 1fr\); \}/,
|
||||
);
|
||||
assert.match(styles, /\.chat-panel \{[^}]*grid-template-rows:\s*46px minmax\(0,\s*1fr\) auto/);
|
||||
});
|
||||
@@ -0,0 +1,200 @@
|
||||
/**
|
||||
* 2026-09-17, iPhone on staging: the rectification timeline readout came through
|
||||
* as `04:48–05:07 20 分钟 代表分钟 04:53 已…` (BUG-918), and the floating
|
||||
* 跳到最新 chip sat on top of the last tappable option of a choice card
|
||||
* (BUG-919).
|
||||
*
|
||||
* What this file can and cannot check, stated plainly: there is no jsdom or
|
||||
* happy-dom in this suite, so `renderToStaticMarkup` is the only render path and
|
||||
* `scrollWidth` / `clientWidth` do not exist to compare. Every geometric claim
|
||||
* below is therefore **arithmetic over the rendered text**, using deliberately
|
||||
* generous per-glyph widths (see `readoutWidthPx`), plus CSS declaration
|
||||
* assertions of the kind `chart-page-view.test.tsx` uses. A real-device pass at
|
||||
* 375 and 390 CSS px is still required and lives in
|
||||
* `docs/testing/rectification-mobile-timeline-readout-20260917.md`.
|
||||
*/
|
||||
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import React from "react";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import test from "node:test";
|
||||
|
||||
import { RectificationTimeline } from "../src/components/rectification-timeline.tsx";
|
||||
import { buildRectificationTimeline } from "../src/lib/rectification-timeline-scale.ts";
|
||||
|
||||
Object.assign(globalThis, { React });
|
||||
|
||||
const styles = readFileSync(new URL("../src/app/globals.css", import.meta.url), "utf8");
|
||||
/** Comments carry prose that would otherwise satisfy declaration assertions. */
|
||||
const declarations = styles.replace(/\/\*[\s\S]*?\*\//g, " ");
|
||||
|
||||
/** The `@media (max-width: 767px)` block that owns the timeline, and only it. */
|
||||
function compactTimelineBlock(source: string): string {
|
||||
const start = source.indexOf("@media (max-width: 767px) {\n .rectification-workspace__chat {\n --rectification-timeline-height: 56px;");
|
||||
assert.notEqual(start, -1, "compact timeline media block not found");
|
||||
let depth = 0;
|
||||
for (let at = start; at < source.length; at += 1) {
|
||||
if (source[at] === "{") depth += 1;
|
||||
else if (source[at] === "}") {
|
||||
depth -= 1;
|
||||
if (depth === 0) return source.slice(start, at + 1);
|
||||
}
|
||||
}
|
||||
assert.fail("compact timeline media block is unbalanced");
|
||||
}
|
||||
|
||||
function ruleBody(source: string, selector: string): string {
|
||||
const at = source.indexOf(`${selector} {`);
|
||||
assert.notEqual(at, -1, `missing rule: ${selector}`);
|
||||
const end = source.indexOf("}", at);
|
||||
assert.notEqual(end, -1, `unterminated rule: ${selector}`);
|
||||
return source.slice(at + selector.length + 2, end);
|
||||
}
|
||||
|
||||
/** The readout's five spans in document order, text only. */
|
||||
function readoutItems(markup: string): string[] {
|
||||
const readout = markup.match(/<p class="rectification-timeline__readout">([\s\S]*?)<\/p>/);
|
||||
assert.ok(readout, "readout paragraph not rendered");
|
||||
return [...readout[1].matchAll(/<span class="rectification-timeline__[a-z-]+">([^<]*)<\/span>/g)]
|
||||
.map((match) => match[1]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Upper bound on a readout item's rendered width at 13px, the size the bar is
|
||||
* pinned to. Every figure here is rounded **up** from the system sans stacks the
|
||||
* app ships (digits measure about 0.556em in -apple-system / Helvetica and the
|
||||
* readout asks for tabular-nums, so 0.6em is slack, not a guess). An assertion
|
||||
* that passes against these numbers passes against the real font.
|
||||
*/
|
||||
const GLYPH_EM: Readonly<Record<string, number>> = {
|
||||
digit: 0.6,
|
||||
colon: 0.33,
|
||||
space: 0.28,
|
||||
dash: 0.5,
|
||||
cjk: 1,
|
||||
other: 0.6,
|
||||
};
|
||||
|
||||
function readoutWidthPx(text: string, fontSizePx = 13): number {
|
||||
let em = 0;
|
||||
for (const character of text) {
|
||||
if (/\d/.test(character)) em += GLYPH_EM.digit;
|
||||
else if (character === ":") em += GLYPH_EM.colon;
|
||||
else if (character === " ") em += GLYPH_EM.space;
|
||||
else if (character === "–" || character === "-") em += GLYPH_EM.dash;
|
||||
else if (/[ -鿿豈-]/.test(character)) em += GLYPH_EM.cjk;
|
||||
else em += GLYPH_EM.other;
|
||||
}
|
||||
return em * fontSizePx;
|
||||
}
|
||||
|
||||
/** The worst realistic readout: every optional item present, widest labels. */
|
||||
const fullView = buildRectificationTimeline({
|
||||
searchWindow: ["04:30", "05:30"],
|
||||
credibleRange: ["04:48", "05:07"],
|
||||
candidateTimes: ["04:48", "04:53", "05:07"],
|
||||
stage: "minute",
|
||||
workingTime: "04:53",
|
||||
answeredProbeCount: 6,
|
||||
datedEventCount: 3,
|
||||
});
|
||||
|
||||
test("the timeline renders every readout figure the projection carries", () => {
|
||||
assert.ok(fullView, "the fixture window has to produce a view");
|
||||
const items = readoutItems(renderToStaticMarkup(<RectificationTimeline view={fullView} />));
|
||||
assert.deepEqual(items, ["04:48–05:07", "20 分钟", "代表分钟 04:53", "已答 6 题", "已对照 3 件"]);
|
||||
// BUG-918's fix is CSS, not omission: the compact viewport hides one item, it
|
||||
// never drops it from the markup, so the accessible axis description below
|
||||
// reads the same on every viewport.
|
||||
const markup = renderToStaticMarkup(<RectificationTimeline view={fullView} />);
|
||||
const axisLabel = markup.match(/aria-label="([^"]*)"/)?.[1] ?? "";
|
||||
for (const item of items) {
|
||||
assert.ok(axisLabel.includes(item), `axis label is missing ${item}`);
|
||||
}
|
||||
});
|
||||
|
||||
test("the four items a 375px phone shows fit the compact content box", () => {
|
||||
assert.ok(fullView);
|
||||
const items = readoutItems(renderToStaticMarkup(<RectificationTimeline view={fullView} />));
|
||||
// `.rectification-timeline__dated` is display:none below 768px, so the visible
|
||||
// row is the first four.
|
||||
const visible = items.slice(0, 4);
|
||||
const columnGapPx = 8; // --space-2, the compact column-gap
|
||||
const required = visible.reduce((total, item) => total + readoutWidthPx(item), 0)
|
||||
+ columnGapPx * (visible.length - 1);
|
||||
|
||||
// 375 CSS px (iPhone SE 2/3, 13 mini) minus the compact padding-inline,
|
||||
// var(--space-4) on each side. The 44px avatar inset that used to be added
|
||||
// here is what pushed the row over (BUG-918).
|
||||
const available375 = 375 - 2 * 16;
|
||||
assert.ok(
|
||||
required <= available375,
|
||||
`readout needs ${required.toFixed(0)}px, ${available375}px available at 375px`,
|
||||
);
|
||||
// 390 CSS px (iPhone 12–16 base) has to hold too.
|
||||
assert.ok(required <= 390 - 2 * 16);
|
||||
|
||||
// And the shape of the old bug: with the inset and all five items it did not
|
||||
// fit, which is why the fourth item arrived as 「已…」.
|
||||
const oldRequired = items.reduce((total, item) => total + readoutWidthPx(item), 0) + 12 * (items.length - 1);
|
||||
assert.ok(oldRequired > 375 - 2 * (16 + 44));
|
||||
});
|
||||
|
||||
test("the compact timeline drops the avatar inset and the fifth figure", () => {
|
||||
const compact = compactTimelineBlock(declarations);
|
||||
// Page margin only — no --assistant-content-inset on a phone.
|
||||
assert.match(ruleBody(compact, ".rectification-timeline"), /padding-inline: var\(--space-4\);/);
|
||||
assert.doesNotMatch(ruleBody(compact, ".rectification-timeline"), /--assistant-content-inset/);
|
||||
assert.match(ruleBody(compact, ".rectification-timeline__readout"), /column-gap: var\(--space-2\);/);
|
||||
assert.match(ruleBody(compact, ".rectification-timeline__dated"), /display: none;/);
|
||||
// The range and the working minute are the two figures the product requires
|
||||
// to stay visible at every width, so neither may be hidden or shrunk here.
|
||||
assert.doesNotMatch(compact, /\.rectification-timeline__(range|width|working)\b/);
|
||||
// Still one line, still a fixed-height bar: the compact block must not undo
|
||||
// either, or the bar would resize outside anything observing it.
|
||||
assert.doesNotMatch(compact, /flex-wrap: wrap|white-space: normal/);
|
||||
assert.match(compact, /--rectification-timeline-height: 56px;/);
|
||||
});
|
||||
|
||||
test("the desktop readout keeps all five figures and the assistant-column inset", () => {
|
||||
const desktop = ruleBody(declarations, ".rectification-timeline");
|
||||
assert.match(desktop, /padding: 0 calc\(var\(--space-8\) \+ var\(--assistant-content-inset\)\);/);
|
||||
assert.match(declarations, /\.rectification-timeline__readout \{[^}]*white-space: nowrap;/);
|
||||
assert.match(declarations, /\.rectification-timeline__readout \{[^}]*flex-wrap: nowrap;/);
|
||||
assert.match(declarations, /\.rectification-timeline__readout \{[^}]*font-size: 13px;/);
|
||||
});
|
||||
|
||||
test("the transcript reserves the jump-to-latest band plus air below it", () => {
|
||||
// BUG-919: reserving exactly the chip band left the last option flush under
|
||||
// the chip. The band is the chip's 44px target plus the space-3 it holds
|
||||
// under itself; the list now reserves that plus one more space-3.
|
||||
assert.match(
|
||||
declarations,
|
||||
/\.rectification-workspace__chat \.conversation \{[^}]*--rectification-jump-clearance: calc\(44px \+ var\(--space-3\)\);/,
|
||||
);
|
||||
assert.match(
|
||||
declarations,
|
||||
/\.rectification-workspace__chat \.message-list \{[^}]*padding-bottom: calc\(var\(--rectification-jump-clearance\) \+ var\(--space-3\)\);/,
|
||||
);
|
||||
// Anything scrolled into view lands above the band too.
|
||||
assert.match(
|
||||
declarations,
|
||||
/\.rectification-workspace__chat \.conversation \{[^}]*scroll-padding-block-end: var\(--rectification-jump-clearance\);/,
|
||||
);
|
||||
});
|
||||
|
||||
test("the jump-to-latest overlay itself is untouched by the clearance change", () => {
|
||||
// Its placement and visibility rule belong to BUG-478; this round only widened
|
||||
// the transcript's reservation.
|
||||
assert.match(declarations, /\.jump-to-latest \{[^}]*position: absolute;/);
|
||||
assert.match(declarations, /\.jump-to-latest \{[^}]*bottom: 100%;/);
|
||||
assert.match(declarations, /\.jump-to-latest \{[^}]*justify-content: center;/);
|
||||
assert.match(declarations, /\.jump-to-latest \{[^}]*padding-bottom: var\(--space-3\);/);
|
||||
assert.match(declarations, /\.jump-to-latest \{[^}]*pointer-events: none;/);
|
||||
assert.match(declarations, /\.jump-to-latest__button \{[^}]*min-height: 44px;/);
|
||||
// No second scroll-follow and no reflowing layout for the chip.
|
||||
const anchor = readFileSync(new URL("../src/hooks/use-conversation-scroll-anchor.ts", import.meta.url), "utf8");
|
||||
assert.doesNotMatch(anchor, /scroll-padding|rectification-jump-clearance/);
|
||||
assert.match(anchor, /export const conversationAnchorThreshold = 96;/);
|
||||
});
|
||||
@@ -418,7 +418,12 @@ test("the chat grid has three rows and the timeline row is a fixed height", () =
|
||||
test("the mobile bar stays within the 44px touch rhythm", () => {
|
||||
const mobile = styles.slice(styles.indexOf("@media (max-width: 767px) {\n .rectification-workspace__chat {"));
|
||||
assert.match(mobile, /--rectification-timeline-height: 56px;/);
|
||||
assert.match(mobile, /padding-inline: calc\(var\(--space-4\) \+ var\(--assistant-content-inset\)\);/);
|
||||
// 原值: padding-inline: calc(var(--space-4) + var(--assistant-content-inset));
|
||||
// 新值: padding-inline: var(--space-4);
|
||||
// 原因: BUG-918。真机 375px 下这 44px 头像沟槽让读数放不下,第四项被
|
||||
// text-overflow 裁成「已…」。条不在消息列里,手机上对齐头像列没有
|
||||
// 可见收益,页面边距才是诚实的边界。桌面那条规则不变(上一条测试)。
|
||||
assert.match(mobile, /padding-inline: var\(--space-4\);/);
|
||||
// 56px is the existing jump-clearance constant, not a new number.
|
||||
assert.match(styles, /--rectification-jump-clearance: calc\(44px \+ var\(--space-3\)\);/);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user