mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +00:00
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:
1 parent
17ad93ca5d
commit
1e469d8185
3 files changed
+58
-10
No files matched your search
@@ -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"
|
||||
|
||||
Reference in new issue
Block a user