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
+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?.(); }, []);