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:
co-authored by
Claude Opus 5.5
parent
a9a917d50c
commit
731c3b364b
@@ -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/);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user