diff --git a/CHANGELOG.md b/CHANGELOG.md index 8d548832..77bb1e82 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,12 @@ - 点「我的报告」不再先等一轮服务器渲染;侧栏悬停时已开始读的列表会直接用上,不再重读一遍。之前读过的列表进页即显示,后台再刷新。 - 「过往的报告」每次显示 10 份,底部「加载更多」再往前 10 份,直到没有更早的;之前固定只显示最新 20 份,更早的看不到。生成中自动刷新不会把已经展开的列表收回去。Skill 版本不 bump。 +## 2026-09-30 — 星盘等待改为「宫位依次点亮」;星盘档案有加载动画;首页今日一句不再跳出来 + +- 星盘加载时不再在空盘中间放 logo 圆环:盘线静止淡显,十二宫按宫序一格一格泛起淡光再退去;西洋盘沿圆周一格格亮。盘的大小与出结果后一致,揭幕不跳。 +- 星盘档案读人物列表时显示与星历、报告相同的轨道动画,不再只写「人物列表还在读」;顶部人物切换弹层同样。 +- 首页问候下面那句「今天月亮在……」:本人资料不再等人物目录,揭幕时就在;之后出现或更换时淡入(240ms),不再突然把问候顶上去。Skill 版本不 bump。 + ## 2026-09-30 — 星盘符号气泡不再闪;星盘表格恢复圆角 - 鼠标停在星盘里的行星、上升或星座编号上,说明气泡会稳定显示;之前鼠标每动一下气泡就消失再出现。 diff --git a/docs/BUG_HISTORY.md b/docs/BUG_HISTORY.md index cb349a0c..308203d9 100644 --- a/docs/BUG_HISTORY.md +++ b/docs/BUG_HISTORY.md @@ -14990,3 +14990,17 @@ - 相关记录:BUG-1123 - 修复版本:分支 `codex/reports-list-paging-20260930` +## BUG-1125 | 首页揭幕后今日一句才出现,问候「突然跳一下」 + +- 状态:resolved(代码 + 回归测试;待部署,真机待产品) +- 首次发现 / 最近更新:2026-09-30 / 2026-09-30 +- 影响面:`frontend/src/components/daily-starlanguage-binder.tsx`、`frontend/src/components/starter-home.tsx`(`StarterGreeting`)、`globals.css` `.starter-greeting-sub`。 +- 现象:产品反馈「首页加载完之后这个标语会突然跳一下才出来」——问候语底对齐在输入框上方,下方一句今日星语在揭幕后才出现 / 换字,把问候整体顶上去。 +- 触发条件:冷启动首页,当前人物为本人,人物目录(`/api/chart-library`)晚于准备阶段其余数据返回。 +- 根因:Binder 在人物目录未就绪(`!subjectReady`)时把今日卡置为 `unavailable`;`unavailable` 计为已结算,准备阶段不再等它,首页先揭幕;目录回来后 Binder 才去取卡(pending → ready),副行在揭幕后出现或从静态句换成真句。本人资料本不依赖人物目录,等待是多余的。另外副行无任何进入动效,出现即硬切。 +- 修复:只有当前人物是他人时才等人物目录,且等待期间置 `pending`(揭幕会等它,受 4s 准备超时约束);本人直接取卡。副行按文本 `key` 重挂载,进入时 240ms 淡入 + 4px 上移,减少动效时无动画。 +- 验证:`daily-starlanguage.test.ts` 守卫断言改写(三栏注释);新增 `loading-motion-20260930.test.tsx`(副行淡入规则与 key);前端全量失败名单与基线逐条一致。浏览器级「冷启动无跳动」留真机(docs/testing 清单)。 +- 防复发:准备阶段里「还不知道」必须是 pending,不得写成 unavailable;首页揭幕后出现的文字一律带进入动效。 +- 相关记录:BUG-1040(暖快照)、BUG-1104 / 1109 / 1110(今日句库与预取) +- 修复版本:分支 `codex/loading-motion-20260930` + diff --git a/docs/testing/loading-motion-20260930.md b/docs/testing/loading-motion-20260930.md new file mode 100644 index 00000000..80270b2d --- /dev/null +++ b/docs/testing/loading-motion-20260930.md @@ -0,0 +1,11 @@ +# 真机验收:星盘等待动画 / 星盘档案加载 / 首页今日一句(2026-09-30) + +分支 `codex/loading-motion-20260930`(BUG-1125)。电脑 Chrome 与 iPhone Safari 各走一遍。 + +1. 打开 `/chart`(网速慢时更明显,可在开发者工具里选「Fast 4G」):等待时空盘线条静止淡显,十二个宫格按 1→12 一格格泛起淡橙光再退去,盘中间没有 logo 圆环;出结果时盘的大小不变。 +2. 切到「西洋」:等待时外圈十二格沿圆周依次亮起。 +3. 系统设置打开「减弱动态效果」,重复 1:只见静止淡线,没有闪动。 +4. 打开 `/people`:读列表时页面中间是与星历、报告相同的轨道动画,不再只写「人物列表还在读」。 +5. 首页顶部人物切换按钮打开弹层:列表还没读到时是小轨道动画。 +6. 当前人物为本人,刷新首页:加载屏消失时问候语下面那句「今天月亮在……」已经在,问候语不再往上跳。 +7. 切换当前人物为档案里的其他人再回首页:那句出现或换字时是淡入,不是硬切。 diff --git a/frontend/DESIGN.md b/frontend/DESIGN.md index a2f3ceae..e00b6983 100644 --- a/frontend/DESIGN.md +++ b/frontend/DESIGN.md @@ -413,7 +413,7 @@ The birth-time rectification session is the consultation transcript plus a house - One line, 46px on desktop. Sidebar trigger, session title, and the credit pill. It was 68px and carried a second line reading `分析对象:{盘名}`. - Ordinary consultation: the person is a quiet button beside the title (`.chat-profile-picker-trigger`, still `.chat-header-chart`). The accessible name is `当前星盘:{人物名}`; the visible text is only the current person's name. Click, Enter, and Space open a popover. `aria-expanded` and `aria-controls` point at that popover. Escape, an outside click, and a successful choice close it and return focus to the button with `preventScroll: true`; focus must not move the chat panel or transcript. The button and each row are at least 44px. The popover uses the elevated canvas, warm hairline, and the same 120ms opacity-plus-4px motion as the model selector; reduced motion drops the translation. - Before the first message the list selects a person and updates that empty session's binding. Self is the visible default. After the first message the current person stays selected; another person offers only `新建会话并使用此人物` and does not rebind the old session. That action waits until the current request ends. -- Loading reads `人物列表还在读`. Failure reads `人物列表暂时读不出来` with `重试`. When the people API itself fails, the popover reads `暂时读不到其他人` and lists only the signed-in person; history stays on that person and the page does not drop into the recoverable error screen. An incomplete row reads `这份资料还不完整` and cannot be chosen. A deleted person reads `资料已删除 · {名字}`; the old session stays readable and sending is blocked. None of these states use a spinner. +- Loading shows the small orbit (`OrbitWaiting`, 2026-09-30), with `人物列表还在读` for screen readers only. Failure reads `人物列表暂时读不出来` with `重试`. When the people API itself fails, the popover reads `暂时读不到其他人` and lists only the signed-in person; history stays on that person and the page does not drop into the recoverable error screen. An incomplete row reads `这份资料还不完整` and cannot be chosen. A deleted person reads `资料已删除 · {名字}`; the old session stays readable and sending is blocked. None of these states use a spinner. - Rectification, reports, and daily horoscope do not get this control. Their quiet note, when present, remains the static `.chat-header-chart` span and still drops out below 768px. The consultation button stays on narrow screens and ellipsizes so the header remains one line. - 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. @@ -565,7 +565,7 @@ one skeleton, none of them carrying the sidebar. ### Short waits on secondary pages (2026-09-30, BUG-1113) -The chart page, the ephemeris and the report list wait with one motion: the home loading screen's orbit ring at 40px (`OrbitWaiting`), centred in the empty chart grid or in the page. No sentence is shown; the old 「……还没拿到。」 copy is kept only as the screen-reader status. The chart's breathing empty grid stays (BUG-1016 exception). +The ephemeris, the report list and the people archive (2026-09-30) wait with one motion: the home loading screen's orbit ring at 40px (`OrbitWaiting`), centred in the page. No sentence is shown; the old 「……还没拿到。」 / 「人物列表还在读」 copy is kept only as the screen-reader status. The person picker popover shows the same orbit while its list loads. The chart page is the exception (BUG-1016): since 2026-09-30 the empty chart itself is the wait — faint still lines while the houses light up one after another in house order — with no orbit on top. ### Login consent and the legal pages (2026-09-30) @@ -648,6 +648,11 @@ page. Three parts now, in reading order: the card is ready it shows the existing static sentence「今天的星语还没写出来。」 with `role="status"` and no `aria-busy`, spinner or placeholder; the reveal rule does not change. Without a natal minute it is not rendered at all. + **Entry (2026-09-30, BUG-1125):** the owner's line no longer waits for the + person catalog, so the reveal holds for it and it is there on the first frame; + whenever the text appears or changes later (another person's card, the + prepare timeout) it fades in — opacity 0 → 1 and a 4px rise over 240ms, + keyed on the text; reduced motion shows it at once. - **Composer**: unchanged, and *in the same DOM position it occupies during a live session*. Nothing remounts when the first message lands, so an in-flight draft, focus, or queued card survives the transition. Its width is @@ -833,7 +838,7 @@ or user IDs. | --- | --- | --- | ---: | --- | | 整页 / 整块阻塞 | 首页揭幕前的两阶段载入(载入账户 → 准备对话)、生时评估浮层 | `AppLoadingIndicator`(轨道环 `app-loading-orbit`) | 1.4s linear | 全局 `*` 规则把循环收成一帧;不要改它的 DOM | | 行内 / 局部等待 | 出生地解析、两个会话面时间线的 live 步、兜底活动面板的 live 行、个人报告列表与详情 | `InlineSpinner`(`inline-spin`) | 0.8s linear | `animation: none`,收成静止圆点,不要半圈圆弧 | -| 盘位骨架(仅星盘页) | D1 / 非 D1 分盘 / 西洋盘尚未返回 | `VedicChartSvg skeleton` / `WesternWheelSvg skeleton`;统一 `.chart-page-skeleton` | 2.4s ease-in-out,opacity 0.35 ↔ 0.6 | 停止动画,opacity 0.5 | +| 盘位骨架(仅星盘页) | D1 / 非 D1 分盘 / 西洋盘尚未返回 | `VedicChartSvg skeleton` / `WesternWheelSvg skeleton`;统一 `.chart-page-skeleton`;2026-09-30 起盘线静止(stroke-opacity .55),十二宫(西洋盘为十二格扇区 `.chart-skeleton-sector`)按宫序依次泛起 `--color-action-strong` 淡光(`chart-skeleton-house`,fill-opacity 0 → .18 → 0),盘上不再叠轨道环 | 每宫间隔 150ms,一轮 2.4s ease-in-out | 停止动画,只留静止淡线 | | 流式生成中 | 引导语打字、时间线 summary 与 live 行的文案 | `onboarding-caret` / `agent-activity-shimmer` | 700ms steps / 1.6s linear | 保持现有全局降级 | | 流式正文的放字节奏 | 普通对话与校正面的回答正文(`stream-frame-buffer`) | 每帧最多 4 字(≈ 240 字/秒),积压超过 600 字才按 1/12 追赶;普通对话的流结束后剩余文字按同一节奏在 1.5 s 内写完(`settle({ paced: true })`),最后一帧才算 settled;停止 / 失败 / 断线 / 页面隐藏、以及校正面的流尾仍一帧全放(2026-09-28,BUG-1075) | 逐帧 | 不涉及 CSS 动画,无需降级 | @@ -1062,7 +1067,7 @@ D40 / D45 / D60 的表格上方,出生时间不是「已校正」时多一句 **不再按断点复制参数。** 原本同一组参数有三种画法:中宫叠一张 `.chart-page-center-card`(手机两行、桌面六行),盘下方再来一张 `.chart-page-params-below` 重复那六行,由 media query 三选一。同样的事实画三遍、靠断点开关,改一处要改三处。键值表让每个字段只出现一次,`.chart-page-center-card` / `-compact` / `-extra` / `.chart-page-params-below` / `.chart-page-boundary-desktop` 五个类连同它们的 767/768 开关块一并删除,由 `chart-page-view.test.tsx` 守着不许回来。 -桌面(≥1024)盘面与参数表左右并排,行星表是跨两列的整行——它的列宽需要完整版心。2026-09-29 起桌面印度盘宽 440px;当晚(BUG-1100,产品「框太丑」)上限从绘图区移到外框 `.chart-page-vedic-stage`(440px + 两侧 `--space-3` 内边距 + 边框),外框贴合星图,不再是一张宽卡片中间放一张窄盘;手机端外框不设上限;西洋桌面绘图区上限 480px。低于此断点的结果盘不收紧,参数区和表格不缩。等待盘与结果盘复用同一桌面列宽与绘图容器,不再使用独立 400px 等待盘上限;印度等待绘图容器以 400/452 比例预留结果图例带,内部空 SVG 仍为 400×400,避免揭幕时外框和说明区纵向跳动,不改共享绘图。只有盘位呼吸,失败后退出,参数表不新增骨架。 +桌面(≥1024)盘面与参数表左右并排,行星表是跨两列的整行——它的列宽需要完整版心。2026-09-29 起桌面印度盘宽 440px;当晚(BUG-1100,产品「框太丑」)上限从绘图区移到外框 `.chart-page-vedic-stage`(440px + 两侧 `--space-3` 内边距 + 边框),外框贴合星图,不再是一张宽卡片中间放一张窄盘;手机端外框不设上限;西洋桌面绘图区上限 480px。低于此断点的结果盘不收紧,参数区和表格不缩。等待盘与结果盘复用同一桌面列宽与绘图容器,不再使用独立 400px 等待盘上限;印度等待绘图容器以 400/452 比例预留结果图例带,内部空 SVG 仍为 400×400,避免揭幕时外框和说明区纵向跳动,不改共享绘图。只有盘位动(2026-09-30 起为宫位依次点亮,不再整张呼吸),失败后退出,参数表不新增骨架。 **符号说明(2026-09-29;当晚改为气泡,BUG-1101)。** `/chart` 开启共享 SVG 的可选交互模式:说明是标记旁的小气泡(`.chart-symbol-bubble`,最宽 220px,`--color-canvas` 底 + 细边 + `--shadow-elevated`),默认在标记上方 8px,离外框顶不足 48px 时翻到下方,左右被夹在外框内;气泡不接收指针,不会挡住旁边的标记。电脑:鼠标悬停出现、移开消失,点击与键盘聚焦同样出现。手机:轻点标记出现,触摸产生的离开事件不关它,点盘外任意处或 3 秒后自动消失。盘下不再有「停在或轻点……」提示行和说明行;读屏器仍从一个隐藏的 `role="status"` 读到同一句话;Esc、离开、失焦可关闭,切盘/人物/数据身份变化清理旧说明。行星与其逆行短线共用一个目标;西洋 caption 和命中圆用一致的行星解析,原点击过滤相位及再次点击取消保留。报告、校正、静态导出默认不启用该模式;纯绘图层不持有 hooks,静态 SVG 白名单不扩。 diff --git a/frontend/src/app/globals.css b/frontend/src/app/globals.css index 5ea5aac3..864adb0e 100644 --- a/frontend/src/app/globals.css +++ b/frontend/src/app/globals.css @@ -986,6 +986,7 @@ button:disabled:where(:not([data-slot="button"])) { cursor: default; opacity: .4 .chat-profile-picker-title { margin: 0; color: var(--color-ink); font-family: var(--font-display); font-size: var(--type-title-sm); font-weight: 500; } .chat-profile-picker-status { margin: var(--space-2) 0 0; color: var(--color-ink-secondary); font-size: var(--type-caption); line-height: 1.45; } .chat-profile-picker-status p { margin: 0; } +.chat-profile-picker-waiting { margin-top: var(--space-2); } .chat-profile-picker-retry { min-height: 44px; margin-top: var(--space-2); padding: 0 var(--space-3); border: 0; border-radius: var(--radius-md); background: transparent; color: var(--color-action); cursor: pointer; font: inherit; font-size: var(--type-caption); } .chat-profile-picker-options { display: grid; gap: var(--space-1); margin: var(--space-2) 0 0; } .chat-profile-picker-option { width: 100%; min-height: 44px; display: grid; grid-template-columns: minmax(0, 1fr) 20px; align-items: center; gap: var(--space-3); padding: var(--space-2) var(--space-3); border: 1px solid transparent; border-radius: var(--radius-md); background: transparent; color: var(--color-ink-secondary); cursor: pointer; text-align: left; font: inherit; transition: border-color 120ms ease-out, background-color 120ms ease-out, color 120ms ease-out; } @@ -2413,6 +2414,16 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class -webkit-line-clamp: 2; line-clamp: 2; } +/* The line enters softly (BUG-1125): opacity and a 4px rise, 240ms, whenever + its text first appears or changes. No placeholder, no spinner. */ +.starter-greeting-sub { animation: starter-greeting-sub-in 240ms var(--ease-out) both; } +@keyframes starter-greeting-sub-in { + from { opacity: 0; transform: translateY(4px); } + to { opacity: 1; transform: none; } +} +@media (prefers-reduced-motion: reduce) { + .starter-greeting-sub { animation: none; } +} /* Entry pills, under the composer. The two product entrances keep their place in the product but not their weight: they were 132px cards competing with the @@ -5361,16 +5372,30 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class width: 100%; min-width: 0; } -.chart-page-skeleton { - animation: chart-skeleton-breathe 2.4s ease-in-out infinite; -} +/* The waiting chart (2026-09-30, 宫位依次点亮): the empty drawing's lines stay + still and faint while the twelve houses (the western wheel's twelve sectors) + take turns glowing in house order, 150ms apart, one wave every 2.4s. Only + the fill moves; the box is the finished chart's box (BUG-1101). */ .chart-page-skeleton svg { display: block; margin-top: 0; } -@keyframes chart-skeleton-breathe { - 0%, 100% { opacity: 0.35; } - 50% { opacity: 0.6; } +.chart-page-skeleton .personal-report-chart-cell, +.chart-page-skeleton .chart-page-western-ring, +.chart-page-skeleton .chart-page-western-spoke { stroke-opacity: 0.55; } +.chart-page-skeleton .personal-report-chart-cell, +.chart-page-skeleton .chart-skeleton-sector { + fill: var(--color-action-strong); + fill-opacity: 0; + stroke-linejoin: round; + animation: chart-skeleton-house 2.4s ease-in-out infinite; + animation-delay: calc(var(--house-index, 0) * 150ms); +} +.chart-page-skeleton .chart-skeleton-sector { stroke: none; } +@keyframes chart-skeleton-house { + 0%, 40%, 100% { fill-opacity: 0; } + 14% { fill-opacity: 0.18; } } @media (prefers-reduced-motion: reduce) { - .chart-page-skeleton { animation: none; opacity: 0.5; } + .chart-page-skeleton .personal-report-chart-cell, + .chart-page-skeleton .chart-skeleton-sector { animation: none; fill-opacity: 0; } } /* 「那一刻的天空」: the chart header's one extra action, tinted with the accent and a sparkle so it reads as an invitation rather than a thin outline (2026-09-29). @@ -5424,14 +5449,8 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class width: max-content; } .chart-symbol-bubble.is-below { transform: translate(-50%, 8px); } -/* Waiting copy sits over the breathing empty chart, centred, so the waiting - stage and the finished one are the same height. */ +/* The waiting stage carries only an sr-only sentence next to the drawing. */ .chart-page-waiting-plot { position: relative; } -.chart-page-skeleton-copy { - position: absolute; - inset: 50% var(--space-4) auto; - transform: translateY(-50%); -} .chart-page-symbol-hit { fill: transparent; stroke: none; } .chart-page-symbol-target { cursor: help; } .chart-page-symbol-target:focus-visible { diff --git a/frontend/src/components/chart-page/chart-page-skeleton.tsx b/frontend/src/components/chart-page/chart-page-skeleton.tsx index e9b6ee13..614bb6f3 100644 --- a/frontend/src/components/chart-page/chart-page-skeleton.tsx +++ b/frontend/src/components/chart-page/chart-page-skeleton.tsx @@ -1,20 +1,20 @@ import { EMPTY_NORTH_INDIAN_CHART, VedicChartSvg } from "@/components/personal-report/vedic-chart-svg"; import { EMPTY_WESTERN_WHEEL, WesternWheelSvg } from "./western-wheel-svg"; -import { OrbitWaiting } from "@/components/orbit-waiting"; /** Only chart positions opt in; the shared page shell remains static. */ export function ChartPageSkeleton({ copy, western = false }: { copy: string; western?: boolean }) { return (
+ {/* The empty drawing is the whole wait (2026-09-30, product: 「星盘的加载动画 + 太丑了」, chose 宫位依次点亮): faint static lines while the houses light up + one after another in house order. No orbit logo over it any more. Same + box as the finished chart, so the reveal does not jump (BUG-1101). */} - {/* Over the empty drawing, not under it, so the waiting stage and the - finished one are the same height (BUG-1101). An orbit, not a sentence - (2026-09-30); the sentence is for screen readers. */} - + {copy}
); diff --git a/frontend/src/components/chart-page/western-wheel-svg.tsx b/frontend/src/components/chart-page/western-wheel-svg.tsx index 228a1b6d..ca332864 100644 --- a/frontend/src/components/chart-page/western-wheel-svg.tsx +++ b/frontend/src/components/chart-page/western-wheel-svg.tsx @@ -1,4 +1,4 @@ -import type { MouseEvent, PointerEvent } from "react"; +import type { CSSProperties, MouseEvent, PointerEvent } from "react"; import type { ChartViewWesternOk } from "@/lib/chart-view-contract"; import { SIGN_ORDER, signZh, type SignName } from "@/lib/chart-view-labels"; @@ -183,6 +183,15 @@ export function WesternWheelSvg({ /> ); })} + {/* Waiting wheel only: twelve sectors that light up in turn around the circle. */} + {skeleton && SIGN_ORDER.map((sign, index) => ( + + ))} diff --git a/frontend/src/components/daily-starlanguage-binder.tsx b/frontend/src/components/daily-starlanguage-binder.tsx index 17cf9e90..70c25f1e 100644 --- a/frontend/src/components/daily-starlanguage-binder.tsx +++ b/frontend/src/components/daily-starlanguage-binder.tsx @@ -5,6 +5,7 @@ import { useEffect } from "react"; import { useCurrentSubject, useCurrentSubjectProfile, useCurrentSubjectReady } from "@/lib/current-subject"; import { resolveBirthTimeConsultationRoute } from "@/lib/birth-time-consultation-consent"; import { missingOtherProfileStep } from "@/lib/home-profile"; +import { SELF_SUBJECT_ID } from "@/lib/subject-birth"; import { calendarDateInTimeZone, dailyStarlanguageProfileKey } from "@/lib/daily-starlanguage"; import type { BootstrapPhase } from "@/lib/home-bootstrap"; import { readHomeWarmDaily, rememberHomeWarmDaily } from "@/lib/home-warm-snapshot"; @@ -36,7 +37,18 @@ export function DailyStarlanguageBinder(props: { const natalMinuteAvailable = route?.kind === "consult" && Boolean(route.time); useEffect(() => { - if (bootstrapPhase === "account" || !accountId || !subjectReady || !profile || !profileComplete || !natalMinuteAvailable) { + // Only another person needs the catalog; the owner's profile is already here. + // While it loads the card is "pending", not "unavailable": "unavailable" counted + // as settled, the home revealed without the line, and the line jumped in under + // the greeting a moment later (BUG-1125). Pending holds the reveal (bounded by + // the prepare timeout) so the line arrives with the first frame. + const waitingForCatalog = !subjectReady && subjectId !== SELF_SUBJECT_ID; + if (bootstrapPhase !== "account" && accountId && waitingForCatalog) { + let cancelled = false; + queueMicrotask(() => { if (!cancelled) setDailyStarlanguage({ kind: "pending" }); }); + return () => { cancelled = true; }; + } + if (bootstrapPhase === "account" || !accountId || waitingForCatalog || !profile || !profileComplete || !natalMinuteAvailable) { let cancelled = false; queueMicrotask(() => { if (!cancelled) setDailyStarlanguage({ kind: "unavailable" }); }); return () => { cancelled = true; }; diff --git a/frontend/src/components/people/people-page.tsx b/frontend/src/components/people/people-page.tsx index 374cf039..b39784d5 100644 --- a/frontend/src/components/people/people-page.tsx +++ b/frontend/src/components/people/people-page.tsx @@ -298,8 +298,8 @@ export function PeoplePage() { } return ( - - {status === "loading" ?

人物列表还在读

: null} + // The shared waiting orbit, like 星盘 / 星历 / 报告 (2026-09-30, product: 「星盘档案这里也要加一个加载动画」). + {status === "failed" ? (

人物列表暂时读不出来

diff --git a/frontend/src/components/personal-report/vedic-chart-svg.tsx b/frontend/src/components/personal-report/vedic-chart-svg.tsx index 78ee9d81..9505fba7 100644 --- a/frontend/src/components/personal-report/vedic-chart-svg.tsx +++ b/frontend/src/components/personal-report/vedic-chart-svg.tsx @@ -16,7 +16,7 @@ * Colour identifies a body. It is not a benefic/malefic signal. */ -import { useId } from "react"; +import { useId, type CSSProperties } from "react"; import type { ChartV1 } from "@/lib/personal-report-contract"; import { SIGN_ORDER, signZh } from "@/lib/chart-view-labels"; @@ -364,6 +364,8 @@ export function VedicChartSvgDrawing({ chart: suppliedChart, ariaLabel, highligh {ordinal !== null && (

{starterGreeting.text}

{dailyTrend ? ( -

+ // Keyed on the text: a line that arrives or changes after the first frame + // fades in instead of snapping in (BUG-1125). +

{dailyTrend}

) : null} diff --git a/frontend/src/components/subject-switcher.tsx b/frontend/src/components/subject-switcher.tsx index c7a29099..91963041 100644 --- a/frontend/src/components/subject-switcher.tsx +++ b/frontend/src/components/subject-switcher.tsx @@ -9,6 +9,7 @@ import { type ChatProfileCatalogStatus, type ChatProfilePickerRow, } from "@/lib/chat-profile-picker-model"; +import { OrbitWaiting } from "@/components/orbit-waiting"; import type { ChartLibraryRecord } from "@/lib/home-types"; export type SubjectSwitcherProps = { @@ -88,7 +89,7 @@ export function SubjectSwitcher({ }} > 当前星盘 - {status === "loading" ?

人物列表还在读

: null} + {status === "loading" ? : null} {status === "failed" ? (

人物列表暂时读不出来

diff --git a/frontend/tests/chart-page-view.test.tsx b/frontend/tests/chart-page-view.test.tsx index 3f09cabe..d7646a9a 100644 --- a/frontend/tests/chart-page-view.test.tsx +++ b/frontend/tests/chart-page-view.test.tsx @@ -373,10 +373,18 @@ test("skeleton shares north Indian geometry without planets degrees or signs", a }); test("chart skeleton has one scoped breath rule and reduced motion is static", () => { - assert.match(cssDeclarations(".chart-page-skeleton", globalStyles), /animation: chart-skeleton-breathe 2\.4s ease-in-out infinite/); - assert.equal((globalStyles.match(/@keyframes chart-skeleton-breathe/g) ?? []).length, 1); - assert.match(globalStyles, /@keyframes chart-skeleton-breathe\s*\{\s*0%, 100% \{ opacity: 0\.35; \}\s*50% \{ opacity: 0\.6; \}/); - assert.match(globalStyles, /@media \(prefers-reduced-motion: reduce\)\s*\{\s*\.chart-page-skeleton \{ animation: none; opacity: 0\.5; \}/); + // 原值: `.chart-page-skeleton { animation: chart-skeleton-breathe 2.4s … }` (the whole empty drawing + // breathed 0.35 ↔ 0.6), one @keyframes chart-skeleton-breathe, reduced motion `animation: none; opacity: 0.5` + // 新值: the lines stay still (stroke-opacity .55); only the house fills move — one scoped + // @keyframes chart-skeleton-house, staggered by --house-index × 150ms; reduced motion switches it off + // 原因: 产品 2026-09-30「星盘的加载动画太丑了」,选「宫位依次点亮」 + assert.doesNotMatch(globalStyles, /\.chart-page-skeleton \{[^}]*animation/, "the drawing as a whole no longer breathes"); + assert.equal((globalStyles.match(/@keyframes chart-skeleton-breathe/g) ?? []).length, 0); + assert.equal((globalStyles.match(/@keyframes chart-skeleton-house/g) ?? []).length, 1); + const house = cssDeclarations(".chart-page-skeleton .personal-report-chart-cell,\n.chart-page-skeleton .chart-skeleton-sector", globalStyles); + assert.match(house, /animation: chart-skeleton-house 2\.4s ease-in-out infinite/); + assert.match(house, /animation-delay: calc\(var\(--house-index, 0\) \* 150ms\)/); + assert.match(globalStyles, /@media \(prefers-reduced-motion: reduce\)\s*\{\s*\.chart-page-skeleton \.personal-report-chart-cell,\s*\.chart-page-skeleton \.chart-skeleton-sector \{ animation: none; fill-opacity: 0; \}/); }); test("chart svgs omit the invalid height attribute and western height stays in css", () => { diff --git a/frontend/tests/daily-starlanguage.test.ts b/frontend/tests/daily-starlanguage.test.ts index 357775ed..3e643d9a 100644 --- a/frontend/tests/daily-starlanguage.test.ts +++ b/frontend/tests/daily-starlanguage.test.ts @@ -338,7 +338,13 @@ test("the home requests the card exactly when it renders one, and retries a fail // no longer shows a busy state after reveal; a pending card reads as the static copy. assert.doesNotMatch(page, /dailyStarlanguageBusy/); assert.ok(effect.length > 0); - assert.match(effect, /if \(bootstrapPhase === "account" \|\| !accountId \|\| !subjectReady \|\| !profile \|\| !profileComplete \|\| !natalMinuteAvailable\)/); + // 原值: /if \(bootstrapPhase === "account" \|\| !accountId \|\| !subjectReady \|\| !profile …\)/ — any unready + // catalog made the card "unavailable" (counted as settled) + // 新值: only another person waits for the catalog, and waiting reads as "pending" so the reveal holds for the line + // 原因: BUG-1125——首页揭幕后今日一句才出现,问候「突然跳一下」 + assert.match(effect, /const waitingForCatalog = !subjectReady && subjectId !== SELF_SUBJECT_ID;/); + assert.match(effect, /if \(bootstrapPhase !== "account" && accountId && waitingForCatalog\) \{[\s\S]*?setDailyStarlanguage\(\{ kind: "pending" \}\)/); + assert.match(effect, /if \(bootstrapPhase === "account" \|\| !accountId \|\| waitingForCatalog \|\| !profile \|\| !profileComplete \|\| !natalMinuteAvailable\)/); assert.match(binder, /const profile = useCurrentSubjectProfile\(props\.profile\)/); assert.match(binder, /const profileComplete = Boolean\(profile && missingOtherProfileStep\(profile\) === null\)/); assert.match(effect, /calendarDateInTimeZone\(new Date\(\), profile\.timezoneId\)/); diff --git a/frontend/tests/loading-motion-20260930.test.tsx b/frontend/tests/loading-motion-20260930.test.tsx new file mode 100644 index 00000000..75089dd6 --- /dev/null +++ b/frontend/tests/loading-motion-20260930.test.tsx @@ -0,0 +1,58 @@ +// 2026-09-30 loading round: the chart wait lights houses up in order, the people +// archive uses the shared orbit, and today's line enters softly (BUG-1125). +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import test from "node:test"; +import React from "react"; +import { renderToStaticMarkup } from "react-dom/server"; + +import { ChartPageSkeleton } from "../src/components/chart-page/chart-page-skeleton.tsx"; +import { EMPTY_NORTH_INDIAN_CHART, VedicChartSvg } from "../src/components/personal-report/vedic-chart-svg.tsx"; +import { StarterGreeting } from "../src/components/starter-home.tsx"; +import { cssDeclarations } from "./css-contract-test-support.ts"; + +Object.assign(globalThis, { React }); +const css = readFileSync(new URL("../src/app/globals.css", import.meta.url), "utf8"); +const read = (path: string) => readFileSync(new URL(path, import.meta.url), "utf8"); + +test("the Vedic wait numbers its twelve houses in house order; the finished chart carries no index", () => { + const waiting = renderToStaticMarkup(); + const indexes = [...waiting.matchAll(/--house-index:\s*(\d+)/g)].map((match) => Number(match[1])); + assert.deepEqual(indexes, [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11]); + assert.doesNotMatch(waiting, /app-loading-orbit|app-loading-mark/, "no orbit logo over the chart"); + assert.match(waiting, /role="status"/); + assert.match(waiting, /虚构的等待句<\/span>/); + const finished = renderToStaticMarkup(); + assert.doesNotMatch(finished, /--house-index/); +}); + +test("the western wait draws twelve sectors that light up around the wheel", () => { + const waiting = renderToStaticMarkup(); + assert.equal((waiting.match(/class="chart-skeleton-sector"/g) ?? []).length, 12); + assert.deepEqual([...waiting.matchAll(/--house-index:\s*(\d+)/g)].map((match) => Number(match[1])), [...Array(12).keys()]); +}); + +test("the house glow is a fill-only keyframe; lines stay still", () => { + const start = css.indexOf("@keyframes chart-skeleton-house"); + const keyframes = css.slice(start, css.indexOf("\n}", start) + 2); + assert.match(keyframes, /fill-opacity: 0\.18/); + assert.doesNotMatch(keyframes, /(^|[^-])opacity:|transform|stroke/); + assert.match(cssDeclarations(".chart-page-skeleton .chart-page-western-spoke", css), /stroke-opacity: 0\.55/); +}); + +test("people archive and the picker wait with the shared orbit, sentence for screen readers only", () => { + const people = read("../src/components/people/people-page.tsx"); + assert.match(people, //); + assert.doesNotMatch(people, /

/); + const picker = read("../src/components/subject-switcher.tsx"); + assert.match(picker, //); +}); + +test("today's line fades in (240ms, 4px) and is keyed on its text; reduced motion is instant (BUG-1125)", () => { + const markup = renderToStaticMarkup(); + assert.match(markup, /

虚构的一句。<\/p>/); + assert.match(read("../src/components/starter-home.tsx"), /

\{label\}<\/span>/); assert.match(orbit, /app-loading-orbit/); assert.match(read("../src/components/secondary-page-shell.tsx"), //); - assert.match(read("../src/components/chart-page/chart-page-skeleton.tsx"), //); + // 原值: // (orbit logo over the empty chart) + // 新值: no orbit over the chart; the sentence is an sr-only span inside the role=status stage + // 原因: 产品 2026-09-30「星盘的加载动画太丑了」,选「宫位依次点亮」——空盘本身就是等待动画 + const chartWait = read("../src/components/chart-page/chart-page-skeleton.tsx"); + assert.doesNotMatch(chartWait, /OrbitWaiting/); + assert.match(chartWait, /\{copy\}<\/span>/); assert.doesNotMatch(read("../src/components/chart-page/chart-page-skeleton.tsx"), /

at all + // 新值: exactly twelve (the waiting glow), and still no other path, text, aspect or label + // 原因: 产品 2026-09-30「星盘的加载动画太丑了」,选「宫位依次点亮」——西洋空盘沿圆周逐格点亮 + assert.equal((markup.match(/]*class="chart-skeleton-sector"/g) ?? []).length, 12); + assert.doesNotMatch(markup, /