diff --git a/CHANGELOG.md b/CHANGELOG.md index 30c1c2c0..caba1c0b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,14 @@ # 印度占星 Skill 更新日志 +## 2026-10-27 — 组件库修复第 3 轮:不再退回加载画面,右栏能拖宽 + +- 给别人建档时,聊天页不再整页换成「正在载入账户」;重新读名单失败也不会卡住。 +- 对话右侧本命盘的左缘能拖宽,拖到最窄会收起。侧栏和右栏的拖宽把手都能用方向键调。 +- 启程清单的胶囊和首页一起出来,不再揭幕后才冒出来。 +- 没有出生分钟时,起步第一问会说「月亮在……」;给别人建档后,天空过场会说「那一刻,{名字}的上升在……」(只在上升不会换座时)。为此多读一次已算好的星盘,这是产品 10-27 同意的。 +- 删掉旧校正画面留下的无用样式。首屏比迁移前大 7.54%,产品已接受。 +- Skill 版本不变。 + ## 2026-10-27 — 启程清单、提示和动效不进首屏 - 首页先出来。启程清单、一次性提示和启程动效随后再载入,中间不转圈、不写「正在加载」。 diff --git a/docs/BUG_HISTORY.md b/docs/BUG_HISTORY.md index 8feb81ec..7cbb74d4 100644 --- a/docs/BUG_HISTORY.md +++ b/docs/BUG_HISTORY.md @@ -19802,3 +19802,83 @@ - 复发自:无 - 修复版本:分支 `codex/ui-kit-onboarding-fix-20261026`。 +## BUG-1455 | 给别人建档后整个聊天页退回「正在载入账户」 + +- 状态:resolved +- 首次发现:2026-10-27 +- 最近更新:2026-10-27 +- 影响面:新手建档时选「帮别人看」的账户。首页聊天。 +- 用户现象:填完别人的名字,或补完别人的出生地点,整个聊天页被换成「正在载入账户」。那次重新读名单失败时,页面一直停在加载画面。 +- 触发条件:建档选「帮别人看」,提交名字,或提交出生地点。 +- 根因:首页的加载判断要求人物名单已就绪。建档这两步先把名单作废,就绪标记跟着变回「没就绪」,于是已经揭幕的页面又回到加载画面。名单只在页面挂上时重试一次,失败就不再读。 +- 修复:建档改用「重新读名单」:旧名单留着,新名单到了再换。读失败时保留原来那份完整名单,不换成只有本人的降级名单。换账户时仍然整个作废。 +- 验证:`frontend/tests/subject-catalog-refresh-20261027.test.ts` 3 通过。重读期间就绪标记一直为真;重读失败后名单还在;建档代码不再作废名单。 +- 防复发:上面的测试。已揭幕的页面不得因为重读名单回到加载画面(AGENTS §6)。 +- 相关记录:BUG-1448。issue #111 第 2 轮验收第 1 条。 +- 复发自:无 +- 修复版本:分支 `codex/ui-kit-fix3-20261027`。 + +## BUG-1456 | 对话右侧本命盘拖不动宽度 + +- 状态:resolved +- 首次发现:2026-10-27 +- 最近更新:2026-10-27 +- 影响面:桌面首页对话右侧的本命盘栏。 +- 用户现象:右栏左缘的把手拖不动,拖到最窄也不会收起。读代码判断,没有在浏览器复现。 +- 触发条件:桌面打开右侧本命盘,拖它的左缘。 +- 根因:为了不让聊天列跟着右栏载入整列重挂(BUG-1448),聊天列被移出了分栏组。组里只剩右栏一个面板,把手两边没有可交换的面板,拖宽和收起都不会触发。 +- 修复:右栏不再用分栏组。左缘换成自己的拖宽把手:按住拖动改宽度,拖到最窄以下收起,聊天列仍在右栏外面。 +- 验证:`frontend/tests/drag-width-handle-20261027.test.tsx` 5 通过。拖动依次报出宽度,超出最宽时停在最宽,拖到最窄以下收起一次,松手后监听移除。`frontend/tests/shell-frame-stable-mount.test.tsx` 仍通过,聊天列不重挂。 +- 防复发:上面的测试。`frontend/tests/chat-chart-panel-state.test.ts` 改为要求右栏用这个把手(三栏说明在测试里)。浏览器手感留给 `docs/testing/ui-kit-migration-20261023.md`。 +- 相关记录:BUG-1448。issue #111 第 2 轮验收第 2 条。 +- 复发自:无 +- 修复版本:分支 `codex/ui-kit-fix3-20261027`。 + +## BUG-1457 | 侧栏和右栏的拖宽把手不能用键盘调 + +- 状态:resolved +- 首次发现:2026-10-27 +- 最近更新:2026-10-27 +- 影响面:桌面侧栏右缘、对话右栏左缘。 +- 用户现象:用 Tab 移到把手上,按左右方向键没有反应,读屏也读不出它是分隔条、现在多宽。 +- 触发条件:只用键盘操作。 +- 根因:组件库换外壳时,侧栏把手换成了只听鼠标的按钮,原来分栏组件带的方向键支持一起没了。 +- 修复:两个把手共用一个拖宽钩子:左右方向键每次 16 像素,Home / End 到最窄 / 最宽,键盘不会把栏收起。读屏读作竖向分隔条,带当前宽度和范围。 +- 验证:`frontend/tests/drag-width-handle-20261027.test.tsx`:方向键改宽度并阻止页面滚动,其他键不处理,分隔条属性齐全;侧栏用同一个钩子。 +- 防复发:上面的测试。 +- 相关记录:BUG-1456。issue #111 第 2 轮验收第 3 条。 +- 复发自:无 +- 修复版本:分支 `codex/ui-kit-fix3-20261027`。 + +## BUG-1458 | 启程清单在首页出来之后才冒出来 + +- 状态:resolved +- 首次发现:2026-10-27 +- 最近更新:2026-10-27 +- 影响面:还没关掉启程清单的账户,首页聊天顶栏。 +- 用户现象:首页揭幕后过一会儿,顶栏右边才多出启程清单,旁边的东西跟着挪一下。 +- 触发条件:进首页,清单还开着。 +- 根因:为了压首屏体积,清单整块改成揭幕后再载入,而且不给占位。清单要不要显示,本来就要等账户的启程状态读回来,再加上这次延后,就在揭幕后出现了第二次变化。 +- 修复:清单的外观(进度圈和一行字)不再等弹层代码:显示时先画外观,弹层载入后才能点开。读启程状态的这段时间,用这台浏览器上次看到的外观先占住位置;读回来说不显示,就拿掉,并删掉那份记录。从没见过清单的浏览器不占位。 +- 验证:`frontend/tests/onboarding-checklist-slot-20261027.test.tsx` 2 通过。`frontend/tests/onboarding-checklist-20261022.test.ts` 改为要求首页用这个占位(三栏说明在测试里)。 +- 防复发:上面的测试。 +- 相关记录:issue #111 第 2 轮验收第 6 条。 +- 复发自:无 +- 修复版本:分支 `codex/ui-kit-fix3-20261027`。 + +## BUG-1459 | 起步第一问走不到「只有月亮」,给别人看时天空过场不说上升 + +- 状态:resolved +- 首次发现:2026-10-26 +- 最近更新:2026-10-27 +- 影响面:新手首页的起步三问;给别人建完档后的「那一刻的天空」过场。 +- 用户现象:没有出生分钟的人,第一问总是「从我的盘看,我是个什么样的人」,不会说「月亮在……」。给别人建档后,过场下面没有「那一刻,{名字}的上升在……」。 +- 触发条件:出生时间不到分钟(大概时段或不知道);或者给别人建档。 +- 根因:两处的星座都只从今日星语取。没有出生分钟时不请求今日星语,给别人看时也不请求,所以星座永远是空的。原任务书不许新增请求,执行方就没做。 +- 修复:产品 10-27 同意为此多读一次已算好的盘。起步三问和别人的天空过场在需要时读一次星盘页同一份结果(之后打开星盘页直接复用),每人每次打开只读一次。上升只在出生时间范围内不会换座时才用;会换时第一问只说月亮,过场不加上升句。读不到就保持原来的说法,不转圈、不报错。 +- 验证:`frontend/tests/starter-chart-signs-20261027.test.ts` 3 通过(真实引擎金样)。上升确定 / 会换 / 不知道三种都对;别人的盘不会被当成本人的;同一人第二次不再请求;读失败时第一问保持原来的说法,且不反复重试。 +- 防复发:上面的测试。读取在用到时才载入,首页首屏不带这段代码。 +- 相关记录:BUG-1444(上升会不会换)。issue #96、#111 U11 第 2、3 项。 +- 复发自:无 +- 修复版本:分支 `codex/ui-kit-fix3-20261027`。 + diff --git a/docs/tasks/PROGRESS-ui-kit-fix3-20261027.md b/docs/tasks/PROGRESS-ui-kit-fix3-20261027.md new file mode 100644 index 00000000..a53cb4e5 --- /dev/null +++ b/docs/tasks/PROGRESS-ui-kit-fix3-20261027.md @@ -0,0 +1,50 @@ +# 进度:组件库修复第 3 轮(issue #111,Claude 直接执行,2026-10-27) + +- 修复清单:`TASK-ui-kit-onboarding-fix-20261026.md` 末尾「第 2 轮验收」第 1~7 条,加 U11 第 2、3 项。 +- 产品决定(#111,2026-10-27):由 Claude 来修;首屏 +7.54% 接受;为 U11 第 2、3 项允许多读一次已算好的盘。 +- 分支 `codex/ui-kit-fix3-20261027`,从 `codex/ui-kit-onboarding-fix-20261026`(`365a623fc`)开出,先合并 `origin/staging`(`9ca933968`,含 #112)。 +- BUG 编号:#112 已占 1444~1447。#111 分支原来的 1444~1450 合并时顺延为 1448~1454,本轮新增 1455~1459。 + +## 逐条 + +| # | 内容 | 结果 | BUG | +| --- | --- | --- | --- | +| 1 | 建档后整页退回「正在载入账户」 | 建档改用 `refreshSubjectCatalog()`:旧名单保留到新名单到;重读失败保留原来的完整名单。换账户仍用 `invalidateSubjectCatalog()` | 1455 | +| 2 | 对话右栏拖不动 | 右栏不再用分栏组,左缘换成 `useDragWidth` 把手;拖到最窄以下收起。聊天列仍在右栏外,不重挂 | 1456 | +| 3 | 侧栏把手没有键盘 | 侧栏和右栏共用 `useDragWidth`:左右方向键 16px、Home / End,键盘不收起;`role="separator"` 带宽度 | 1457 | +| 4 | 删减断言没写三栏 | 「关闭导出」点按补回;选盘型的源码守卫补回;旧校正组件那条删除补写三栏 | — | +| 5 | 为测试改了线上登录代码 | `modules/identity/client.ts`、`email-otp-login.tsx` 改回合并基线原样。测试替身放进 `login-code-entry.test.tsx`:加载钩子把 `next/image` 换成组件本身;先装好 fetch 替身再动态导入登录页 | — | +| 6 | 清单揭幕后才冒出来 | 新增 `OnboardingChecklistSlot`:胶囊不等弹层代码;读启程状态期间用本机上次的胶囊占位 | 1458 | +| 7 | 无用样式 | 删除旧校正组件独有的 30 个类的规则(`globals.css`、`birth-time-choice.css`),逐个确认 `src/` 无引用、测试无断言 | — | +| U11-2 | 起步第一问「只有月亮」档 | 没有今日星语时读一次星盘页同一份结果;上升只在 `lagnaPrecheck.state === "fixed"` 时用 | 1459 | +| U11-3 | 别人的天空过场上升句 | 过场开始时为别人读同一份结果;上升会换或读不到就不写这句 | 1459 | + +## 改动的既有断言(原值 / 新值 / 原因) + +| 文件 | 原值 | 新值 | 原因 | +| --- | --- | --- | --- | +| `tests/chat-chart-panel-state.test.ts` | split 里要有 `ResizablePanelGroup`、`collapsible` | split 用 `useDragWidth(`,`onCollapse` 调 `onOpenChange(false)` | 组里只剩一个面板时把手拖不动;聊天列不能进组(BUG-1448),改成不依赖分组的把手 | +| `tests/class-name-definition-contract.test.ts` | 白名单含 `chat-chart-resize-handle` | 删掉这一项 | 这个类现在有自己的样式规则 | +| `tests/onboarding-checklist-20261022.test.ts` | 首页 ``,page.tsx 里 dynamic 引入且不给占位 | 首页 ``;dynamic 引入在 slot 文件里,带 `loading` | 不给占位导致揭幕后才冒出来(第 6 条) | +| `tests/report-export-drawer.test.tsx` | (365a623fc)没有「关闭导出」点按 | 补回,点了再关一次 | 按钮还在,是最常用的关法 | +| `tests/chart-type-picker.test.tsx` | (365a623fc)没有源码守卫 | 补回 `doesNotMatch(/`)占住位置;读回来说不显示就拿掉。从没见过清单的浏览器不占位(BUG-1458)。手机(侧栏视口小于 768)从底部抽屉拉出,带现有的下拉把手。平板和桌面用现有弹出层,靠右对齐,宽 `min(22rem, calc(100vw - 2rem))`,行是 `1.25rem minmax(0, 1fr) auto`,句子 `overflow-wrap: anywhere`。已完成的行用对勾,没有「去做」。底部是「先不看了」。 七项在这一次使用里刚满时,抽屉或弹出层再打开一次,进度环处同时迸一次星点(1000ms,只一次)。标题「都走过一遍了」,正文「以后有新东西,我会在用到的时候再告诉你。」关掉后胶囊消失。进站时已经满的账户,胶囊、这一屏和星点都不出现。 一次性提示用现有弹出层,宽 `min(18rem, calc(100vw - 2rem))`,不抢焦点。侧栏两项从锚点右侧伸出,其余从上方伸出,间距 8px。按钮是「知道了」。同一时刻最多一条。天空过场、兑换窗、任何弹窗或抽屉、启程清单正打开、回答还在生成时,提示不出;挡着的那一条等条件过去再出。侧栏「星盘」、手机菜单和星盘四个页签上的小圆点是 8px,贴在右上角 4px,不是字符。 -启程专用动效只在启程里播放:清单还开着且有未完成项,或账户还没完成第一问。清单已经关掉的账户不播追问进场、档案卡进场、星盘和大运画出、合盘头像靠拢。动效代码还没到手、或 300ms 内没到手时,内容保持可见,不先藏起来。只在元素第一次出现时播放。重绘、来回切换不重播。除回答流式中的呼吸点外,没有无限循环。只动 `opacity`、`transform`、颜色、`clip-path`,不触发布局。Motion 用到才加载,不进首屏。启程清单、一次性提示和启程动效宿主也在页面出来后再载入,不进首屏。没载入之前不占位、不转圈;内容保持可见。系统开启「减少动态效果」时,星点和圆点闪烁去掉,其余只淡入,没有位移、缩放和擦除。 +启程专用动效只在启程里播放:清单还开着且有未完成项,或账户还没完成第一问。清单已经关掉的账户不播追问进场、档案卡进场、星盘和大运画出、合盘头像靠拢。动效代码还没到手、或 300ms 内没到手时,内容保持可见,不先藏起来。只在元素第一次出现时播放。重绘、来回切换不重播。除回答流式中的呼吸点外,没有无限循环。只动 `opacity`、`transform`、颜色、`clip-path`,不触发布局。Motion 用到才加载,不进首屏。启程清单的弹层、一次性提示和启程动效宿主也在页面出来后再载入,不进首屏。提示和动效宿主没载入之前不占位、不转圈;内容保持可见。清单胶囊例外,见上文。 + +首屏体积:组件库迁移后首页 `/` 的首屏 gzip-9 是 694,941 字节,比迁移前基线 `0f66d92d4`(646,201)大 7.54%,超出 ±2%。多出来的是组件库外壳和整站样式,能延后的(清单弹层、提示、动效、拖动排序、分栏)都已延后。产品负责人 2026-10-27 接受这个数(issue #111)。之后的轮次以新数为基线,继续按 ±2% 管。系统开启「减少动态效果」时,星点和圆点闪烁去掉,其余只淡入,没有位移、缩放和擦除。 | 类别 | 用在哪 | 时长 | 曲线 | | --- | --- | --- | --- | diff --git a/frontend/src/app/(app)/page.tsx b/frontend/src/app/(app)/page.tsx index 8ff42192..2fb59269 100644 --- a/frontend/src/app/(app)/page.tsx +++ b/frontend/src/app/(app)/page.tsx @@ -18,6 +18,7 @@ import { AccountDeletionSection } from "@/components/account-deletion-section"; import { ThemePreferencePanel } from "@/components/theme-preference-menu"; import type { BirthTimeAssessmentPhase } from "@/components/birth-time-assessment-overlay"; import { AppLoadingIndicator } from "@/components/app-loading-indicator"; +import { OnboardingChecklistSlot } from "@/components/onboarding-checklist-slot"; import { HomeBootstrapError } from "@/components/home-bootstrap-error"; import { HomeOnboardingShellSlot } from "@/components/home-onboarding-shell-slot"; import { BirthSkyReveal } from "@/components/birth-sky/birth-sky-reveal"; @@ -200,11 +201,6 @@ const BillingPanel = dynamic(loadBillingPanel, { loading: () =>

账户信息还没拿到。

, }); -// After the page is up. No placeholder: a spinner is not allowed once the home screen is revealed. -const OnboardingChecklistEntry = dynamic( - () => import("@/components/onboarding-checklist").then((module) => module.OnboardingChecklistEntry), - { ssr: false }, -); let nudgeComposerTimer: number | undefined; @@ -1303,7 +1299,7 @@ export default function Home() { ) : null}
- +
void startSuggestedConsultation(text)} onNudge={nudgeComposer} diff --git a/frontend/src/app/birth-time-choice.css b/frontend/src/app/birth-time-choice.css index 29f9cd42..e3c132de 100644 --- a/frontend/src/app/birth-time-choice.css +++ b/frontend/src/app/birth-time-choice.css @@ -1,10 +1,6 @@ -.birth-time-question-progress { flex-wrap: wrap; gap: var(--space-2) var(--space-4); line-height: 1.5; } -.birth-time-question-progress b { color: var(--color-ink); font-weight: 600; } -.birth-time-choice-surface { min-width: 0; display: grid; gap: var(--space-4); } .birth-time-choice-question { min-width: 0; display: grid; gap: var(--space-3); margin: 0; padding: 0; border: 0; } .birth-time-choice-question legend { width: 100%; margin: 0 0 var(--space-3); color: var(--color-ink); font-family: var(--font-display); font-size: var(--type-title-md); font-weight: 400; line-height: 1.55; text-wrap: pretty; word-break: auto-phrase; } .birth-time-primary-choices { display: grid; gap: var(--space-2); } -.birth-time-special-choices, .birth-time-reframe-actions, .birth-time-journey-controls { display: flex; flex-wrap: wrap; gap: var(--space-2); } .birth-time-choice-option { min-height: 52px; height: auto; justify-content: flex-start; white-space: normal; border: 1px solid var(--color-border); border-radius: var(--radius-md); background: var(--color-canvas); color: var(--color-ink); cursor: pointer; font-size: var(--type-body-sm); font-weight: 400; line-height: 1.5; text-align: left; text-wrap: pretty; word-break: auto-phrase; transition: border-color 120ms ease-out, background-color 120ms ease-out, transform 120ms ease-out; } .birth-time-choice-option.is-primary { width: 100%; padding: var(--space-3) var(--space-4); } .birth-time-choice-option.is-secondary { min-height: 44px; flex: 1 1 0; justify-content: center; padding: var(--space-2) var(--space-4); color: var(--color-ink-secondary); text-align: center; } @@ -18,14 +14,7 @@ .birth-time-choice-option[data-selected="true"]:focus-visible { outline: 2px solid Highlight; outline-offset: -2px; } } .birth-time-choice-question:disabled .birth-time-choice-option { cursor: wait; opacity: .62; } -.birth-time-choice-pending { margin: 0; color: var(--color-action); font-size: var(--type-caption); } -.birth-time-unmatched-note { display: grid; gap: var(--space-2); color: var(--color-ink-secondary); font-size: var(--type-caption); font-weight: 600; } -.birth-time-unmatched-note textarea { min-height: var(--space-24); resize: vertical; color: var(--color-ink); font-size: var(--type-body-sm); font-weight: 400; line-height: 1.55; } -.birth-time-reframe-actions, .birth-time-journey-controls { justify-content: flex-end; } -.birth-time-new-assessment { display: grid; justify-items: start; gap: var(--space-2); } -.birth-time-new-assessment small { color: var(--color-ink-secondary); font-size: var(--type-caption); line-height: 1.5; } @media (max-width: 767px) { - .birth-time-special-choices, .birth-time-reframe-actions, .birth-time-journey-controls { display: grid; grid-template-columns: 1fr; } - .birth-time-choice-option.is-secondary, .birth-time-reframe-actions > button, .birth-time-journey-controls > button { width: 100%; } + .birth-time-choice-option.is-secondary { width: 100%; } } diff --git a/frontend/src/app/globals.css b/frontend/src/app/globals.css index beea3a03..8dc89d10 100644 --- a/frontend/src/app/globals.css +++ b/frontend/src/app/globals.css @@ -1024,7 +1024,11 @@ button.shell-resize-handle:active { position: absolute; top: 0; right: -3px; z-i inside `.chat-column`. Secondary pages stay direct children of `.chat-panel`. */ .chat-chart-frame { grid-row: 1 / -1; grid-column: 1; min-width: 0; min-height: 0; height: 100%; display: flex; flex-direction: row; } .chat-chart-frame > .chat-column { flex: 1 1 auto; } -.chat-chart-split { height: 100%; min-width: 0; min-height: 0; flex: 0 0 auto; } +.chat-chart-split { position: relative; height: 100%; min-width: 0; min-height: 0; flex: 0 0 auto; border-left: 1px solid var(--color-border); } +/* Same 6px transparent strip as the sidebar edge, on the panel's left edge. */ +button.chat-chart-resize-handle, +button.chat-chart-resize-handle:hover, +button.chat-chart-resize-handle:active { position: absolute; top: 0; left: -3px; z-index: 2; width: 6px; height: 100%; padding: 0; border: 0; border-radius: 0; background: transparent; transform: none; cursor: col-resize; } .chat-column { height: 100%; min-width: 0; min-height: 0; display: grid; grid-template-rows: 46px minmax(0, 1fr) auto; } .chat-panel.is-rectification .chat-column { grid-template-rows: 46px minmax(0, 1fr); } .chat-panel:has(.starter-balance) .chat-column { grid-template-rows: 46px minmax(0, 1fr) auto minmax(0, 1fr); } @@ -1283,61 +1287,15 @@ button.shell-resize-handle:active { position: absolute; top: 0; right: -3px; z-i .birth-time-detail-note { padding-bottom: 12px; } .birth-time-legacy-note { padding: var(--space-3) 0; border-top: 1px solid var(--color-border); background: transparent; } .birth-time-rectification { width: 100%; min-width: 0; max-width: 680px; display: grid; box-sizing: border-box; gap: var(--space-5); } -.birth-time-assessment-heading { display: flex; align-items: flex-start; justify-content: space-between; gap: var(--space-4); } -.birth-time-assessment-heading > div { display: grid; gap: var(--space-1); } -.birth-time-assessment-heading span { color: var(--color-action); font-size: var(--type-overline); font-weight: 600; letter-spacing: .08em; } -.birth-time-assessment-heading h2 { margin: 0; font-family: var(--font-display); font-size: var(--type-title-lg); font-weight: 500; } -.birth-time-status-badge { min-height: 30px; display: inline-flex; align-items: center; padding: 0 var(--space-3); border: 1px solid color-mix(in srgb, var(--color-warning) 46%, var(--color-border)); border-radius: var(--radius-md); background: color-mix(in srgb, var(--color-warning) 10%, var(--color-canvas)); color: var(--color-ink-secondary) !important; font-size: var(--type-overline) !important; letter-spacing: .04em !important; } -.birth-time-range-summary { display: grid; grid-template-columns: 1fr 1fr; gap: 1px; margin: 0; overflow: hidden; border: 1px solid var(--color-border); border-radius: var(--radius-md); background: var(--color-border); } -.birth-time-range-summary > div { display: grid; gap: var(--space-1); padding: var(--space-3) var(--space-4); background: var(--color-canvas); } -.birth-time-range-summary dt { color: var(--color-ink-tertiary); font-size: var(--type-overline); } -.birth-time-range-summary dd { margin: 0; color: var(--color-ink); font-size: var(--type-body-sm); font-variant-numeric: tabular-nums; } -.birth-time-assistant-intent, .birth-time-assessment-unavailable { margin: 0; padding: var(--space-3) 0; border-top: 1px solid var(--color-border); background: transparent; color: var(--color-ink); font-size: var(--type-body-sm); line-height: 1.6; word-break: keep-all; overflow-wrap: break-word; } - -.birth-time-question-progress { display: flex; justify-content: space-between; color: var(--color-ink-secondary); font-size: var(--type-caption); } .birth-time-answer-list button.is-selected { border-color: var(--color-action); background: var(--color-action-soft); } -.birth-time-evidence-note, .birth-time-evidence-boundary { margin: 0; color: var(--color-ink-secondary); font-size: var(--type-caption); line-height: 1.6; word-break: keep-all; overflow-wrap: break-word; } - -.birth-time-candidate-result { display: grid; gap: var(--space-4); padding: var(--space-4); border: 1px solid var(--color-border); border-radius: var(--radius-md); background: var(--color-canvas); } -.birth-time-candidate-heading { display: flex; align-items: center; justify-content: space-between; gap: var(--space-3); } -.birth-time-candidate-heading > b { color: var(--color-ink); font-size: var(--type-body-sm); } -.birth-time-candidate-heading > span { color: var(--color-action); font-size: var(--type-caption); font-weight: 600; } -.birth-time-candidate-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 1px; margin: 0; overflow: hidden; border: 1px solid var(--color-border); border-radius: var(--radius-md); background: var(--color-border); } -.birth-time-candidate-grid > div { display: grid; gap: var(--space-1); padding: var(--space-3); background: var(--color-canvas-soft); } -.birth-time-candidate-grid dt { color: var(--color-ink-tertiary); font-size: var(--type-overline); } -.birth-time-candidate-grid dd { margin: 0; color: var(--color-ink); font-size: var(--type-body-sm); font-variant-numeric: tabular-nums; } -.birth-time-confirmation-panel { display: grid; gap: var(--space-3); padding: var(--space-4); border: 1px solid var(--color-border); background: var(--color-canvas); } -.birth-time-confirmation-panel > b { color: var(--color-ink); font-size: var(--type-body-sm); } -.birth-time-confirmation-panel > p { margin: 0; color: var(--color-ink-secondary); font-size: var(--type-caption); line-height: 1.6; word-break: keep-all; overflow-wrap: break-word; } +.birth-time-evidence-note { margin: 0; color: var(--color-ink-secondary); font-size: var(--type-caption); line-height: 1.6; word-break: keep-all; overflow-wrap: break-word; } .birth-time-next-step { box-sizing: border-box; width: 100%; display: grid; gap: var(--space-3); padding: var(--space-4); border: 1px solid var(--color-border); border-radius: var(--radius-md); background: var(--color-action-soft); } .birth-time-next-step > b { color: var(--color-ink); } .birth-time-next-step > p { margin: 0; color: var(--color-ink-secondary); } -.birth-time-next-step > .birth-time-guided-action { width: 100%; min-height: 48px; } .birth-time-next-step > .form-error { width: 100%; color: var(--color-danger); } -.birth-time-guide-turn, .birth-time-evidence-draft-card, .birth-time-scoring-status, .birth-time-candidate-terminal { min-width: 0; max-width: 100%; display: grid; gap: var(--space-4); } -.birth-time-guide-question { margin: 0; color: var(--color-ink); font-family: var(--font-display); font-size: var(--type-title-md); line-height: 1.55; text-wrap: pretty; word-break: auto-phrase; } -.birth-time-guide-composer, .birth-time-draft-fields label { min-width: 0; display: grid; gap: var(--space-2); } -.birth-time-guide-composer > span, .birth-time-draft-fields label > span { color: var(--color-ink-secondary); font-size: var(--type-overline); font-weight: 600; } -.birth-time-guide-composer textarea { min-height: var(--space-24); resize: vertical; line-height: 1.55; } -.birth-time-guide-hint, .birth-time-scoring-status p, .birth-time-candidate-terminal p, .birth-time-success-note { margin: 0; color: var(--color-ink-secondary); font-size: var(--type-body-sm); line-height: 1.6; text-wrap: pretty; word-break: auto-phrase; } -.birth-time-guided-actions { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: var(--space-2); } -.birth-time-guided-action { min-height: 44px; height: auto; padding-inline: var(--space-4); font-size: var(--type-body-sm); white-space: normal; } -.button-text.birth-time-guided-action { border: 0; background: transparent; color: var(--color-action); cursor: pointer; } -.button-text.birth-time-guided-action:hover { background: transparent; color: var(--color-action); } -.birth-time-draft-domain { margin: 0; padding: var(--space-3) var(--space-4); border: 1px solid var(--color-border); border-radius: var(--radius-md); background: var(--color-canvas); } -.birth-time-draft-domain > div { display: grid; gap: var(--space-1); } -.birth-time-draft-domain dt { color: var(--color-ink-tertiary); font-size: var(--type-overline); } -.birth-time-draft-domain dd { margin: 0; color: var(--color-ink); font-size: var(--type-body-sm); } -.birth-time-draft-fields { display: grid; grid-template-columns: 1fr 1fr; gap: var(--space-3); } -.birth-time-scoring-status { padding: var(--space-4); border: 1px solid var(--color-border); background: var(--color-canvas); } -.birth-time-scoring-status > b { font-size: var(--type-body-sm); } -.birth-time-success-note { padding: var(--space-3) 0; border-top: 1px solid var(--color-border); background: transparent; color: var(--color-success); } -.birth-time-candidate-terminal { justify-items: start; } -.birth-time-candidate-result, .birth-time-candidate-result > *, .birth-time-confirmation-panel > * { min-width: 0; max-width: 100%; } - .conversational-rectification { width: 100%; min-width: 0; max-width: 720px; display: grid; gap: var(--space-5); margin: 0 auto; overflow-wrap: anywhere; color: var(--color-ink); } .conversational-rectification > *, .conversational-rectification form, .conversational-rectification fieldset { min-width: 0; max-width: 100%; } .conversational-rectification button { min-height: 44px; max-width: 100%; overflow-wrap: anywhere; } @@ -2284,13 +2242,9 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class } @media (max-width: 480px) { - - .birth-time-assessment-heading { display: grid; grid-template-columns: minmax(0, 1fr); gap: var(--space-2); } - .birth-time-status-badge { justify-self: start; } - .birth-time-detail-grid, .birth-time-range-summary, .birth-time-answer-list, .birth-time-candidate-grid { grid-template-columns: 1fr; } + .birth-time-detail-grid, .birth-time-answer-list { grid-template-columns: 1fr; } .birth-time-profile-result dl { grid-template-columns: 1fr; } - .birth-time-evidence-actions > button, .birth-time-guided-actions > button, .birth-time-candidate-terminal > button, .birth-time-confirmation-panel > button { width: 100%; } - .birth-time-draft-fields { grid-template-columns: 1fr; } + .birth-time-evidence-actions > button { width: 100%; } .welcome > .onboarding-message:first-child .message-bubble p { font-size: var(--type-title-lg); } .account-modal h2 { font-size: var(--type-title-lg); } .account-redeem-form > div { grid-template-columns: 1fr; } diff --git a/frontend/src/components/chat-chart-split.tsx b/frontend/src/components/chat-chart-split.tsx index 0c5e24c3..ca0a1be8 100644 --- a/frontend/src/components/chat-chart-split.tsx +++ b/frontend/src/components/chat-chart-split.tsx @@ -1,12 +1,15 @@ "use client"; -import { useEffect, useRef } from "react"; -import type { PanelImperativeHandle } from "react-resizable-panels"; - import { ChatNatalChart } from "@/components/chat-natal-chart"; -import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from "@/components/ui/resizable"; +import { Button } from "@/components/ui/button"; +import { useDragWidth } from "@/hooks/use-drag-width"; import { CHAT_CHART_WIDTH_MAX, CHAT_CHART_WIDTH_MIN } from "@/lib/chat-chart-panel-state"; +/** + * The right-hand natal column. The chat column stays outside it, so loading this + * never remounts the chat (BUG-1448). Its left edge drags or takes the arrow + * keys; dragging below the minimum closes the panel (BUG-1456). + */ export function ChatChartSplit({ open, width, @@ -20,44 +23,26 @@ export function ChatChartSplit({ onOpenChange: (open: boolean) => void; onWidth: (width: number) => void; }) { - const panelRef = useRef(null); - - useEffect(() => { - const panel = panelRef.current; - if (!panel) return; - if (open && panel.isCollapsed()) panel.expand(); - if (!open && !panel.isCollapsed()) panel.collapse(); - }, [open]); - - function onLayoutChanged(_layout: Record, meta: { isUserInteraction: boolean }) { - if (!meta.isUserInteraction) return; - const panel = panelRef.current; - if (!panel) return; - if (panel.isCollapsed()) { - onOpenChange(false); - return; - } - onOpenChange(true); - const pixels = panel.getSize().inPixels; - if (pixels >= CHAT_CHART_WIDTH_MIN) onWidth(Math.round(pixels)); - } + const resize = useDragWidth({ + width, + min: CHAT_CHART_WIDTH_MIN, + max: CHAT_CHART_WIDTH_MAX, + edge: "left", + onWidth, + onCollapse: () => onOpenChange(false), + }); if (!open) return null; return ( - - - - - - + ); } diff --git a/frontend/src/components/email-otp-login.tsx b/frontend/src/components/email-otp-login.tsx index b39b1964..911d9521 100644 --- a/frontend/src/components/email-otp-login.tsx +++ b/frontend/src/components/email-otp-login.tsx @@ -1,6 +1,6 @@ "use client"; -import NextImage from "next/image"; +import Image from "next/image"; import { FormEvent, useState } from "react"; import { LOGIN_CONSENT_HINT, LoginConsentField, LoginLegalFooter } from "@/components/legal/login-consent"; @@ -14,11 +14,6 @@ import { dismissNotice, notifyError, notifyInfo, notifySuccess, NOTICE_DURATION_ import { createBrowserSupabaseClient } from "@/lib/supabase/client"; import { selfHostedAuthActions } from "@/modules/identity/client"; -// Next unwraps this package to the image component. The test runner leaves the -// package object, whose component sits on `default`. Either shape renders the logo. -const imageModule = NextImage as unknown as { $$typeof?: unknown; default?: typeof NextImage }; -const Image = (imageModule.$$typeof ? NextImage : (imageModule.default ?? NextImage)) as typeof NextImage; - type AuthProvider = "supabase" | "self-hosted"; type AuthMode = "otp" | "password" | "register" | "forgot"; type AuthStep = "email" | "otp" | "two-factor" | "set-password" | "existing"; diff --git a/frontend/src/components/onboarding-checklist-slot.tsx b/frontend/src/components/onboarding-checklist-slot.tsx new file mode 100644 index 00000000..5f5fe8b7 --- /dev/null +++ b/frontend/src/components/onboarding-checklist-slot.tsx @@ -0,0 +1,91 @@ +"use client"; + +import dynamic from "next/dynamic"; +import { useEffect } from "react"; + +import { OnboardingProgressRing } from "@/components/onboarding-progress-ring"; +import { Button } from "@/components/ui/button"; +import { useOnboardingJourneyOptional } from "@/lib/onboarding-journey"; + +/** + * The checklist's face in the chat header (BUG-1458). The popover and drawer + * still load after the page is up, but the face does not wait for them: it + * shows with the page, from the last face this browser saw for the account, + * while the account's answer is being read. Once the answer is in it either + * becomes the real entry or goes away. + */ +const CHECKLIST_FACE_KEY_PREFIX = "onboarding_checklist_face:"; + +export type ChecklistFaceSnapshot = { readonly label: string; readonly done: number; readonly total: number }; + +export function ChecklistFace({ label, done, total }: ChecklistFaceSnapshot) { + return ( + <> + + {label} + + ); +} + +export function readChecklistFace(userId: string): ChecklistFaceSnapshot | null { + if (!userId) return null; + try { + const raw = typeof localStorage === "undefined" ? null : localStorage.getItem(`${CHECKLIST_FACE_KEY_PREFIX}${userId}`); + if (!raw) return null; + const parsed = JSON.parse(raw) as Partial; + if (typeof parsed.label !== "string" || typeof parsed.done !== "number" || typeof parsed.total !== "number") return null; + return { label: parsed.label, done: parsed.done, total: parsed.total }; + } catch { + return null; + } +} + +function writeChecklistFace(userId: string, face: ChecklistFaceSnapshot | null): void { + if (!userId) return; + try { + if (typeof localStorage === "undefined") return; + const key = `${CHECKLIST_FACE_KEY_PREFIX}${userId}`; + if (face) localStorage.setItem(key, JSON.stringify(face)); + else localStorage.removeItem(key); + } catch { /* Optional storage: without it the face waits for the answer. */ } +} + +function HeldFace({ face }: { readonly face: ChecklistFaceSnapshot }) { + return ( + + ); +} + +function LoadingFace() { + const journey = useOnboardingJourneyOptional(); + if (!journey?.showChecklist) return null; + const done = journey.items.filter((item) => item.done).length; + return ; +} + +const OnboardingChecklistEntry = dynamic( + () => import("@/components/onboarding-checklist").then((module) => module.OnboardingChecklistEntry), + { ssr: false, loading: () => }, +); + +export function OnboardingChecklistSlot() { + const journey = useOnboardingJourneyOptional(); + const userId = journey?.userId ?? ""; + const show = Boolean(journey?.showChecklist); + const pending = Boolean(journey?.checklistPending); + const label = journey?.label ?? ""; + const done = journey ? journey.items.filter((item) => item.done).length : 0; + const total = journey ? journey.items.length : 0; + + useEffect(() => { + if (show) writeChecklistFace(userId, { label, done, total }); + else if (!pending) writeChecklistFace(userId, null); + }, [show, pending, userId, label, done, total]); + + if (show) return ; + if (!pending) return null; + const held = readChecklistFace(userId); + return held ? : null; +} diff --git a/frontend/src/components/onboarding-checklist.tsx b/frontend/src/components/onboarding-checklist.tsx index 3224d8d6..2f61f213 100644 --- a/frontend/src/components/onboarding-checklist.tsx +++ b/frontend/src/components/onboarding-checklist.tsx @@ -3,7 +3,7 @@ import { useEffect, useRef, useState } from "react"; import { Check } from "lucide-react"; -import { OnboardingProgressRing } from "@/components/onboarding-progress-ring"; +import { ChecklistFace } from "@/components/onboarding-checklist-slot"; import { Button } from "@/components/ui/button"; import { Drawer, DrawerContent, DrawerDescription, DrawerFooter, DrawerHeader, DrawerTitle, DrawerTrigger } from "@/components/ui/drawer"; import { Popover, PopoverContent, PopoverDescription, PopoverTrigger } from "@/components/ui/popover"; @@ -71,14 +71,6 @@ function ChecklistBody({ ); } -function ChecklistFace({ label, done, total }: { readonly label: string; readonly done: number; readonly total: number }) { - return ( - <> - - {label} - - ); -} export function OnboardingChecklistEntry() { const journey = useOnboardingJourneyOptional(); diff --git a/frontend/src/components/shell-frame.tsx b/frontend/src/components/shell-frame.tsx index 74fb9b05..28aa4642 100644 --- a/frontend/src/components/shell-frame.tsx +++ b/frontend/src/components/shell-frame.tsx @@ -1,9 +1,10 @@ "use client"; -import { useEffect, useRef, type PointerEvent as ReactPointerEvent, type ReactNode } from "react"; +import { useEffect, type ReactNode } from "react"; import { Button } from "@/components/ui/button"; import { useSidebar } from "@/components/ui/sidebar"; +import { useDragWidth } from "@/hooks/use-drag-width"; import { SIDEBAR_WIDTH_COLLAPSED, SIDEBAR_WIDTH_DEFAULT, @@ -19,7 +20,6 @@ export function ShellFrame({ sidebar, children }: { sidebar: ReactNode; children const hydrated = useUiStore((state) => state.sidebarWidthHydrated); const hydrate = useUiStore((state) => state.hydrateSidebarWidth); const setSidebarWidth = useUiStore((state) => state.setSidebarWidth); - const dragStop = useRef<(() => void) | null>(null); const desktop = ready && !isMobile; const expandedWidth = hydrated ? width @@ -30,31 +30,17 @@ export function ShellFrame({ sidebar, children }: { sidebar: ReactNode; children hydrate(); }, [hydrate]); - useEffect(() => () => dragStop.current?.(), []); - - function onResizeStart(event: ReactPointerEvent) { - if (event.button !== 0) return; - event.preventDefault(); - const startX = event.clientX; - const startWidth = open ? expandedWidth : SIDEBAR_WIDTH_COLLAPSED; - const move = (ev: PointerEvent) => { - const next = startWidth + ev.clientX - startX; - if (next < SIDEBAR_WIDTH_MIN) { - setOpen(false); - return; - } + const resize = useDragWidth({ + width: pixelWidth, + min: SIDEBAR_WIDTH_MIN, + max: SIDEBAR_WIDTH_MAX, + edge: "right", + onWidth: (next) => { setOpen(true); - setSidebarWidth(Math.min(SIDEBAR_WIDTH_MAX, Math.round(next))); - }; - const stop = () => { - dragStop.current = null; - window.removeEventListener("pointermove", move); - window.removeEventListener("pointerup", stop); - }; - dragStop.current = stop; - window.addEventListener("pointermove", move); - window.addEventListener("pointerup", stop); - } + setSidebarWidth(next); + }, + onCollapse: () => setOpen(false), + }); return (
) : null}
diff --git a/frontend/src/components/starter-questions.tsx b/frontend/src/components/starter-questions.tsx index 6943c28e..24c9ed27 100644 --- a/frontend/src/components/starter-questions.tsx +++ b/frontend/src/components/starter-questions.tsx @@ -1,5 +1,7 @@ "use client"; +import { useEffect } from "react"; + import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { @@ -13,16 +15,29 @@ import { export function StarterQuestions({ audience, + subjectId, signs, onAsk, onNudge, }: { readonly audience: StarterAudience; + readonly subjectId: string; readonly signs: StarterChartSigns; readonly onAsk: (question: string) => void; readonly onNudge: () => void; }) { const questions = starterQuestions(audience, signs); + const moonKnown = Boolean(signs.moonSign); + + // Without a birth minute 今日星语 is not requested, so its signs never come. + // Read the computed chart once instead (BUG-1459); until then, and on any + // failure, the first question keeps its no-sign wording. + useEffect(() => { + if (moonKnown) return; + void import("@/lib/starter-chart-signs") + .then((module) => module.loadStarterSignsFromChart(subjectId)) + .catch(() => undefined); + }, [moonKnown, subjectId]); return (

{starterLead(audience)}

diff --git a/frontend/src/hooks/use-birth-sky-reveal.ts b/frontend/src/hooks/use-birth-sky-reveal.ts index 25c235cc..72a5c9e3 100644 --- a/frontend/src/hooks/use-birth-sky-reveal.ts +++ b/frontend/src/hooks/use-birth-sky-reveal.ts @@ -23,6 +23,14 @@ export function useBirthSkyReveal() { cancel.current = startBirthSkyReveal({ // The overlay chunk loads inside the same budget, so the first frame never waits for code. load: async (signal) => { + // Someone else's sky names their ascendant (「那一刻,{名字}的上升在……」). + // Their 今日星语 is not read, so the sign comes from the computed chart; + // the line appears when it arrives and is left out if it never does (BUG-1459). + if (id !== "self") { + void import("@/lib/starter-chart-signs") + .then((module) => module.loadStarterSignsFromChart(id)) + .catch(() => undefined); + } const [sky] = await Promise.all([ loadBirthSkyRevealSky(signal, id), import("@/components/birth-sky/birth-sky-converge"), diff --git a/frontend/src/hooks/use-drag-width.ts b/frontend/src/hooks/use-drag-width.ts new file mode 100644 index 00000000..6c4c8cd8 --- /dev/null +++ b/frontend/src/hooks/use-drag-width.ts @@ -0,0 +1,101 @@ +"use client"; + +import { useEffect, useRef, type KeyboardEvent as ReactKeyboardEvent, type PointerEvent as ReactPointerEvent } from "react"; + +/** + * A column edge you can drag or move with the arrow keys (BUG-1456 / BUG-1457). + * `edge: "right"` is a column on the left whose right edge moves (the sidebar); + * `edge: "left"` is a column on the right whose left edge moves (the chat chart). + */ +export type DragWidthEdge = "left" | "right"; + +export const DRAG_WIDTH_KEY_STEP = 16; + +/** Width after the pointer moved `deltaX` pixels from where the drag began. */ +export function dragWidthFromPointer(startWidth: number, deltaX: number, edge: DragWidthEdge): number { + return Math.round(edge === "right" ? startWidth + deltaX : startWidth - deltaX); +} + +/** Width after one arrow key, clamped to the range; null for any other key. Keys never collapse. */ +export function dragWidthFromKey( + current: number, + key: string, + range: { min: number; max: number }, + edge: DragWidthEdge, + step = DRAG_WIDTH_KEY_STEP, +): number | null { + const grow = edge === "right" ? "ArrowRight" : "ArrowLeft"; + const shrink = edge === "right" ? "ArrowLeft" : "ArrowRight"; + let next: number; + if (key === grow) next = current + step; + else if (key === shrink) next = current - step; + else if (key === "Home") next = range.min; + else if (key === "End") next = range.max; + else return null; + return Math.min(range.max, Math.max(range.min, Math.round(next))); +} + +export function useDragWidth({ + width, + min, + max, + edge, + onWidth, + onCollapse, +}: { + /** The width shown now, including a collapsed width below `min`. */ + width: number; + min: number; + max: number; + edge: DragWidthEdge; + onWidth: (width: number) => void; + /** Dragging below `min` calls this instead of `onWidth`. */ + onCollapse: () => void; +}) { + const dragStop = useRef<(() => void) | null>(null); + + useEffect(() => () => dragStop.current?.(), []); + + function onPointerDown(event: ReactPointerEvent) { + if (event.button !== 0) return; + event.preventDefault(); + dragStop.current?.(); + const startX = event.clientX; + const startWidth = width; + const move = (ev: PointerEvent) => { + const next = dragWidthFromPointer(startWidth, ev.clientX - startX, edge); + if (next < min) { + onCollapse(); + return; + } + onWidth(Math.min(max, next)); + }; + const stop = () => { + dragStop.current = null; + window.removeEventListener("pointermove", move); + window.removeEventListener("pointerup", stop); + window.removeEventListener("pointercancel", stop); + }; + dragStop.current = stop; + window.addEventListener("pointermove", move); + window.addEventListener("pointerup", stop); + window.addEventListener("pointercancel", stop); + } + + function onKeyDown(event: ReactKeyboardEvent) { + const next = dragWidthFromKey(width, event.key, { min, max }, edge); + if (next === null) return; + event.preventDefault(); + onWidth(next); + } + + return { + role: "separator" as const, + "aria-orientation": "vertical" as const, + "aria-valuemin": min, + "aria-valuemax": max, + "aria-valuenow": Math.min(max, Math.max(min, Math.round(width))), + onPointerDown, + onKeyDown, + }; +} diff --git a/frontend/src/hooks/use-profile-onboarding.ts b/frontend/src/hooks/use-profile-onboarding.ts index d2d2462b..c5faad78 100644 --- a/frontend/src/hooks/use-profile-onboarding.ts +++ b/frontend/src/hooks/use-profile-onboarding.ts @@ -31,7 +31,7 @@ import { readProfile, selectedBirthPlace, } from "@/lib/home-profile"; -import { setCurrentSubject, loadSubjectCatalog, invalidateSubjectCatalog } from "@/lib/current-subject"; +import { setCurrentSubject, refreshSubjectCatalog } from "@/lib/current-subject"; import { clearOnboardingIntake, notifyOnboardingFinished, @@ -324,9 +324,8 @@ export function useProfileOnboarding(params: ProfileOnboardingParams) { await saveOtherProfile(otherPlace.draft, { id: otherPlace.id }); subjectId = otherPlace.id; clearOnboardingIntake(account.user.id); - invalidateSubjectCatalog(); setCurrentSubject(subjectId); - await loadSubjectCatalog(); + await refreshSubjectCatalog(); notifyOnboardingFinished(subjectId); setStartGreeting(createStartGreeting(profile.name.trim() || otherPlace.draft.name)); } else { @@ -371,8 +370,7 @@ export function useProfileOnboarding(params: ProfileOnboardingParams) { setIntakeChoice("other"); setChosenOtherId(saved.id); setDraftOverride(draft); - invalidateSubjectCatalog(); - void loadSubjectCatalog().catch(() => undefined); + void refreshSubjectCatalog().catch(() => undefined); setPresetMessageLength(0); setOnboardingStep("birth"); } catch (caught) { diff --git a/frontend/src/lib/current-subject.ts b/frontend/src/lib/current-subject.ts index 92a27358..d1b03e8b 100644 --- a/frontend/src/lib/current-subject.ts +++ b/frontend/src/lib/current-subject.ts @@ -182,6 +182,17 @@ export function resetSubjectCatalogForTests(): void { clearCatalogRecovery(); } +/** + * Re-read the people list without dropping the one already shown. Once the page + * has revealed with a list, a reload must not send it back to the loading screen, + * and a failed reload leaves the old list in place (BUG-1455). An account change + * still uses `invalidateSubjectCatalog`. + */ +export function refreshSubjectCatalog(): Promise { + catalogRequest = null; + return loadSubjectCatalog(true); +} + export function invalidateSubjectCatalog(): void { catalogGeneration += 1; catalogRequest = null; @@ -270,6 +281,8 @@ export function loadSubjectCatalog(force = false): Promise return rememberCatalog(account, library, false); } catch (error) { if (generation !== catalogGeneration) throw error; + // A refresh that fails keeps the full list it already had (BUG-1455). + if (catalogReady && catalog && !othersUnavailable && catalogAccountId === account.user.id) throw error; const self = readProfile(account.profile); await loadPlacesFor([self]); const library = upsertSelfChart([], self); diff --git a/frontend/src/lib/onboarding-journey.tsx b/frontend/src/lib/onboarding-journey.tsx index 52a50bcd..b47d2cb8 100644 --- a/frontend/src/lib/onboarding-journey.tsx +++ b/frontend/src/lib/onboarding-journey.tsx @@ -90,6 +90,9 @@ type HomeActions = { export type OnboardingJourney = { readonly showChecklist: boolean; + /** True while the account's checklist answer is still being read (BUG-1458). */ + readonly checklistPending: boolean; + readonly userId: string; readonly items: readonly ChecklistItem[]; readonly label: string; readonly celebration: boolean; @@ -573,6 +576,11 @@ export function OnboardingJourneyProvider({ children }: { readonly children: Rea arrivedComplete, }) && (!canDecide || arrivalSettled); + const checklistPending = userId !== "" && !dismissed && ( + remote.status === "loading" + || remote.userId !== userId + || (canDecide && !arrivalSettled) + ); const motionEnabled = onboardingMotionEnabled({ checklistDismissed: Boolean(merged.checklistDismissedAt), checklistOpen: showChecklist, @@ -667,6 +675,8 @@ export function OnboardingJourneyProvider({ children }: { readonly children: Rea const value = useMemo(() => ({ showChecklist, + checklistPending, + userId, items, label: progress.label, celebration, @@ -686,6 +696,8 @@ export function OnboardingJourneyProvider({ children }: { readonly children: Rea motionEnabled, }), [ showChecklist, + checklistPending, + userId, items, progress.label, celebration, diff --git a/frontend/src/lib/starter-chart-signs.ts b/frontend/src/lib/starter-chart-signs.ts new file mode 100644 index 00000000..beb6c392 --- /dev/null +++ b/frontend/src/lib/starter-chart-signs.ts @@ -0,0 +1,49 @@ +import type { ChartViewResponse } from "@/lib/chart-view-contract"; +import { peekChartPage, refreshChartPage } from "@/lib/secondary-page-data"; +import { rememberStarterSigns, type StarterChartSigns } from "@/lib/starter-questions"; + +/** + * Signs for the starter question and the sky line about someone else, read + * from the chart the engine already computed (BUG-1459). The product allowed + * this one extra read on 2026-10-27; it goes through the chart page's shared + * request, so opening 星盘 afterwards reuses it. Loaded on demand, never in the + * first screen. Any failure leaves today's wording. + * + * The ascendant is used only when it cannot change inside the reported birth + * time (#112 `lagnaPrecheck.state === "fixed"`); otherwise only the Moon. + */ +export function starterSignsFromChartView(view: ChartViewResponse | null | undefined, subjectId: string): StarterChartSigns | null { + if (!view || view.status !== "ok") return null; + if ((view.subjectId ?? "self") !== subjectId) return null; + const moonSign = view.vedic.planets.find((row) => row.name === "Moon")?.sign ?? null; + const ascendantSign = view.lagnaPrecheck?.state === "fixed" ? view.vedic.ascendant?.sign ?? null : null; + if (!moonSign && !ascendantSign) return null; + return { ascendantSign, moonSign }; +} + +const attempted = new Set(); + +/** One read per subject per page load. Resolves to the signs it remembered, or null. */ +export async function loadStarterSignsFromChart(subjectId: string): Promise { + const id = subjectId.trim() || "self"; + const cached = peekChartPage(); + const fromCache = cached?.kind === "view" ? starterSignsFromChartView(cached.view, id) : null; + if (fromCache) { + rememberStarterSigns(id, fromCache); + return fromCache; + } + if (attempted.has(id)) return null; + attempted.add(id); + try { + const snapshot = await refreshChartPage(); + const signs = snapshot.kind === "view" ? starterSignsFromChartView(snapshot.view, id) : null; + if (signs) rememberStarterSigns(id, signs); + return signs; + } catch { + return null; + } +} + +export function resetStarterChartSignsForTests(): void { + attempted.clear(); +} diff --git a/frontend/src/modules/identity/client.ts b/frontend/src/modules/identity/client.ts index 75291cfc..c0b587a5 100644 --- a/frontend/src/modules/identity/client.ts +++ b/frontend/src/modules/identity/client.ts @@ -175,13 +175,7 @@ export type SelfHostedOtpClient = SelfHostedAuthClient; export type SelfHostedOtpActions = SelfHostedAuthActions; export const createSelfHostedOtpActions = createSelfHostedAuthActions; -// better-auth stores the fetch function it is given when this module loads. -// Reading the current global fetch on each request keeps the browser on the -// same fetch, and lets a test replace it after the module has already loaded. const authClient = createAuthClient({ - fetchOptions: { - customFetchImpl: (input, init) => globalThis.fetch(input, init), - }, plugins: [emailOTPClient(), twoFactorClient()], }); diff --git a/frontend/tests/birth-time-guided-review-fixes.test.ts b/frontend/tests/birth-time-guided-review-fixes.test.ts index 68b8472d..6c3c0b69 100644 --- a/frontend/tests/birth-time-guided-review-fixes.test.ts +++ b/frontend/tests/birth-time-guided-review-fixes.test.ts @@ -128,6 +128,12 @@ test("ready completion is explicit and terminal low has no finish mutation", () assert.doesNotMatch(hookSource, /completeCandidate:/); }); +// 原值(365a623fc 之前):test("unconfirmed terminal candidates preserve the range without offering direct adoption") +// 核对 birth-time-candidate-result / birth-time-choice-question / birth-time-rectification / +// birth-time-legacy-rectification 四个文件的源码(候选未达采用条件的文案、childOwnsError 分工、ErrorToast 只一处) +// 新值:整条删除 +// 原因:这四个旧校正组件已经没有页面入口,随 #111 一起删除(按「多余入口宁可删除」)。 +// 现行校正在对话里,候选不直接采用由 rectification-agentic 的测试守住;本文件其余条目继续守引导 hook。 test("terminal CJK copy stays intact while homepage candidates remain unconfirmed in v3", () => { const pageSource = homeSurface; diff --git a/frontend/tests/chart-type-picker.test.tsx b/frontend/tests/chart-type-picker.test.tsx index 3d11c818..ed04b32a 100644 --- a/frontend/tests/chart-type-picker.test.tsx +++ b/frontend/tests/chart-type-picker.test.tsx @@ -127,6 +127,11 @@ test("the type sheet closes when a cell is chosen or Escape is pressed", async ( } finally { await h.close(); } + // 原值(365a623fc):删掉了 doesNotMatch(source, / { diff --git a/frontend/tests/chat-chart-panel-state.test.ts b/frontend/tests/chat-chart-panel-state.test.ts index 1056975f..0c671d6f 100644 --- a/frontend/tests/chat-chart-panel-state.test.ts +++ b/frontend/tests/chat-chart-panel-state.test.ts @@ -62,7 +62,10 @@ test("the chat page loads the natal split only when the panel opens, and phones assert.match(frame, /收起本命盘/); assert.match(frame, /if \(!ready \|\| isMobile\) return null/); assert.match(frame, /import\("@\/components\/chat-chart-split"\)/); - assert.match(split, /ResizablePanelGroup/); - assert.match(split, /collapsible/); + // 原值:split 里要有 ResizablePanelGroup 和 collapsible + // 新值:split 用 useDragWidth,拖到最小以下调 onOpenChange(false) 收起 + // 原因:组里只剩右栏一个面板时把手拖不动(BUG-1456);聊天列不能进组(BUG-1448),所以改成不依赖分组的把手 + assert.match(split, /useDragWidth\(/); + assert.match(split, /onCollapse: \(\) => onOpenChange\(false\)/); assert.doesNotMatch(split, /data-onboarding-draw/); }); diff --git a/frontend/tests/class-name-definition-contract.test.ts b/frontend/tests/class-name-definition-contract.test.ts index d1246bbd..beac02ec 100644 --- a/frontend/tests/class-name-definition-contract.test.ts +++ b/frontend/tests/class-name-definition-contract.test.ts @@ -61,8 +61,10 @@ const knownUnstyled = new Set([ // 新值:删掉。chart-page-dasha-panel、chat-chart-resize-handle 加入。 // 原因:启程进度条已经给 li.is-done 上色。下面这几个类名落在自己会画样式的 // 折叠内容、分栏、侧栏导航和手机侧栏上,类名只是钩子。 + // 原值:chat-chart-resize-handle 在白名单里(落在组件库分栏把手上,由组件自己画) + // 新值:删掉这一项 + // 原因:对话右栏改成自己的拖宽把手(BUG-1456),样式表里有了这个类的规则 "chart-page-dasha-panel", - "chat-chart-resize-handle", "sidebar-nav-item", "sidebar-sheet", "is-live", diff --git a/frontend/tests/drag-width-handle-20261027.test.tsx b/frontend/tests/drag-width-handle-20261027.test.tsx new file mode 100644 index 00000000..f88af04e --- /dev/null +++ b/frontend/tests/drag-width-handle-20261027.test.tsx @@ -0,0 +1,136 @@ +/** + * BUG-1456 / BUG-1457: the chat chart edge and the sidebar edge drag, and take + * the arrow keys, through one hook. Drag below the minimum closes the panel. + */ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import test from "node:test"; +import { renderToStaticMarkup } from "react-dom/server"; + +import { ChatChartSplit } from "../src/components/chat-chart-split.tsx"; +import { dragWidthFromKey, dragWidthFromPointer, useDragWidth, type DragWidthEdge } from "../src/hooks/use-drag-width.ts"; +import { CHAT_CHART_WIDTH_MAX, CHAT_CHART_WIDTH_MIN } from "../src/lib/chat-chart-panel-state.ts"; + +type Handlers = ReturnType; + +function captureHandlers(props: Parameters[0]): Handlers { + let captured: Handlers | null = null; + function Probe() { + captured = useDragWidth(props); + return null; + } + renderToStaticMarkup(); + assert.ok(captured); + return captured; +} + +function fakeWindow() { + const buckets = new Map void>>(); + const original = (globalThis as { window?: unknown }).window; + (globalThis as { window?: unknown }).window = { + addEventListener(type: string, listener: (event: { clientX: number }) => void) { + const set = buckets.get(type) ?? new Set(); + set.add(listener); + buckets.set(type, set); + }, + removeEventListener(type: string, listener: (event: { clientX: number }) => void) { + buckets.get(type)?.delete(listener); + }, + }; + return { + fire(type: string, clientX = 0) { + for (const listener of [...(buckets.get(type) ?? [])]) listener({ clientX }); + }, + count(type: string) { + return buckets.get(type)?.size ?? 0; + }, + restore() { + (globalThis as { window?: unknown }).window = original; + }, + }; +} + +function pointerDown(clientX: number) { + return { button: 0, clientX, preventDefault() {} } as unknown as Parameters[0]; +} + +function keyDown(key: string) { + let prevented = false; + const event = { key, preventDefault() { prevented = true; } } as unknown as Parameters[0]; + return { event, prevented: () => prevented }; +} + +test("pointer and key widths follow the moving edge", () => { + const cases: Array<[DragWidthEdge, number, number]> = [["right", 40, 340], ["left", 40, 260]]; + for (const [edge, delta, expected] of cases) assert.equal(dragWidthFromPointer(300, delta, edge), expected); + const range = { min: 280, max: 560 }; + assert.equal(dragWidthFromKey(360, "ArrowLeft", range, "left"), 376); + assert.equal(dragWidthFromKey(360, "ArrowRight", range, "left"), 344); + assert.equal(dragWidthFromKey(360, "ArrowRight", range, "right"), 376); + assert.equal(dragWidthFromKey(284, "ArrowRight", range, "left"), 280, "keys clamp instead of collapsing"); + assert.equal(dragWidthFromKey(360, "End", range, "left"), 560); + assert.equal(dragWidthFromKey(360, "Enter", range, "left"), null); +}); + +test("dragging the chat chart edge reports widths and closes below the minimum", () => { + const win = fakeWindow(); + const widths: number[] = []; + let collapsed = 0; + try { + const handlers = captureHandlers({ + width: 360, + min: CHAT_CHART_WIDTH_MIN, + max: CHAT_CHART_WIDTH_MAX, + edge: "left", + onWidth: (width) => widths.push(width), + onCollapse: () => { collapsed += 1; }, + }); + handlers.onPointerDown(pointerDown(1000)); + win.fire("pointermove", 960); + win.fire("pointermove", 700); + win.fire("pointermove", 1100); + win.fire("pointerup"); + assert.deepEqual(widths, [400, CHAT_CHART_WIDTH_MAX]); + assert.equal(collapsed, 1); + assert.equal(win.count("pointermove"), 0, "listeners removed after the drag"); + } finally { + win.restore(); + } +}); + +test("arrow keys move the edge and are announced as a separator", () => { + const widths: number[] = []; + const handlers = captureHandlers({ + width: 360, + min: CHAT_CHART_WIDTH_MIN, + max: CHAT_CHART_WIDTH_MAX, + edge: "left", + onWidth: (width) => widths.push(width), + onCollapse: () => {}, + }); + const left = keyDown("ArrowLeft"); + handlers.onKeyDown(left.event); + assert.equal(left.prevented(), true); + const other = keyDown("a"); + handlers.onKeyDown(other.event); + assert.equal(other.prevented(), false); + assert.deepEqual(widths, [376]); + assert.equal(handlers.role, "separator"); + assert.equal(handlers["aria-valuenow"], 360); +}); + +test("the open chat chart renders a focusable separator on its edge", () => { + const html = renderToStaticMarkup( + {}} onWidth={() => {}} />, + ); + assert.match(html, /]*class="[^"]*chat-chart-resize-handle/); + assert.match(html, /role="separator"/); + assert.match(html, /aria-valuenow="360"/); + assert.match(html, /aria-label="调整本命盘宽度"/); +}); + +test("the sidebar edge uses the same hook, so it takes the arrow keys too", () => { + const source = readFileSync(new URL("../src/components/shell-frame.tsx", import.meta.url), "utf8"); + assert.match(source, /useDragWidth\(\{/); + assert.match(source, /\{\.\.\.resize\}/); +}); diff --git a/frontend/tests/login-code-entry.test.tsx b/frontend/tests/login-code-entry.test.tsx index ef49eb24..635517f8 100644 --- a/frontend/tests/login-code-entry.test.tsx +++ b/frontend/tests/login-code-entry.test.tsx @@ -1,13 +1,32 @@ import "./install-test-document"; import assert from "node:assert/strict"; import test from "node:test"; +import { createRequire, register } from "node:module"; +import { pathToFileURL } from "node:url"; import React from "react"; -import { EmailOtpLogin } from "../src/components/email-otp-login.tsx"; import { createClientLifecycleHarness } from "./react-client-lifecycle-test-support.ts"; Object.assign(globalThis, { React }); +// Test doubles only; the login page itself is unchanged (第 2 轮验收第 5 条). +// 1. Next hands `next/image` to the page as the image component. Under the test +// runner the page gets the package object instead, so a loader hook gives +// the page a module whose default is the component. +// 2. better-auth keeps the fetch it finds when the identity client loads, so the +// page module is imported only after the fetch stub below is in place. +const imagePackageUrl = pathToFileURL(createRequire(import.meta.url).resolve("next/image")).href; +const imageShim = `import pkg from ${JSON.stringify(imagePackageUrl)}; +export default pkg.default ?? pkg; +export const getImageProps = pkg.getImageProps;`; +const imageHooks = `export async function resolve(specifier, context, next) { + if (specifier === "next/image" && context.parentURL && context.parentURL.includes("/src/components/")) { + return { url: ${JSON.stringify(`data:text/javascript,${encodeURIComponent(imageShim)}`)}, shortCircuit: true }; + } + return next(specifier, context); +}`; +register(`data:text/javascript,${encodeURIComponent(imageHooks)}`); + test("login types an email, sends a code, then types that code", async () => { // 原值:K5 验收要的这条交互没有测试,登录页只核对源码里的文案 // 新值:输入邮箱、勾选同意、发送验证码,再把六位码打进格子,然后验证 @@ -26,6 +45,7 @@ test("login types an email, sends a code, then types that code", async () => { headers: { "content-type": "application/json" }, }); }) as typeof fetch; + const { EmailOtpLogin } = await import("../src/components/email-otp-login.tsx"); Object.assign(window, { location: { href: "https://staging.jyotisha.chat/login", diff --git a/frontend/tests/onboarding-checklist-20261022.test.ts b/frontend/tests/onboarding-checklist-20261022.test.ts index 7d6c80c4..f5ccc39c 100644 --- a/frontend/tests/onboarding-checklist-20261022.test.ts +++ b/frontend/tests/onboarding-checklist-20261022.test.ts @@ -281,18 +281,19 @@ test("checklist and hint wiring stay inside the existing shell", () => { const journey = read("../src/lib/onboarding-journey.tsx"); const home = page.slice(page.indexOf("export default function Home(")); assert.match(page, //); - assert.match(page, //); - // 原值:首页静态引入启程清单。 - // 新值:清单、提示层、动效宿主都是页面出来后再载入,且不给占位。 - // 原因:首屏 gzip-9 比基线 0f66d92d4 大 11.05%,任务书要求这三块不进首屏。 - assert.match(page, /import\("@\/components\/onboarding-checklist"\)[\s\S]{0,200}ssr: false/); - assert.doesNotMatch(page, /import \{ OnboardingChecklistEntry \}/); + // 原值:首页 ``,并在 page.tsx 里 dynamic 引入 onboarding-checklist(ssr: false,不给占位) + // 新值:首页 ``;dynamic 引入移到 onboarding-checklist-slot.tsx,载入时先显示清单的外观 + // 原因:不给占位时清单在揭幕后才冒出来(第 2 轮验收第 6 条,BUG-1458)。浮层仍不进首屏 + assert.match(page, //); + const slot = read("../src/components/onboarding-checklist-slot.tsx"); + assert.match(slot, /import\("@\/components\/onboarding-checklist"\)[\s\S]{0,200}ssr: false, loading:/); + assert.doesNotMatch(page, /import \{ OnboardingChecklistEntry \}|@\/components\/onboarding-checklist"/); assert.match(layout, /import\("@\/components\/onboarding-hint-layer"\)[\s\S]{0,200}ssr: false/); assert.match(layout, /import\("@\/components\/onboarding-motion-host"\)[\s\S]{0,200}ssr: false/); assert.doesNotMatch(layout, /import \{ OnboardingHintLayer \}/); assert.doesNotMatch(layout, /import \{ OnboardingMotionHost \}/); const header = page.slice(page.indexOf('
'), page.indexOf("composer-wrap")); - assert.match(header, //); + assert.match(header, //); assert.equal(header.includes("composer-wrap"), false); assert.match(layout, /list\.account\?\.compatibility === true/); assert.doesNotMatch(layout, /\/api\/product-access/); diff --git a/frontend/tests/onboarding-checklist-slot-20261027.test.tsx b/frontend/tests/onboarding-checklist-slot-20261027.test.tsx new file mode 100644 index 00000000..c9c26682 --- /dev/null +++ b/frontend/tests/onboarding-checklist-slot-20261027.test.tsx @@ -0,0 +1,92 @@ +/** + * BUG-1458: the checklist face shows with the page instead of popping in after + * reveal. While the account's answer is being read, the face last seen in this + * browser holds the spot; once the answer says no checklist, it goes away. + */ +import assert from "node:assert/strict"; +import test from "node:test"; +import React from "react"; +import { renderToStaticMarkup } from "react-dom/server"; + +import { OnboardingChecklistSlot, readChecklistFace } from "../src/components/onboarding-checklist-slot.tsx"; +import { OnboardingJourneyContext, type OnboardingJourney } from "../src/lib/onboarding-journey.tsx"; + +Object.assign(globalThis, { React }); + +const USER = "11111111-1111-4111-8111-111111111111"; + +function memoryStorage() { + const values = new Map(); + return { + values, + getItem: (key: string) => values.get(key) ?? null, + setItem: (key: string, value: string) => { values.set(key, value); }, + removeItem: (key: string) => { values.delete(key); }, + }; +} + +function journey(patch: Partial): OnboardingJourney { + return { + showChecklist: false, + checklistPending: false, + userId: USER, + items: [], + label: "", + celebration: false, + activeHint: null, + hintText: "", + showChartDot: false, + tabHasDot: () => false, + dismissChecklist: () => {}, + dismissHint: async () => true, + go: () => {}, + setChartTab: () => {}, + markChartVisited: () => {}, + setChecklistOpen: () => {}, + setLive: () => {}, + registerHome: () => {}, + hintWatch: false, + motionEnabled: false, + ...patch, + }; +} + +function render(value: OnboardingJourney) { + return renderToStaticMarkup( + + + , + ); +} + +test("while the answer is read, the last face holds the spot", () => { + const storage = memoryStorage(); + const original = (globalThis as { localStorage?: unknown }).localStorage; + (globalThis as { localStorage?: unknown }).localStorage = storage; + try { + assert.equal(render(journey({ checklistPending: true })), "", "nothing held for a browser that never saw the checklist"); + storage.setItem(`onboarding_checklist_face:${USER}`, JSON.stringify({ label: "启程 2/5", done: 2, total: 5 })); + const held = render(journey({ checklistPending: true })); + assert.match(held, /onboarding-checklist-trigger/); + assert.match(held, /启程 2\/5/); + assert.match(held, /disabled/); + assert.equal(render(journey({ checklistPending: false })), "", "an answer of no checklist shows nothing"); + assert.deepEqual(readChecklistFace(USER), { label: "启程 2/5", done: 2, total: 5 }); + assert.equal(readChecklistFace(""), null); + } finally { + (globalThis as { localStorage?: unknown }).localStorage = original; + } +}); + +test("a shown checklist renders its face while the panel code loads", () => { + const html = render(journey({ + showChecklist: true, + label: "启程 1/2", + items: [ + { id: "ask", label: "问一个问题", done: true }, + { id: "chart", label: "看看星盘", done: false }, + ] as unknown as OnboardingJourney["items"], + })); + assert.match(html, /onboarding-checklist-trigger/); + assert.match(html, /启程 1\/2/); +}); diff --git a/frontend/tests/onboarding-hint-observer.test.tsx b/frontend/tests/onboarding-hint-observer.test.tsx index 0e34f8f1..9beeee0d 100644 --- a/frontend/tests/onboarding-hint-observer.test.tsx +++ b/frontend/tests/onboarding-hint-observer.test.tsx @@ -29,6 +29,8 @@ function facts(patch: Partial = {}): HintFacts { function journey(hintWatch: boolean): OnboardingJourney { return { showChecklist: false, + checklistPending: false, + userId: "", items: [], label: "", celebration: false, diff --git a/frontend/tests/onboarding-motion-gate.test.tsx b/frontend/tests/onboarding-motion-gate.test.tsx index 7b71cc07..3d68208a 100644 --- a/frontend/tests/onboarding-motion-gate.test.tsx +++ b/frontend/tests/onboarding-motion-gate.test.tsx @@ -12,6 +12,8 @@ import { createClientLifecycleHarness } from "./react-client-lifecycle-test-supp function closedJourney(): OnboardingJourney { return { showChecklist: false, + checklistPending: false, + userId: "", items: [], label: "", celebration: false, diff --git a/frontend/tests/report-export-drawer.test.tsx b/frontend/tests/report-export-drawer.test.tsx index 10455440..57d12306 100644 --- a/frontend/tests/report-export-drawer.test.tsx +++ b/frontend/tests/report-export-drawer.test.tsx @@ -273,6 +273,11 @@ test("mounted drawer closes from Escape and the overlay, and restores focus", as fireListeners(elementListeners.bag(overlay), "click", click, false); }); assert.equal(closes, 2); + // 原值(365a623fc):删掉了点「关闭导出」关一次的断言 + // 新值:补回,点「关闭导出」再关一次 + // 原因:按钮还在页面上,是用户最常用的关法;只测 Esc 和遮罩会漏掉它(第 2 轮验收第 4 条) + await h.event(allNodes(h).find(node => node.getAttribute("aria-label") === "关闭导出")!); + assert.equal(closes, 3); await h.render(null); assert.equal(document.activeElement, previous); } finally { elementListeners.restore(); await h.close(); } }); diff --git a/frontend/tests/starter-chart-signs-20261027.test.ts b/frontend/tests/starter-chart-signs-20261027.test.ts new file mode 100644 index 00000000..7c18d0b3 --- /dev/null +++ b/frontend/tests/starter-chart-signs-20261027.test.ts @@ -0,0 +1,123 @@ +/** + * BUG-1459: the 「只有月亮」 starter question and the 「那一刻,{名字}的上升在……」 + * sky line read the already-computed chart once (product decision 2026-10-27). + * The ascendant is used only when it cannot change inside the reported time. + * Golden engine response, fictional profile. + */ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import test from "node:test"; + +import { assembleChartView } from "../src/lib/chart-view-load.ts"; +import type { ChartViewOk } from "../src/lib/chart-view-contract.ts"; +import { CHART_VIEW_LAYERS } from "../src/lib/chart-view-engine.ts"; +import { bindCurrentSubjectAccount, resetSubjectCatalogForTests, setCurrentSubject } from "../src/lib/current-subject.ts"; +import { resetSecondaryPageDataForTests } from "../src/lib/secondary-page-data.ts"; +import { loadStarterSignsFromChart, resetStarterChartSignsForTests, starterSignsFromChartView } from "../src/lib/starter-chart-signs.ts"; +import { otherSkySubjectLine, readStarterSigns, resetStarterSignsForTests, starterQuestions } from "../src/lib/starter-questions.ts"; + +const golden = JSON.parse( + readFileSync(new URL("./fixtures/chart-view-golden.json", import.meta.url), "utf8"), +) as { chart: Record; varga_full: Record; chara: Record }; + +const profile = { + name: "示例", + date: "1990-06-15", + time: "12:00", + placeLabel: "北京", + latitude: 39.9042, + longitude: 116.4074, + timezoneOffset: 8, + timezoneId: "Asia/Shanghai", + ayanamsa: "raman", + birthTimeStatus: "confirmed", +}; + +async function goldenView(): Promise { + const result = await assembleChartView({ + userId: "user-1", + profile, + asOf: "2026-09-15", + layers: CHART_VIEW_LAYERS, + postEngine: async (path) => { + if (path === "/api/chart") return { status: "ok", payload: golden.chart }; + if (path === "/api/varga_full") return { status: "ok", payload: golden.varga_full }; + if (path === "/api/dasha/chara") return { status: "ok", payload: golden.chara }; + return { status: "http_error", path, elapsedMs: 1, httpStatus: 500 }; + }, + }); + assert.equal(result.body.status, "ok"); + return result.body as ChartViewOk; +} + +const FIXED = { state: "fixed" as const, signs: ["x"], windowMinutes: 31, precisionKind: "minutes" as const }; +const MAY_CHANGE = { state: "may_change" as const, signs: ["x", "y"], changeAt: ["12:01"], windowMinutes: 31, precisionKind: "minutes" as const }; + +test("signs come from the computed chart; the ascendant only when it is fixed", async () => { + const view = await goldenView(); + const moon = view.vedic.planets.find((row) => row.name === "Moon")?.sign; + const ascendant = view.vedic.ascendant?.sign; + assert.ok(moon && ascendant); + assert.deepEqual(starterSignsFromChartView({ ...view, lagnaPrecheck: FIXED }, "self"), { ascendantSign: ascendant, moonSign: moon }); + assert.deepEqual(starterSignsFromChartView({ ...view, lagnaPrecheck: MAY_CHANGE }, "self"), { ascendantSign: null, moonSign: moon }); + assert.deepEqual(starterSignsFromChartView({ ...view, lagnaPrecheck: undefined }, "self"), { ascendantSign: null, moonSign: moon }); + assert.equal(starterSignsFromChartView({ ...view, subjectId: "person-1" }, "self"), null, "another person's chart is never used"); + assert.equal(starterSignsFromChartView({ status: "chart_unavailable", billed: false, message: "x" } as never, "self"), null); + + const moonOnly = starterQuestions({ kind: "self" }, { ascendantSign: null, moonSign: moon })[0]; + assert.match(moonOnly, /^月亮在.+,我是个什么样的人$/); + assert.match(otherSkySubjectLine("小舟", ascendant), /^那一刻,小舟的上升在.+$/); +}); + +test("one read through the chart page request; a failure keeps today's wording", async () => { + const view = await goldenView(); + const originalFetch = globalThis.fetch; + const urls: string[] = []; + resetSubjectCatalogForTests(); + resetSecondaryPageDataForTests(); + resetStarterSignsForTests(); + resetStarterChartSignsForTests(); + bindCurrentSubjectAccount("11111111-1111-4111-8111-111111111111", ["person-1"]); + setCurrentSubject("person-1"); + try { + globalThis.fetch = (async (input: unknown) => { + urls.push(String(input)); + return new Response(JSON.stringify({ ...view, accountId: "user-1", profileFingerprint: "fp-1", subjectId: "person-1", lagnaPrecheck: FIXED }), { status: 200 }); + }) as typeof fetch; + const signs = await loadStarterSignsFromChart("person-1"); + assert.ok(signs?.ascendantSign && signs.moonSign); + assert.deepEqual(readStarterSigns("person-1"), signs); + assert.equal(urls.length, 1); + assert.match(urls[0], /^\/api\/chart-view\?subject=person-1$/); + await loadStarterSignsFromChart("person-1"); + assert.equal(urls.length, 1, "the cached chart is reused"); + + resetSecondaryPageDataForTests(); + resetStarterSignsForTests(); + resetStarterChartSignsForTests(); + globalThis.fetch = (async (input: unknown) => { + urls.push(String(input)); + return new Response("{}", { status: 500 }); + }) as typeof fetch; + const before = urls.length; + assert.equal(await loadStarterSignsFromChart("person-1"), null); + assert.deepEqual(readStarterSigns("person-1"), { ascendantSign: null, moonSign: null }); + assert.equal(await loadStarterSignsFromChart("person-1"), null); + assert.equal(urls.length, before + 1, "a failed read is not retried in a loop"); + } finally { + globalThis.fetch = originalFetch; + resetSubjectCatalogForTests(); + resetSecondaryPageDataForTests(); + resetStarterSignsForTests(); + resetStarterChartSignsForTests(); + } +}); + +test("the starter questions and the sky reveal ask for the chart signs, loaded on demand", () => { + const questions = readFileSync(new URL("../src/components/starter-questions.tsx", import.meta.url), "utf8"); + const reveal = readFileSync(new URL("../src/hooks/use-birth-sky-reveal.ts", import.meta.url), "utf8"); + const page = readFileSync(new URL("../src/app/(app)/page.tsx", import.meta.url), "utf8"); + assert.match(questions, /import\("@\/lib\/starter-chart-signs"\)/); + assert.match(reveal, /if \(id !== "self"\) \{\s*void import\("@\/lib\/starter-chart-signs"\)/); + assert.doesNotMatch(page, /starter-chart-signs|secondary-page-data/); +}); diff --git a/frontend/tests/subject-catalog-refresh-20261027.test.ts b/frontend/tests/subject-catalog-refresh-20261027.test.ts new file mode 100644 index 00000000..62705506 --- /dev/null +++ b/frontend/tests/subject-catalog-refresh-20261027.test.ts @@ -0,0 +1,92 @@ +/** + * BUG-1455: after the page has revealed with a people list, a reload must not + * drop the list (that put the whole chat back on 「正在载入账户」), and a failed + * reload keeps the old list. Synthetic account and person only. + */ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import test from "node:test"; + +import { + currentSubjectCatalogReady, + loadSubjectCatalog, + peekSubjectCatalog, + refreshSubjectCatalog, + resetSubjectCatalogForTests, + subscribeSubjectCatalog, +} from "../src/lib/current-subject.ts"; +import { emptyProfile, type Account } from "../src/lib/home-types.ts"; + +const account: Account = { + user: { id: "11111111-1111-4111-8111-111111111111", email: null }, + profile: { ...emptyProfile, name: "Synthetic" }, + avatar: null, credits: 10, isAdmin: false, adminUrl: null, + rectificationPriceCredits: 0, activeSubscription: null, + hasConfirmedBirthTime: false, hasUsableBirthTime: false, +}; + +const person = { + id: "person-1", + role: "other", + profile: { ...emptyProfile, name: "Synthetic Friend" }, + updated_at: "2026-10-27T00:00:00.000Z", +}; + +function stubFetch(libraryResponses: Array<() => Response>) { + const original = globalThis.fetch; + let libraryCalls = 0; + globalThis.fetch = (async (input: unknown) => { + const url = String(input); + if (url === "/api/account") return new Response(JSON.stringify(account), { status: 200 }); + if (url === "/api/chart-profiles") { + const next = libraryResponses[Math.min(libraryCalls, libraryResponses.length - 1)]; + libraryCalls += 1; + return next(); + } + return new Response("{}", { status: 404 }); + }) as typeof fetch; + return () => { globalThis.fetch = original; }; +} + +const ok = () => new Response(JSON.stringify({ profiles: [person] }), { status: 200 }); +const failed = () => new Response("{}", { status: 500 }); + +test("a refresh after reveal keeps the list ready while it reloads", async () => { + resetSubjectCatalogForTests(); + const restore = stubFetch([ok]); + try { + await loadSubjectCatalog(); + assert.equal(currentSubjectCatalogReady(), true); + const readiness: boolean[] = []; + const off = subscribeSubjectCatalog(() => readiness.push(currentSubjectCatalogReady())); + const pending = refreshSubjectCatalog(); + assert.equal(currentSubjectCatalogReady(), true, "the page must not fall back to the loading screen"); + assert.ok(peekSubjectCatalog()?.some((row) => row.id === "person-1")); + await pending; + off(); + assert.ok(readiness.every(Boolean), `ready never drops: ${readiness.join(",")}`); + } finally { + restore(); + resetSubjectCatalogForTests(); + } +}); + +test("a failed refresh keeps the full list instead of a degraded one", async () => { + resetSubjectCatalogForTests(); + const restore = stubFetch([ok, failed]); + try { + await loadSubjectCatalog(); + await assert.rejects(refreshSubjectCatalog()); + assert.equal(currentSubjectCatalogReady(), true); + assert.ok(peekSubjectCatalog()?.some((row) => row.id === "person-1")); + } finally { + restore(); + resetSubjectCatalogForTests(); + } +}); + +test("onboarding reloads the list without invalidating it", () => { + const source = readFileSync(new URL("../src/hooks/use-profile-onboarding.ts", import.meta.url), "utf8"); + assert.doesNotMatch(source, /invalidateSubjectCatalog/); + assert.match(source, /refreshSubjectCatalog\(\)/); +});