feat(chart): 「那一刻的天空」封面 — engine, BFF, canvas painter, chart header entry

T1 scripts/birth_sky.py: real alt/az of the seven visible grahas (swe.calc_ut +
swe.azalt, geometric altitude, azimuth converted from swe's south origin to
north-east) and Yale BSC5 stars V<=4.0 (IAU 1976 precession); phase
day / twilight_morning / twilight_evening / night. Thin POST /api/birth_sky
registration in do_POST (no new handler method, no __new__).

T2 GET /api/birth-sky?subject=: birth truth read server side through
loadSubjectBirth + prepareChartViewProfile; zod contract; no billing, no
writes, no birth data in the response.

T3/T4 lib/birth-sky: sentence table, pure Canvas 2D drawBirthSky (night /
paper palettes from DESIGN tokens, east on the left, serif headline with
1.5 s font wait), PNG export via navigator.share or Blob + a.download.
Chart page prefetches and paints the cover once the chart is ok; the header
button appears only when the PNG is ready. Dialog is a lazy chunk.

T7 DESIGN §17, VOICE, CONTEXT glossary, CHANGELOG, real-device checklist.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0199rbQDTsUbCVw84wc8BTFe
This commit is contained in:
Jesse_Chen
2026-09-28 20:27:40 +08:00
co-authored by Claude Opus 5.5
parent d670521f47
commit d277cbd8ad
35 changed files with 2056 additions and 1 deletions
@@ -0,0 +1,78 @@
"use client";
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";
export type BirthSkyDialogProps = Readonly<{
cover: BirthSkyCover;
onClose: () => void;
/** First-chart reveal: fade in over the current step and offer 「继续」. */
reveal?: boolean;
}>;
/**
* 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.
*/
export function BirthSkyDialog({ cover, onClose, reveal = false }: 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)}。`;
return (
<dialog
ref={dialog}
className={reveal ? "birth-sky-dialog is-reveal" : "birth-sky-dialog"}
aria-labelledby="birth-sky-title"
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>
{reveal ? <Button type="button" onClick={onClose}>继续</Button> : null}
</footer>
</div>
</dialog>
);
}
@@ -0,0 +1,30 @@
"use client";
import dynamic from "next/dynamic";
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),
{ ssr: false },
);
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.
*/
export function BirthSkyEntry({ cover }: { cover: BirthSkyCover }) {
const [open, setOpen] = useState(false);
return (
<>
<Button type="button" variant="outline" size="sm" onClick={() => setOpen(true)}>
{BIRTH_SKY_ENTRY_LABEL}
</Button>
{open ? <BirthSkyDialog cover={cover} onClose={() => setOpen(false)} /> : null}
</>
);
}
@@ -0,0 +1,83 @@
/* 「那一刻的天空」 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. */
.birth-sky-dialog {
position: fixed;
inset: 0;
width: 100%;
max-width: none;
height: 100%;
max-height: none;
margin: 0;
padding: 0;
border: 0;
background: var(--color-canvas);
color: var(--color-ink);
overflow: hidden;
}
@supports (height: 1dvh) {
.birth-sky-dialog { height: 100dvh; }
}
.birth-sky-dialog::backdrop { background: var(--color-scrim); }
.birth-sky-panel { display: flex; flex-direction: column; height: 100%; }
.birth-sky-header {
display: flex;
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);
}
.birth-sky-image {
display: block;
width: auto;
height: auto;
max-width: 100%;
max-height: 100%;
aspect-ratio: 3 / 4;
border-radius: var(--radius-md);
-webkit-touch-callout: default;
}
.birth-sky-footer {
display: flex;
flex-wrap: wrap;
justify-content: 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); }
}
/* 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; } }
@@ -2,8 +2,11 @@
import { useState } from "react";
import { BirthSkyEntry } from "@/components/birth-sky/birth-sky-entry";
import { SecondaryPageShell } from "@/components/secondary-page-shell";
import { useBirthSkyCover } from "@/hooks/use-birth-sky-cover";
import { useChartPage } from "@/hooks/use-chart-page";
import type { BirthSkyCover } from "@/lib/birth-sky/cover";
import {
CHART_VIEW_TABS,
type ChartViewFailureStatus,
@@ -22,12 +25,16 @@ import { ChartWesternTab } from "./chart-western-tab";
export function ChartPageRoute() {
const { view, pendingLayers, layerFailures, requestLayer } = useChartPage();
const ok = view?.status === "ok" ? view : null;
// 「那一刻的天空」 is prefetched as soon as the chart is ok (TASK-birth-sky-cover T3).
const birthSky = useBirthSkyCover(ok ? ok.subjectId ?? "self" : null, ok?.profileFingerprint ?? null);
return (
<ChartPageView
view={view}
pendingLayers={pendingLayers}
layerFailures={layerFailures}
onNeedLayer={requestLayer}
birthSky={birthSky}
/>
);
}
@@ -66,12 +73,15 @@ export function ChartPageView({
layerFailures,
onNeedLayer,
initialTab = "vedic",
birthSky = null,
}: {
view: ChartViewResponse | null;
pendingLayers?: ReadonlySet<ChartViewLayer>;
layerFailures?: ReadonlyMap<ChartViewLayer, ChartViewFailureStatus>;
onNeedLayer?: (layer: ChartViewLayer) => void;
initialTab?: ChartViewTabId;
/** Painted cover for the header button; null until ready, and after any failure. */
birthSky?: BirthSkyCover | null;
}) {
const [tab, setTab] = useState<ChartViewTabId>(initialTab);
const [vargaId, setVargaId] = useState<ChartTypeId>("D1");
@@ -98,6 +108,7 @@ export function ChartPageView({
<SecondaryPageShell
title="星盘"
note={birthline}
actions={view?.status === "ok" && birthSky ? <BirthSkyEntry cover={birthSky} /> : undefined}
>
{view != null && view.status !== "ok" ? (
<section className="chart-page-message">