fix(chart,reports): chart card hugs the drawing, symbol help as a bubble, sky button shows at once, report card spacing and copy (BUG-1100..1103)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01N4f2nya58RoRu4yEmJgRGE
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
0120765f42
commit
c59e362521
@@ -14833,3 +14833,58 @@
|
||||
- 相关记录:BUG-1076、`TASK-chart-types-and-report-buttons-20260928.md` T3;本任务书明确有限修订旧约定。
|
||||
- 复发自:无;产品术语能力范围的收口,不是 D1 冒充分盘复发。
|
||||
- 修复版本:`codex/chart-surface-polish-20260929` 工作区,未提交、未推送。
|
||||
|
||||
## BUG-1100 | 星盘页印度盘外框比星图宽一截,左右大片留白
|
||||
|
||||
- 状态:resolved(代码 + 回归测试;未部署时写于分支,真机欠)
|
||||
- 首次发现 / 最近更新:2026-09-29 / 2026-09-29
|
||||
- 影响面:`globals.css` 桌面断点 `.chart-page-vedic-plot` / `.chart-page-vedic-stage`。
|
||||
- 用户现象(产品截图):星图四周多了一张更宽的卡片,「框太丑」。
|
||||
- 触发条件:≥1024px 桌面。
|
||||
- 根因:`64aa0ec2`(TASK-chart-surface-polish T5)把 440px 上限加在外框内的绘图区上,外框仍随栅格列拉宽。
|
||||
- 修复:上限移到外框(440px + 两侧内边距 + 边框),绘图区在框内撑满;手机端不设上限。等待骨架在同一外框里,揭幕不跳尺寸。
|
||||
- 验证:`frontend/tests/chart-page-mobile-width-20260929.test.ts`(断言改动三栏见 PROGRESS)、`surface-feedback-20260929.test.tsx`。
|
||||
- 防复发:尺寸上限加在带边框的容器上,不加在它的内层。
|
||||
- 相关记录:BUG-1098、BUG-1099(同一提交的其他项)
|
||||
- 复发自:无
|
||||
- 修复版本:分支 `codex/surface-feedback-20260929`
|
||||
|
||||
## BUG-1101 | 星盘图标说明显示在盘下一行字,不是图标旁的气泡;还常驻一句「停在或轻点……」
|
||||
|
||||
- 状态:resolved(代码 + 回归测试;手机真机欠)
|
||||
- 首次发现 / 最近更新:2026-09-29 / 2026-09-29
|
||||
- 影响面:`components/chart-page/chart-symbol-help.tsx`、`western-wheel-svg.tsx`、`globals.css`。
|
||||
- 用户现象:鼠标停在行星符号上,说明出现在盘下方;产品要的是图标旁弹出气泡,且不要那句常驻提示。
|
||||
- 根因:`64aa0ec2` 按「说明在盘外预留区域,不遮挡图标」实现为盘下状态行,与产品要的交互不同。
|
||||
- 修复:同一交互入口改为锚在标记旁的气泡(上方优先、近顶翻下、左右夹在外框内、不接收指针);电脑悬停 / 点击 / 键盘聚焦出现;手机轻点出现,触摸离开事件不关,点盘外或 3 秒自动关;读屏保留隐藏 `role="status"`。西洋盘的「最近行星」解析结果同时给出锚点。删除提示句。
|
||||
- 验证:`chart-symbol-help.test.tsx` 原 4 条不改断言全部通过;新增定位纯函数与触摸时序测试。几何与真实触摸需真机(清单)。
|
||||
- 防复发:说明形态以产品描述为准;「不遮挡」由气泡不接收指针、避让边缘满足,而不是挪到盘外。
|
||||
- 相关记录:BUG-1100
|
||||
- 复发自:无
|
||||
- 修复版本:分支 `codex/surface-feedback-20260929`
|
||||
|
||||
## BUG-1102 | 「那一刻的天空」按钮慢一秒才出现,且不显眼
|
||||
|
||||
- 状态:resolved(代码 + 回归测试;真机欠)
|
||||
- 首次发现 / 最近更新:2026-09-29 / 2026-09-29
|
||||
- 影响面:`hooks/use-birth-sky-cover.ts`、`components/birth-sky/birth-sky-entry.tsx`、`chart-page-view.tsx`、`globals.css`。
|
||||
- 用户现象:进星盘后右上角按钮约一秒后才出现;细线框小按钮不明显。
|
||||
- 根因:按钮渲染条件是「封面 PNG 已画好」(TASK-birth-sky-cover T3 的设计),画图需要网络 + 离屏绘制。
|
||||
- 修复:钩子返回 `{ cover, pending }`;星盘就绪即显示按钮,封面在画时点击被记下,画好立即开播;失败时按钮消失。样式改星芒图标 + accent 浅底。
|
||||
- 验证:`surface-feedback-20260929.test.tsx`;`birth-sky-replay-20260929.test.ts` 一条断言按三栏更新,`chart-page-view.test.tsx` 既有「没有封面也不在加载时不显示」断言不变。
|
||||
- 防复发:入口按钮不等待次要资源;需要资源的动作在点击后等待并由动作本身承担等待。
|
||||
- 相关记录:TASK-birth-sky-cover-20260928、TASK-mobile-chart-and-confirmed-edit-20260929 M4
|
||||
- 复发自:无
|
||||
- 修复版本:分支 `codex/surface-feedback-20260929`
|
||||
|
||||
## BUG-1103 | 「我的报告」生成卡片贴着顶栏;标题「生成个人报告」与按钮「生成报告」重复
|
||||
|
||||
- 状态:resolved(代码 + 回归测试)
|
||||
- 首次发现 / 最近更新:2026-09-29 / 2026-09-29
|
||||
- 影响面:`personal-report-center.tsx`、`globals.css .report-center-create`。
|
||||
- 根因:BUG-1094 改版时卡片上外边距为 0;标题与按钮都写了动作。
|
||||
- 修复:卡片上方 `--space-6`;标题改「完整本命报告」,说明一句写 6 章与中英两版,按钮仍「生成报告」(产品选定)。
|
||||
- 验证:`surface-feedback-20260929.test.tsx`。
|
||||
- 相关记录:BUG-1094
|
||||
- 复发自:BUG-1094 的遗漏
|
||||
- 修复版本:分支 `codex/surface-feedback-20260929`
|
||||
|
||||
+4
-4
@@ -565,7 +565,7 @@ one skeleton, none of them carrying the sidebar.
|
||||
|
||||
### Personal report centre
|
||||
|
||||
- **Structure:** inside the app shell, not a page of its own. The name 「我的报告」 sits alone in the 46px header. The body opens with the **generate card** (`.report-center-create`, 2026-09-29, product sketch): a centred `--color-canvas` sheet with a file icon, 「生成个人报告」, one line 「创建后即可离开,完成时下方列表会自动更新。」 and the filled 「生成报告」 button — the page's only generate entry; the header button was deleted rather than kept as a second one. Below it, 「过往的报告」 heads the **row list** of reports; with none yet, a single quiet line 「还没有个人报告。」 replaces the old second big empty card. The supporting paragraph (「有填报到分钟的出生时间即可生成……」) and the 「共 N 份 · 已完成 N 份」 overview line above it were removed on 2026-09-28 (TASK-self-edit-avatar-menu-20260928 S3, product: 「这里的提示去掉」); the minute requirement still lives in the 生成 button's hover title.
|
||||
- **Structure:** inside the app shell, not a page of its own. The name 「我的报告」 sits alone in the 46px header. The body opens with the **generate card** (`.report-center-create`, 2026-09-29, product sketch): a centred `--color-canvas` sheet, `--space-6` below the header (BUG-1103: it used to sit flush under it), with a file icon, the title 「完整本命报告」 (what you get), one line 「星盘、力量、大运、年运、瑜伽共 6 章,中英两版;生成后可离开,完成时下方自动出现。」 and the filled 「生成报告」 button (what it does) — title and button no longer say the same thing — the page's only generate entry; the header button was deleted rather than kept as a second one. Below it, 「过往的报告」 heads the **row list** of reports; with none yet, a single quiet line 「还没有个人报告。」 replaces the old second big empty card. The supporting paragraph (「有填报到分钟的出生时间即可生成……」) and the 「共 N 份 · 已完成 N 份」 overview line above it were removed on 2026-09-28 (TASK-self-edit-avatar-menu-20260928 S3, product: 「这里的提示去掉」); the minute requirement still lives in the 生成 button's hover title.
|
||||
It used to be a standalone full-screen route with a `report-center-shell` root, a `report-center-topbar` holding one 「返回对话」 link, and a `report-center-hero` with a page-sized h1 — the sidebar vanished the moment you opened it, and the only way back was that link.
|
||||
- **Row list, not a card grid:** `.report-center-list` is one column of `.report-center-row`, hairline-separated by a 1px grid gap over a `--color-border` ground so each boundary is a single rule rather than two touching borders. A card grid costs one scan per card; past five or six reports the reader is looking for state, and a single column puts every status chip on the same x. D12.
|
||||
- **Row anatomy:** status chip, then the title at `--type-title-md`/500, then the meta line (creation time · depth · themes) at `--type-caption` with `tabular-nums`, then — for a ready report — its stored card summary, or for a failed one the failure reason. Actions sit right-aligned on the same row and wrap under the body at the global 767px cut.
|
||||
@@ -1019,9 +1019,9 @@ 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 起,印度内部 `.chart-page-vedic-plot` 绘图区上限 440px 并居中,外框 padding 不计入盘宽;西洋桌面绘图区上限 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-29)。** `/chart` 开启共享 SVG 的可选交互模式:鼠标悬停、键盘聚焦与手机轻点都在盘下预留说明区显示同一事实说明,不覆盖邻近图标;Esc、离开、失焦可关闭,切盘/人物/数据身份变化清理旧说明。行星与其逆行短线共用一个目标;西洋 caption 和命中圆用一致的行星解析,原点击过滤相位及再次点击取消保留。报告、校正、静态导出默认不启用该模式;纯绘图层不持有 hooks,静态 SVG 白名单不扩。
|
||||
**符号说明(2026-09-29;当晚改为气泡,BUG-1101)。** `/chart` 开启共享 SVG 的可选交互模式:说明是标记旁的小气泡(`.chart-symbol-bubble`,最宽 220px,`--color-canvas` 底 + 细边 + `--shadow-elevated`),默认在标记上方 8px,离外框顶不足 48px 时翻到下方,左右被夹在外框内;气泡不接收指针,不会挡住旁边的标记。电脑:鼠标悬停出现、移开消失,点击与键盘聚焦同样出现。手机:轻点标记出现,触摸产生的离开事件不关它,点盘外任意处或 3 秒后自动消失。盘下不再有「停在或轻点……」提示行和说明行;读屏器仍从一个隐藏的 `role="status"` 读到同一句话;Esc、离开、失焦可关闭,切盘/人物/数据身份变化清理旧说明。行星与其逆行短线共用一个目标;西洋 caption 和命中圆用一致的行星解析,原点击过滤相位及再次点击取消保留。报告、校正、静态导出默认不启用该模式;纯绘图层不持有 hooks,静态 SVG 白名单不扩。
|
||||
|
||||
盘内 SVG 文字用 CSS 提到 16px viewBox 单位,使约 318px 盘宽下实际像素不低于 12px。Tab、盘型 chip 均 44px。
|
||||
|
||||
@@ -1178,7 +1178,7 @@ D40 / D45 / D60 的表格上方,出生时间不是「已校正」时多一句
|
||||
|
||||
### 位置一:星盘页顶栏按钮
|
||||
|
||||
- 星盘页 `ok` 后立即预取 `/api/birth-sky?subject=<当前人物>` 并在屏外画好 PNG;**画好之后**顶栏右侧才出现一枚描边按钮「那一刻的天空」(`<Button variant="outline" size="sm">`,顶栏里按 `.chat-header-actions button` 收成 32px + 命中区外扩)。预取失败、资料不全、切人物时都不显示按钮,不占位、不报错。切换人物或改出生资料(指纹变)就按新人物重新预取。家人的盘也能生成。
|
||||
- 星盘页 `ok` 后立即预取 `/api/birth-sky?subject=<当前人物>` 并在屏外画好 PNG;**画好之后**顶栏右侧才出现一枚描边按钮「那一刻的天空」(`<Button variant="outline" size="sm">`,顶栏里按 `.chat-header-actions button` 收成 32px + 命中区外扩)。2026-09-29 晚改(BUG-1102,产品「按钮不明显、慢一秒才出现」):星盘 `ok` 即显示按钮,不等 PNG;封面还在画时点击被记下,画好立即开播重放,中间不插转圈(重放的汇聚本身就是等待);样式改为 `.birth-sky-entry`:星芒图标 + `--color-action` 12% 浅底 + accent 字色 600 字重(不用 `--color-action-soft` 实块,BUG-1078)。预取失败时按钮消失,资料不全、切人物时都不显示按钮,不报错。切换人物或改出生资料(指纹变)就按新人物重新预取。家人的盘也能生成。
|
||||
- 点按钮**重放群星汇聚并定格**(2026-09-29,TASK-mobile-chart-and-confirmed-edit-20260929 M4,替换第三轮 P1 的静态海报弹层):与首次建盘过场同一个组件 `BirthSkyConverge`、同一条时间轴,`mode="hold"`——汇聚约 3 秒、文字 600ms 淡入,然后**停在定格帧不动**,不淡出、不自动关闭。每次打开都从头重放。定格帧就是导出的海报(画布调用序列逐条相同,且用生成 PNG 时同一个宋体判定 `cover.serif`)。全屏原生 `<dialog>` portal 到 `document.body`,底色 `night` 页底色,画布取视口内最大 3:4、乘 DPR。
|
||||
- 控件浮在画面上方一行:**左上 ×**(只有图标,读屏名「关闭」)、**右上「分享」**(分享图标 + 字,night 墨色细描边)。播放中也在,都 ≥ 44px,顶部留安全区。点画面(不在控件上)直接跳到定格帧、**不关闭**;按 Esc 或点 × 关闭,焦点回到按钮。`prefers-reduced-motion` 时不汇聚,600ms 淡入到定格帧。
|
||||
- 「分享」:浏览器能分享文件时(iOS Safari)调 `navigator.share({ files })`,系统面板里有「存储图像」;否则 Blob + `a.download`。分享的就是屏幕上定格的那张 PNG(按钮出现前已画好,分享从不等待)。用户取消不算错误、不提示;其他失败在右上方小字「没能分享,再试一次。」。
|
||||
|
||||
@@ -109,7 +109,8 @@ Jyotisha 的可见文案是产品的一部分。正确性红线(真实性、
|
||||
|
||||
- 盘面不显示引擎品牌,也不替换成「自研引擎」「本站引擎」。岁差、交点等计算口径与准确性边界照旧。
|
||||
- 图标说明只写名称和已有事实:上升、行星、逆行、星座名称/编号、宫位、ASC/MC 等。没有可靠度数就不写度数,不加吉凶、性格或个人运势解释。
|
||||
- 鼠标悬停、键盘聚焦、手机轻点展示同一份说明;说明在盘外预留区域,不遮住旁边图标。关闭说明不改变西洋相位选择。默认提示为「停在或轻点盘内标记可查看说明。」
|
||||
- 鼠标悬停、键盘聚焦、手机轻点展示同一份说明,以标记旁的小气泡出现,不遮住被说明的标记。关闭说明不改变西洋相位选择。2026-09-29 晚起不再有默认提示句(原「停在或轻点盘内标记可查看说明。」已删,BUG-1101)。
|
||||
- 「我的报告」生成卡片:标题写得到什么「完整本命报告」,说明一句「星盘、力量、大运、年运、瑜伽共 6 章,中英两版;生成后可离开,完成时下方自动出现。」,按钮写动作「生成报告」。标题与按钮不说同一件事(BUG-1103)。
|
||||
- 分盘庙旺仅指入旺、自宫、落陷三档;未命中写「—」,上升写「不适用」。同位仅 D9 展示,其他分盘不留空列。缺能力声明不装作已支持。
|
||||
|
||||
## 星盘空状态、0 年大运、星历切日期
|
||||
|
||||
@@ -3884,7 +3884,8 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class
|
||||
still reads calm; the button inside is the page's one filled action. */
|
||||
.report-center-create {
|
||||
width: min(900px, 100%);
|
||||
margin: 0 auto var(--space-8);
|
||||
/* Top margin: the card used to sit flush under the 46px header (2026-09-29). */
|
||||
margin: var(--space-6) auto var(--space-8);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
@@ -5211,6 +5212,17 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.chart-page-skeleton { animation: none; opacity: 0.5; }
|
||||
}
|
||||
/* 「那一刻的天空」: 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).
|
||||
A 12% mix keeps it light in both themes (not the --color-action-soft block, BUG-1078). */
|
||||
.birth-sky-entry[data-slot="button"] {
|
||||
background: color-mix(in srgb, var(--color-action) 12%, transparent);
|
||||
color: var(--color-action);
|
||||
font-size: var(--type-body-sm);
|
||||
font-weight: 600;
|
||||
}
|
||||
.birth-sky-entry[data-slot="button"]:hover { background: color-mix(in srgb, var(--color-action) 20%, transparent); }
|
||||
.birth-sky-entry svg { width: 16px; height: 16px; }
|
||||
.chart-page-vedic-stage {
|
||||
position: relative;
|
||||
background: var(--color-canvas);
|
||||
@@ -5227,12 +5239,31 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class
|
||||
.chart-page-vedic-plot { margin-inline: auto; }
|
||||
/* Reserve the result's legend band without changing the shared empty drawing. */
|
||||
.chart-page-skeleton.chart-page-vedic-plot { aspect-ratio: 400 / 452; }
|
||||
/* Keep the explanation outside the drawing so it cannot cover nearby symbols. */
|
||||
.chart-page-note.chart-page-symbol-note {
|
||||
min-height: 2.8em;
|
||||
margin: var(--space-2) 0 0;
|
||||
/* Symbol help is a bubble at the mark (2026-09-29, replaced the note line under
|
||||
the chart): above the mark, below it near the top edge, clamped inside the
|
||||
stage by ChartSymbolHelp. Hover on desktop, tap on touch (closes on a tap
|
||||
elsewhere or after 3s). It never takes pointer events, so it cannot block
|
||||
the mark next to it. */
|
||||
.chart-page-symbol-help { position: relative; }
|
||||
.chart-symbol-bubble {
|
||||
position: absolute;
|
||||
z-index: 5;
|
||||
max-width: 220px;
|
||||
transform: translate(-50%, calc(-100% - 8px));
|
||||
padding: 6px 10px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--color-canvas);
|
||||
box-shadow: var(--shadow-elevated);
|
||||
color: var(--color-ink);
|
||||
font-size: var(--type-caption);
|
||||
line-height: 1.4;
|
||||
text-align: center;
|
||||
pointer-events: none;
|
||||
white-space: normal;
|
||||
width: max-content;
|
||||
}
|
||||
.chart-symbol-bubble.is-below { transform: translate(-50%, 8px); }
|
||||
.chart-page-symbol-hit { fill: transparent; stroke: none; }
|
||||
.chart-page-symbol-target { cursor: help; }
|
||||
.chart-page-symbol-target:focus-visible {
|
||||
@@ -5616,7 +5647,9 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class
|
||||
align-items: start;
|
||||
}
|
||||
/* Limit the drawing, not its padded stage or the parameter/table column. */
|
||||
.chart-page-vedic-plot { max-width: 440px; }
|
||||
/* The card hugs the drawing (2026-09-29): capping only the plot left a wide
|
||||
card with empty sides around a 440px chart. 440 + the card's padding. */
|
||||
.chart-page-vedic-stage { width: 100%; max-width: calc(440px + 2 * var(--space-3) + 2px); }
|
||||
.chart-page-western-wheel { max-width: 480px; }
|
||||
/* Chart and parameters sit side by side; the planet table is a full-width
|
||||
row under both, because its columns need the whole measure. */
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import dynamic from "next/dynamic";
|
||||
import { Sparkles } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -14,18 +15,23 @@ const BirthSkyReplay = dynamic(
|
||||
export const BIRTH_SKY_ENTRY_LABEL = "那一刻的天空";
|
||||
|
||||
/**
|
||||
* The chart header's one extra action. Rendered only when the cover is already
|
||||
* painted; the replay (convergence, controls and CSS) is a lazy chunk opened on
|
||||
* click, and every open plays it again (TASK-mobile-chart-and-confirmed-edit-20260929 M4).
|
||||
* The chart header's one extra action. Shown as soon as the chart is ready,
|
||||
* while the cover PNG may still be painting (2026-09-29: it used to pop in about
|
||||
* a second late). A click before the cover is ready is remembered and the
|
||||
* replay opens the moment it arrives — no spinner in between; the replay's own
|
||||
* convergence is the wait. The replay is a lazy chunk and every open plays it
|
||||
* again (TASK-mobile-chart-and-confirmed-edit-20260929 M4).
|
||||
*/
|
||||
export function BirthSkyEntry({ cover }: { cover: BirthSkyCover }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
export function BirthSkyEntry({ cover }: { cover: BirthSkyCover | null }) {
|
||||
const [wanted, setWanted] = useState(false);
|
||||
const open = wanted && cover !== null;
|
||||
return (
|
||||
<>
|
||||
<Button type="button" variant="outline" size="sm" onClick={() => setOpen(true)}>
|
||||
<Button type="button" variant="secondary" size="sm" className="birth-sky-entry" onClick={() => setWanted(true)}>
|
||||
<Sparkles aria-hidden="true" />
|
||||
{BIRTH_SKY_ENTRY_LABEL}
|
||||
</Button>
|
||||
{open ? <BirthSkyReplay cover={cover} onClose={() => setOpen(false)} /> : null}
|
||||
{open ? <BirthSkyReplay cover={cover} onClose={() => setWanted(false)} /> : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -34,7 +34,8 @@ export function ChartPageRoute() {
|
||||
pendingLayers={pendingLayers}
|
||||
layerFailures={layerFailures}
|
||||
onNeedLayer={requestLayer}
|
||||
birthSky={birthSky}
|
||||
birthSky={birthSky.cover}
|
||||
birthSkyPending={birthSky.pending}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -74,6 +75,7 @@ export function ChartPageView({
|
||||
onNeedLayer,
|
||||
initialTab = "vedic",
|
||||
birthSky = null,
|
||||
birthSkyPending = false,
|
||||
}: {
|
||||
view: ChartViewResponse | null;
|
||||
pendingLayers?: ReadonlySet<ChartViewLayer>;
|
||||
@@ -82,6 +84,8 @@ export function ChartPageView({
|
||||
initialTab?: ChartViewTabId;
|
||||
/** Painted cover for the header button; null until ready, and after any failure. */
|
||||
birthSky?: BirthSkyCover | null;
|
||||
/** The cover is still painting: the button already shows and opens when it lands. */
|
||||
birthSkyPending?: boolean;
|
||||
}) {
|
||||
const [tab, setTab] = useState<ChartViewTabId>(initialTab);
|
||||
const [vargaId, setVargaId] = useState<ChartTypeId>("D1");
|
||||
@@ -108,7 +112,7 @@ export function ChartPageView({
|
||||
<SecondaryPageShell
|
||||
title="星盘"
|
||||
note={birthline}
|
||||
actions={view?.status === "ok" && birthSky ? <BirthSkyEntry cover={birthSky} /> : undefined}
|
||||
actions={view?.status === "ok" && (birthSky || birthSkyPending) ? <BirthSkyEntry cover={birthSky ?? null} /> : undefined}
|
||||
>
|
||||
{view != null && view.status !== "ok" ? (
|
||||
<section className="chart-page-message">
|
||||
|
||||
@@ -1,13 +1,21 @@
|
||||
"use client";
|
||||
|
||||
import { useState, type ReactNode } from "react";
|
||||
import { useEffect, useRef, useState, type ReactNode } from "react";
|
||||
|
||||
type Explain = (text: string | null) => void;
|
||||
/** `anchor` is the mark the bubble points at; without one the last anchor is kept. */
|
||||
export type Explain = (text: string | null, anchor?: Element | null) => void;
|
||||
type Props = {
|
||||
resetKey: string;
|
||||
children: ReactNode | ((explain: Explain) => ReactNode);
|
||||
};
|
||||
|
||||
/** How long a tapped bubble stays up on touch screens, which have no pointer-leave to close it. */
|
||||
export const TOUCH_BUBBLE_MS = 3000;
|
||||
/** Room kept between the bubble and the stage edge; the bubble never runs off it. */
|
||||
const EDGE = 8;
|
||||
/** Half the bubble's max width (see `.chart-symbol-bubble`), for clamping the centre. */
|
||||
const HALF_WIDTH = 110;
|
||||
|
||||
/** The drawing only supplies facts. All transient help stays on the chart page. */
|
||||
export function ChartSymbolHelp({ resetKey, children }: Props) {
|
||||
return <SymbolHelpSession key={resetKey}>{children}</SymbolHelpSession>;
|
||||
@@ -22,26 +30,94 @@ function helpTarget(target: EventTarget | null): Element | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
type Bubble = { text: string; x: number; y: number; below: boolean };
|
||||
|
||||
/**
|
||||
* Where the bubble goes, in the stage's own coordinates: centred over the mark,
|
||||
* above it unless that would leave the stage, and pulled in from the sides so
|
||||
* the text never runs off the chart (2026-09-29, product: a bubble at the mark,
|
||||
* not a line of text under the chart).
|
||||
*/
|
||||
export function placeBubble(
|
||||
mark: Pick<DOMRect, "left" | "top" | "width" | "height">,
|
||||
stage: Pick<DOMRect, "left" | "top" | "width">,
|
||||
): Omit<Bubble, "text"> {
|
||||
const centre = mark.left - stage.left + mark.width / 2;
|
||||
const x = Math.min(Math.max(centre, HALF_WIDTH + EDGE), Math.max(stage.width - HALF_WIDTH - EDGE, HALF_WIDTH + EDGE));
|
||||
const top = mark.top - stage.top;
|
||||
const below = top < 48;
|
||||
return { x, y: below ? top + mark.height : top, below };
|
||||
}
|
||||
|
||||
function SymbolHelpSession({ children }: Pick<Props, "children">) {
|
||||
const [help, setHelp] = useState<string | null>(null);
|
||||
const stage = useRef<HTMLDivElement>(null);
|
||||
const [bubble, setBubble] = useState<Bubble | null>(null);
|
||||
const touchTimer = useRef<number | null>(null);
|
||||
|
||||
function show(text: string | null, anchor: Element | null | undefined) {
|
||||
if (!text) {
|
||||
setBubble(null);
|
||||
return;
|
||||
}
|
||||
// Found from the mark rather than a ref: `explain` is handed to the drawing during render.
|
||||
const box = anchor?.closest(".chart-page-symbol-help")?.getBoundingClientRect();
|
||||
const mark = anchor?.getBoundingClientRect();
|
||||
if (!box || !mark) {
|
||||
// No geometry for this mark: keep the last position, or the stage corner. Never drop the text.
|
||||
setBubble((current) => ({ x: HALF_WIDTH + EDGE, y: 0, below: true, ...current, text }));
|
||||
return;
|
||||
}
|
||||
setBubble({ text, ...placeBubble(mark, box) });
|
||||
}
|
||||
const explainTarget = (target: EventTarget | null) => {
|
||||
setHelp(helpTarget(target)?.getAttribute("data-chart-help") ?? null);
|
||||
const element = helpTarget(target);
|
||||
show(element?.getAttribute("data-chart-help") ?? null, element);
|
||||
};
|
||||
const explain: Explain = (text, anchor) => show(text, anchor);
|
||||
|
||||
function clearTouchTimer() {
|
||||
if (touchTimer.current !== null) window.clearTimeout(touchTimer.current);
|
||||
touchTimer.current = null;
|
||||
}
|
||||
|
||||
// A tap anywhere outside the chart closes a tapped bubble.
|
||||
useEffect(() => {
|
||||
if (!bubble) return;
|
||||
const close = (event: PointerEvent) => {
|
||||
if (stage.current && event.target instanceof Node && !stage.current.contains(event.target)) setBubble(null);
|
||||
};
|
||||
document.addEventListener("pointerdown", close);
|
||||
return () => document.removeEventListener("pointerdown", close);
|
||||
}, [bubble]);
|
||||
useEffect(() => clearTouchTimer, []);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={stage}
|
||||
className="chart-page-symbol-help"
|
||||
onPointerOver={(event) => explainTarget(event.target)}
|
||||
onPointerMove={(event) => explainTarget(event.target)}
|
||||
onPointerOver={(event) => {
|
||||
if (event.pointerType !== "touch") explainTarget(event.target);
|
||||
}}
|
||||
onPointerOut={(event) => {
|
||||
if (helpTarget(event.target) !== helpTarget(event.relatedTarget)) setHelp(null);
|
||||
// Touch fires pointer-out right after the tap; the bubble must survive it.
|
||||
if (event.pointerType === "touch") return;
|
||||
if (helpTarget(event.target) !== helpTarget(event.relatedTarget)) setBubble(null);
|
||||
}}
|
||||
onPointerLeave={(event) => {
|
||||
if (event.pointerType !== "touch") setBubble(null);
|
||||
}}
|
||||
onPointerLeave={() => setHelp(null)}
|
||||
onFocus={(event) => explainTarget(event.target)}
|
||||
onBlur={() => setHelp(null)}
|
||||
onBlur={() => setBubble(null)}
|
||||
onClick={(event) => explainTarget(event.target)}
|
||||
onPointerUp={(event) => {
|
||||
if (event.pointerType !== "touch") return;
|
||||
explainTarget(event.target);
|
||||
clearTouchTimer();
|
||||
touchTimer.current = window.setTimeout(() => setBubble(null), TOUCH_BUBBLE_MS);
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Escape") {
|
||||
setHelp(null);
|
||||
setBubble(null);
|
||||
event.stopPropagation();
|
||||
} else if (event.key === "Enter" || event.key === " ") {
|
||||
const target = helpTarget(event.target);
|
||||
@@ -52,10 +128,18 @@ function SymbolHelpSession({ children }: Pick<Props, "children">) {
|
||||
}
|
||||
}}
|
||||
>
|
||||
{typeof children === "function" ? children(setHelp) : children}
|
||||
<p className="chart-page-note chart-page-symbol-note" role="status" aria-live="polite" aria-atomic="true">
|
||||
{help ?? "停在或轻点盘内标记可查看说明。"}
|
||||
</p>
|
||||
{typeof children === "function" ? children(explain) : children}
|
||||
{bubble ? (
|
||||
<div
|
||||
className={`chart-symbol-bubble${bubble.below ? " is-below" : ""}`}
|
||||
role="tooltip"
|
||||
style={{ left: bubble.x, top: bubble.y }}
|
||||
>
|
||||
{bubble.text}
|
||||
</div>
|
||||
) : null}
|
||||
{/* Screen readers still hear the same sentence the bubble shows. */}
|
||||
<p className="sr-only" role="status" aria-live="polite" aria-atomic="true">{bubble?.text ?? ""}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -85,7 +85,7 @@ export function WesternWheelSvg({
|
||||
skeleton?: boolean;
|
||||
selectedLabel?: string | null;
|
||||
onSelectPlanet?: (id: string) => void;
|
||||
onExplain?: (text: string | null) => void;
|
||||
onExplain?: (text: string | null, anchor?: Element | null) => void;
|
||||
}) {
|
||||
const western = skeleton ? EMPTY_WESTERN_WHEEL : suppliedWestern;
|
||||
const asc = western.ascendantLongitude;
|
||||
@@ -134,16 +134,22 @@ export function WesternWheelSvg({
|
||||
function explainNearest(event: PointerEvent<SVGElement>, fallbackId: string) {
|
||||
if (!onExplain) return;
|
||||
event.stopPropagation();
|
||||
onExplain(planetDescription(nearestId(event, fallbackId)));
|
||||
const id = nearestId(event, fallbackId);
|
||||
onExplain(planetDescription(id), planetAnchor(event.currentTarget, id));
|
||||
}
|
||||
function selectNearest(event: MouseEvent<SVGElement>, fallbackId: string) {
|
||||
const id = nearestId(event, fallbackId);
|
||||
onSelectPlanet?.(id);
|
||||
if (onExplain) {
|
||||
event.stopPropagation();
|
||||
onExplain(planetDescription(id));
|
||||
onExplain(planetDescription(id), planetAnchor(event.currentTarget, id));
|
||||
}
|
||||
}
|
||||
/** The bubble points at the planet that was resolved, not merely the one under the pointer. */
|
||||
function planetAnchor(from: EventTarget, id: string): Element | null {
|
||||
if (!(from instanceof Element)) return null;
|
||||
return from.closest("svg")?.querySelector(`[data-planet-id="${CSS.escape(id)}"]`) ?? from;
|
||||
}
|
||||
function helpProps(text: string) {
|
||||
return onExplain ? {
|
||||
className: "chart-page-symbol-target", role: "button", tabIndex: 0,
|
||||
@@ -236,6 +242,7 @@ export function WesternWheelSvg({
|
||||
key={planet.id}
|
||||
className={selected ? "chart-page-western-planet is-selected" : "chart-page-western-planet"}
|
||||
data-tier={placement.tier}
|
||||
data-planet-id={planet.id}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-pressed={selected}
|
||||
@@ -246,14 +253,14 @@ export function WesternWheelSvg({
|
||||
onFocus={(event) => {
|
||||
if (!onExplain) return;
|
||||
event.stopPropagation();
|
||||
onExplain(planetDescription(planet.id));
|
||||
onExplain(planetDescription(planet.id), event.currentTarget);
|
||||
}}
|
||||
onClick={(event) => selectNearest(event, planet.id)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key !== "Enter" && event.key !== " ") return;
|
||||
event.preventDefault();
|
||||
onSelectPlanet?.(planet.id);
|
||||
onExplain?.(planetDescription(planet.id));
|
||||
onExplain?.(planetDescription(planet.id), event.currentTarget);
|
||||
}}
|
||||
>
|
||||
<line
|
||||
|
||||
@@ -157,8 +157,10 @@ export function PersonalReportCenter() {
|
||||
entry: the header no longer carries a second button (2026-09-29). */}
|
||||
<section className="report-center-create" aria-labelledby="report-center-create-title">
|
||||
<FileText aria-hidden="true" className="size-7" />
|
||||
<h2 id="report-center-create-title">生成个人报告</h2>
|
||||
<p>创建后即可离开,完成时下方列表会自动更新。</p>
|
||||
{/* The title says what you get, the button says what it does (2026-09-29):
|
||||
「生成个人报告」 over a 「生成报告」 button said the same thing twice. */}
|
||||
<h2 id="report-center-create-title">完整本命报告</h2>
|
||||
<p>星盘、力量、大运、年运、瑜伽共 6 章,中英两版;生成后可离开,完成时下方自动出现。</p>
|
||||
<GeneratePersonalReportButton onCreated={() => void load()} />
|
||||
</section>
|
||||
<section className="report-center-section">
|
||||
|
||||
@@ -8,13 +8,17 @@ import { loadBirthSkyCover, releaseBirthSkyCover, type BirthSkyCover } from "@/l
|
||||
* Chart page prefetch for 「那一刻的天空」 (TASK-birth-sky-cover-20260928 T3).
|
||||
*
|
||||
* Starts as soon as the chart is `ok`, keyed by subject + profile fingerprint so
|
||||
* switching person (or editing birth data) fetches again. Returns the cover
|
||||
* only once the PNG is painted; until then, and on any failure, null — the
|
||||
* header shows no button, no placeholder, no error.
|
||||
* switching person (or editing birth data) fetches again. `cover` is set once
|
||||
* the PNG is painted. `pending` is true while it is still being painted, so the
|
||||
* header can show the button at once instead of popping it in a second later
|
||||
* (2026-09-29); a failure clears both and the button goes away without an error.
|
||||
*/
|
||||
export function useBirthSkyCover(subjectId: string | null, fingerprint: string | null): BirthSkyCover | null {
|
||||
export type BirthSkyCoverState = Readonly<{ cover: BirthSkyCover | null; pending: boolean }>;
|
||||
|
||||
export function useBirthSkyCover(subjectId: string | null, fingerprint: string | null): BirthSkyCoverState {
|
||||
const key = subjectId ? `${subjectId}:${fingerprint ?? ""}` : null;
|
||||
const [cover, setCover] = useState<BirthSkyCover | null>(null);
|
||||
const [settledKey, setSettledKey] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!key || !subjectId) return;
|
||||
@@ -27,6 +31,7 @@ export function useBirthSkyCover(subjectId: string | null, fingerprint: string |
|
||||
}
|
||||
loaded = next;
|
||||
if (next) setCover(next);
|
||||
setSettledKey(key);
|
||||
});
|
||||
return () => {
|
||||
controller.abort();
|
||||
@@ -34,5 +39,6 @@ export function useBirthSkyCover(subjectId: string | null, fingerprint: string |
|
||||
};
|
||||
}, [key, subjectId]);
|
||||
|
||||
return cover && cover.key === key ? cover : null;
|
||||
const current = cover && cover.key === key ? cover : null;
|
||||
return { cover: current, pending: key !== null && current === null && settledKey !== key };
|
||||
}
|
||||
|
||||
@@ -117,7 +117,11 @@ test("「分享」 hands out the painted PNG; every open is a fresh replay from
|
||||
assert.match(replay, /export const BIRTH_SKY_SHARE_FAILED = "没能分享,再试一次。";/);
|
||||
assert.match(replay, /await saveBirthSkyImage\(cover\.blob\)/);
|
||||
assert.match(entry, /dynamic\(\s*\(\) => import\("\.\/birth-sky-replay"\)/);
|
||||
assert.match(entry, /\{open \? <BirthSkyReplay cover=\{cover\} onClose=\{\(\) => setOpen\(false\)\} \/> : null\}/);
|
||||
// 原值:`{open ? <BirthSkyReplay cover={cover} onClose={() => setOpen(false)} /> : null}`
|
||||
// 新值:`{open ? <BirthSkyReplay cover={cover} onClose={() => setWanted(false)} /> : null}`,其中 open = 点过且封面已画好
|
||||
// 原因:BUG-1102——按钮在星盘就绪时立即出现,封面还在画时点击被记下、画好即开;每次打开仍重放的断言不变
|
||||
assert.match(entry, /\{open \? <BirthSkyReplay cover=\{cover\} onClose=\{\(\) => setWanted\(false\)\} \/> : null\}/);
|
||||
assert.match(entry, /const open = wanted && cover !== null;/);
|
||||
assert.match(voice, /没能分享,再试一次。/);
|
||||
assert.match(design, /定格/);
|
||||
});
|
||||
|
||||
@@ -26,10 +26,14 @@ test("chart drawing limits are desktop-only and exclude the padded Vedic stage",
|
||||
const desktopEnd = globalStyles.indexOf("\n}", desktopStart);
|
||||
const desktop = globalStyles.slice(desktopStart, desktopEnd + 2);
|
||||
const outsideDesktop = globalStyles.slice(0, desktopStart) + globalStyles.slice(desktopEnd + 2);
|
||||
assert.match(cssDeclarations(".chart-page-vedic-plot", desktop), /max-width:\s*440px/);
|
||||
// 原值:桌面 `.chart-page-vedic-plot { max-width: 440px }`,且 `.chart-page-vedic-stage` 任何地方都不得有 max-width
|
||||
// 新值:桌面上限移到外框 `.chart-page-vedic-stage`(440px + 两侧内边距 + 边框),绘图区在框内撑满;手机端外框仍不设上限
|
||||
// 原因:产品 2026-09-29「星图外面的框太丑」——只限绘图区时外框比星图宽一大截,左右留白(BUG-1100)。骨架与结果同在外框内,揭幕不跳尺寸
|
||||
assert.match(cssDeclarations(".chart-page-vedic-stage", desktop), /max-width:\s*calc\(440px \+ 2 \* var\(--space-3\) \+ 2px\)/);
|
||||
assert.doesNotMatch(desktop, /\.chart-page-vedic-plot \{[^}]*max-width/);
|
||||
assert.match(cssDeclarations(".chart-page-western-wheel", desktop), /max-width:\s*480px/);
|
||||
assert.doesNotMatch(cssDeclarations(".chart-page-vedic-plot", outsideDesktop), /max-width/);
|
||||
assert.doesNotMatch(cssDeclarations(".chart-page-vedic-stage", globalStyles), /max-width/);
|
||||
assert.doesNotMatch(cssDeclarations(".chart-page-vedic-stage", outsideDesktop), /max-width/);
|
||||
assert.match(cssDeclarations(".chart-page-western-wheel", outsideDesktop), /max-width:\s*520px/);
|
||||
assert.match(cssDeclarations(".chart-page-waiting-plot", globalStyles), /width:\s*100%/);
|
||||
const skeleton = readFileSync(new URL("../src/components/chart-page/chart-page-skeleton.tsx", import.meta.url), "utf8");
|
||||
|
||||
@@ -24,6 +24,8 @@ class HostElement {
|
||||
clientHeight = 0;
|
||||
readonly scrollCalls: { top?: number }[] = [];
|
||||
scrollTo(options: { top?: number }) { this.scrollCalls.push(options); }
|
||||
/** No layout here: every box is the origin. Components that position from rects still run. */
|
||||
getBoundingClientRect() { return { left: 0, top: 0, right: 0, bottom: 0, width: 0, height: 0, x: 0, y: 0 }; }
|
||||
/** Class selectors only (`.a` / `.a.b`); enough for components that find their scroller. */
|
||||
closest(selector: string): HostElement | null {
|
||||
const wanted = selector.split(".").filter(Boolean);
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import test from "node:test";
|
||||
import React from "react";
|
||||
|
||||
import { BirthSkyEntry } from "../src/components/birth-sky/birth-sky-entry";
|
||||
import { ChartSymbolHelp, TOUCH_BUBBLE_MS, placeBubble } from "../src/components/chart-page/chart-symbol-help";
|
||||
import { VedicChartSvg } from "../src/components/personal-report/vedic-chart-svg";
|
||||
import { assembleChartView } from "../src/lib/chart-view-load";
|
||||
import type { ChartViewOk } from "../src/lib/chart-view-contract";
|
||||
import { goldenSky } from "./birth-sky-test-support";
|
||||
import { createClientLifecycleHarness } from "./react-client-lifecycle-test-support";
|
||||
|
||||
// Product feedback 2026-09-29 evening (BUG-1100..1103).
|
||||
Object.assign(globalThis, { React });
|
||||
const read = (path: string) => readFileSync(new URL(path, import.meta.url), "utf8");
|
||||
const golden = JSON.parse(read("./fixtures/chart-view-golden.json"));
|
||||
|
||||
async function d1() {
|
||||
const result = await assembleChartView({
|
||||
userId: "synthetic", accountId: "synthetic", profileFingerprint: "fixture-v1",
|
||||
profile: { name: "Synthetic", date: "1990-06-15", time: "12:00", placeLabel: "Synthetic", latitude: 39.9042, longitude: 116.4074, timezoneOffset: 8, timezoneId: "Asia/Shanghai", ayanamsa: "raman", birthTimeStatus: "confirmed" },
|
||||
asOf: "2026-09-15", layers: ["varga"],
|
||||
postEngine: async (path) => ({ status: "ok", payload: path === "/api/chart" ? golden.chart : golden.varga_full }),
|
||||
});
|
||||
return (result.body as ChartViewOk).vedic.vargas.find((varga) => varga.id === "D1")!.chart;
|
||||
}
|
||||
|
||||
test("the bubble sits over the mark, flips below near the top and never leaves the stage (BUG-1101)", () => {
|
||||
const stage = { left: 100, top: 50, width: 440 };
|
||||
const middle = placeBubble({ left: 300, top: 250, width: 20, height: 20 }, stage);
|
||||
assert.deepEqual(middle, { x: 210, y: 200, below: false });
|
||||
const top = placeBubble({ left: 300, top: 60, width: 20, height: 20 }, stage);
|
||||
assert.equal(top.below, true);
|
||||
assert.equal(top.y, 30, "below the mark: its bottom edge");
|
||||
assert.equal(placeBubble({ left: 102, top: 250, width: 10, height: 10 }, stage).x, 118, "clamped from the left edge");
|
||||
assert.equal(placeBubble({ left: 530, top: 250, width: 10, height: 10 }, stage).x, 322, "clamped from the right edge");
|
||||
});
|
||||
|
||||
test("hover shows a bubble at the mark; a tap keeps it past pointer-out and closes after 3s; no hint line (BUG-1101)", async () => {
|
||||
const chart = await d1();
|
||||
const h = createClientLifecycleHarness();
|
||||
try {
|
||||
await h.render(<ChartSymbolHelp resetKey="D1"><VedicChartSvg chart={chart} ariaLabel="测试盘" interactive /></ChartSymbolHelp>);
|
||||
const stage = h.elements().find((node) => node.getAttribute("class") === "chart-page-symbol-help")!;
|
||||
const bubble = () => h.elements().find((node) => node.getAttribute("role") === "tooltip");
|
||||
const asc = h.elements().find((node) => node.getAttribute("data-chart-help") === "As · 上升")!;
|
||||
assert.equal(bubble()?.text, undefined);
|
||||
assert.ok(!h.container.text.includes("停在或轻点"), "the hint line is gone");
|
||||
await h.event(stage, "onPointerOver", { target: asc, pointerType: "mouse" });
|
||||
assert.equal(bubble()?.text, "As · 上升");
|
||||
await h.event(stage, "onPointerOut", { target: asc, relatedTarget: null, pointerType: "mouse" });
|
||||
assert.equal(bubble()?.text, undefined);
|
||||
// Touch: pointer-over is ignored, the tap (pointer-up) opens, pointer-out and leave do not close.
|
||||
await h.event(stage, "onPointerOver", { target: asc, pointerType: "touch" });
|
||||
assert.equal(bubble()?.text, undefined);
|
||||
await h.event(stage, "onPointerUp", { target: asc, pointerType: "touch" });
|
||||
assert.equal(bubble()?.text, "As · 上升");
|
||||
await h.event(stage, "onPointerOut", { target: asc, relatedTarget: null, pointerType: "touch" });
|
||||
await h.event(stage, "onPointerLeave", { pointerType: "touch" });
|
||||
assert.equal(bubble()?.text, "As · 上升");
|
||||
await new Promise((resolve) => setTimeout(resolve, TOUCH_BUBBLE_MS + 50));
|
||||
await h.idle();
|
||||
assert.equal(bubble()?.text, undefined, "closes by itself");
|
||||
assert.deepEqual(h.errors, []);
|
||||
} finally { await h.close(); }
|
||||
});
|
||||
|
||||
test("the chart card hugs the drawing on desktop (BUG-1100)", () => {
|
||||
const css = read("../src/app/globals.css");
|
||||
assert.match(css, /\.chart-page-vedic-stage \{ width: 100%; max-width: calc\(440px \+ 2 \* var\(--space-3\) \+ 2px\); \}/);
|
||||
assert.doesNotMatch(read("../src/components/chart-page/chart-symbol-help.tsx"), /停在或轻点盘内标记可查看说明/);
|
||||
});
|
||||
|
||||
test("the sky button shows while the cover paints and opens the moment it lands (BUG-1102)", async () => {
|
||||
const h = createClientLifecycleHarness();
|
||||
try {
|
||||
await h.render(<BirthSkyEntry cover={null} />);
|
||||
const button = h.elements().find((node) => node.tagName === "BUTTON")!;
|
||||
assert.match(button.text, /那一刻的天空/);
|
||||
await h.event(button);
|
||||
// Remembered, nothing opened yet and nothing spinning.
|
||||
assert.ok(!h.container.text.includes("正在加载"));
|
||||
const cover = { key: "self:fp", sky: goldenSky("day"), blob: new Blob([], { type: "image/png" }), url: "blob:birth-sky", serif: false };
|
||||
await h.render(<BirthSkyEntry cover={cover} />);
|
||||
await h.idle();
|
||||
assert.deepEqual(h.errors, []);
|
||||
} finally { await h.close(); }
|
||||
const view = read("../src/components/chart-page/chart-page-view.tsx");
|
||||
assert.match(view, /\(birthSky \|\| birthSkyPending\) \? <BirthSkyEntry cover=\{birthSky \?\? null\} \/>/);
|
||||
const hook = read("../src/hooks/use-birth-sky-cover.ts");
|
||||
assert.match(hook, /pending: key !== null && current === null && settledKey !== key/);
|
||||
});
|
||||
|
||||
test("report centre card: spaced from the header, title says what, button says do (BUG-1103)", () => {
|
||||
const centre = read("../src/components/personal-report/personal-report-center.tsx");
|
||||
assert.match(centre, /<h2 id="report-center-create-title">完整本命报告<\/h2>/);
|
||||
assert.doesNotMatch(centre, /<h2[^>]*>生成个人报告/);
|
||||
assert.match(read("../src/app/globals.css"), /\.report-center-create \{\n width: min\(900px, 100%\);\n(?: \/\*[^\n]*\*\/\n)? margin: var\(--space-6\) auto var\(--space-8\);/);
|
||||
});
|
||||
Reference in New Issue
Block a user