mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 06:06:10 +00:00
The two consoles used the same primitives and still read as two systems, because everything without a primitive had drifted: the header-to-body step existed in three competing techniques at once (`mt-4` on the first body child, `space-y-4` on the card, `mb-4` on the header), the body started either at the card edge or 28 px in, and both shells hand-rolled the page title instead of calling `PageHeader`. Move the decisions into the primitives so a call site cannot re-open them: - `SettingsCard` is gap-based like `Card` (`flex flex-col gap-4`). The header-to-body step is the container's, not a margin somebody remembers. A dense card still overrides the gap on the card itself. - The body starts at the card edge. `pl-7` is gone — off the spacing scale, and it mis-aligned action rows against their own forms wherever two neighbouring cards disagreed about it. - The card header's description slot loses its `space-y-1`. That was the licence call sites used to stack explainer paragraphs into a muted `text-xs` slot; the slot holds one sentence. - New `SettingsCardActions`: one action row, trailing edge, last thing in the card. `align="start"` spells the one exception, a card whose only actions are links out. - New `StatTile` replaces four hand-rolled label/value boxes in Admin. - `SectionHeading` moves from `insights/` to `ui/`. It is the heading above a group of cards, which is what Settings needs; Settings had been using a card header with no card, which reads as a card that failed to paint. Both shells now render their heading through `PageHeader`, which gained `headingAs="div"` for the second copy each shell paints (mobile above the nav strip, desktop inside the grid) so only one is a real `<h1>`. The page explainer is muted everywhere: the primitive said foreground, the standards and every hand-rolled copy said muted, so pages disagreed with each other. Medications, Vorsorge, the labs / custom-metric / illness detail pages and the clinician view drop their hand-rolled title blocks too, including the mid-word truncation on the medications subtitle. Tests pin the parts that can be checked exactly: the card's gap, the description slot's missing rhythm, the action row's two alignments, the `<h1>`-free shells, and a sweep asserting `pl-7` stays gone.
83 lines
2.8 KiB
TypeScript
83 lines
2.8 KiB
TypeScript
import type { ReactNode } from "react";
|
|
|
|
import { BackLink } from "@/components/ui/back-link";
|
|
import { cn } from "@/lib/utils";
|
|
|
|
/**
|
|
* The one canonical page header. Every module surface renders its title
|
|
* through this so the header vocabulary reads identically app-wide: the H1
|
|
* is always `text-2xl font-bold tracking-tight`, the explainer under it is
|
|
* always `text-muted-foreground text-sm` (heading in foreground, one-line
|
|
* explainer in muted — the app-wide convention the design standards pin for
|
|
* `CardDescription`, `PageHeader`, and `SettingsCardHeader` alike), and no
|
|
* icon sits beside the H1. An optional back-link rides above the title, and
|
|
* an `actions` slot holds the page's primary buttons to the right of the
|
|
* title block.
|
|
*
|
|
* The explainer never truncates. A page that clipped it mid-word to hold one
|
|
* line on a phone was trading a readable sentence for a fixed height; the
|
|
* fixed height now comes from every header being built the same way, and a
|
|
* sentence too long for one line is a text problem, fixed in the string.
|
|
*
|
|
* `headingAs="div"` renders the title as `role="heading" aria-level={1}`
|
|
* instead of an `<h1>`. The Settings and Admin shells paint their heading
|
|
* twice — once above the mobile section strip, once inside the desktop grid
|
|
* row — and only one of the two may be a real `<h1>`. Without this the two
|
|
* shells hand-rolled the whole block to get the second copy, which is how
|
|
* they drifted apart from every module page in the first place.
|
|
*/
|
|
export function PageHeader({
|
|
title,
|
|
titleId,
|
|
description,
|
|
headingAs = "h1",
|
|
backLink,
|
|
topSlot,
|
|
actions,
|
|
className,
|
|
}: {
|
|
title: ReactNode;
|
|
titleId?: string;
|
|
description?: ReactNode;
|
|
headingAs?: "h1" | "div";
|
|
backLink?: { href: string; label: string; dataSlot?: string };
|
|
/** Optional block above the title (e.g. a hub back-link the shell owns). */
|
|
topSlot?: ReactNode;
|
|
actions?: ReactNode;
|
|
className?: string;
|
|
}) {
|
|
const heading =
|
|
headingAs === "div" ? (
|
|
<div
|
|
id={titleId}
|
|
role="heading"
|
|
aria-level={1}
|
|
className="text-2xl font-bold tracking-tight"
|
|
>
|
|
{title}
|
|
</div>
|
|
) : (
|
|
<h1 id={titleId} className="text-2xl font-bold tracking-tight">
|
|
{title}
|
|
</h1>
|
|
);
|
|
|
|
return (
|
|
<div className={cn("space-y-1.5", className)}>
|
|
{backLink ? <BackLink {...backLink} /> : null}
|
|
{topSlot}
|
|
<div className="flex items-start justify-between gap-3">
|
|
<div className="min-w-0 space-y-1.5">
|
|
{heading}
|
|
{description ? (
|
|
<p className="text-muted-foreground text-sm">{description}</p>
|
|
) : null}
|
|
</div>
|
|
{actions ? (
|
|
<div className="flex shrink-0 items-center gap-2">{actions}</div>
|
|
) : null}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|