fix(insights): eliminate layout shift on the Today's-signal card

The dynamic loader skeleton reserved 120px while the resolved card's
insufficient (~168px) and fired (~192px) states ran taller, shifting the hero
and everything below it down 48–72px on resolve. Pin a min-h-48 on the card
shell sized to the tallest realistic state, and match both the chunk-loader
skeleton and the in-component CardSkeleton to that footprint (including the
44px provenance header row) so there is zero shift across loading → any state.
Announce the fired state to screen readers via a polite role=status (not the
assertive role=alert), and mark the decorative loader skeleton aria-hidden.
This commit is contained in:
Marc-André Bombeck committed 2026-06-03 20:07:46 +02:00
1 parent 17ad93ca5d
commit 1e469d8185
3 files changed
+58 -10

No files matched your search

+8 -4
View File
@@ -100,9 +100,10 @@ const RhythmEventsCard = dynamic(
// v1.10.3 — "Today's signal" headline card. Promotes COINCIDENT_DEVIATION from
// a buried below-the-fold tile to the top-of-overview daily read (the
// always-present Apple/WHOOP/Oura pattern). Deferred behind `next/dynamic`; it
// owns its own derived-metric query and renders four calm states. A
// fixed-height skeleton matches the resolved card so the top of the page does
// not shift while the chunk + data resolve.
// owns its own derived-metric query and renders four calm states. The chunk
// loader's skeleton shares the card's `min-h-48` footprint (and the in-card
// `CardSkeleton` matches it too) so the top of the page does not shift across
// loading → any resolved state. Decorative → `aria-hidden`.
const CoincidentDeviationCard = dynamic(
() =>
import("@/components/insights/coincident-deviation-card").then((mod) => ({
@@ -111,7 +112,10 @@ const CoincidentDeviationCard = dynamic(
{
ssr: false,
loading: () => (
<div className="bg-card border-border h-[7.5rem] animate-pulse rounded-xl border motion-reduce:animate-none" />
<div
aria-hidden="true"
className="bg-card border-border min-h-48 animate-pulse rounded-xl border motion-reduce:animate-none"
/>
),
},
);
@@ -89,6 +89,30 @@ describe("<CoincidentDeviationCard>", () => {
const html = render(<CoincidentDeviationCard />);
expect(html).toContain('data-slot="coincident-deviation-card-skeleton"');
expect(html).not.toContain('data-slot="coincident-deviation-card"');
// The skeleton reserves the resolved card's footprint (no downward shift).
expect(html).toContain("min-h-48");
});
it("the skeleton and a resolved card share the same min-height footprint", () => {
mock(undefined);
const skeleton = render(<CoincidentDeviationCard />);
mock(
ok({
fired: true,
day: "2026-06-03",
vitals: [
deviation("RESTING_HEART_RATE", true),
deviation("RESPIRATORY_RATE", true),
],
contributing: [
deviation("RESTING_HEART_RATE", true),
deviation("RESPIRATORY_RATE", true),
],
}),
);
const fired = render(<CoincidentDeviationCard />);
expect(skeleton).toContain("min-h-48");
expect(fired).toContain("min-h-48");
});
it("renders the insufficient (building baselines) state, never an alarm", () => {
@@ -163,6 +187,9 @@ describe("<CoincidentDeviationCard>", () => {
expect(html).not.toContain("text-destructive");
// The provenance affordance reaches the card.
expect(html).toContain('data-slot="provenance-explainer-trigger"');
// The state change is announced politely to SR users (not assertively).
expect(html).toContain('role="status"');
expect(html).not.toContain('role="alert"');
});
it("softens a fired flag to the watch tone when history is thin", () => {
@@ -99,7 +99,13 @@ function CardShell({
data-slot="coincident-deviation-card"
data-state={state}
className={cn(
"bg-card flex w-full min-w-0 flex-col gap-2 rounded-xl border p-4 md:p-6",
// `min-h` sized to the tallest realistic state (fired: 44px provenance
// header + the named-vitals body + the mandatory factors line). Every
// calmer state shares this footprint so the card — and everything below
// it on the overview — never shifts as the state resolves (the CLS the
// design pass closes). `min-h` not fixed `h`: a rare taller body still
// grows, it just never shrinks below the reserved height.
"bg-card flex min-h-48 w-full min-w-0 flex-col gap-2 rounded-xl border p-4 md:p-6",
// At most amber — never destructive/red. The fired state borders
// warning; every calmer state keeps the neutral card border.
state === "fired" ? "border-warning/40" : "border-border",
@@ -116,16 +122,24 @@ function CardShell({
);
}
/** A fixed-footprint skeleton matching the resolved card (CLS-safe). */
/**
* A fixed-footprint skeleton matching the resolved card geometry exactly
* (CLS-safe): the same `min-h-48` shell, the same gap, and a 44px header row
* mirroring the provenance trigger the resolved header carries — so the card
* does not shift when the real content drops in.
*/
function CardSkeleton() {
return (
<div
data-slot="coincident-deviation-card-skeleton"
aria-hidden="true"
className="bg-card border-border flex w-full min-w-0 flex-col gap-3 rounded-xl border p-4 md:p-6"
className="bg-card border-border flex min-h-48 w-full min-w-0 flex-col gap-2 rounded-xl border p-4 md:p-6"
>
<div className="bg-muted/40 h-3 w-28 rounded" />
<div className="bg-muted/40 h-5 w-3/4 rounded" />
<div className="flex h-11 items-center justify-between gap-2">
<div className="bg-muted/40 h-3 w-28 rounded" />
<div className="bg-muted/40 h-5 w-5 rounded-full" />
</div>
<div className="bg-muted/40 h-4 w-3/4 rounded" />
<div className="bg-muted/40 h-3 w-1/2 rounded" />
</div>
);
@@ -247,10 +261,13 @@ export function CoincidentDeviationCard({
}
// fired — amber awareness, named vitals, possible-factors line (mandatory).
// `role="status"` (a polite live region, not the assertive `role="alert"`)
// so a screen-reader user gets the day-to-day state change in the calm tone
// the card keeps visually — the one state whose meaning shifts vs all-clear.
return (
<div className={className}>
<CardShell state="fired" provenance={data.provenance}>
<div className="flex items-start gap-2">
<div className="flex items-start gap-2" role="status">
<AlertTriangle
className="text-warning mt-0.5 h-4 w-4 shrink-0"
aria-hidden="true"