feat(birth-sky): the chart page replays the convergence, holds on the poster, shares top right (M4)

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-29 08:41:27 +08:00
co-authored by Claude Opus 5.5
parent 5e97db4077
commit 60b5542b7d
15 changed files with 372 additions and 232 deletions
@@ -23,3 +23,47 @@
.birth-sky-converge canvas { display: block; }
@supports (height: 1dvh) { .birth-sky-converge { height: 100dvh; } }
@media print { .birth-sky-converge { display: none !important; } }
/* Chart page replay controls (TASK-mobile-chart-and-confirmed-edit-20260929 M4):
× top left, 「分享」 top right, on the night floor in its ink (DESIGN §17:
#1a1a19 / #f2f0ea — the picture is always night). 44px targets; the first-
chart reveal passes no controls and stays button-free. */
.birth-sky-controls {
position: absolute;
inset: 0 0 auto 0;
display: flex;
justify-content: space-between;
align-items: flex-start;
flex-wrap: wrap;
padding: max(var(--space-2), env(safe-area-inset-top)) max(var(--space-2), env(safe-area-inset-right)) 0 max(var(--space-2), env(safe-area-inset-left));
pointer-events: none;
}
.birth-sky-control {
pointer-events: auto;
min-width: 44px;
height: 44px;
display: inline-flex;
align-items: center;
justify-content: center;
gap: var(--space-1);
padding: 0 var(--space-3);
border: 0;
border-radius: var(--radius-md);
background: transparent;
color: #f2f0ea;
font: inherit;
font-size: var(--type-body-sm);
cursor: pointer;
transition: background-color 120ms ease-out, transform 120ms ease-out;
}
.birth-sky-control:hover { background: color-mix(in srgb, #f2f0ea 10%, transparent); }
.birth-sky-control:active { transform: scale(.96); }
.birth-sky-control:focus-visible { outline: 2px solid #f2f0ea; outline-offset: 2px; }
.birth-sky-control svg { width: 20px; height: 20px; }
.birth-sky-share { border: 1px solid color-mix(in srgb, #f2f0ea 42%, transparent); }
.birth-sky-control-error {
flex-basis: 100%;
margin: var(--space-2) 0 0;
text-align: right;
color: #b3afa4;
font-size: var(--type-body-sm);
}
@@ -1,11 +1,12 @@
"use client";
import { useEffect, useRef } from "react";
import type { ReactNode } from "react";
import { createPortal } from "react-dom";
import "./birth-sky-converge.css";
import { birthSkyRevealAt, birthSkyScatter } from "@/lib/birth-sky/converge";
import { birthSkyHoldAtMs, birthSkyRevealAt, birthSkyScatter, type BirthSkyRevealMode } 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";
@@ -17,13 +18,23 @@ export function birthSkyConvergeSize(viewportWidth: number, viewportHeight: numb
}
/**
* 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.
* The star convergence. Every frame is `drawBirthSky` with interpolated
* positions, so the settled frame is the exported cover.
*
* - `reveal` (first chart, 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.
* - `hold` (chart page, TASK-mobile-chart-and-confirmed-edit-20260929 M4): the
* same converge and words, then it stays on the settled frame. A tap skips to
* that frame; Esc closes. The caller's `controls` (close, share) sit on top.
*/
export function BirthSkyConverge({ reveal, onDone }: { reveal: BirthSkyRevealSky; onDone: () => void }) {
export function BirthSkyConverge({ reveal, onDone, mode = "reveal", controls }: {
reveal: BirthSkyRevealSky;
onDone: () => void;
mode?: BirthSkyRevealMode;
controls?: ReactNode;
}) {
const dialog = useRef<HTMLDialogElement>(null);
const canvas = useRef<HTMLCanvasElement>(null);
@@ -40,7 +51,18 @@ export function BirthSkyConverge({ reveal, onDone }: { reveal: BirthSkyRevealSky
let frameHandle = 0;
let started: number | null = null;
let finished = false;
let last: ReturnType<typeof birthSkyRevealAt> | null = null;
const draw = (moment: ReturnType<typeof birthSkyRevealAt>) => {
last = moment;
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 });
};
const fit = () => {
const size = birthSkyConvergeSize(window.innerWidth, window.innerHeight);
const ratio = window.devicePixelRatio || 1;
@@ -48,6 +70,8 @@ export function BirthSkyConverge({ reveal, onDone }: { reveal: BirthSkyRevealSky
surface.style.height = `${size.height}px`;
surface.width = Math.round(size.width * ratio);
surface.height = Math.round(size.height * ratio);
// Resizing clears the canvas; a held frame has no next tick to repaint it.
if (last?.held) draw(last);
};
const finish = () => {
if (finished) return;
@@ -58,17 +82,20 @@ export function BirthSkyConverge({ reveal, onDone }: { reveal: BirthSkyRevealSky
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 });
const moment = birthSkyRevealAt(now - started, reduced, mode);
draw(moment);
if (moment.done) finish();
else frameHandle = requestAnimationFrame(tick);
else if (!moment.held) frameHandle = requestAnimationFrame(tick);
};
// `hold`: a tap on the stage (not on a control) jumps to the settled frame.
const skip = (event: Event) => {
if (finished || last?.held) return;
if (event.target instanceof Element && event.target.closest(".birth-sky-controls")) return;
cancelAnimationFrame(frameHandle);
started = performance.now() - birthSkyHoldAtMs(reduced);
frameHandle = requestAnimationFrame(tick);
};
const onPointerDown = mode === "hold" ? skip : finish;
const cancel = (event: Event) => {
event.preventDefault();
finish();
@@ -78,18 +105,18 @@ export function BirthSkyConverge({ reveal, onDone }: { reveal: BirthSkyRevealSky
layer.style.opacity = "0";
layer.showModal();
layer.addEventListener("cancel", cancel);
layer.addEventListener("pointerdown", finish);
layer.addEventListener("pointerdown", onPointerDown);
window.addEventListener("resize", fit);
frameHandle = requestAnimationFrame(tick);
return () => {
finished = true;
cancelAnimationFrame(frameHandle);
layer.removeEventListener("cancel", cancel);
layer.removeEventListener("pointerdown", finish);
layer.removeEventListener("pointerdown", onPointerDown);
window.removeEventListener("resize", fit);
layer.close();
};
}, [reveal, onDone]);
}, [reveal, onDone, mode]);
// Portalled to <body>, outside SidebarInset, which is inert while any
// account dialog or the redeem paywall is open (BUG-968, BUG-1080).
@@ -105,6 +132,7 @@ export function BirthSkyConverge({ reveal, onDone }: { reveal: BirthSkyRevealSky
role="img"
aria-label={`${birthSkyHeadline(reveal.sky)}${birthSkyFactLine(reveal.sky)}。`}
/>
{controls}
</dialog>,
document.body,
);
@@ -1,77 +0,0 @@
"use client";
import { X } from "lucide-react";
import { useEffect, useRef, useState } from "react";
import "./birth-sky.css";
import type { BirthSkyCover } from "@/lib/birth-sky/cover";
import { saveBirthSkyImage } from "@/lib/birth-sky/export";
import { birthSkyFactLine, birthSkyHeadline } from "@/lib/birth-sky/sentence";
export type BirthSkyDialogProps = Readonly<{
cover: BirthSkyCover;
onClose: () => void;
}>;
/** Accessible name of the stage; the picture itself carries the words. */
const BIRTH_SKY_DIALOG_LABEL = "那一刻的天空";
/**
* Native `<dialog>` that shows only the picture on a dimmed stage, sized to
* the viewport, with 「保存图片」 under it. 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 }: BirthSkyDialogProps) {
const dialog = useRef<HTMLDialogElement>(null);
const saving = useRef(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
const element = dialog.current;
const previous = document.activeElement;
element?.showModal();
return () => {
element?.close();
if (previous instanceof HTMLElement && previous.isConnected) previous.focus({ preventScroll: true });
};
}, []);
async function save() {
if (saving.current) return;
saving.current = true;
setError(null);
try {
await saveBirthSkyImage(cover.blob);
} catch {
setError("没能保存。长按或右键上面的图,也能存下来。");
} finally {
saving.current = false;
}
}
const description = `${birthSkyHeadline(cover.sky)}${birthSkyFactLine(cover.sky)}。`;
// Poster only (TASK-birth-sky-polish-20260928 P1): no panel, no title bar.
// The dialog is the dimmed stage; a click on it (not on the picture or a
// button) closes, as do Esc and the icon ×.
return (
<dialog
ref={dialog}
className="birth-sky-dialog"
aria-label={BIRTH_SKY_DIALOG_LABEL}
onCancel={(event) => { event.preventDefault(); onClose(); }}
onClick={(event) => { if (event.target === event.currentTarget) onClose(); }}
>
<button className="birth-sky-close" type="button" onClick={onClose} aria-label="关闭">
<X aria-hidden="true" />
</button>
{/* eslint-disable-next-line @next/next/no-img-element -- a blob: URL of the painted PNG; long press must save the file itself */}
<img className="birth-sky-image" src={cover.url} alt={description} width={1080} height={1440} />
<div className="birth-sky-actions">
{error ? <p role="alert">{error}</p> : null}
<button className="birth-sky-save" type="button" onClick={() => void save()}>保存图片</button>
</div>
</dialog>
);
}
@@ -6,8 +6,8 @@ import { useState } from "react";
import { Button } from "@/components/ui/button";
import type { BirthSkyCover } from "@/lib/birth-sky/cover";
const BirthSkyDialog = dynamic(
() => import("./birth-sky-dialog").then((module) => module.BirthSkyDialog),
const BirthSkyReplay = dynamic(
() => import("./birth-sky-replay").then((module) => module.BirthSkyReplay),
{ ssr: false },
);
@@ -15,7 +15,8 @@ export const BIRTH_SKY_ENTRY_LABEL = "那一刻的天空";
/**
* The chart header's one extra action. Rendered only when the cover is already
* painted; the dialog (and its CSS) is a lazy chunk opened on click.
* painted; the replay (convergence, controls and CSS) is a lazy chunk opened on
* click, and every open plays it again (TASK-mobile-chart-and-confirmed-edit-20260929 M4).
*/
export function BirthSkyEntry({ cover }: { cover: BirthSkyCover }) {
const [open, setOpen] = useState(false);
@@ -24,7 +25,7 @@ export function BirthSkyEntry({ cover }: { cover: BirthSkyCover }) {
<Button type="button" variant="outline" size="sm" onClick={() => setOpen(true)}>
{BIRTH_SKY_ENTRY_LABEL}
</Button>
{open ? <BirthSkyDialog cover={cover} onClose={() => setOpen(false)} /> : null}
{open ? <BirthSkyReplay cover={cover} onClose={() => setOpen(false)} /> : null}
</>
);
}
@@ -0,0 +1,60 @@
"use client";
import { Share, X } from "lucide-react";
import { useMemo, useRef, useState } from "react";
import { BirthSkyConverge } from "./birth-sky-converge";
import type { BirthSkyCover } from "@/lib/birth-sky/cover";
import { saveBirthSkyImage } from "@/lib/birth-sky/export";
/** VOICE.md: the one control that hands the poster out; share sheet on a phone, download elsewhere. */
export const BIRTH_SKY_SHARE_LABEL = "分享";
export const BIRTH_SKY_SHARE_FAILED = "没能分享,再试一次。";
/**
* The chart page's 「那一刻的天空」 (TASK-mobile-chart-and-confirmed-edit-20260929
* M4). Every open replays the star convergence and stays on the settled frame,
* which is the poster itself. × (top left) or Esc closes; 「分享」 (top right)
* hands out the painted PNG through the share sheet, or downloads it where
* there is none. The PNG was painted before the entry button appeared, so
* sharing never waits.
*/
export function BirthSkyReplay({ cover, onClose }: { cover: BirthSkyCover; onClose: () => void }) {
const reveal = useMemo(() => ({ sky: cover.sky, serif: cover.serif }), [cover.sky, cover.serif]);
const sharing = useRef(false);
const [error, setError] = useState<string | null>(null);
async function share() {
if (sharing.current) return;
sharing.current = true;
setError(null);
try {
await saveBirthSkyImage(cover.blob);
} catch {
setError(BIRTH_SKY_SHARE_FAILED);
} finally {
sharing.current = false;
}
}
return (
<BirthSkyConverge
reveal={reveal}
mode="hold"
onDone={onClose}
controls={(
<div className="birth-sky-controls">
<button className="birth-sky-control" type="button" onClick={onClose} aria-label="关闭">
<X aria-hidden="true" />
</button>
<button className="birth-sky-control birth-sky-share" type="button" onClick={() => void share()}>
<Share aria-hidden="true" />
<span>{BIRTH_SKY_SHARE_LABEL}</span>
</button>
{error ? <p className="birth-sky-control-error" role="alert">{error}</p> : null}
</div>
)}
/>
);
}
@@ -1,89 +0,0 @@
/* 「那一刻的天空」 dialog (TASK-birth-sky-cover-20260928; poster only since
TASK-birth-sky-polish-20260928 P1). Loaded with the lazy dialog chunk only,
never on first paint. No panel and no title bar: the dialog is a dimmed
stage showing the painted picture, 「保存图片」 under it and an icon × in the
corner. The stage and controls use the night palette (DESIGN §17: the dark
theme floor #1a1a19 and ink #f2f0ea), because the picture is always night. */
.birth-sky-dialog {
position: fixed;
inset: 0;
width: 100%;
max-width: none;
height: 100%;
max-height: none;
margin: 0;
padding: var(--space-4);
padding-top: max(var(--space-4), env(safe-area-inset-top));
padding-bottom: max(var(--space-4), env(safe-area-inset-bottom));
border: 0;
background: transparent;
color: #f2f0ea;
overflow: hidden;
box-sizing: border-box;
}
.birth-sky-dialog[open] {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: var(--space-4);
}
.birth-sky-dialog::backdrop { background: color-mix(in srgb, #1a1a19 90%, transparent); }
.birth-sky-image {
display: block;
flex: 0 1 auto;
min-height: 0;
width: auto;
height: auto;
/* Room for the save row (44px) + gap + stage padding. */
max-width: 100%;
max-height: calc(100vh - 44px - 3 * var(--space-4) - env(safe-area-inset-top) - env(safe-area-inset-bottom));
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 - 44px - 3 * var(--space-4) - env(safe-area-inset-top) - env(safe-area-inset-bottom)); }
}
.birth-sky-actions {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--space-2);
flex-shrink: 0;
}
.birth-sky-actions p { margin: 0; text-align: center; color: #b3afa4; font-size: var(--type-body-sm); }
.birth-sky-save {
min-height: 44px;
padding: 0 var(--space-4);
border: 1px solid color-mix(in srgb, #f2f0ea 42%, transparent);
border-radius: var(--radius-md);
background: transparent;
color: #f2f0ea;
font: inherit;
font-size: var(--type-body-sm);
cursor: pointer;
transition: background-color 120ms ease-out, transform 120ms ease-out;
}
.birth-sky-save:hover { background: color-mix(in srgb, #f2f0ea 10%, transparent); }
.birth-sky-save:active { transform: scale(.96); }
.birth-sky-close {
position: absolute;
top: max(var(--space-2), env(safe-area-inset-top));
right: var(--space-2);
width: 44px;
height: 44px;
display: grid;
place-items: center;
border: 0;
border-radius: var(--radius-md);
background: transparent;
color: #f2f0ea;
cursor: pointer;
transition: background-color 120ms ease-out;
}
.birth-sky-close:hover { background: color-mix(in srgb, #f2f0ea 10%, transparent); }
.birth-sky-close svg { width: 20px; height: 20px; }
.birth-sky-save:focus-visible, .birth-sky-close:focus-visible { outline: 2px solid #f2f0ea; outline-offset: 2px; }
@media print { .birth-sky-dialog { display: none !important; } }
+31 -5
View File
@@ -111,10 +111,25 @@ export type BirthSkyRevealTimeline = Readonly<{
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. */
/** True once the last fade has finished: remove the overlay, show the next step. Never in `hold`. */
done: boolean;
/** `hold` only: the settled frame is on screen and stays; no more frames are needed. */
held: boolean;
}>;
/**
* `reveal`: the first-chart overlay — converge, words, fade out, done.
* `hold` (TASK-mobile-chart-and-confirmed-edit-20260929 M4): the chart page's
* 「那一刻的天空」 — the same converge and words, then it stops on the settled
* frame (the exported poster) and never fades.
*/
export type BirthSkyRevealMode = "reveal" | "hold";
/** `hold`: elapsed time at which the settled frame is fully on screen (words faded in). */
export function birthSkyHoldAtMs(reducedMotion: boolean): number {
return reducedMotion ? REDUCED_FADE_IN_MS : BIRTH_SKY_CONVERGE_MS + TEXT_FADE_IN_MS;
}
const SETTLED = { converge: 1, chrome: 1, text: 1 } as const;
/**
@@ -123,13 +138,22 @@ const SETTLED = { converge: 1, chrome: 1, text: 1 } as const;
* 3–5.5 s words fade in and hold; 5.5–6.5 s the layer fades out.
* Reduced motion: the finished picture fades in, holds, fades out.
*/
export function birthSkyRevealAt(elapsedMs: number, reducedMotion: boolean): BirthSkyRevealTimeline {
export function birthSkyRevealAt(
elapsedMs: number,
reducedMotion: boolean,
mode: BirthSkyRevealMode = "reveal",
): BirthSkyRevealTimeline {
const t = Math.max(0, elapsedMs);
if (mode === "hold") {
const held = t >= birthSkyHoldAtMs(reducedMotion);
if (reducedMotion) return { frame: SETTLED, layer: clamp01(t / REDUCED_FADE_IN_MS), done: false, held };
if (held) return { frame: SETTLED, layer: 1, done: false, held };
}
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;
const fadeOut = mode === "hold" ? 1 : 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 };
return { frame: SETTLED, layer: Math.min(clamp01(t / REDUCED_FADE_IN_MS), fadeOut), done, held: false };
}
if (t < BIRTH_SKY_CONVERGE_MS) {
const progress = t / BIRTH_SKY_CONVERGE_MS;
@@ -137,11 +161,13 @@ export function birthSkyRevealAt(elapsedMs: number, reducedMotion: boolean): Bir
frame: { converge: easeOutCubic(progress), chrome: progress, text: 0 },
layer: clamp01(t / LAYER_FADE_IN_MS),
done: false,
held: false,
};
}
return {
frame: { converge: 1, chrome: 1, text: clamp01((t - BIRTH_SKY_CONVERGE_MS) / TEXT_FADE_IN_MS) },
layer: fadeOut,
done,
done: mode === "hold" ? false : done,
held: false,
};
}
+5 -3
View File
@@ -9,6 +9,8 @@ export type BirthSkyCover = Readonly<{
sky: BirthSkyOk;
blob: Blob;
url: string;
/** Whether the PNG was painted with the heading serif; the live replay draws the same. */
serif: boolean;
}>;
export async function fetchBirthSky(subjectId: string, signal?: AbortSignal): Promise<BirthSkyOk | null> {
@@ -29,10 +31,10 @@ export async function loadBirthSkyCover(key: string, subjectId: string, signal?:
try {
const sky = await fetchBirthSky(subjectId, signal);
if (!sky || signal?.aborted) return null;
const { renderBirthSkyPng } = await import("./render.ts");
const blob = await renderBirthSkyPng(sky, "night");
const { paintBirthSkyPng } = await import("./render.ts");
const { blob, serif } = await paintBirthSkyPng(sky, "night");
if (signal?.aborted) return null;
return { key, sky, blob, url: URL.createObjectURL(blob) };
return { key, sky, blob, url: URL.createObjectURL(blob), serif };
} catch {
return null;
}
+16 -4
View File
@@ -39,11 +39,15 @@ export async function waitForHeadingSerif(
}
}
/** Paint the cover on an off-screen canvas and return the PNG. Browser only. */
export async function renderBirthSkyPng(
/**
* Paint the cover on an off-screen canvas. Returns the PNG and whether the
* heading serif made it in time, so a live canvas can draw the identical
* picture (TASK-mobile-chart-and-confirmed-edit-20260929 M4). Browser only.
*/
export async function paintBirthSkyPng(
sky: BirthSkyOk,
palette: BirthSkyPaletteName = "night",
): Promise<Blob> {
): Promise<Readonly<{ blob: Blob; serif: boolean }>> {
const serif = await waitForHeadingSerif(`${birthSkyHeadline(sky)}${BIRTH_SKY_TOP_LABEL}`);
const canvas = document.createElement("canvas");
canvas.width = BIRTH_SKY_EXPORT_WIDTH;
@@ -56,5 +60,13 @@ export async function renderBirthSkyPng(
height: BIRTH_SKY_EXPORT_HEIGHT,
serif,
});
return canvasToPngBlob(canvas);
return { blob: await canvasToPngBlob(canvas), serif };
}
/** Paint the cover on an off-screen canvas and return the PNG. Browser only. */
export async function renderBirthSkyPng(
sky: BirthSkyOk,
palette: BirthSkyPaletteName = "night",
): Promise<Blob> {
return (await paintBirthSkyPng(sky, palette)).blob;
}