mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
refactor(ui): give Settings and Admin one card rhythm and one page header
The two consoles used the same primitives and still read as two systems, because everything without a primitive had drifted: the header-to-body step existed in three competing techniques at once (`mt-4` on the first body child, `space-y-4` on the card, `mb-4` on the header), the body started either at the card edge or 28 px in, and both shells hand-rolled the page title instead of calling `PageHeader`. Move the decisions into the primitives so a call site cannot re-open them: - `SettingsCard` is gap-based like `Card` (`flex flex-col gap-4`). The header-to-body step is the container's, not a margin somebody remembers. A dense card still overrides the gap on the card itself. - The body starts at the card edge. `pl-7` is gone — off the spacing scale, and it mis-aligned action rows against their own forms wherever two neighbouring cards disagreed about it. - The card header's description slot loses its `space-y-1`. That was the licence call sites used to stack explainer paragraphs into a muted `text-xs` slot; the slot holds one sentence. - New `SettingsCardActions`: one action row, trailing edge, last thing in the card. `align="start"` spells the one exception, a card whose only actions are links out. - New `StatTile` replaces four hand-rolled label/value boxes in Admin. - `SectionHeading` moves from `insights/` to `ui/`. It is the heading above a group of cards, which is what Settings needs; Settings had been using a card header with no card, which reads as a card that failed to paint. Both shells now render their heading through `PageHeader`, which gained `headingAs="div"` for the second copy each shell paints (mobile above the nav strip, desktop inside the grid) so only one is a real `<h1>`. The page explainer is muted everywhere: the primitive said foreground, the standards and every hand-rolled copy said muted, so pages disagreed with each other. Medications, Vorsorge, the labs / custom-metric / illness detail pages and the clinician view drop their hand-rolled title blocks too, including the mid-word truncation on the medications subtitle. Tests pin the parts that can be checked exactly: the card's gap, the description slot's missing rhythm, the action row's two alignments, the `<h1>`-free shells, and a sweep asserting `pl-7` stays gone.
This commit is contained in:
1 parent
00e8d05235
commit
5445bfc1f1
121 files changed
+832
-620
No files matched your search
@@ -33,6 +33,7 @@ import {
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { EmptyState } from "@/components/ui/empty-state";
|
||||
import { PageHeader } from "@/components/ui/page-header";
|
||||
import { QueryErrorCard } from "@/components/ui/query-error-card";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import {
|
||||
@@ -359,14 +360,10 @@ export default function MedicationsPageClient() {
|
||||
if (!isAuthenticated) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="space-y-1">
|
||||
<h1 className="text-2xl font-bold tracking-tight">
|
||||
{t("medications.title")}
|
||||
</h1>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t("medications.loginRequired")}
|
||||
</p>
|
||||
</div>
|
||||
<PageHeader
|
||||
title={t("medications.title")}
|
||||
description={t("medications.loginRequired")}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -407,90 +404,83 @@ export default function MedicationsPageClient() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<PullToRefreshIndicator {...pull} />
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0 space-y-1">
|
||||
<h1
|
||||
data-tour-id="medications-hero"
|
||||
className="text-2xl font-bold tracking-tight"
|
||||
>
|
||||
{t("medications.title")}
|
||||
</h1>
|
||||
{/* v1.4.34 IW-G — subtitle stays visible on mobile so the
|
||||
H1 isn't an unframed label. */}
|
||||
<p className="text-muted-foreground truncate text-xs sm:text-sm">
|
||||
{t("medications.subtitle")}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
{/* v1.16.11 (#316) — "Alle fälligen einnehmen". Contextual, not a
|
||||
<PageHeader
|
||||
title={
|
||||
<span data-tour-id="medications-hero">{t("medications.title")}</span>
|
||||
}
|
||||
description={t("medications.subtitle")}
|
||||
actions={
|
||||
<>
|
||||
{/* v1.16.11 (#316) — "Alle fälligen einnehmen". Contextual, not a
|
||||
permanent fixture: it renders only while at least TWO
|
||||
medications are currently due (a single due dose is the
|
||||
card's own one-tap job). Calm outline variant so the primary
|
||||
Add button keeps the visual lead; same responsive 44-px
|
||||
mobile tap floor as its neighbours. */}
|
||||
{canAdd && dueMeds.length >= 2 && (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setTakeAllOpen(true)}
|
||||
className="min-h-11 sm:min-h-9"
|
||||
>
|
||||
<CheckCheck className="h-4 w-4" aria-hidden="true" />
|
||||
{t("medications.takeAllDue.button")}
|
||||
</Button>
|
||||
)}
|
||||
{/* v1.16.11 — the wrench is the one customize entry point:
|
||||
{canAdd && dueMeds.length >= 2 && (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setTakeAllOpen(true)}
|
||||
className="min-h-11 sm:min-h-9"
|
||||
>
|
||||
<CheckCheck className="h-4 w-4" aria-hidden="true" />
|
||||
{t("medications.takeAllDue.button")}
|
||||
</Button>
|
||||
)}
|
||||
{/* v1.16.11 — the wrench is the one customize entry point:
|
||||
it links to /settings/medications, which owns the view
|
||||
preference (cards ⇄ table) and the manual-order editor.
|
||||
Same glyph, slot (left of the add button) and responsive
|
||||
44-px mobile tap floor as the dashboard and insights
|
||||
headers. */}
|
||||
{canManage && (
|
||||
<Button
|
||||
asChild
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="min-h-11 min-w-11 sm:min-h-9 sm:min-w-9"
|
||||
>
|
||||
<Link
|
||||
href="/settings/layout/medications"
|
||||
aria-label={t("medications.customize")}
|
||||
title={t("medications.customize")}
|
||||
{canManage && (
|
||||
<Button
|
||||
asChild
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="min-h-11 min-w-11 sm:min-h-9 sm:min-w-9"
|
||||
>
|
||||
<Wrench className="h-4 w-4" aria-hidden="true" />
|
||||
</Link>
|
||||
</Button>
|
||||
)}
|
||||
{/* v1.14.0 — the "Add" button is now a choice: log an intake
|
||||
<Link
|
||||
href="/settings/layout/medications"
|
||||
aria-label={t("medications.customize")}
|
||||
title={t("medications.customize")}
|
||||
>
|
||||
<Wrench className="h-4 w-4" aria-hidden="true" />
|
||||
</Link>
|
||||
</Button>
|
||||
)}
|
||||
{/* v1.14.0 — the "Add" button is now a choice: log an intake
|
||||
(incl. a backdated one) against an existing medication, or
|
||||
create a new medication. v1.12.2 — match the dashboard Add
|
||||
button's responsive tap-target floor (`min-h-11 sm:min-h-9`) so
|
||||
both primary "add" entry points clear the WCAG 2.5.5 44px mobile
|
||||
minimum identically. */}
|
||||
{canAdd && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button className="min-h-11 sm:min-h-9">
|
||||
<Plus className="h-4 w-4" />
|
||||
{t("medications.addMedication")}
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem
|
||||
onSelect={() => setLogIntakeOpen(true)}
|
||||
disabled={activeMeds.length === 0}
|
||||
>
|
||||
<CheckCircle2 className="mr-2 h-4 w-4" />
|
||||
{t("medications.addChoice.logIntake")}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onSelect={openCreate}>
|
||||
<Pill className="mr-2 h-4 w-4" />
|
||||
{t("medications.addChoice.newMedication")}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{canAdd && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button className="min-h-11 sm:min-h-9">
|
||||
<Plus className="h-4 w-4" />
|
||||
{t("medications.addMedication")}
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem
|
||||
onSelect={() => setLogIntakeOpen(true)}
|
||||
disabled={activeMeds.length === 0}
|
||||
>
|
||||
<CheckCircle2 className="mr-2 h-4 w-4" />
|
||||
{t("medications.addChoice.logIntake")}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onSelect={openCreate}>
|
||||
<Pill className="mr-2 h-4 w-4" />
|
||||
{t("medications.addChoice.newMedication")}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
{isLoading || isLayoutLoading ? (
|
||||
// v1.11.3 C4 — skeletons instead of a bare centred spinner so the
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import * as React from "react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/**
|
||||
* `<StatTile>` — the one label/value mini tile in the Admin console.
|
||||
*
|
||||
* Four hand-rolled variants of this box existed side by side: `StatusItem`
|
||||
* (`bg-muted/50 p-3`), the encryption section's local `Stat`
|
||||
* (`bg-muted/40 px-3 py-2`), the user-management inline panels
|
||||
* (`bg-muted/80 p-4`), and the backups upload box (`bg-muted/30 p-3`). Four
|
||||
* fills, three paddings, one role. This is the role.
|
||||
*
|
||||
* `icon` is optional — a tile with a glyph and one without still share the
|
||||
* frame, so a grid can mix them without a step in tile height.
|
||||
*/
|
||||
export function StatTile({
|
||||
icon: Icon,
|
||||
label,
|
||||
value,
|
||||
valueClassName,
|
||||
className,
|
||||
}: {
|
||||
icon?: React.ComponentType<{ className?: string }>;
|
||||
label: React.ReactNode;
|
||||
value: React.ReactNode;
|
||||
/** Extra classes on the value line — a status colour, or `font-mono`. */
|
||||
valueClassName?: string;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
data-slot="stat-tile"
|
||||
className={cn("bg-muted/50 space-y-1 rounded-lg p-3", className)}
|
||||
>
|
||||
<div className="text-muted-foreground flex items-center gap-1.5 text-xs">
|
||||
{Icon ? <Icon className="size-3.5 shrink-0" /> : null}
|
||||
{label}
|
||||
</div>
|
||||
<p className={cn("text-sm font-semibold", valueClassName)}>{value}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -37,6 +37,7 @@ import {
|
||||
} from "lucide-react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import { PageHeader } from "@/components/ui/page-header";
|
||||
import { SUB_SHELL_GRID_FLOOR } from "@/components/layout/shell-metrics";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
@@ -253,21 +254,15 @@ export function AdminShell({ active, children }: AdminShellProps) {
|
||||
? t("admin.subtitle")
|
||||
: t(`admin.section.${activeSlug}.subtitle`);
|
||||
|
||||
// Same two-copy heading as the Settings shell (mobile above the chip strip,
|
||||
// desktop inside the grid row) and the same primitive, so the two consoles
|
||||
// cannot drift a step apart again.
|
||||
const headingBlock = (mobile = false) => (
|
||||
<div>
|
||||
{mobile ? (
|
||||
<div
|
||||
role="heading"
|
||||
aria-level={1}
|
||||
className="text-2xl font-bold tracking-tight"
|
||||
>
|
||||
{headingTitle}
|
||||
</div>
|
||||
) : (
|
||||
<h1 className="text-2xl font-bold tracking-tight">{headingTitle}</h1>
|
||||
)}
|
||||
<p className="text-muted-foreground text-sm">{headingSubtitle}</p>
|
||||
</div>
|
||||
<PageHeader
|
||||
headingAs={mobile ? "div" : "h1"}
|
||||
title={headingTitle}
|
||||
description={headingSubtitle}
|
||||
/>
|
||||
);
|
||||
|
||||
// v1.4.25 W8 — AuthShell wraps the page in `px-4 py-6 md:px-6`
|
||||
|
||||
@@ -76,7 +76,7 @@ export function AiQualitySection() {
|
||||
// resolves. Mirrors <CoachFeedbackSection> (header outside the
|
||||
// fetch-state branch).
|
||||
return (
|
||||
<SettingsCard className="space-y-4">
|
||||
<SettingsCard>
|
||||
<SettingsCardHeader
|
||||
icon={Sparkles}
|
||||
title={t("admin.aiQuality.title")}
|
||||
|
||||
@@ -114,7 +114,7 @@ export function AiServerKeySection() {
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="mt-4 space-y-4 pl-7">
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="admin-ai-server-key-input">
|
||||
{t("admin.aiServerKey.keyLabel")}
|
||||
|
||||
@@ -160,7 +160,7 @@ export function ApiTokenOverviewSection() {
|
||||
description={t("admin.apiTokensDescription")}
|
||||
/>
|
||||
|
||||
<div className="mt-4">
|
||||
<div>
|
||||
{isLoading ? (
|
||||
<div className="flex justify-center py-4">
|
||||
<Loader2 className="text-muted-foreground h-5 w-5 animate-spin motion-reduce:animate-none" />
|
||||
|
||||
@@ -116,7 +116,7 @@ export function AppLogPreviewSection() {
|
||||
const events = data?.events ?? [];
|
||||
|
||||
return (
|
||||
<SettingsCard className="space-y-4">
|
||||
<SettingsCard>
|
||||
<SettingsCardHeader
|
||||
icon={FileText}
|
||||
title={t("admin.section.app-logs.title")}
|
||||
@@ -280,7 +280,7 @@ export function AppLogPreviewSection() {
|
||||
</table>
|
||||
</div>
|
||||
<div
|
||||
className="text-muted-foreground mt-3 flex items-center justify-between text-xs"
|
||||
className="text-muted-foreground flex items-center justify-between text-xs"
|
||||
data-testid="app-log-preview-summary"
|
||||
>
|
||||
<span>
|
||||
|
||||
@@ -103,7 +103,7 @@ export function AssistantSection() {
|
||||
title={t("admin.assistant.title")}
|
||||
description={t("admin.assistant.description")}
|
||||
/>
|
||||
<div className="mt-4 space-y-4 pl-7">
|
||||
<div className="space-y-4">
|
||||
<SettingsToggle
|
||||
label={t("admin.assistant.master.title")}
|
||||
description={t("admin.assistant.master.description")}
|
||||
|
||||
@@ -493,13 +493,8 @@ export function BackupsSection() {
|
||||
|
||||
return (
|
||||
<SettingsCard>
|
||||
{/* v1.18.1 E3 — the snapshot count leads (numbers first), the
|
||||
"Run now" button follows, and the explainer + docs link move
|
||||
into the header's description slot. Previously the description
|
||||
sat in a separate `mt-1 pl-7` paragraph that crowded the button
|
||||
directly above it; routing it through the header's `space-y-1`
|
||||
stack restores breathing room and left-aligns it under the
|
||||
title. */}
|
||||
{/* v1.18.1 E3 — the snapshot count leads (numbers first) and the
|
||||
"Run now" button follows. */}
|
||||
<SettingsCardHeader
|
||||
icon={Database}
|
||||
title={t("admin.section.backups.title")}
|
||||
@@ -547,7 +542,7 @@ export function BackupsSection() {
|
||||
backup file independently of any existing rows. The visible
|
||||
button proxies a hidden file input so the layout stays clean
|
||||
while keyboard / screen-reader users keep a labelled control. */}
|
||||
<div className="bg-muted/30 border-border mt-4 flex flex-col gap-2 rounded-md border p-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="bg-muted/30 border-border flex flex-col gap-2 rounded-md border p-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<div className="text-sm font-medium">
|
||||
{t("admin.section.backups.uploadTitle")}
|
||||
@@ -596,7 +591,7 @@ export function BackupsSection() {
|
||||
) : null}
|
||||
|
||||
{isLoading ? (
|
||||
<div className="mt-4 flex items-center gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Loader2 className="text-muted-foreground h-4 w-4 animate-spin motion-reduce:animate-none" />
|
||||
<span className="text-muted-foreground text-sm">
|
||||
{t("admin.section.backups.loading")}
|
||||
@@ -605,7 +600,7 @@ export function BackupsSection() {
|
||||
) : isError ? (
|
||||
<div
|
||||
role="alert"
|
||||
className="text-destructive bg-destructive/10 border-destructive/30 mt-4 rounded-md border px-3 py-2 text-sm"
|
||||
className="text-destructive bg-destructive/10 border-destructive/30 rounded-md border px-3 py-2 text-sm"
|
||||
>
|
||||
{t("admin.section.backups.loadError")}
|
||||
</div>
|
||||
@@ -614,7 +609,7 @@ export function BackupsSection() {
|
||||
// primitive. The header already exposes "Backup now" but a
|
||||
// brand-new admin lands inside the card and benefits from a
|
||||
// duplicate CTA right next to the explanation.
|
||||
<div className="mt-4">
|
||||
<div>
|
||||
<EmptyState
|
||||
icon={<Database className="size-6" />}
|
||||
title={t("admin.section.backups.emptyTitle")}
|
||||
@@ -637,7 +632,7 @@ export function BackupsSection() {
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-4">
|
||||
<div>
|
||||
{/* Wide layout: the canonical table. Hidden below `md`, where the
|
||||
five columns push Download / Restore off-screen behind a
|
||||
horizontal scroll; the card list below takes over there. */}
|
||||
|
||||
@@ -166,7 +166,7 @@ export function CentralCodexSection() {
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="mt-4 space-y-4 pl-7">
|
||||
<div className="space-y-4">
|
||||
<div className="text-muted-foreground flex items-start gap-2 text-xs">
|
||||
<ShieldAlert className="mt-0.5 size-3.5 shrink-0" aria-hidden />
|
||||
<p className="min-w-0">{t("admin.centralCodex.honesty")}</p>
|
||||
|
||||
@@ -71,7 +71,7 @@ export function CoachFeedbackSection() {
|
||||
// /admin/coach-feedback. Mirrors the canonical structure used by
|
||||
// <SystemStatusSection> (header outside the fetch-state branch).
|
||||
return (
|
||||
<SettingsCard className="space-y-4">
|
||||
<SettingsCard>
|
||||
<SettingsCardHeader
|
||||
icon={Sparkles}
|
||||
title={t("admin.coachFeedback.title")}
|
||||
|
||||
@@ -170,7 +170,7 @@ export function DangerZoneSection() {
|
||||
/>
|
||||
{wipeMsg && (
|
||||
<p
|
||||
className={`mt-3 pl-7 text-sm ${
|
||||
className={`mt-3 text-sm ${
|
||||
wipeAllData.isError ? "text-destructive" : "text-success"
|
||||
}`}
|
||||
>
|
||||
|
||||
@@ -125,7 +125,7 @@ export function EncryptionSection() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* ── Coverage summary ───────────────────────────────────────── */}
|
||||
<SettingsCard className="space-y-4">
|
||||
<SettingsCard>
|
||||
<SettingsCardHeader
|
||||
icon={ShieldCheck}
|
||||
title={t("admin.section.encryption.coverageTitle")}
|
||||
@@ -163,7 +163,7 @@ export function EncryptionSection() {
|
||||
</SettingsCard>
|
||||
|
||||
{/* ── Rotation status + trigger ──────────────────────────────── */}
|
||||
<SettingsCard className="space-y-4">
|
||||
<SettingsCard>
|
||||
<SettingsCardHeader
|
||||
icon={KeyRound}
|
||||
title={t("admin.section.encryption.rotationTitle")}
|
||||
@@ -230,7 +230,7 @@ export function EncryptionSection() {
|
||||
</SettingsCard>
|
||||
|
||||
{/* ── Per-column coverage table ──────────────────────────────── */}
|
||||
<SettingsCard className="space-y-3">
|
||||
<SettingsCard>
|
||||
<SettingsCardHeader
|
||||
icon={ShieldCheck}
|
||||
title={t("admin.section.encryption.columnsTitle")}
|
||||
|
||||
@@ -85,7 +85,7 @@ export function GeneralSettingsSection() {
|
||||
title={t("admin.appSettings")}
|
||||
description={t("admin.appSettingsDescription")}
|
||||
/>
|
||||
<div className="mt-4 space-y-4 pl-7">
|
||||
<div className="space-y-4">
|
||||
<SettingsToggle
|
||||
label={t("admin.registrationEnabled")}
|
||||
description={t("admin.registrationEnabledDescription")}
|
||||
|
||||
@@ -78,7 +78,7 @@ export function GlitchtipSection() {
|
||||
status={configured ? <ConfiguredBadge /> : null}
|
||||
/>
|
||||
|
||||
<div className="mt-4 space-y-3 pl-7">
|
||||
<div className="space-y-3">
|
||||
<SettingsToggle
|
||||
label={t("admin.glitchtipEnabled")}
|
||||
icon={AlertTriangle}
|
||||
@@ -130,7 +130,7 @@ export function GlitchtipSection() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex justify-end gap-2">
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
|
||||
@@ -185,7 +185,7 @@ export function HostMetricsChart() {
|
||||
</h2>
|
||||
<p
|
||||
role="alert"
|
||||
className="text-destructive bg-destructive/10 border-destructive/30 mt-3 rounded-md border px-3 py-2 text-xs"
|
||||
className="text-destructive bg-destructive/10 border-destructive/30 rounded-md border px-3 py-2 text-xs"
|
||||
>
|
||||
{t("admin.hostMetrics.loadError")}
|
||||
</p>
|
||||
|
||||
@@ -322,10 +322,10 @@ export function InviteTokensSection() {
|
||||
{t("admin.invites.title")}
|
||||
</h2>
|
||||
</div>
|
||||
<p className="text-muted-foreground mt-1 pl-7 text-xs">
|
||||
<p className="text-muted-foreground mt-1 text-xs">
|
||||
{t("admin.invites.description")}
|
||||
</p>
|
||||
<p className="text-muted-foreground mt-1 flex items-center gap-1.5 pl-7 text-xs">
|
||||
<p className="text-muted-foreground mt-1 flex items-center gap-1.5 text-xs">
|
||||
<ShieldCheck className="size-3.5 shrink-0" aria-hidden="true" />
|
||||
{t("admin.invites.adminOnlyHint")}
|
||||
</p>
|
||||
|
||||
@@ -227,7 +227,7 @@ export function LoginOverviewSection() {
|
||||
description={t("admin.loginOverviewDescription")}
|
||||
/>
|
||||
|
||||
<div className="mt-4 space-y-3">
|
||||
<div className="space-y-3">
|
||||
{/* Quick-filter pills. Failed-only is the most common
|
||||
admin "is something wrong" question — keep the one-tap shortcut. */}
|
||||
<div className="flex gap-1">
|
||||
|
||||
@@ -94,7 +94,7 @@ export function ModuleAvailabilitySection() {
|
||||
title={t("admin.modules.title")}
|
||||
description={t("admin.modules.description")}
|
||||
/>
|
||||
<div className="mt-4 space-y-4 pl-7">
|
||||
<div className="space-y-4">
|
||||
{MODULE_KEYS
|
||||
// A module switched off in code (pending a rebuild) is hard-off
|
||||
// server-wide; drop its row so the operator toggle can't mislead.
|
||||
|
||||
@@ -70,7 +70,7 @@ export function RecentAuditPreview() {
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="mt-4">
|
||||
<div>
|
||||
{isLoading ? (
|
||||
<div className="flex items-center gap-2 py-2">
|
||||
<Loader2
|
||||
|
||||
@@ -61,7 +61,7 @@ function NotificationHealthPanel() {
|
||||
description={t("admin.notificationHealth.description")}
|
||||
/>
|
||||
|
||||
<div className="mt-4 pl-7">
|
||||
<div>
|
||||
{isLoading && (
|
||||
<Loader2 className="text-muted-foreground h-4 w-4 animate-spin motion-reduce:animate-none" />
|
||||
)}
|
||||
@@ -205,7 +205,7 @@ export function RemindersSection() {
|
||||
description={t("admin.medicationRemindersDescription")}
|
||||
/>
|
||||
|
||||
<div className="mt-4 space-y-3 pl-7">
|
||||
<div className="space-y-3">
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="admin-reminder-late" className="text-xs">
|
||||
@@ -260,7 +260,7 @@ export function RemindersSection() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex justify-end gap-2">
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
@@ -319,7 +319,7 @@ export function RemindersSection() {
|
||||
|
||||
{testNotification.data?.results &&
|
||||
testNotification.data.results.length > 0 && (
|
||||
<div className="mt-4 space-y-1">
|
||||
<div className="space-y-1">
|
||||
{testNotification.data.results.map((r, i) => (
|
||||
<div key={i} className="flex items-center gap-1.5 text-xs">
|
||||
{r.success ? (
|
||||
@@ -338,7 +338,7 @@ export function RemindersSection() {
|
||||
|
||||
{reminderCheck.data?.medications &&
|
||||
reminderCheck.data.medications.length > 0 && (
|
||||
<div className="mt-4 space-y-2">
|
||||
<div className="space-y-2">
|
||||
{/* v1.4.33 IW9 — h4 -> h2 so the page outline stays
|
||||
sequential (parent admin SectionFrame ships <h1>). */}
|
||||
<h2 className="text-sm font-medium">
|
||||
|
||||
@@ -18,7 +18,7 @@ export function ServicesSection() {
|
||||
title={t("admin.servicesGlobal")}
|
||||
description={t("admin.servicesGlobalDescription")}
|
||||
/>
|
||||
<div className="mt-4 space-y-3 pl-7">
|
||||
<div className="space-y-3">
|
||||
<SettingsToggle
|
||||
label="Telegram"
|
||||
description={t("admin.telegramGlobal")}
|
||||
|
||||
@@ -74,7 +74,7 @@ export function FailingJobsCard({
|
||||
title={t("admin.failingJobs.title")}
|
||||
description={t("admin.failingJobs.description")}
|
||||
/>
|
||||
<p className="text-muted-foreground mt-4 pl-7 text-sm">
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t("admin.failingJobs.unavailable")}
|
||||
</p>
|
||||
</SettingsCard>
|
||||
@@ -91,12 +91,12 @@ export function FailingJobsCard({
|
||||
{failingJobs.queues.length === 0 ? (
|
||||
<p
|
||||
data-testid="failing-jobs-none"
|
||||
className="text-muted-foreground mt-4 pl-7 text-sm"
|
||||
className="text-muted-foreground text-sm"
|
||||
>
|
||||
{t("admin.failingJobs.none", { hours: failingJobs.windowHours })}
|
||||
</p>
|
||||
) : (
|
||||
<ul data-testid="failing-jobs-list" className="mt-4 space-y-3 pl-7">
|
||||
<ul data-testid="failing-jobs-list" className="space-y-3">
|
||||
{failingJobs.queues.map((queue) => (
|
||||
<li key={queue.queue} className="text-sm">
|
||||
<p className="text-foreground font-medium">
|
||||
@@ -146,7 +146,7 @@ export function SystemStatusSection() {
|
||||
// branches below it are a load error and a spinner, so the
|
||||
// marker cannot stand for a section still waiting on the read.
|
||||
data-slot="system-status-grid"
|
||||
className="mt-4 grid gap-4 pl-7 sm:grid-cols-2 lg:grid-cols-4"
|
||||
className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4"
|
||||
>
|
||||
<StatusItem
|
||||
icon={Database}
|
||||
@@ -275,7 +275,7 @@ export function SystemStatusSection() {
|
||||
// full page reload to recover.
|
||||
<div
|
||||
role="alert"
|
||||
className="text-destructive bg-destructive/10 border-destructive/30 mt-4 flex flex-col items-start gap-2 rounded-md border px-3 py-2 text-sm sm:flex-row sm:items-center sm:justify-between"
|
||||
className="text-destructive bg-destructive/10 border-destructive/30 flex flex-col items-start gap-2 rounded-md border px-3 py-2 text-sm sm:flex-row sm:items-center sm:justify-between"
|
||||
>
|
||||
<span>{t("admin.systemStatusLoadError")}</span>
|
||||
<Button
|
||||
@@ -296,7 +296,7 @@ export function SystemStatusSection() {
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-4 flex items-center gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Loader2 className="text-muted-foreground h-4 w-4 animate-spin motion-reduce:animate-none" />
|
||||
<span className="text-muted-foreground text-sm">
|
||||
{t("admin.loadingStatus")}
|
||||
|
||||
@@ -45,7 +45,7 @@ export function SystemStatusSummary() {
|
||||
/>
|
||||
|
||||
{status ? (
|
||||
<div className="mt-4 grid gap-3 pl-7 sm:grid-cols-2 lg:grid-cols-3">
|
||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
<StatusItem
|
||||
icon={Tag}
|
||||
label={t("admin.overview.snapshotVersion")}
|
||||
@@ -120,12 +120,12 @@ export function SystemStatusSummary() {
|
||||
) : isError ? (
|
||||
<div
|
||||
role="alert"
|
||||
className="text-destructive bg-destructive/10 border-destructive/30 mt-4 rounded-md border px-3 py-2 text-sm"
|
||||
className="text-destructive bg-destructive/10 border-destructive/30 rounded-md border px-3 py-2 text-sm"
|
||||
>
|
||||
{t("admin.overview.snapshotLoadError")}
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-4 flex items-center gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Loader2
|
||||
className="text-muted-foreground h-4 w-4 animate-spin motion-reduce:animate-none"
|
||||
aria-hidden="true"
|
||||
|
||||
@@ -81,7 +81,7 @@ export function UmamiSection() {
|
||||
status={configured ? <ConfiguredBadge /> : null}
|
||||
/>
|
||||
|
||||
<div className="mt-4 space-y-3 pl-7">
|
||||
<div className="space-y-3">
|
||||
<SettingsToggle
|
||||
label={t("admin.umamiEnabled")}
|
||||
icon={Activity}
|
||||
@@ -131,7 +131,7 @@ export function UmamiSection() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex justify-end gap-2">
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
|
||||
@@ -306,7 +306,7 @@ export function UserManagementSection() {
|
||||
// rendered an empty `<tbody>` so an admin filter that returned
|
||||
// no rows produced a blank rectangle; the icon + filter-aware
|
||||
// copy + "Show all users" CTA make the state explicit.
|
||||
<div className="mt-4">
|
||||
<div>
|
||||
<EmptyState
|
||||
icon={<Users className="size-6" />}
|
||||
title={t("admin.section.users.emptyTitle")}
|
||||
@@ -330,7 +330,7 @@ export function UserManagementSection() {
|
||||
`md:block hidden` swaps it out for the card-list below at
|
||||
`< md`. Hiding the table-only wrapper (instead of just
|
||||
the cells) saves DOM weight on mobile too. */}
|
||||
<div className="mt-4 hidden overflow-x-auto md:block">
|
||||
<div className="hidden overflow-x-auto md:block">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="text-muted-foreground border-b text-xs">
|
||||
@@ -399,7 +399,7 @@ export function UserManagementSection() {
|
||||
All four actions stay visible and tap-targetable; nothing
|
||||
is hidden behind a horizontal scroll. */}
|
||||
<ul
|
||||
className="mt-4 space-y-2 md:hidden"
|
||||
className="space-y-2 md:hidden"
|
||||
data-slot="admin-user-rows"
|
||||
data-testid="admin-users-mobile-list"
|
||||
>
|
||||
@@ -444,7 +444,7 @@ export function UserManagementSection() {
|
||||
</>
|
||||
)
|
||||
) : (
|
||||
<div className="mt-4 flex items-center gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Loader2 className="text-muted-foreground h-4 w-4 animate-spin motion-reduce:animate-none" />
|
||||
<span className="text-muted-foreground text-sm">
|
||||
{t("admin.loadingUsers")}
|
||||
@@ -454,7 +454,7 @@ export function UserManagementSection() {
|
||||
|
||||
{/* Edit Dialog */}
|
||||
{editingUser && (
|
||||
<div className="bg-muted/80 mt-4 rounded-lg p-4">
|
||||
<div className="bg-muted/80 rounded-lg p-4">
|
||||
<h3 className="mb-3 text-sm font-semibold">
|
||||
{t("admin.editUserTitle", { name: editingUser.username })}
|
||||
</h3>
|
||||
@@ -567,7 +567,7 @@ export function UserManagementSection() {
|
||||
|
||||
{/* Password Reset Dialog */}
|
||||
{resetUser && (
|
||||
<div className="bg-muted/80 mt-4 rounded-lg p-4">
|
||||
<div className="bg-muted/80 rounded-lg p-4">
|
||||
<h3 className="mb-3 text-sm font-semibold">
|
||||
{t("admin.resetPasswordTitle", { name: resetUser.username })}
|
||||
</h3>
|
||||
|
||||
@@ -114,7 +114,7 @@ export function VersionTileSection() {
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="mt-4 grid gap-3 pl-7 sm:grid-cols-2 lg:grid-cols-3">
|
||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
<StatusItem
|
||||
icon={Tag}
|
||||
label={t("admin.overview.versionTileVersion")}
|
||||
|
||||
@@ -128,7 +128,7 @@ export function WebPushVapidSection() {
|
||||
status={configured ? <ConfiguredBadge /> : null}
|
||||
/>
|
||||
|
||||
<div className="mt-4 grid gap-3 pl-7 sm:grid-cols-2">
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<div className="space-y-1.5 sm:col-span-2">
|
||||
<Label htmlFor="admin-web-push-public-key" className="text-xs">
|
||||
{t("admin.webPushVapidPublicKey")}
|
||||
@@ -191,7 +191,7 @@ export function WebPushVapidSection() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex justify-end gap-2">
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
|
||||
@@ -19,6 +19,7 @@ import { makeFormatters } from "@/lib/format-locale";
|
||||
import type { Locale } from "@/lib/i18n/config";
|
||||
import type { ShareViewDocument } from "@/lib/clinician-share/share-view-data";
|
||||
import type { ReportSelection } from "@/lib/report-selection/selection";
|
||||
import { PageHeader } from "@/components/ui/page-header";
|
||||
import { DocumentEntry } from "./documents-list";
|
||||
import { ShareDownloadActions } from "./download-actions";
|
||||
import {
|
||||
@@ -98,13 +99,8 @@ export function ClinicianView({
|
||||
className="mx-auto min-h-dvh w-full max-w-3xl px-4 py-8"
|
||||
>
|
||||
{/* ── Provenance header ───────────────────────────────────────── */}
|
||||
<header className="mb-6">
|
||||
<h1 className="text-2xl font-bold tracking-tight">
|
||||
{t("clinicianView.title")}
|
||||
</h1>
|
||||
{label ? (
|
||||
<p className="text-muted-foreground mt-1 text-sm">{label}</p>
|
||||
) : null}
|
||||
<header className="mb-6 space-y-1.5">
|
||||
<PageHeader title={t("clinicianView.title")} description={label} />
|
||||
{report && !documentOnly ? (
|
||||
<p className="text-muted-foreground mt-3 text-sm">
|
||||
{t("clinicianView.period", {
|
||||
|
||||
@@ -18,6 +18,7 @@ import { Card, CardContent } from "@/components/ui/card";
|
||||
import { QueryErrorCard } from "@/components/ui/query-error-card";
|
||||
import { EmptyState } from "@/components/ui/empty-state";
|
||||
import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
|
||||
import { PageHeader } from "@/components/ui/page-header";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { MetricStatStrip } from "@/components/insights/metric-stat-strip";
|
||||
import { apiDelete, apiGet } from "@/lib/api/api-fetch";
|
||||
@@ -171,14 +172,10 @@ export function CustomMetricDetail({
|
||||
if (metricError || listError) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<header className="space-y-1.5">
|
||||
<h1 className="text-2xl font-bold tracking-tight">
|
||||
{metric?.name ?? t("customMetrics.detail.title")}
|
||||
</h1>
|
||||
<p className="text-foreground text-sm leading-relaxed">
|
||||
{description}
|
||||
</p>
|
||||
</header>
|
||||
<PageHeader
|
||||
title={metric?.name ?? t("customMetrics.detail.title")}
|
||||
description={description}
|
||||
/>
|
||||
<QueryErrorCard
|
||||
description={t("customMetrics.loadError")}
|
||||
onRetry={() => {
|
||||
@@ -192,70 +189,69 @@ export function CustomMetricDetail({
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<h1 className="text-2xl font-bold tracking-tight">
|
||||
{metric?.name ?? <Skeleton className="h-7 w-32" />}
|
||||
</h1>
|
||||
{metric?.unit ? (
|
||||
<p className="text-muted-foreground text-xs sm:text-sm">
|
||||
{metric.unit}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
{/* Controls, left → right: Delete · Edit · Show-all-values · Add. */}
|
||||
<DeleteButton
|
||||
onConfirm={() => deleteMetric.mutate()}
|
||||
title={t("customMetrics.deleteConfirmTitle")}
|
||||
description={t("customMetrics.deleteConfirmDescription")}
|
||||
confirmLabel={t("customMetrics.delete")}
|
||||
triggerTitle={t("customMetrics.delete")}
|
||||
className="min-h-11 min-w-11 sm:min-h-9 sm:min-w-9"
|
||||
iconClassName="h-4 w-4"
|
||||
/>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="min-h-11 min-w-11 sm:min-h-9 sm:min-w-9"
|
||||
onClick={() => setEditOpen(true)}
|
||||
disabled={!metric}
|
||||
aria-label={t("customMetrics.edit")}
|
||||
title={t("customMetrics.edit")}
|
||||
>
|
||||
<Pencil className="h-4 w-4" />
|
||||
</Button>
|
||||
{entries.length > 0 ? (
|
||||
<PageHeader
|
||||
title={metric?.name ?? <Skeleton className="h-7 w-32" />}
|
||||
description={description}
|
||||
actions={
|
||||
<>
|
||||
{/* Controls, left → right: Delete · Edit · Show-all-values · Add. */}
|
||||
<DeleteButton
|
||||
onConfirm={() => deleteMetric.mutate()}
|
||||
title={t("customMetrics.deleteConfirmTitle")}
|
||||
description={t("customMetrics.deleteConfirmDescription")}
|
||||
confirmLabel={t("customMetrics.delete")}
|
||||
triggerTitle={t("customMetrics.delete")}
|
||||
className="min-h-11 min-w-11 sm:min-h-9 sm:min-w-9"
|
||||
iconClassName="h-4 w-4"
|
||||
/>
|
||||
<Button
|
||||
asChild
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
data-slot="custom-metric-show-all-values"
|
||||
className="min-h-11 min-w-11 sm:min-h-9 sm:min-w-9"
|
||||
onClick={() => setEditOpen(true)}
|
||||
disabled={!metric}
|
||||
aria-label={t("customMetrics.edit")}
|
||||
title={t("customMetrics.edit")}
|
||||
>
|
||||
<Link
|
||||
href={`/custom-metrics/${customMetricId}/values`}
|
||||
aria-label={t("customMetrics.showAllValues")}
|
||||
title={t("customMetrics.showAllValues")}
|
||||
>
|
||||
<ListOrdered className="h-4 w-4" aria-hidden="true" />
|
||||
</Link>
|
||||
<Pencil className="h-4 w-4" />
|
||||
</Button>
|
||||
) : null}
|
||||
<Button
|
||||
onClick={() => setAddOpen(true)}
|
||||
className="min-h-11 min-w-11 shrink-0 sm:min-h-9 sm:min-w-0"
|
||||
aria-label={t("customMetrics.addValue")}
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
<span className="hidden sm:inline">
|
||||
{t("customMetrics.addValue")}
|
||||
</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{entries.length > 0 ? (
|
||||
<Button
|
||||
asChild
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
data-slot="custom-metric-show-all-values"
|
||||
className="min-h-11 min-w-11 sm:min-h-9 sm:min-w-9"
|
||||
>
|
||||
<Link
|
||||
href={`/custom-metrics/${customMetricId}/values`}
|
||||
aria-label={t("customMetrics.showAllValues")}
|
||||
title={t("customMetrics.showAllValues")}
|
||||
>
|
||||
<ListOrdered className="h-4 w-4" aria-hidden="true" />
|
||||
</Link>
|
||||
</Button>
|
||||
) : null}
|
||||
<Button
|
||||
onClick={() => setAddOpen(true)}
|
||||
className="min-h-11 min-w-11 shrink-0 sm:min-h-9 sm:min-w-0"
|
||||
aria-label={t("customMetrics.addValue")}
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
<span className="hidden sm:inline">
|
||||
{t("customMetrics.addValue")}
|
||||
</span>
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
<p className="text-foreground text-sm leading-relaxed">{description}</p>
|
||||
{/* The unit is meta about the metric, not its explainer, so it reads
|
||||
below the header rather than competing for the one description line
|
||||
every page header carries. */}
|
||||
{metric?.unit ? (
|
||||
<p className="text-muted-foreground text-sm">{metric.unit}</p>
|
||||
) : null}
|
||||
|
||||
{isLoading ? (
|
||||
<Card>
|
||||
|
||||
@@ -5,7 +5,7 @@ import Link from "next/link";
|
||||
import { ArrowRight, Droplets } from "lucide-react";
|
||||
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { SectionHeading } from "@/components/insights/section-heading";
|
||||
import { SectionHeading } from "@/components/ui/section-heading";
|
||||
import { CyclePhaseHeadline } from "./cycle-phase-crosstab";
|
||||
import { PHASE_HUE } from "./phase-tokens";
|
||||
import { localYmd, useCycleCalendar, useCycleInsights } from "./use-cycle";
|
||||
|
||||
@@ -16,6 +16,7 @@ import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { QueryErrorCard } from "@/components/ui/query-error-card";
|
||||
import { PageHeader } from "@/components/ui/page-header";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { useTranslations, useFormatters } from "@/lib/i18n/context";
|
||||
|
||||
@@ -67,13 +68,11 @@ export function IllnessEpisodeDetail({ episodeId }: { episodeId: string }) {
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<h1 className="truncate text-2xl font-bold tracking-tight">
|
||||
{episode?.label ?? <Skeleton className="h-7 w-40" />}
|
||||
</h1>
|
||||
{episode ? (
|
||||
<div className="mt-1.5 flex flex-wrap items-center gap-2">
|
||||
<PageHeader
|
||||
title={episode?.label ?? <Skeleton className="h-7 w-40" />}
|
||||
description={
|
||||
episode ? (
|
||||
<span className="flex flex-wrap items-center gap-2">
|
||||
<Badge variant="secondary">
|
||||
{t(`illness.type.${episode.type}`)}
|
||||
</Badge>
|
||||
@@ -94,32 +93,34 @@ export function IllnessEpisodeDetail({ episodeId }: { episodeId: string }) {
|
||||
})}`
|
||||
: ""}
|
||||
</span>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
{canManage && (
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
<Button
|
||||
onClick={() => setLogOpen(true)}
|
||||
className="min-h-11 sm:min-h-9"
|
||||
>
|
||||
{t("illness.logDay")}
|
||||
</Button>
|
||||
{episode ? (
|
||||
<EpisodeMenu
|
||||
episode={episode}
|
||||
onEdit={() => setEditOpen(true)}
|
||||
onResolve={
|
||||
active && !isChronic
|
||||
? () => resolve.mutate(episode.id)
|
||||
: undefined
|
||||
}
|
||||
resolving={resolve.isPending}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</span>
|
||||
) : undefined
|
||||
}
|
||||
actions={
|
||||
canManage ? (
|
||||
<>
|
||||
<Button
|
||||
onClick={() => setLogOpen(true)}
|
||||
className="min-h-11 sm:min-h-9"
|
||||
>
|
||||
{t("illness.logDay")}
|
||||
</Button>
|
||||
{episode ? (
|
||||
<EpisodeMenu
|
||||
episode={episode}
|
||||
onEdit={() => setEditOpen(true)}
|
||||
onResolve={
|
||||
active && !isChronic
|
||||
? () => resolve.mutate(episode.id)
|
||||
: undefined
|
||||
}
|
||||
resolving={resolve.isPending}
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
|
||||
{episode?.note ? (
|
||||
<Card>
|
||||
|
||||
@@ -48,7 +48,7 @@ export function IllnessSettings() {
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<SettingsCard id="illness-view" className="scroll-mt-28 space-y-4">
|
||||
<SettingsCard id="illness-view" className="scroll-mt-28">
|
||||
<SettingsCardHeader
|
||||
icon={Eye}
|
||||
title={t("moduleList.viewHeading")}
|
||||
@@ -57,7 +57,7 @@ export function IllnessSettings() {
|
||||
/>
|
||||
</SettingsCard>
|
||||
|
||||
<SettingsCard id="illness-order" className="scroll-mt-28 space-y-4">
|
||||
<SettingsCard id="illness-order" className="scroll-mt-28">
|
||||
<SettingsCardHeader
|
||||
icon={ListOrdered}
|
||||
title={t("moduleList.reorder.heading")}
|
||||
|
||||
@@ -8,7 +8,7 @@ import { useAuth } from "@/hooks/use-auth";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { apiGet } from "@/lib/api/api-fetch";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import { SectionHeading } from "@/components/insights/section-heading";
|
||||
import { SectionHeading } from "@/components/ui/section-heading";
|
||||
import type {
|
||||
BreathingClassification,
|
||||
BreathingTrend,
|
||||
|
||||
@@ -4,7 +4,7 @@ import { AlertTriangle, Activity } from "lucide-react";
|
||||
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { SectionHeading } from "@/components/insights/section-heading";
|
||||
import { SectionHeading } from "@/components/ui/section-heading";
|
||||
import { MEASUREMENT_TYPE_LABEL_KEYS } from "@/components/measurements/measurement-list-meta";
|
||||
import { CoverageMeter } from "@/components/insights/derived/coverage-meter";
|
||||
import { ProvenanceExplainer } from "@/components/insights/derived/provenance-explainer";
|
||||
|
||||
@@ -23,7 +23,7 @@ import { Button } from "@/components/ui/button";
|
||||
import { EmptyState } from "@/components/ui/empty-state";
|
||||
import { ListRow } from "@/components/ui/list-row";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { SectionHeading } from "@/components/insights/section-heading";
|
||||
import { SectionHeading } from "@/components/ui/section-heading";
|
||||
import {
|
||||
useTranslations,
|
||||
useFormatters,
|
||||
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
MEASUREMENT_TYPE_LABEL_KEYS,
|
||||
} from "@/components/measurements/measurement-list-meta";
|
||||
import { TileHeader } from "@/components/insights/tile-header";
|
||||
import { SectionHeading } from "@/components/insights/section-heading";
|
||||
import { SectionHeading } from "@/components/ui/section-heading";
|
||||
import { SparklineDeltaTile } from "./sparkline-delta-tile";
|
||||
import { LearnMoreLink } from "@/components/ui/learn-more-link";
|
||||
import { CoverageMeter } from "./coverage-meter";
|
||||
|
||||
@@ -14,7 +14,7 @@ import { cn } from "@/lib/utils";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { SectionHeading } from "@/components/insights/section-heading";
|
||||
import { SectionHeading } from "@/components/ui/section-heading";
|
||||
import { ScoreRing } from "./score-ring";
|
||||
import { TILE_HUE, type RingHue } from "./ring-hues";
|
||||
import type { DerivedBatchRead } from "./use-derived-metric";
|
||||
|
||||
@@ -11,7 +11,7 @@ import type { DataSummary } from "@/lib/analytics/trends";
|
||||
import { Card, CardContent, CardHeader } from "@/components/ui/card";
|
||||
import { LearningGate } from "@/components/ui/learning-gate";
|
||||
import { HealthChartDynamicMini } from "@/components/charts/health-chart-dynamic";
|
||||
import { SectionHeading } from "@/components/insights/section-heading";
|
||||
import { SectionHeading } from "@/components/ui/section-heading";
|
||||
import { TileHeader } from "@/components/insights/tile-header";
|
||||
|
||||
/**
|
||||
|
||||
@@ -9,7 +9,7 @@ import { queryKeys } from "@/lib/query-keys";
|
||||
import { apiGet } from "@/lib/api/api-fetch";
|
||||
import { useTranslations, useFormatters } from "@/lib/i18n/context";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { SectionHeading } from "@/components/insights/section-heading";
|
||||
import { SectionHeading } from "@/components/ui/section-heading";
|
||||
import { EcgWaveform } from "@/components/insights/ecg-waveform";
|
||||
import { InsightSectionCard } from "./insight-section-card";
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ import { useAuth } from "@/hooks/use-auth";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { apiGet } from "@/lib/api/api-fetch";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import { SectionHeading } from "@/components/insights/section-heading";
|
||||
import { SectionHeading } from "@/components/ui/section-heading";
|
||||
import { MEASUREMENT_TYPE_LABEL_KEYS } from "@/components/measurements/measurement-list-meta";
|
||||
import type {
|
||||
HealthStatusDeviation,
|
||||
|
||||
@@ -8,7 +8,7 @@ import { useAuth } from "@/hooks/use-auth";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { apiGet } from "@/lib/api/api-fetch";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import { SectionHeading } from "@/components/insights/section-heading";
|
||||
import { SectionHeading } from "@/components/ui/section-heading";
|
||||
import type { LabChange } from "@/lib/insights/labs-changes";
|
||||
import { InsightSectionCard } from "./insight-section-card";
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ import { formatUpdatedLabel } from "@/lib/i18n/relative-time";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { SectionHeading } from "@/components/insights/section-heading";
|
||||
import { SectionHeading } from "@/components/ui/section-heading";
|
||||
import { AskCoachIconButton } from "@/components/insights/ask-coach-action";
|
||||
import { ProseBlocks } from "@/components/insights/prose-blocks";
|
||||
import { apiGet } from "@/lib/api/api-fetch";
|
||||
|
||||
@@ -9,7 +9,7 @@ import { queryKeys } from "@/lib/query-keys";
|
||||
import { apiGet } from "@/lib/api/api-fetch";
|
||||
import { useTranslations, useFormatters } from "@/lib/i18n/context";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { SectionHeading } from "@/components/insights/section-heading";
|
||||
import { SectionHeading } from "@/components/ui/section-heading";
|
||||
import { InsightSectionCard } from "./insight-section-card";
|
||||
|
||||
/**
|
||||
|
||||
@@ -5,7 +5,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 { SectionHeading } from "@/components/ui/section-heading";
|
||||
import { DeviceScoreTile } from "@/components/insights/device-score-tile";
|
||||
import { DeviceScoreGridSkeleton } from "@/components/insights/device-score-tile-skeleton";
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ import { ArrowRight, TrendingUp } from "lucide-react";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { ChartSkeleton } from "@/components/charts/chart-skeleton";
|
||||
import { SectionHeading } from "@/components/insights/section-heading";
|
||||
import { SectionHeading } from "@/components/ui/section-heading";
|
||||
import { HealthChartDynamicMini } from "@/components/charts/health-chart-dynamic";
|
||||
import type { DailyBriefing } from "@/lib/ai/schema";
|
||||
import {
|
||||
|
||||
@@ -5,7 +5,7 @@ import Link from "next/link";
|
||||
import { ArrowRight, CalendarDays, Moon, Smile } from "lucide-react";
|
||||
|
||||
import { ChartSkeleton } from "@/components/charts/chart-skeleton";
|
||||
import { SectionHeading } from "@/components/insights/section-heading";
|
||||
import { SectionHeading } from "@/components/ui/section-heading";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { QueryErrorRow } from "@/components/ui/query-error-row";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
|
||||
@@ -27,6 +27,7 @@ import { Card, CardContent } from "@/components/ui/card";
|
||||
import { EmptyState } from "@/components/ui/empty-state";
|
||||
import { QueryErrorCard } from "@/components/ui/query-error-card";
|
||||
import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
|
||||
import { PageHeader } from "@/components/ui/page-header";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { InsightStatusCard } from "@/components/insights/insight-status-card";
|
||||
import { MetricStatStrip } from "@/components/insights/metric-stat-strip";
|
||||
@@ -303,14 +304,10 @@ export function LabBiomarkerDetail({ biomarkerId }: { biomarkerId: string }) {
|
||||
if (markerError || listError) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<header className="space-y-1.5">
|
||||
<h1 className="text-2xl font-bold tracking-tight">
|
||||
{marker?.name ?? t("labs.detail.title")}
|
||||
</h1>
|
||||
<p className="text-foreground text-sm leading-relaxed">
|
||||
{description}
|
||||
</p>
|
||||
</header>
|
||||
<PageHeader
|
||||
title={marker?.name ?? t("labs.detail.title")}
|
||||
description={description}
|
||||
/>
|
||||
<QueryErrorCard
|
||||
title={t("labs.loadError")}
|
||||
onRetry={() => {
|
||||
@@ -324,91 +321,88 @@ export function LabBiomarkerDetail({ biomarkerId }: { biomarkerId: string }) {
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<h1 className="text-2xl font-bold tracking-tight">
|
||||
{marker?.name ?? <Skeleton className="h-7 w-32" />}
|
||||
</h1>
|
||||
{marker?.unit ? (
|
||||
<p className="text-muted-foreground text-xs sm:text-sm">
|
||||
{marker.unit}
|
||||
{marker.panel ? ` · ${marker.panel}` : ""}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
{/* v1.25.3 — header control cluster, left → right:
|
||||
<PageHeader
|
||||
title={marker?.name ?? <Skeleton className="h-7 w-32" />}
|
||||
description={description}
|
||||
actions={
|
||||
<>
|
||||
{/* v1.25.3 — header control cluster, left → right:
|
||||
Delete · Edit · Show-all-values · Add. The icon controls share one
|
||||
ghost-icon treatment (the target-range editing lives inside the
|
||||
full marker editor behind the pencil, so the separate range
|
||||
control was dropped). */}
|
||||
<DeleteButton
|
||||
onConfirm={() => deleteMarker.mutate()}
|
||||
title={t("labs.biomarker.deleteConfirmTitle")}
|
||||
description={t("labs.biomarker.deleteConfirmDescription")}
|
||||
confirmLabel={t("labs.biomarker.delete")}
|
||||
triggerTitle={t("labs.biomarker.delete")}
|
||||
className="min-h-11 min-w-11 sm:min-h-9 sm:min-w-9"
|
||||
iconClassName="h-4 w-4"
|
||||
/>
|
||||
{canManage && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
<DeleteButton
|
||||
onConfirm={() => deleteMarker.mutate()}
|
||||
title={t("labs.biomarker.deleteConfirmTitle")}
|
||||
description={t("labs.biomarker.deleteConfirmDescription")}
|
||||
confirmLabel={t("labs.biomarker.delete")}
|
||||
triggerTitle={t("labs.biomarker.delete")}
|
||||
className="min-h-11 min-w-11 sm:min-h-9 sm:min-w-9"
|
||||
onClick={() => setEditOpen(true)}
|
||||
disabled={!marker}
|
||||
aria-label={t("labs.biomarker.edit")}
|
||||
title={t("labs.biomarker.edit")}
|
||||
>
|
||||
<Pencil className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
{/* "Show all readings" mirrors the metric sub-pages' `<SubPageShell>`
|
||||
iconClassName="h-4 w-4"
|
||||
/>
|
||||
{canManage && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="min-h-11 min-w-11 sm:min-h-9 sm:min-w-9"
|
||||
onClick={() => setEditOpen(true)}
|
||||
disabled={!marker}
|
||||
aria-label={t("labs.biomarker.edit")}
|
||||
title={t("labs.biomarker.edit")}
|
||||
>
|
||||
<Pencil className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
{/* "Show all readings" mirrors the metric sub-pages' `<SubPageShell>`
|
||||
control. The full reading feed lives on
|
||||
`/labs/[biomarkerId]/values`; the detail page keeps the
|
||||
numbers-first spine. */}
|
||||
{readings.length > 0 ? (
|
||||
<Button
|
||||
asChild
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
data-slot="lab-show-all-values"
|
||||
className="min-h-11 min-w-11 sm:min-h-9 sm:min-w-9"
|
||||
>
|
||||
<Link
|
||||
href={`/labs/${biomarkerId}/values`}
|
||||
aria-label={t("insights.subPage.showAllValues")}
|
||||
title={t("insights.subPage.showAllValues")}
|
||||
{readings.length > 0 ? (
|
||||
<Button
|
||||
asChild
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
data-slot="lab-show-all-values"
|
||||
className="min-h-11 min-w-11 sm:min-h-9 sm:min-w-9"
|
||||
>
|
||||
<ListOrdered className="h-4 w-4" aria-hidden="true" />
|
||||
</Link>
|
||||
</Button>
|
||||
) : null}
|
||||
{/* v1.36.x — entering a result is an admitted delegated write;
|
||||
<Link
|
||||
href={`/labs/${biomarkerId}/values`}
|
||||
aria-label={t("insights.subPage.showAllValues")}
|
||||
title={t("insights.subPage.showAllValues")}
|
||||
>
|
||||
<ListOrdered className="h-4 w-4" aria-hidden="true" />
|
||||
</Link>
|
||||
</Button>
|
||||
) : null}
|
||||
{/* v1.36.x — entering a result is an admitted delegated write;
|
||||
renaming or removing the marker is not. */}
|
||||
{canAdd && (
|
||||
<Button
|
||||
onClick={() => setAddOpen(true)}
|
||||
// v1.18.10 (W10) — on the narrowest phones the h1 + Edit + Delete +
|
||||
// text "Add" button crowd the row and truncate the title hard.
|
||||
// Drop the Add button to icon-only under `sm` (label kept for
|
||||
// screen readers via `aria-label`); the full text returns at `sm+`.
|
||||
className="min-h-11 min-w-11 shrink-0 sm:min-h-9 sm:min-w-0"
|
||||
aria-label={t("labs.addResult")}
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
<span className="hidden sm:inline">{t("labs.addResult")}</span>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{canAdd && (
|
||||
<Button
|
||||
onClick={() => setAddOpen(true)}
|
||||
// v1.18.10 (W10) — on the narrowest phones the h1 + Edit + Delete +
|
||||
// text "Add" button crowd the row and truncate the title hard.
|
||||
// Drop the Add button to icon-only under `sm` (label kept for
|
||||
// screen readers via `aria-label`); the full text returns at `sm+`.
|
||||
className="min-h-11 min-w-11 shrink-0 sm:min-h-9 sm:min-w-0"
|
||||
aria-label={t("labs.addResult")}
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
<span className="hidden sm:inline">{t("labs.addResult")}</span>
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* v1.25.1 — the static description leads the page, directly beneath the
|
||||
heading, mirroring the metric sub-pages' explainer caption. Computed
|
||||
once above (catalog desc → user `context` → generic fallback) so a
|
||||
catalog-less marker still carries a description. */}
|
||||
<p className="text-foreground text-sm leading-relaxed">{description}</p>
|
||||
{/* Unit + panel are meta about the marker, not its explainer, so they
|
||||
read below the header block rather than competing with it for the
|
||||
one description line every page header carries. */}
|
||||
{marker?.unit ? (
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{marker.unit}
|
||||
{marker.panel ? ` · ${marker.panel}` : ""}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{/* Bridge to the source report PDFs. There is no per-value document
|
||||
relation in the data model yet, so this links to the vault filtered
|
||||
|
||||
@@ -79,7 +79,7 @@ export function LabsSettings() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* View — card vs compact list. */}
|
||||
<SettingsCard id="labs-view" className="scroll-mt-28 space-y-4">
|
||||
<SettingsCard id="labs-view" className="scroll-mt-28">
|
||||
<SettingsCardHeader
|
||||
icon={LayoutGrid}
|
||||
title={t("moduleList.viewHeading")}
|
||||
@@ -89,7 +89,7 @@ export function LabsSettings() {
|
||||
</SettingsCard>
|
||||
|
||||
{/* Sort order — recency, alphabetical, or manual. */}
|
||||
<SettingsCard id="labs-sort-card" className="scroll-mt-28 space-y-4">
|
||||
<SettingsCard id="labs-sort-card" className="scroll-mt-28">
|
||||
<SettingsCardHeader
|
||||
icon={ArrowDownUp}
|
||||
title={t("labs.sort.heading")}
|
||||
@@ -112,7 +112,7 @@ export function LabsSettings() {
|
||||
</SettingsCard>
|
||||
|
||||
{manual ? (
|
||||
<SettingsCard id="labs-order" className="scroll-mt-28 space-y-4">
|
||||
<SettingsCard id="labs-order" className="scroll-mt-28">
|
||||
<SettingsCardHeader
|
||||
icon={ListOrdered}
|
||||
title={t("moduleList.reorder.heading")}
|
||||
@@ -130,7 +130,7 @@ export function LabsSettings() {
|
||||
) : null}
|
||||
|
||||
{/* v1.18.10 — local (in-browser) OCR opt-in for lab scans. */}
|
||||
<SettingsCard id="labs-local-ocr" className="scroll-mt-28 space-y-4">
|
||||
<SettingsCard id="labs-local-ocr" className="scroll-mt-28">
|
||||
<SettingsCardHeader
|
||||
icon={ScanText}
|
||||
title={t("labs.localOcr.heading")}
|
||||
@@ -147,7 +147,7 @@ export function LabsSettings() {
|
||||
</SettingsCard>
|
||||
|
||||
{/* Biomarker catalog — define / edit / delete. */}
|
||||
<SettingsCard id="labs-biomarkers" className="scroll-mt-28 space-y-4">
|
||||
<SettingsCard id="labs-biomarkers" className="scroll-mt-28">
|
||||
<SettingsCardHeader
|
||||
icon={FlaskConical}
|
||||
title={t("labs.biomarker.manageTitle")}
|
||||
|
||||
@@ -62,6 +62,7 @@ import {
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { PageHeader } from "@/components/ui/page-header";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import {
|
||||
@@ -400,24 +401,21 @@ export function VorsorgeSection({
|
||||
return (
|
||||
<section aria-labelledby="vorsorge-section-title" className="space-y-4">
|
||||
{variant === "page" ? (
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<h1
|
||||
id="vorsorge-section-title"
|
||||
data-tour-id="vorsorge-hero"
|
||||
className="text-2xl font-bold tracking-tight"
|
||||
>
|
||||
<PageHeader
|
||||
titleId="vorsorge-section-title"
|
||||
title={
|
||||
<span data-tour-id="vorsorge-hero">
|
||||
{t("measurementReminders.sectionTitle")}
|
||||
</h1>
|
||||
<p className="text-muted-foreground text-xs sm:text-sm">
|
||||
{t("measurementReminders.sectionDescription")}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
{wrenchButton}
|
||||
{addButton}
|
||||
</div>
|
||||
</div>
|
||||
</span>
|
||||
}
|
||||
description={t("measurementReminders.sectionDescription")}
|
||||
actions={
|
||||
<>
|
||||
{wrenchButton}
|
||||
{addButton}
|
||||
</>
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<SettingsCardHeader
|
||||
icon={CalendarClock}
|
||||
|
||||
@@ -81,7 +81,7 @@ export function VorsorgeSettings() {
|
||||
title={t("moduleList.reorder.heading")}
|
||||
description={t("moduleList.reorder.description")}
|
||||
/>
|
||||
<div className="mt-4 pl-7">
|
||||
<div>
|
||||
{isLoading ? (
|
||||
<div className="space-y-2">
|
||||
{Array.from({ length: 3 }, (_, i) => (
|
||||
@@ -100,7 +100,7 @@ export function VorsorgeSettings() {
|
||||
title={t("measurementReminders.manage.title")}
|
||||
description={t("measurementReminders.manage.description")}
|
||||
/>
|
||||
<div className="mt-4 pl-7">
|
||||
<div>
|
||||
{isLoading ? (
|
||||
<Skeleton className="h-24 w-full" />
|
||||
) : (reminders?.length ?? 0) === 0 ? (
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { readFileSync, readdirSync, statSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { Bell } from "lucide-react";
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
import { PageHeader } from "@/components/ui/page-header";
|
||||
import { SettingsCard } from "@/components/settings/settings-card";
|
||||
import { SettingsCardHeader } from "@/components/settings/_card-header";
|
||||
import { SettingsCardActions } from "@/components/settings/_card-actions";
|
||||
|
||||
/**
|
||||
* Settings + Admin read as one surface. These pin the parts of that claim
|
||||
* that can be checked exactly:
|
||||
*
|
||||
* 1. the header→body step is owned by `SettingsCard`, so a call site can
|
||||
* neither re-declare it nor drift from it;
|
||||
* 2. the card header's description slot carries no vertical rhythm — the
|
||||
* licence call sites used to stack explainer paragraphs into a muted
|
||||
* `text-xs` slot;
|
||||
* 3. the action row has one shape, and its one documented exception
|
||||
* (a card whose only actions are links out) is spelled as a prop;
|
||||
* 4. `pl-7` — the off-scale body gutter that broke wherever two
|
||||
* neighbouring cards disagreed about it — stays gone;
|
||||
* 5. both consoles render their page heading through `PageHeader`, so
|
||||
* Settings and Admin cannot drift a step apart from the module pages
|
||||
* or from each other.
|
||||
*/
|
||||
|
||||
function render(node: React.ReactNode) {
|
||||
return renderToStaticMarkup(
|
||||
<I18nProvider initialLocale="en">{node}</I18nProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
const ROOT = process.cwd();
|
||||
|
||||
function walk(dir: string, out: string[] = []): string[] {
|
||||
for (const entry of readdirSync(dir)) {
|
||||
const full = join(dir, entry);
|
||||
if (statSync(full).isDirectory()) {
|
||||
if (entry === "__tests__") continue;
|
||||
walk(full, out);
|
||||
} else if (entry.endsWith(".tsx")) {
|
||||
out.push(full);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
describe("SettingsCard — the container owns the rhythm", () => {
|
||||
it("paints the gap-based shell, not a call-site margin", () => {
|
||||
const html = render(<SettingsCard>body</SettingsCard>);
|
||||
expect(html).toContain("flex flex-col gap-4");
|
||||
expect(html).toContain("p-4");
|
||||
expect(html).toContain("md:p-6");
|
||||
});
|
||||
|
||||
it("gives the semantic-landmark variant the same rhythm", () => {
|
||||
const html = render(<SettingsCard as="section">body</SettingsCard>);
|
||||
expect(html).toContain("<section");
|
||||
expect(html).toContain("flex flex-col gap-4");
|
||||
});
|
||||
|
||||
it("still lets a dense card override the gap on the card itself", () => {
|
||||
const html = render(<SettingsCard className="gap-2">body</SettingsCard>);
|
||||
expect(html).toContain("gap-2");
|
||||
});
|
||||
});
|
||||
|
||||
describe("SettingsCardHeader — one description sentence, one text run", () => {
|
||||
it("renders the description without any vertical rhythm of its own", () => {
|
||||
const html = render(
|
||||
<SettingsCardHeader icon={Bell} title="Channels" description="One." />,
|
||||
);
|
||||
// The slot is muted meta at the size floor…
|
||||
expect(html).toContain("text-muted-foreground text-xs");
|
||||
// …and carries no `space-y-*`, so a stack of <p>s cannot read as prose.
|
||||
const slot = html.slice(html.indexOf("text-muted-foreground text-xs"));
|
||||
expect(slot.slice(0, 60)).not.toMatch(/space-y-/);
|
||||
});
|
||||
|
||||
it("keeps the title in the foreground tier at one size", () => {
|
||||
const html = render(<SettingsCardHeader icon={Bell} title="Channels" />);
|
||||
expect(html).toMatch(/<h2[^>]*class="text-lg font-semibold"/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("SettingsCardActions — one row, one shape", () => {
|
||||
it("puts the row at the trailing edge by default", () => {
|
||||
const html = render(
|
||||
<SettingsCardActions>
|
||||
<button>Save</button>
|
||||
</SettingsCardActions>,
|
||||
);
|
||||
expect(html).toContain('data-slot="settings-card-actions"');
|
||||
expect(html).toContain("justify-end");
|
||||
});
|
||||
|
||||
it("spells the link-row exception as a prop rather than a local class", () => {
|
||||
const html = render(
|
||||
<SettingsCardActions align="start">
|
||||
<a href="/x">Open</a>
|
||||
</SettingsCardActions>,
|
||||
);
|
||||
expect(html).toContain("justify-start");
|
||||
expect(html).not.toContain("justify-end");
|
||||
});
|
||||
});
|
||||
|
||||
describe("PageHeader — the one page heading, including the shells", () => {
|
||||
it("renders the explainer in the muted tier", () => {
|
||||
const html = render(<PageHeader title="Settings" description="Yours." />);
|
||||
expect(html).toMatch(/<p class="text-muted-foreground text-sm">Yours\./);
|
||||
});
|
||||
|
||||
it("renders a second copy as a heading role, never a second <h1>", () => {
|
||||
const html = render(
|
||||
<PageHeader headingAs="div" title="Settings" description="Yours." />,
|
||||
);
|
||||
expect(html).not.toContain("<h1");
|
||||
expect(html).toContain('role="heading"');
|
||||
expect(html).toContain('aria-level="1"');
|
||||
});
|
||||
|
||||
it("never truncates the explainer", () => {
|
||||
const html = render(
|
||||
<PageHeader title="Medications" description="Schedules and reminders." />,
|
||||
);
|
||||
expect(html).not.toContain("truncate");
|
||||
});
|
||||
});
|
||||
|
||||
describe("the Settings and Admin shells route their heading through PageHeader", () => {
|
||||
for (const shell of [
|
||||
join("src", "components", "settings", "settings-shell.tsx"),
|
||||
join("src", "components", "admin", "admin-shell.tsx"),
|
||||
]) {
|
||||
it(`${shell} has no hand-rolled H1 block`, () => {
|
||||
const source = readFileSync(join(ROOT, shell), "utf8");
|
||||
expect(source).toContain("<PageHeader");
|
||||
// The hand-rolled copies were a literal `<h1 className="text-2xl …">`
|
||||
// plus a `role="heading"` div beside it. Both belong to the primitive.
|
||||
const code = source
|
||||
.split("\n")
|
||||
.filter((line) => !line.trimStart().startsWith("//"))
|
||||
.join("\n");
|
||||
expect(code).not.toMatch(/<h1\b/);
|
||||
expect(code).not.toMatch(/role="heading"/);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
describe("the abolished body gutter stays abolished", () => {
|
||||
it("has no `pl-7` left under Settings or Admin", () => {
|
||||
const offenders: string[] = [];
|
||||
for (const dir of ["settings", "admin"]) {
|
||||
for (const file of walk(join(ROOT, "src", "components", dir))) {
|
||||
const source = readFileSync(file, "utf8");
|
||||
for (const [i, line] of source.split("\n").entries()) {
|
||||
// Comments may name the class they retired; class strings may not.
|
||||
if (line.trimStart().startsWith("*")) continue;
|
||||
if (line.includes("//")) continue;
|
||||
if (/\bpl-7\b/.test(line)) {
|
||||
offenders.push(`${file.slice(ROOT.length + 1)}:${i + 1}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
expect(offenders).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,46 @@
|
||||
import * as React from "react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/**
|
||||
* `<SettingsCardActions>` — the action row of a Settings or Admin card.
|
||||
*
|
||||
* The audit found four positions (bottom-right, bottom-left, the header's
|
||||
* status slot, inline mid-card) and five variants carrying the SAME role —
|
||||
* "the primary action of this card" — sometimes two of them in one file. The
|
||||
* two sections that already read right (`score-section.tsx`,
|
||||
* `coach-prefs-section.tsx`) agreed on one shape, and this is it.
|
||||
*
|
||||
* The contract (design standards §12):
|
||||
* - The row is the LAST thing in the card. Status lines and results sit
|
||||
* above it; a result set big enough to need a frame becomes its own card.
|
||||
* - Primary action last (rightmost), `variant` default, `size="sm"` with
|
||||
* the `min-h-11` mobile tap floor. One per card.
|
||||
* - Secondary action `outline`, before the primary in the same row.
|
||||
* - Destructive card-wide action: same row, `variant="destructive"` —
|
||||
* never an `outline` hand-tinted with `text-destructive`.
|
||||
*
|
||||
* `align="start"` is the one documented exception: a card whose only actions
|
||||
* are deep links out of it (privacy, about, export) has no primary action, so
|
||||
* its links read with the text on the left rather than against it.
|
||||
*/
|
||||
export function SettingsCardActions({
|
||||
align = "end",
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & { align?: "start" | "end" }) {
|
||||
return (
|
||||
<div
|
||||
data-slot="settings-card-actions"
|
||||
className={cn(
|
||||
"flex flex-wrap items-center gap-2",
|
||||
align === "end" ? "justify-end" : "justify-start",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -47,9 +47,13 @@ export interface SettingsCardHeaderProps {
|
||||
* after the title (e.g. a tag chip + experimental badge). The title
|
||||
* row wraps so the accessories reflow below on a narrow viewport. */
|
||||
titleAccessory?: React.ReactNode;
|
||||
/** Optional short description rendered as muted text below the
|
||||
* title row. Either a string (paragraph) or arbitrary node
|
||||
* (links, badges, extra sub-notes, etc.). */
|
||||
/** Optional description rendered as muted text below the title row.
|
||||
* Exactly ONE sentence, guideline ≤ 120 characters (design standards
|
||||
* §3). A node is accepted so the sentence can carry an inline link,
|
||||
* not so it can carry a second paragraph — the slot owns no vertical
|
||||
* rhythm, so a stack of `<p>`s collapses into one run and reads
|
||||
* broken. Multi-sentence prose goes in the body as
|
||||
* `<p className="text-sm">` (foreground). */
|
||||
description?: React.ReactNode;
|
||||
/** Optional right-aligned status surface — typically an
|
||||
* `<IntegrationStatusPill>` or a wrapper around badges. */
|
||||
@@ -88,10 +92,11 @@ export function SettingsCardHeader({
|
||||
<div className="flex shrink-0 items-center gap-2">{status}</div>
|
||||
) : null}
|
||||
</div>
|
||||
{/* One sentence, one text run. This slot deliberately carries no
|
||||
`space-y-*`: the vertical rhythm was the licence call sites used
|
||||
to stack explainer paragraphs into a muted `text-xs` slot. */}
|
||||
{description ? (
|
||||
<div className="text-muted-foreground space-y-1 text-xs">
|
||||
{description}
|
||||
</div>
|
||||
<div className="text-muted-foreground text-xs">{description}</div>
|
||||
) : null}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@@ -195,7 +195,6 @@ export function AboutMeSection({
|
||||
as="section"
|
||||
aria-labelledby="settings-ai-about-me-title"
|
||||
data-testid="settings-about-me-card"
|
||||
className="space-y-4"
|
||||
>
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<SettingsCardHeader
|
||||
@@ -229,7 +228,7 @@ export function AboutMeSection({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="pl-7">
|
||||
<div>
|
||||
{structuredField(
|
||||
"allergies",
|
||||
t("settings.ai.aboutMe.allergiesLabel"),
|
||||
@@ -237,7 +236,7 @@ export function AboutMeSection({
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5 pl-7">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="settings-about-me-freetext">
|
||||
{t("settings.ai.aboutMe.freeTextLabel")}
|
||||
</Label>
|
||||
@@ -255,7 +254,7 @@ export function AboutMeSection({
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center justify-between gap-3 pl-7">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<p
|
||||
data-testid="settings-about-me-count"
|
||||
className="text-muted-foreground text-xs tabular-nums"
|
||||
@@ -339,14 +338,14 @@ export function AboutMeSection({
|
||||
</p>
|
||||
)}
|
||||
|
||||
<p className="text-muted-foreground border-border border-t pt-3 pl-7 text-xs">
|
||||
<p className="text-muted-foreground border-border border-t pt-3 text-xs">
|
||||
{t("settings.ai.aboutMe.hint")} {t("settings.ai.aboutMe.profileHint")}
|
||||
</p>
|
||||
|
||||
{/* v1.25.12 — cross-link to the Anamnese (medical-history) home, where the
|
||||
chronic conditions the Coach watches now live alongside allergies +
|
||||
family history as one coherent medical history. */}
|
||||
<p className="text-muted-foreground pl-7 text-xs">
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("settings.ai.aboutMe.recordsLink")}{" "}
|
||||
<Link
|
||||
href="/settings/anamnesis"
|
||||
|
||||
@@ -197,18 +197,17 @@ export function AboutSection() {
|
||||
icon={Info}
|
||||
title="HealthLog"
|
||||
description={t("settings.about.identityDescription")}
|
||||
className="mb-4"
|
||||
/>
|
||||
|
||||
{isLoading || !version ? (
|
||||
<div className="flex items-center gap-2 pl-7">
|
||||
<div className="flex items-center gap-2">
|
||||
<Loader2 className="text-muted-foreground h-4 w-4 animate-spin motion-reduce:animate-none" />
|
||||
<span className="text-muted-foreground text-sm">
|
||||
{t("common.loading")}
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<dl className="flex flex-wrap items-baseline gap-x-6 gap-y-3 pl-7 text-sm">
|
||||
<dl className="flex flex-wrap items-baseline gap-x-6 gap-y-3 text-sm">
|
||||
<div className="flex items-baseline gap-2">
|
||||
<dt className="text-muted-foreground text-xs tracking-wide uppercase">
|
||||
{t("settings.about.version")}
|
||||
@@ -272,9 +271,8 @@ export function AboutSection() {
|
||||
icon={BookOpen}
|
||||
title={t("settings.about.linksHeading")}
|
||||
description={t("settings.about.linksDescription")}
|
||||
className="mb-4"
|
||||
/>
|
||||
<div className="flex flex-col gap-2 pl-7 sm:flex-row sm:flex-wrap">
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:flex-wrap">
|
||||
<Button
|
||||
asChild
|
||||
variant="outline"
|
||||
|
||||
@@ -148,7 +148,7 @@ export function GrantInviteCard() {
|
||||
title={t("recordSharing.invite.title")}
|
||||
description={t("recordSharing.invite.description")}
|
||||
/>
|
||||
<form onSubmit={submit} className="mt-4 space-y-3">
|
||||
<form onSubmit={submit} className="space-y-3">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="grant-invite-identifier">
|
||||
{t("recordSharing.invite.identifierLabel")}
|
||||
|
||||
@@ -52,7 +52,7 @@ export function GrantsGivenCard() {
|
||||
title={t("recordSharing.given.title")}
|
||||
description={t("recordSharing.given.description")}
|
||||
/>
|
||||
<div className="mt-4">
|
||||
<div>
|
||||
{isLoading && (
|
||||
<Loader2
|
||||
className="text-muted-foreground size-5 animate-spin motion-reduce:animate-none"
|
||||
|
||||
@@ -68,7 +68,7 @@ export function GrantsReceivedCard() {
|
||||
title={t("recordSharing.received.title")}
|
||||
description={t("recordSharing.received.description")}
|
||||
/>
|
||||
<div className="mt-4">
|
||||
<div>
|
||||
{isLoading && (
|
||||
<Loader2
|
||||
className="text-muted-foreground size-5 animate-spin motion-reduce:animate-none"
|
||||
|
||||
@@ -75,7 +75,7 @@ export function ManagedProfileCard() {
|
||||
title={t("recordSharing.managed.title")}
|
||||
description={t("recordSharing.managed.description")}
|
||||
/>
|
||||
<div className="mt-4 space-y-4">
|
||||
<div className="space-y-4">
|
||||
{/* Content, not meta: this is what somebody is consenting to take on,
|
||||
and UI-STANDARDS §3 reserves muted for the incidental. */}
|
||||
<p data-slot="managed-profile-explainer" className="text-sm">
|
||||
|
||||
@@ -50,7 +50,7 @@ export function RecordActivityCard() {
|
||||
title={t("recordSharing.activity.title")}
|
||||
description={t("recordSharing.activity.description")}
|
||||
/>
|
||||
<div className="mt-4 space-y-3">
|
||||
<div className="space-y-3">
|
||||
{/* The window, stated. Rendered only once the server has said what it
|
||||
is — a placeholder number here would be exactly the invented value
|
||||
the sentence exists to avoid. */}
|
||||
|
||||
@@ -108,20 +108,16 @@ export function AvatarSection() {
|
||||
|
||||
return (
|
||||
<SettingsCard>
|
||||
<SettingsCardHeader
|
||||
icon={ImageUp}
|
||||
title={t("settings.avatar.title")}
|
||||
className="mb-4"
|
||||
/>
|
||||
<SettingsCardHeader icon={ImageUp} title={t("settings.avatar.title")} />
|
||||
{/* The accepted formats and limits belong to the sentence that explains
|
||||
what this card does, not to a caption of their own under the buttons.
|
||||
Two strings rather than one merged key: the same wording is the
|
||||
upload's own constraint copy, and a translator keeps the shorter unit
|
||||
either way. */}
|
||||
<p className="text-muted-foreground mb-4 pl-7 text-sm">
|
||||
<p className="text-muted-foreground mb-4 text-sm">
|
||||
{t("settings.avatar.description")} {t("settings.avatar.hint")}
|
||||
</p>
|
||||
<div className="flex items-center gap-4 pl-7">
|
||||
<div className="flex items-center gap-4">
|
||||
<Avatar className="size-16">
|
||||
{avatarUrl && <AvatarImage src={avatarUrl} alt={user.username} />}
|
||||
<AvatarFallback className="text-base">
|
||||
|
||||
@@ -362,12 +362,8 @@ export function AccountSection() {
|
||||
<AvatarSection />
|
||||
|
||||
<SettingsCard>
|
||||
<SettingsCardHeader
|
||||
icon={User}
|
||||
title={t("settings.profile")}
|
||||
className="mb-4"
|
||||
/>
|
||||
<form onSubmit={handleSaveProfile} className="space-y-4 pl-7">
|
||||
<SettingsCardHeader icon={User} title={t("settings.profile")} />
|
||||
<form onSubmit={handleSaveProfile} className="space-y-4">
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="username">{t("settings.username")}</Label>
|
||||
|
||||
@@ -151,7 +151,7 @@ function ResearchModeCard() {
|
||||
<div
|
||||
role="alert"
|
||||
data-slot="settings-research-mode-reprompt"
|
||||
className="border-warning/40 bg-warning/10 mt-4 rounded-md border-l-4 px-3 py-2 text-sm"
|
||||
className="border-warning/40 bg-warning/10 rounded-md border-l-4 px-3 py-2 text-sm"
|
||||
>
|
||||
<p className="text-foreground font-medium">
|
||||
{t("settings.researchMode.rePromptTitle")}
|
||||
@@ -171,7 +171,7 @@ function ResearchModeCard() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-4 flex items-start justify-between gap-3">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="space-y-0.5">
|
||||
<p className="text-sm font-medium">
|
||||
{t("settings.researchMode.toggleLabel")}
|
||||
@@ -197,7 +197,7 @@ function ResearchModeCard() {
|
||||
{errorMessage && (
|
||||
<p
|
||||
role="alert"
|
||||
className="text-destructive mt-3 text-sm"
|
||||
className="text-destructive text-sm"
|
||||
data-slot="settings-research-mode-error"
|
||||
>
|
||||
{errorMessage}
|
||||
|
||||
@@ -140,13 +140,13 @@ export function AiInsightsCard({
|
||||
}
|
||||
|
||||
return (
|
||||
<SettingsCard className="space-y-4">
|
||||
<SettingsCard>
|
||||
{/* The card used to render an icon-only header row (Sparkles +
|
||||
status badges, no title), which left the tile unanchored next
|
||||
to its titled siblings. It now follows the shared
|
||||
`SettingsCardHeader` contract: icon column, title + description
|
||||
in the content column, status badges top-right — and the body
|
||||
below indents to the title column (`pl-7`). */}
|
||||
starts on the card edge like every other Settings card. */}
|
||||
<SettingsCardHeader
|
||||
icon={Sparkles}
|
||||
title={t("settings.ai.providerCardTitle")}
|
||||
@@ -159,7 +159,7 @@ export function AiInsightsCard({
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="space-y-4 pl-7">
|
||||
<div className="space-y-4">
|
||||
<ActiveProviderSelect
|
||||
value={selectedProvider}
|
||||
onChange={pickProvider}
|
||||
|
||||
@@ -169,8 +169,8 @@ export function DisableCoachCard({
|
||||
aria-live="polite"
|
||||
className={
|
||||
msgType === "error"
|
||||
? "text-destructive mt-3 pl-7 text-xs"
|
||||
: "text-muted-foreground mt-3 pl-7 text-xs"
|
||||
? "text-destructive mt-3 text-xs"
|
||||
: "text-muted-foreground mt-3 text-xs"
|
||||
}
|
||||
>
|
||||
{msg}
|
||||
|
||||
@@ -44,7 +44,7 @@ export function AnamnesisSection() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{(coachEnabled || insightsEnabled) && (
|
||||
<SettingsCard className="space-y-4">
|
||||
<SettingsCard>
|
||||
<SettingsCardHeader
|
||||
icon={EyeOff}
|
||||
title={t("records.aiInclusion.cardTitle")}
|
||||
@@ -55,7 +55,7 @@ export function AnamnesisSection() {
|
||||
)}
|
||||
|
||||
{coachEnabled && (
|
||||
<SettingsCard className="space-y-4">
|
||||
<SettingsCard>
|
||||
<SettingsCardHeader
|
||||
icon={HeartPulse}
|
||||
title={t("records.conditions.cardTitle")}
|
||||
@@ -64,7 +64,7 @@ export function AnamnesisSection() {
|
||||
<ConditionsManager />
|
||||
</SettingsCard>
|
||||
)}
|
||||
<SettingsCard className="space-y-4">
|
||||
<SettingsCard>
|
||||
<SettingsCardHeader
|
||||
icon={ClipboardList}
|
||||
title={t("records.profileFacts.cardTitle")}
|
||||
@@ -73,7 +73,7 @@ export function AnamnesisSection() {
|
||||
<HealthProfileFactsManager />
|
||||
</SettingsCard>
|
||||
|
||||
<SettingsCard className="space-y-4">
|
||||
<SettingsCard>
|
||||
<SettingsCardHeader
|
||||
icon={ShieldAlert}
|
||||
title={t("records.allergies.cardTitle")}
|
||||
@@ -82,7 +82,7 @@ export function AnamnesisSection() {
|
||||
<AllergyManager />
|
||||
</SettingsCard>
|
||||
|
||||
<SettingsCard className="space-y-4">
|
||||
<SettingsCard>
|
||||
<SettingsCardHeader
|
||||
icon={Users}
|
||||
title={t("records.family.cardTitle")}
|
||||
|
||||
@@ -75,7 +75,7 @@ function ApiEndpointsCard() {
|
||||
/>
|
||||
|
||||
{/* Desktop table — verbatim layout for md+. */}
|
||||
<div className="border-border mt-4 hidden overflow-x-auto rounded-lg border md:block">
|
||||
<div className="border-border hidden overflow-x-auto rounded-lg border md:block">
|
||||
<table className="w-full min-w-[760px] text-xs md:min-w-0">
|
||||
<thead>
|
||||
<tr className="bg-muted/40 text-muted-foreground border-b">
|
||||
@@ -109,7 +109,7 @@ function ApiEndpointsCard() {
|
||||
{/* Mobile card list — each endpoint stacks its columns vertically
|
||||
with a labelled meta row. No horizontal scroll. */}
|
||||
<ul
|
||||
className="mt-4 space-y-2 md:hidden"
|
||||
className="space-y-2 md:hidden"
|
||||
data-testid="settings-api-endpoints-mobile-list"
|
||||
>
|
||||
{endpoints.map((endpoint) => (
|
||||
@@ -206,7 +206,7 @@ function ApiTokensCard() {
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="mt-4 space-y-4 pl-7">
|
||||
<div className="space-y-4">
|
||||
{/* Tokens are no longer minted here. The generic mint issued a
|
||||
`medication:ingest` token that the ingest endpoint refused (it
|
||||
gates on the per-medication grant too) and that every other
|
||||
|
||||
@@ -141,7 +141,6 @@ export function CoachMemorySection({
|
||||
as="section"
|
||||
aria-labelledby="settings-ai-coach-memory-title"
|
||||
data-testid="settings-coach-memory-card"
|
||||
className="space-y-4"
|
||||
>
|
||||
<SettingsCardHeader
|
||||
icon={Brain}
|
||||
|
||||
@@ -225,7 +225,7 @@ export function CoachNudgeCard({
|
||||
}
|
||||
/>
|
||||
{enabled && (
|
||||
<div className="mt-4 space-y-3 pl-7">
|
||||
<div className="space-y-3">
|
||||
<p className="text-sm font-medium">
|
||||
{t("notifications.coachNudge.groupsLabel")}
|
||||
</p>
|
||||
@@ -307,7 +307,7 @@ export function CoachNudgeCard({
|
||||
push-nudge master switch above (a user can keep nudges but silence
|
||||
the example prompts), so it sits outside the `enabled` gate and rides
|
||||
the same `coach` prefs blob. */}
|
||||
<div className="border-border/60 mt-4 flex min-h-11 items-center justify-between gap-3 border-t pt-4">
|
||||
<div className="border-border/60 flex min-h-11 items-center justify-between gap-3 border-t pt-4">
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm">
|
||||
{t("notifications.coachNudge.suggestionsLabel")}
|
||||
@@ -331,8 +331,8 @@ export function CoachNudgeCard({
|
||||
aria-live="polite"
|
||||
className={
|
||||
msgType === "error"
|
||||
? "text-destructive mt-3 pl-7 text-sm"
|
||||
: "text-muted-foreground mt-3 pl-7 text-sm"
|
||||
? "text-destructive mt-3 text-sm"
|
||||
: "text-muted-foreground mt-3 text-sm"
|
||||
}
|
||||
>
|
||||
{msg}
|
||||
|
||||
@@ -120,10 +120,7 @@ export function CoachPrefsSection({ isAuthenticated }: CoachPrefsSectionProps) {
|
||||
description={t("insights.coach.settingsDescription")}
|
||||
/>
|
||||
{isLoading ? (
|
||||
<div
|
||||
data-slot="coach-prefs-skeleton"
|
||||
className="mt-4 flex flex-col gap-4 pl-7"
|
||||
>
|
||||
<div data-slot="coach-prefs-skeleton" className="flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-2">
|
||||
<Skeleton className="h-3 w-20" />
|
||||
<Skeleton className="h-9 w-full" />
|
||||
@@ -139,7 +136,7 @@ export function CoachPrefsSection({ isAuthenticated }: CoachPrefsSectionProps) {
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-4 flex flex-col gap-4 pl-7">
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
{/* Tone */}
|
||||
<div className="flex flex-col gap-2">
|
||||
@@ -338,7 +335,7 @@ export function CoachPrefsSection({ isAuthenticated }: CoachPrefsSectionProps) {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="mt-4 flex justify-end pl-7">
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
|
||||
@@ -155,7 +155,6 @@ export function CoachRemindersSection({
|
||||
as="section"
|
||||
aria-labelledby="settings-ai-coach-reminders-title"
|
||||
data-testid="settings-coach-reminders-card"
|
||||
className="space-y-4"
|
||||
>
|
||||
<SettingsCardHeader
|
||||
icon={BellRing}
|
||||
|
||||
@@ -66,7 +66,7 @@ export function CycleTrackingCard({
|
||||
title={t("settings.cycleTracking.title")}
|
||||
description={t("settings.cycleTracking.description")}
|
||||
/>
|
||||
<div className="mt-4 space-y-4 pl-7">
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<Label htmlFor="cycle-tracking-enable" className="text-sm">
|
||||
{t("settings.cycleTracking.enable")}
|
||||
|
||||
@@ -440,7 +440,7 @@ export function DashboardLayoutSection({ id }: { id: string }) {
|
||||
const dirty = draft !== null && layout !== null;
|
||||
|
||||
return (
|
||||
<SettingsCard id={id} className="scroll-mt-28 space-y-4">
|
||||
<SettingsCard id={id} className="scroll-mt-28">
|
||||
{/* v1.4.19 A6 — title / action row uses the same stack-on-mobile,
|
||||
right-align-on-desktop contract as Account → Password +
|
||||
Restart onboarding tour. Avoids the long German "Auf Standard
|
||||
|
||||
@@ -120,7 +120,7 @@ export function EmailCard({ isAuthenticated }: { isAuthenticated: boolean }) {
|
||||
description={t("settings.emailDescription")}
|
||||
/>
|
||||
|
||||
<div className="mt-4 space-y-4 pl-7">
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label htmlFor="email-toggle">{t("settings.emailEnable")}</Label>
|
||||
<Switch
|
||||
|
||||
@@ -203,9 +203,8 @@ export function EnvironmentSection() {
|
||||
<SettingsCardHeader
|
||||
icon={CloudSun}
|
||||
title={t("settings.sections.environment.title")}
|
||||
className="mb-2"
|
||||
/>
|
||||
<p className="text-muted-foreground pl-7 text-sm leading-relaxed">
|
||||
<p className="text-muted-foreground text-sm leading-relaxed">
|
||||
{t("settings.sections.environment.disabledHint")}
|
||||
</p>
|
||||
</SettingsCard>
|
||||
@@ -227,12 +226,11 @@ export function EnvironmentSection() {
|
||||
<SettingsCardHeader
|
||||
icon={MapPin}
|
||||
title={t("settings.sections.environment.home.title")}
|
||||
className="mb-2"
|
||||
/>
|
||||
<p className="text-muted-foreground mb-3 pl-7 text-sm leading-relaxed">
|
||||
<p className="text-muted-foreground mb-3 text-sm leading-relaxed">
|
||||
{t("settings.sections.environment.home.description")}
|
||||
</p>
|
||||
<div className="space-y-3 pl-7">
|
||||
<div className="space-y-3">
|
||||
{home ? (
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm" data-slot="environment-home-current">
|
||||
@@ -315,12 +313,11 @@ export function EnvironmentSection() {
|
||||
<SettingsCardHeader
|
||||
icon={Plane}
|
||||
title={t("settings.sections.environment.travel.title")}
|
||||
className="mb-2"
|
||||
/>
|
||||
<p className="text-muted-foreground mb-3 pl-7 text-sm leading-relaxed">
|
||||
<p className="text-muted-foreground mb-3 text-sm leading-relaxed">
|
||||
{t("settings.sections.environment.travel.description")}
|
||||
</p>
|
||||
<div className="space-y-3 pl-7">
|
||||
<div className="space-y-3">
|
||||
{data && data.travel.length > 0 ? (
|
||||
<ul className="divide-border divide-y rounded-md border">
|
||||
{data.travel.map((tr) => (
|
||||
@@ -459,12 +456,11 @@ export function EnvironmentSection() {
|
||||
<SettingsCardHeader
|
||||
icon={CloudSun}
|
||||
title={t("settings.sections.environment.backfill.title")}
|
||||
className="mb-2"
|
||||
/>
|
||||
<p className="text-muted-foreground mb-3 pl-7 text-sm leading-relaxed">
|
||||
<p className="text-muted-foreground mb-3 text-sm leading-relaxed">
|
||||
{t("settings.sections.environment.backfill.description")}
|
||||
</p>
|
||||
<div className="flex flex-wrap items-end gap-2 pl-7">
|
||||
<div className="flex flex-wrap items-end gap-2">
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="env-backfill-start">
|
||||
{t("settings.sections.environment.startDate")}
|
||||
@@ -499,7 +495,7 @@ export function EnvironmentSection() {
|
||||
</Button>
|
||||
</div>
|
||||
{data && (
|
||||
<p className="text-muted-foreground mt-3 pl-7 text-xs">
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("settings.sections.environment.storedDays", {
|
||||
count: data.context.days,
|
||||
})}
|
||||
@@ -509,7 +505,7 @@ export function EnvironmentSection() {
|
||||
<p
|
||||
role="status"
|
||||
data-testid="environment-fetch-failed"
|
||||
className="text-destructive mt-1 pl-7 text-xs"
|
||||
className="text-destructive mt-1 text-xs"
|
||||
>
|
||||
{tCount(
|
||||
"settings.sections.environment.fetchFailed",
|
||||
|
||||
@@ -144,8 +144,8 @@ function ExportCardShell({
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
{children && <div className="mt-3 space-y-3">{children}</div>}
|
||||
<div className="mt-4 flex flex-wrap items-center gap-3">{footer}</div>
|
||||
{children && <div className="space-y-3">{children}</div>}
|
||||
<div className="flex flex-wrap items-center gap-3">{footer}</div>
|
||||
</SettingsCard>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -70,7 +70,7 @@ export function GlucoseReferenceCard() {
|
||||
title={t("settings.glucoseReference.title")}
|
||||
description={t("settings.glucoseReference.description")}
|
||||
/>
|
||||
<div className="mt-4 space-y-4 pl-7">
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<Label htmlFor="glucose-reference-diabetes" className="text-sm">
|
||||
{t("settings.glucoseReference.enable")}
|
||||
|
||||
@@ -25,7 +25,7 @@ export function ImportCardShell({
|
||||
<h3 className="text-base font-semibold">{title}</h3>
|
||||
</div>
|
||||
<p className="text-muted-foreground mt-1 text-xs">{description}</p>
|
||||
<div className="mt-3 flex flex-1 flex-col gap-3">{children}</div>
|
||||
<div className="flex flex-1 flex-col gap-3">{children}</div>
|
||||
</SettingsCard>
|
||||
);
|
||||
}
|
||||
@@ -73,7 +73,7 @@ export function InjectionSitesCard({
|
||||
title={t("settings.globalExcludedInjectionSitesLabel")}
|
||||
description={t("settings.globalExcludedInjectionSitesHint")}
|
||||
/>
|
||||
<div className="mt-4 grid grid-cols-1 gap-2 pl-7 sm:grid-cols-2">
|
||||
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2">
|
||||
{INJECTION_SITE_KEYS.map((site) => (
|
||||
<label key={site} className="flex items-center gap-2 text-sm">
|
||||
<Checkbox
|
||||
|
||||
@@ -257,7 +257,6 @@ export function InsightsPillOrderSection({ id }: { id?: string }) {
|
||||
id={id}
|
||||
data-slot="insights-pill-order-section"
|
||||
aria-labelledby="insights-pill-order-title"
|
||||
className="space-y-4"
|
||||
>
|
||||
<SettingsCardHeader
|
||||
icon={ListOrdered}
|
||||
|
||||
@@ -160,12 +160,9 @@ export function AppleHealthCard({ enabled }: { enabled: boolean }) {
|
||||
}
|
||||
/>
|
||||
|
||||
<hr
|
||||
data-testid="integration-card-divider"
|
||||
className="border-border/60 mt-4"
|
||||
/>
|
||||
<hr data-testid="integration-card-divider" className="border-border/60" />
|
||||
|
||||
<div className="mt-4 space-y-4 pl-7">
|
||||
<div className="space-y-4">
|
||||
{isError && (
|
||||
<IntegrationErrorMessage
|
||||
message={t("settings.appleHealth.status.unavailable")}
|
||||
|
||||
@@ -243,12 +243,9 @@ export function FitbitCard({
|
||||
}
|
||||
/>
|
||||
|
||||
<hr
|
||||
data-testid="integration-card-divider"
|
||||
className="border-border/60 mt-4"
|
||||
/>
|
||||
<hr data-testid="integration-card-divider" className="border-border/60" />
|
||||
|
||||
<div className="mt-4 space-y-4 pl-7">
|
||||
<div className="space-y-4">
|
||||
{errorMessage && <IntegrationErrorMessage message={errorMessage} />}
|
||||
{/* Parked-integration resume CTA. Surfaces only when the row
|
||||
state is `parked` (>24h of persistent failures). The button
|
||||
|
||||
@@ -415,12 +415,9 @@ export function GoogleHealthCard({
|
||||
}
|
||||
/>
|
||||
|
||||
<hr
|
||||
data-testid="integration-card-divider"
|
||||
className="border-border/60 mt-4"
|
||||
/>
|
||||
<hr data-testid="integration-card-divider" className="border-border/60" />
|
||||
|
||||
<div className="mt-4 space-y-4 pl-7">
|
||||
<div className="space-y-4">
|
||||
{errorMessage && <IntegrationErrorMessage message={errorMessage} />}
|
||||
|
||||
{/* Re-consent CTA — distinct from parked/disconnected. Google expires
|
||||
|
||||
@@ -95,7 +95,7 @@ export function IntegrationFallbackRow({
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<p className="text-muted-foreground mt-4 pl-7 text-xs">{meta}</p>
|
||||
<p className="text-muted-foreground text-xs">{meta}</p>
|
||||
</SettingsCard>
|
||||
);
|
||||
}
|
||||
@@ -216,12 +216,9 @@ export function NightscoutCard({
|
||||
}
|
||||
/>
|
||||
|
||||
<hr
|
||||
data-testid="integration-card-divider"
|
||||
className="border-border/60 mt-4"
|
||||
/>
|
||||
<hr data-testid="integration-card-divider" className="border-border/60" />
|
||||
|
||||
<div className="mt-4 space-y-4 pl-7">
|
||||
<div className="space-y-4">
|
||||
{errorMessage && <IntegrationErrorMessage message={errorMessage} />}
|
||||
{operatorApprovalRequired && (
|
||||
<div
|
||||
|
||||
@@ -285,12 +285,9 @@ export function OAuthProviderCard({
|
||||
}
|
||||
/>
|
||||
|
||||
<hr
|
||||
data-testid="integration-card-divider"
|
||||
className="border-border/60 mt-4"
|
||||
/>
|
||||
<hr data-testid="integration-card-divider" className="border-border/60" />
|
||||
|
||||
<div className="mt-4 space-y-4 pl-7">
|
||||
<div className="space-y-4">
|
||||
{errorMessage && (
|
||||
<div data-testid={`${provider}-error`}>
|
||||
<IntegrationErrorMessage message={errorMessage} />
|
||||
|
||||
@@ -224,12 +224,9 @@ export function WhoopCard({
|
||||
}
|
||||
/>
|
||||
|
||||
<hr
|
||||
data-testid="integration-card-divider"
|
||||
className="border-border/60 mt-4"
|
||||
/>
|
||||
<hr data-testid="integration-card-divider" className="border-border/60" />
|
||||
|
||||
<div className="mt-4 space-y-4 pl-7">
|
||||
<div className="space-y-4">
|
||||
{errorMessage && <IntegrationErrorMessage message={errorMessage} />}
|
||||
{/* Parked-integration resume CTA. Surfaces only when the row
|
||||
state is `parked` (>24h of persistent failures). The button
|
||||
|
||||
@@ -236,12 +236,9 @@ export function WithingsCard({
|
||||
}
|
||||
/>
|
||||
|
||||
<hr
|
||||
data-testid="integration-card-divider"
|
||||
className="border-border/60 mt-4"
|
||||
/>
|
||||
<hr data-testid="integration-card-divider" className="border-border/60" />
|
||||
|
||||
<div className="mt-4 space-y-4 pl-7">
|
||||
<div className="space-y-4">
|
||||
{errorMessage && <IntegrationErrorMessage message={errorMessage} />}
|
||||
{/* v1.4.25 W5d — Withings activity-scope reconnect banner.
|
||||
Only paints when (a) the user has an active Withings
|
||||
|
||||
@@ -243,7 +243,7 @@ export function LowStockCard({
|
||||
}
|
||||
/>
|
||||
{enabled && (
|
||||
<div className="mt-4 flex min-h-11 items-center gap-3 pl-7">
|
||||
<div className="flex min-h-11 items-center gap-3">
|
||||
<label htmlFor="low-stock-days" className="text-sm font-medium">
|
||||
{t("notifications.lowStock.daysLabel")}
|
||||
</label>
|
||||
@@ -273,7 +273,7 @@ export function LowStockCard({
|
||||
users never touch. A single medication can override this on its
|
||||
own supply tab. */}
|
||||
{enabled && (
|
||||
<div className="mt-4 pl-7">
|
||||
<div>
|
||||
<div className="flex min-h-11 items-center gap-3">
|
||||
<label htmlFor="low-stock-lead" className="text-sm font-medium">
|
||||
{t("notifications.lowStock.leadLabel")}
|
||||
@@ -309,8 +309,8 @@ export function LowStockCard({
|
||||
aria-live="polite"
|
||||
className={
|
||||
msgType === "error"
|
||||
? "text-destructive mt-3 pl-7 text-sm"
|
||||
: "text-muted-foreground mt-3 pl-7 text-sm"
|
||||
? "text-destructive mt-3 text-sm"
|
||||
: "text-muted-foreground mt-3 text-sm"
|
||||
}
|
||||
>
|
||||
{msg}
|
||||
@@ -318,7 +318,7 @@ export function LowStockCard({
|
||||
)}
|
||||
{/* v1.16.11 — back-link to the medications list, the surface the
|
||||
alert is about (the supply tab links here the same way). */}
|
||||
<p className="mt-3 pl-7">
|
||||
<p>
|
||||
<Link
|
||||
href="/medications"
|
||||
className="text-muted-foreground hover:text-foreground text-xs underline-offset-2 hover:underline"
|
||||
|
||||
@@ -108,7 +108,7 @@ export function ManagedIntegrationStatus() {
|
||||
description={t("settings.sharedRecord.integrationStatusDescription")}
|
||||
/>
|
||||
<ul
|
||||
className="mt-4 space-y-3"
|
||||
className="space-y-3"
|
||||
aria-label={activeRecord?.displayName ?? undefined}
|
||||
>
|
||||
{statusQuery.data.integrations.map((integration) => (
|
||||
|
||||
@@ -1011,7 +1011,7 @@ export function ManagedRecordSettingsSection({
|
||||
settings={query.data.settings}
|
||||
/>
|
||||
{save.isError || validationFailed ? (
|
||||
<p className="text-destructive mt-3 text-sm" role="alert">
|
||||
<p className="text-destructive text-sm" role="alert">
|
||||
{t("common.networkError")}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
@@ -108,7 +108,7 @@ function McpConnectionsCard() {
|
||||
description={t("settings.mcp.connectionsDescription")}
|
||||
/>
|
||||
|
||||
<div className="mt-4 space-y-4 pl-7">
|
||||
<div className="space-y-4">
|
||||
{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")}
|
||||
@@ -223,7 +223,7 @@ function McpEnableCard() {
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="mt-4 space-y-4 pl-7">
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<span className="text-sm font-medium">
|
||||
{t("settings.mcp.enableToggleLabel")}
|
||||
@@ -337,7 +337,7 @@ function McpTokensCard() {
|
||||
description={t("settings.mcp.tokensDescription")}
|
||||
/>
|
||||
|
||||
<div className="mt-4 space-y-4 pl-7">
|
||||
<div className="space-y-4">
|
||||
<p className="text-muted-foreground text-xs leading-relaxed">
|
||||
{t("settings.mcp.scopeNote")}
|
||||
</p>
|
||||
|
||||
@@ -80,7 +80,7 @@ export function MedicationsSection() {
|
||||
<div className="space-y-6">
|
||||
{/* View preference — cards vs table. The shared header toggle
|
||||
writes optimistically, so there is no Save button here. */}
|
||||
<SettingsCard id="medications-view" className="scroll-mt-28 space-y-4">
|
||||
<SettingsCard id="medications-view" className="scroll-mt-28">
|
||||
<SettingsCardHeader
|
||||
icon={LayoutGrid}
|
||||
title={t("medications.viewToggleLabel")}
|
||||
@@ -98,7 +98,7 @@ export function MedicationsSection() {
|
||||
</SettingsCard>
|
||||
|
||||
{/* Manual order — applies to both list views. */}
|
||||
<SettingsCard id="medications-order" className="scroll-mt-28 space-y-4">
|
||||
<SettingsCard id="medications-order" className="scroll-mt-28">
|
||||
<SettingsCardHeader
|
||||
icon={Pill}
|
||||
title={t("medications.reorderTitle")}
|
||||
|
||||
@@ -206,7 +206,7 @@ export function ModulesSection() {
|
||||
title={t("settings.sections.modules.toggleable.title")}
|
||||
description={t("settings.sections.modules.toggleable.description")}
|
||||
/>
|
||||
<div className="divide-border mt-4 divide-y pl-7">
|
||||
<div className="divide-border divide-y">
|
||||
{(Object.keys(MODULE_REGISTRY) as ModuleKey[])
|
||||
// Modules switched off in code (pending a rebuild) carry no live
|
||||
// toggle — drop the row entirely so a user can't turn one on.
|
||||
|
||||
@@ -202,7 +202,7 @@ export function MoodReminderCard({
|
||||
}
|
||||
/>
|
||||
{enabled && (
|
||||
<div className="mt-4 flex min-h-11 items-center gap-3 pl-7">
|
||||
<div className="flex min-h-11 items-center gap-3">
|
||||
<label htmlFor="mood-reminder-hour" className="text-sm font-medium">
|
||||
{t("notifications.moodReminder.hourLabel")}
|
||||
</label>
|
||||
@@ -228,8 +228,8 @@ export function MoodReminderCard({
|
||||
aria-live="polite"
|
||||
className={
|
||||
msgType === "error"
|
||||
? "text-destructive mt-3 pl-7 text-sm"
|
||||
: "text-muted-foreground mt-3 pl-7 text-sm"
|
||||
? "text-destructive mt-3 text-sm"
|
||||
: "text-muted-foreground mt-3 text-sm"
|
||||
}
|
||||
>
|
||||
{msg}
|
||||
|
||||
@@ -46,7 +46,7 @@ export function MoodSection() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Groups — picker group order + own groups. */}
|
||||
<SettingsCard id="mood-groups" className="scroll-mt-28 space-y-4">
|
||||
<SettingsCard id="mood-groups" className="scroll-mt-28">
|
||||
<SettingsCardHeader
|
||||
icon={Folders}
|
||||
title={t("mood.manage.groupsTitle")}
|
||||
@@ -55,13 +55,13 @@ export function MoodSection() {
|
||||
</SettingsCard>
|
||||
|
||||
{/* Tags — visibility, order, edit, move, create. */}
|
||||
<SettingsCard id="mood-tags" className="scroll-mt-28 space-y-4">
|
||||
<SettingsCard id="mood-tags" className="scroll-mt-28">
|
||||
<SettingsCardHeader icon={Tags} title={t("mood.manage.tagsTitle")} />
|
||||
{isLoading || !catalog ? loading : <TagManagerCard catalog={catalog} />}
|
||||
</SettingsCard>
|
||||
|
||||
{/* Archived custom tags — restore / hard delete. */}
|
||||
<SettingsCard id="mood-archived" className="scroll-mt-28 space-y-4">
|
||||
<SettingsCard id="mood-archived" className="scroll-mt-28">
|
||||
<SettingsCardHeader
|
||||
icon={Archive}
|
||||
title={t("mood.manage.archivedTitle")}
|
||||
|
||||
@@ -156,7 +156,7 @@ export function NotificationStatusCard() {
|
||||
/>
|
||||
|
||||
<ul
|
||||
className="divide-border/60 mt-4 divide-y pl-7"
|
||||
className="divide-border/60 divide-y"
|
||||
data-testid="notification-status-list"
|
||||
>
|
||||
{channels.map((ch) => (
|
||||
|
||||
@@ -128,7 +128,7 @@ export function NtfyCard({ isAuthenticated }: { isAuthenticated: boolean }) {
|
||||
description={t("settings.ntfyDescription")}
|
||||
/>
|
||||
|
||||
<div className="mt-4 space-y-4 pl-7">
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label htmlFor="ntfy-toggle">{t("settings.ntfyEnable")}</Label>
|
||||
<Switch
|
||||
|
||||
@@ -77,10 +77,9 @@ export function NutrientIntakeCard() {
|
||||
description={t("settings.sections.sources.nutrients.description", {
|
||||
days: WINDOW_DAYS,
|
||||
})}
|
||||
className="mb-3"
|
||||
/>
|
||||
{overview.isLoading ? (
|
||||
<div className="space-y-1 pl-7" aria-hidden="true">
|
||||
<div className="space-y-1" aria-hidden="true">
|
||||
<Skeleton className="h-9 w-full rounded-md" />
|
||||
<Skeleton className="h-9 w-full rounded-md" />
|
||||
<Skeleton className="h-9 w-full rounded-md" />
|
||||
@@ -118,7 +117,7 @@ export function NutrientIntakeCard() {
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
<p className="text-muted-foreground mt-3 text-xs">
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("settings.sections.sources.nutrients.moduleHint")}{" "}
|
||||
<Link
|
||||
href="/settings/modules"
|
||||
|
||||
Loaded 100 of 121 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user