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
@@ -0,0 +1,25 @@
|
||||
/* First-chart convergence (TASK-birth-sky-followup-20260928 F4). Loaded with
|
||||
the lazy overlay chunk only. Full screen, no buttons; opacity is driven
|
||||
frame by frame from requestAnimationFrame, never from CSS keyframes. The
|
||||
background is the night palette's floor, set inline from the painter. */
|
||||
.birth-sky-converge {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
max-width: none;
|
||||
height: 100%;
|
||||
max-height: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
overflow: hidden;
|
||||
cursor: default;
|
||||
touch-action: none;
|
||||
}
|
||||
.birth-sky-converge:not([open]) { display: none; }
|
||||
.birth-sky-converge::backdrop { background: transparent; }
|
||||
.birth-sky-converge canvas { display: block; }
|
||||
@supports (height: 1dvh) { .birth-sky-converge { height: 100dvh; } }
|
||||
@media print { .birth-sky-converge { display: none !important; } }
|
||||
@@ -0,0 +1,107 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef } from "react";
|
||||
|
||||
import "./birth-sky-converge.css";
|
||||
|
||||
import { birthSkyRevealAt, birthSkyScatter } from "@/lib/birth-sky/converge";
|
||||
import { BIRTH_SKY_PALETTES, drawBirthSky, type BirthSkyCanvas } from "@/lib/birth-sky/draw";
|
||||
import type { BirthSkyRevealSky } from "@/lib/birth-sky/reveal";
|
||||
import { birthSkyFactLine, birthSkyHeadline } from "@/lib/birth-sky/sentence";
|
||||
|
||||
/** Largest 3:4 box that fits the viewport, in CSS pixels. */
|
||||
export function birthSkyConvergeSize(viewportWidth: number, viewportHeight: number) {
|
||||
const width = Math.max(1, Math.min(viewportWidth, (viewportHeight * 3) / 4));
|
||||
return { width, height: (width * 4) / 3 };
|
||||
}
|
||||
|
||||
/**
|
||||
* First-chart convergence (TASK-birth-sky-followup-20260928 F4). A full-screen
|
||||
* layer with no buttons: the stars converge, the words appear, the layer fades
|
||||
* and the step underneath (unchanged) is simply there. A tap, a click or Esc
|
||||
* ends it at once. Every frame is `drawBirthSky` with interpolated positions,
|
||||
* so the settled frame is the exported cover.
|
||||
*/
|
||||
export function BirthSkyConverge({ reveal, onDone }: { reveal: BirthSkyRevealSky; onDone: () => void }) {
|
||||
const dialog = useRef<HTMLDialogElement>(null);
|
||||
const canvas = useRef<HTMLCanvasElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const layer = dialog.current;
|
||||
const surface = canvas.current;
|
||||
const context = surface?.getContext("2d");
|
||||
if (!layer || !surface || !context) {
|
||||
onDone();
|
||||
return;
|
||||
}
|
||||
const reduced = window.matchMedia?.("(prefers-reduced-motion: reduce)").matches ?? false;
|
||||
const scatter = birthSkyScatter(reveal.sky);
|
||||
let frameHandle = 0;
|
||||
let started: number | null = null;
|
||||
let finished = false;
|
||||
|
||||
const fit = () => {
|
||||
const size = birthSkyConvergeSize(window.innerWidth, window.innerHeight);
|
||||
const ratio = window.devicePixelRatio || 1;
|
||||
surface.style.width = `${size.width}px`;
|
||||
surface.style.height = `${size.height}px`;
|
||||
surface.width = Math.round(size.width * ratio);
|
||||
surface.height = Math.round(size.height * ratio);
|
||||
};
|
||||
const finish = () => {
|
||||
if (finished) return;
|
||||
finished = true;
|
||||
cancelAnimationFrame(frameHandle);
|
||||
onDone();
|
||||
};
|
||||
const tick = (now: number) => {
|
||||
if (finished) return;
|
||||
started ??= now;
|
||||
const moment = birthSkyRevealAt(now - started, reduced);
|
||||
layer.style.opacity = String(moment.layer);
|
||||
drawBirthSky(context as BirthSkyCanvas, reveal.sky, {
|
||||
palette: "night",
|
||||
width: surface.width,
|
||||
height: surface.height,
|
||||
serif: reveal.serif,
|
||||
}, { ...moment.frame, scatter });
|
||||
if (moment.done) finish();
|
||||
else frameHandle = requestAnimationFrame(tick);
|
||||
};
|
||||
const cancel = (event: Event) => {
|
||||
event.preventDefault();
|
||||
finish();
|
||||
};
|
||||
|
||||
fit();
|
||||
layer.style.opacity = "0";
|
||||
layer.showModal();
|
||||
layer.addEventListener("cancel", cancel);
|
||||
layer.addEventListener("pointerdown", finish);
|
||||
window.addEventListener("resize", fit);
|
||||
frameHandle = requestAnimationFrame(tick);
|
||||
return () => {
|
||||
finished = true;
|
||||
cancelAnimationFrame(frameHandle);
|
||||
layer.removeEventListener("cancel", cancel);
|
||||
layer.removeEventListener("pointerdown", finish);
|
||||
window.removeEventListener("resize", fit);
|
||||
layer.close();
|
||||
};
|
||||
}, [reveal, onDone]);
|
||||
|
||||
return (
|
||||
<dialog
|
||||
ref={dialog}
|
||||
className="birth-sky-converge"
|
||||
aria-label="那一刻的天空"
|
||||
style={{ background: BIRTH_SKY_PALETTES.night.background }}
|
||||
>
|
||||
<canvas
|
||||
ref={canvas}
|
||||
role="img"
|
||||
aria-label={`${birthSkyHeadline(reveal.sky)}${birthSkyFactLine(reveal.sky)}。`}
|
||||
/>
|
||||
</dialog>
|
||||
);
|
||||
}
|
||||
@@ -12,8 +12,6 @@ import { birthSkyFactLine, birthSkyHeadline } from "@/lib/birth-sky/sentence";
|
||||
export type BirthSkyDialogProps = Readonly<{
|
||||
cover: BirthSkyCover;
|
||||
onClose: () => void;
|
||||
/** First-chart reveal: fade in over the current step and offer 「继续」. */
|
||||
reveal?: boolean;
|
||||
}>;
|
||||
|
||||
/**
|
||||
@@ -21,7 +19,7 @@ export type BirthSkyDialogProps = Readonly<{
|
||||
* centred on desktop. The picture is an `<img>` of the painted PNG, so a long
|
||||
* press saves it on iOS; 「保存图片」 shares or downloads the same file.
|
||||
*/
|
||||
export function BirthSkyDialog({ cover, onClose, reveal = false }: BirthSkyDialogProps) {
|
||||
export function BirthSkyDialog({ cover, onClose }: BirthSkyDialogProps) {
|
||||
const dialog = useRef<HTMLDialogElement>(null);
|
||||
const saving = useRef(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
@@ -53,7 +51,7 @@ export function BirthSkyDialog({ cover, onClose, reveal = false }: BirthSkyDialo
|
||||
return (
|
||||
<dialog
|
||||
ref={dialog}
|
||||
className={reveal ? "birth-sky-dialog is-reveal" : "birth-sky-dialog"}
|
||||
className="birth-sky-dialog"
|
||||
aria-labelledby="birth-sky-title"
|
||||
onCancel={(event) => { event.preventDefault(); onClose(); }}
|
||||
onClick={(event) => { if (event.target === event.currentTarget) onClose(); }}
|
||||
@@ -70,7 +68,6 @@ export function BirthSkyDialog({ cover, onClose, reveal = false }: BirthSkyDialo
|
||||
<footer className="birth-sky-footer">
|
||||
{error ? <p role="alert">{error}</p> : null}
|
||||
<Button type="button" variant="outline" onClick={() => void save()}>保存图片</Button>
|
||||
{reveal ? <Button type="button" onClick={onClose}>继续</Button> : null}
|
||||
</footer>
|
||||
</div>
|
||||
</dialog>
|
||||
|
||||
@@ -2,14 +2,14 @@
|
||||
|
||||
import dynamic from "next/dynamic";
|
||||
|
||||
import type { BirthSkyCover } from "@/lib/birth-sky/cover";
|
||||
import type { BirthSkyRevealSky } from "@/lib/birth-sky/reveal";
|
||||
|
||||
const BirthSkyDialog = dynamic(
|
||||
() => import("./birth-sky-dialog").then((module) => module.BirthSkyDialog),
|
||||
const BirthSkyConverge = dynamic(
|
||||
() => import("./birth-sky-converge").then((module) => module.BirthSkyConverge),
|
||||
{ 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;
|
||||
/** First-chart reveal: the stars converge once over the next step, then get out of the way. */
|
||||
export function BirthSkyReveal({ cover, onClose }: { cover: BirthSkyRevealSky | null; onClose: () => void }) {
|
||||
return cover ? <BirthSkyConverge reveal={cover} onDone={onClose} /> : null;
|
||||
}
|
||||
|
||||
@@ -74,12 +74,4 @@
|
||||
.birth-sky-panel { height: auto; max-height: calc(92vh - 2px); }
|
||||
.birth-sky-image { max-height: calc(92vh - 160px); }
|
||||
}
|
||||
/* First-chart reveal: one 600ms fade, no motion when the reader asked for none. */
|
||||
.birth-sky-dialog.is-reveal[open] { animation: birth-sky-reveal 600ms ease-out; }
|
||||
.birth-sky-dialog.is-reveal[open]::backdrop { animation: birth-sky-reveal 600ms ease-out; }
|
||||
@keyframes birth-sky-reveal { from { opacity: 0; } to { opacity: 1; } }
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.birth-sky-dialog.is-reveal[open],
|
||||
.birth-sky-dialog.is-reveal[open]::backdrop { animation: none; }
|
||||
}
|
||||
@media print { .birth-sky-dialog { display: none !important; } }
|
||||
|
||||
Reference in New Issue
Block a user