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:
co-authored by
Claude Opus 5.5
parent
1a4f77584b
commit
dec36bce01
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
) : (
|
||||
|
||||
Reference in New Issue
Block a user