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
+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();