feat(onboarding): one-time 「那一刻的天空」 reveal after the first own chart (T6)

After saveOnboardingPlace succeeds (and the next step is already set), the
new useBirthSkyReveal hook fetches /api/birth-sky?subject=self and paints
the cover; if that finishes within 3 s the chart page's dialog fades in
(600 ms, none under reduced motion) with 「继续」. Timeout or failure is
silent, no retry; closing touches only the reveal's own state. Home() gains
no useState/useRef; page.tsx only wires. Family profiles and profile edits
never trigger it.

Also caps the dialog image by viewport height (the percentage max-height did
not resolve inside the flex/grid cell), and completes PROGRESS, DESIGN §17,
CHANGELOG and the real-device checklist.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0199rbQDTsUbCVw84wc8BTFe
This commit is contained in:
Jesse_Chen
2026-09-28 20:47:35 +08:00
co-authored by Claude Opus 5.5
parent 028aef2737
commit 14ef18ac24
13 changed files with 369 additions and 9 deletions
+5
View File
@@ -12,6 +12,7 @@ import type { BirthTimeAssessmentPhase } from "@/components/birth-time-assessmen
import { AppLoadingIndicator } from "@/components/app-loading-indicator";
import { HomeBootstrapError } from "@/components/home-bootstrap-error";
import { HomeOnboardingShell } from "@/components/home-onboarding-shell";
import { BirthSkyReveal } from "@/components/birth-sky/birth-sky-reveal";
import {
entrySummaryFromResponse,
rectificationEntryLabels,
@@ -66,6 +67,7 @@ import type { ConsultationBirthTimeMode } from "@/lib/consultation-birth-time-mo
import { useBirthTimeGuidedJourney } from "@/hooks/use-birth-time-guided-journey";
import { useConsultationRun } from "@/hooks/use-consultation-run";
import { useConversationScrollAnchor } from "@/hooks/use-conversation-scroll-anchor";
import { useBirthSkyReveal } from "@/hooks/use-birth-sky-reveal";
import { useProfileOnboarding } from "@/hooks/use-profile-onboarding";
import {
createRectificationShellSetters,
@@ -297,6 +299,7 @@ export default function Home() {
const uiPreview = useRef(false);
const uiPreviewMode = useRef<string | null>(null);
const birthTimeRevisionPendingRef = useRef(false);
const birthSkyReveal = useBirthSkyReveal();
const {
profile,
setProfile,
@@ -330,6 +333,7 @@ export default function Home() {
setRectificationError,
setSigningOut, setStartGreeting, signingOut, uiPreview,
initialProfile: () => warmStart()?.profile ?? null,
onOwnBirthPlaceSaved: birthSkyReveal.begin,
});
const birthTimeGuided = useBirthTimeGuidedJourney({
@@ -1349,6 +1353,7 @@ export default function Home() {
? <div className="starter-balance" aria-hidden="true" />
: null}
<BirthSkyReveal cover={birthSkyReveal.cover} onClose={birthSkyReveal.close} />
<AccountDialogOverlay
open={activeAccountDialog !== null}
dialog={activeAccountDialog}
@@ -0,0 +1,15 @@
"use client";
import dynamic from "next/dynamic";
import type { BirthSkyCover } from "@/lib/birth-sky/cover";
const BirthSkyDialog = dynamic(
() => import("./birth-sky-dialog").then((module) => module.BirthSkyDialog),
{ 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;
}
@@ -15,9 +15,6 @@
color: var(--color-ink);
overflow: hidden;
}
@supports (height: 1dvh) {
.birth-sky-dialog { height: 100dvh; }
}
.birth-sky-dialog::backdrop { background: var(--color-scrim); }
.birth-sky-panel { display: flex; flex-direction: column; height: 100%; }
.birth-sky-header {
@@ -42,11 +39,16 @@
width: auto;
height: auto;
max-width: 100%;
max-height: 100%;
/* Header + footer ≈ 170px; a percentage would not resolve inside the flex/grid cell everywhere. */
max-height: calc(100vh - 170px);
aspect-ratio: 3 / 4;
border-radius: var(--radius-md);
-webkit-touch-callout: default;
}
@supports (height: 1dvh) {
.birth-sky-dialog { height: 100dvh; }
.birth-sky-image { max-height: calc(100dvh - 170px); }
}
.birth-sky-footer {
display: flex;
flex-wrap: wrap;
@@ -0,0 +1,37 @@
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import { loadBirthSkyCover, releaseBirthSkyCover, type BirthSkyCover } from "@/lib/birth-sky/cover";
import { startBirthSkyReveal } from "@/lib/birth-sky/reveal";
/**
* 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.
*/
export function useBirthSkyReveal() {
const [cover, setCover] = useState<BirthSkyCover | null>(null);
const cancel = useRef<(() => void) | null>(null);
const begin = useCallback(() => {
cancel.current?.();
cancel.current = startBirthSkyReveal({
load: (signal) => loadBirthSkyCover("self:reveal", "self", signal),
onReady: (next) => setCover(next),
onDiscard: releaseBirthSkyCover,
});
}, []);
const close = useCallback(() => {
setCover((current) => {
releaseBirthSkyCover(current);
return null;
});
}, []);
useEffect(() => () => { cancel.current?.(); }, []);
return { cover, begin, close };
}
@@ -83,6 +83,8 @@ export type ProfileOnboardingParams = {
uiPreview: MutableRefObject<boolean>;
/** Warm return to `/` (BUG-1040): the profile Home starts with instead of empty. */
initialProfile?: () => Profile | null;
/** The reader's own birth place was saved in onboarding (first chart): 「那一刻的天空」 reveal. */
onOwnBirthPlaceSaved?: () => void;
};
export function useProfileOnboarding(params: ProfileOnboardingParams) {
@@ -111,6 +113,7 @@ export function useProfileOnboarding(params: ProfileOnboardingParams) {
signingOut,
uiPreview,
initialProfile,
onOwnBirthPlaceSaved,
} = params;
// Owned here since 2026-09-16 (state lowering batch 2). Home reads them back
@@ -379,6 +382,8 @@ export function useProfileOnboarding(params: ProfileOnboardingParams) {
setStartGreeting(createStartGreeting(savedProfile.name));
setPresetMessageLength(0);
setOnboardingJustCompleted(false);
// Fire and forget: the flow above has already moved on and never waits for it.
onOwnBirthPlaceSaved?.();
} catch (caught) {
setAccountError(friendlyError(caught instanceof Error ? caught.message : "出生地点保存失败"));
} finally {
+54
View File
@@ -0,0 +1,54 @@
import type { BirthSkyCover } from "./cover.ts";
/**
* First-chart reveal timing (TASK-birth-sky-cover-20260928 T6).
*
* 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.
*/
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;
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 {
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();
let settled = false;
const timer = setTimer(() => {
if (settled) return;
settled = true;
controller.abort();
}, input.timeoutMs ?? BIRTH_SKY_REVEAL_TIMEOUT_MS);
void input.load(controller.signal).then(
(cover) => {
if (settled || !cover) {
if (cover) input.onDiscard?.(cover);
return;
}
settled = true;
clearTimer(timer);
input.onReady(cover);
},
() => {
settled = true;
clearTimer(timer);
},
);
return () => {
if (settled) return;
settled = true;
clearTimer(timer);
controller.abort();
};
}