chore(release): v1.28.43 — visual consistency polish

Verified UI-consistency findings from the campaign's design audit, each
pixel-faithful except the intended correction:

- insights hero band: off-scale py-5/gap-5 → the standard scale; H1
  font-semibold → font-bold; sm:text-[28px] → sm:text-3xl.
- medication-compliance-chart + recent-achievements-card: hand-rolled
  muted h-4 icon + text-sm headers → the standard TileHeader primitive so
  they match sibling tiles.
- achievements stat tiles + clinician-view shells: p-5 → p-4 md:p-6.
- two text-primary header glyphs (research-mode dialog, enroll-mfa) →
  text-foreground (never accent on a header glyph); the deliberate
  badge-boxed icons left untouched.
- SheetHeader now reserves the trailing close-X gutter like DialogHeader,
  closing the latent overlap class for future raw-Sheet consumers.
- five raw Dialogs on phone-reachable surfaces → ResponsiveSheet.
- new bg-card-scoped healthlog/spacing-scale lint (flags p-5/gap-5 etc.
  only on card shells; list-marker insets + half-steps excluded) at error,
  with the five offenders it flagged swept.
- dropped the stale CLAUDE.md '18 PascalCase outliers' line (0 remain).

Every data-slot preserved. No migration.
This commit is contained in:
Marc-André Bombeck committed 2026-07-16 03:44:00 +02:00
1 parent 3a9ed8dd0e
commit 6d83115d16
25 files changed
+717 -544

No files matched your search

@@ -49,4 +49,22 @@ describe("<MedicationComplianceChart> v1.4.18 clean-line revert", () => {
expect(html).not.toContain("chart-gradient-medication");
expect(html).not.toContain("linearGradient");
});
it("leads with the canonical TileHeader, not the old muted h-4 / text-sm row", async () => {
const { I18nProvider } = await import("@/lib/i18n/context");
const { MedicationComplianceChart } =
await import("../medication-compliance-chart");
const html = renderToStaticMarkup(
<I18nProvider initialLocale="en">
<MedicationComplianceChart />
</I18nProvider>,
);
// Adopts the shared tile-header primitive so the card matches its sibling
// HealthChart tiles (foreground h-5 glyph, text-base title).
expect(html).toContain('data-slot="tile-header"');
// The old hand-rolled muted h-4 icon row is gone.
expect(html).not.toContain("text-muted-foreground h-4 w-4");
});
});
@@ -47,6 +47,7 @@ import {
} from "recharts";
import { ArrowDown, ArrowRight, ArrowUp, Pill } from "lucide-react";
import { ComplianceInfoTip } from "@/components/medications/card-parts/compliance-info-tip";
import { TileHeader } from "@/components/insights/tile-header";
import { Button } from "@/components/ui/button";
import { Skeleton } from "@/components/ui/skeleton";
import { useAuth } from "@/hooks/use-auth";
@@ -402,8 +403,10 @@ export function MedicationComplianceChart({
≥sm: original side-by-side layout. */}
<div className="mb-4 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<div className="flex flex-wrap items-center gap-2">
<Pill className="text-muted-foreground h-4 w-4" />
<h3 className="text-sm font-semibold">{displayTitle}</h3>
{/* Canonical tile header (foreground h-5 icon, text-base title) so
this card reads in the same language as its sibling HealthChart
tiles instead of the old muted h-4 / text-sm row. */}
<TileHeader icon={Pill} title={displayTitle} />
{/* `?` explainer for what the adherence percentage measures —
shares the i18n body with the per-card bars. */}
<ComplianceInfoTip />
+2 -2
View File
@@ -106,7 +106,7 @@ function Section({
children: React.ReactNode;
}) {
return (
<section className="border-border bg-card rounded-lg border p-5">
<section className="border-border bg-card rounded-lg border p-4 md:p-6">
<h2 className="mb-3 text-base font-semibold">{title}</h2>
{children}
</section>
@@ -345,7 +345,7 @@ export function ClinicianView({
{/* ── Fenced wellness card (descriptive, NOT clinical) ────── */}
{wellness.length > 0 ? (
<section className="border-warning/50 bg-warning/5 rounded-lg border border-dashed p-5">
<section className="border-warning/50 bg-warning/5 rounded-lg border border-dashed p-4 md:p-6">
<h2 className="text-muted-foreground mb-1 text-base font-semibold">
{t("clinicianView.wellness.title")}
</h2>
@@ -202,6 +202,18 @@ describe("<RecentAchievementsCard>", () => {
expect(html).toContain("View all");
});
it("routes the header through the canonical TileHeader (foreground icon, h2)", () => {
mockData = { summary: {}, achievements: [], metrics: {} };
const html = render();
// Adopts the shared tile-header primitive so it matches sibling tiles…
expect(html).toContain('data-slot="tile-header"');
// …with the foreground (never muted) glyph and the h2 heading, not the
// old muted h-4 / text-sm row.
expect(html).toContain("Recent unlocks");
expect(html).not.toMatch(/<h2[^>]*text-sm font-semibold/);
expect(html).not.toContain("text-muted-foreground h-4 w-4");
});
it("renders up to three most-recent unlocks with title + date", () => {
mockData = {
summary: {},
@@ -25,6 +25,7 @@ import { formatDate } from "@/lib/format";
import { useAchievementsQuery } from "@/lib/queries/use-achievements-query";
import type { AchievementProgress } from "@/lib/gamification/achievements";
import { Skeleton } from "@/components/ui/skeleton";
import { TileHeader } from "@/components/insights/tile-header";
const iconMap: Record<string, LucideIcon> = {
Activity,
@@ -113,23 +114,23 @@ export function RecentAchievementsCard() {
data-slot="recent-achievements-card"
className="bg-card border-border space-y-3 rounded-xl border p-4 md:p-6"
>
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-2">
<Trophy
className="text-muted-foreground h-4 w-4"
aria-hidden="true"
/>
<h2 className="text-sm font-semibold">
{t("achievements.dashboardCard.title")}
</h2>
</div>
<Link
href="/achievements"
className="text-muted-foreground hover:text-foreground inline-flex min-h-11 items-center text-xs"
>
{t("achievements.dashboardCard.viewAll")}
</Link>
</div>
{/* Canonical tile header (foreground h-5 icon, text-base h2) so the
dashboard card matches its sibling tiles instead of the old muted
h-4 / text-sm row. The "view all" link rides the header's right slot
(§11 inline-action-row). */}
<TileHeader
icon={Trophy}
titleAs="h2"
title={t("achievements.dashboardCard.title")}
right={
<Link
href="/achievements"
className="text-muted-foreground hover:text-foreground inline-flex min-h-11 items-center text-xs"
>
{t("achievements.dashboardCard.viewAll")}
</Link>
}
/>
{isPending ? (
<ul
@@ -55,6 +55,27 @@ describe("<HeroStrip>", () => {
expect(html).toContain("Good morning");
});
it("renders the H1 at the canonical bold weight and on-scale sizes", () => {
const html = render(<HeroStrip briefing={null} now={morningLocal} />);
const greeting = html.match(
/data-slot="insights-hero-strip-greeting"[^>]*class="([^"]*)"/,
);
expect(greeting).not.toBeNull();
// App H1 weight is bold (UI-STANDARDS §5) — never font-semibold.
expect(greeting![1]).toContain("font-bold");
expect(greeting![1]).not.toContain("font-semibold");
// No off-scale arbitrary type sizes (§5) — round to the neighbouring step.
expect(greeting![1]).not.toContain("text-[28px]");
expect(greeting![1]).toContain("sm:text-3xl");
});
it("keeps the hero band and its columns on the spacing scale (no step-5)", () => {
const html = render(<HeroStrip briefing={null} now={morningLocal} />);
// The band's own padding + the flex column gaps stay on 4/6, never 5.
expect(html).not.toContain("py-5");
expect(html).not.toContain("gap-5");
});
it("renders the morning greeting in German", () => {
const html = render(<HeroStrip briefing={null} now={morningLocal} />, "de");
expect(html).toContain("Guten Morgen");
+4 -4
View File
@@ -257,7 +257,7 @@ export function HeroStrip({
// it the shadow bled through the sticky section nav below
// (the nav uses bg-background/80 + backdrop-blur, which the
// shadow leaked through).
"relative isolate overflow-hidden rounded-xl px-4 py-5 sm:px-6 sm:py-6",
"relative isolate overflow-hidden rounded-xl px-4 py-4 sm:px-6 sm:py-6",
)}
>
{/*
@@ -290,12 +290,12 @@ export function HeroStrip({
from first paint. */}
<div
className={cn(
"flex flex-col gap-5",
"flex flex-col gap-4",
(healthScore || healthScorePending) &&
"md:flex-row md:items-stretch md:gap-6 lg:flex-row lg:items-stretch lg:gap-6",
)}
>
<div className="flex min-w-0 flex-1 flex-col gap-5">
<div className="flex min-w-0 flex-1 flex-col gap-4">
<div className="flex flex-col gap-3">
<div className="flex items-center gap-2">
{/* Header glyph stays in the foreground tier — UI-STANDARDS §1
@@ -307,7 +307,7 @@ export function HeroStrip({
/>
<h1
data-slot="insights-hero-strip-greeting"
className="text-2xl leading-tight font-semibold tracking-tight sm:text-[28px]"
className="text-2xl leading-tight font-bold tracking-tight sm:text-3xl"
>
{greeting}
</h1>
@@ -125,7 +125,7 @@ export function ResearchModeAcknowledgmentDialog({
onOpenChange={onOpenChange}
title={
<span className="flex items-center gap-2">
<BookOpenCheck className="text-primary h-5 w-5 shrink-0" />
<BookOpenCheck className="text-foreground h-5 w-5 shrink-0" />
{t("medications.researchMode.dialog.title")}
</span>
}
@@ -45,14 +45,7 @@ import { useCallback, useId, useRef, useState } from "react";
import { Loader2, Sparkles } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { useTranslations } from "@/lib/i18n/context";
@@ -208,92 +201,20 @@ export function NaturalLanguageExtractor({
const overLimit = charCount > MAX_TEXT_LENGTH;
return (
<Dialog
<ResponsiveSheet
open={open}
onOpenChange={(next) => {
if (!next) handleClose();
}}
>
<DialogContent aria-busy={busy || undefined}>
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Sparkles className="size-4" aria-hidden="true" />
{t("medications.scheduling.naturalLanguage.title")}
</DialogTitle>
<DialogDescription>
{t("medications.scheduling.naturalLanguage.description")}
</DialogDescription>
</DialogHeader>
<div className="flex flex-col gap-3">
<div>
<Label htmlFor={textareaId} className="sr-only">
{t("medications.scheduling.naturalLanguage.title")}
</Label>
<Textarea
id={textareaId}
value={text}
onChange={(e) => {
setText(e.target.value);
if (error) setError(null);
}}
placeholder={t(
"medications.scheduling.naturalLanguage.placeholder",
)}
rows={4}
maxLength={MAX_TEXT_LENGTH + 200}
disabled={busy}
aria-invalid={overLimit || error?.kind === "empty" || undefined}
aria-describedby={
errorMessage ? `${textareaId}-error` : undefined
}
/>
<div className="text-muted-foreground mt-1 text-right text-xs">
{charCount} / {MAX_TEXT_LENGTH}
</div>
</div>
{errorMessage ? (
<p
id={`${textareaId}-error`}
role="alert"
className="text-destructive text-sm"
>
{errorMessage}
</p>
) : null}
<fieldset className="flex flex-col gap-2">
<legend className="text-muted-foreground text-xs font-medium">
{t("medications.scheduling.naturalLanguage.examples.label")}
</legend>
<div className="flex flex-col gap-2 sm:flex-row sm:flex-wrap">
<ExampleChip
onPick={insertExample}
label={t(
"medications.scheduling.naturalLanguage.examples.weekly",
)}
disabled={busy}
/>
<ExampleChip
onPick={insertExample}
label={t(
"medications.scheduling.naturalLanguage.examples.daily",
)}
disabled={busy}
/>
<ExampleChip
onPick={insertExample}
label={t(
"medications.scheduling.naturalLanguage.examples.rolling",
)}
disabled={busy}
/>
</div>
</fieldset>
</div>
<DialogFooter>
title={
<span className="flex items-center gap-2">
<Sparkles className="size-4" aria-hidden="true" />
{t("medications.scheduling.naturalLanguage.title")}
</span>
}
description={t("medications.scheduling.naturalLanguage.description")}
footer={
<>
<Button
type="button"
variant="outline"
@@ -322,9 +243,73 @@ export function NaturalLanguageExtractor({
? t("medications.scheduling.naturalLanguage.submitBusy")
: t("medications.scheduling.naturalLanguage.submit")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
}
>
<div className="flex flex-col gap-3" aria-busy={busy || undefined}>
<div>
<Label htmlFor={textareaId} className="sr-only">
{t("medications.scheduling.naturalLanguage.title")}
</Label>
<Textarea
id={textareaId}
value={text}
onChange={(e) => {
setText(e.target.value);
if (error) setError(null);
}}
placeholder={t(
"medications.scheduling.naturalLanguage.placeholder",
)}
rows={4}
maxLength={MAX_TEXT_LENGTH + 200}
disabled={busy}
aria-invalid={overLimit || error?.kind === "empty" || undefined}
aria-describedby={errorMessage ? `${textareaId}-error` : undefined}
/>
<div className="text-muted-foreground mt-1 text-right text-xs">
{charCount} / {MAX_TEXT_LENGTH}
</div>
</div>
{errorMessage ? (
<p
id={`${textareaId}-error`}
role="alert"
className="text-destructive text-sm"
>
{errorMessage}
</p>
) : null}
<fieldset className="flex flex-col gap-2">
<legend className="text-muted-foreground text-xs font-medium">
{t("medications.scheduling.naturalLanguage.examples.label")}
</legend>
<div className="flex flex-col gap-2 sm:flex-row sm:flex-wrap">
<ExampleChip
onPick={insertExample}
label={t(
"medications.scheduling.naturalLanguage.examples.weekly",
)}
disabled={busy}
/>
<ExampleChip
onPick={insertExample}
label={t("medications.scheduling.naturalLanguage.examples.daily")}
disabled={busy}
/>
<ExampleChip
onPick={insertExample}
label={t(
"medications.scheduling.naturalLanguage.examples.rolling",
)}
disabled={busy}
/>
</div>
</fieldset>
</div>
</ResponsiveSheet>
);
}
@@ -35,7 +35,7 @@
* (pill packs count too).
*/
import { useState, type FormEvent } from "react";
import { useId, useState, type FormEvent } from "react";
import Link from "next/link";
import {
useQuery,
@@ -48,14 +48,7 @@ import { Loader2, PackageOpen, Plus } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { DeleteButton } from "@/components/data-list/delete-button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
import { DateField } from "@/components/ui/date-field";
import { EmptyState } from "@/components/ui/empty-state";
import { Input } from "@/components/ui/input";
@@ -500,6 +493,7 @@ export function AddInventoryDialog({
useState<ContainerType>(initialContainerType);
const [expiry, setExpiry] = useState("");
const [busy, setBusy] = useState(false);
const formId = useId();
const parsed = Number(quantity);
const effectiveMode = unitsPerDose > 1 ? quantityMode : "units";
@@ -531,152 +525,147 @@ export function AddInventoryDialog({
}
return (
<Dialog open onOpenChange={(open) => !open && onClose()}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>{t("medications.detail.bestand.addTitle")}</DialogTitle>
<DialogDescription>
{t("medications.detail.bestand.addDescription")}
</DialogDescription>
</DialogHeader>
<form onSubmit={submit} className="space-y-4">
<div className="space-y-2">
<label
htmlFor="inventory-add-container-type"
className="text-sm font-medium"
>
{t("medications.detail.bestand.containerTypeLabel")}
</label>
<Select
value={containerType}
onValueChange={(v) => setContainerType(v as ContainerType)}
>
<SelectTrigger
id="inventory-add-container-type"
className="w-full"
data-slot="inventory-container-type"
>
<SelectValue />
</SelectTrigger>
<SelectContent>
{CONTAINER_TYPES.map((ct) => (
<SelectItem key={ct} value={ct}>
{t(`medications.detail.bestand.containerType.${ct}`)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<label
htmlFor="inventory-add-quantity"
className="text-sm font-medium"
>
{t("medications.detail.bestand.addQuantityLabel")}
</label>
{unitsPerDose > 1 && (
<div
className="border-border/60 inline-flex rounded-md border p-0.5"
role="group"
aria-label={t("medications.detail.bestand.addQuantityLabel")}
data-slot="inventory-quantity-mode"
>
<Button
type="button"
size="sm"
variant={quantityMode === "doses" ? "secondary" : "ghost"}
aria-pressed={quantityMode === "doses"}
className="h-7 px-2 text-xs"
onClick={() => setQuantityMode("doses")}
>
{t("medications.detail.bestand.quantityModeDoses")}
</Button>
<Button
type="button"
size="sm"
variant={quantityMode === "units" ? "secondary" : "ghost"}
aria-pressed={quantityMode === "units"}
className="h-7 px-2 text-xs"
onClick={() => setQuantityMode("units")}
>
{t("medications.detail.bestand.quantityModeUnits")}
</Button>
</div>
<ResponsiveSheet
open
onOpenChange={(open) => !open && onClose()}
title={t("medications.detail.bestand.addTitle")}
description={t("medications.detail.bestand.addDescription")}
footer={
<>
<Button type="button" variant="outline" onClick={onClose}>
{t("common.cancel")}
</Button>
<Button
type="submit"
form={formId}
disabled={!quantityValid || busy}
aria-busy={busy || undefined}
>
{busy && (
<Loader2
aria-hidden="true"
className="h-4 w-4 animate-spin motion-reduce:animate-none"
/>
)}
<Input
id="inventory-add-quantity"
type="number"
inputMode="numeric"
min={1}
max={
effectiveMode === "doses"
? Math.floor(1000 / unitsPerDose)
: 1000
}
step={1}
required
autoComplete="off"
value={quantity}
onChange={(e) => setQuantity(e.target.value)}
aria-describedby="inventory-add-quantity-helper"
/>
{unitsPerDose > 1 && Number.isInteger(parsed) && parsed >= 1 && (
<p
className="text-muted-foreground text-xs"
data-slot="inventory-quantity-conversion"
{t("medications.detail.bestand.addSubmit")}
</Button>
</>
}
>
<form id={formId} onSubmit={submit} className="space-y-4">
<div className="space-y-2">
<label
htmlFor="inventory-add-container-type"
className="text-sm font-medium"
>
{t("medications.detail.bestand.containerTypeLabel")}
</label>
<Select
value={containerType}
onValueChange={(v) => setContainerType(v as ContainerType)}
>
<SelectTrigger
id="inventory-add-container-type"
className="w-full"
data-slot="inventory-container-type"
>
<SelectValue />
</SelectTrigger>
<SelectContent>
{CONTAINER_TYPES.map((ct) => (
<SelectItem key={ct} value={ct}>
{t(`medications.detail.bestand.containerType.${ct}`)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<label
htmlFor="inventory-add-quantity"
className="text-sm font-medium"
>
{t("medications.detail.bestand.addQuantityLabel")}
</label>
{unitsPerDose > 1 && (
<div
className="border-border/60 inline-flex rounded-md border p-0.5"
role="group"
aria-label={t("medications.detail.bestand.addQuantityLabel")}
data-slot="inventory-quantity-mode"
>
<Button
type="button"
size="sm"
variant={quantityMode === "doses" ? "secondary" : "ghost"}
aria-pressed={quantityMode === "doses"}
className="h-7 px-2 text-xs"
onClick={() => setQuantityMode("doses")}
>
{effectiveMode === "doses"
? t("medications.detail.bestand.quantityInUnits", { units })
: // A unit count below one dose must not read "≈ 0 doses" —
// it is simply less than one dose.
Math.floor(parsed / unitsPerDose) === 0
? t("medications.detail.bestand.quantityUnderOneDose")
: t("medications.detail.bestand.quantityInDoses", {
doses: Math.floor(parsed / unitsPerDose),
})}
</p>
)}
{t("medications.detail.bestand.quantityModeDoses")}
</Button>
<Button
type="button"
size="sm"
variant={quantityMode === "units" ? "secondary" : "ghost"}
aria-pressed={quantityMode === "units"}
className="h-7 px-2 text-xs"
onClick={() => setQuantityMode("units")}
>
{t("medications.detail.bestand.quantityModeUnits")}
</Button>
</div>
)}
<Input
id="inventory-add-quantity"
type="number"
inputMode="numeric"
min={1}
max={
effectiveMode === "doses" ? Math.floor(1000 / unitsPerDose) : 1000
}
step={1}
required
autoComplete="off"
value={quantity}
onChange={(e) => setQuantity(e.target.value)}
aria-describedby="inventory-add-quantity-helper"
/>
{unitsPerDose > 1 && Number.isInteger(parsed) && parsed >= 1 && (
<p
id="inventory-add-quantity-helper"
className="text-muted-foreground text-xs"
data-slot="inventory-quantity-conversion"
>
{t("medications.detail.bestand.addQuantityHelper")}
{effectiveMode === "doses"
? t("medications.detail.bestand.quantityInUnits", { units })
: // A unit count below one dose must not read "≈ 0 doses" —
// it is simply less than one dose.
Math.floor(parsed / unitsPerDose) === 0
? t("medications.detail.bestand.quantityUnderOneDose")
: t("medications.detail.bestand.quantityInDoses", {
doses: Math.floor(parsed / unitsPerDose),
})}
</p>
</div>
<div className="space-y-2">
<label
htmlFor="inventory-add-expiry"
className="text-sm font-medium"
>
{t("medications.detail.bestand.addExpiryLabel")}
</label>
<DateField
id="inventory-add-expiry"
value={expiry}
onChange={setExpiry}
/>
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={onClose}>
{t("common.cancel")}
</Button>
<Button
type="submit"
disabled={!quantityValid || busy}
aria-busy={busy || undefined}
>
{busy && (
<Loader2
aria-hidden="true"
className="h-4 w-4 animate-spin motion-reduce:animate-none"
/>
)}
{t("medications.detail.bestand.addSubmit")}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
)}
<p
id="inventory-add-quantity-helper"
className="text-muted-foreground text-xs"
>
{t("medications.detail.bestand.addQuantityHelper")}
</p>
</div>
<div className="space-y-2">
<label htmlFor="inventory-add-expiry" className="text-sm font-medium">
{t("medications.detail.bestand.addExpiryLabel")}
</label>
<DateField
id="inventory-add-expiry"
value={expiry}
onChange={setExpiry}
/>
</div>
</form>
</ResponsiveSheet>
);
}
@@ -703,6 +692,7 @@ function AdjustInventoryDialog({
item.unitsRemaining == null ? "" : String(item.unitsRemaining),
);
const [busy, setBusy] = useState(false);
const formId = useId();
const parsed = Number(value);
// v1.16.12 — fractional remaining allowed (a ½-tablet dose leaves 29.5).
@@ -733,68 +723,65 @@ function AdjustInventoryDialog({
}
return (
<Dialog open onOpenChange={(open) => !open && onClose()}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>
{t("medications.detail.bestand.adjustTitle")}
</DialogTitle>
<DialogDescription>
{t("medications.detail.bestand.adjustDescription")}
</DialogDescription>
</DialogHeader>
<form onSubmit={submit} className="space-y-4">
<div className="space-y-2">
<label
htmlFor="inventory-adjust-remaining"
className="text-sm font-medium"
>
{t("medications.detail.bestand.adjustQuantityLabel")}
</label>
<Input
id="inventory-adjust-remaining"
type="number"
inputMode="decimal"
min={0}
max={item.unitsTotal ?? undefined}
step="any"
required
autoComplete="off"
value={value}
onChange={(e) => setValue(e.target.value)}
aria-describedby="inventory-adjust-helper"
/>
<p
id="inventory-adjust-helper"
className="text-muted-foreground text-xs"
>
{t("medications.detail.bestand.adjustHelper", {
total:
item.unitsTotal ?? t("medications.detail.bestand.unknown"),
})}
</p>
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={onClose}>
{t("common.cancel")}
</Button>
<Button
type="submit"
disabled={!valid || busy}
aria-busy={busy || undefined}
>
{busy && (
<Loader2
aria-hidden="true"
className="h-4 w-4 animate-spin motion-reduce:animate-none"
/>
)}
{t("medications.detail.bestand.adjustSubmit")}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
<ResponsiveSheet
open
onOpenChange={(open) => !open && onClose()}
title={t("medications.detail.bestand.adjustTitle")}
description={t("medications.detail.bestand.adjustDescription")}
footer={
<>
<Button type="button" variant="outline" onClick={onClose}>
{t("common.cancel")}
</Button>
<Button
type="submit"
form={formId}
disabled={!valid || busy}
aria-busy={busy || undefined}
>
{busy && (
<Loader2
aria-hidden="true"
className="h-4 w-4 animate-spin motion-reduce:animate-none"
/>
)}
{t("medications.detail.bestand.adjustSubmit")}
</Button>
</>
}
>
<form id={formId} onSubmit={submit} className="space-y-4">
<div className="space-y-2">
<label
htmlFor="inventory-adjust-remaining"
className="text-sm font-medium"
>
{t("medications.detail.bestand.adjustQuantityLabel")}
</label>
<Input
id="inventory-adjust-remaining"
type="number"
inputMode="decimal"
min={0}
max={item.unitsTotal ?? undefined}
step="any"
required
autoComplete="off"
value={value}
onChange={(e) => setValue(e.target.value)}
aria-describedby="inventory-adjust-helper"
/>
<p
id="inventory-adjust-helper"
className="text-muted-foreground text-xs"
>
{t("medications.detail.bestand.adjustHelper", {
total: item.unitsTotal ?? t("medications.detail.bestand.unknown"),
})}
</p>
</div>
</form>
</ResponsiveSheet>
);
}
@@ -823,6 +810,7 @@ function PackagingDialog({
dosesPerUnit === null ? "" : String(dosesPerUnit),
);
const [busy, setBusy] = useState(false);
const formId = useId();
const parsedPerDose = Number(perDoseValue);
const perDoseValid =
@@ -864,94 +852,92 @@ function PackagingDialog({
}
return (
<Dialog open onOpenChange={(open) => !open && onClose()}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>
{t("medications.detail.bestand.packagingTitle")}
</DialogTitle>
<DialogDescription>
{t("medications.detail.bestand.packagingDescription")}
</DialogDescription>
</DialogHeader>
<form onSubmit={submit} className="space-y-4">
<div className="space-y-2">
<label
htmlFor="packaging-units-per-dose"
className="text-sm font-medium"
>
{t("medications.detail.bestand.packagingUnitsPerDoseLabel")}
</label>
<Input
id="packaging-units-per-dose"
type="number"
inputMode="numeric"
min={1}
max={100}
step={1}
required
autoComplete="off"
value={perDoseValue}
onChange={(e) => setPerDoseValue(e.target.value)}
aria-describedby="packaging-units-per-dose-helper"
/>
<p
id="packaging-units-per-dose-helper"
className="text-muted-foreground text-xs"
>
{t("medications.detail.bestand.packagingUnitsPerDoseHelper")}
</p>
</div>
<div className="space-y-2">
<label
htmlFor="packaging-default-pack"
className="text-sm font-medium"
>
{t("medications.detail.bestand.packagingDefaultPackLabel")}
</label>
<Input
id="packaging-default-pack"
type="number"
inputMode="numeric"
min={1}
max={1000}
step={1}
autoComplete="off"
value={packValue}
onChange={(e) => setPackValue(e.target.value)}
aria-describedby="packaging-default-pack-helper"
/>
<p
id="packaging-default-pack-helper"
className="text-muted-foreground text-xs"
>
{t("medications.detail.bestand.packagingDefaultPackHelper")}
</p>
</div>
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={onClose}
disabled={busy}
>
{t("common.cancel")}
</Button>
<Button
type="submit"
disabled={!perDoseValid || !packValid || busy}
>
{busy && (
<Loader2
aria-hidden="true"
className="h-4 w-4 animate-spin motion-reduce:animate-none"
/>
)}
{t("common.save")}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
<ResponsiveSheet
open
onOpenChange={(open) => !open && onClose()}
title={t("medications.detail.bestand.packagingTitle")}
description={t("medications.detail.bestand.packagingDescription")}
footer={
<>
<Button
type="button"
variant="outline"
onClick={onClose}
disabled={busy}
>
{t("common.cancel")}
</Button>
<Button
type="submit"
form={formId}
disabled={!perDoseValid || !packValid || busy}
>
{busy && (
<Loader2
aria-hidden="true"
className="h-4 w-4 animate-spin motion-reduce:animate-none"
/>
)}
{t("common.save")}
</Button>
</>
}
>
<form id={formId} onSubmit={submit} className="space-y-4">
<div className="space-y-2">
<label
htmlFor="packaging-units-per-dose"
className="text-sm font-medium"
>
{t("medications.detail.bestand.packagingUnitsPerDoseLabel")}
</label>
<Input
id="packaging-units-per-dose"
type="number"
inputMode="numeric"
min={1}
max={100}
step={1}
required
autoComplete="off"
value={perDoseValue}
onChange={(e) => setPerDoseValue(e.target.value)}
aria-describedby="packaging-units-per-dose-helper"
/>
<p
id="packaging-units-per-dose-helper"
className="text-muted-foreground text-xs"
>
{t("medications.detail.bestand.packagingUnitsPerDoseHelper")}
</p>
</div>
<div className="space-y-2">
<label
htmlFor="packaging-default-pack"
className="text-sm font-medium"
>
{t("medications.detail.bestand.packagingDefaultPackLabel")}
</label>
<Input
id="packaging-default-pack"
type="number"
inputMode="numeric"
min={1}
max={1000}
step={1}
autoComplete="off"
value={packValue}
onChange={(e) => setPackValue(e.target.value)}
aria-describedby="packaging-default-pack-helper"
/>
<p
id="packaging-default-pack-helper"
className="text-muted-foreground text-xs"
>
{t("medications.detail.bestand.packagingDefaultPackHelper")}
</p>
</div>
</form>
</ResponsiveSheet>
);
}
+1 -1
View File
@@ -100,7 +100,7 @@ export function DoneScreen() {
<section
aria-labelledby="onboarding-ai-panel-title"
data-slot="onboarding-ai-panel"
className="border-border bg-card mx-auto flex w-full max-w-md flex-col gap-4 rounded-xl border p-5 text-left"
className="border-border bg-card mx-auto flex w-full max-w-md flex-col gap-4 rounded-xl border p-4 text-left md:p-6"
>
<header className="flex items-start gap-3">
<span
@@ -5,13 +5,7 @@ import { useRouter } from "next/navigation";
import { Loader2, Save, Shield, User } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
import { Input } from "@/components/ui/input";
import { DateField } from "@/components/ui/date-field";
import { Label } from "@/components/ui/label";
@@ -506,7 +500,7 @@ export function AccountSection() {
Erweitert. It is a maintenance / reset action, not a profile or
security control, so it sits beside Research Mode + the danger zone. */}
<Dialog
<ResponsiveSheet
open={passwordDialogOpen}
onOpenChange={(open) => {
setPasswordDialogOpen(open);
@@ -518,81 +512,73 @@ export function AccountSection() {
setPasswordMsgType(null);
}
}}
title={t("settings.passwordReset")}
description={t("settings.changePasswordDescription")}
className="sm:max-w-xl"
>
<DialogContent className="sm:max-w-xl">
<DialogHeader>
<DialogTitle>{t("settings.passwordReset")}</DialogTitle>
<DialogDescription>
{t("settings.changePasswordDescription")}
</DialogDescription>
</DialogHeader>
<form onSubmit={handleChangePassword} className="space-y-4">
<div className="grid gap-3 lg:grid-cols-3">
<div className="space-y-2">
<Label htmlFor="current-password">
{t("settings.currentPassword")}
</Label>
<PasswordInput
id="current-password"
autoComplete="current-password"
value={currentPassword}
onChange={(e) => setCurrentPassword(e.target.value)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="new-password">
{t("settings.newPassword")}
</Label>
<PasswordInput
id="new-password"
autoComplete="new-password"
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="confirm-password">
{t("settings.confirmNewPassword")}
</Label>
<PasswordInput
id="confirm-password"
autoComplete="new-password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
/>
</div>
<form onSubmit={handleChangePassword} className="space-y-4">
<div className="grid gap-3 lg:grid-cols-3">
<div className="space-y-2">
<Label htmlFor="current-password">
{t("settings.currentPassword")}
</Label>
<PasswordInput
id="current-password"
autoComplete="current-password"
value={currentPassword}
onChange={(e) => setCurrentPassword(e.target.value)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="new-password">{t("settings.newPassword")}</Label>
<PasswordInput
id="new-password"
autoComplete="new-password"
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="confirm-password">
{t("settings.confirmNewPassword")}
</Label>
<PasswordInput
id="confirm-password"
autoComplete="new-password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
/>
</div>
</div>
{passwordMsg && (
<p
role="alert"
className={`text-sm ${
passwordMsgType === "success"
? "text-success"
: "text-destructive"
}`}
>
{statusText(passwordMsg, t)}
</p>
)}
<Button
type="submit"
variant="outline"
className="min-h-11 sm:min-h-9"
disabled={passwordSaving}
{passwordMsg && (
<p
role="alert"
className={`text-sm ${
passwordMsgType === "success"
? "text-success"
: "text-destructive"
}`}
>
{passwordSaving ? (
<Loader2 className="h-4 w-4 animate-spin motion-reduce:animate-none" />
) : (
<Save className="h-4 w-4" />
)}
{t("settings.changePassword")}
</Button>
</form>
</DialogContent>
</Dialog>
{statusText(passwordMsg, t)}
</p>
)}
<Button
type="submit"
variant="outline"
className="min-h-11 sm:min-h-9"
disabled={passwordSaving}
>
{passwordSaving ? (
<Loader2 className="h-4 w-4 animate-spin motion-reduce:animate-none" />
) : (
<Save className="h-4 w-4" />
)}
{t("settings.changePassword")}
</Button>
</form>
</ResponsiveSheet>
</div>
);
}
@@ -0,0 +1,30 @@
import { describe, expect, it } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { SheetHeader } from "../sheet";
/**
* The raw `SheetHeader` reserves a trailing gutter for the sheet's absolute
* close-X the way `DialogHeader` (pr-9) does — sized to the sheet close
* button (`right-4`, min-w-9…11) so a long raw-`<Sheet>` title never slides
* under the X. Latent hardening (every current consumer passes its own
* header/close), but it forecloses the Documents-class overlap at the
* primitive. Consumers that render no close-X drop the reserve via className,
* and tailwind-merge keeps the later value.
*/
describe("<SheetHeader>", () => {
it("reserves the trailing close-X gutter (pr-12) by default", () => {
const html = renderToStaticMarkup(<SheetHeader>Title</SheetHeader>);
expect(html).toContain('data-slot="sheet-header"');
expect(html).toContain("pr-12");
});
it("lets a consumer className override the reserve", () => {
const html = renderToStaticMarkup(
<SheetHeader className="p-3">Title</SheetHeader>,
);
// A later padding utility from the consumer wins under tailwind-merge.
expect(html).toContain("p-3");
expect(html).not.toMatch(/class="[^"]*\bpr-12\b/);
});
});
+8 -1
View File
@@ -102,7 +102,14 @@ function SheetHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sheet-header"
className={cn("flex flex-col gap-1.5 p-4", className)}
// Reserve the trailing gutter for the absolute close-X the way
// `DialogHeader` (pr-9) does, sized to the sheet's own close button
// (`top-3 right-4`, min-w-9…11) so a long raw-`<Sheet>` title never
// slides under the X — the Documents close-X overlap class, foreclosed
// at the primitive. Consumers that render `showCloseButton={false}`
// (or their own close affordance) drop it back with `pr-4` via
// `className` — tailwind-merge keeps the later value.
className={cn("flex flex-col gap-1.5 p-4 pr-12", className)}
{...props}
/>
);