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
@@ -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/);
});