feat(report): birth-sky report cover bound to the report's birth fingerprint (F1–F3)

Product decision A (TASK-birth-sky-followup-20260928):
- every generation path writes subject.birthFingerprint (stored-row
  fingerprint, same function as the chart page's profileFingerprint);
  never into the evidence bundle the model reads
- GET /api/reports/:id compares on the server and returns only
  report.coverSubject (subject id or null); legacy reports never get one
- report page shows the night cover above the body, paper cover alone on
  the first printed A4 page; silent on any failure, no waiting state
- TS / JSON schema / Python contract accept the optional field
- BLOCKED T5 struck through; DESIGN §17, CHANGELOG, checklist, PROGRESS

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-28 21:47:44 +08:00
co-authored by Claude Opus 5.5
parent 14ef18ac24
commit a9a917d50c
27 changed files with 739 additions and 27 deletions
@@ -0,0 +1,31 @@
/* Report cover (TASK-birth-sky-followup-20260928 F3). Loaded with the lazy
cover chunk on the report page only. Screen: night palette, as wide as the
reading column, 3:4. Print: paper palette, alone on the first A4 page. */
.report-sky-cover { margin: 0 0 var(--space-6); }
.report-sky-cover img {
display: block;
width: 100%;
height: auto;
aspect-ratio: 3 / 4;
border-radius: var(--radius-md);
}
.report-sky-cover-print { display: none !important; }
@media print {
.report-sky-cover {
margin: 0;
break-after: page;
page-break-after: always;
break-inside: avoid-page;
}
.report-sky-cover-screen { display: none !important; }
.report-sky-cover-print {
display: block !important;
/* A4 inside the report's @page margins is 186mm × 270mm; 3:4 at 186mm
wide is 248mm tall, so 246mm keeps it on one page. */
width: auto !important;
max-width: 100%;
height: 246mm !important;
margin: 0 auto;
border-radius: 0;
}
}
@@ -0,0 +1,46 @@
"use client";
import { useEffect, useState } from "react";
import "./report-sky-cover.css";
import { loadReportSkyCover } from "@/lib/birth-sky/report-cover";
type ReportSkyCoverImages = Readonly<{ screen: string; print: string; description: string }>;
/**
* 「那一刻的天空」 as the report cover (TASK-birth-sky-followup-20260928 F3).
*
* Mounted only after the report body is on screen and only when the server
* said the report's birth data still matches (`coverSubject`). It fetches the
* sky and paints two PNGs with the same `drawBirthSky`: `night` for the screen,
* `paper` for print. Until both are painted, and on any failure, it renders
* nothing — no placeholder, no waiting state (DESIGN §9).
*/
export function ReportSkyCover({ subjectId }: { subjectId: string }) {
const [images, setImages] = useState<ReportSkyCoverImages | null>(null);
useEffect(() => {
const controller = new AbortController();
const urls: string[] = [];
void loadReportSkyCover(subjectId, controller.signal).then((pngs) => {
if (!pngs || controller.signal.aborted) return;
urls.push(URL.createObjectURL(pngs.screen), URL.createObjectURL(pngs.print));
setImages({ screen: urls[0], print: urls[1], description: pngs.description });
});
return () => {
controller.abort();
for (const url of urls) URL.revokeObjectURL(url);
};
}, [subjectId]);
if (!images) return null;
return (
<figure className="report-sky-cover">
{/* eslint-disable-next-line @next/next/no-img-element -- blob: URL of the painted cover */}
<img className="report-sky-cover-screen" src={images.screen} alt={images.description} width={1080} height={1440} />
{/* eslint-disable-next-line @next/next/no-img-element -- blob: URL of the painted cover, paper palette */}
<img className="report-sky-cover-print" src={images.print} alt="" width={1080} height={1440} />
</figure>
);
}