Merge pull request #757 from MBombeck/fix/fable-retrofit-headers-spacing

UI: modernize managed-record controls, headers, and spacing
This commit is contained in:
Marc-André Bombeck authored and GitHub committed 2026-08-09 13:04:25 +02:00
commit a0200c61a6
47 files changed
+638 -440

No files matched your search

+2
View File
@@ -5,6 +5,8 @@
### Changed
- Copy across several screens reads tighter. The insight-page metric explainers now open on a one-line definition rather than a paragraph of general guidance, with the longer background staying behind the knowledge disclosure where it already lived; the measurements subtitle and the daily-briefing setup prompt each drop a redundant sentence, and the mood self-rating card takes a short title so it stops wrapping and stops reusing a word from the assessment beside it. Wording that had drifted between neighbouring screens is settled too, so the measurement delete prompts, the practitioner label on the visit and vaccination forms, and the visit-saved toasts all match the screens they ship with.
- The settings for a managed profile now use the same controls as the rest of the app. The forms for a delegated profile carry the shared date picker, dropdowns, text fields and switches instead of a plainer set of their own, so the birth date reads and saves in your own date order rather than depending on the browser, and each form keeps its save button at the end with any error shown just above it.
- Spacing and tap targets read more evenly across settings, the dashboard and the charts. A handful of off-scale gaps are back on the spacing scale, the document upload buttons meet the mobile tap size their neighbours already set, the revoke on a share link is a right-aligned row action rather than a full-width red bar on every row, and a few empty and loading states now use the shared components so they match their siblings.
## [1.37.4] — 2026-08-09
@@ -60,22 +60,38 @@ ruleTester.run("spacing-scale", rule, {
code: '<CardHeader className="pb-2" />',
filename: "/repo/src/components/__tests__/example.test.tsx",
},
// The card-shell `5` check is scoped to the bg-card+border pair, so the
// The card-shell check is scoped to the bg-card+border pair, so the
// justified list-marker inset never trips it (no shell).
{
code: '<ul className="list-disc pl-5 space-y-1" />',
filename: APP_FILE,
},
// A directional inset ON a shell is out of scope (pl-5/pr-5 not banned).
{
code: '<div className="bg-card border-border rounded-xl border pl-5" />',
filename: APP_FILE,
},
// Form-body rhythm without a shell surface is fine.
// Off-scale spacing without a shell surface is fine — a form-body
// `space-y-5` rhythm and a half-step gap outside a shell both pass.
{
code: '<form className="space-y-5" />',
filename: APP_FILE,
},
{
code: '<div className="flex flex-col gap-3.5" />',
filename: APP_FILE,
},
// The sanctioned `1.5` half-step is on-scale, even on a shell.
{
code: '<div className="bg-card border-border rounded-xl border gap-1.5" />',
filename: APP_FILE,
},
// A deliberate negative margin is never matched (boundary anchored).
{
code: '<div className="bg-card border-border rounded-full border -mt-5" />',
filename: APP_FILE,
},
// Height is a dimension, not a scale step — `h-15` on a shell is out of
// this rule's scope by design.
{
code: '<div className="bg-card border-border rounded-xl border h-15" />',
filename: APP_FILE,
},
// A shell already on-scale is fine.
{
code: '<div className="bg-card border-border rounded-xl border p-4 md:p-6" />',
@@ -138,30 +154,64 @@ ruleTester.run("spacing-scale", rule, {
{
code: '<div className="bg-card border-border rounded-xl border p-5" />',
filename: APP_FILE,
errors: [{ messageId: "cardShellStep5" }],
errors: [{ messageId: "cardShellOffScale" }],
},
// …py-5, …space-y-5, …gap-5 on a shell all trip it.
{
code: '<section className="bg-card rounded-lg border py-5" />',
filename: APP_FILE,
errors: [{ messageId: "cardShellStep5" }],
errors: [{ messageId: "cardShellOffScale" }],
},
{
code: '<div className="bg-card border-border rounded-xl border gap-5 flex" />',
filename: APP_FILE,
errors: [{ messageId: "cardShellStep5" }],
errors: [{ messageId: "cardShellOffScale" }],
},
// Split across cn() args — the join still sees the shell + the step-5.
// The `7` step is banned with the `5` step.
{
code: '<div className="bg-card border-border rounded-xl border p-7" />',
filename: APP_FILE,
errors: [{ messageId: "cardShellOffScale" }],
},
// Half-steps other than 1.5 on a shell: gap-3.5, space-y-3.5, py-2.5.
{
code: '<div className="bg-card border-border rounded-xl border flex flex-col gap-3.5" />',
filename: APP_FILE,
errors: [{ messageId: "cardShellOffScale" }],
},
{
code: '<div className="bg-card border rounded-xl space-y-3.5" />',
filename: APP_FILE,
errors: [{ messageId: "cardShellOffScale" }],
},
{
code: '<div className="bg-card border-border rounded-lg border py-2.5" />',
filename: APP_FILE,
errors: [{ messageId: "cardShellOffScale" }],
},
// The directional `pl-`/`ml-` spellings that slipped the `5`-only check
// now trip it on a shell.
{
code: '<div className="bg-card border-border rounded-xl border pl-5" />',
filename: APP_FILE,
errors: [{ messageId: "cardShellOffScale" }],
},
{
code: '<div className="bg-card border-border rounded-xl border ml-5" />',
filename: APP_FILE,
errors: [{ messageId: "cardShellOffScale" }],
},
// Split across cn() args — the join still sees the shell + the step.
{
code: '<div className={cn("bg-card border rounded-xl", "p-5")} />',
filename: APP_FILE,
errors: [{ messageId: "cardShellStep5" }],
errors: [{ messageId: "cardShellOffScale" }],
},
// Modifier-prefixed step on a shell.
{
code: '<div className="bg-card border-border rounded-xl border sm:p-5" />',
filename: APP_FILE,
errors: [{ messageId: "cardShellStep5" }],
errors: [{ messageId: "cardShellOffScale" }],
},
],
});
+38 -21
View File
@@ -17,16 +17,25 @@
* modifier-prefixed (`md:pb-2`) and arbitrary-value (`pb-[10px]`) forms.
* Non-numeric suffixes (`pb-safe`) never match.
*
* 2. `cardShellStep5` — no off-scale `5` step (20 px, banned by §2) on a
* hand-rolled card SHELL. The shell heuristic (§7.2) keys off the
* `bg-card` + `border` pair on ONE element: a `p-5` / `px-5` / `py-5` /
* `space-y-5` / `gap-5` there sits denser than every sibling `<Card>`
* (which is `p-4 md:p-6`), and the 4 px step reads next to a conformant
* card on the same viewport. Deliberately NARROW: it fires only when the
* same element paints a card surface, so justified `pl-5`/`pl-7`
* list-marker insets (no `bg-card`) and form-body `space-y-5` rhythm (no
* shell) never trip it. Hand-rolled shells sweep to `p-4 md:p-6`
* (or `p-3` for a dense inner tile); new surfaces compose `<Card>`.
* 2. `cardShellOffScale` — no off-scale spacing step on a hand-rolled card
* SHELL. The scale (§2) is `1 / 1.5 / 2 / 3 / 4 / 6 / 8 / 10`; the `5`
* (20 px) and `7` (28 px) steps and every half-step other than `1.5`
* (`2.5`, `3.5`, …) are banned. The shell heuristic (§7.2) keys off the
* `bg-card` + `border` pair on ONE element: a `p-5` / `py-2.5` /
* `space-y-3.5` / `gap-3.5` / `ml-5` there sits off the scale next to
* every sibling `<Card>` (which is `p-4 md:p-6`), and reads on the same
* viewport. The prefix set is the full spacing family — padding
* (`p/px/py/pt/pb/pl/pr`), margin (`m/mx/my/mt/mb/ml/mr`), gap
* (`gap/gap-x/gap-y`), and `space-x/space-y` — so the `pl-`/`ml-`/`gap-`
* spellings that slipped the earlier `5`-only check are covered.
* Deliberately NARROW: it fires only when the same element paints a card
* surface, so a justified `pl-5` list-marker inset (no `bg-card`), a
* form-body `space-y-5` rhythm (no shell), and a deliberate negative
* margin (`-mt-5`, never matched) never trip it. Height (`h-`) is a
* dimension, not a scale step (`h-11` tap floor, `h-14`, `h-64` are all
* legal), so it is out of scope by design. Hand-rolled shells sweep to
* `p-4 md:p-6` (or `p-3` for a dense inner tile); new surfaces compose
* `<Card>`.
*
* Both checks collect strings from the whole `className` subtree, so
* `cn("…", cond && "…")`, ternaries, and template literals are all seen.
@@ -63,12 +72,20 @@ const SLOT_NAMES = new Set(["CardHeader", "CardContent"]);
const SHELL_BG_RE = /\bbg-card\b/;
const SHELL_BORDER_RE = /\bborder(?:-|\b)/;
// The banned `5` step (20 px) on a shell — the subset §7.2 scopes to an
// error-clean rule: p-5 / px-5 / py-5 / space-y-5 / gap-5, with optional
// modifier prefixes (`sm:p-5`). Directional insets (`pl-5`/`pr-5`) and
// half-steps are intentionally OUT of scope (see the header).
const SHELL_STEP5_RE =
/(?:^|\s)(?:[^\s"']*:)*((?:p|px|py|space-y|gap)-5)(?=\s|$)/;
// An off-scale spacing step on a shell — the banned `5` / `7` integer steps
// and any half-step other than `1.5` (`2.5`, `3.5`, …), across the full
// spacing prefix family (padding, margin, gap, space), with optional modifier
// prefixes (`sm:p-5`). A leading `-` (negative margin) is never matched
// because the prefix has to sit at a `(?:^|\s)` / modifier boundary. Height
// (`h-`) is a dimension, not a scale step, and is intentionally OUT of scope.
const SPACING_PREFIX =
"space-x|space-y|gap-x|gap-y|gap|px|py|pt|pb|pl|pr|p|mx|my|mt|mb|ml|mr|m";
// `5` or `7`; or a half-step whose integer part is anything but `1`
// (`0.5`, `2.5`…`9.5`, `10.5`+), so the sanctioned `1.5` is left alone.
const OFF_SCALE_STEP = "5|7|(?:[02-9]|\\d\\d+)\\.5";
const SHELL_OFFSCALE_RE = new RegExp(
`(?:^|\\s)(?:[^\\s"']*:)*((?:${SPACING_PREFIX})-(?:${OFF_SCALE_STEP}))(?=\\s|$)`,
);
function toPosix(filename) {
return filename.replace(/\\/g, "/");
@@ -148,14 +165,14 @@ const spacingScaleRule = {
type: "problem",
docs: {
description:
"Spacing-scale discipline — no pt-*/pb-* overrides on gap-based Card slots, and no off-scale `5` step on a bg-card+border shell.",
"Spacing-scale discipline — no pt-*/pb-* overrides on gap-based Card slots, and no off-scale spacing step (`5`/`7`/half-steps) on a bg-card+border shell.",
},
schema: [],
messages: {
slotPadding:
'"{{match}}" on <{{element}}> fights the gap-based Card contract — the header→body distance comes from the parent flex gap, so slot padding stacks on top of it (or is a no-op). Tune density on the Card itself instead: <Card className="gap-2 py-3 md:py-4">.',
cardShellStep5:
'Off-scale "{{match}}" (20 px) on a bg-card+border shell — the `5` step is banned (UI-STANDARDS §2) and reads denser than every sibling <Card> (p-4 md:p-6). Sweep the shell to `p-4 md:p-6` (or `p-3` for a dense inner tile); new surfaces should compose <Card>.',
cardShellOffScale:
'Off-scale "{{match}}" on a bg-card+border shell — the scale is 1/1.5/2/3/4/6/8/10, so the `5`/`7` steps and half-steps other than 1.5 are banned (UI-STANDARDS §2) and read off next to every sibling <Card> (p-4 md:p-6). Sweep the shell to an on-scale step (`p-4 md:p-6`, or `p-3` for a dense inner tile); new surfaces should compose <Card>.',
},
},
create(context) {
@@ -197,11 +214,11 @@ const spacingScaleRule = {
// seen together even when split across cn() args, then report once.
const joined = strings.map((s) => s.text).join(" ");
if (SHELL_BG_RE.test(joined) && SHELL_BORDER_RE.test(joined)) {
const m = SHELL_STEP5_RE.exec(joined);
const m = SHELL_OFFSCALE_RE.exec(joined);
if (m) {
context.report({
node: classNameAttr,
messageId: "cardShellStep5",
messageId: "cardShellOffScale",
data: { match: m[1] },
});
}
+1 -1
View File
@@ -282,7 +282,7 @@ function CoachConversationsBody() {
aria-hidden="true"
/>
{(c.attachments?.length ?? 0) > 1 ? (
<span className="text-[10px] font-semibold tabular-nums">
<span className="text-2xs font-semibold tabular-nums">
{c.attachments?.length}
</span>
) : null}
+10 -10
View File
@@ -8,6 +8,7 @@ import { Label } from "@/components/ui/label";
import { Button } from "@/components/ui/button";
import { PageHeader } from "@/components/ui/page-header";
import { Skeleton } from "@/components/ui/skeleton";
import { EmptyState } from "@/components/ui/empty-state";
import { QueryErrorCard } from "@/components/ui/query-error-card";
import Link from "next/link";
import { Bell, Settings, AlertCircle } from "lucide-react";
@@ -201,22 +202,21 @@ export default function NotificationsPage() {
description={t("notifications.subtitle")}
actions={headerActions}
/>
<div className="bg-card border-border max-w-2xl rounded-xl border p-6">
<div className="flex flex-col items-center gap-4 py-8 text-center">
<Bell className="text-muted-foreground h-12 w-12" />
<p className="text-muted-foreground text-sm">
{t("notifications.noChannels")}
</p>
{/* Channel setup lives in Settings → Integrations since v1.25.7;
point the CTA at that anchor, not the reminder-types screen. */}
<EmptyState
className="max-w-2xl"
icon={<Bell className="size-6" />}
title={t("notifications.noChannels")}
/* Channel setup lives in Settings → Integrations since v1.25.7;
point the CTA at that anchor, not the reminder-types screen. */
action={
<Button asChild variant="outline">
<Link href="/settings/integrations#channels">
<Settings className="h-4 w-4" />
{t("notifications.goToSettings")}
</Link>
</Button>
</div>
</div>
}
/>
</div>
);
}
+1
View File
@@ -101,6 +101,7 @@ export default async function OnboardingStepPage({ params }: PageProps) {
aria-labelledby="onboarding-welcomeback-title"
className="space-y-4"
>
{/* Onboarding hero H1: intentionally semibold, not the app-wide bold PageHeader H1 (UI-STANDARDS §5 hero exception). Do not sweep to font-bold. */}
<h1
id="onboarding-welcomeback-title"
tabIndex={-1}
+1 -1
View File
@@ -73,7 +73,7 @@ export function RichChartTooltip({
return (
<div
data-slot="rich-chart-tooltip"
className="bg-card border-border min-w-[140px] rounded-xl border p-2.5 text-xs shadow-lg"
className="bg-card border-border min-w-[140px] rounded-xl border p-3 text-xs shadow-lg"
style={{
// The card itself is layered above the chart's own SVG; without
// an explicit background the gradient bleeds through the
+4 -5
View File
@@ -18,6 +18,7 @@ import { useEffect, useState, useMemo, useRef } from "react";
import { Button } from "@/components/ui/button";
import { Skeleton } from "@/components/ui/skeleton";
import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card";
import { TagChip } from "@/components/ui/tag-chip";
import { useTranslations } from "@/lib/i18n/context";
import { makeBucketLabelFormatters } from "@/lib/charts/bucket-label";
import { readStoredTimezone } from "@/lib/timezone-mirror";
@@ -1033,17 +1034,15 @@ export function MoodChart({
back to side-by-side. */}
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<div className="flex flex-wrap items-center gap-2">
<CardTitle className="text-base font-medium">
{displayTitle}
</CardTitle>
<CardTitle className="text-base">{displayTitle}</CardTitle>
{activeBucket !== "day" && (
<span className="bg-muted/40 text-muted-foreground hidden rounded-md px-1.5 py-0.5 text-[10px] font-medium tracking-wide uppercase sm:inline-flex">
<TagChip className="hidden tracking-wide uppercase sm:inline-flex">
{t(
activeBucket === "week"
? "charts.bucketWeekly"
: "charts.bucketMonthly",
)}
</span>
</TagChip>
)}
{/* v1.4.16 B8 — comparison caption (mood).
v1.4.19 A2 — hidden on mobile to free up the title row. */}
@@ -11,6 +11,7 @@ import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { QueryErrorCard } from "@/components/ui/query-error-card";
import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
import { SectionHeading } from "@/components/ui/section-heading";
import { Skeleton } from "@/components/ui/skeleton";
import { apiGet } from "@/lib/api/api-fetch";
import { formatDate } from "@/lib/format";
@@ -63,26 +64,23 @@ export function CustomMetricList() {
className="border-border space-y-3 border-t pt-6"
data-slot="custom-metric-list"
>
<div className="flex items-center justify-between gap-2">
<div className="min-w-0">
<h2 className="text-lg font-semibold tracking-tight">
{t("customMetrics.sectionTitle")}
</h2>
<p className="text-muted-foreground truncate text-xs sm:text-sm">
{t("customMetrics.sectionSubtitle")}
</p>
</div>
<Button
variant="outline"
size="sm"
className="min-h-11 shrink-0 sm:min-h-9"
onClick={() => setAddOpen(true)}
aria-label={t("customMetrics.add")}
>
<Plus className="h-4 w-4" />
<span className="hidden sm:inline">{t("customMetrics.add")}</span>
</Button>
</div>
<SectionHeading
icon={Gauge}
title={t("customMetrics.sectionTitle")}
subtitle={t("customMetrics.sectionSubtitle")}
action={
<Button
variant="outline"
size="sm"
className="min-h-11 shrink-0 sm:min-h-9"
onClick={() => setAddOpen(true)}
aria-label={t("customMetrics.add")}
>
<Plus className="h-4 w-4" />
<span className="hidden sm:inline">{t("customMetrics.add")}</span>
</Button>
}
/>
{isLoading ? (
<Card aria-hidden="true">
+1 -1
View File
@@ -177,7 +177,7 @@ export function CycleCalendar({
<div
key={i}
role="columnheader"
className="text-muted-foreground pb-1 text-center text-[11px] font-medium uppercase"
className="text-muted-foreground text-2xs pb-1 text-center font-medium uppercase"
>
{wk}
</div>
@@ -6,6 +6,7 @@ import { Activity } from "lucide-react";
import { useTranslations } from "@/lib/i18n/context";
import { useWorkouts, type WorkoutListEntry } from "@/hooks/use-workouts";
import { cn } from "@/lib/utils";
import { Card, CardContent, CardHeader } from "@/components/ui/card";
import { TileHeader } from "@/components/insights/tile-header";
import { getDateTimeFormat } from "@/lib/intl/formatter-cache";
import { Skeleton } from "@/components/ui/skeleton";
@@ -78,90 +79,90 @@ export function RecentWorkoutsTile() {
const workouts = data?.workouts ?? [];
return (
<div
data-slot="recent-workouts-tile"
className={cn(
"bg-card border-border space-y-3 rounded-xl border p-4 md:p-6",
)}
>
{/* Canonical tile header (foreground icon + title) — the same
`TileHeader` contract the Insights tiles use, so the dashboard
stops speaking a separate header language. `size="sm"` matches the
compact dashboard tile. */}
<TileHeader
icon={Activity}
size="sm"
title={t("dashboard.recentWorkouts.title")}
titleAs="h2"
right={
<Link
href="/insights/workouts"
className="text-muted-foreground hover:text-foreground inline-flex min-h-11 items-center text-xs"
>
{t("dashboard.recentWorkouts.viewAll")}
</Link>
}
/>
{showLoading ? (
// v1.4.43 W11-L6 — reserve roughly the loaded tile height
// (3 workouts × ~3 rem rows + spacing) so the surrounding
// dashboard layout doesn't reflow once the data lands.
// v1.29.x — row-shaped skeletons instead of a "loading…" text
// line, matching the sport-icon + label + day + duration shape
// the loaded rows render.
<div data-slot="recent-workouts-loading" className="space-y-2">
{[0, 1, 2].map((i) => (
<div key={i} className="flex items-center gap-3 px-1 py-1">
<Skeleton className="size-7 shrink-0 rounded-full" />
<Skeleton className="h-4 min-w-0 flex-1" />
<Skeleton className="h-4 w-12 shrink-0" />
<Skeleton className="h-4 w-8 shrink-0" />
</div>
))}
</div>
) : isError ? (
<QueryErrorCard
onRetry={() => refetch()}
className="border-0 bg-transparent shadow-none"
<Card data-slot="recent-workouts-tile" className="h-full">
<CardHeader>
{/* Canonical tile header (foreground icon + title) — the same
`TileHeader` contract the Insights tiles use, so the dashboard
stops speaking a separate header language. `size="sm"` matches the
compact dashboard tile. */}
<TileHeader
icon={Activity}
size="sm"
title={t("dashboard.recentWorkouts.title")}
titleAs="h2"
right={
<Link
href="/insights/workouts"
className="text-muted-foreground hover:text-foreground inline-flex min-h-11 items-center text-xs"
>
{t("dashboard.recentWorkouts.viewAll")}
</Link>
}
/>
) : workouts.length === 0 ? (
<div data-slot="recent-workouts-empty" className="space-y-1">
<p className="text-sm">{t("dashboard.recentWorkouts.empty.title")}</p>
<p className="text-muted-foreground text-xs">
{t("dashboard.recentWorkouts.empty.cta")}
</p>
</div>
) : (
<ul className="space-y-2">
{workouts.map((workout) => {
const sportLabelKey = `insights.workouts.sport.${workout.sportType}`;
const sportLabel = t(sportLabelKey);
const sportName =
sportLabel === sportLabelKey ? workout.sportType : sportLabel;
return (
<li key={workout.id}>
<Link
href={`/insights/workouts/${encodeURIComponent(workout.id)}`}
data-slot="recent-workouts-link"
className={cn(
"flex items-center gap-2 rounded-md px-1 py-1 transition-colors",
"hover:bg-accent focus-visible:ring-ring/50 focus-visible:ring-2 focus-visible:outline-none",
)}
>
{renderRow(workout, sportName)}
<span className="text-muted-foreground shrink-0 text-xs tabular-nums">
{formatDayLabel(workout.startedAt, locale)}
</span>
<span className="ml-1 shrink-0 text-xs font-medium tabular-nums">
{formatDuration(workout.durationSec)}
</span>
</Link>
</li>
);
})}
</ul>
)}
</div>
</CardHeader>
<CardContent>
{showLoading ? (
// v1.4.43 W11-L6 — reserve roughly the loaded tile height
// (3 workouts × ~3 rem rows + spacing) so the surrounding
// dashboard layout doesn't reflow once the data lands.
// v1.29.x — row-shaped skeletons instead of a "loading…" text
// line, matching the sport-icon + label + day + duration shape
// the loaded rows render.
<div data-slot="recent-workouts-loading" className="space-y-2">
{[0, 1, 2].map((i) => (
<div key={i} className="flex items-center gap-3 px-1 py-1">
<Skeleton className="size-7 shrink-0 rounded-full" />
<Skeleton className="h-4 min-w-0 flex-1" />
<Skeleton className="h-4 w-12 shrink-0" />
<Skeleton className="h-4 w-8 shrink-0" />
</div>
))}
</div>
) : isError ? (
<QueryErrorCard
onRetry={() => refetch()}
className="border-0 bg-transparent shadow-none"
/>
) : workouts.length === 0 ? (
<div data-slot="recent-workouts-empty" className="space-y-1">
<p className="text-sm">
{t("dashboard.recentWorkouts.empty.title")}
</p>
<p className="text-muted-foreground text-xs">
{t("dashboard.recentWorkouts.empty.cta")}
</p>
</div>
) : (
<ul className="space-y-2">
{workouts.map((workout) => {
const sportLabelKey = `insights.workouts.sport.${workout.sportType}`;
const sportLabel = t(sportLabelKey);
const sportName =
sportLabel === sportLabelKey ? workout.sportType : sportLabel;
return (
<li key={workout.id}>
<Link
href={`/insights/workouts/${encodeURIComponent(workout.id)}`}
data-slot="recent-workouts-link"
className={cn(
"flex items-center gap-2 rounded-md px-1 py-1 transition-colors",
"hover:bg-accent focus-visible:ring-ring/50 focus-visible:ring-2 focus-visible:outline-none",
)}
>
{renderRow(workout, sportName)}
<span className="text-muted-foreground shrink-0 text-xs tabular-nums">
{formatDayLabel(workout.startedAt, locale)}
</span>
<span className="ml-1 shrink-0 text-xs font-medium tabular-nums">
{formatDuration(workout.durationSec)}
</span>
</Link>
</li>
);
})}
</ul>
)}
</CardContent>
</Card>
);
}
+8 -2
View File
@@ -883,7 +883,10 @@ export function DocumentsView() {
description={t("documents.subtitle")}
actions={
canManage ? (
<Button onClick={() => uploadInputRef.current?.click()}>
<Button
className="min-h-11 sm:min-h-9"
onClick={() => uploadInputRef.current?.click()}
>
<Upload className="size-4" aria-hidden />
{t("documents.pageUpload")}
</Button>
@@ -940,7 +943,10 @@ export function DocumentsView() {
ctaSize="lg"
action={
canManage ? (
<Button onClick={() => uploadInputRef.current?.click()}>
<Button
className="min-h-11 sm:min-h-9"
onClick={() => uploadInputRef.current?.click()}
>
<Upload className="size-4" aria-hidden />
{t("documents.empty.action")}
</Button>
@@ -12,6 +12,7 @@
import { ArrowDown, ArrowUp, Info } from "lucide-react";
import { Card, CardContent, CardHeader } from "@/components/ui/card";
import { TileHeader } from "@/components/insights/tile-header";
import { Skeleton } from "@/components/ui/skeleton";
import { useTranslations } from "@/lib/i18n/context";
@@ -172,12 +173,11 @@ export function IllnessCorrelationCard({ episodeId }: { episodeId: string }) {
return (
<Card>
<CardHeader>
<div className="flex items-center gap-2">
<Info className="text-muted-foreground h-4 w-4" aria-hidden />
<h2 className="text-base font-semibold">
{t("illness.correlation.title")}
</h2>
</div>
<TileHeader
icon={Info}
titleAs="h2"
title={t("illness.correlation.title")}
/>
</CardHeader>
<CardContent>
{isLoading ? (
@@ -14,6 +14,7 @@
*/
import { useRecordCapabilities } from "@/hooks/use-record-capabilities";
import { Card, CardContent, CardHeader } from "@/components/ui/card";
import { TileHeader } from "@/components/insights/tile-header";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Skeleton } from "@/components/ui/skeleton";
@@ -101,9 +102,7 @@ export function IllnessDayTimeline({
return (
<Card>
<CardHeader>
<h2 className="text-base font-semibold">
{t("illness.timeline.title")}
</h2>
<TileHeader titleAs="h2" title={t("illness.timeline.title")} />
</CardHeader>
<CardContent className="space-y-3">
{isLoading ? (
@@ -15,6 +15,7 @@ import { History } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader } from "@/components/ui/card";
import { TileHeader } from "@/components/insights/tile-header";
import { Skeleton } from "@/components/ui/skeleton";
import { useTranslations } from "@/lib/i18n/context";
@@ -42,12 +43,11 @@ export function IllnessInsightsCard({
return (
<Card>
<CardHeader>
<div className="flex items-center gap-2">
<History className="text-muted-foreground h-4 w-4" aria-hidden />
<h2 className="text-base font-semibold">
{t("illness.insights.title")}
</h2>
</div>
<TileHeader
icon={History}
titleAs="h2"
title={t("illness.insights.title")}
/>
</CardHeader>
<CardContent>
{counts.isLoading ? (
@@ -234,7 +234,7 @@ export function HistoryRail({
>
<Paperclip className="size-3.5" aria-hidden="true" />
{(c.attachments?.length ?? 0) > 1 ? (
<span className="text-[10px] font-semibold tabular-nums">
<span className="text-2xs font-semibold tabular-nums">
{c.attachments?.length}
</span>
) : null}
@@ -56,7 +56,7 @@ const SIZE: Record<
NonNullable<ScoreRingProps["size"]>,
{ px: number; numberClass: string; labelClass: string }
> = {
sm: { px: 120, numberClass: "text-3xl", labelClass: "text-[11px]" },
sm: { px: 120, numberClass: "text-3xl", labelClass: "text-2xs" },
md: { px: 168, numberClass: "text-5xl", labelClass: "text-xs" },
lg: { px: 232, numberClass: "text-7xl", labelClass: "text-sm" },
};
@@ -284,7 +284,7 @@ export function ScoreRing({
{!hasScore && (
<span
data-slot="score-ring-provisional"
className="text-muted-foreground absolute inset-x-0 bottom-0 line-clamp-1 px-1 text-center text-[10px] leading-tight"
className="text-muted-foreground text-2xs absolute inset-x-0 bottom-0 line-clamp-1 px-1 text-center leading-tight"
>
{t("insights.derived.scoreRing.provisionalCaption")}
</span>
@@ -310,7 +310,7 @@ export function HealthScoreCard({
PANEL_COLUMN_CLASS,
// `h-full` lets the hero band's `items-stretch` equalise the column
// against the greeting; `mt-auto` on the foot collects the slack.
"flex h-full flex-col gap-3.5",
"flex h-full flex-col gap-3",
className,
)}
>
@@ -827,7 +827,7 @@ export function HealthScoreCardSkeleton({ className }: { className?: string }) {
PANEL_CHROME_CLASS,
"border-border/60",
PANEL_COLUMN_CLASS,
"flex h-full flex-col gap-3.5",
"flex h-full flex-col gap-3",
PANEL_RESERVE_CLASS,
className,
)}
@@ -838,7 +838,7 @@ export function HealthScoreCardSkeleton({ className }: { className?: string }) {
keeps these blocks under the minimum above, so the minimum is the
thing that decides the height. */}
<Skeleton className="h-4 w-24" />
<Skeleton className="h-15 w-28" />
<Skeleton className="h-14 w-28" />
<Skeleton className="h-2 w-full rounded-full" />
<div className="space-y-1">
<Skeleton className="h-4 w-40" />
@@ -1,5 +1,7 @@
"use client";
import { Spline } from "lucide-react";
import { useAnalyticsQuery } from "@/lib/queries/use-analytics-query";
import { useFeatureFlags } from "@/hooks/use-feature-flags";
import { useTranslations } from "@/lib/i18n/context";
@@ -8,6 +10,7 @@ import type {
CorrelationResult,
} from "@/lib/insights/correlations";
import { CorrelationCard } from "@/components/insights/correlation-card";
import { SectionHeading } from "@/components/ui/section-heading";
/**
* v1.12.0 — per-metric correlation card.
@@ -82,9 +85,10 @@ export function MetricCorrelationCard({ slug }: MetricCorrelationCardProps) {
aria-label={t("insights.correlationRow.title")}
className="space-y-2"
>
<h2 className="text-lg font-semibold">
{t("insights.correlationRow.title")}
</h2>
<SectionHeading
icon={Spline}
title={t("insights.correlationRow.title")}
/>
<CorrelationCard result={result} />
{/* v1.18.6 (DISC-01) — the "observational, not causal / talk to your
doctor" disclaimer is removed; the one-time onboarding acknowledgment
@@ -120,7 +120,7 @@ export function PersonalRecordBadge({
// green on the dark card (#50fa7b). Both clear 4.5:1 against
// their card background — the previous `text-success` on
// `bg-success/10` measured ~2.6:1 in dark mode.
"border-success/40 bg-success/15 text-success inline-flex shrink-0 items-center rounded-full border px-1.5 py-0.5 text-[10px] leading-none font-semibold tracking-wide uppercase tabular-nums",
"border-success/40 bg-success/15 text-success text-2xs inline-flex shrink-0 items-center rounded-full border px-1.5 py-0.5 leading-none font-semibold tracking-wide uppercase tabular-nums",
className,
)}
aria-label={t("insights.personalRecord.badge")}
@@ -443,7 +443,7 @@ export function RecommendationCard({
{/* v1.21.0 (C4 H2) — discreet "ask the Coach about this rec" link,
aligned under the rec text (the number gutter is `ml-6`). */}
<div className="mt-1 ml-5">
<div className="mt-1 ml-6">
<AskCoachAction
question={coachQuestion}
scope={coachScopeSource ? { metric: coachScopeSource } : undefined}
@@ -30,7 +30,8 @@ import {
import type { EncounterKind } from "@/generated/prisma/client";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Card, CardContent, CardHeader } from "@/components/ui/card";
import { TileHeader } from "@/components/insights/tile-header";
import { ListRow } from "@/components/ui/list-row";
import { QueryErrorRow } from "@/components/ui/query-error-row";
import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
@@ -142,10 +143,11 @@ export function VorsorgeDashboardCard() {
return (
<Card data-slot="vorsorge-dashboard-card" className="h-full">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-sm">
<CalendarClock className="h-4 w-4" aria-hidden="true" />
{t("measurementReminders.sectionTitle")}
</CardTitle>
<TileHeader
size="sm"
icon={CalendarClock}
title={t("measurementReminders.sectionTitle")}
/>
</CardHeader>
<CardContent className="space-y-2">
{showLoading ? (
@@ -142,6 +142,7 @@ export function CheckInWizard({
{/* In-test header — instrument title + ONE standardized explanation line.
No disclaimer here (§2); the landing carries it. */}
<header className="flex flex-col gap-1">
{/* Wizard hero H1: intentionally semibold, same hero exception as onboarding (UI-STANDARDS §5). Do not sweep to font-bold. */}
<h1 className="text-2xl font-semibold">
{t(`mentalHealth.instrument.${key}`)}
</h1>
+2 -2
View File
@@ -184,7 +184,7 @@ export function MoodTagPicker({
}`}
>
<TagIcon className="h-5 w-5" aria-hidden="true" />
<span className="text-[11px] leading-tight">{label}</span>
<span className="text-2xs leading-tight">{label}</span>
</button>
);
})}
@@ -337,7 +337,7 @@ function AddTagTile({
className="border-border/70 text-muted-foreground hover:bg-accent hover:text-foreground focus-visible:ring-ring focus-visible:ring-offset-background flex min-h-16 w-[4.5rem] flex-col items-center justify-center gap-1 rounded-xl border border-dashed p-2 text-center transition-colors focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none"
>
<Plus className="h-5 w-5" aria-hidden="true" />
<span className="text-[11px] leading-tight">{label}</span>
<span className="text-2xs leading-tight">{label}</span>
</button>
);
}
@@ -188,6 +188,7 @@ export function BaselineForm() {
return (
<section aria-labelledby="onboarding-baseline-title" className="space-y-6">
<header className="space-y-2">
{/* Onboarding hero H1: intentionally semibold, not the app-wide bold PageHeader H1 (UI-STANDARDS §5 hero exception). Do not sweep to font-bold. */}
<h1
id="onboarding-baseline-title"
tabIndex={-1}
@@ -81,6 +81,7 @@ export function DoneScreen() {
</span>
<header className="space-y-2">
{/* Onboarding hero H1: intentionally semibold, not the app-wide bold PageHeader H1 (UI-STANDARDS §5 hero exception). Do not sweep to font-bold. */}
<h1
id="onboarding-done-title"
tabIndex={-1}
@@ -139,6 +139,7 @@ export function GoalsChipPicker({ userId: _userId }: GoalsChipPickerProps) {
return (
<section aria-labelledby="onboarding-goals-title" className="space-y-6">
<header className="space-y-2">
{/* Onboarding hero H1: intentionally semibold, not the app-wide bold PageHeader H1 (UI-STANDARDS §5 hero exception). Do not sweep to font-bold. */}
<h1
id="onboarding-goals-title"
tabIndex={-1}
@@ -148,6 +148,7 @@ export function SourceCardGrid() {
return (
<section aria-labelledby="onboarding-source-title" className="space-y-6">
<header className="space-y-2">
{/* Onboarding hero H1: intentionally semibold, not the app-wide bold PageHeader H1 (UI-STANDARDS §5 hero exception). Do not sweep to font-bold. */}
<h1
id="onboarding-source-title"
tabIndex={-1}
+1 -1
View File
@@ -695,7 +695,7 @@ export function OnboardingTour({
{/* `flex-wrap` keeps long localised labels (Skip / Back / Next) from
forcing a horizontal scrollbar inside the card — the row wraps
instead, so the primary action is always reachable. */}
<footer className="mt-5 flex flex-wrap items-center justify-between gap-2">
<footer className="mt-4 flex flex-wrap items-center justify-between gap-2">
<Button
type="button"
variant="ghost"
@@ -164,6 +164,7 @@ export function WelcomeCarousel() {
return (
<section aria-labelledby="onboarding-welcome-title" className="space-y-6">
{/* Onboarding hero H1: intentionally semibold, not the app-wide bold PageHeader H1 (UI-STANDARDS §5 hero exception). Do not sweep to font-bold. */}
<h1
id="onboarding-welcome-title"
tabIndex={-1}
@@ -13,6 +13,7 @@ const render = (
family: Parameters<typeof ManagedRecordSettingsForm>[0]["family"],
settings: Record<string, unknown>,
locale: (typeof LOCALES)[number] = "en",
error?: string,
) =>
renderToStaticMarkup(
<I18nProvider initialLocale={locale}>
@@ -22,6 +23,7 @@ const render = (
onSave={vi.fn()}
saveLabel="Save"
settings={settings}
error={error}
/>
</I18nProvider>,
);
@@ -72,6 +74,53 @@ describe("managed record settings forms", () => {
},
);
it("gives the profile date of birth a DateField, never a native date input", () => {
const html = render("profile", { timezone: "Europe/Berlin" });
// The DateField primitive marks itself; the browser-locale native
// `type="date"` trap (v1.25.4) must be gone.
expect(html).toContain('data-slot="date-field"');
expect(html).not.toContain('type="date"');
// Its hidden mirror still carries the submit name.
expect(html).toContain('name="dateOfBirth"');
});
it.each([
"profile",
"modules",
"notifications",
"thresholds",
"coach",
] as const)(
"orders the %s action row last, with the error above it (§12)",
(family) => {
const settingsByFamily: Record<string, Record<string, unknown>> = {
profile: { timezone: "Europe/Berlin" },
modules: { modulePreferences: {} },
notifications: {},
thresholds: { overrides: {}, effective: {} },
coach: {},
};
const html = render(family, settingsByFamily[family], "en", "Boom");
const actionAt = html.indexOf('data-slot="settings-card-actions"');
const alertAt = html.indexOf('role="alert"');
// The action row exists and is a real SettingsCardActions.
expect(actionAt).toBeGreaterThan(-1);
// The error line renders, and it sits ABOVE the action row.
expect(alertAt).toBeGreaterThan(-1);
expect(alertAt).toBeLessThan(actionAt);
// Nothing renders after the action row inside the form.
expect(html.indexOf("</form>")).toBeGreaterThan(actionAt);
},
);
it("toggles the managed modules with a Switch, matching the owner section", () => {
const html = render("modules", { modulePreferences: { mood: true } });
// The Switch primitive (role="switch") replaces the bare checkbox the
// owner module section never used; the hidden mirror keeps the submit name.
expect(html).toContain('role="switch"');
expect(html).toContain('name="mood"');
});
it("renders every direct module and threshold metric for a fresh record", () => {
const modules = render("modules", { modulePreferences: {} });
const thresholds = render("thresholds", {
@@ -285,10 +285,18 @@ export function AccountSection() {
}
if (!mounted || isLoading) {
// §13 — a loading section paints its card + header, not a bare centered
// spinner (reference: admin/coach-feedback-section).
return (
<div className="flex h-64 items-center justify-center">
<Loader2 className="text-primary h-8 w-8 animate-spin motion-reduce:animate-none" />
</div>
<SettingsCard>
<SettingsCardHeader icon={User} title={t("settings.profile")} />
<div className="flex items-center gap-2">
<Loader2 className="h-4 w-4 animate-spin motion-reduce:animate-none" />
<span className="text-muted-foreground text-sm">
{t("common.loading")}
</span>
</div>
</SettingsCard>
);
}
+2 -2
View File
@@ -108,7 +108,7 @@ function DataResetCard() {
{msg && (
<p
role="alert"
className={`mt-3 text-sm ${msgType === "success" ? "text-success" : "text-destructive"}`}
className={`text-sm ${msgType === "success" ? "text-success" : "text-destructive"}`}
>
{msg}
</p>
@@ -246,7 +246,7 @@ function AccountDeleteCard() {
{msg && (
<p
role="alert"
className={`mt-3 text-sm ${msgType === "success" ? "text-success" : "text-destructive"}`}
className={`text-sm ${msgType === "success" ? "text-success" : "text-destructive"}`}
>
{msg}
</p>
@@ -10,6 +10,7 @@ import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Switch } from "@/components/ui/switch";
import { SettingsCardActions } from "@/components/settings/_card-actions";
import { apiFetchRaw, apiPut } from "@/lib/api/api-fetch";
import { formatDateTime } from "@/lib/format";
import { useTranslations } from "@/lib/i18n/context";
@@ -199,8 +200,54 @@ export function RuntimeActionsRow({
void userProvider;
return (
// §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.
<div className="space-y-3">
<div className="flex flex-wrap items-center gap-2">
{canRegenerate && (
<div className="bg-muted/50 rounded-lg p-3">
<div className="flex items-center justify-between gap-4">
<div className="pr-2">
<p className="text-sm font-medium">{t("settings.rawData")}</p>
<p className="text-muted-foreground text-xs">
{privacyMode === "raw"
? t("settings.rawDataOnDescription")
: t("settings.rawDataOffDescription")}
</p>
</div>
<Switch
aria-label={t("settings.rawData")}
checked={privacyMode === "raw"}
onCheckedChange={togglePrivacy}
/>
</div>
{privacyMode === "raw" && (
<div className="bg-warning/15 text-warning mt-2 rounded-lg p-2 text-xs">
{t("settings.rawDataWarning")}
</div>
)}
</div>
)}
{lastInsightLine && (
<p className="text-muted-foreground text-xs">{lastInsightLine}</p>
)}
{testMsg && (
<p
className={`text-xs ${testOk ? "text-success" : "text-destructive"}`}
>
{testMsg}
</p>
)}
{regenMsg && (
<p
className={`text-xs ${regenOk ? "text-success" : "text-destructive"}`}
>
{regenMsg}
</p>
)}
<SettingsCardActions align="start">
<Button
type="button"
size="sm"
@@ -233,52 +280,7 @@ export function RuntimeActionsRow({
{t("settings.regenerateInsights")}
</Button>
)}
{lastInsightLine && (
<span className="text-muted-foreground text-xs">
{lastInsightLine}
</span>
)}
</div>
{canRegenerate && (
<div className="bg-muted/50 rounded-lg p-3">
<div className="flex items-center justify-between gap-4">
<div className="pr-2">
<p className="text-sm font-medium">{t("settings.rawData")}</p>
<p className="text-muted-foreground text-xs">
{privacyMode === "raw"
? t("settings.rawDataOnDescription")
: t("settings.rawDataOffDescription")}
</p>
</div>
<Switch
aria-label={t("settings.rawData")}
checked={privacyMode === "raw"}
onCheckedChange={togglePrivacy}
/>
</div>
{privacyMode === "raw" && (
<div className="bg-warning/15 text-warning mt-2 rounded-lg p-2 text-xs">
{t("settings.rawDataWarning")}
</div>
)}
</div>
)}
{testMsg && (
<p
className={`text-xs ${testOk ? "text-success" : "text-destructive"}`}
>
{testMsg}
</p>
)}
{regenMsg && (
<p
className={`text-xs ${regenOk ? "text-success" : "text-destructive"}`}
>
{regenMsg}
</p>
)}
</SettingsCardActions>
</div>
);
}
+2 -2
View File
@@ -406,9 +406,9 @@ function ApiTokensCard() {
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
variant="outline"
variant="destructive"
size="sm"
className="text-destructive border-destructive/30 min-h-11 w-full"
className="min-h-11 w-full"
>
<Trash2 className="h-3.5 w-3.5" />
{t("settings.tokenRevoke")}
@@ -72,13 +72,13 @@ export function DoseHistoryColumnRulings() {
<dt className="text-foreground flex flex-wrap items-baseline gap-x-2 text-xs">
<code className="font-mono">{ruling.column}</code>
<span
className="text-muted-foreground text-[0.6875rem] uppercase"
className="text-muted-foreground text-2xs uppercase"
data-verdict={ruling.verdict}
>
{verdictFor(ruling.verdict)}
</span>
{ruling.jsonOnly && (
<span className="text-muted-foreground text-[0.6875rem]">
<span className="text-muted-foreground text-2xs">
{t("settings.sections.export.import.doseHistory.jsonOnly")}
</span>
)}
@@ -82,7 +82,7 @@ function DeliveryDiagnostic({
return (
<div className="space-y-2" data-testid="apple-health-delivery">
<h3 className="text-sm font-semibold">
<h3 className="text-muted-foreground text-xs font-medium tracking-wide uppercase">
{t("settings.appleHealth.delivery.title")}
</h3>
{status.lastSyncedAt ? (
@@ -181,7 +181,7 @@ export function AppleHealthCard({ enabled }: { enabled: boolean }) {
/>
<div className="space-y-2">
<h3 className="text-sm font-semibold">
<h3 className="text-muted-foreground text-xs font-medium tracking-wide uppercase">
{t("settings.appleHealth.setupTitle")}
</h3>
<ol className="text-muted-foreground list-decimal space-y-2 pl-5 text-sm">
@@ -305,7 +305,7 @@ export function FitbitCard({
)}
<div className="space-y-3">
<h3 className="text-sm font-semibold">
<h3 className="text-muted-foreground text-xs font-medium tracking-wide uppercase">
{t("settings.fitbitCredentials")}
</h3>
{!status?.configured && (
@@ -502,7 +502,7 @@ export function GoogleHealthCard({
)}
<div className="space-y-3">
<h3 className="text-sm font-semibold">
<h3 className="text-muted-foreground text-xs font-medium tracking-wide uppercase">
{t("settings.googleHealthCredentials")}
</h3>
<form onSubmit={handleSaveCredentials} className="space-y-3">
@@ -332,7 +332,7 @@ export function OAuthProviderCard({
{credentials && (
<div className="space-y-3" data-testid={`${provider}-credentials`}>
<h3 className="text-sm font-semibold">
<h3 className="text-muted-foreground text-xs font-medium tracking-wide uppercase">
{t(`${i18nPrefix}Credentials`)}
</h3>
{!status?.hasOwnCredentials && (
@@ -287,7 +287,7 @@ export function WhoopCard({
)}
<div className="space-y-3">
<h3 className="text-sm font-semibold">
<h3 className="text-muted-foreground text-xs font-medium tracking-wide uppercase">
{t("settings.whoopCredentials")}
</h3>
{!status?.configured && (
@@ -326,7 +326,7 @@ export function WithingsCard({
</p>
)}
<div className="space-y-3">
<h3 className="text-sm font-semibold">
<h3 className="text-muted-foreground text-xs font-medium tracking-wide uppercase">
{t("settings.withingsCredentials")}
</h3>
{!status?.configured && (
@@ -1,6 +1,6 @@
"use client";
import { useState, type FormEvent } from "react";
import { useState, type FormEvent, type ReactNode } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Settings } from "lucide-react";
@@ -26,7 +26,13 @@ import {
import { ALL_METRICS } from "@/lib/validations/thresholds";
import { SUB_PAGE_TABS } from "@/components/insights/insights-tab-strip";
import { Button } from "@/components/ui/button";
import { DateField } from "@/components/ui/date-field";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { NativeSelect } from "@/components/ui/native-select";
import { QueryErrorCard } from "@/components/ui/query-error-card";
import { Switch } from "@/components/ui/switch";
import { SettingsCardActions } from "./_card-actions";
import { SettingsCardHeader } from "./_card-header";
import { SettingsCard } from "./settings-card";
@@ -45,6 +51,8 @@ type SettingsFormProps = {
disabled: boolean;
onSave: (patch: unknown) => void;
saveLabel: string;
/** Save/validation error, rendered ABOVE the action row per §12. */
error?: ReactNode;
};
const COACH_EXCLUDE_METRICS = [
@@ -222,11 +230,76 @@ function insightItemLabel(
: t("settings.sharedRecord.managedSettings.insights.unknownItem");
}
function SaveButton({ disabled, label }: { disabled: boolean; label: string }) {
/**
* The form footer, §12: the error line sits ABOVE the action row, and the
* action row (`SettingsCardActions`) is the last thing in the form. `span`
* lets a grid form put the footer in a full-width cell.
*/
function FormFooter({
disabled,
label,
error,
span,
}: {
disabled: boolean;
label: string;
error?: ReactNode;
span?: string;
}) {
return (
<Button disabled={disabled} type="submit">
{label}
</Button>
<div className={span ? `${span} space-y-3` : "space-y-3"}>
{error ? (
<p className="text-destructive text-sm" role="alert">
{error}
</p>
) : null}
<SettingsCardActions>
<Button disabled={disabled} type="submit">
{label}
</Button>
</SettingsCardActions>
</div>
);
}
/**
* A managed-settings toggle row — a `<Switch>` (the same primitive the owner
* module section uses) paired with a hidden mirror input so the FormData
* submit still reads `name === "on"`. Uncontrolled at the DOM level, controlled
* in React so the switch and its mirror stay in lockstep.
*/
function ManagedSwitchRow({
name,
label,
defaultChecked,
disabled,
className,
}: {
name: string;
label: string;
defaultChecked: boolean;
disabled: boolean;
className?: string;
}) {
const id = `managed-switch-${name}`;
const [on, setOn] = useState(defaultChecked);
return (
<div
className={className ?? "flex items-center justify-between gap-3 text-sm"}
>
<Label htmlFor={id} className="font-normal">
{label}
</Label>
<Switch
id={id}
checked={on}
onCheckedChange={setOn}
disabled={disabled}
aria-label={label}
className="shrink-0"
/>
<input name={name} type="hidden" value={on ? "on" : "off"} />
</div>
);
}
@@ -235,6 +308,7 @@ function ProfileSettingsForm({
disabled,
onSave,
saveLabel,
error,
}: SettingsFormProps) {
const { t } = useTranslations();
function submit(event: FormEvent<HTMLFormElement>) {
@@ -259,8 +333,7 @@ function ProfileSettingsForm({
<form className="grid gap-4 sm:grid-cols-2" onSubmit={submit}>
<label className="grid gap-1 text-sm" htmlFor="managed-display-name">
{t("settings.sharedRecord.managedSettings.profile.displayName")}
<input
className="border-input bg-background rounded-md border px-3 py-2"
<Input
defaultValue={asString(settings.displayName)}
disabled={disabled}
id="managed-display-name"
@@ -269,8 +342,7 @@ function ProfileSettingsForm({
</label>
<label className="grid gap-1 text-sm" htmlFor="managed-height">
{t("settings.sharedRecord.managedSettings.profile.height")}
<input
className="border-input bg-background rounded-md border px-3 py-2"
<Input
defaultValue={
settings.heightCm === null ? "" : String(settings.heightCm ?? "")
}
@@ -284,19 +356,20 @@ function ProfileSettingsForm({
</label>
<label className="grid gap-1 text-sm" htmlFor="managed-date-of-birth">
{t("settings.sharedRecord.managedSettings.profile.dateOfBirth")}
<input
className="border-input bg-background rounded-md border px-3 py-2"
{/* v1.25.4 — the profile date must not depend on the browser's date
parsing; DateField displays in the user's order and submits ISO
through its hidden mirror. */}
<DateField
defaultValue={asString(settings.dateOfBirth)}
disabled={disabled}
id="managed-date-of-birth"
name="dateOfBirth"
type="date"
autoComplete="bday"
/>
</label>
<label className="grid gap-1 text-sm" htmlFor="managed-gender">
{t("settings.sharedRecord.managedSettings.profile.gender")}
<select
className="border-input bg-background rounded-md border px-3 py-2"
<NativeSelect
defaultValue={asString(settings.gender)}
disabled={disabled}
id="managed-gender"
@@ -314,12 +387,11 @@ function ProfileSettingsForm({
<option value="OTHER">
{t("settings.sharedRecord.managedSettings.profile.other")}
</option>
</select>
</NativeSelect>
</label>
<label className="grid gap-1 text-sm" htmlFor="managed-locale">
{t("settings.sharedRecord.managedSettings.profile.locale")}
<select
className="border-input bg-background rounded-md border px-3 py-2"
<NativeSelect
defaultValue={asString(settings.locale)}
disabled={disabled}
id="managed-locale"
@@ -333,12 +405,11 @@ function ProfileSettingsForm({
{locale}
</option>
))}
</select>
</NativeSelect>
</label>
<label className="grid gap-1 text-sm" htmlFor="managed-timezone">
{t("settings.sharedRecord.managedSettings.profile.timezone")}
<input
className="border-input bg-background rounded-md border px-3 py-2"
<Input
defaultValue={asString(settings.timezone, "Europe/Berlin")}
disabled={disabled}
id="managed-timezone"
@@ -348,8 +419,7 @@ function ProfileSettingsForm({
</label>
<label className="grid gap-1 text-sm" htmlFor="managed-units">
{t("settings.sharedRecord.managedSettings.profile.units")}
<select
className="border-input bg-background rounded-md border px-3 py-2"
<NativeSelect
defaultValue={asString(settings.unitPreference, "metric")}
disabled={disabled}
id="managed-units"
@@ -361,12 +431,11 @@ function ProfileSettingsForm({
<option value="imperial">
{t("settings.sharedRecord.managedSettings.profile.imperial")}
</option>
</select>
</NativeSelect>
</label>
<label className="grid gap-1 text-sm" htmlFor="managed-time-format">
{t("settings.sharedRecord.managedSettings.profile.timeFormat")}
<select
className="border-input bg-background rounded-md border px-3 py-2"
<NativeSelect
defaultValue={asString(settings.timeFormat, "AUTO")}
disabled={disabled}
id="managed-time-format"
@@ -381,12 +450,11 @@ function ProfileSettingsForm({
<option value="H24">
{t("settings.sharedRecord.managedSettings.profile.hour24")}
</option>
</select>
</NativeSelect>
</label>
<label className="grid gap-1 text-sm" htmlFor="managed-date-format">
{t("settings.sharedRecord.managedSettings.profile.dateFormat")}
<select
className="border-input bg-background rounded-md border px-3 py-2"
<NativeSelect
defaultValue={asString(settings.dateFormat, "AUTO")}
disabled={disabled}
id="managed-date-format"
@@ -404,11 +472,14 @@ function ProfileSettingsForm({
<option value="YMD">
{t("settings.sharedRecord.managedSettings.profile.dateYmd")}
</option>
</select>
</NativeSelect>
</label>
<div className="sm:col-span-2">
<SaveButton disabled={disabled} label={saveLabel} />
</div>
<FormFooter
disabled={disabled}
label={saveLabel}
error={error}
span="sm:col-span-2"
/>
</form>
);
}
@@ -418,6 +489,7 @@ function ModulesSettingsForm({
disabled,
onSave,
saveLabel,
error,
}: SettingsFormProps) {
const { t } = useTranslations();
const preferences = asRecord(settings.modulePreferences);
@@ -439,21 +511,19 @@ function ModulesSettingsForm({
return (
<form className="space-y-3" onSubmit={submit}>
{entries.map(([key, defaultEnabled]) => (
<label className="flex items-center gap-3 text-sm" key={key}>
<input
defaultChecked={
preferences[key] === undefined
? defaultEnabled
: preferences[key] === true
}
disabled={disabled}
name={key}
type="checkbox"
/>
{t(MODULE_REGISTRY[key].labelKey)}
</label>
<ManagedSwitchRow
key={key}
name={key}
label={t(MODULE_REGISTRY[key].labelKey)}
defaultChecked={
preferences[key] === undefined
? defaultEnabled
: preferences[key] === true
}
disabled={disabled}
/>
))}
<SaveButton disabled={disabled} label={saveLabel} />
<FormFooter disabled={disabled} label={saveLabel} error={error} />
</form>
);
}
@@ -463,6 +533,7 @@ function NotificationsSettingsForm({
disabled,
onSave,
saveLabel,
error,
}: SettingsFormProps) {
const { t } = useTranslations();
const preferences = asRecord(settings.notificationPreferences);
@@ -489,19 +560,18 @@ function NotificationsSettingsForm({
return (
<form className="grid gap-4 sm:grid-cols-2" onSubmit={submit}>
<label className="flex items-center gap-3 text-sm sm:col-span-2">
<input
defaultChecked={settings.moodReminderEnabled === true}
disabled={disabled}
name="moodReminderEnabled"
type="checkbox"
/>
{t("settings.sharedRecord.managedSettings.notifications.moodReminder")}
</label>
<ManagedSwitchRow
name="moodReminderEnabled"
label={t(
"settings.sharedRecord.managedSettings.notifications.moodReminder",
)}
defaultChecked={settings.moodReminderEnabled === true}
disabled={disabled}
className="flex items-center justify-between gap-3 text-sm sm:col-span-2"
/>
<label className="grid gap-1 text-sm" htmlFor="managed-reminder-hour">
{t("settings.sharedRecord.managedSettings.notifications.reminderHour")}
<input
className="border-input bg-background rounded-md border px-3 py-2"
<Input
defaultValue={asNumber(mood.reminderHour, 22)}
disabled={disabled}
id="managed-reminder-hour"
@@ -515,8 +585,7 @@ function NotificationsSettingsForm({
{t(
"settings.sharedRecord.managedSettings.notifications.lowStockRunwayDays",
)}
<input
className="border-input bg-background rounded-md border px-3 py-2"
<Input
defaultValue={
medication.lowStockRunwayDays === null
? ""
@@ -534,8 +603,7 @@ function NotificationsSettingsForm({
{t(
"settings.sharedRecord.managedSettings.notifications.reorderLeadDays",
)}
<input
className="border-input bg-background rounded-md border px-3 py-2"
<Input
defaultValue={asNumber(medication.reorderLeadDays, 10)}
disabled={disabled}
id="managed-reorder-lead"
@@ -545,9 +613,12 @@ function NotificationsSettingsForm({
type="number"
/>
</label>
<div className="sm:col-span-2">
<SaveButton disabled={disabled} label={saveLabel} />
</div>
<FormFooter
disabled={disabled}
label={saveLabel}
error={error}
span="sm:col-span-2"
/>
</form>
);
}
@@ -557,6 +628,7 @@ function ThresholdsSettingsForm({
disabled,
onSave,
saveLabel,
error,
}: SettingsFormProps) {
const { t } = useTranslations();
const overrides = asRecord(settings.overrides);
@@ -591,8 +663,7 @@ function ThresholdsSettingsForm({
<form className="grid gap-4 sm:grid-cols-3" onSubmit={submit}>
<label className="grid gap-1 text-sm" htmlFor="managed-threshold-metric">
{t("settings.sharedRecord.managedSettings.thresholds.metric")}
<select
className="border-input bg-background rounded-md border px-3 py-2"
<NativeSelect
onChange={(event) => setMetric(event.target.value as ThresholdMetric)}
value={metric}
disabled={disabled}
@@ -604,12 +675,11 @@ function ThresholdsSettingsForm({
{t(METRIC_LABEL_KEYS[entry])}
</option>
))}
</select>
</NativeSelect>
</label>
<label className="grid gap-1 text-sm" htmlFor="managed-threshold-minimum">
{t("settings.sharedRecord.managedSettings.thresholds.minimum")}
<input
className="border-input bg-background rounded-md border px-3 py-2"
<Input
defaultValue={minimum}
disabled={disabled}
id="managed-threshold-minimum"
@@ -621,8 +691,7 @@ function ThresholdsSettingsForm({
</label>
<label className="grid gap-1 text-sm" htmlFor="managed-threshold-maximum">
{t("settings.sharedRecord.managedSettings.thresholds.maximum")}
<input
className="border-input bg-background rounded-md border px-3 py-2"
<Input
defaultValue={maximum}
disabled={disabled}
id="managed-threshold-maximum"
@@ -632,9 +701,12 @@ function ThresholdsSettingsForm({
type="number"
/>
</label>
<div className="sm:col-span-3">
<SaveButton disabled={disabled} label={saveLabel} />
</div>
<FormFooter
disabled={disabled}
label={saveLabel}
error={error}
span="sm:col-span-3"
/>
</form>
);
}
@@ -644,6 +716,7 @@ function CoachSettingsForm({
disabled,
onSave,
saveLabel,
error,
}: SettingsFormProps) {
const { t } = useTranslations();
const preferences = asRecord(settings.preferences);
@@ -676,20 +749,16 @@ function CoachSettingsForm({
return (
<form className="space-y-4" onSubmit={submit}>
<label className="flex items-center gap-3 text-sm">
<input
defaultChecked={settings.disableCoach === true}
disabled={disabled}
name="disableCoach"
type="checkbox"
/>
{t("settings.sharedRecord.managedSettings.coach.disable")}
</label>
<ManagedSwitchRow
name="disableCoach"
label={t("settings.sharedRecord.managedSettings.coach.disable")}
defaultChecked={settings.disableCoach === true}
disabled={disabled}
/>
<div className="grid gap-4 sm:grid-cols-3">
<label className="grid gap-1 text-sm" htmlFor="managed-coach-tone">
{t("settings.sharedRecord.managedSettings.coach.tone")}
<select
className="border-input bg-background rounded-md border px-3 py-2"
<NativeSelect
defaultValue={asString(preferences.tone, "warm")}
disabled={disabled}
id="managed-coach-tone"
@@ -704,12 +773,11 @@ function CoachSettingsForm({
<option value="concise">
{t("settings.sharedRecord.managedSettings.coach.concise")}
</option>
</select>
</NativeSelect>
</label>
<label className="grid gap-1 text-sm" htmlFor="managed-coach-verbosity">
{t("settings.sharedRecord.managedSettings.coach.verbosity")}
<select
className="border-input bg-background rounded-md border px-3 py-2"
<NativeSelect
defaultValue={asString(preferences.verbosity, "default")}
disabled={disabled}
id="managed-coach-verbosity"
@@ -724,12 +792,11 @@ function CoachSettingsForm({
<option value="detailed">
{t("settings.sharedRecord.managedSettings.coach.detailed")}
</option>
</select>
</NativeSelect>
</label>
<label className="grid gap-1 text-sm" htmlFor="managed-coach-window">
{t("settings.sharedRecord.managedSettings.coach.defaultWindow")}
<select
className="border-input bg-background rounded-md border px-3 py-2"
<NativeSelect
defaultValue={asString(preferences.defaultWindow, "allTime")}
disabled={disabled}
id="managed-coach-window"
@@ -747,66 +814,56 @@ function CoachSettingsForm({
<option value="allTime">
{t("settings.sharedRecord.managedSettings.coach.allTime")}
</option>
</select>
</NativeSelect>
</label>
</div>
<label className="flex items-center gap-3 text-sm">
<input
defaultChecked={preferences.showEvidenceByDefault === true}
disabled={disabled}
name="showEvidenceByDefault"
type="checkbox"
/>
{t("settings.sharedRecord.managedSettings.coach.showEvidence")}
</label>
<ManagedSwitchRow
name="showEvidenceByDefault"
label={t("settings.sharedRecord.managedSettings.coach.showEvidence")}
defaultChecked={preferences.showEvidenceByDefault === true}
disabled={disabled}
/>
<fieldset className="space-y-2">
<legend className="text-sm font-medium">
{t("settings.sharedRecord.managedSettings.coach.excludedMetrics")}
</legend>
{COACH_EXCLUDE_METRICS.map((metric) => (
<label
className="mr-4 inline-flex items-center gap-2 text-sm"
key={metric}
>
<input
<div className="grid gap-2 sm:grid-cols-2">
{COACH_EXCLUDE_METRICS.map((metric) => (
<ManagedSwitchRow
key={metric}
name={`exclude-${metric}`}
label={coachMetricLabel(t, metric)}
defaultChecked={excluded.has(metric)}
disabled={disabled}
name={`exclude-${metric}`}
type="checkbox"
/>
{coachMetricLabel(t, metric)}
</label>
))}
))}
</div>
</fieldset>
<fieldset className="space-y-2">
<legend className="text-sm font-medium">
{t("settings.sharedRecord.managedSettings.coach.dataClusters")}
</legend>
<label className="mb-2 flex items-center gap-2 text-sm">
<input
defaultChecked={Array.isArray(preferences.dataClusters)}
disabled={disabled}
name="useCustomClusters"
type="checkbox"
/>
{t("settings.sharedRecord.managedSettings.coach.customClusters")}
</label>
{COACH_DATA_CLUSTERS.map((cluster) => (
<label
className="mr-4 inline-flex items-center gap-2 text-sm"
key={cluster}
>
<input
<ManagedSwitchRow
name="useCustomClusters"
label={t(
"settings.sharedRecord.managedSettings.coach.customClusters",
)}
defaultChecked={Array.isArray(preferences.dataClusters)}
disabled={disabled}
/>
<div className="grid gap-2 sm:grid-cols-2">
{COACH_DATA_CLUSTERS.map((cluster) => (
<ManagedSwitchRow
key={cluster}
name={`cluster-${cluster}`}
label={coachClusterLabel(t, cluster)}
defaultChecked={dataClusters.has(cluster)}
disabled={disabled}
name={`cluster-${cluster}`}
type="checkbox"
/>
{coachClusterLabel(t, cluster)}
</label>
))}
))}
</div>
</fieldset>
<SaveButton disabled={disabled} label={saveLabel} />
<FormFooter disabled={disabled} label={saveLabel} error={error} />
</form>
);
}
@@ -835,6 +892,7 @@ function InsightsSettingsForm({
disabled,
onSave,
saveLabel,
error,
}: SettingsFormProps) {
const { t } = useTranslations();
const layout = asRecord(settings.layout);
@@ -867,22 +925,20 @@ function InsightsSettingsForm({
</legend>
{items.map((item) => (
<div className="flex items-center gap-3 text-sm" key={item.id}>
<label className="flex flex-1 items-center gap-2">
<input
defaultChecked={item.visible}
disabled={disabled}
name={`${kind}-visible-${item.id}`}
type="checkbox"
/>
{insightItemLabel(t, kind, item.id)}
</label>
<ManagedSwitchRow
className="flex flex-1 items-center justify-between gap-2 text-sm"
name={`${kind}-visible-${item.id}`}
label={insightItemLabel(t, kind, item.id)}
defaultChecked={item.visible}
disabled={disabled}
/>
<label
className="flex items-center gap-2"
htmlFor={`${kind}-order-${item.id}`}
>
{t("settings.sharedRecord.managedSettings.insights.order")}
<input
className="border-input bg-background w-16 rounded-md border px-2 py-1"
<Input
className="w-16"
defaultValue={item.order}
disabled={disabled}
id={`${kind}-order-${item.id}`}
@@ -900,7 +956,7 @@ function InsightsSettingsForm({
<form className="space-y-4" onSubmit={submit}>
{renderItems("section", sections)}
{renderItems("tile", tiles)}
<SaveButton disabled={disabled} label={saveLabel} />
<FormFooter disabled={disabled} label={saveLabel} error={error} />
</form>
);
}
@@ -994,32 +1050,30 @@ export function ManagedRecordSettingsSection({
title={t(`settings.sharedRecord.managedSettings.${family}.title`)}
/>
{query.data ? (
<>
<ManagedRecordSettingsForm
disabled={save.isPending}
family={family}
onSave={(patch) => {
const parsed = safeParseManagedRecordSettingsPatch(family, patch);
if (!parsed.success) {
setValidationFailed(true);
return;
}
setValidationFailed(false);
save.mutate(parsed.data);
}}
saveLabel={t("common.save")}
settings={query.data.settings}
/>
{validationFailed ? (
<p className="text-destructive text-sm" role="alert">
{t("settings.sharedRecord.managedSettings.validationError")}
</p>
) : save.isError ? (
<p className="text-destructive text-sm" role="alert">
{t("settings.sharedRecord.managedSettings.saveError")}
</p>
) : null}
</>
<ManagedRecordSettingsForm
disabled={save.isPending}
family={family}
// §12 — the form renders this ABOVE its action row; nothing follows
// the action.
error={
validationFailed
? t("settings.sharedRecord.managedSettings.validationError")
: save.isError
? t("settings.sharedRecord.managedSettings.saveError")
: undefined
}
onSave={(patch) => {
const parsed = safeParseManagedRecordSettingsPatch(family, patch);
if (!parsed.success) {
setValidationFailed(true);
return;
}
setValidationFailed(false);
save.mutate(parsed.data);
}}
saveLabel={t("common.save")}
settings={query.data.settings}
/>
) : null}
</SettingsCard>
);
+7 -10
View File
@@ -34,6 +34,7 @@ import {
} from "@/components/ui/alert-dialog";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { EmptyState } from "@/components/ui/empty-state";
import { Input } from "@/components/ui/input";
import { QueryErrorCard } from "@/components/ui/query-error-card";
import { Skeleton } from "@/components/ui/skeleton";
@@ -122,9 +123,7 @@ function McpConnectionsCard() {
) : isLoading ? (
<Skeleton className="h-24 w-full rounded-lg" />
) : list.length === 0 ? (
<p className="text-muted-foreground rounded-lg border border-dashed px-3 py-4 text-center text-sm">
{t("settings.mcp.noConnections")}
</p>
<EmptyState size="compact" title={t("settings.mcp.noConnections")} />
) : (
<ul className="space-y-2">
{list.map((conn) => (
@@ -157,9 +156,9 @@ function McpConnectionsCard() {
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
variant="outline"
variant="destructive"
size="sm"
className="text-destructive border-destructive/30 min-h-11 w-full"
className="min-h-11 w-full"
>
<Trash2 className="h-3.5 w-3.5" />
{t("settings.mcp.connectionRevoke")}
@@ -440,9 +439,7 @@ function McpTokensCard() {
) : isLoading ? (
<Skeleton className="h-24 w-full rounded-lg" />
) : activeTokens.length === 0 ? (
<p className="text-muted-foreground rounded-lg border border-dashed px-3 py-4 text-center text-sm">
{t("settings.noActiveTokens")}
</p>
<EmptyState size="compact" title={t("settings.noActiveTokens")} />
) : (
<ul className="space-y-2">
{activeTokens.map((tok) => {
@@ -490,9 +487,9 @@ function McpTokensCard() {
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
variant="outline"
variant="destructive"
size="sm"
className="text-destructive border-destructive/30 min-h-11 w-full"
className="min-h-11 w-full"
>
<Trash2 className="h-3.5 w-3.5" />
{t("settings.tokenRevoke")}
@@ -325,9 +325,9 @@ export function TotpCard({
{/* ── Fresh recovery codes (post-confirm or post-regen) ── */}
{freshCodes && (
<div>
<div className="space-y-3">
<RecoveryCodesPanel codes={freshCodes} />
<SettingsCardActions className="mt-3">
<SettingsCardActions>
<Button
type="button"
variant="outline"
+4 -1
View File
@@ -236,12 +236,15 @@ function ShareLinksCard() {
? ` · ${formatDateTime(link.lastAccessAt)}`
: ""}
</p>
{/* §12 — a right-aligned row action, not a full-width
destructive strip stacked once per link (which reads as
an alarm wall on a list of shares). */}
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
variant="destructive"
size="sm"
className="min-h-11 w-full sm:min-h-9"
className="ml-auto flex min-h-11 w-fit sm:min-h-9"
>
<Trash2 className="mr-2 h-3.5 w-3.5" />
{t("settings.sharing.revoke")}
@@ -172,7 +172,7 @@ function DoseRow({
: undefined
}
>
<CardContent className="flex items-center justify-between gap-3 px-4 py-2.5">
<CardContent className="flex items-center justify-between gap-3 px-4 py-3">
<div className="min-w-0 space-y-0.5">
<div className="flex flex-wrap items-center gap-x-2 text-sm">
{series ? (