Files
Jyotisha/frontend/src/components/rectification-board.tsx
T
Jesse_Chen 9670b661c4 fix(rectification): show mobile natal board as a covering bottom sheet
BUG-330: the compact board sat in the same layer as the composer, so peek copy overlapped the sheet title. Overlay only below 768px; desktop stays a side column.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-20 21:35:48 +08:00

283 lines
9.4 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import { X } from "lucide-react";
import { useEffect, useRef } from "react";
import {
groupWindowTransitions,
rectificationBoardPeekCopy,
workingRectificationTime,
type RectificationBoardDiff,
} from "@/lib/rectification-board-model";
import {
workingRectificationHouseTable,
type RectificationCandidateResult,
} from "@/lib/rectification-candidate-result";
import {
windowScanLayerLabel,
type WindowScanLayer,
} from "@/lib/rectification-agentic/v9/refinement-packet";
import { RectificationHouseTableView } from "./rectification-house-table";
import { ConfirmationGateDisclosure, TechniqueAuditDisclosure } from "./technique-audit-disclosure";
function LayerChips({ layers }: Readonly<{ layers: readonly WindowScanLayer[] }>) {
if (layers.length === 0) return null;
return (
<ul className="rectification-board__chips">
{layers.map((layer) => (
<li key={layer}>
<span className="rectification-board__chip">{windowScanLayerLabel(layer)}</span>
</li>
))}
</ul>
);
}
function RectificationBoardBody({
result,
savedStatus,
diff,
compact,
titleId,
onClose,
}: Readonly<{
result: RectificationCandidateResult | null;
savedStatus: "accepted" | "confirmed" | null;
diff: RectificationBoardDiff;
compact: boolean;
titleId: string;
onClose: () => void;
}>) {
const table = result ? workingRectificationHouseTable(result) : null;
const workingTime = workingRectificationTime(result);
const grouped = result ? groupWindowTransitions(result.windowTransitions) : [];
const scoringMinutes = grouped.filter((row) => row.scoringLayers.length > 0);
const displayMinutes = grouped.filter((row) => row.displayLayers.length > 0);
const changedHouses = new Set(diff.houseNumbers);
const changedMinutes = new Set(diff.transitionMinutes);
const stage = result?.precisionStage?.user_meaning
?? result?.natalRecast?.user_meaning
?? null;
const ledger = result?.eventDashaLedger ?? [];
const contrast = result?.lagnaContrast ?? null;
const agreement = result?.dashaAgreement ?? null;
const nakshatra = result?.nakshatraBoundary ?? null;
return (
<>
{compact ? <div className="rectification-board__handle" aria-hidden="true" /> : null}
<header className="rectification-board__header">
<div className="rectification-board__title-row">
<h2 id={titleId}></h2>
{workingTime ? <span className="rectification-board__clock">{workingTime}</span> : null}
{compact && (
<button
className="rectification-board__close"
type="button"
aria-label="关闭盘面"
onClick={onClose}
>
<X aria-hidden="true" strokeWidth={2} />
</button>
)}
</div>
{stage ? <p className="rectification-board__stage">{stage}</p> : null}
</header>
<div className="rectification-board__body">
<p className="sr-only" role="status" aria-live="polite" aria-atomic="true">{diff.liveMessage}</p>
{!table && (
<p className="rectification-board__empty">
</p>
)}
{table && (
<div className="rectification-snapshot">
<RectificationHouseTableView
table={table}
changedHouses={changedHouses}
lagnaChanged={diff.lagnaChanged}
/>
{result && <ConfirmationGateDisclosure gate={result.confirmationGate} />}
{savedStatus === "accepted" && result && result.techniqueAudit.length > 0 && (
<TechniqueAuditDisclosure rows={result.techniqueAudit} />
)}
</div>
)}
{(scoringMinutes.length > 0 || displayMinutes.length > 0) && (
<section className="rectification-board__section" aria-label="换升时刻">
<h3></h3>
{scoringMinutes.length > 0 && (
<ol className="rectification-board__minutes">
{scoringMinutes.map((row) => {
const changed = changedMinutes.has(row.at);
const working = row.at === workingTime;
return (
<li
key={row.at}
className={[
"rectification-board__minute",
changed ? "is-changed" : "",
working ? "is-working" : "",
].filter(Boolean).join(" ")}
>
<time dateTime={row.at}>{row.at}</time>
{working ? <span className="rectification-board__minute-note"></span> : null}
{changed ? <span className="rectification-board__minute-note"></span> : null}
<LayerChips layers={row.scoringLayers} />
</li>
);
})}
</ol>
)}
{displayMinutes.length > 0 && (
<details className="technique-audit">
<summary>
<small>{displayMinutes.length} </small>
</summary>
<ol className="rectification-board__minutes">
{displayMinutes.map((row) => (
<li
key={`display-${row.at}`}
className={changedMinutes.has(row.at) ? "rectification-board__minute is-changed" : "rectification-board__minute"}
>
<time dateTime={row.at}>{row.at}</time>
{changedMinutes.has(row.at) ? <span className="rectification-board__minute-note"></span> : null}
<LayerChips layers={row.displayLayers} />
</li>
))}
</ol>
</details>
)}
</section>
)}
{contrast && (
<details className="technique-audit">
<summary></summary>
<div className="rectification-board__disclosure">
<p>{contrast.user_meaning}</p>
<ul>
{contrast.intervals.map((interval) => (
<li key={`${interval.start}-${interval.end}`}>
{interval.start}-{interval.end}{interval.lagna}
{interval.lords.l1 ? `;一/四/七/十宫主 ${[interval.lords.l1, interval.lords.l4, interval.lords.l7, interval.lords.l10].filter(Boolean).join("、")}` : ""}
</li>
))}
</ul>
</div>
</details>
)}
{ledger.length > 0 && (
<details className="technique-audit">
<summary>
<small>{ledger.length} </small>
</summary>
<ul className="rectification-board__disclosure">
{ledger.map((item) => (
<li key={item.summary}>{item.user_meaning}</li>
))}
</ul>
{agreement ? <p className="rectification-board__disclosure">{agreement.user_meaning}</p> : null}
</details>
)}
{nakshatra?.near_boundary && nakshatra.user_meaning && (
<details className="technique-audit">
<summary></summary>
<p className="rectification-board__disclosure">{nakshatra.user_meaning}</p>
</details>
)}
</div>
</>
);
}
export function RectificationBoard({
result,
savedStatus,
diff,
compact,
open,
boardId,
titleId,
onClose,
}: Readonly<{
result: RectificationCandidateResult | null;
savedStatus: "accepted" | "confirmed" | null;
diff: RectificationBoardDiff;
compact: boolean;
open: boolean;
boardId: string;
titleId: string;
onClose: () => void;
}>) {
const sheetRef = useRef<HTMLElement>(null);
useEffect(() => {
if (!compact || !open) return;
sheetRef.current?.focus();
const handleKey = (event: KeyboardEvent) => {
if (event.key === "Escape") onClose();
};
window.addEventListener("keydown", handleKey);
return () => window.removeEventListener("keydown", handleKey);
}, [compact, open, onClose]);
if (compact && !open) return null;
const sheet = (
<aside
ref={sheetRef}
id={boardId}
className={`rectification-board${compact ? " is-sheet" : ""}`}
aria-labelledby={titleId}
role={compact ? "dialog" : undefined}
aria-modal={compact ? true : undefined}
tabIndex={compact ? -1 : undefined}
>
<RectificationBoardBody
result={result}
savedStatus={savedStatus}
diff={diff}
compact={compact}
titleId={titleId}
onClose={onClose}
/>
</aside>
);
if (!compact) return sheet;
return (
<div className="rectification-board-overlay">
<div className="rectification-board-scrim" aria-hidden="true" onClick={onClose} />
{sheet}
</div>
);
}
export function RectificationBoardPeek({
result,
expanded,
boardId,
onOpen,
}: Readonly<{
result: RectificationCandidateResult | null;
expanded: boolean;
boardId: string;
onOpen: () => void;
}>) {
const peek = rectificationBoardPeekCopy(result);
return (
<button
className="rectification-board-peek"
type="button"
aria-expanded={expanded}
aria-controls={boardId}
onClick={onOpen}
>
<strong>{peek.title}</strong>
<span>{peek.detail}</span>
</button>
);
}