feat(onboarding): first-chart reveal becomes a star convergence, no buttons (F4)

TASK-birth-sky-followup-20260928 F4:
- pure timeline / seeded scatter / interpolation in lib/birth-sky/converge.ts;
  drawBirthSky takes an optional frame (positions interpolated, alphas
  multiplied) so the settled frame is the exported cover call for call
- lazy full-screen <dialog> overlay driven by requestAnimationFrame at
  viewport x DPR; tap / click / Esc end it; reduced motion only fades
- data, serif and overlay chunk must all arrive inside the 3 s budget,
  otherwise skipped silently; page.tsx wiring unchanged
- one sonner hint after it ends; dialog reveal variant (继续, is-reveal) removed
- DESIGN §17, VOICE, CHANGELOG, checklist, PROGRESS with verification

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:
Jesse_Chen
2026-09-28 22:07:01 +08:00
co-authored by Claude Opus 5.5
parent a9a917d50c
commit 731c3b364b
15 changed files with 654 additions and 92 deletions
+10 -5
View File
@@ -1160,9 +1160,14 @@ D40 / D45 / D60 的表格上方,出生时间不是「已校正」时多一句
- **打印 / PDF:** 同一次取数同时画一张 `paper` palette 的图,只在打印时显示;封面独占 A4 第一页(`break-after: page`,高 246mm 居中,恰好放进报告 `@page` 的 186 × 270mm 版心)。不新增打印入口,仍是报告原来的「打印」。
- **取数与等待:** 组件与正文同一次渲染挂载,副作用在正文上屏之后才发请求;两张图都画好才出现。取数、画图任一失败就不显示封面,不留占位、不加等待态,正文不受影响。组件与样式在懒加载 chunk 里(`components/birth-sky/report-sky-cover.tsx`),首页不带。
### 位置三:首次建盘揭幕
### 位置三:首次建盘群星汇聚过场(2026-09-28 追加,TASK-birth-sky-followup-20260928 F4,替换原弹窗)
- 触发只有一处:首页 onboarding 里**自己的**出生地保存成功(`saveOnboardingPlace` 成功之后)。每个账户只发生一次,所以没有「看过」标记、不写库。星盘档案里新建家人、设置里改资料都不触发。
- 保存成功后,onboarding 先照常走到下一步,再去取 `/api/birth-sky?subject=self` 并画图;**3 秒内**画好就在当前页面之上淡入同一个弹层(600ms 透明度,`prefers-reduced-motion` 时直接出现),多一个主按钮「继续」。超时或失败静默跳过:不提示、不重试、不等待。
- 「继续」、「关闭」、Esc、点遮罩都只关掉弹层,下面就是原本的下一步(问候或校正引导),下一步不因它改变。onboarding 保存出生地后本来不会自动进入出生时间校正,所以不存在「关闭后再进入」的排队。0 点数账户的兑换弹窗若同时出现,天空弹层在它之上(原生 `<dialog>` 顶层),关掉后兑换弹窗仍在。
- 这是用户操作之后的结果展示,不是首页加载等待,不违反 §9「首页只揭幕一次」;首页加载揭幕的轨道环逻辑不动。逻辑在 `hooks/use-birth-sky-reveal.ts`(自持状态),`page.tsx` 只接线(`Home()` 的 `useState` / `useRef` 数不变)。
- **触发不变:** 只有一处——首页 onboarding 里**自己的**出生地保存成功(`saveOnboardingPlace` 成功、下一步已设好之后)。每个账户只发生一次,没有「看过」标记、不写库。星盘档案里新建家人、设置里改资料、平时打开首页都不触发。
- **不等待:** 保存成功后 onboarding 照常走到下一步,同时取 `/api/birth-sky?subject=self`、等宋体(最多 1.5 秒)、加载过场代码;三样都在 **3 秒**内到齐才开始放,否则静默跳过:不提示、不重试、不转圈、不骨架(§9)。过场开始时数据已全部就绪。
- **时序(约 3 秒):** ① 汇聚约 1.5 秒:地平线以上的恒星与行星从散开的位置 ease-out(三次方)汇聚到真实投影位置;散开位置由这张盘自己的数据做种子,均匀撒在地平线圆内,同一张盘每次一样。地平线圆、30° / 60° 高度圈、方位字、星座连线、行星名同步淡入。整层前 200ms 淡入,避免页面一下变黑。② 显现文字约 1 秒:顶部小字、细线、主句、事实句、底部说明在前 400ms 淡入,然后停留。③ 淡出约 0.5 秒:整层透明度降到 0,露出**原本的下一步**(问候 / 首页,或出生时间校正引导),下一步不因它改变。
- **最后一帧就是封面:** 每一帧都是 `drawBirthSky` 画的,只对位置插值(t = 0 散开、t = 1 真实位置,`interpolateSkyPoint`,t = 1 精确返回真实点)、对透明度乘系数;停留阶段的画布调用序列与导出封面逐条相同(单测逐调用比对)。
- **全屏、无按钮:** 原生 `<dialog>`(顶层,0 点数账户的兑换弹窗若同时出现,过场在它之上)铺满视口,底色是 `night` 的页底色;画布按视口取最大的 3:4 区域、乘 `devicePixelRatio` 绘制,`requestAnimationFrame` 驱动,卸载时取消。点击、触摸(`pointerdown`)或 Esc 立刻结束,直接进入下一步。
- **减少动态效果:** `prefers-reduced-motion: reduce` 时不汇聚:成品画面整体淡入(400ms)、停留、同一时刻淡出,总长同样约 3 秒。
- **结束后的提示:** 过场正常结束或被点掉后,首页用已有的 sonner Toaster 轻提示一次「这片天空在星盘页可以保存。」(VOICE.md)。取数失败、超时时没有过场,也没有提示。
- **平时的首页加载动画不改。** 这是用户操作之后的一次性结果展示,不是首页加载等待,不违反 §9「首页只揭幕一次」;轨道环揭幕逻辑不动。
- 代码:时序与插值是 `lib/birth-sky/converge.ts` 的纯函数;过场组件 `components/birth-sky/birth-sky-converge.tsx`(懒加载 chunk,含 `draw.ts` 与样式);状态在 `hooks/use-birth-sky-reveal.ts`,`page.tsx` 接线未变(`Home()` 的 `useState` / `useRef` 数不变)。星盘页的「那一刻的天空」弹窗删掉了只给揭幕用的 `is-reveal` 样式与「继续」按钮,其余不动。
+3 -1
View File
@@ -159,7 +159,9 @@ Jyotisha 的可见文案是产品的一部分。正确性红线(真实性、
首页开场语不用星星、月亮一类比喻(上文),那一条只管首页问候。封面写的是出生那一刻真实在天上的天体,不是比喻,两者不冲突。
弹层文案:标题「那一刻的天空」(也是星盘页按钮字),按钮「保存图片」「关闭」,首次揭幕多一个「继续」。保存失败(极少见,分享与下载都不可用时)写「没能保存。长按或右键上面的图,也能存下来。」图片的替代文字是主句加事实句。
弹层文案:标题「那一刻的天空」(也是星盘页按钮字),按钮「保存图片」「关闭」。保存失败(极少见,分享与下载都不可用时)写「没能保存。长按或右键上面的图,也能存下来。」图片的替代文字是主句加事实句。
首次建盘的群星汇聚过场(2026-09-28 追加,TASK-birth-sky-followup-20260928)里**没有按钮、没有额外文字**,字就是封面上的主句与事实句;原来弹层上的「继续」已删除。过场结束后首页轻提示一次:「这片天空在星盘页可以保存。」——一句陈述,句号结尾,不加感叹、不加 emoji,不催用户去做。报告封面也不加任何文字或按钮。
## 七条原则
@@ -0,0 +1,25 @@
/* First-chart convergence (TASK-birth-sky-followup-20260928 F4). Loaded with
the lazy overlay chunk only. Full screen, no buttons; opacity is driven
frame by frame from requestAnimationFrame, never from CSS keyframes. The
background is the night palette's floor, set inline from the painter. */
.birth-sky-converge {
position: fixed;
inset: 0;
width: 100%;
max-width: none;
height: 100%;
max-height: none;
margin: 0;
padding: 0;
border: 0;
display: grid;
place-items: center;
overflow: hidden;
cursor: default;
touch-action: none;
}
.birth-sky-converge:not([open]) { display: none; }
.birth-sky-converge::backdrop { background: transparent; }
.birth-sky-converge canvas { display: block; }
@supports (height: 1dvh) { .birth-sky-converge { height: 100dvh; } }
@media print { .birth-sky-converge { display: none !important; } }
@@ -0,0 +1,107 @@
"use client";
import { useEffect, useRef } from "react";
import "./birth-sky-converge.css";
import { birthSkyRevealAt, birthSkyScatter } from "@/lib/birth-sky/converge";
import { BIRTH_SKY_PALETTES, drawBirthSky, type BirthSkyCanvas } from "@/lib/birth-sky/draw";
import type { BirthSkyRevealSky } from "@/lib/birth-sky/reveal";
import { birthSkyFactLine, birthSkyHeadline } from "@/lib/birth-sky/sentence";
/** Largest 3:4 box that fits the viewport, in CSS pixels. */
export function birthSkyConvergeSize(viewportWidth: number, viewportHeight: number) {
const width = Math.max(1, Math.min(viewportWidth, (viewportHeight * 3) / 4));
return { width, height: (width * 4) / 3 };
}
/**
* First-chart convergence (TASK-birth-sky-followup-20260928 F4). A full-screen
* layer with no buttons: the stars converge, the words appear, the layer fades
* and the step underneath (unchanged) is simply there. A tap, a click or Esc
* ends it at once. Every frame is `drawBirthSky` with interpolated positions,
* so the settled frame is the exported cover.
*/
export function BirthSkyConverge({ reveal, onDone }: { reveal: BirthSkyRevealSky; onDone: () => void }) {
const dialog = useRef<HTMLDialogElement>(null);
const canvas = useRef<HTMLCanvasElement>(null);
useEffect(() => {
const layer = dialog.current;
const surface = canvas.current;
const context = surface?.getContext("2d");
if (!layer || !surface || !context) {
onDone();
return;
}
const reduced = window.matchMedia?.("(prefers-reduced-motion: reduce)").matches ?? false;
const scatter = birthSkyScatter(reveal.sky);
let frameHandle = 0;
let started: number | null = null;
let finished = false;
const fit = () => {
const size = birthSkyConvergeSize(window.innerWidth, window.innerHeight);
const ratio = window.devicePixelRatio || 1;
surface.style.width = `${size.width}px`;
surface.style.height = `${size.height}px`;
surface.width = Math.round(size.width * ratio);
surface.height = Math.round(size.height * ratio);
};
const finish = () => {
if (finished) return;
finished = true;
cancelAnimationFrame(frameHandle);
onDone();
};
const tick = (now: number) => {
if (finished) return;
started ??= now;
const moment = birthSkyRevealAt(now - started, reduced);
layer.style.opacity = String(moment.layer);
drawBirthSky(context as BirthSkyCanvas, reveal.sky, {
palette: "night",
width: surface.width,
height: surface.height,
serif: reveal.serif,
}, { ...moment.frame, scatter });
if (moment.done) finish();
else frameHandle = requestAnimationFrame(tick);
};
const cancel = (event: Event) => {
event.preventDefault();
finish();
};
fit();
layer.style.opacity = "0";
layer.showModal();
layer.addEventListener("cancel", cancel);
layer.addEventListener("pointerdown", finish);
window.addEventListener("resize", fit);
frameHandle = requestAnimationFrame(tick);
return () => {
finished = true;
cancelAnimationFrame(frameHandle);
layer.removeEventListener("cancel", cancel);
layer.removeEventListener("pointerdown", finish);
window.removeEventListener("resize", fit);
layer.close();
};
}, [reveal, onDone]);
return (
<dialog
ref={dialog}
className="birth-sky-converge"
aria-label="那一刻的天空"
style={{ background: BIRTH_SKY_PALETTES.night.background }}
>
<canvas
ref={canvas}
role="img"
aria-label={`${birthSkyHeadline(reveal.sky)}${birthSkyFactLine(reveal.sky)}。`}
/>
</dialog>
);
}
@@ -12,8 +12,6 @@ import { birthSkyFactLine, birthSkyHeadline } from "@/lib/birth-sky/sentence";
export type BirthSkyDialogProps = Readonly<{
cover: BirthSkyCover;
onClose: () => void;
/** First-chart reveal: fade in over the current step and offer 「继续」. */
reveal?: boolean;
}>;
/**
@@ -21,7 +19,7 @@ export type BirthSkyDialogProps = Readonly<{
* centred on desktop. The picture is an `<img>` of the painted PNG, so a long
* press saves it on iOS; 「保存图片」 shares or downloads the same file.
*/
export function BirthSkyDialog({ cover, onClose, reveal = false }: BirthSkyDialogProps) {
export function BirthSkyDialog({ cover, onClose }: BirthSkyDialogProps) {
const dialog = useRef<HTMLDialogElement>(null);
const saving = useRef(false);
const [error, setError] = useState<string | null>(null);
@@ -53,7 +51,7 @@ export function BirthSkyDialog({ cover, onClose, reveal = false }: BirthSkyDialo
return (
<dialog
ref={dialog}
className={reveal ? "birth-sky-dialog is-reveal" : "birth-sky-dialog"}
className="birth-sky-dialog"
aria-labelledby="birth-sky-title"
onCancel={(event) => { event.preventDefault(); onClose(); }}
onClick={(event) => { if (event.target === event.currentTarget) onClose(); }}
@@ -70,7 +68,6 @@ export function BirthSkyDialog({ cover, onClose, reveal = false }: BirthSkyDialo
<footer className="birth-sky-footer">
{error ? <p role="alert">{error}</p> : null}
<Button type="button" variant="outline" onClick={() => void save()}>保存图片</Button>
{reveal ? <Button type="button" onClick={onClose}>继续</Button> : null}
</footer>
</div>
</dialog>
@@ -2,14 +2,14 @@
import dynamic from "next/dynamic";
import type { BirthSkyCover } from "@/lib/birth-sky/cover";
import type { BirthSkyRevealSky } from "@/lib/birth-sky/reveal";
const BirthSkyDialog = dynamic(
() => import("./birth-sky-dialog").then((module) => module.BirthSkyDialog),
const BirthSkyConverge = dynamic(
() => import("./birth-sky-converge").then((module) => module.BirthSkyConverge),
{ ssr: false },
);
/** First-chart reveal: the chart page's dialog, faded in once, with 「继续」. */
export function BirthSkyReveal({ cover, onClose }: { cover: BirthSkyCover | null; onClose: () => void }) {
return cover ? <BirthSkyDialog cover={cover} onClose={onClose} reveal /> : null;
/** First-chart reveal: the stars converge once over the next step, then get out of the way. */
export function BirthSkyReveal({ cover, onClose }: { cover: BirthSkyRevealSky | null; onClose: () => void }) {
return cover ? <BirthSkyConverge reveal={cover} onDone={onClose} /> : null;
}
@@ -74,12 +74,4 @@
.birth-sky-panel { height: auto; max-height: calc(92vh - 2px); }
.birth-sky-image { max-height: calc(92vh - 160px); }
}
/* First-chart reveal: one 600ms fade, no motion when the reader asked for none. */
.birth-sky-dialog.is-reveal[open] { animation: birth-sky-reveal 600ms ease-out; }
.birth-sky-dialog.is-reveal[open]::backdrop { animation: birth-sky-reveal 600ms ease-out; }
@keyframes birth-sky-reveal { from { opacity: 0; } to { opacity: 1; } }
@media (prefers-reduced-motion: reduce) {
.birth-sky-dialog.is-reveal[open],
.birth-sky-dialog.is-reveal[open]::backdrop { animation: none; }
}
@media print { .birth-sky-dialog { display: none !important; } }
+30 -15
View File
@@ -1,34 +1,49 @@
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import { toast } from "sonner";
import { loadBirthSkyCover, releaseBirthSkyCover, type BirthSkyCover } from "@/lib/birth-sky/cover";
import { startBirthSkyReveal } from "@/lib/birth-sky/reveal";
import { loadBirthSkyRevealSky, startBirthSkyReveal, type BirthSkyRevealSky } from "@/lib/birth-sky/reveal";
/** Shown once on the home page after the convergence ends (VOICE.md). */
export const BIRTH_SKY_REVEAL_HINT = "这片天空在星盘页可以保存。";
/**
* The one-time 「那一刻的天空」 reveal after the reader's own first chart
* (TASK-birth-sky-cover-20260928 T6). Owns its state so Home only wires it:
* `begin` goes to the onboarding hook, `cover` / `close` to the dialog.
* Saving one's own birth place happens once per account, so no "seen" flag.
* The one-time 「那一刻的天空」 convergence after the reader's own first chart
* (TASK-birth-sky-cover-20260928 T6 → TASK-birth-sky-followup-20260928 F4).
* Owns its state so Home only wires it: `begin` goes to the onboarding hook,
* `cover` / `close` to the overlay. `cover` is the sky plus the serif verdict,
* ready before the first frame. Saving one's own birth place happens once per
* account, so no "seen" flag.
*/
export function useBirthSkyReveal() {
const [cover, setCover] = useState<BirthSkyCover | null>(null);
const [cover, setCover] = useState<BirthSkyRevealSky | null>(null);
const cancel = useRef<(() => void) | null>(null);
const playing = useRef(false);
const begin = useCallback(() => {
cancel.current?.();
cancel.current = startBirthSkyReveal({
load: (signal) => loadBirthSkyCover("self:reveal", "self", signal),
onReady: (next) => setCover(next),
onDiscard: releaseBirthSkyCover,
cancel.current = startBirthSkyReveal<BirthSkyRevealSky>({
// The overlay chunk loads inside the same budget, so the first frame never waits for code.
load: async (signal) => {
const [sky] = await Promise.all([
loadBirthSkyRevealSky(signal),
import("@/components/birth-sky/birth-sky-converge"),
]);
return sky;
},
onReady: (next) => {
playing.current = true;
setCover(next);
},
});
}, []);
const close = useCallback(() => {
setCover((current) => {
releaseBirthSkyCover(current);
return null;
});
if (!playing.current) return;
playing.current = false;
setCover(null);
toast(BIRTH_SKY_REVEAL_HINT);
}, []);
useEffect(() => () => { cancel.current?.(); }, []);
+146
View File
@@ -0,0 +1,146 @@
import type { BirthSkyOk } from "./contract.ts";
import {
BIRTH_SKY_CENTER,
BIRTH_SKY_RADIUS,
birthSkyBodyKey,
interpolateSkyPoint,
skyPoint,
type BirthSkyFrame,
type SkyPoint,
} from "./draw.ts";
/**
* First-chart convergence (TASK-birth-sky-followup-20260928 F4): the visible
* stars and planets travel from seeded scattered positions to their true
* projection, then the words appear, then the whole layer fades out. Pure
* functions only; the overlay feeds them `requestAnimationFrame` time.
*/
export const BIRTH_SKY_CONVERGE_MS = 1_500;
export const BIRTH_SKY_TEXT_MS = 1_000;
export const BIRTH_SKY_FADE_MS = 500;
export const BIRTH_SKY_REVEAL_TOTAL_MS = BIRTH_SKY_CONVERGE_MS + BIRTH_SKY_TEXT_MS + BIRTH_SKY_FADE_MS;
/** Words fade in over the first part of their second, then hold. */
const TEXT_FADE_IN_MS = 400;
/** The layer itself comes in quickly so the page does not flash to dark. */
const LAYER_FADE_IN_MS = 200;
/** Reduced motion: no travel, one fade in, a hold, one fade out. */
const REDUCED_FADE_IN_MS = 400;
/** Scattered stars stay inside the horizon so the clip never hides them. */
const SCATTER_RADIUS = BIRTH_SKY_RADIUS * 0.95;
export type BirthSkyVisibleBody = Readonly<{ key: string; point: SkyPoint }>;
/** Bodies drawn on the cover (above the horizon), keyed as the painter keys them. */
export function birthSkyVisibleBodies(sky: BirthSkyOk): readonly BirthSkyVisibleBody[] {
return [
...sky.stars
.filter((star) => star.altitude > 0)
.map((star) => ({ key: birthSkyBodyKey("star", star.hr), point: skyPoint(star.altitude, star.azimuth) })),
...sky.planets
.filter((planet) => planet.altitude > 0)
.map((planet) => ({ key: birthSkyBodyKey("planet", planet.id), point: skyPoint(planet.altitude, planet.azimuth) })),
];
}
/** Seed from the chart itself, so the same chart always scatters the same way. */
export function birthSkySeed(sky: BirthSkyOk): number {
let hash = 0x811c9dc5;
const feed = (value: string) => {
for (let index = 0; index < value.length; index += 1) {
hash ^= value.charCodeAt(index);
hash = Math.imul(hash, 0x01000193);
}
};
for (const body of birthSkyVisibleBodies(sky)) feed(`${body.key}@${body.point.x.toFixed(3)},${body.point.y.toFixed(3)};`);
return hash >>> 0;
}
/** mulberry32: small, fast, deterministic. */
function random(seed: number): () => number {
let state = seed >>> 0;
return () => {
state = (state + 0x6d2b79f5) >>> 0;
let t = state;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4_294_967_296;
};
}
/** Uniform scattered start points inside the horizon, one per visible body. */
export function birthSkyScatter(sky: BirthSkyOk, seed = birthSkySeed(sky)): ReadonlyMap<string, SkyPoint> {
const next = random(seed);
const scatter = new Map<string, SkyPoint>();
for (const body of birthSkyVisibleBodies(sky)) {
const radius = Math.sqrt(next()) * SCATTER_RADIUS;
const angle = next() * Math.PI * 2;
scatter.set(body.key, {
x: BIRTH_SKY_CENTER.x + radius * Math.cos(angle),
y: BIRTH_SKY_CENTER.y + radius * Math.sin(angle),
});
}
return scatter;
}
/** Positions at progress t (0 = scattered, 1 = true). */
export function birthSkyPositionsAt(
sky: BirthSkyOk,
scatter: ReadonlyMap<string, SkyPoint>,
t: number,
): ReadonlyMap<string, SkyPoint> {
const positions = new Map<string, SkyPoint>();
for (const body of birthSkyVisibleBodies(sky)) {
positions.set(body.key, interpolateSkyPoint(scatter.get(body.key) ?? body.point, body.point, t));
}
return positions;
}
export function easeOutCubic(t: number): number {
if (t <= 0) return 0;
if (t >= 1) return 1;
return 1 - (1 - t) ** 3;
}
function clamp01(value: number): number {
return value <= 0 ? 0 : value >= 1 ? 1 : value;
}
export type BirthSkyRevealTimeline = Readonly<{
/** Painter frame at this moment. */
frame: Omit<BirthSkyFrame, "scatter">;
/** Opacity of the whole overlay. */
layer: number;
/** True once the last fade has finished: remove the overlay, show the next step. */
done: boolean;
}>;
const SETTLED = { converge: 1, chrome: 1, text: 1 } as const;
/**
* Normal: 0–1.5 s converge (ease-out) with horizon, rings and letters fading
* in; 1.5–2.5 s words fade in and hold; 2.5–3 s the layer fades out.
* Reduced motion: the finished picture fades in, holds, fades out.
*/
export function birthSkyRevealAt(elapsedMs: number, reducedMotion: boolean): BirthSkyRevealTimeline {
const t = Math.max(0, elapsedMs);
const fadeStart = BIRTH_SKY_CONVERGE_MS + BIRTH_SKY_TEXT_MS;
const done = t >= BIRTH_SKY_REVEAL_TOTAL_MS;
const fadeOut = t >= fadeStart ? 1 - clamp01((t - fadeStart) / BIRTH_SKY_FADE_MS) : 1;
if (reducedMotion) {
return { frame: SETTLED, layer: Math.min(clamp01(t / REDUCED_FADE_IN_MS), fadeOut), done };
}
if (t < BIRTH_SKY_CONVERGE_MS) {
const progress = t / BIRTH_SKY_CONVERGE_MS;
return {
frame: { converge: easeOutCubic(progress), chrome: progress, text: 0 },
layer: clamp01(t / LAYER_FADE_IN_MS),
done: false,
};
}
return {
frame: { converge: 1, chrome: 1, text: clamp01((t - BIRTH_SKY_CONVERGE_MS) / TEXT_FADE_IN_MS) },
layer: fadeOut,
done,
};
}
+52 -10
View File
@@ -92,6 +92,35 @@ export function skyPoint(altitude: number, azimuth: number): SkyPoint {
}
export const BIRTH_SKY_CENTER: SkyPoint = { x: CENTER_X, y: CENTER_Y };
/** Horizon radius in design units. */
export const BIRTH_SKY_RADIUS = RADIUS;
/**
* One frame of the first-chart convergence (TASK-birth-sky-followup-20260928 F4).
* Omitted, or with every field at 1, the painter draws the exported cover
* exactly: positions are only interpolated, alphas only multiplied.
*/
export type BirthSkyFrame = Readonly<{
/** Eased progress: 0 = scattered positions, 1 = true projected positions. */
converge: number;
/** Horizon circle, altitude rings, direction letters, constellation lines, planet names. */
chrome: number;
/** Text frame: top label, rules, headline, fact line, footnote, disclaimer. */
text: number;
/** Scattered start positions keyed by `birthSkyBodyKey`. */
scatter: ReadonlyMap<string, SkyPoint>;
}>;
export function birthSkyBodyKey(kind: "star" | "planet", id: number | string): string {
return `${kind}:${id}`;
}
/** Pure: t = 0 returns `from`, t = 1 returns `to` exactly (no floating-point residue). */
export function interpolateSkyPoint(from: SkyPoint, to: SkyPoint, t: number): SkyPoint {
if (t >= 1) return to;
if (t <= 0) return from;
return { x: from.x * (1 - t) + to.x * t, y: from.y * (1 - t) + to.y * t };
}
function resolvePalette(palette: BirthSkyDrawOptions["palette"]): BirthSkyPalette {
return typeof palette === "string" ? BIRTH_SKY_PALETTES[palette] : palette;
@@ -111,8 +140,18 @@ function overlaps(a: Box, b: Box): boolean {
return a.x0 < b.x1 && b.x0 < a.x1 && a.y0 < b.y1 && b.y0 < a.y1;
}
export function drawBirthSky(ctx: BirthSkyCanvas, sky: BirthSkyOk, options: BirthSkyDrawOptions): void {
export function drawBirthSky(
ctx: BirthSkyCanvas,
sky: BirthSkyOk,
options: BirthSkyDrawOptions,
frame?: BirthSkyFrame,
): void {
const palette = resolvePalette(options.palette);
const chromeAlpha = frame?.chrome ?? 1;
const textAlpha = frame?.text ?? 1;
const place = (key: string, point: SkyPoint): SkyPoint => (
frame ? interpolateSkyPoint(frame.scatter.get(key) ?? point, point, frame.converge) : point
);
const serif = options.serif ?? true;
const footnote = options.footnote === undefined ? birthSkyFootnote(sky) : options.footnote;
const unit = options.width / UNIT_WIDTH;
@@ -131,7 +170,7 @@ export function drawBirthSky(ctx: BirthSkyCanvas, sky: BirthSkyOk, options: Birt
ctx.strokeStyle = palette.star;
ctx.lineWidth = 0.5;
ctx.globalAlpha = 0.22;
ctx.globalAlpha = 0.22 * chromeAlpha;
ctx.setLineDash([1, 4]);
for (const ring of [30, 60]) {
ctx.beginPath();
@@ -145,7 +184,7 @@ export function drawBirthSky(ctx: BirthSkyCanvas, sky: BirthSkyOk, options: Birt
ctx.globalAlpha = 0.85;
for (const star of sky.stars) {
if (star.altitude <= 0) continue;
const point = skyPoint(star.altitude, star.azimuth);
const point = place(birthSkyBodyKey("star", star.hr), skyPoint(star.altitude, star.azimuth));
visible.set(star.hr, point);
ctx.beginPath();
ctx.arc(point.x, point.y, starRadius(star.magnitude), 0, Math.PI * 2);
@@ -154,7 +193,7 @@ export function drawBirthSky(ctx: BirthSkyCanvas, sky: BirthSkyOk, options: Birt
ctx.strokeStyle = palette.star;
ctx.lineWidth = 0.6;
ctx.globalAlpha = 0.4;
ctx.globalAlpha = 0.4 * chromeAlpha;
for (const line of sky.lines) {
for (const stroke of line.strokes) {
for (let index = 1; index < stroke.length; index += 1) {
@@ -170,8 +209,11 @@ export function drawBirthSky(ctx: BirthSkyCanvas, sky: BirthSkyOk, options: Birt
}
const planets = sky.planets.filter((planet) => planet.altitude > 0);
const planetPoint = (planet: (typeof planets)[number]) => (
place(birthSkyBodyKey("planet", planet.id), skyPoint(planet.altitude, planet.azimuth))
);
for (const planet of planets) {
const point = skyPoint(planet.altitude, planet.azimuth);
const point = planetPoint(planet);
if (planet.id === "sun") {
ctx.fillStyle = palette.sun;
ctx.globalAlpha = 0.18;
@@ -201,7 +243,7 @@ export function drawBirthSky(ctx: BirthSkyCanvas, sky: BirthSkyOk, options: Birt
ctx.font = font(400, 11, false);
const labels: Box[] = [];
for (const planet of planets) {
const point = skyPoint(planet.altitude, planet.azimuth);
const point = planetPoint(planet);
const name = BIRTH_SKY_PLANET_NAMES[planet.id];
const width = 24;
const candidates = planet.id === "sun"
@@ -214,20 +256,20 @@ export function drawBirthSky(ctx: BirthSkyCanvas, sky: BirthSkyOk, options: Birt
const choice = candidates.find((item) => !labels.some((taken) => overlaps(box(item), taken))) ?? candidates[0];
labels.push(box(choice));
ctx.fillStyle = planet.id === "sun" ? palette.sun : palette.ink;
ctx.globalAlpha = 0.9;
ctx.globalAlpha = 0.9 * chromeAlpha;
ctx.textAlign = choice.align;
ctx.fillText(name, point.x + choice.dx, point.y + choice.dy);
}
ctx.strokeStyle = palette.star;
ctx.lineWidth = 0.9;
ctx.globalAlpha = 0.6;
ctx.globalAlpha = 0.6 * chromeAlpha;
ctx.beginPath();
ctx.arc(CENTER_X, CENTER_Y, RADIUS, 0, Math.PI * 2);
ctx.stroke();
ctx.fillStyle = palette.inkSecondary;
ctx.globalAlpha = 0.9;
ctx.globalAlpha = 0.9 * chromeAlpha;
ctx.textAlign = "center";
ctx.font = font(400, 12, false);
for (const [text, dx, dy] of [["北", 0, -1], ["南", 0, 1], ["东", -1, 0], ["西", 1, 0]] as const) {
@@ -237,7 +279,7 @@ export function drawBirthSky(ctx: BirthSkyCanvas, sky: BirthSkyOk, options: Birt
// Text frame (sample C): label, rule, headline … rule, fact, footnote, disclaimer.
ctx.textAlign = "left";
ctx.fillStyle = palette.inkSecondary;
ctx.globalAlpha = 1;
ctx.globalAlpha = 1 * textAlpha;
ctx.font = font(600, 12, serif);
// Tracked by hand (canvas letterSpacing is not in every Safari we support).
let cursor = 48;
+30 -11
View File
@@ -1,26 +1,45 @@
import type { BirthSkyCover } from "./cover.ts";
import type { BirthSkyOk } from "./contract.ts";
import { fetchBirthSky } from "./cover.ts";
/**
* First-chart reveal timing (TASK-birth-sky-cover-20260928 T6).
* First-chart reveal timing (TASK-birth-sky-cover-20260928 T6, convergence
* since TASK-birth-sky-followup-20260928 F4).
*
* After the reader's own birth place is saved, the cover gets one chance: if
* it is fetched and painted within `timeoutMs` it is shown, otherwise it is
* dropped silently — no error, no retry, and the onboarding flow never waits.
* After the reader's own birth place is saved, the reveal gets one chance: if
* its data is ready within `timeoutMs` it plays, otherwise it is dropped
* silently — no error, no retry, and the onboarding flow never waits.
*/
export const BIRTH_SKY_REVEAL_TIMEOUT_MS = 3_000;
export type BirthSkyRevealInput = Readonly<{
load: (signal: AbortSignal) => Promise<BirthSkyCover | null>;
onReady: (cover: BirthSkyCover) => void;
/** A cover that arrived too late; the caller releases its object URL. */
onDiscard?: (cover: BirthSkyCover) => void;
/** Everything the convergence overlay needs before its first frame. */
export type BirthSkyRevealSky = Readonly<{ sky: BirthSkyOk; serif: boolean }>;
/** Fetch the reader's own sky and wait (bounded) for the heading serif. Null on any failure. */
export async function loadBirthSkyRevealSky(signal: AbortSignal): Promise<BirthSkyRevealSky | null> {
try {
const sky = await fetchBirthSky("self", signal);
if (!sky || signal.aborted) return null;
const { BIRTH_SKY_TOP_LABEL, birthSkyHeadline } = await import("./sentence.ts");
const { waitForHeadingSerif } = await import("./render.ts");
const serif = await waitForHeadingSerif(`${birthSkyHeadline(sky)}${BIRTH_SKY_TOP_LABEL}`);
return signal.aborted ? null : { sky, serif };
} catch {
return null;
}
}
export type BirthSkyRevealInput<T> = Readonly<{
load: (signal: AbortSignal) => Promise<T | null>;
onReady: (value: T) => void;
/** A value that arrived too late; the caller releases whatever it holds. */
onDiscard?: (value: T) => void;
timeoutMs?: number;
setTimer?: (callback: () => void, ms: number) => unknown;
clearTimer?: (handle: unknown) => void;
}>;
/** Starts one reveal attempt; the returned function cancels it. */
export function startBirthSkyReveal(input: BirthSkyRevealInput): () => void {
export function startBirthSkyReveal<T>(input: BirthSkyRevealInput<T>): () => void {
const setTimer = input.setTimer ?? ((callback: () => void, ms: number) => setTimeout(callback, ms));
const clearTimer = input.clearTimer ?? ((handle: unknown) => clearTimeout(handle as ReturnType<typeof setTimeout>));
const controller = new AbortController();
@@ -2,20 +2,41 @@ import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
import type { BirthSkyCover } from "../src/lib/birth-sky/cover.ts";
import { BIRTH_SKY_REVEAL_TIMEOUT_MS, startBirthSkyReveal } from "../src/lib/birth-sky/reveal.ts";
import {
BIRTH_SKY_CONVERGE_MS,
BIRTH_SKY_FADE_MS,
BIRTH_SKY_REVEAL_TOTAL_MS,
BIRTH_SKY_TEXT_MS,
birthSkyPositionsAt,
birthSkyRevealAt,
birthSkyScatter,
birthSkySeed,
birthSkyVisibleBodies,
} from "../src/lib/birth-sky/converge.ts";
import {
BIRTH_SKY_EXPORT_HEIGHT,
BIRTH_SKY_EXPORT_WIDTH,
drawBirthSky,
interpolateSkyPoint,
type BirthSkyCanvas,
} from "../src/lib/birth-sky/draw.ts";
import { BIRTH_SKY_REVEAL_TIMEOUT_MS, startBirthSkyReveal, type BirthSkyRevealSky } from "../src/lib/birth-sky/reveal.ts";
import { goldenSky } from "./birth-sky-test-support.ts";
// 「那一刻的天空」 first-chart reveal (TASK-birth-sky-cover-20260928 T6).
// 「那一刻的天空」 first-chart reveal (TASK-birth-sky-cover-20260928 T6),
// convergence since TASK-birth-sky-followup-20260928 F4.
const onboarding = readFileSync(new URL("../src/hooks/use-profile-onboarding.ts", import.meta.url), "utf8");
const page = readFileSync(new URL("../src/app/(app)/page.tsx", import.meta.url), "utf8");
const revealHook = readFileSync(new URL("../src/hooks/use-birth-sky-reveal.ts", import.meta.url), "utf8");
const revealComponent = readFileSync(new URL("../src/components/birth-sky/birth-sky-reveal.tsx", import.meta.url), "utf8");
const converge = readFileSync(new URL("../src/components/birth-sky/birth-sky-converge.tsx", import.meta.url), "utf8");
const convergeCss = readFileSync(new URL("../src/components/birth-sky/birth-sky-converge.css", import.meta.url), "utf8");
const dialog = readFileSync(new URL("../src/components/birth-sky/birth-sky-dialog.tsx", import.meta.url), "utf8");
const dialogCss = readFileSync(new URL("../src/components/birth-sky/birth-sky.css", import.meta.url), "utf8");
const voice = readFileSync(new URL("../docs/VOICE.md", import.meta.url), "utf8");
const cover: BirthSkyCover = { key: "self:reveal", sky: goldenSky("day"), blob: new Blob([]), url: "blob:sky" };
const cover: BirthSkyRevealSky = { sky: goldenSky("day"), serif: true };
function fakeClock() {
let pending: { callback: () => void; ms: number } | null = null;
@@ -34,10 +55,10 @@ function deferred<T>() {
return { promise, resolve, reject };
}
test("saved in time: the dialog opens with the painted cover; closing touches nothing else", async () => {
test("saved in time: the convergence starts once its data is ready; ending touches nothing else", async () => {
const clock = fakeClock();
const load = deferred<BirthSkyCover | null>();
const shown: BirthSkyCover[] = [];
const load = deferred<BirthSkyRevealSky | null>();
const shown: BirthSkyRevealSky[] = [];
startBirthSkyReveal({ load: () => load.promise, onReady: (item) => shown.push(item), ...clock });
assert.equal(clock.ms, BIRTH_SKY_REVEAL_TIMEOUT_MS);
assert.equal(BIRTH_SKY_REVEAL_TIMEOUT_MS, 3_000);
@@ -45,18 +66,18 @@ test("saved in time: the dialog opens with the painted cover; closing touches no
await load.promise;
await Promise.resolve();
assert.deepEqual(shown, [cover]);
assert.equal(clock.ms, null, "the timeout is cleared once the cover is shown");
// Close only clears the reveal's own cover; the onboarding step is not touched.
assert.equal(clock.ms, null, "the timeout is cleared once the reveal starts");
// Ending only clears the reveal's own state; the onboarding step is not touched.
assert.match(page, /<BirthSkyReveal cover=\{birthSkyReveal\.cover\} onClose=\{birthSkyReveal\.close\} \/>/);
const close = revealHook.slice(revealHook.indexOf("const close"));
assert.doesNotMatch(close.slice(0, close.indexOf("}, []);")), /Onboarding|setOnboarding|setProfile|Journey/);
});
test("timeout: no dialog, the late cover is released, the request is aborted", async () => {
test("timeout: no overlay, the late data is dropped, the request is aborted", async () => {
const clock = fakeClock();
const load = deferred<BirthSkyCover | null>();
const shown: BirthSkyCover[] = [];
const discarded: BirthSkyCover[] = [];
const load = deferred<BirthSkyRevealSky | null>();
const shown: BirthSkyRevealSky[] = [];
const discarded: BirthSkyRevealSky[] = [];
let signal: AbortSignal | null = null;
startBirthSkyReveal({
load: (next) => { signal = next; return load.promise; },
@@ -73,18 +94,20 @@ test("timeout: no dialog, the late cover is released, the request is aborted", a
assert.deepEqual(discarded, [cover]);
});
test("failure: no dialog, no retry, no error surfaced", async () => {
test("failure: no overlay, no retry, no error surfaced", async () => {
const clock = fakeClock();
let calls = 0;
const shown: BirthSkyCover[] = [];
const shown: BirthSkyRevealSky[] = [];
startBirthSkyReveal({ load: async () => { calls += 1; throw new Error("offline"); }, onReady: (item) => shown.push(item), ...clock });
await new Promise((resolve) => setImmediate(resolve));
const nullCover = fakeClock();
startBirthSkyReveal({ load: async () => { calls += 1; return null; }, onReady: (item) => shown.push(item), ...nullCover });
startBirthSkyReveal<BirthSkyRevealSky>({ load: async () => { calls += 1; return null; }, onReady: (item) => shown.push(item), ...nullCover });
await new Promise((resolve) => setImmediate(resolve));
assert.equal(calls, 2);
assert.deepEqual(shown, []);
assert.doesNotMatch(revealHook, /toast|setError|console\.(error|warn)/);
assert.doesNotMatch(revealHook, /setError|console\.(error|warn)/);
// The only toast is the hint in `close`, which runs after the overlay has played.
assert.equal(revealHook.match(/toast\(/g)?.length, 1);
});
test("only the reader's own onboarding place save triggers it, after the flow has moved on", () => {
@@ -106,15 +129,123 @@ test("only the reader's own onboarding place save triggers it, after the flow ha
assert.doesNotMatch(readFileSync(new URL(path, import.meta.url), "utf8"), /BirthSkyReveal|useBirthSkyReveal|onOwnBirthPlaceSaved/);
}
assert.match(page, /onOwnBirthPlaceSaved: birthSkyReveal\.begin,/);
assert.match(revealHook, /loadBirthSkyCover\("self:reveal", "self", signal\)/);
assert.match(revealHook, /loadBirthSkyRevealSky\(signal\)/);
});
test("the reveal is the chart page's dialog plus 「继续」, faded in 600ms, still under reduced motion", () => {
assert.match(revealComponent, /dynamic\(\s*\(\) => import\("\.\/birth-sky-dialog"\)/);
assert.match(revealComponent, /reveal \/>/);
assert.match(dialog, /\{reveal \? <Button type="button" onClick=\{onClose\}>继续<\/Button> : null\}/);
assert.match(dialog, /onCancel=\{\(event\) => \{ event\.preventDefault\(\); onClose\(\); \}\}/);
assert.match(dialog, /if \(event\.target === event\.currentTarget\) onClose\(\);/);
assert.match(dialogCss, /\.birth-sky-dialog\.is-reveal\[open\] \{ animation: birth-sky-reveal 600ms ease-out; \}/);
assert.match(dialogCss, /@media \(prefers-reduced-motion: reduce\) \{\s*\.birth-sky-dialog\.is-reveal\[open\],\s*\.birth-sky-dialog\.is-reveal\[open\]::backdrop \{ animation: none; \}/);
test("interpolation is pure: t=0 is the seeded scatter, t=1 is the true projection point for point", () => {
const sky = goldenSky("night");
const bodies = birthSkyVisibleBodies(sky);
assert.ok(bodies.length > 20, "night golden has plenty of visible stars");
const scatter = birthSkyScatter(sky);
// Same chart, same seed, same scatter; another chart scatters differently.
assert.deepEqual([...birthSkyScatter(sky)], [...scatter]);
assert.deepEqual([...birthSkyScatter(sky, birthSkySeed(sky))], [...scatter]);
assert.notEqual(birthSkySeed(goldenSky("day")), birthSkySeed(sky));
const start = birthSkyPositionsAt(sky, scatter, 0);
const end = birthSkyPositionsAt(sky, scatter, 1);
for (const body of bodies) {
assert.deepEqual(start.get(body.key), scatter.get(body.key));
assert.deepEqual(end.get(body.key), body.point, body.key);
const point = scatter.get(body.key)!;
assert.ok(Math.hypot(point.x - 300, point.y - 400) <= 200, "scatter stays inside the horizon");
}
assert.deepEqual(interpolateSkyPoint({ x: 0, y: 0 }, { x: 10, y: 20 }, 0.5), { x: 5, y: 10 });
});
type Call = { method: string; args: unknown[]; state: Record<string, unknown> };
function recorder() {
const calls: Call[] = [];
const state: Record<string, unknown> = {};
const ctx = new Proxy({} as Record<string, unknown>, {
get(_target, key: string) {
if (key in state) return state[key];
if (key === "measureText") return (text: string) => ({ width: text.length * 10 });
return (...args: unknown[]) => { calls.push({ method: key, args, state: { ...state } }); };
},
set(_target, key: string, value: unknown) {
state[key] = value;
return true;
},
}) as unknown as BirthSkyCanvas;
return { ctx, calls };
}
test("the settled frame is the exported cover, call for call; earlier frames move and fade", () => {
for (const phase of ["day", "night", "twilight_evening"] as const) {
const sky = goldenSky(phase);
const options = { palette: "night" as const, width: BIRTH_SKY_EXPORT_WIDTH, height: BIRTH_SKY_EXPORT_HEIGHT };
const exported = recorder();
drawBirthSky(exported.ctx, sky, options);
const scatter = birthSkyScatter(sky);
const settled = recorder();
const hold = birthSkyRevealAt(BIRTH_SKY_CONVERGE_MS + BIRTH_SKY_TEXT_MS - 1, false);
drawBirthSky(settled.ctx, sky, options, { ...hold.frame, scatter });
assert.deepEqual(settled.calls, exported.calls, phase);
const first = recorder();
drawBirthSky(first.ctx, sky, options, { ...birthSkyRevealAt(0, false).frame, scatter });
assert.notDeepEqual(first.calls, exported.calls);
const words = first.calls.filter((call) => call.method === "fillText" && call.args[0] === "仅供自我探索,不替你做决定。");
assert.equal(words[0]?.state.globalAlpha, 0, "the words are not visible while the stars travel");
}
// The overlay draws every frame through the same painter.
assert.match(converge, /drawBirthSky\(context as BirthSkyCanvas, reveal\.sky, \{/);
});
test("timeline: converge ~1.5 s, words ~1 s, fade ~0.5 s; reduced motion only fades in, holds, fades out", () => {
assert.equal(BIRTH_SKY_CONVERGE_MS, 1_500);
assert.equal(BIRTH_SKY_TEXT_MS, 1_000);
assert.equal(BIRTH_SKY_FADE_MS, 500);
assert.equal(BIRTH_SKY_REVEAL_TOTAL_MS, 3_000);
const at = (ms: number) => birthSkyRevealAt(ms, false);
assert.deepEqual(at(0).frame, { converge: 0, chrome: 0, text: 0 });
assert.ok(at(750).frame.converge > 0.5, "ease-out: more than half way at half time");
assert.equal(at(750).frame.text, 0);
assert.equal(at(1_500).frame.converge, 1);
assert.equal(at(2_400).frame.text, 1);
assert.equal(at(2_400).layer, 1);
assert.ok(at(2_750).layer > 0 && at(2_750).layer < 1);
assert.equal(at(2_999).done, false);
assert.equal(at(3_000).done, true);
assert.equal(at(3_000).layer, 0);
for (const ms of [0, 200, 1_000, 2_000, 2_700]) {
assert.deepEqual(birthSkyRevealAt(ms, true).frame, { converge: 1, chrome: 1, text: 1 }, `no travel at ${ms}ms`);
}
assert.equal(birthSkyRevealAt(0, true).layer, 0);
assert.equal(birthSkyRevealAt(1_000, true).layer, 1);
assert.ok(birthSkyRevealAt(2_750, true).layer < 1);
assert.equal(birthSkyRevealAt(3_000, true).done, true);
assert.match(converge, /window\.matchMedia\?\.\("\(prefers-reduced-motion: reduce\)"\)/);
});
test("overlay: full screen, no buttons; tap, click or Esc ends it at once; rAF sized to viewport × DPR and cancelled on unmount", () => {
assert.match(revealComponent, /dynamic\(\s*\(\) => import\("\.\/birth-sky-converge"\)/);
assert.match(revealComponent, /<BirthSkyConverge reveal=\{cover\} onDone=\{onClose\} \/>/);
assert.doesNotMatch(converge, /<Button|<button|继续|Spinner/);
assert.match(converge, /layer\.addEventListener\("pointerdown", finish\)/);
assert.match(converge, /layer\.addEventListener\("cancel", cancel\)/);
assert.match(converge, /requestAnimationFrame\(tick\)/);
assert.match(converge, /window\.devicePixelRatio/);
assert.match(converge, /window\.innerWidth, window\.innerHeight/);
const cleanup = converge.slice(converge.indexOf("return () => {\n finished = true;"));
assert.match(cleanup, /cancelAnimationFrame\(frameHandle\)/);
assert.match(convergeCss, /\.birth-sky-converge \{[^}]*position: fixed;[^}]*inset: 0;/);
assert.doesNotMatch(convergeCss, /animation|@keyframes/, "opacity comes from the frame clock, not CSS");
// The reveal only starts once data (and the overlay code) is ready, inside the 3 s budget.
assert.equal(BIRTH_SKY_REVEAL_TIMEOUT_MS, 3_000);
assert.match(revealHook, /import\("@\/components\/birth-sky\/birth-sky-converge"\)/);
});
test("the hint is the VOICE line, shown once after the overlay ends", () => {
assert.match(revealHook, /export const BIRTH_SKY_REVEAL_HINT = "这片天空在星盘页可以保存。";/);
assert.match(voice, /这片天空在星盘页可以保存。/);
const close = revealHook.slice(revealHook.indexOf("const close"));
const body = close.slice(0, close.indexOf("}, []);"));
assert.match(body, /if \(!playing\.current\) return;\s*playing\.current = false;/);
assert.match(body, /toast\(BIRTH_SKY_REVEAL_HINT\)/);
assert.match(revealHook, /from "sonner"/, "reuses the app's Toaster, no new component");
});
test("the chart page dialog no longer has a reveal variant (「继续」, is-reveal)", () => {
assert.doesNotMatch(dialog, /reveal|继续/);
assert.doesNotMatch(dialogCss, /is-reveal|birth-sky-reveal/);
});