From 97e4dd4c05bdfb9d945a937e7548a5d0e3bf6c22 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 8 Oct 2026 20:20:02 +0200 Subject: [PATCH 1/6] Split the AI provider settings into one card per concern The page was one card titled like the page, holding seven grey panels with three primary Saves, each left-aligned mid-card. Provider, fallback chain, response timeout, document reading, consents and the check row are separate Settings cards now, each with one action row at its foot. The chain rows keep the name on its own line on a phone, and the cards below the provider card wait for its reads so they do not jump. --- .../settings/ai/ai-consent-card.tsx | 305 +++++++++--------- .../settings/ai/ai-insights-card.tsx | 178 +++++----- .../settings/ai/anthropic-provider-form.tsx | 17 +- src/components/settings/ai/auto-read-card.tsx | 67 ++-- .../settings/ai/central-codex-switch.tsx | 67 ++-- .../settings/ai/codex-provider-form.tsx | 29 +- .../settings/ai/compat-provider-form.tsx | 17 +- .../settings/ai/fallback-chain-card.tsx | 157 ++++----- .../settings/ai/local-provider-form.tsx | 17 +- .../settings/ai/openai-provider-form.tsx | 17 +- .../settings/ai/response-timeout-card.tsx | 52 +-- .../settings/ai/runtime-actions-row.tsx | 27 +- 12 files changed, 490 insertions(+), 460 deletions(-) diff --git a/src/components/settings/ai/ai-consent-card.tsx b/src/components/settings/ai/ai-consent-card.tsx index 0741df1cc..2dab317fc 100644 --- a/src/components/settings/ai/ai-consent-card.tsx +++ b/src/components/settings/ai/ai-consent-card.tsx @@ -34,6 +34,9 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { Loader2, ShieldCheck, ShieldOff } from "lucide-react"; import { Button } from "@/components/ui/button"; +import { SettingsCard } from "@/components/settings/settings-card"; +import { SettingsCardActions } from "@/components/settings/_card-actions"; +import { SettingsCardHeader } from "@/components/settings/_card-header"; import { Skeleton } from "@/components/ui/skeleton"; import { QueryErrorRow } from "@/components/ui/query-error-row"; import { apiFetchRaw } from "@/lib/api/api-fetch"; @@ -124,24 +127,29 @@ export function AiConsentCard({ // below it once the receipt arrived. if (isLoading) { return ( -
-
- - -
- -
+ + } + /> + + ); } if (isError) { return ( -
+ + void refetch()} /> -
+ ); } @@ -150,100 +158,106 @@ export function AiConsentCard({ const activeReceipt = data && data.revokedAt === null ? data : null; return ( -
-
-

- {activeReceipt ? ( - - ) : ( - - )} - {t("settings.ai.consent.title")} -

-

- {activeReceipt - ? t("settings.ai.consent.activeSince", { - date: formatDateTime(activeReceipt.signedAt), - }) - : t("settings.ai.consent.withdrawn")} -

-
+ <> + + - {activeReceipt ? ( - confirming ? ( -
-

- {t("settings.ai.consent.confirmBody")} -

- - -
- ) : ( - - ) - ) : ( -
+ {activeReceipt && confirming ? ( +

+ {t("settings.ai.consent.confirmBody")} +

+ ) : null} + {!activeReceipt ? (

{t("settings.ai.consent.regrantHint")}

- -
- )} + ) : null} - {revoke.isError ? ( -

- {t("settings.ai.consent.error")} -

- ) : null} + {revoke.isError ? ( +

+ {t("settings.ai.consent.error")} +

+ ) : null} - {grant.isError ? ( -

- {t("settings.ai.consent.grantError")} -

- ) : null} + {grant.isError ? ( +

+ {t("settings.ai.consent.grantError")} +

+ ) : null} + + {/* One action row, last in the card; while confirming, Cancel sits + before the destructive confirm (design standards §12). */} + + {activeReceipt ? ( + confirming ? ( + <> + + + + ) : ( + + ) + ) : ( + + )} + +
-
+ ); } @@ -298,62 +312,65 @@ function DocumentReadingConsentRow({ if (!active) return null; return ( -
-

- {t("settings.ai.consent.documentReading.title")} -

-

- {t("settings.ai.consent.documentReading.activeSince", { + + + /> {confirming ? ( -

-

- {t("settings.ai.consent.documentReading.confirmBody")} -

- - -
- ) : ( - - )} +

+ {t("settings.ai.consent.documentReading.confirmBody")} +

+ ) : null} {revoke.isError ? (

{t("settings.ai.consent.error")}

) : null} -
+ + {confirming ? ( + <> + + + + ) : ( + + )} + + ); } diff --git a/src/components/settings/ai/ai-insights-card.tsx b/src/components/settings/ai/ai-insights-card.tsx index 9daea5ea4..f032d5f27 100644 --- a/src/components/settings/ai/ai-insights-card.tsx +++ b/src/components/settings/ai/ai-insights-card.tsx @@ -19,6 +19,7 @@ import { useQuery, useQueryClient } from "@tanstack/react-query"; import { Sparkles } from "lucide-react"; import { Badge } from "@/components/ui/badge"; +import { Skeleton } from "@/components/ui/skeleton"; import { MedicalDisclaimer } from "@/components/common/medical-disclaimer"; import { Label } from "@/components/ui/label"; import { NativeSelect } from "@/components/ui/native-select"; @@ -26,7 +27,6 @@ import { QueryErrorCard } from "@/components/ui/query-error-card"; import { SettingsCard } from "@/components/settings/settings-card"; import { SettingsCardHeader } from "@/components/settings/_card-header"; import { apiFetchRaw } from "@/lib/api/api-fetch"; -import { formatDateTime } from "@/lib/format"; import { useTranslations } from "@/lib/i18n/context"; import { aiInputDependentKeys, @@ -72,6 +72,7 @@ export function AiInsightsCard({ // card paints an honest error instead of a misleading form. const { data: insightsSettings, + isPending: settingsPending, isError: settingsError, refetch: refetchSettings, } = useQuery({ @@ -87,6 +88,7 @@ export function AiInsightsCard({ const { data: userProvider, + isPending: providerPending, isError: providerError, refetch: refetchProvider, } = useQuery({ @@ -102,6 +104,7 @@ export function AiInsightsCard({ const { data: chainData, + isPending: chainPending, isError: chainError, refetch: refetchChain, } = useQuery({ @@ -116,6 +119,12 @@ export function AiInsightsCard({ }); const hasReadError = settingsError || providerError || chainError; + // The cards below the provider card size themselves from these reads (the + // chain rows, the provider form). Painting them before the reads land + // moved every card under the cursor once they did, so the page holds one + // placeholder in the provider card until all three have settled. + const readsPending = + isAuthenticated && (settingsPending || providerPending || chainPending); function retryReads() { void refetchSettings(); void refetchProvider(); @@ -177,94 +186,95 @@ export function AiInsightsCard({ router.replace(`${pathname}?${params.toString()}`); } + // v1.42 — one card per concern. The page used to be a single "AI provider" + // card (repeating the page title) holding seven grey panels, three of them + // with their own primary Save, each left-aligned mid-card. Every concern is + // its own Settings card now, each with one action row at its foot + // (design standards §12/§13). return ( - - {/* The card used to render an icon-only header row (Sparkles + - status badges, no title), which left the tile unanchored next - to its titled siblings. It now follows the shared - `SettingsCardHeader` contract: icon column, title + description - in the content column, status badges top-right — and the body - starts on the card edge like every other Settings card. */} - - } - /> - -
+
+ + + } + /> {hasReadError ? ( + ) : readsPending ? ( + ) : ( <> - - - - - {/* v1.22 (#89) — per-user response timeout (mainly for slow - local/self-hosted backends). */} - - - {/* Read uploaded vault documents automatically with AI (opt-in). */} - - - {/* Use the operator's shared central Codex connection (opt-in; only - renders when the operator has connected it). */} - - - {/* The standing consent and the one control that withdraws it. It - sits last because it governs everything above rather than - configuring any single provider. */} - - - - queryClient.invalidateQueries({ - queryKey: queryKeys.insightsRoot(), - }) - } - onPrivacyChanged={() => - queryClient.invalidateQueries({ - queryKey: queryKeys.insightsRoot(), - }) - } - /> - - {/* v1.25 — the data-posture statement lives where the provider key is - configured: self-hosted, data stays on this instance, BYOK / local - model, no third-party analytics. One calm line, not a banner. */} - )} -
- + + + {hasReadError || readsPending ? null : ( + <> + + + {/* v1.22 (#89) — per-user response timeout (mainly for slow + local/self-hosted backends). */} + + + {/* Read uploaded vault documents automatically with AI (opt-in). */} + + + {/* Use the operator's shared central Codex connection (opt-in; only + renders when the operator has connected it). */} + + + {/* The standing consent and the one control that withdraws it. It + sits after the provider cards because it governs all of them. */} + + + + queryClient.invalidateQueries({ + queryKey: queryKeys.insightsRoot(), + }) + } + onPrivacyChanged={() => + queryClient.invalidateQueries({ + queryKey: queryKeys.insightsRoot(), + }) + } + // v1.25 — the data-posture statement lives where the provider + // key is configured: self-hosted, data stays on this instance, + // BYOK / local model, no third-party analytics. + footnote={} + /> + + )} +
); } @@ -295,12 +305,6 @@ function ProviderStatusBadges({ {t("settings.ai.connectionExpiredBadge")} )} - {settings?.lastInsightAt && ( - - {t("settings.lastGeneratedAt")}:{" "} - {formatDateTime(settings.lastInsightAt)} - - )} ); } @@ -324,13 +328,7 @@ function ActiveProviderSelect({ }) { const { t } = useTranslations(); return ( -
-

- {t("settings.ai.activeProviderHeading")} -

-

- {t("settings.ai.activeProviderBody")} -

+
@@ -342,7 +340,7 @@ function ActiveProviderSelect({ const next = e.target.value; if (isProviderType(next)) onChange(next); }} - className="mt-1 sm:max-w-md" + className="sm:max-w-md" > {PROVIDER_TYPES.map((p) => (
+ ); } diff --git a/src/components/settings/ai/central-codex-switch.tsx b/src/components/settings/ai/central-codex-switch.tsx index 010366b03..980de0bac 100644 --- a/src/components/settings/ai/central-codex-switch.tsx +++ b/src/components/settings/ai/central-codex-switch.tsx @@ -17,9 +17,12 @@ import { useState } from "react"; import { useMutation, useQueryClient } from "@tanstack/react-query"; -import { ShieldAlert } from "lucide-react"; +import { Share2, ShieldAlert } from "lucide-react"; import { Button } from "@/components/ui/button"; +import { SettingsCard } from "@/components/settings/settings-card"; +import { SettingsCardActions } from "@/components/settings/_card-actions"; +import { SettingsCardHeader } from "@/components/settings/_card-header"; import { Switch } from "@/components/ui/switch"; import { apiPatch } from "@/lib/api/api-fetch"; import { useTranslations } from "@/lib/i18n/context"; @@ -74,27 +77,21 @@ export function CentralCodexSwitch({ const checked = enabled || pendingEnable; return ( -
-
-
-

- {t("settings.ai.centralCodex.title")} -

-

- {t("settings.ai.centralCodex.subLabel")} -

-
- -
+ + + } + /> {pendingEnable ? (

{t("settings.ai.centralCodex.honesty")}

-
+ + - -
+
) : null} @@ -139,6 +136,6 @@ export function CentralCodexSwitch({ {t("settings.ai.errorGeneric")}

) : null} -
+
); } diff --git a/src/components/settings/ai/codex-provider-form.tsx b/src/components/settings/ai/codex-provider-form.tsx index 2aafecff3..eaf4098d2 100644 --- a/src/components/settings/ai/codex-provider-form.tsx +++ b/src/components/settings/ai/codex-provider-form.tsx @@ -10,6 +10,7 @@ import { Loader2, Sparkles, Trash2 } from "lucide-react"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; +import { SettingsCardActions } from "@/components/settings/_card-actions"; import { ConfirmButton } from "@/components/ui/confirm-button"; import { apiFetchRaw } from "@/lib/api/api-fetch"; import { formatDateTime } from "@/lib/format"; @@ -270,19 +271,21 @@ export function CodexProviderForm({ ) : ( - + + + )} {msg && ( diff --git a/src/components/settings/ai/compat-provider-form.tsx b/src/components/settings/ai/compat-provider-form.tsx index 3e621cf2f..8717457f8 100644 --- a/src/components/settings/ai/compat-provider-form.tsx +++ b/src/components/settings/ai/compat-provider-form.tsx @@ -12,6 +12,7 @@ import { useMutation, useQueryClient } from "@tanstack/react-query"; import { Loader2, Save } from "lucide-react"; import { Button } from "@/components/ui/button"; +import { SettingsCardActions } from "@/components/settings/_card-actions"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { PasswordInput } from "@/components/ui/password-input"; @@ -144,7 +145,13 @@ export function CompatProviderForm({ onChange={setReasoning} /> -
+ {msg && ( +

+ {msg} +

+ )} + +
- - {msg && ( -

- {msg} -

- )} + ); } diff --git a/src/components/settings/ai/fallback-chain-card.tsx b/src/components/settings/ai/fallback-chain-card.tsx index 80977ecfb..0b4d64e41 100644 --- a/src/components/settings/ai/fallback-chain-card.tsx +++ b/src/components/settings/ai/fallback-chain-card.tsx @@ -10,6 +10,7 @@ import { useMutation, useQueryClient } from "@tanstack/react-query"; import { ArrowDown, ArrowUp, + ListOrdered, Loader2, PlusCircle, RotateCcw, @@ -29,6 +30,9 @@ import { AlertDialogTrigger, } from "@/components/ui/alert-dialog"; import { Button } from "@/components/ui/button"; +import { SettingsCard } from "@/components/settings/settings-card"; +import { SettingsCardActions } from "@/components/settings/_card-actions"; +import { SettingsCardHeader } from "@/components/settings/_card-header"; import { NativeSelect } from "@/components/ui/native-select"; import { Switch } from "@/components/ui/switch"; import { apiPut } from "@/lib/api/api-fetch"; @@ -148,25 +152,19 @@ export function FallbackChainCard({ } return ( -
-
-

- {t("settings.ai.providerChain.title")} -

-

- {t("settings.ai.providerChain.description")} -

-
+ +
    {entries.map((entry, idx) => (
  • {idx + 1}. + {/* The name keeps its own line on a phone and the controls wrap + under it, right-aligned; squeezed beside five controls it + broke a three-word name over three lines. */} - toggle(idx)} - aria-label={t( - `settings.ai.providerChain.types.${entry.providerType}`, - )} - /> - - - +
    + toggle(idx)} + aria-label={t( + `settings.ai.providerChain.types.${entry.providerType}`, + )} + /> + + + +
  • ))}
@@ -234,20 +237,14 @@ export function FallbackChainCard({ {t("settings.ai.providerChain.addNoneAvailable")}

)} - +
+ + {msg && ( +

+ {msg} +

+ )} + + + ); } diff --git a/src/components/settings/ai/local-provider-form.tsx b/src/components/settings/ai/local-provider-form.tsx index 58d255a2e..9a2abd63b 100644 --- a/src/components/settings/ai/local-provider-form.tsx +++ b/src/components/settings/ai/local-provider-form.tsx @@ -9,6 +9,7 @@ import { useMutation, useQueryClient } from "@tanstack/react-query"; import { Loader2, Save } from "lucide-react"; import { Button } from "@/components/ui/button"; +import { SettingsCardActions } from "@/components/settings/_card-actions"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { NativeSelect } from "@/components/ui/native-select"; @@ -180,7 +181,13 @@ export function LocalProviderForm({ onChange={setReasoning} /> -
+ {msg && ( +

+ {msg} +

+ )} + +
- - {msg && ( -

- {msg} -

- )} + ); } diff --git a/src/components/settings/ai/openai-provider-form.tsx b/src/components/settings/ai/openai-provider-form.tsx index 84c5f63de..b598f9514 100644 --- a/src/components/settings/ai/openai-provider-form.tsx +++ b/src/components/settings/ai/openai-provider-form.tsx @@ -12,6 +12,7 @@ import { useMutation, useQueryClient } from "@tanstack/react-query"; import { Loader2, Save } from "lucide-react"; import { Button } from "@/components/ui/button"; +import { SettingsCardActions } from "@/components/settings/_card-actions"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { NativeSelect } from "@/components/ui/native-select"; @@ -198,7 +199,13 @@ export function OpenAIProviderForm({ )} -
+ {msg && ( +

+ {msg} +

+ )} + +
- - {msg && ( -

- {msg} -

- )} + ); } diff --git a/src/components/settings/ai/response-timeout-card.tsx b/src/components/settings/ai/response-timeout-card.tsx index 628e9c0e4..8af2bcadd 100644 --- a/src/components/settings/ai/response-timeout-card.tsx +++ b/src/components/settings/ai/response-timeout-card.tsx @@ -13,9 +13,12 @@ import { useRef, useState, type FormEvent } from "react"; import { useMutation, useQueryClient } from "@tanstack/react-query"; -import { Loader2, Save } from "lucide-react"; +import { Loader2, Save, Timer } from "lucide-react"; import { Button } from "@/components/ui/button"; +import { SettingsCard } from "@/components/settings/settings-card"; +import { SettingsCardActions } from "@/components/settings/_card-actions"; +import { SettingsCardHeader } from "@/components/settings/_card-header"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { AI_BUDGETS } from "@/lib/ai/ai-budgets"; @@ -100,20 +103,12 @@ export function ResponseTimeoutCard({ } return ( -
-
-

- {t("settings.ai.responseTimeoutHeading")} -

-

- {t("settings.ai.responseTimeoutBody", RESPONSE_TIMEOUT_COPY_PARAMS)} -

-
-
+ + +
@@ -129,10 +124,24 @@ export function ResponseTimeoutCard({ "settings.ai.responseTimeoutPlaceholder", RESPONSE_TIMEOUT_COPY_PARAMS, )} - className="mt-1 sm:max-w-xs" + aria-describedby="ai-response-timeout-hint" + className="sm:max-w-xs" /> + {/* The explainer is several sentences, so it rides under the field it + explains rather than in the one-sentence header slot. */} +

+ {t("settings.ai.responseTimeoutBody", RESPONSE_TIMEOUT_COPY_PARAMS)} +

-
+ {msg && ( +

+ {msg} +

+ )} +
- {msg && ( -

- {msg} -

- )} - + +
); } diff --git a/src/components/settings/ai/runtime-actions-row.tsx b/src/components/settings/ai/runtime-actions-row.tsx index c0a1ebcce..e91f09034 100644 --- a/src/components/settings/ai/runtime-actions-row.tsx +++ b/src/components/settings/ai/runtime-actions-row.tsx @@ -4,13 +4,15 @@ * Runtime actions — Test active provider, regenerate insights, raw-mode toggle. * ──────────────────────────────────────────────────────────────── */ -import { useState } from "react"; -import { Loader2, RefreshCw, Sparkles } from "lucide-react"; +import { useState, type ReactNode } from "react"; +import { Activity, Loader2, RefreshCw, Sparkles } from "lucide-react"; import { toast } from "sonner"; import { Button } from "@/components/ui/button"; import { Switch } from "@/components/ui/switch"; +import { SettingsCard } from "@/components/settings/settings-card"; import { SettingsCardActions } from "@/components/settings/_card-actions"; +import { SettingsCardHeader } from "@/components/settings/_card-header"; import { apiFetchRaw, apiPut } from "@/lib/api/api-fetch"; import { formatDateTime } from "@/lib/format"; import { useAiCapability } from "@/hooks/use-ai-capability"; @@ -30,6 +32,7 @@ export function RuntimeActionsRow({ lastInsightAt, onRegenerated, onPrivacyChanged, + footnote, }: { provider: ProviderType; userProvider: UserAIProvider | null | undefined; @@ -38,6 +41,8 @@ export function RuntimeActionsRow({ lastInsightAt: string | null; onRegenerated: () => void; onPrivacyChanged: () => void; + /** A closing statement rendered above the action row (the data posture). */ + footnote?: ReactNode; }) { const { t } = useTranslations(); // "Regenerate" writes the daily briefing, so it is offered only while the @@ -215,9 +220,14 @@ export function RuntimeActionsRow({ // §12 — the action row is the LAST thing in the block. The raw-data // setting and every status line (last-generated, test / regenerate // result) sit ABOVE it, never under it. -
+ + {showRegenerate && ( -
+

{t("settings.rawData")}

@@ -241,9 +251,6 @@ export function RuntimeActionsRow({
)} - {lastInsightLine && ( -

{lastInsightLine}

- )} {testMsg && (

)} - + {footnote} + +

+ ); } From 991764b0c8c1cede79514c850e0da19c6f0be4be Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 8 Oct 2026 20:20:03 +0200 Subject: [PATCH 2/6] Give settings cards one action row at the foot The TOTP enable, the source-priority reset, the biomarker and record add actions, and the about-me, profile-facts, emergency and condition saves move into SettingsCardActions, right-aligned and last in the card. The emergency Save waits for a change like its neighbours, an empty list leaves its add action to the empty state, and the source ladder keeps its up/down buttons side by side on a phone. --- .../biomarker-manager-actions.test.tsx | 82 +++++++++++ src/components/labs/biomarker-manager.tsx | 23 ++-- src/components/labs/labs-settings.tsx | 1 + .../__tests__/emergency-profile-save.test.tsx | 45 ++++++ .../records/about-me-note-manager.tsx | 78 +++++------ .../records/ai-profile-inclusion-manager.tsx | 5 +- .../records/allergy-free-text-note.tsx | 5 +- src/components/records/allergy-manager.tsx | 30 ++-- src/components/records/conditions-manager.tsx | 5 +- .../records/emergency-profile-manager.tsx | 16 ++- .../records/family-history-manager.tsx | 25 ++-- .../records/health-profile-facts-manager.tsx | 130 ++++++++++-------- .../settings/security-section/totp-card.tsx | 40 +++--- src/components/settings/sources-section.tsx | 98 ++++++------- 14 files changed, 374 insertions(+), 209 deletions(-) create mode 100644 src/components/labs/__tests__/biomarker-manager-actions.test.tsx create mode 100644 src/components/records/__tests__/emergency-profile-save.test.tsx diff --git a/src/components/labs/__tests__/biomarker-manager-actions.test.tsx b/src/components/labs/__tests__/biomarker-manager-actions.test.tsx new file mode 100644 index 000000000..ae1836687 --- /dev/null +++ b/src/components/labs/__tests__/biomarker-manager-actions.test.tsx @@ -0,0 +1,82 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; + +// The biomarker catalog card offers its add action once: the empty state's +// own button while the catalog is empty, otherwise one action row at the +// foot of the card (design standards §12). It used to carry a "Define" +// button above the list AND the empty state's "Define your first biomarker". + +const mocks = vi.hoisted(() => ({ + biomarkers: [] as Array>, +})); + +vi.mock("@tanstack/react-query", () => ({ + useQuery: () => ({ + data: { biomarkers: mocks.biomarkers }, + isLoading: false, + isError: false, + refetch: vi.fn(), + }), + useQueryClient: () => ({ invalidateQueries: vi.fn() }), + useMutation: () => ({ mutate: vi.fn(), isPending: false }), +})); +vi.mock("@/lib/api/api-fetch", () => ({ + apiDelete: vi.fn(), + apiGet: vi.fn(), + apiPut: vi.fn(), +})); +vi.mock("sonner", () => ({ toast: { success: vi.fn(), error: vi.fn() } })); +vi.mock("@/components/ui/responsive-sheet", () => ({ + ResponsiveSheet: () => null, +})); +vi.mock("@/components/data-list", () => ({ + DeleteButton: (props: { title: string }) => ( + + ), +})); + +import { I18nProvider } from "@/lib/i18n/context"; +import { BiomarkerManager } from "../biomarker-manager"; + +function render() { + return renderToStaticMarkup( + + + , + ); +} + +beforeEach(() => { + mocks.biomarkers = []; +}); + +describe(" action grammar", () => { + it("leaves the add action to the empty state while the catalog is empty", () => { + const html = render(); + expect(html.match(/Define your first biomarker/g)).toHaveLength(1); + expect(html).not.toContain('data-slot="settings-card-actions"'); + }); + + it("closes a populated catalog with one add action below the list", () => { + mocks.biomarkers = [ + { + id: "b1", + name: "LDL", + unit: "mg/dL", + lowerBound: null, + upperBound: 115, + panel: null, + hasContext: false, + context: null, + hidden: false, + createdAt: "2026-07-28T10:00:00.000Z", + updatedAt: "2026-07-28T10:00:00.000Z", + }, + ]; + const html = render(); + expect(html).not.toContain("Define your first biomarker"); + const row = html.indexOf('data-slot="settings-card-actions"'); + expect(row).toBeGreaterThan(html.indexOf("LDL")); + expect(html.slice(row).match(/>Define<\/button>/g)).toHaveLength(1); + }); +}); diff --git a/src/components/labs/biomarker-manager.tsx b/src/components/labs/biomarker-manager.tsx index 56c883b77..18cc9f5a5 100644 --- a/src/components/labs/biomarker-manager.tsx +++ b/src/components/labs/biomarker-manager.tsx @@ -7,6 +7,7 @@ import { toast } from "sonner"; import { DeleteButton } from "@/components/data-list"; import { Button } from "@/components/ui/button"; +import { SettingsCardActions } from "@/components/settings/_card-actions"; import { EmptyState } from "@/components/ui/empty-state"; import { QueryErrorCard } from "@/components/ui/query-error-card"; import { ResponsiveSheet } from "@/components/ui/responsive-sheet"; @@ -166,16 +167,6 @@ export function BiomarkerManager() { return (
-
-

- {t("labs.biomarker.managerDescription")} -

- -
- {isLoading ? (
{Array.from({ length: 3 }, (_, i) => ( @@ -217,6 +208,18 @@ export function BiomarkerManager() {
)} + {/* The add action is the card's one action row, last in the card + (design standards §12). An empty catalog leaves it to the empty + state's own button, so the card never offers the same action twice. */} + {!isLoading && !isError && markers.length > 0 ? ( + + + + ) : null} + diff --git a/src/components/records/__tests__/emergency-profile-save.test.tsx b/src/components/records/__tests__/emergency-profile-save.test.tsx new file mode 100644 index 000000000..6640644ce --- /dev/null +++ b/src/components/records/__tests__/emergency-profile-save.test.tsx @@ -0,0 +1,45 @@ +import { describe, expect, it, vi } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; + +// The emergency card's Save stayed enabled on an untouched, stored profile +// while every other Save on the medical-history page waits for a change. +// It now follows them: nothing edited, nothing to save. + +vi.mock("@tanstack/react-query", () => ({ + useQuery: () => ({ + data: { + bloodType: "A_POS", + organDonor: null, + advanceDirective: null, + contacts: "A contact", + implants: null, + note: null, + updatedAt: "2026-07-28T10:00:00.000Z", + }, + isLoading: false, + isError: false, + refetch: vi.fn(), + }), + useQueryClient: () => ({ + invalidateQueries: vi.fn(), + setQueryData: vi.fn(), + }), + useMutation: () => ({ mutate: vi.fn(), isPending: false }), +})); +vi.mock("@/lib/api/api-fetch", () => ({ apiGet: vi.fn(), apiPatch: vi.fn() })); +vi.mock("sonner", () => ({ toast: { success: vi.fn(), error: vi.fn() } })); + +import { I18nProvider } from "@/lib/i18n/context"; +import { EmergencyProfileManager } from "../emergency-profile-manager"; + +describe(" Save", () => { + it("stays disabled until a field differs from the stored profile", () => { + const html = renderToStaticMarkup( + + + , + ); + const row = html.slice(html.indexOf('data-slot="settings-card-actions"')); + expect(row).toMatch(/]*disabled=""[^>]*>[^]*?Save<\/button>/); + }); +}); diff --git a/src/components/records/about-me-note-manager.tsx b/src/components/records/about-me-note-manager.tsx index 66def03fc..2fa1059bf 100644 --- a/src/components/records/about-me-note-manager.tsx +++ b/src/components/records/about-me-note-manager.tsx @@ -32,6 +32,7 @@ import { Loader2, MessageCircleQuestion, Trash2 } from "lucide-react"; import { toast } from "sonner"; import { Button } from "@/components/ui/button"; +import { SettingsCardActions } from "@/components/settings/_card-actions"; import { QueryErrorRow } from "@/components/ui/query-error-row"; import { SettingsInfoTile } from "@/components/settings/_info-tile"; import { Label } from "@/components/ui/label"; @@ -137,9 +138,6 @@ export function AboutMeNoteManager() { disabled={disabled} onChange={(e) => setDraft(e.target.value)} /> -
- -

-

- {hasNote && ( - } - variant="outline" - size="sm" - className="min-h-11 sm:min-h-9" - title={t("settings.ai.aboutMe.clearTitle")} - body={t("settings.ai.aboutMe.clearBody")} - confirmLabel={t("settings.ai.aboutMe.clearConfirm")} - pending={save.isPending} - onConfirm={() => save.mutate({ aboutMe: "" })} - /> - )} - -
+ {/* What the note is for, under the field it describes; the action row + stays the last thing in the card (design standards §12). */} +

+ {t("settings.ai.aboutMe.hint")} {t("settings.ai.aboutMe.profileHint")} +

{pendingQuestions.length > 0 && ( @@ -218,9 +188,39 @@ export function AboutMeNoteManager() { /> )} -

- {t("settings.ai.aboutMe.hint")} {t("settings.ai.aboutMe.profileHint")} -

+ + {hasNote && ( + } + variant="outline" + size="sm" + className="min-h-11 sm:min-h-9" + title={t("settings.ai.aboutMe.clearTitle")} + body={t("settings.ai.aboutMe.clearBody")} + confirmLabel={t("settings.ai.aboutMe.clearConfirm")} + pending={save.isPending} + onConfirm={() => save.mutate({ aboutMe: "" })} + /> + )} + +
); } diff --git a/src/components/records/ai-profile-inclusion-manager.tsx b/src/components/records/ai-profile-inclusion-manager.tsx index 1b8fadee4..8f0c3e88b 100644 --- a/src/components/records/ai-profile-inclusion-manager.tsx +++ b/src/components/records/ai-profile-inclusion-manager.tsx @@ -6,6 +6,7 @@ import { Loader2 } from "lucide-react"; import { toast } from "sonner"; import { Button } from "@/components/ui/button"; +import { SettingsCardActions } from "@/components/settings/_card-actions"; import { toastWrittenOutcome } from "@/components/outcome/outcome-toast"; import { QueryErrorCard } from "@/components/ui/query-error-card"; import { Switch } from "@/components/ui/switch"; @@ -136,7 +137,7 @@ export function AiProfileInclusionManager() { ); })}
-
+
+
); } diff --git a/src/components/records/allergy-free-text-note.tsx b/src/components/records/allergy-free-text-note.tsx index 11fd89acd..1e0d63416 100644 --- a/src/components/records/allergy-free-text-note.tsx +++ b/src/components/records/allergy-free-text-note.tsx @@ -27,6 +27,7 @@ import { Loader2 } from "lucide-react"; import { toast } from "sonner"; import { Button } from "@/components/ui/button"; +import { SettingsCardActions } from "@/components/settings/_card-actions"; import { QueryErrorRow } from "@/components/ui/query-error-row"; import { Label } from "@/components/ui/label"; import { useTranslations } from "@/lib/i18n/context"; @@ -125,7 +126,7 @@ export function AllergyFreeTextNote() { onRetry={() => query.refetch()} /> ) : ( -
+
+ )}
); diff --git a/src/components/records/allergy-manager.tsx b/src/components/records/allergy-manager.tsx index deef991e8..31de01954 100644 --- a/src/components/records/allergy-manager.tsx +++ b/src/components/records/allergy-manager.tsx @@ -8,6 +8,7 @@ import { toast } from "sonner"; import { DeleteButton } from "@/components/data-list"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; +import { SettingsCardActions } from "@/components/settings/_card-actions"; import { EmptyState } from "@/components/ui/empty-state"; import { QueryErrorCard } from "@/components/ui/query-error-card"; import { ResponsiveSheet } from "@/components/ui/responsive-sheet"; @@ -120,18 +121,6 @@ export function AllergyManager() { return (
-
-

- {t("records.allergies.description")} -

- {rows.length > 0 ? ( - - ) : null} -
- {isLoading ? (
{Array.from({ length: 2 }, (_, i) => ( @@ -156,6 +145,23 @@ export function AllergyManager() {
    {rows.map(renderRow)}
)} + {/* The add action closes the list (design standards §12); an empty + list leaves it to the empty state's own button. Outline here, + because the card's primary is the free-text note's Save below. */} + {!isLoading && !isError && rows.length > 0 ? ( + + + + ) : null} + +
+ )}
diff --git a/src/components/records/emergency-profile-manager.tsx b/src/components/records/emergency-profile-manager.tsx index ef27daa44..7bf147763 100644 --- a/src/components/records/emergency-profile-manager.tsx +++ b/src/components/records/emergency-profile-manager.tsx @@ -6,6 +6,7 @@ import { Loader2 } from "lucide-react"; import { toast } from "sonner"; import { Button } from "@/components/ui/button"; +import { SettingsCardActions } from "@/components/settings/_card-actions"; import { Label } from "@/components/ui/label"; import { QueryErrorCard } from "@/components/ui/query-error-card"; import { Textarea } from "@/components/ui/textarea"; @@ -96,6 +97,13 @@ export function EmergencyProfileManager() { } const disabled = query.isLoading || save.isPending || current === null; + // Save stays off until a field differs from the stored profile, like every + // other Save on this page; a fresh draft that matches the record is not a + // change. + const dirty = + draft !== null && + query.data != null && + JSON.stringify(draft) !== JSON.stringify(draftFromDto(query.data)); const set = (patch: Partial) => setDraft((prev) => ({ ...(prev ?? draftFromDto(query.data!)), ...patch })); @@ -113,7 +121,7 @@ export function EmergencyProfileManager() { disabled={disabled} onValueChange={(next) => set({ [key]: next } as Partial)} > - + @@ -170,12 +178,12 @@ export function EmergencyProfileManager() { {t("records.emergency.reportHint")}

-
+ -
+ ); } diff --git a/src/components/records/family-history-manager.tsx b/src/components/records/family-history-manager.tsx index 1b865bc8a..2f84fff10 100644 --- a/src/components/records/family-history-manager.tsx +++ b/src/components/records/family-history-manager.tsx @@ -7,6 +7,7 @@ import { toast } from "sonner"; import { DeleteButton } from "@/components/data-list"; import { Button } from "@/components/ui/button"; +import { SettingsCardActions } from "@/components/settings/_card-actions"; import { EmptyState } from "@/components/ui/empty-state"; import { QueryErrorCard } from "@/components/ui/query-error-card"; import { ResponsiveSheet } from "@/components/ui/responsive-sheet"; @@ -105,18 +106,6 @@ export function FamilyHistoryManager() { return (
-
-

- {t("records.family.description")} -

- {rows.length > 0 ? ( - - ) : null} -
- {isLoading ? (
{Array.from({ length: 2 }, (_, i) => ( @@ -141,6 +130,18 @@ export function FamilyHistoryManager() {
    {rows.map(renderRow)}
)} + {/* The add action closes the list as the card's action row (design + standards §12); an empty list leaves it to the empty state's own + button. */} + {!isLoading && !isError && rows.length > 0 ? ( + + + + ) : null} + editDraft(kind, next)} > - + - {failed ? ( - - {t("records.profileFacts.saveKindError")} - - ) : ( - - )} - {current ? ( - remove.mutate(current)} - pending={ - remove.isPending && remove.variables?.id === current.id - } - disabled={disabled} - variant="ghost" - size="sm" - className="text-destructive min-h-11 sm:min-h-9" - icon={} - slot={`health-profile-fact-remove-${kind}`} - /> - ) : null} -
+ {failed || current ? ( +
+ {failed ? ( + + {t("records.profileFacts.saveKindError")} + + ) : ( + + )} + {current ? ( + remove.mutate(current)} + pending={ + remove.isPending && remove.variables?.id === current.id + } + disabled={disabled} + variant="ghost" + size="sm" + className="text-destructive min-h-11 sm:min-h-9" + icon={} + slot={`health-profile-fact-remove-${kind}`} + /> + ) : null} +
+ ) : null}
); })} - {/* One Save for the whole tile. It writes only the dirty kinds and stays - disabled until at least one field differs from its stored value. */} -
- -
- {closedHistory.length > 0 && (

@@ -412,6 +398,28 @@ export function HealthProfileFactsManager() {

)} + + {/* One Save for the whole tile. It writes only the dirty kinds and stays + disabled until at least one field differs from its stored value. It + is the last thing in the card, below the history (design standards + §12). */} + + + ); } diff --git a/src/components/settings/security-section/totp-card.tsx b/src/components/settings/security-section/totp-card.tsx index 7495a9c93..e8346359a 100644 --- a/src/components/settings/security-section/totp-card.tsx +++ b/src/components/settings/security-section/totp-card.tsx @@ -265,21 +265,25 @@ export function TotpCard({
{/* ── Not enabled, no wizard: the enable CTA ── */} + {/* The enable CTA is the card's primary, right-aligned in the one + action row like every other Settings card (design standards §12). */} {!enabled && !setup && !freshCodes && ( - + + + )} -
+ @@ -544,7 +548,7 @@ export function TotpCard({ -
+
)} diff --git a/src/components/settings/sources-section.tsx b/src/components/settings/sources-section.tsx index 254db3663..8e88cd081 100644 --- a/src/components/settings/sources-section.tsx +++ b/src/components/settings/sources-section.tsx @@ -297,21 +297,6 @@ export function SourcesSection() { icon={Layers} title={t("settings.sections.sources.cardTitle")} description={t("settings.sections.sources.help")} - status={ - } - label={t("settings.sections.sources.resetDefaults")} - title={t("settings.sections.sources.resetDefaultsTitle")} - body={t("settings.sections.sources.resetDefaultsBody")} - confirmLabel={t("settings.sections.sources.resetDefaultsConfirm")} - disabled={saveMutation.isPending} - pending={resetMutation.isPending} - onConfirm={() => resetMutation.mutate()} - /> - } />

{t("settings.sections.sources.helpDetail")}

@@ -346,12 +331,12 @@ export function SourcesSection() { {t(MEASUREMENT_SOURCE_SETTINGS_LABEL_KEYS[source])} - {/* v1.4.27 R3d MB2 — stack up/down vertically on - narrow viewports so each button keeps the - 44 px floor without crowding the row. From - `sm:` up the two buttons sit side-by-side - again to preserve the desktop layout. */} -
+ {/* Up/down sit side by side at every width: two + 44 px buttons leave a 390 px row ~170 px for the + source name. Stacking them on phones (v1.4.27 + MB2) doubled every row and pushed this page past + twelve thousand pixels. */} +
- - - )} + {/* One action row, last in the card (design standards §12). The + reset used to ride the header's status slot, which carries + status, not actions; Cancel + Save join it while a draft is open. */} + + } + label={t("settings.sections.sources.resetDefaults")} + title={t("settings.sections.sources.resetDefaultsTitle")} + body={t("settings.sections.sources.resetDefaultsBody")} + confirmLabel={t("settings.sections.sources.resetDefaultsConfirm")} + disabled={saveMutation.isPending} + pending={resetMutation.isPending} + onConfirm={() => resetMutation.mutate()} + /> + {dirty && priority ? ( + <> + + + + ) : null} + {/* v1.28 shipped `` here; the 2026-07-17 UX/IA From 2dda933d77e173403813d0c3b77fd0ea5f03735d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 8 Oct 2026 20:20:03 +0200 Subject: [PATCH 3/6] Align layout settings cards and the insights arrange editor The insights arrange list becomes a Settings card with its own header and a Save that waits for a change; the self-link back to the page it sits on and the focus grab on load are gone. The medications view card takes the header shape the labs and documents cards use, the order card shares their title and loads with row placeholders, and the documents view card uses their icon. --- .../insights-edit-mode-manager.test.tsx | 22 +++ .../insights/insights-edit-mode.tsx | 176 ++++++++++++------ .../__tests__/medications-section.test.tsx | 3 +- src/components/settings/documents-section.tsx | 4 +- .../insights-overview-arrange-section.tsx | 33 ++-- .../settings/medications-section.tsx | 39 ++-- 6 files changed, 188 insertions(+), 89 deletions(-) diff --git a/src/components/insights/__tests__/insights-edit-mode-manager.test.tsx b/src/components/insights/__tests__/insights-edit-mode-manager.test.tsx index d39d4fad5..0b4a4d599 100644 --- a/src/components/insights/__tests__/insights-edit-mode-manager.test.tsx +++ b/src/components/insights/__tests__/insights-edit-mode-manager.test.tsx @@ -60,4 +60,26 @@ describe(" — detail-page manager retired (v1.15.20)", () => expect(html).toContain('data-slot="insights-edit-section-row"'); expect(html).toContain('data-slot="insights-edit-section-eye"'); }); + + it("as a Settings card: settings header, one action row last, no self-link", () => { + // On /settings/layout/insights the editor is a standing card. The + // pointer "Manage chips in Settings" would link to the page it is on, + // and Reset + Done beside the title broke the one-action-row rule. + const html = render( + {}} + />, + ); + expect(html).not.toContain('data-slot="insights-edit-manage-link"'); + expect(html).toContain('id="insights-overview-arrange-title"'); + const row = html.indexOf('data-slot="settings-card-actions"'); + expect(row).toBeGreaterThan( + html.lastIndexOf('data-slot="insights-edit-section-row"'), + ); + expect(html.slice(row)).toContain('data-slot="insights-edit-done"'); + expect(html.slice(row)).toContain(">Save"); + }); }); diff --git a/src/components/insights/insights-edit-mode.tsx b/src/components/insights/insights-edit-mode.tsx index e16191a35..7ccf96c92 100644 --- a/src/components/insights/insights-edit-mode.tsx +++ b/src/components/insights/insights-edit-mode.tsx @@ -4,7 +4,14 @@ import { useEffect, useMemo, useRef, useState } from "react"; import Link from "next/link"; import { useMutation, useQueryClient } from "@tanstack/react-query"; import { toast } from "sonner"; -import { Eye, EyeOff, GripVertical, Loader2, RotateCcw } from "lucide-react"; +import { + Eye, + EyeOff, + GripVertical, + LayoutGrid, + Loader2, + RotateCcw, +} from "lucide-react"; import { DndContext, KeyboardSensor, @@ -24,6 +31,8 @@ import { CSS } from "@dnd-kit/utilities"; import { Button } from "@/components/ui/button"; import { SettingsCard } from "@/components/settings/settings-card"; +import { SettingsCardActions } from "@/components/settings/_card-actions"; +import { SettingsCardHeader } from "@/components/settings/_card-header"; import { ConfirmButton } from "@/components/ui/confirm-button"; import { cn } from "@/lib/utils"; import { prefersReducedMotion } from "@/lib/charts/reduced-motion"; @@ -88,13 +97,24 @@ interface InsightsEditModeProps { gatedOffSectionIds: ReadonlySet; /** Close edit mode (the "Fertig" / save-success path calls this). */ onClose: () => void; + /** + * `page` (default) is the inline editor the Insights overview opens: its own + * title, Reset + Done beside it, and a pointer to the Settings pill manager. + * `settings` is the same editor as a standing Settings card: a settings + * header, the actions in one row at the bottom (design standards §12), no + * pointer back to the page it already sits on, and no focus grab on mount — + * the card is part of the page, not a surface the user just opened. + */ + variant?: "page" | "settings"; } export function InsightsEditMode({ layout, gatedOffSectionIds, onClose, + variant = "page", }: InsightsEditModeProps) { + const inSettings = variant === "settings"; const { t } = useTranslations(); const queryClient = useQueryClient(); @@ -115,8 +135,9 @@ export function InsightsEditMode({ // "Anpassen" toggle on close via the page's onClose handler. const headingRef = useRef(null); useEffect(() => { + if (inSettings) return; headingRef.current?.focus(); - }, []); + }, [inSettings]); const sensors = useSensors( useSensor(PointerSensor, { activationConstraint: { distance: 6 } }), @@ -186,6 +207,15 @@ export function InsightsEditMode({ }); const busy = saveMutation.isPending || resetMutation.isPending; + // The Settings card's Save waits for a change, like the pill-order Save + // beside it; the overview's inline editor keeps "Done" live because it + // also closes the editor. + const arrangement = (rows: readonly InsightsSectionConfig[]) => + [...rows] + .sort((a, b) => a.order - b.order) + .map((r) => `${r.id}:${r.visible ? 1 : 0}`) + .join(","); + const dirty = arrangement(draft.sections) !== arrangement(layout.sections); const sections = useMemo( () => [...draft.sections].sort((a, b) => a.order - b.order), @@ -215,46 +245,55 @@ export function InsightsEditMode({ return ( -
-
-

- {t("insights.editMode.title")} -

-

- {t("insights.editMode.description")} -

+ {inSettings ? ( + + ) : ( +
+
+

+ {t("insights.editMode.title")} +

+

+ {t("insights.editMode.description")} +

+
+
+ } + label={t("insights.editMode.reset")} + title={t("insights.editMode.resetTitle")} + body={t("insights.editMode.resetBody")} + confirmLabel={t("insights.editMode.resetConfirm")} + disabled={busy && !resetMutation.isPending} + pending={resetMutation.isPending} + onConfirm={() => resetMutation.mutate()} + /> + +
-
- } - label={t("insights.editMode.reset")} - title={t("insights.editMode.resetTitle")} - body={t("insights.editMode.resetBody")} - confirmLabel={t("insights.editMode.resetConfirm")} - disabled={busy && !resetMutation.isPending} - pending={resetMutation.isPending} - onConfirm={() => resetMutation.mutate()} - /> - -
-
+ )} {allHidden && (

- {/* v1.15.20 — the per-detail-page manager (sort + show/hide) lives on - Settings → Insights; the disclosure this card used to nest under - the Vitals row duplicated it. Keep a quiet pointer instead. */} -

- - {t("insights.editMode.manageInSettings")} - -

+ {inSettings ? ( + + } + label={t("insights.editMode.reset")} + title={t("insights.editMode.resetTitle")} + body={t("insights.editMode.resetBody")} + confirmLabel={t("insights.editMode.resetConfirm")} + disabled={busy && !resetMutation.isPending} + pending={resetMutation.isPending} + onConfirm={() => resetMutation.mutate()} + /> + + + ) : ( + // v1.15.20 — the per-detail-page manager (sort + show/hide) lives on + // Settings → Insights; the disclosure this card used to nest under + // the Vitals row duplicated it. Keep a quiet pointer instead. +

+ + {t("insights.editMode.manageInSettings")} + +

+ )} ); } diff --git a/src/components/settings/__tests__/medications-section.test.tsx b/src/components/settings/__tests__/medications-section.test.tsx index f44a9f36d..8704f98e9 100644 --- a/src/components/settings/__tests__/medications-section.test.tsx +++ b/src/components/settings/__tests__/medications-section.test.tsx @@ -97,7 +97,8 @@ describe(" — SSR smoke", () => { it("renders the German copy end-to-end", () => { const html = render("de"); - expect(html).toContain("Reihenfolge anpassen"); + // The order card shares its title with the Labs order card. + expect(html).toContain(">Reihenfolge<"); expect(html).toContain( "Wähle, ob deine Medikamente als Karten oder als Tabelle angezeigt werden.", ); diff --git a/src/components/settings/documents-section.tsx b/src/components/settings/documents-section.tsx index f1e9b4f12..35f60b49b 100644 --- a/src/components/settings/documents-section.tsx +++ b/src/components/settings/documents-section.tsx @@ -9,7 +9,7 @@ * shape): the card/list view and the month arrangement. Both persist per user * through `/api/documents/inbound/layout`. */ -import { Eye, Rows3, WrapText } from "lucide-react"; +import { LayoutGrid, Rows3, WrapText } from "lucide-react"; import { SettingsCardHeader } from "@/components/settings/_card-header"; import { SettingsCard } from "@/components/settings/settings-card"; @@ -26,7 +26,7 @@ export function DocumentsSection() {
} diff --git a/src/components/settings/insights-overview-arrange-section.tsx b/src/components/settings/insights-overview-arrange-section.tsx index 5acb424cb..5ed574433 100644 --- a/src/components/settings/insights-overview-arrange-section.tsx +++ b/src/components/settings/insights-overview-arrange-section.tsx @@ -9,8 +9,8 @@ import { useAuth } from "@/hooks/use-auth"; import { useAiCapability } from "@/hooks/use-ai-capability"; import { useInsightsLayoutQuery } from "@/hooks/use-insights-layout"; import { InsightsEditMode } from "@/components/insights/insights-edit-mode"; -import { SectionHeading } from "@/components/ui/section-heading"; import { SettingsCard } from "@/components/settings/settings-card"; +import { SettingsCardHeader } from "@/components/settings/_card-header"; import { INSIGHTS_SECTION_IDS, type InsightsSectionId, @@ -59,29 +59,28 @@ export function InsightsOverviewArrangeSection({ id }: { id?: string }) { id={id} data-slot="insights-overview-arrange-section" aria-labelledby="insights-overview-arrange-title" - className="space-y-3" + className="scroll-mt-28" > - - {/* Gate the editor mount until the layout GET settles. The editor seeds its draft once from `layout` on mount, which is the canonical default - while in flight; mounting it early would let a "Fertig" save flush - defaults over the user's real saved layout (the same QA-L1 gate the - mother page applies to its "Anpassen" toggle). */} + while in flight; mounting it early would let a Save flush defaults + over the user's real saved layout (the same QA-L1 gate the mother + page applies to its "Anpassen" toggle). The loading card paints the + same header the loaded card carries (design standards §13), so the + page does not reflow under the cursor when the list arrives. */} {isLoading ? ( - - + + + ) : ( {}} diff --git a/src/components/settings/medications-section.tsx b/src/components/settings/medications-section.tsx index 6fc8ec40f..d02f97ed4 100644 --- a/src/components/settings/medications-section.tsx +++ b/src/components/settings/medications-section.tsx @@ -1,7 +1,7 @@ "use client"; import { useQuery } from "@tanstack/react-query"; -import { LayoutGrid, Loader2, Pill } from "lucide-react"; +import { LayoutGrid, ListOrdered } from "lucide-react"; import { MedicationOrderEditor, @@ -9,6 +9,7 @@ import { } from "@/components/medications/medication-order-editor"; import { MedicationViewToggle } from "@/components/medications/medication-view-toggle"; import { SettingsCard } from "@/components/settings/settings-card"; +import { Skeleton } from "@/components/ui/skeleton"; import { SettingsCardHeader } from "@/components/settings/_card-header"; import { InjectionSitesCard } from "@/components/settings/injection-sites-card"; import { useAuth } from "@/hooks/use-auth"; @@ -81,33 +82,39 @@ export function MedicationsSection() { {/* View preference — cards vs table. The shared header toggle writes optimistically, so there is no Save button here. */} + {/* Same shape as the Labs and Documents view cards: the hint is the + header description, the toggle sits in the status slot. */} -
-

- {t("medications.viewToggleHint")} -

- {isLayoutLoading ? ( - - ) : ( + description={t("medications.viewToggleHint")} + status={ + // Painted from the first frame, like the Labs and Documents + // toggles: a spinner the width of one icon swapped for a + // two-button toggle moved the cards below it. - )} -
+ } + />
{/* Manual order — applies to both list views. */} {isLoading || isLayoutLoading ? ( -
- - {t("common.loading")} + // Row-shaped placeholders instead of a one-line spinner, so the + // card below moves less when the list arrives. +
+ {Array.from({ length: 3 }, (_, i) => ( + + ))}
) : ( From 37f922af9733ab0b09be0ffdf0b4e7b7f0cc741a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 8 Oct 2026 20:20:03 +0200 Subject: [PATCH 4/6] Fix settings field rows, defaults and narrow layouts Module rows drop the field-label colon and inset, the reminder number fields align, target defaults print at their own precision, the appointment offer wraps instead of squeezing to one word per line, the export format legend matches its neighbour label, and the profile-photo explainer reads as body text. --- .../__tests__/password-card-placement.test.tsx | 2 +- .../__tests__/thresholds-editor-glucose-unit.test.tsx | 9 +++++++++ .../settings/account-section/avatar-section.tsx | 4 ++-- src/components/settings/low-stock-card.tsx | 8 ++++---- src/components/settings/module-toggle-row.tsx | 5 ++++- src/components/settings/mood-reminder-card.tsx | 6 +++--- src/components/settings/password-card.tsx | 4 ++-- .../settings/report-selection/report-visit-offer.tsx | 9 ++++++--- .../settings/report-selection/selection-panel.tsx | 2 +- src/components/settings/thresholds-editor-section.tsx | 11 +++++++++-- 10 files changed, 41 insertions(+), 19 deletions(-) diff --git a/src/components/settings/__tests__/password-card-placement.test.tsx b/src/components/settings/__tests__/password-card-placement.test.tsx index e08ced3b9..57618e9ff 100644 --- a/src/components/settings/__tests__/password-card-placement.test.tsx +++ b/src/components/settings/__tests__/password-card-placement.test.tsx @@ -113,7 +113,7 @@ describe("change-password card placement", () => { "utf8", ); for (const marker of [ - "settings.passwordReset", + "settings.passwordTitle", "settings.changePassword", "/api/auth/password", // A change-password form is the pair current + new. The Account form diff --git a/src/components/settings/__tests__/thresholds-editor-glucose-unit.test.tsx b/src/components/settings/__tests__/thresholds-editor-glucose-unit.test.tsx index 362d029b6..134ed4e60 100644 --- a/src/components/settings/__tests__/thresholds-editor-glucose-unit.test.tsx +++ b/src/components/settings/__tests__/thresholds-editor-glucose-unit.test.tsx @@ -73,4 +73,13 @@ describe(" glucose unit", () => { expect(html).toContain('value="72"'); expect(html).toContain('min="40"'); }); + + it("prints a whole-number default without a trailing .0", () => { + // A bound reads at its own precision: the fixed one decimal printed + // "70.0–99.0 mg/dL" and "8,000.0–15,000.0 steps". + authUser.current = { glucoseUnit: "mg/dL", unitPreference: "metric" }; + const html = render(); + expect(html).toMatch(/70–99 mg\/dL/); + expect(html).not.toMatch(/70\.0–99\.0/); + }); }); diff --git a/src/components/settings/account-section/avatar-section.tsx b/src/components/settings/account-section/avatar-section.tsx index 2ed7576a1..1da325672 100644 --- a/src/components/settings/account-section/avatar-section.tsx +++ b/src/components/settings/account-section/avatar-section.tsx @@ -114,7 +114,7 @@ export function AvatarSection() { Two strings rather than one merged key: the same wording is the upload's own constraint copy, and a translator keeps the shorter unit either way. */} -

+

{t("settings.avatar.description")} {t("settings.avatar.hint")}

@@ -171,7 +171,7 @@ export function AvatarSection() { {msg && (

diff --git a/src/components/settings/low-stock-card.tsx b/src/components/settings/low-stock-card.tsx index df910d221..d6a49c46e 100644 --- a/src/components/settings/low-stock-card.tsx +++ b/src/components/settings/low-stock-card.tsx @@ -244,7 +244,7 @@ export function LowStockCard({ />

{t("notifications.lowStock.detail")}

{enabled && ( -
+
@@ -254,7 +254,7 @@ export function LowStockCard({ inputMode="numeric" min={MIN_DAYS} max={MAX_DAYS} - className="w-auto" + className="w-24 text-right tabular-nums" value={draft ?? String(days)} disabled={!isAuthenticated || saving} onChange={(e) => setDraft(e.target.value)} @@ -275,7 +275,7 @@ export function LowStockCard({ own supply tab. */} {enabled && (
-
+
@@ -285,7 +285,7 @@ export function LowStockCard({ inputMode="numeric" min={MIN_LEAD} max={MAX_LEAD} - className="w-auto" + className="w-24 text-right tabular-nums" value={leadDraft ?? String(leadDays)} disabled={!isAuthenticated || saving} onChange={(e) => setLeadDraft(e.target.value)} diff --git a/src/components/settings/module-toggle-row.tsx b/src/components/settings/module-toggle-row.tsx index 912739990..557fa074b 100644 --- a/src/components/settings/module-toggle-row.tsx +++ b/src/components/settings/module-toggle-row.tsx @@ -98,7 +98,10 @@ export function ModuleToggleRow({ aria-hidden="true" />
-