diff --git a/src/app/admin/[section]/renderer.tsx b/src/app/admin/[section]/renderer.tsx
index 89cb04f39..d927a353e 100644
--- a/src/app/admin/[section]/renderer.tsx
+++ b/src/app/admin/[section]/renderer.tsx
@@ -36,6 +36,8 @@ import { ModuleAvailabilitySection } from "@/components/admin/module-availabilit
import { RemindersSection } from "@/components/admin/reminders-section";
import { ServicesSection } from "@/components/admin/services-section";
import { SystemStatusSection } from "@/components/admin/system-status-section";
+import { RevealWhenSettled } from "@/components/ui/reveal-when-settled";
+import { Skeleton } from "@/components/ui/skeleton";
import { MeasurementMaintenanceCard } from "@/components/admin/measurement-maintenance-card";
import { InviteTokensSection } from "@/components/admin/invite-tokens-section";
import { UserManagementSection } from "@/components/admin/user-management-section";
@@ -59,11 +61,18 @@ export function AdminSectionRenderer({
switch (slug) {
case "system-status":
return (
-
-
- {/* v1.42 — the table maintenance after the tombstone purge. */}
-
-
+ // The status cards grow as their reads land (host metrics, workers,
+ // queues); revealed together so the maintenance card below is not
+ // pushed down the page three times while they load.
+ }
+ >
+
+
+ {/* v1.42 — the table maintenance after the tombstone purge. */}
+
+
+
);
case "general":
return (
diff --git a/src/app/measurements/page.tsx b/src/app/measurements/page.tsx
index 64e0d653a..ac0efe6cc 100644
--- a/src/app/measurements/page.tsx
+++ b/src/app/measurements/page.tsx
@@ -17,6 +17,8 @@ import { PageHeader } from "@/components/ui/page-header";
import { PageAuthGate } from "@/components/ui/page-auth-gate";
import { PullToRefreshIndicator } from "@/components/ui/pull-to-refresh-indicator";
import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
+import { RevealWhenSettled } from "@/components/ui/reveal-when-settled";
+import { Skeleton } from "@/components/ui/skeleton";
import { Plus } from "lucide-react";
import { useRouter, useSearchParams } from "next/navigation";
import { useTranslations } from "@/lib/i18n/context";
@@ -203,14 +205,21 @@ export default function MeasurementsPage() {
/>
- {
- setReturnTo(null);
- setDialogOpen(true);
- }}
- />
+ {/* The list's height is the data's; the custom metrics sit below it.
+ Revealed together once both reads settle, so neither pushes the
+ other down the page while it loads. */}
+ }
+ >
+ {
+ setReturnTo(null);
+ setDialogOpen(true);
+ }}
+ />
-
+
+
);
}
diff --git a/src/components/charts/chart-skeleton.tsx b/src/components/charts/chart-skeleton.tsx
index 6e1c0e7c3..2d10370cd 100644
--- a/src/components/charts/chart-skeleton.tsx
+++ b/src/components/charts/chart-skeleton.tsx
@@ -48,11 +48,51 @@ import { useTranslations } from "@/lib/i18n/context";
const SLOW_HINT_DELAY_MS = 3_000;
+/**
+ * The footer a loaded `` paints under its plot: the row of day
+ * dots and its caption on a day-linked chart, and the collapsed "show as
+ * table" disclosure. A loading shell without it was ~100 px shorter than the
+ * card it turned into (CLS on every metric page, the BMI page most visibly),
+ * so the shells reserve the same boxes: `h-3` rug, `mt-1` caption (two lines
+ * on a phone, one from `sm`), and the disclosure's `mt-3 border-t pt-3` frame
+ * around its `min-h-11` button.
+ */
+export function ChartFooterReserve({
+ dayLinks = false,
+ dataTable = false,
+}: {
+ dayLinks?: boolean;
+ dataTable?: boolean;
+}) {
+ if (!dayLinks && !dataTable) return null;
+ return (
+
+ );
+}
+
export function ChartSkeleton({
className,
mini = false,
+ dayLinks = false,
+ dataTable = false,
}: {
className?: string;
+ /** Reserve the day-dot row + caption a day-linked chart paints. */
+ dayLinks?: boolean;
+ /** Reserve the collapsed data-table disclosure. */
+ dataTable?: boolean;
/**
* When true, render the compact variant used inside the trends-row
* chart slot (140 px band, lighter chrome). Defaults to false for
@@ -106,8 +146,9 @@ export function ChartSkeleton({
{/* Header row — title + range tabs match the real chart's
`mb-4 flex flex-col gap-2 sm:flex-row` chrome. */}
-
-
+
+ {/* The range tabs are 44 px tap targets, so the row is too. */}
+
@@ -118,6 +159,7 @@ export function ChartSkeleton({
{/* Chart band — mirrors the same `h-[var(--chart-height,…)]`
contract `health-chart.tsx` paints at line 1087-1089. */}
+
{showSlowHint && (
- importWithRetry(() => import("@/components/charts/chart-runtime")).then(
- (mod) => ({ default: mod.HealthChart }),
- ),
- { ssr: false, loading: () => },
-);
+const loadHealthChart = () =>
+ importWithRetry(() => import("@/components/charts/chart-runtime")).then(
+ (mod) => ({ default: mod.HealthChart }),
+ );
+
+// One lazy boundary per footer shape: `next/dynamic`'s loading slot gets no
+// props, so the shell that must match a day-linked chart with its data table
+// is its own boundary. All four resolve the same chunk.
+const HealthChartLazy = dynamic(loadHealthChart, {
+ ssr: false,
+ loading: () => ,
+});
+const HealthChartLazyDay = dynamic(loadHealthChart, {
+ ssr: false,
+ loading: () => ,
+});
+const HealthChartLazyTable = dynamic(loadHealthChart, {
+ ssr: false,
+ loading: () => ,
+});
+const HealthChartLazyDayTable = dynamic(loadHealthChart, {
+ ssr: false,
+ loading: () => ,
+});
export function HealthChartDynamic(
props: ComponentProps,
): ReactElement {
+ const Lazy = props.mini
+ ? HealthChartLazy
+ : props.dayLinks
+ ? props.showDataTable
+ ? HealthChartLazyDayTable
+ : HealthChartLazyDay
+ : props.showDataTable
+ ? HealthChartLazyTable
+ : HealthChartLazy;
return (
-
+
);
}
diff --git a/src/components/charts/health-chart.tsx b/src/components/charts/health-chart.tsx
index ab229be3c..82ff67e67 100644
--- a/src/components/charts/health-chart.tsx
+++ b/src/components/charts/health-chart.tsx
@@ -68,6 +68,7 @@ import { bucketCaptionKey } from "@/lib/charts/bucket-caption";
import type { MeasurementType } from "@/generated/prisma/client";
import { ChartOverlayControls } from "./chart-overlay-controls";
import { ChartDataTable, type ChartDataTableColumn } from "./chart-data-table";
+import { ChartFooterReserve } from "./chart-skeleton";
import { useChartOverlayPrefs } from "@/hooks/use-chart-overlay-prefs";
import { useViewportWidth } from "@/hooks/use-viewport-width";
import { computeTickPositions } from "@/lib/charts/x-axis-density";
@@ -1945,7 +1946,15 @@ export function HealthChart({
// card height never jumps when the data lands — the dashboard's
// shared-reveal overlay and every insights mount stay
// layout-shift-free.
-
+ <>
+
+ {mini ? null : (
+
+ )}
+ >
) : isError ? (
// v1.16.8 — a failed query paints as an ERROR with a retry
// affordance, not as the "no data in this range" empty state.
diff --git a/src/components/illness/illness-episode-detail.tsx b/src/components/illness/illness-episode-detail.tsx
index 6ce6e29cf..91e36a332 100644
--- a/src/components/illness/illness-episode-detail.tsx
+++ b/src/components/illness/illness-episode-detail.tsx
@@ -21,6 +21,7 @@ import { Card, CardContent } from "@/components/ui/card";
import { QueryErrorCard } from "@/components/ui/query-error-card";
import { PageHeader } from "@/components/ui/page-header";
import { Skeleton } from "@/components/ui/skeleton";
+import { RevealWhenSettled } from "@/components/ui/reveal-when-settled";
import { useTranslations, useFormatters } from "@/lib/i18n/context";
import { bodySiteText } from "@/components/encounters/encounter-labels";
import { canReadBodySites } from "@/hooks/use-body-sites";
@@ -154,49 +155,56 @@ export function IllnessEpisodeDetail({ episodeId }: { episodeId: string }) {
}
/>
- {episode?.note ? (
-
-
-
- {t("illness.detail.note")}
-
-
- {episode.note}
-
-
-
- ) : null}
+ {/* Every card below has a data-shaped height (the day timeline above
+ all). Revealed together once the reads settle, so none of them
+ pushes the rest down while the page loads. */}
+ }
+ >
+ {episode?.note ? (
+
+
+
+ {t("illness.detail.note")}
+
+
+ {episode.note}
+
+
+
+ ) : null}
- {/* The per-day timeline lives only on the detail surface — the list
- rows stay clean summaries, so navigating here reveals genuinely new
- content (today's logged symptoms / impact / fever). */}
- {episode ? (
- setLogOpen(true)}
- />
- ) : null}
+ {/* The per-day timeline lives only on the detail surface — the list
+ rows stay clean summaries, so navigating here reveals genuinely new
+ content (today's logged symptoms / impact / fever). */}
+ {episode ? (
+ setLogOpen(true)}
+ />
+ ) : null}
- {/* v1.40 — the person's own symptoms filed against this episode or
- logged while it ran. Renders nothing when there are none. */}
- {episode ? : null}
+ {/* v1.40 — the person's own symptoms filed against this episode or
+ logged while it ran. Renders nothing when there are none. */}
+ {episode ? : null}
- {isLoading ? (
-
- ) : (
-
- )}
+ {isLoading ? (
+
+ ) : (
+
+ )}
- {/* Condition ⇄ documents linking, condition side: the episode's
- linked documents plus link/upload entries into the vault. Renders
- nothing when the documents module is off for this account. */}
- {episode ? : null}
+ {/* Condition ⇄ documents linking, condition side: the episode's
+ linked documents plus link/upload entries into the vault. Renders
+ nothing when the documents module is off for this account. */}
+ {episode ? : null}
- {/* Condition ⇄ visits linking, condition side ONLY. A visit never
- suggests an episode: an episode spans weeks and a visit is a point,
- so the reverse match would be wrong often enough to train the person
- to ignore every suggestion in the product. */}
- {episode ? : null}
+ {/* Condition ⇄ visits linking, condition side ONLY. A visit never
+ suggests an episode: an episode spans weeks and a visit is a point,
+ so the reverse match would be wrong often enough to train the person
+ to ignore every suggestion in the product. */}
+ {episode ? : null}
+
{episode ? (
<>
diff --git a/src/components/settings/insights-section.tsx b/src/components/settings/insights-section.tsx
index 2fa49fa9d..b9030ac43 100644
--- a/src/components/settings/insights-section.tsx
+++ b/src/components/settings/insights-section.tsx
@@ -2,6 +2,8 @@
import { InsightsOverviewArrangeSection } from "@/components/settings/insights-overview-arrange-section";
import { InsightsPillOrderSection } from "@/components/settings/insights-pill-order-section";
+import { RevealWhenSettled } from "@/components/ui/reveal-when-settled";
+import { Skeleton } from "@/components/ui/skeleton";
/**
* v1.15.18 — the "Insights" settings section (analogous to the Dashboard
@@ -20,9 +22,14 @@ import { InsightsPillOrderSection } from "@/components/settings/insights-pill-or
*/
export function InsightsSection() {
return (
-
+ // The arrange list grows from its loading card to the full set of
+ // sections, which pushed the pill-order card below it down the page.
+ // Both reveal together once their reads settle.
+ }
+ >
-
+
);
}
diff --git a/src/components/settings/score-section.tsx b/src/components/settings/score-section.tsx
index 18b8f90b0..88c2542f1 100644
--- a/src/components/settings/score-section.tsx
+++ b/src/components/settings/score-section.tsx
@@ -56,6 +56,7 @@ import { Gauge, Loader2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { QueryErrorCard } from "@/components/ui/query-error-card";
import { QueryErrorRow } from "@/components/ui/query-error-row";
+import { RevealWhenSettled } from "@/components/ui/reveal-when-settled";
import { Skeleton } from "@/components/ui/skeleton";
import { SettingsCard } from "@/components/settings/settings-card";
import { SettingsCardHeader } from "@/components/settings/_card-header";
@@ -319,194 +320,201 @@ export function ScoreSection() {
const busy = saveMutation.isPending;
return (
-
- {/* The sentence the whole feature exists for. Foreground, in
- the body, because it is content and not a meta
- description. */}
-
- {t("settings.sections.score.threeAxes")}
-
+ {showComposition && composition ? (
+
+ dismissCompositionMutation.mutate(composition.itemKey)
+ }
+ />
+ ) : null}
- {/* v1.38 — the breadth sentence. Three areas of health used to
- be the price of a score, and the write refused anything
- narrower; it is a recommendation now and one pillar saves.
- The recommendation stays on the surface anyway, because a
- one-area score is honest but thin and a person choosing
- what counts should know that before they save, not after
- the card tells them what it rests on. Content, so
- foreground and in the body beside the sentence it
- qualifies — not a muted footnote under the rows. */}
-
- {t("settings.sections.score.recommendation")}
-
+ {configQuery.isError ? (
+ void configQuery.refetch()}
+ />
+ ) : (
+
+
- {/* The starting points. Both name data; neither names a
- person. A template that supplies a rationale for dropping a
- pillar is the one shape the evidence rules out, so there is
- none. */}
-
+ {/* The sentence the whole feature exists for. Foreground, in
+ the body, because it is content and not a meta
+ description. */}
+
+ {t("settings.sections.score.threeAxes")}
- ) : null}
- {analyticsQuery.isError ? (
- void analyticsQuery.refetch()}
- />
- ) : null}
+ {/* v1.38 — the breadth sentence. Three areas of health used to
+ be the price of a score, and the write refused anything
+ narrower; it is a recommendation now and one pillar saves.
+ The recommendation stays on the surface anyway, because a
+ one-area score is honest but thin and a person choosing
+ what counts should know that before they save, not after
+ the card tells them what it rests on. Content, so
+ foreground and in the body beside the sentence it
+ qualifies — not a muted footnote under the rows. */}
+
+ {t("settings.sections.score.recommendation")}
+
- {configQuery.isLoading ? (
-
-
-
-
-
-
- ) : (
- // One flat list of topics separated by hairline dividers, the
- // way Anamnese lists its facts. The group headings are gone; the
- // registry order (domain, then pillar) is preserved by flattening
- // the groups in order, and each row keeps its `data-domain` for
- // tests and e2e.
-
- {/* Bottom-right, in its own row, like every other settings card
- that saves a form. */}
-
-
-
-
- )}
-
+
+ {configQuery.isLoading ? (
+
+
+
+
+
+
+ ) : (
+ // One flat list of topics separated by hairline dividers, the
+ // way Anamnese lists its facts. The group headings are gone; the
+ // registry order (domain, then pillar) is preserved by flattening
+ // the groups in order, and each row keeps its `data-domain` for
+ // tests and e2e.
+
+
+ {/* Bottom-right, in its own row, like every other settings card
+ that saves a form. */}
+
+
+
+
+ )}
+
+
);
}
diff --git a/src/components/ui/__tests__/reveal-when-settled.test.tsx b/src/components/ui/__tests__/reveal-when-settled.test.tsx
new file mode 100644
index 000000000..39037f967
--- /dev/null
+++ b/src/components/ui/__tests__/reveal-when-settled.test.tsx
@@ -0,0 +1,27 @@
+import { describe, expect, it } from "vitest";
+import { renderToStaticMarkup } from "react-dom/server";
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+
+import { RevealWhenSettled } from "../reveal-when-settled";
+
+/**
+ * The body mounts at once (so its reads start) but stays out of layout until
+ * they settle; the fallback holds the place. Nothing inside a `display: none`
+ * box can register a layout shift, which is the point.
+ */
+describe("", () => {
+ it("paints the fallback and keeps the mounted body out of layout", () => {
+ const html = renderToStaticMarkup(
+
+ }>
+
content
+
+ ,
+ );
+ expect(html).toContain('data-testid="fallback"');
+ expect(html).toContain('data-testid="body"');
+ expect(html).toMatch(
+ /data-slot="reveal-when-settled" data-revealed="false" aria-busy="true" class="hidden"/,
+ );
+ });
+});
diff --git a/src/components/ui/reveal-when-settled.tsx b/src/components/ui/reveal-when-settled.tsx
new file mode 100644
index 000000000..ac98626ea
--- /dev/null
+++ b/src/components/ui/reveal-when-settled.tsx
@@ -0,0 +1,79 @@
+"use client";
+
+import { useEffect, useState, type ReactNode } from "react";
+import { useIsFetching, useQueryClient } from "@tanstack/react-query";
+
+/**
+ * Holds a page body out of layout until its first reads have settled, then
+ * shows it in one step.
+ *
+ * Some bodies stack several cards whose final height depends on data — a
+ * day-by-day timeline, a paginated list, a card that turns out empty. Each
+ * resolving on its own pushed everything beneath it down the page a few
+ * times in the first few hundred milliseconds (CLS well above 0.1 at
+ * desktop width). A skeleton cannot know a list's real length; this does
+ * not need to.
+ *
+ * The children mount immediately, so their queries start at once; they sit
+ * in a `hidden` container (out of layout, so nothing in it can shift) while
+ * the `fallback` holds the place. The body reveals when no query is in
+ * flight for two frames running, or after `maxWaitMs` at the latest, so a
+ * slow or polling read elsewhere in the app can delay it but never strand
+ * it. Revealing is latched: a later background refetch does not hide the
+ * body again.
+ */
+export function RevealWhenSettled({
+ children,
+ fallback,
+ maxWaitMs = 2000,
+ className = "space-y-6",
+}: {
+ children: ReactNode;
+ /** The body's own rhythm: the wrapper is the one child of the page's
+ * stack, so the gap between the cards it holds is set here. */
+ className?: string;
+ /** The placeholder painted until the body reveals. */
+ fallback: ReactNode;
+ maxWaitMs?: number;
+}) {
+ const queryClient = useQueryClient();
+ const fetching = useIsFetching();
+ const [revealed, setRevealed] = useState(false);
+
+ useEffect(() => {
+ if (revealed || fetching > 0) return;
+ // Two frames: the children's query observers subscribe in their own
+ // effects, so a count of zero on the first pass can simply mean nothing
+ // has started yet.
+ let second = 0;
+ const first = requestAnimationFrame(() => {
+ second = requestAnimationFrame(() => {
+ if (queryClient.isFetching() === 0) setRevealed(true);
+ });
+ });
+ return () => {
+ cancelAnimationFrame(first);
+ cancelAnimationFrame(second);
+ };
+ }, [fetching, revealed, queryClient]);
+
+ useEffect(() => {
+ if (revealed) return;
+ const timer = setTimeout(() => setRevealed(true), maxWaitMs);
+ return () => clearTimeout(timer);
+ }, [revealed, maxWaitMs]);
+
+ return (
+ <>
+ {revealed ? null : fallback}
+