mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 13:22:14 +00:00
refactor(ui): give Settings and Admin one card rhythm and one page header
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.
This commit is contained in:
1 parent
00e8d05235
commit
5445bfc1f1
121 files changed
+832
-620
No files matched your search
@@ -6,34 +6,71 @@ 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 description is always
|
||||
* `text-foreground text-sm` (it is content, not meta — the muted tier is
|
||||
* reserved for timestamps/counts per the design standards), 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.
|
||||
* 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">
|
||||
<h1 className="text-2xl font-bold tracking-tight">{title}</h1>
|
||||
{heading}
|
||||
{description ? (
|
||||
<p className="text-foreground text-sm">{description}</p>
|
||||
<p className="text-muted-foreground text-sm">{description}</p>
|
||||
) : null}
|
||||
</div>
|
||||
{actions ? (
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
import type { ComponentType, ReactNode } from "react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/**
|
||||
* The one heading for a GROUP of cards, app-wide.
|
||||
*
|
||||
* Every top-level Insights overview section (health scores, daily briefing,
|
||||
* vitals, trends, period-in-review, cycle summary, signals of the day) and
|
||||
* every Settings card group (notification channels, the health record,
|
||||
* integrations) leads with this identical heading ABOVE its cards: a leading
|
||||
* Lucide icon and an `<h2>` title, both in the foreground colour (white in
|
||||
* dark mode — never a per-section accent hue), at one size + weight. An
|
||||
* optional `action` slot pins a trailing affordance (a subtitle, a meta
|
||||
* control) to the right edge without breaking the row's alignment.
|
||||
*
|
||||
* It lives under `ui/` because it is not an Insights component: Settings
|
||||
* used to open its groups with a bare `SettingsCardHeader` and no card, which
|
||||
* reads as a card title whose card failed to paint. `text-base` here against
|
||||
* `text-lg` on a card title is what separates "this labels the group" from
|
||||
* "this titles a card".
|
||||
*
|
||||
* The overview used to mix three heading dialects: a `text-lg` `<h2>` with a
|
||||
* purple Sparkles glyph (briefing), a `text-lg` `<h2>` with NO icon (trends),
|
||||
* a `text-base` `TileHeader` with a white icon (scores / vitals), and in-card
|
||||
* titles buried inside the period-narrative, cycle-summary and signals cards.
|
||||
* Side by side they read as four different surfaces. `SectionHeading` pins the
|
||||
* single contract so the system can't drift: icon `h-5 w-5 shrink-0
|
||||
* text-foreground`, title `<h2>` `text-base font-semibold`, `gap-2` between
|
||||
* icon and title. The caller owns the `space-y-3` gap to the card below.
|
||||
*
|
||||
* RSC-safe: no hooks, no browser API.
|
||||
*/
|
||||
|
||||
interface SectionHeadingProps {
|
||||
/**
|
||||
* Leading glyph. The caller passes the Lucide component itself
|
||||
* (`icon={Sparkles}`), not a pre-sized node — `SectionHeading` owns the
|
||||
* size and colour so every overview heading matches.
|
||||
*/
|
||||
icon: ComponentType<{ className?: string }>;
|
||||
/** Heading text rendered inside the `<h2>`. */
|
||||
title: ReactNode;
|
||||
/**
|
||||
* Optional one-line description rendered directly under the `<h2>`, in the
|
||||
* same muted typography the detail pages use beneath their headings
|
||||
* (`text-muted-foreground text-sm`). Opt-in per section so the overview can
|
||||
* carry the "descriptive line under every heading" rule without forcing a
|
||||
* subtitle where none reads well.
|
||||
*/
|
||||
subtitle?: ReactNode;
|
||||
/** Optional trailing affordance pinned to the right edge of the row. */
|
||||
action?: ReactNode;
|
||||
/** Optional id on the `<h2>`, e.g. for an `aria-labelledby` link. */
|
||||
id?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function SectionHeading({
|
||||
icon: Icon,
|
||||
title,
|
||||
subtitle,
|
||||
action,
|
||||
id,
|
||||
className,
|
||||
}: SectionHeadingProps) {
|
||||
return (
|
||||
<div
|
||||
data-slot="section-heading"
|
||||
className={cn(
|
||||
"flex flex-wrap items-center justify-between gap-2",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<Icon className="text-foreground h-5 w-5 shrink-0" aria-hidden="true" />
|
||||
<div className="min-w-0">
|
||||
<h2 id={id} className="text-base font-semibold">
|
||||
{title}
|
||||
</h2>
|
||||
{subtitle ? (
|
||||
<p
|
||||
data-slot="section-heading-subtitle"
|
||||
className="text-muted-foreground text-sm"
|
||||
>
|
||||
{subtitle}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
{action ? (
|
||||
// The action slot must be able to SHRINK. It was `shrink-0`, which
|
||||
// pins a flex item at its max-content width — fine for the compact
|
||||
// control the slot was designed for, wrong for the prose every real
|
||||
// caller passes today (the provenance method caption, the
|
||||
// device-score subtitle, the sleep-quality subtitle). A sentence at
|
||||
// max-content is ~1.2-1.5k px, and because the row sits inside the
|
||||
// AuthShell `<main>` — whose `overflow-y:auto` makes `overflow-x`
|
||||
// compute to `auto` — that width became a horizontally scrollable
|
||||
// page at every phone width, growing with the locale's string length.
|
||||
// `min-w-0` lifts the flex `min-width:auto` floor so the text can wrap
|
||||
// to the row width instead. The parent's `flex-wrap` still moves the
|
||||
// slot onto its own line before anything is squeezed, so a compact
|
||||
// control keeps its size.
|
||||
<div className="flex min-w-0 items-center gap-2">{action}</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user