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:
co-authored by
Claude Opus 5.5
parent
028aef2737
commit
14ef18ac24
@@ -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 {
|
||||
|
||||
@@ -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();
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user