diff --git a/frontend/DESIGN.md b/frontend/DESIGN.md index 5b6e4dcc..96c20625 100644 --- a/frontend/DESIGN.md +++ b/frontend/DESIGN.md @@ -1164,10 +1164,11 @@ D40 / D45 / D60 的表格上方,出生时间不是「已校正」时多一句 ### 位置一:星盘页顶栏按钮 - 星盘页 `ok` 后立即预取 `/api/birth-sky?subject=<当前人物>` 并在屏外画好 PNG;**画好之后**顶栏右侧才出现一枚描边按钮「那一刻的天空」(` - {/* eslint-disable-next-line @next/next/no-img-element -- a blob: URL of the painted PNG; long press must save the file itself */} - {description} -
- {error ?

{error}

: null} - -
- - ); -} diff --git a/frontend/src/components/birth-sky/birth-sky-entry.tsx b/frontend/src/components/birth-sky/birth-sky-entry.tsx index 4389739d..e6185bed 100644 --- a/frontend/src/components/birth-sky/birth-sky-entry.tsx +++ b/frontend/src/components/birth-sky/birth-sky-entry.tsx @@ -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 }) { - {open ? setOpen(false)} /> : null} + {open ? setOpen(false)} /> : null} ); } diff --git a/frontend/src/components/birth-sky/birth-sky-replay.tsx b/frontend/src/components/birth-sky/birth-sky-replay.tsx new file mode 100644 index 00000000..ea2258e9 --- /dev/null +++ b/frontend/src/components/birth-sky/birth-sky-replay.tsx @@ -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(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 ( + + + + {error ?

{error}

: null} + + )} + /> + ); +} diff --git a/frontend/src/components/birth-sky/birth-sky.css b/frontend/src/components/birth-sky/birth-sky.css deleted file mode 100644 index 71b9f676..00000000 --- a/frontend/src/components/birth-sky/birth-sky.css +++ /dev/null @@ -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; } } diff --git a/frontend/src/lib/birth-sky/converge.ts b/frontend/src/lib/birth-sky/converge.ts index f282be0a..39e349f0 100644 --- a/frontend/src/lib/birth-sky/converge.ts +++ b/frontend/src/lib/birth-sky/converge.ts @@ -111,10 +111,25 @@ export type BirthSkyRevealTimeline = Readonly<{ frame: Omit; /** 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, }; } diff --git a/frontend/src/lib/birth-sky/cover.ts b/frontend/src/lib/birth-sky/cover.ts index 56fdfa29..765965da 100644 --- a/frontend/src/lib/birth-sky/cover.ts +++ b/frontend/src/lib/birth-sky/cover.ts @@ -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 { @@ -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; } diff --git a/frontend/src/lib/birth-sky/render.ts b/frontend/src/lib/birth-sky/render.ts index 46c8285b..eb14f906 100644 --- a/frontend/src/lib/birth-sky/render.ts +++ b/frontend/src/lib/birth-sky/render.ts @@ -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 { +): Promise> { 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 { + return (await paintBirthSkyPng(sky, palette)).blob; } diff --git a/frontend/tests/birth-sky-polish-20260928.test.ts b/frontend/tests/birth-sky-polish-20260928.test.ts index be4af409..ddaaa83a 100644 --- a/frontend/tests/birth-sky-polish-20260928.test.ts +++ b/frontend/tests/birth-sky-polish-20260928.test.ts @@ -7,29 +7,25 @@ import { tryOpenRedeemPaywall } from "../src/lib/redeem-paywall-once.ts"; // TASK-birth-sky-polish-20260928 P1 (poster-only dialog) and P4 (paywall timing, BUG-1080). const read = (path: string) => readFileSync(new URL(`../${path}`, import.meta.url), "utf8"); -const dialog = read("src/components/birth-sky/birth-sky-dialog.tsx"); -const dialogCss = read("src/components/birth-sky/birth-sky.css"); const converge = read("src/components/birth-sky/birth-sky-converge.tsx"); const hook = read("src/hooks/use-consultation-run.ts"); const page = read("src/app/(app)/page.tsx"); -test("chart page dialog shows only the picture: no title bar, no panel; icon × and 「保存图片」", () => { - assert.doesNotMatch(dialog, /关闭\s*