From 304f0c705727e24e56b597dfbd208b5ecb273b66 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Tue, 30 Jun 2026 12:22:08 +0200 Subject: [PATCH 01/15] refactor(settings): route every card header through SettingsCardHeader MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Hand-rolled section headers had drifted into a dozen variants — some without an icon, some with a text-sm description instead of the shared text-xs, the data-reset title once even tinted red. Route the remaining ad-hoc headers through the SettingsCardHeader primitive so icon sizing, title weight and description size live in one place. Give the integrations connections group (the data sources) its own labelled header for parity with the delivery-channels group below it. A coverage test now globs every settings *-section.tsx / *-card.tsx and fails on any hand-rolled

outside the primitive, with a short allowlist for the collapsible-toggle security cards and the layout section pending its hub rebuild. --- messages/de.json | 2 + messages/en.json | 2 + messages/es.json | 2 + messages/fr.json | 2 + messages/it.json | 2 + messages/pl.json | 2 + .../advanced-account-delete.test.tsx | 11 +- .../settings-card-header-coverage.test.ts | 84 ++++++ .../settings/account-section/index.tsx | 36 ++- src/components/settings/advanced-section.tsx | 244 +++++++++--------- .../settings/dashboard-layout-section.tsx | 35 ++- .../settings/gesundheitsakte-section.tsx | 16 +- .../insights-overview-arrange-section.tsx | 20 +- .../settings/insights-pill-order-section.tsx | 45 ++-- .../settings/integrations-section.tsx | 30 ++- .../settings/notifications-section.tsx | 18 +- 16 files changed, 323 insertions(+), 228 deletions(-) create mode 100644 src/components/settings/__tests__/settings-card-header-coverage.test.ts diff --git a/messages/de.json b/messages/de.json index e25fd4009..81fd5f333 100644 --- a/messages/de.json +++ b/messages/de.json @@ -4039,6 +4039,8 @@ "title": "Integrationen", "description": "Verbundene Dienste, Zustellkanäle und die Quellen-Priorität an einem Ort.", "subtitle": "Datenquellen und Geräte verbinden und verwalten.", + "connectionsHeading": "Verbindungen", + "connectionsDescription": "Datenquellen und Geräte verbinden und verwalten.", "channelsHeading": "Zustellkanäle", "channelsDescription": "Wohin Erinnerungen und Hinweise zugestellt werden." }, diff --git a/messages/en.json b/messages/en.json index ab971937a..288535375 100644 --- a/messages/en.json +++ b/messages/en.json @@ -4039,6 +4039,8 @@ "title": "Integrations", "description": "Connected services, delivery channels, and source priority in one place.", "subtitle": "Connect and manage your data sources and devices.", + "connectionsHeading": "Connections", + "connectionsDescription": "Connect and manage your data sources and devices.", "channelsHeading": "Delivery channels", "channelsDescription": "Where reminders and alerts are delivered." }, diff --git a/messages/es.json b/messages/es.json index 61dae0946..4433313fa 100644 --- a/messages/es.json +++ b/messages/es.json @@ -4039,6 +4039,8 @@ "title": "Integraciones", "description": "Servicios conectados, canales de entrega y prioridad de fuentes en un solo lugar.", "subtitle": "Conecta y gestiona tus fuentes de datos y dispositivos.", + "connectionsHeading": "Conexiones", + "connectionsDescription": "Conecta y gestiona tus fuentes de datos y dispositivos.", "channelsHeading": "Canales de entrega", "channelsDescription": "Dónde se entregan los recordatorios y avisos." }, diff --git a/messages/fr.json b/messages/fr.json index 1bdaa1ba6..07dcf3613 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -4039,6 +4039,8 @@ "title": "Intégrations", "description": "Services connectés, canaux de diffusion et priorité des sources au même endroit.", "subtitle": "Connectez et gérez vos sources de données et appareils.", + "connectionsHeading": "Connexions", + "connectionsDescription": "Connectez et gérez vos sources de données et appareils.", "channelsHeading": "Canaux de diffusion", "channelsDescription": "Où sont envoyés les rappels et alertes." }, diff --git a/messages/it.json b/messages/it.json index 35ad15289..8722951d1 100644 --- a/messages/it.json +++ b/messages/it.json @@ -4039,6 +4039,8 @@ "title": "Integrazioni", "description": "Servizi collegati, canali di consegna e priorità delle fonti in un unico posto.", "subtitle": "Collega e gestisci le tue fonti di dati e i dispositivi.", + "connectionsHeading": "Connessioni", + "connectionsDescription": "Collega e gestisci le tue fonti di dati e i dispositivi.", "channelsHeading": "Canali di recapito", "channelsDescription": "Dove vengono recapitati promemoria e avvisi." }, diff --git a/messages/pl.json b/messages/pl.json index f5d479989..1b3521565 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -4039,6 +4039,8 @@ "title": "Integracje", "description": "Połączone usługi, kanały dostarczania i priorytet źródeł w jednym miejscu.", "subtitle": "Połącz i zarządzaj źródłami danych oraz urządzeniami.", + "connectionsHeading": "Połączenia", + "connectionsDescription": "Połącz i zarządzaj źródłami danych oraz urządzeniami.", "channelsHeading": "Kanały dostarczania", "channelsDescription": "Gdzie dostarczane są przypomnienia i powiadomienia." }, diff --git a/src/components/settings/__tests__/advanced-account-delete.test.tsx b/src/components/settings/__tests__/advanced-account-delete.test.tsx index fd1c4e711..fd04efba8 100644 --- a/src/components/settings/__tests__/advanced-account-delete.test.tsx +++ b/src/components/settings/__tests__/advanced-account-delete.test.tsx @@ -53,14 +53,17 @@ describe(" danger-zone shaping (v1.4.43 M3 + L5)", () => { it("L5: the data-reset card title is neutral foreground (no destructive red)", () => { const html = render(); // The reset card's title used to ship with `text-destructive`; the - // v1.4.43 fix pins it on `text-foreground`. We don't care if other - // strings in the section happen to use `text-destructive` (the CTA - // button does), only that the specific card title slot does NOT. + // v1.4.43 fix neutralised it. The header now routes through the + // canonical ``, whose `

` carries no colour + // class at all and so inherits the neutral foreground. We don't care + // if other strings in the section happen to use `text-destructive` + // (the CTA button does), only that the specific card title slot does + // NOT paint red. const cardMatch = html.match( /data-slot="settings-data-reset-card"[\s\S]*?<\/h2>/, ); expect(cardMatch).not.toBeNull(); - expect(cardMatch![0]).toContain("text-foreground"); + expect(cardMatch![0]).toContain("text-lg font-semibold"); expect(cardMatch![0]).not.toContain("text-destructive"); }); diff --git a/src/components/settings/__tests__/settings-card-header-coverage.test.ts b/src/components/settings/__tests__/settings-card-header-coverage.test.ts new file mode 100644 index 000000000..46dfcf65b --- /dev/null +++ b/src/components/settings/__tests__/settings-card-header-coverage.test.ts @@ -0,0 +1,84 @@ +/** + * Settings card-header drift guard. + * + * Every Settings card paints the same header shape — a muted icon, an + * `

` title, and an optional muted + * description. The canonical primitive `SettingsCardHeader` + * (`_card-header.tsx`) owns that shape; routing every card header through + * it keeps the typography, the icon sizing, and the description size in + * one place instead of drifting across hand-rolled `flex …

` tag whose className carries `text-lg`. + * The primitive itself is excluded (it OWNS the `

`), and a + * short, explicitly-documented allowlist covers the handful of headers + * that legitimately cannot use the primitive yet. + * + * Mirrors the call-site coverage guards under `src/__tests__/`: walk the + * tree, match a mechanical pattern, assert the offending set is empty. + */ +import { describe, it, expect } from "vitest"; +import { readFileSync, readdirSync, statSync } from "node:fs"; +import { join, relative } from "node:path"; + +const ROOT = join(__dirname, "../../../.."); +const SETTINGS_DIR = join(ROOT, "src/components/settings"); + +// Files allowed to retain a hand-rolled `

` header. +const ALLOWLIST = new Set([ + // TODO #148: rebuilt into hub+subpages, will use SettingsCardHeader then + "layout-section.tsx", + // allowed: collapsible-toggle header, button-wrapped, typography already canonical + "security-activity-card.tsx", + // allowed: collapsible-toggle header, button-wrapped, typography already canonical + "security-sessions-card.tsx", +]); + +// The primitive itself owns the canonical `

`. +const PRIMITIVE = "_card-header.tsx"; + +function walk(dir: string, out: string[] = []): string[] { + for (const name of readdirSync(dir)) { + if (name === "__tests__" || name === "node_modules") continue; + const p = join(dir, name); + const stat = statSync(p); + if (stat.isDirectory()) { + walk(p, out); + } else if (name.endsWith("-section.tsx") || name.endsWith("-card.tsx")) { + out.push(p); + } + } + return out; +} + +// A hand-rolled card header: a ` { + it("has no hand-rolled

outside the primitive + allowlist", () => { + const offenders: string[] = []; + + for (const file of walk(SETTINGS_DIR)) { + const base = file.slice(file.lastIndexOf("/") + 1); + if (base === PRIMITIVE) continue; + if (ALLOWLIST.has(base)) continue; + + const source = readFileSync(file, "utf8"); + if (AD_HOC_HEADER.test(source)) { + offenders.push(relative(ROOT, file)); + } + } + + expect( + offenders, + `These Settings files still hand-roll a card header (\`

\`).\n` + + `Route them through (src/components/settings/_card-header.tsx),\n` + + `or, if a header genuinely cannot use the primitive, add it to the ALLOWLIST\n` + + `in this test with a one-line justification.\n\n` + + offenders.map((f) => ` - ${f}`).join("\n"), + ).toEqual([]); + }); +}); diff --git a/src/components/settings/account-section/index.tsx b/src/components/settings/account-section/index.tsx index c51307fc6..1ca9531b9 100644 --- a/src/components/settings/account-section/index.tsx +++ b/src/components/settings/account-section/index.tsx @@ -473,27 +473,21 @@ export function AccountSection() { card's "Restart onboarding tour" actually broke through the right border by ~48 px). */} -
-
- -
-

- {t("settings.passwordReset")} -

-

- {t("settings.changePasswordDescription")} -

-
-
- -
+ setPasswordDialogOpen(true)} + className="min-h-11 w-full shrink-0 sm:min-h-9 sm:w-auto" + > + {t("settings.changePassword")} + + } + />
{/* v1.25.7 — active-session management + the security-activity feed diff --git a/src/components/settings/advanced-section.tsx b/src/components/settings/advanced-section.tsx index 39ef7f925..eb47d5c6c 100644 --- a/src/components/settings/advanced-section.tsx +++ b/src/components/settings/advanced-section.tsx @@ -258,72 +258,69 @@ function DataResetCard() { GitHub-style (red CTA only) rather than red-on-red-on-red. The protective gate (confirmation dialog) is unchanged; this is purely a visual-tone fix per the v1.4.43 audit. */} -
-
-

- {t("settings.dangerZone")} -

-

- {t("settings.dangerZoneDescription")} -

-
- { - // Hold the dialog open while the destructive mutation is in - // flight so the in-dialog pending state stays visible and a - // stray backdrop tap can't dismiss it mid-request. - if (deleting) return; - setConfirmOpen(open); - }} - > - - - - - - - {t("settings.dangerZoneConfirm")} - - - {t("settings.dangerZoneConfirmDescription")} - - - - - {t("common.cancel")} - - { + // Hold the dialog open while the destructive mutation is in + // flight so the in-dialog pending state stays visible and a + // stray backdrop tap can't dismiss it mid-request. + if (deleting) return; + setConfirmOpen(open); + }} + > + +
+ {t("settings.dangerZone")} + + + + + + {t("settings.dangerZoneConfirm")} + + + {t("settings.dangerZoneConfirmDescription")} + + + + + {t("common.cancel")} + + { + e.preventDefault(); + void handleDeleteAllData(); + }} + disabled={deleting} + aria-busy={deleting || undefined} + data-slot="settings-data-reset-confirm" + > + {deleting && ( + + )} + {t("settings.finalDelete")} + + + + + } + /> {msg && (

-

-
-

- {t("settings.deleteAccountCardTitle")} -

-

- {t("settings.deleteAccountCardDescription")} -

-
- { - // Hold the dialog open while the irreversible delete runs so - // the pending state stays on screen and a stray dismissal - // can't fire a second request. - if (deleting) return; - setConfirmOpen(open); - }} - > - - - - - - - {t("settings.deleteAccountConfirmTitle")} - - - {t("settings.deleteAccountConfirmDescription")} - - - - - {t("common.cancel")} - - { + // Hold the dialog open while the irreversible delete runs so + // the pending state stays on screen and a stray dismissal + // can't fire a second request. + if (deleting) return; + setConfirmOpen(open); + }} + > + +
+ {t("settings.deleteAccountCta")} + + + + + + {t("settings.deleteAccountConfirmTitle")} + + + {t("settings.deleteAccountConfirmDescription")} + + + + + {t("common.cancel")} + + { + e.preventDefault(); + void handleDeleteAccount(); + }} + disabled={deleting} + aria-busy={deleting || undefined} + data-slot="settings-account-delete-confirm" + > + {deleting && ( + + )} + {t("settings.deleteAccountFinal")} + + + + + } + /> {msg && (

-

- -

- {t("dashboard.customizeTitle")} -

-
- - + resetMutation.mutate()} + disabled={resetMutation.isPending} + className="min-h-11 self-end sm:min-h-9 sm:self-auto" + > + + {t("dashboard.layoutReset")} + + } + /> {/* v1.4.22 D / F-32 — the surrounding `` page already renders a `settings.sections.dashboard.description` diff --git a/src/components/settings/gesundheitsakte-section.tsx b/src/components/settings/gesundheitsakte-section.tsx index ea13de7bd..45d9389a2 100644 --- a/src/components/settings/gesundheitsakte-section.tsx +++ b/src/components/settings/gesundheitsakte-section.tsx @@ -24,7 +24,10 @@ * `/api/share-links` routes, and the public `/c/[token]` view are unchanged. */ +import { Share2 } from "lucide-react"; + import { useTranslations } from "@/lib/i18n/context"; +import { SettingsCardHeader } from "@/components/settings/_card-header"; import { HealthRecordExportPanel } from "@/components/settings/health-record-export-panel"; import { SharingSection } from "@/components/settings/sharing-section"; @@ -39,14 +42,11 @@ export function GesundheitsakteSection() {
-
-

- {t("settings.sections.sharing.title")} -

-

- {t("settings.sections.sharing.subtitle")} -

-
+
diff --git a/src/components/settings/insights-overview-arrange-section.tsx b/src/components/settings/insights-overview-arrange-section.tsx index e73d5ec09..7c63760c4 100644 --- a/src/components/settings/insights-overview-arrange-section.tsx +++ b/src/components/settings/insights-overview-arrange-section.tsx @@ -2,9 +2,10 @@ import { useMemo } from "react"; -import { Loader2 } from "lucide-react"; +import { LayoutGrid, Loader2 } from "lucide-react"; import { useTranslations } from "@/lib/i18n/context"; +import { SettingsCardHeader } from "@/components/settings/_card-header"; import { useAuth } from "@/hooks/use-auth"; import { useFeatureFlags } from "@/hooks/use-feature-flags"; import { useInsightsLayoutQuery } from "@/hooks/use-insights-layout"; @@ -50,17 +51,12 @@ export function InsightsOverviewArrangeSection({ id }: { id?: string }) { aria-labelledby="insights-overview-arrange-title" className="space-y-3" > -
-

- {t("insights.settings.overviewTitle")} -

-

- {t("insights.settings.overviewDescription")} -

-
+ {/* Gate the editor mount until the layout GET settles. The editor seeds its draft once from `layout` on mount, which is the canonical default diff --git a/src/components/settings/insights-pill-order-section.tsx b/src/components/settings/insights-pill-order-section.tsx index 248e61053..59fa7eb51 100644 --- a/src/components/settings/insights-pill-order-section.tsx +++ b/src/components/settings/insights-pill-order-section.tsx @@ -3,7 +3,7 @@ import { useMemo, useState } from "react"; import { useMutation, useQueryClient } from "@tanstack/react-query"; import { toast } from "sonner"; -import { Eye, EyeOff, GripVertical, Loader2 } from "lucide-react"; +import { Eye, EyeOff, GripVertical, ListOrdered, Loader2 } from "lucide-react"; import { DndContext, KeyboardSensor, @@ -44,6 +44,7 @@ import { import { SUB_PAGE_TABS } from "@/components/insights/insights-tab-strip"; import { apiPut } from "@/lib/api/api-fetch"; import { SettingsCard } from "@/components/settings/settings-card"; +import { SettingsCardHeader } from "@/components/settings/_card-header"; /** * v1.15.18 — dedicated pill-sort control for the Insights settings section. @@ -210,28 +211,26 @@ export function InsightsPillOrderSection({ id }: { id?: string }) { aria-labelledby="insights-pill-order-title" className="space-y-4" > -
-
-

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

-

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

-
- -
+ saveMutation.mutate(draftTiles)} + disabled={busy || !dirty} + data-slot="insights-pill-order-save" + className="min-h-11 self-end sm:min-h-9 sm:self-auto" + > + {saveMutation.isPending && ( + + )} + {t("insights.pillOrder.save")} + + } + /> {isLoading ? (

- + {/* Connections ("where the data comes from"). The OAuth + device data + sources. A labelled group header gives this block parity with the + delivery-channels group below it. */} +

+ + +
{/* Delivery channels ("where it's delivered"). The existing channels panel, unchanged. `id="channels"` anchors the group for the `/settings/channels` redirect; the per-channel anchors stay inside the panel. */}
-
-

- {t("settings.sections.integrations.channelsHeading")} -

-

- {t("settings.sections.integrations.channelsDescription")} -

-
+
diff --git a/src/components/settings/notifications-section.tsx b/src/components/settings/notifications-section.tsx index 5e0eba377..8ba29af03 100644 --- a/src/components/settings/notifications-section.tsx +++ b/src/components/settings/notifications-section.tsx @@ -1,7 +1,10 @@ "use client"; +import { Bell } from "lucide-react"; + import { useAuth } from "@/hooks/use-auth"; import { useTranslations } from "@/lib/i18n/context"; +import { SettingsCardHeader } from "@/components/settings/_card-header"; import { LowStockCard } from "@/components/settings/low-stock-card"; import { MoodReminderCard } from "@/components/settings/mood-reminder-card"; @@ -46,14 +49,13 @@ export function NotificationsSection() { account never shows an empty heading. */} {anyReminder ? (
-
-

- {t("settings.sections.notifications.remindersHeading")} -

-

- {t("settings.sections.notifications.remindersDescription")} -

-
+ {/* Mood check-in reminder — only when the mood module is enabled. */} {moodEnabled ? ( From ca067bfe3dbeb2d09601952c6370d36455c8571f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Tue, 30 Jun 2026 12:29:17 +0200 Subject: [PATCH 02/15] fix(ui): conform the wellbeing card and fill lone grid items full-width The mental-wellbeing instrument card carried a font-medium title and a gap-4 body, drifting from the medication card it should mirror; bring its title to text-lg font-semibold and its body to the shared space-y-3.5 rhythm. Several insight grids left a lone trailing tile stranded at half width with an empty column beside it. Gate each multi-column grid on having more than one item so a single tile spans the full row, and give the fixed five-card mood-correlation grid a trailing-card span so it no longer orphans on the two-up breakpoint. Add a little bottom breathing room to the device-score tile so its rolling-average line no longer reads flush against the card edge. --- .../insights/derived/vitals-dashboard.tsx | 16 ++++++++++++++-- src/components/insights/device-score-tile.tsx | 2 +- .../insights/mood/mood-correlation-cards.tsx | 18 ++++++++++++++++-- .../insights/mood/mood-narrative-feed.tsx | 8 +++++++- .../insights/sleep/sleep-quality-section.tsx | 10 +++++++++- src/components/labs/lab-list.tsx | 10 +++++++++- .../mental-health/instrument-card.tsx | 4 ++-- 7 files changed, 58 insertions(+), 10 deletions(-) diff --git a/src/components/insights/derived/vitals-dashboard.tsx b/src/components/insights/derived/vitals-dashboard.tsx index 76f0c1394..ce6169597 100644 --- a/src/components/insights/derived/vitals-dashboard.tsx +++ b/src/components/insights/derived/vitals-dashboard.tsx @@ -784,7 +784,14 @@ export function VitalsDashboard({ batch, layout, className }: DashboardProps) { aria-label={ isLoading ? t("insights.derived.vitals.loadingLabel") : undefined } - className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3" + className={cn( + "grid grid-cols-1 gap-3", + // Multi-column only when the skeleton row or more than one tile + // fills it; a lone tile spans the full width rather than sitting + // in a one-third column with dead space beside it. + (isLoading || vitalTiles.length > 1) && + "sm:grid-cols-2 lg:grid-cols-3", + )} > {isLoading ? Array.from({ length: 6 }).map((_, i) => ( @@ -806,7 +813,12 @@ export function VitalsDashboard({ batch, layout, className }: DashboardProps) { />
1 && "sm:grid-cols-2 lg:grid-cols-3", + )} > {mobilityTiles.map((tile) => tile.node)}
diff --git a/src/components/insights/device-score-tile.tsx b/src/components/insights/device-score-tile.tsx index 178617bb1..40a10fd9c 100644 --- a/src/components/insights/device-score-tile.tsx +++ b/src/components/insights/device-score-tile.tsx @@ -121,7 +121,7 @@ export function DeviceScoreTile({ ) : null} - + {isLearning ? ( = { function MoodCorrelationCard({ kind, data, + className, }: { kind: MetricKind; data: MoodMetricCorrelationData; + className?: string; }) { const { t } = useTranslations(); const hasResult = data.result != null && data.n >= 5; return ( - +
@@ -165,7 +171,15 @@ export function MoodCorrelationCards({ - + {/* Five fixed cards: on the two-up sm grid the trailing card lands + alone in its row, so it spans full width there; the three-up lg + grid leaves it paired (row of 3 + row of 2) and it stays one + column. */} +
); } diff --git a/src/components/insights/mood/mood-narrative-feed.tsx b/src/components/insights/mood/mood-narrative-feed.tsx index 2e53c899d..6560ce81d 100644 --- a/src/components/insights/mood/mood-narrative-feed.tsx +++ b/src/components/insights/mood/mood-narrative-feed.tsx @@ -14,6 +14,7 @@ import { import type { ComponentType } from "react"; import { useTranslations } from "@/lib/i18n/context"; +import { cn } from "@/lib/utils"; /** * v1.8.6 — narrative takeaway feed. @@ -69,7 +70,12 @@ export function MoodNarrativeFeed({ items }: { items: MoodNarrativeItem[] }) { // ladder. Lay them two-up on anything wider than a phone; a lone // takeaway still spans the row rather than orphaning half of it.
    1 && "sm:grid-cols-2", + )} data-slot="mood-narrative-feed" aria-label={t("insights.mood.narrative.title")} > diff --git a/src/components/insights/sleep/sleep-quality-section.tsx b/src/components/insights/sleep/sleep-quality-section.tsx index 7813c07d2..b471c092f 100644 --- a/src/components/insights/sleep/sleep-quality-section.tsx +++ b/src/components/insights/sleep/sleep-quality-section.tsx @@ -4,6 +4,7 @@ import { Activity, Gauge, Moon, Repeat, Target, Waves } from "lucide-react"; import { useInsightsAnalytics } from "@/hooks/use-insights-analytics"; import { useTranslations } from "@/lib/i18n/context"; +import { cn } from "@/lib/utils"; import { SectionHeading } from "@/components/insights/section-heading"; import { DeviceScoreTile } from "@/components/insights/device-score-tile"; import { DeviceScoreGridSkeleton } from "@/components/insights/device-score-tile-skeleton"; @@ -111,7 +112,14 @@ export function SleepQualitySection({ enabled }: { enabled: boolean }) {

    } /> -
    +
    1 && "sm:grid-cols-2", + )} + > {present.map((tile) => ( void } = {}) { // the line-1 chip, the panel as the category badge. v1.18.10 (#3) — no delete // control on the tile; the trash icon lives only on the value detail view. return ( -
      +
        1 && "sm:grid-cols-2", + )} + > {truncated ? (
      • diff --git a/src/components/mental-health/instrument-card.tsx b/src/components/mental-health/instrument-card.tsx index be9204a70..2b24cf69b 100644 --- a/src/components/mental-health/instrument-card.tsx +++ b/src/components/mental-health/instrument-card.tsx @@ -36,9 +36,9 @@ export function InstrumentCard({ return ( - +

        - + {t(`mentalHealth.instrument.${key}`)} From d0b4b1e4cd65b2ffa4892da3046926b709cd9551 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Tue, 30 Jun 2026 12:35:36 +0200 Subject: [PATCH 03/15] fix(ui): introduce FieldGroup and unify the add-form rhythm MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The measurement add form opened with a horizontal type row (label beside the select) while every field below it stacked label-above-control, so the form read with two competing rhythms. Introduce a small FieldGroup primitive — the long-standing space-y-2 label-above-control idiom in one place — and route the measurement, mood and baseline forms through it so they share one vertical rhythm. The type row now stacks like the rest. Output stays byte-identical for the already-vertical groups; only the type row changes, by design. --- .../measurements/measurement-form.tsx | 76 ++++++++++--------- src/components/mood/mood-form.tsx | 26 ++++--- src/components/onboarding/baseline-form.tsx | 50 ++++++------ src/components/ui/field-group.tsx | 51 +++++++++++++ 4 files changed, 128 insertions(+), 75 deletions(-) create mode 100644 src/components/ui/field-group.tsx diff --git a/src/components/measurements/measurement-form.tsx b/src/components/measurements/measurement-form.tsx index 40b296d82..d415d1c8c 100644 --- a/src/components/measurements/measurement-form.tsx +++ b/src/components/measurements/measurement-form.tsx @@ -12,6 +12,7 @@ import { } from "@/components/ui/dropdown-menu"; import { Input } from "@/components/ui/input"; import { DateTimeField } from "@/components/ui/date-time-field"; +import { FieldGroup } from "@/components/ui/field-group"; import { Label } from "@/components/ui/label"; import { Select, @@ -386,12 +387,9 @@ export function MeasurementForm({ return (
        -
        - + -
        + {isBpMode ? (
        @@ -463,16 +461,16 @@ export function MeasurementForm({
        ) : ( -
        - + -
        + )} {isGlucoseMode && ( -
        - + -
        + )} -
        - + - {/* v1.17.1 — surface that the timestamp is editable. The picker - already defaults to now and accepts any earlier instant; many - users never notice they can backdate a reading. */} -

        - {t("measurements.timestampBackdateHint")} -

        -
        + -
        -
        - + + } + labelAccessory={ {notes.length}/{MAX_COMMENT_LENGTH} -
        + } + > -
        + {error && (
        {/* v1.8.5 (C1) — free-text note. */} -
        -
        - - {/* v1.11.5 — character counter so the `maxLength` cap no + + } + labelAccessory={ + /* v1.11.5 — character counter so the `maxLength` cap no longer truncates silently. Turns destructive (warns) as the - input approaches the limit. */} + input approaches the limit. */ -
        + } + >