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:
co-authored by
Claude Opus 5.5
parent
14ef18ac24
commit
a9a917d50c
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -11,6 +11,7 @@
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
|
||||
import dynamic from "next/dynamic";
|
||||
import Link from "next/link";
|
||||
|
||||
import { Clock3, TriangleAlert } from "lucide-react";
|
||||
@@ -40,6 +41,12 @@ import {
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useVisibilityAwarePoll } from "@/hooks/use-visibility-aware-poll";
|
||||
|
||||
// Mounted with the body, so the sky is only fetched once the report is on screen.
|
||||
const ReportSkyCover = dynamic(
|
||||
() => import("@/components/birth-sky/report-sky-cover").then((module) => module.ReportSkyCover),
|
||||
{ ssr: false },
|
||||
);
|
||||
|
||||
export type ReportLoadState =
|
||||
| { phase: "loading" }
|
||||
| { phase: "unauthorized" }
|
||||
@@ -54,7 +61,7 @@ export type ReportLoadState =
|
||||
| { phase: "failed"; failureCode: string | null; failureSummary?: string | null; appendixLastErrorCode?: string | null }
|
||||
| { phase: "invalid"; message: string }
|
||||
| { phase: "network-error" }
|
||||
| { phase: "markdown-ready"; markdown: string; reportId: string; createdAt: string; factTables?: ReportFactTable[]; calculationCharts?: ReportDocumentV2["charts"] }
|
||||
| { phase: "markdown-ready"; markdown: string; reportId: string; createdAt: string; factTables?: ReportFactTable[]; calculationCharts?: ReportDocumentV2["charts"]; coverSubject?: string }
|
||||
| { phase: "legacy-unavailable" };
|
||||
|
||||
/** GET /api/reports/:id envelope view (mirrors the API route's reportView). */
|
||||
@@ -72,6 +79,8 @@ export interface ReportEnvelopeView {
|
||||
progressPercent?: number;
|
||||
progressPhase?: string;
|
||||
sections?: readonly ReportSectionProgress[];
|
||||
/** Report cover: set by the server only when the report's birth data still matches. */
|
||||
coverSubject?: string | null;
|
||||
}
|
||||
|
||||
const SECTION_PROGRESS_STATES: readonly ReportSectionProgressState[] = [
|
||||
@@ -131,9 +140,11 @@ export function classifyReportEnvelope(statusCode: number, json: unknown): Repor
|
||||
if (markdown.length > 0) {
|
||||
const facts = reportFactTablesSchema.safeParse(json.factTables);
|
||||
const charts = reportDocumentV2Schema.shape.charts.safeParse(json.calculationCharts);
|
||||
const coverSubject = typeof view.coverSubject === "string" && view.coverSubject.length > 0 ? view.coverSubject : null;
|
||||
return {
|
||||
...(facts.success && facts.data.length ? { factTables: facts.data } : {}),
|
||||
...(charts.success ? { calculationCharts: charts.data } : {}),
|
||||
...(coverSubject ? { coverSubject } : {}),
|
||||
phase: "markdown-ready",
|
||||
markdown,
|
||||
reportId: typeof view.id === "string" ? view.id : "",
|
||||
@@ -482,6 +493,7 @@ export function PersonalReportPage({ reportId }: { reportId: string }) {
|
||||
reportDate={state.createdAt}
|
||||
/>
|
||||
<div className="personal-report-reader-body">
|
||||
{state.coverSubject ? <ReportSkyCover subjectId={state.coverSubject} /> : null}
|
||||
<PersonalReportMarkdownView markdown={state.markdown} />
|
||||
{state.calculationCharts && <ReportCalculationCharts charts={state.calculationCharts} />}
|
||||
{state.factTables && <ReportFactTables tables={state.factTables} />}
|
||||
|
||||
Reference in New Issue
Block a user