Merge pull request #500 from MBombeck/release/v1.28.43

release/v1.28.43 — visual consistency polish
This commit is contained in:
Marc-André Bombeck authored and GitHub committed 2026-07-16 03:58:08 +02:00
commit bed2e1333e
25 files changed
+717 -544

No files matched your search

+15
View File
@@ -2,6 +2,21 @@
## [Unreleased]
## [1.28.43] — 2026-07-16 — Visual consistency polish
A pass over small visual inconsistencies the design audit surfaced:
- The insights header now uses the standard heading weight and spacing scale.
- Two dashboard tiles that hand-rolled a dimmer header now use the same tile
header as their neighbours, so the cards read as one family.
- A few tiles sat a step denser than their siblings; they now match.
- Two header icons that used the accent colour are back to the standard.
- Several edit dialogs on phone-reachable surfaces now open as the same bottom
sheet the rest of the app uses.
A new lint rule keeps card spacing on the standard scale so this drift can't
quietly return.
## [1.28.42] — 2026-07-16 — Snappier lists, correct mood refresh, safer first sign-up
Performance and correctness fixes:
+1 -1
View File
@@ -95,7 +95,7 @@ The architecture map in `.planning/codebase/arch.md` walks each layer with file:
- **`annotate()` on every interesting code path.** Wide-event observability. Action names follow `<surface>.<noun>.<verb>` (`coach.budget.exceeded`, `measurement.batch.ingest`). Don't free-text in `meta` — pin the shape so dashboards stay stable.
- **i18n keys exist for every `t()` call.** `i18n-call-site-coverage.test.ts` walks every `.ts(x)` under `src/`, extracts every `t("ns.key")` literal, and asserts each key resolves in `messages/en.json`. `i18n-locale-integrity.test.ts` propagates the EN guarantee across `de / en / es / fr / it / pl`. If a guard fails, fix the bundle, don't suppress the test.
- **OpenAPI registry stays in sync.** Zod schemas carry `.meta()` annotations under `src/lib/openapi/`; `pnpm openapi:generate` emits `docs/api/openapi.yaml`. CI fails on drift. Re-run the generator after touching a request / response schema and commit the YAML alongside the Zod change.
- **File naming.** Components and lib files: kebab-case (`daily-briefing.tsx`, `secure-cookie.ts`). Hooks: `use-` prefix kebab-case (`use-coach-prefs.ts`). React component exports stay PascalCase regardless of filename. The 18 PascalCase outliers under `src/components/medications/` + `src/components/onboarding/` are pre-existing drift and worth cleaning up when the surrounding files come up for edit.
- **File naming.** Components and lib files: kebab-case (`daily-briefing.tsx`, `secure-cookie.ts`). Hooks: `use-` prefix kebab-case (`use-coach-prefs.ts`). React component exports stay PascalCase regardless of filename.
## Tests and commands
+1 -1
View File
@@ -1,7 +1,7 @@
openapi: 3.1.0
info:
title: HealthLog API
version: 1.28.42
version: 1.28.43
description: >-
Self-hosted personal-health-tracking PWA — public API surface for the iOS native client and external ingest.
@@ -60,6 +60,32 @@ ruleTester.run("spacing-scale", rule, {
code: '<CardHeader className="pb-2" />',
filename: "/repo/src/components/__tests__/example.test.tsx",
},
// The card-shell `5` check is scoped to the bg-card+border pair, so the
// justified list-marker inset never trips it (no shell).
{
code: '<ul className="list-disc pl-5 space-y-1" />',
filename: APP_FILE,
},
// A directional inset ON a shell is out of scope (pl-5/pr-5 not banned).
{
code: '<div className="bg-card border-border rounded-xl border pl-5" />',
filename: APP_FILE,
},
// Form-body rhythm without a shell surface is fine.
{
code: '<form className="space-y-5" />',
filename: APP_FILE,
},
// A shell already on-scale is fine.
{
code: '<div className="bg-card border-border rounded-xl border p-4 md:p-6" />',
filename: APP_FILE,
},
// bg-card without a border is not treated as a shell.
{
code: '<div className="bg-card p-5" />',
filename: APP_FILE,
},
],
invalid: [
{
@@ -108,5 +134,34 @@ ruleTester.run("spacing-scale", rule, {
filename: APP_FILE,
errors: [{ messageId: "slotPadding" }],
},
// Card-shell `5` step: bg-card + border + p-5 on one element.
{
code: '<div className="bg-card border-border rounded-xl border p-5" />',
filename: APP_FILE,
errors: [{ messageId: "cardShellStep5" }],
},
// …py-5, …space-y-5, …gap-5 on a shell all trip it.
{
code: '<section className="bg-card rounded-lg border py-5" />',
filename: APP_FILE,
errors: [{ messageId: "cardShellStep5" }],
},
{
code: '<div className="bg-card border-border rounded-xl border gap-5 flex" />',
filename: APP_FILE,
errors: [{ messageId: "cardShellStep5" }],
},
// Split across cn() args — the join still sees the shell + the step-5.
{
code: '<div className={cn("bg-card border rounded-xl", "p-5")} />',
filename: APP_FILE,
errors: [{ messageId: "cardShellStep5" }],
},
// Modifier-prefixed step on a shell.
{
code: '<div className="bg-card border-border rounded-xl border sm:p-5" />',
filename: APP_FILE,
errors: [{ messageId: "cardShellStep5" }],
},
],
});
+2 -1
View File
@@ -6,7 +6,8 @@
* - `safe-fetch-required` — outbound fetch must route through safeFetch.
* - `api-fetch-required` — client /api/ calls must route through apiFetch.
* - `no-raw-palette-color` — ban raw Tailwind palette utilities in app UI.
* - `spacing-scale` — no pt-/pb- overrides on gap-based Card slots.
* - `spacing-scale` — no pt-/pb- overrides on gap-based Card slots,
* and no off-scale `5` step on a bg-card shell.
*/
"use strict";
+77 -32
View File
@@ -1,34 +1,47 @@
/**
* @fileoverview ESLint rule — no `pt-*` / `pb-*` overrides on the
* gap-based Card slots.
* @fileoverview ESLint rule — spacing-scale discipline. Two checks:
*
* The Card primitive is gap-based, not padding-based
* (`src/components/ui/card.tsx`): the header→body distance comes from
* the PARENT flex gap, never from slot padding. A `pb-*` on
* `<CardHeader>` or a `pt-*` on `<CardContent>` therefore adds on top
* of the gap (or is a no-op) and inverts its own intent — the drift
* class the sl-001 sweep cleaned (~35 stale overrides). Density is
* expressed on the Card itself (`<Card className="gap-2 py-3 md:py-4">`,
* the sanctioned compact tier), never on a slot.
* 1. `slotPadding` — no `pt-*` / `pb-*` overrides on the gap-based Card
* slots. The Card primitive is gap-based, not padding-based
* (`src/components/ui/card.tsx`): the header→body distance comes from
* the PARENT flex gap, never from slot padding. A `pb-*` on
* `<CardHeader>` or a `pt-*` on `<CardContent>` therefore adds on top
* of the gap (or is a no-op) and inverts its own intent — the drift
* class the sl-001 sweep cleaned (~35 stale overrides). Density is
* expressed on the Card itself (`<Card className="gap-2 py-3 md:py-4">`,
* the sanctioned compact tier), never on a slot.
*
* Detection: JSX elements named `CardHeader` / `CardContent` (any
* member-expression tail, so `UI.CardHeader` matches too) whose
* `className` attribute carries a static `pt-<n>` / `pb-<n>` fragment —
* including modifier-prefixed (`md:pb-2`) and arbitrary-value
* (`pb-[10px]`) forms. Strings are collected from the whole attribute
* subtree, so `cn("pb-2", …)`, ternaries, and template literals are all
* seen. Non-numeric suffixes (`pb-safe`) never match.
* Detection: JSX elements named `CardHeader` / `CardContent` (any
* member-expression tail, so `UI.CardHeader` matches too) whose
* `className` carries a static `pt-<n>` / `pb-<n>` fragment — including
* modifier-prefixed (`md:pb-2`) and arbitrary-value (`pb-[10px]`) forms.
* Non-numeric suffixes (`pb-safe`) never match.
*
* 2. `cardShellStep5` — no off-scale `5` step (20 px, banned by §2) on a
* hand-rolled card SHELL. The shell heuristic (§7.2) keys off the
* `bg-card` + `border` pair on ONE element: a `p-5` / `px-5` / `py-5` /
* `space-y-5` / `gap-5` there sits denser than every sibling `<Card>`
* (which is `p-4 md:p-6`), and the 4 px step reads next to a conformant
* card on the same viewport. Deliberately NARROW: it fires only when the
* same element paints a card surface, so justified `pl-5`/`pl-7`
* list-marker insets (no `bg-card`) and form-body `space-y-5` rhythm (no
* shell) never trip it. Hand-rolled shells sweep to `p-4 md:p-6`
* (or `p-3` for a dense inner tile); new surfaces compose `<Card>`.
*
* Both checks collect strings from the whole `className` subtree, so
* `cn("…", cond && "…")`, ternaries, and template literals are all seen.
*
* Scope: `src/components/**` + `src/app/**`; test files exempt — same
* gating as the sibling rules. `src/components/ui/card.tsx` itself
* composes raw `<div>`s and is untouched by construction.
* gating as the sibling rules. `src/components/ui/*` primitives compose the
* shells themselves and are untouched by construction (they carry no
* `bg-card` + `border` + step-5 combination).
*
* There is deliberately NO allowlist: no current site has a documented
* load-bearing reason for a slot padding override. If one ever appears,
* it carries an inline `eslint-disable-next-line` with the reason —
* visible in review — rather than a silent registry entry here.
* load-bearing reason. If one ever appears it carries an inline
* `eslint-disable-next-line` with the reason — visible in review — rather
* than a silent registry entry here.
*
* @see .planning/audits/2026-07-05-fable/UI-STANDARDS.md §1 + §2.
* @see .planning/audits/2026-07-05-fable/UI-STANDARDS.md §1 + §2 + §7.
*/
"use strict";
@@ -44,6 +57,19 @@ const SLOT_PADDING_RE =
const SLOT_NAMES = new Set(["CardHeader", "CardContent"]);
// Card-shell heuristic: the element paints a card surface (`bg-card`) AND a
// border. `\bborder\b` matches `border`, `border-border`, `border-b`,
// `border-dashed` — any border utility qualifies as a shell.
const SHELL_BG_RE = /\bbg-card\b/;
const SHELL_BORDER_RE = /\bborder(?:-|\b)/;
// The banned `5` step (20 px) on a shell — the subset §7.2 scopes to an
// error-clean rule: p-5 / px-5 / py-5 / space-y-5 / gap-5, with optional
// modifier prefixes (`sm:p-5`). Directional insets (`pl-5`/`pr-5`) and
// half-steps are intentionally OUT of scope (see the header).
const SHELL_STEP5_RE =
/(?:^|\s)(?:[^\s"']*:)*((?:p|px|py|space-y|gap)-5)(?=\s|$)/;
function toPosix(filename) {
return filename.replace(/\\/g, "/");
}
@@ -122,12 +148,14 @@ const spacingScaleRule = {
type: "problem",
docs: {
description:
"Ban pt-*/pb-* overrides on CardHeader/CardContent — the Card is gap-based; density is expressed via the Card's own gap/py, never via slot padding.",
"Spacing-scale discipline — no pt-*/pb-* overrides on gap-based Card slots, and no off-scale `5` step on a bg-card+border shell.",
},
schema: [],
messages: {
slotPadding:
'"{{match}}" on <{{element}}> fights the gap-based Card contract — the header→body distance comes from the parent flex gap, so slot padding stacks on top of it (or is a no-op). Tune density on the Card itself instead: <Card className="gap-2 py-3 md:py-4">.',
cardShellStep5:
'Off-scale "{{match}}" (20 px) on a bg-card+border shell — the `5` step is banned (UI-STANDARDS §2) and reads denser than every sibling <Card> (p-4 md:p-6). Sweep the shell to `p-4 md:p-6` (or `p-3` for a dense inner tile); new surfaces should compose <Card>.',
},
},
create(context) {
@@ -138,9 +166,6 @@ const spacingScaleRule = {
return {
JSXOpeningElement(node) {
const name = elementName(node);
if (!name || !SLOT_NAMES.has(name)) return;
const classNameAttr = node.attributes.find(
(attr) =>
attr.type === "JSXAttribute" &&
@@ -151,13 +176,33 @@ const spacingScaleRule = {
const strings = [];
collectStrings(classNameAttr.value, strings);
for (const { node: strNode, text } of strings) {
const m = SLOT_PADDING_RE.exec(text);
// Check 1 — pt-/pb- slot padding on the gap-based Card slots.
const name = elementName(node);
if (name && SLOT_NAMES.has(name)) {
for (const { node: strNode, text } of strings) {
const m = SLOT_PADDING_RE.exec(text);
if (m) {
context.report({
node: strNode,
messageId: "slotPadding",
data: { match: m[1], element: name },
});
}
}
}
// Check 2 — off-scale `5` step on a card shell. Join every collected
// string so the `bg-card` + `border` pair and the step-5 utility are
// seen together even when split across cn() args, then report once.
const joined = strings.map((s) => s.text).join(" ");
if (SHELL_BG_RE.test(joined) && SHELL_BORDER_RE.test(joined)) {
const m = SHELL_STEP5_RE.exec(joined);
if (m) {
context.report({
node: strNode,
messageId: "slotPadding",
data: { match: m[1], element: name },
node: classNameAttr,
messageId: "cardShellStep5",
data: { match: m[1] },
});
}
}
+8 -3
View File
@@ -66,9 +66,14 @@ const eslintConfig = defineConfig([
// token references, not utilities, and stay legal — the light-theme
// overrides in globals.css keep them AA on both themes.
"healthlog/no-dracula-utility": ["error", { checks: ["dracula"] }],
// The Card primitive is gap-based; pt-/pb- on CardHeader/CardContent
// fights the gap and re-opens the sl-001 drift class. Density lives
// on the Card itself (`gap-2 py-3 md:py-4`). See the rule header.
// Spacing-scale discipline. Two error-clean checks: pt-/pb- on
// CardHeader/CardContent fights the gap-based Card contract (re-opens
// the sl-001 drift class), and an off-scale `5` step (p-5/py-5/space-y-5/
// gap-5) on a bg-card+border shell reads denser than every sibling
// <Card>. The shell check is scoped to the bg-card+border pair so
// justified list-marker insets (pl-5/pl-7) and form-body rhythm never
// trip it. Density lives on the Card itself (`gap-2 py-3 md:py-4`) or on
// a swept `p-4 md:p-6` shell. See the rule header.
"healthlog/spacing-scale": "error",
},
},
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "healthlog",
"version": "1.28.42",
"version": "1.28.43",
"description": "Self-hosted personal-health-tracking PWA with Withings integration, AI insights, and doctor-report PDF export.",
"license": "PolyForm-Noncommercial-1.0.0",
"homepage": "https://healthlog.dev",
+1 -1
View File
@@ -36,7 +36,7 @@ try {
// v1.4.38.4 → v1.4.42. Do not hand-edit; bump `package.json` and rebuild.
const CACHE_VERSION =
(typeof self !== "undefined" && self.__APP_VERSION__) ||
/* @sw-version-fallback */ "v1.28.42";
/* @sw-version-fallback */ "v1.28.43";
const STATIC_CACHE = `healthlog-static-${CACHE_VERSION}`;
const PAGE_CACHE = `healthlog-pages-${CACHE_VERSION}`;
// v1.18.6 — read-only data cache for a curated allowlist of safe GET `/api/*`
+3 -3
View File
@@ -334,7 +334,7 @@ export default function AchievementsPage() {
/>
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
<div className="bg-card border-border flex min-h-34 flex-col justify-between rounded-xl border p-5">
<div className="bg-card border-border flex min-h-34 flex-col justify-between rounded-xl border p-4 md:p-6">
<p className="text-muted-foreground text-sm">
{t("achievements.points")}
</p>
@@ -354,7 +354,7 @@ export default function AchievementsPage() {
aria-label={t("achievements.points")}
/>
</div>
<div className="bg-card border-border flex min-h-34 flex-col justify-between rounded-xl border p-5">
<div className="bg-card border-border flex min-h-34 flex-col justify-between rounded-xl border p-4 md:p-6">
<p className="text-muted-foreground text-sm">
{t("achievements.unlocked")}
</p>
@@ -368,7 +368,7 @@ export default function AchievementsPage() {
{t("achievements.remainingUnlocks", { count: remainingUnlocks })}
</p>
</div>
<div className="bg-card border-border min-h-34 rounded-xl border p-5 sm:col-span-2 lg:col-span-1">
<div className="bg-card border-border min-h-34 rounded-xl border p-4 sm:col-span-2 md:p-6 lg:col-span-1">
<p className="text-muted-foreground text-sm">
{t("achievements.nextGoal")}
</p>
+4 -1
View File
@@ -31,7 +31,10 @@ export default function EnrollMfaPage() {
</div>
<div className="space-y-2">
<div className="text-foreground inline-flex items-center gap-2 text-lg font-semibold">
<ShieldAlert className="text-primary h-5 w-5" aria-hidden="true" />
<ShieldAlert
className="text-foreground h-5 w-5"
aria-hidden="true"
/>
{t("auth.enrollMfa.title")}
</div>
<p className="text-muted-foreground text-sm">
@@ -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}
/>
);