feat(birth-sky): poster-only dialog, date/time/city caption, slower reveal, paywall on first refused send (P1–P5, BUG-1080)

TASK-birth-sky-polish-20260928.
- P1: chart page dialog shows only the picture on a dimmed stage, 「保存图片」 below, icon × in the corner.
- P2: BFF returns one server-built `caption` (「1994年5月18日 07:40 · 杭州」); printed under the fact line in all three places; adopted time adds 「 · 校正后」.
- P3: convergence 3 s + words 2.5 s + fade 1 s; settled frame still equals the exported cover.
- P4: redeem paywall no longer opens when the starter home appears; first send refused for credits opens it, later ones open billing; convergence dialog portalled to <body>. BUG-1080 investigating (Chrome repro rules out the overlay covering the convergence).

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 00:03:46 +08:00
co-authored by Claude Opus 5.5
parent 1a4f77584b
commit dec36bce01
30 changed files with 670 additions and 165 deletions
@@ -1,6 +1,7 @@
"use client";
import { useEffect, useRef } from "react";
import { createPortal } from "react-dom";
import "./birth-sky-converge.css";
@@ -90,7 +91,9 @@ export function BirthSkyConverge({ reveal, onDone }: { reveal: BirthSkyRevealSky
};
}, [reveal, onDone]);
return (
// Portalled to <body>, outside SidebarInset, which is inert while any
// account dialog or the redeem paywall is open (BUG-968, BUG-1080).
return createPortal(
<dialog
ref={dialog}
className="birth-sky-converge"
@@ -102,6 +105,7 @@ export function BirthSkyConverge({ reveal, onDone }: { reveal: BirthSkyRevealSky
role="img"
aria-label={`${birthSkyHeadline(reveal.sky)}${birthSkyFactLine(reveal.sky)}。`}
/>
</dialog>
</dialog>,
document.body,
);
}
@@ -1,10 +1,10 @@
"use client";
import { X } from "lucide-react";
import { useEffect, useRef, useState } from "react";
import "./birth-sky.css";
import { Button } from "@/components/ui/button";
import type { BirthSkyCover } from "@/lib/birth-sky/cover";
import { saveBirthSkyImage } from "@/lib/birth-sky/export";
import { birthSkyFactLine, birthSkyHeadline } from "@/lib/birth-sky/sentence";
@@ -14,10 +14,14 @@ export type BirthSkyDialogProps = Readonly<{
onClose: () => void;
}>;
/** Accessible name of the stage; the picture itself carries the words. */
const BIRTH_SKY_DIALOG_LABEL = "那一刻的天空";
/**
* Native `<dialog>` like the report export drawer: full screen on phones,
* 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.
* 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);
@@ -48,27 +52,25 @@ export function BirthSkyDialog({ cover, onClose }: BirthSkyDialogProps) {
}
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-labelledby="birth-sky-title"
aria-label={BIRTH_SKY_DIALOG_LABEL}
onCancel={(event) => { event.preventDefault(); onClose(); }}
onClick={(event) => { if (event.target === event.currentTarget) onClose(); }}
>
<div className="birth-sky-panel">
<header className="birth-sky-header">
<h2 id="birth-sky-title">那一刻的天空</h2>
<Button type="button" variant="ghost" onClick={onClose} aria-label="关闭">关闭</Button>
</header>
<div className="birth-sky-frame">
{/* 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>
<footer className="birth-sky-footer">
{error ? <p role="alert">{error}</p> : null}
<Button type="button" variant="outline" onClick={() => void save()}>保存图片</Button>
</footer>
<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>
);
+57 -45
View File
@@ -1,6 +1,9 @@
/* 「那一刻的天空」 dialog (TASK-birth-sky-cover-20260928). Loaded with the lazy
dialog chunk only, never on first paint. Tokens only; the picture itself is
painted on canvas with the night palette. */
/* 「那一刻的天空」 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;
@@ -9,69 +12,78 @@
height: 100%;
max-height: none;
margin: 0;
padding: 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: var(--color-canvas);
color: var(--color-ink);
background: transparent;
color: #f2f0ea;
overflow: hidden;
box-sizing: border-box;
}
.birth-sky-dialog::backdrop { background: var(--color-scrim); }
.birth-sky-panel { display: flex; flex-direction: column; height: 100%; }
.birth-sky-header {
.birth-sky-dialog[open] {
display: flex;
flex-direction: column;
align-items: center;
justify-content: space-between;
gap: var(--space-2);
padding: var(--space-2) var(--space-4);
padding-top: max(var(--space-2), env(safe-area-inset-top));
flex-shrink: 0;
}
.birth-sky-header h2 { margin: 0; font-size: var(--type-title-md); font-weight: 500; }
.birth-sky-frame {
flex: 1 1 auto;
min-height: 0;
display: grid;
place-items: center;
padding: 0 var(--space-4);
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%;
/* Header + footer ≈ 170px; a percentage would not resolve inside the flex/grid cell everywhere. */
max-height: calc(100vh - 170px);
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 - 170px); }
.birth-sky-image { max-height: calc(100dvh - 44px - 3 * var(--space-4) - env(safe-area-inset-top) - env(safe-area-inset-bottom)); }
}
.birth-sky-footer {
.birth-sky-actions {
display: flex;
flex-wrap: wrap;
justify-content: center;
flex-direction: column;
align-items: center;
gap: var(--space-2);
padding: var(--space-4);
padding-bottom: max(var(--space-4), env(safe-area-inset-bottom));
flex-shrink: 0;
}
.birth-sky-footer p { flex-basis: 100%; margin: 0; text-align: center; color: var(--color-danger); font-size: var(--type-body-sm); }
.birth-sky-dialog button { min-height: 44px; min-width: 44px; }
@media (min-width: 860px) {
.birth-sky-dialog {
inset: 0;
width: min(520px, 92vw);
height: auto;
max-height: 92vh;
margin: auto;
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-elevated);
}
.birth-sky-panel { height: auto; max-height: calc(92vh - 2px); }
.birth-sky-image { max-height: calc(92vh - 160px); }
.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; } }
@@ -94,7 +94,7 @@ export function OnboardingRedeemPaywall({
</header>
<div className="paywall-intro">
<span className="paywall-mark" aria-hidden="true"><Gift /></span>
<p><b>入门问题已经准备好。</b>填写兑换码,解锁后续咨询、生时校正与完整解读。</p>
<p><b>问题还在输入框里。</b>填写兑换码,点数到账就能发送,生时校正与完整解读也能用。</p>
</div>
<div className="redeem-balance">
<span>当前可用点数</span>
@@ -124,7 +124,7 @@ export function OnboardingRedeemPaywall({
{message && <p className="form-success" role="status">{message}</p>}
</form>
<div className="paywall-footer">
<span>{unlocked ? "点数已到账,可以继续选择问题。" : "还没有兑换码?"}</span>
<span>{unlocked ? "点数已到账,回到输入框点发送就行。" : "还没有兑换码?"}</span>
{unlocked ? (
<button className="button-primary" type="button" onClick={close}>开始咨询</button>
) : (