"use client"; import { useEffect, useId, useRef } from "react"; import Link from "next/link"; import { label, longLabel, type Item, type Snapshot } from "@/lib/compute"; /* The menu used to be numbered 01–11 and every page eyebrow repeated its number back — "01 — Overview". The rail's icons carry that ordering now, so the digits were only a second thing to read on a screen already being read from across a linen room. The word stays. Every screen now passes the bare word, so this is a floor rather than the mechanism: one page that came back with its number would be the only page in the app wearing one. */ const EYEBROW_NUMBER = /^\s*\d{1,2}\s*[—–-]\s*/; export function PageHead({ eyebrow, title, sub, children, below }: { eyebrow: string; title: React.ReactNode; sub?: React.ReactNode; children?: React.ReactNode; below?: React.ReactNode }) { return (
{eyebrow.replace(EYEBROW_NUMBER, "")}

{title}

{sub &&
{sub}
} {below}
{children &&
{children}
}
); } export function Sec({ children, right, style }: { children: React.ReactNode; right?: React.ReactNode; style?: React.CSSProperties }) { return (
{children}
{right &&
{right}
}
); } /** The props a Field hands its control. Spread them onto the input/select/textarea. */ export type FieldControl = { id: string; "aria-describedby": string | undefined; "aria-invalid": true | undefined }; /* A labelled form control. * * The pattern this replaces — `
` * — draws a label and leaves it a sibling of the box it names, so nothing connects the two: a * screen reader reaching the input announces "edit text, blank", and clicking the label does not * put the cursor in the field. Field generates one id per instance and wires it as the label's * htmlFor and the control's id, so every consumer gets the association for free rather than having * to invent an id at each of the ninety-odd fields in the product. * * The control comes in as a function because only the consumer knows which element is the one the * label names — some fields draw a button or a hint alongside the input. Grouped controls (a Seg, * a set of radios) are not Fields: a single label cannot name several controls, and they want a * fieldset or role="group" instead. * * The markup is the same div.field the stylesheet already targets, so the visual result is * unchanged; `hint` and `error` only appear when a consumer asks for them, and both are wired into * aria-describedby so they are read out as part of the field rather than as loose text. */ export function Field({ label, hint, error, className, style, children }: { label: React.ReactNode; hint?: React.ReactNode; error?: string; className?: string; style?: React.CSSProperties; children: (control: FieldControl) => React.ReactNode; }) { const base = useId(); const id = base + "c"; const hintId = hint ? base + "h" : undefined; const errId = error ? base + "e" : undefined; const describedBy = [hintId, errId].filter(Boolean).join(" ") || undefined; return (
{children({ id, "aria-describedby": describedBy, "aria-invalid": error ? true : undefined })} {hint &&
{hint}
} {/* Red on its own says nothing here — the accent is already the primary button an inch below this line. The mark is what carries at a glance; the words are what carry the meaning, so the mark is decoration and stays out of the reading. */}
); } /* Everything the keyboard can reach inside a dialog, in tab order. getClientRects() is the visibility test rather than offsetParent because a fixed-position control inside the dialog has no offset parent and would otherwise drop out of the cycle. */ const FOCUSABLE = 'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])'; function focusables(root: HTMLElement) { return Array.from(root.querySelectorAll(FOCUSABLE)).filter((el) => el.getClientRects().length > 0); } /* Drawn here rather than pulled off a CDN, and drawn the way the rail's icons are: 2px strokes, square caps, mitred joins. A rounded × would be the only soft corner in an app built out of 2px square borders. */ const CLOSE_ICON = ( ); /* A dialog is chrome, so it wears the chrome's ink: a dark head with the accent rule under it, the same band the rail and the page header carry. What the dialog is *about* — the garment rows, the counted figures, the invoice costs — stays on paper below it, for the same reason the page content did: those numbers get read at arm's length under ward lighting. * * `foot` is the row of buttons. It is a prop rather than the last thing in `children` so the head * and the buttons stay put and only the middle scrolls: Receive delivery on a fourteen-line order * used to push Receive off the bottom of the screen, and someone had to scroll a dialog they had * just finished filling in to find out where the button went. */ export function Dialog({ title, width = 560, onClose, children, sub, foot }: { title: React.ReactNode; width?: number; onClose: () => void; children: React.ReactNode; sub?: React.ReactNode; foot?: React.ReactNode }) { const box = useRef(null); const titleId = useId(); const subId = useId(); // Whoever had focus when the dialog was opened gets it back when it closes. Read during render, // not in the effect: by the time an effect runs the dialog is in the page and a field with // autoFocus may already have taken focus off the button that opened it. const opener = useRef(null); if (opener.current === null && typeof document !== "undefined") opener.current = document.activeElement; useEffect(() => { const node = box.current; if (!node) return; const opened = opener.current; // "Modal" has to mean something to the keyboard and the screen reader, not just to the eye. // Marking every ancestor's other children `inert` takes the page behind the overlay out of the // tab order and out of the accessibility tree, which is what the dim layer only implies. Doing // it by walking the ancestors keeps the dialog where it is rendered — moving it to a portal // would change which React tree its events bubble through. const off: HTMLElement[] = []; for (let el: HTMLElement | null = node.parentElement; el && el !== document.body && el.parentElement; el = el.parentElement) { for (const sib of Array.from(el.parentElement.children)) { if (sib !== el && sib instanceof HTMLElement && !sib.inert) { sib.inert = true; off.push(sib); } } } // Focus the dialog itself rather than its first control: the name and the contents get read // out, and nothing is armed by accident. The two dialogs that autoFocus a field have already // moved focus inside by now, so leave those alone. if (!node.contains(document.activeElement)) node.focus(); return () => { for (const el of off) el.inert = false; if (opened instanceof HTMLElement && opened.isConnected) opened.focus(); }; }, []); useEffect(() => { const h = (e: KeyboardEvent) => { if (e.key === "Escape") { onClose(); return; } if (e.key !== "Tab") return; const node = box.current; if (!node) return; // Wrap the tab cycle inside the dialog. `inert` already stops the page behind it from taking // focus, but on a browser without inert this is what keeps Tab from walking out, and it is // what returns Tab at the last control to the first rather than to the browser chrome. const f = focusables(node); if (f.length === 0) { e.preventDefault(); node.focus(); return; } const at = document.activeElement; if (e.shiftKey && (at === f[0] || at === node)) { e.preventDefault(); f[f.length - 1].focus(); } else if (!e.shiftKey && at === f[f.length - 1]) { e.preventDefault(); f[0].focus(); } }; window.addEventListener("keydown", h); return () => window.removeEventListener("keydown", h); }, [onClose]); return (
{ if (e.target === e.currentTarget) onClose(); }}> {/* The stylesheet's .dialog is a padded box that scrolls as a whole. Overridden here to a column that clips, so the three bands below can decide for themselves what scrolls — 88vh and the 2px frame still come off the class. */}
{/* --tc-* are literal values that no scope remaps, unlike the --color-* tokens the page head reassigns — a dialog can be rendered inside one, and this band has to stay ink either way. */}
{title}
{sub &&
{sub}
}
{/* The rail's collapse button wears this class: it is the product's one piece of ink-on-ink chrome, and a dialog head is the same material. Escape and a click outside already close, but neither is discoverable on a shared linen-room PC. */}
{/* Light on top: every dialog's first element already brings its own top margin — they had to, sitting directly under a title in the old box — and a full gutter here on top of that opens a hole under the accent rule. Enough that a future dialog without one is not printed against the band. */}
{children}
{foot &&
{foot}
}
); } export function Empty({ children, pad = 6 }: { children: React.ReactNode; pad?: number }) { return
{children}
; } /* Something the app has to say back: a save that failed, a save that went through. * * A message that is only painted is silent — nothing about a div that turns up mid-page reaches * anyone who is not looking at it, which is why a failed login used to leave a screen reader user * with an apparently unchanged page. `tone` chooses how much it interrupts: "alert" cuts into * whatever is being read (a failure that has to be acted on), "status" waits for a gap (a result). * * The element is rendered only when there is something to say. A live region that is inserted * along with its text is announced by current screen readers, and keeping an empty one mounted * would count as a flex item wherever one of these sits in a column and open a gap in the layout. */ export function LiveRegion({ msg, tone = "status", id, className, style }: { msg?: React.ReactNode; tone?: "status" | "alert"; id?: string; className?: string; style?: React.CSSProperties }) { if (!msg) return null; return
{msg}
; } /* A save that would not go through. Marked three ways over — the rule down the left edge, the heavier type, and the mark — because this sits under a form whose primary button is already the same red, and two reds a metre apart across a linen room is a guess rather than a signal. */ export function ErrorLine({ msg }: { msg: string }) { if (!msg) return null; return (