diff --git a/CHANGELOG.md b/CHANGELOG.md
index 0cbae4fd3..1e1cc9423 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,6 +5,8 @@
### Changed
- Copy across several screens reads tighter. The insight-page metric explainers now open on a one-line definition rather than a paragraph of general guidance, with the longer background staying behind the knowledge disclosure where it already lived; the measurements subtitle and the daily-briefing setup prompt each drop a redundant sentence, and the mood self-rating card takes a short title so it stops wrapping and stops reusing a word from the assessment beside it. Wording that had drifted between neighbouring screens is settled too, so the measurement delete prompts, the practitioner label on the visit and vaccination forms, and the visit-saved toasts all match the screens they ship with.
+- The settings for a managed profile now use the same controls as the rest of the app. The forms for a delegated profile carry the shared date picker, dropdowns, text fields and switches instead of a plainer set of their own, so the birth date reads and saves in your own date order rather than depending on the browser, and each form keeps its save button at the end with any error shown just above it.
+- Spacing and tap targets read more evenly across settings, the dashboard and the charts. A handful of off-scale gaps are back on the spacing scale, the document upload buttons meet the mobile tap size their neighbours already set, the revoke on a share link is a right-aligned row action rather than a full-width red bar on every row, and a few empty and loading states now use the shared components so they match their siblings.
## [1.37.4] — 2026-08-09
diff --git a/eslint-plugins/healthlog/__tests__/spacing-scale.test.mjs b/eslint-plugins/healthlog/__tests__/spacing-scale.test.mjs
index 5c242ecbe..405f9fc27 100644
--- a/eslint-plugins/healthlog/__tests__/spacing-scale.test.mjs
+++ b/eslint-plugins/healthlog/__tests__/spacing-scale.test.mjs
@@ -60,20 +60,36 @@ ruleTester.run("spacing-scale", rule, {
code: '',
filename: "/repo/src/components/__tests__/example.test.tsx",
},
- // The card-shell `5` check is scoped to the bg-card+border pair, so the
+ // The card-shell check is scoped to the bg-card+border pair, so the
// justified list-marker inset never trips it (no shell).
{
code: '
',
filename: APP_FILE,
},
- // A directional inset ON a shell is out of scope (pl-5/pr-5 not banned).
+ // Off-scale spacing without a shell surface is fine — a form-body
+ // `space-y-5` rhythm and a half-step gap outside a shell both pass.
{
- code: '',
+ code: '',
filename: APP_FILE,
},
- // Form-body rhythm without a shell surface is fine.
{
- code: '',
+ code: '',
+ filename: APP_FILE,
+ },
+ // The sanctioned `1.5` half-step is on-scale, even on a shell.
+ {
+ code: '',
+ filename: APP_FILE,
+ },
+ // A deliberate negative margin is never matched (boundary anchored).
+ {
+ code: '',
+ filename: APP_FILE,
+ },
+ // Height is a dimension, not a scale step — `h-15` on a shell is out of
+ // this rule's scope by design.
+ {
+ code: '',
filename: APP_FILE,
},
// A shell already on-scale is fine.
@@ -138,30 +154,64 @@ ruleTester.run("spacing-scale", rule, {
{
code: '',
filename: APP_FILE,
- errors: [{ messageId: "cardShellStep5" }],
+ errors: [{ messageId: "cardShellOffScale" }],
},
// …py-5, …space-y-5, …gap-5 on a shell all trip it.
{
code: '',
filename: APP_FILE,
- errors: [{ messageId: "cardShellStep5" }],
+ errors: [{ messageId: "cardShellOffScale" }],
},
{
code: '',
filename: APP_FILE,
- errors: [{ messageId: "cardShellStep5" }],
+ errors: [{ messageId: "cardShellOffScale" }],
+ },
+ // The `7` step is banned with the `5` step.
+ {
+ code: '',
+ filename: APP_FILE,
+ errors: [{ messageId: "cardShellOffScale" }],
+ },
+ // Half-steps other than 1.5 on a shell: gap-3.5, space-y-3.5, py-2.5.
+ {
+ code: '',
+ filename: APP_FILE,
+ errors: [{ messageId: "cardShellOffScale" }],
+ },
+ {
+ code: '',
+ filename: APP_FILE,
+ errors: [{ messageId: "cardShellOffScale" }],
+ },
+ {
+ code: '',
+ filename: APP_FILE,
+ errors: [{ messageId: "cardShellOffScale" }],
+ },
+ // The directional `pl-`/`ml-` spellings that slipped the `5`-only check
+ // now trip it on a shell.
+ {
+ code: '',
+ filename: APP_FILE,
+ errors: [{ messageId: "cardShellOffScale" }],
+ },
+ {
+ code: '',
+ filename: APP_FILE,
+ errors: [{ messageId: "cardShellOffScale" }],
},
- // Split across cn() args — the join still sees the shell + the step-5.
+ // Split across cn() args — the join still sees the shell + the step.
{
code: '',
filename: APP_FILE,
- errors: [{ messageId: "cardShellStep5" }],
+ errors: [{ messageId: "cardShellOffScale" }],
},
// Modifier-prefixed step on a shell.
{
code: '',
filename: APP_FILE,
- errors: [{ messageId: "cardShellStep5" }],
+ errors: [{ messageId: "cardShellOffScale" }],
},
],
});
diff --git a/eslint-plugins/healthlog/spacing-scale.js b/eslint-plugins/healthlog/spacing-scale.js
index 8fa8bcad4..6c5e032fa 100644
--- a/eslint-plugins/healthlog/spacing-scale.js
+++ b/eslint-plugins/healthlog/spacing-scale.js
@@ -17,16 +17,25 @@
* modifier-prefixed (`md:pb-2`) and arbitrary-value (`pb-[10px]`) forms.
* Non-numeric suffixes (`pb-safe`) never match.
*
- * 2. `cardShellStep5` — no off-scale `5` step (20 px, banned by §2) on a
- * hand-rolled card SHELL. The shell heuristic (§7.2) keys off the
- * `bg-card` + `border` pair on ONE element: a `p-5` / `px-5` / `py-5` /
- * `space-y-5` / `gap-5` there sits denser than every sibling ``
- * (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 ``.
+ * 2. `cardShellOffScale` — no off-scale spacing step on a hand-rolled card
+ * SHELL. The scale (§2) is `1 / 1.5 / 2 / 3 / 4 / 6 / 8 / 10`; the `5`
+ * (20 px) and `7` (28 px) steps and every half-step other than `1.5`
+ * (`2.5`, `3.5`, …) are banned. The shell heuristic (§7.2) keys off the
+ * `bg-card` + `border` pair on ONE element: a `p-5` / `py-2.5` /
+ * `space-y-3.5` / `gap-3.5` / `ml-5` there sits off the scale next to
+ * every sibling `` (which is `p-4 md:p-6`), and reads on the same
+ * viewport. The prefix set is the full spacing family — padding
+ * (`p/px/py/pt/pb/pl/pr`), margin (`m/mx/my/mt/mb/ml/mr`), gap
+ * (`gap/gap-x/gap-y`), and `space-x/space-y` — so the `pl-`/`ml-`/`gap-`
+ * spellings that slipped the earlier `5`-only check are covered.
+ * Deliberately NARROW: it fires only when the same element paints a card
+ * surface, so a justified `pl-5` list-marker inset (no `bg-card`), a
+ * form-body `space-y-5` rhythm (no shell), and a deliberate negative
+ * margin (`-mt-5`, never matched) never trip it. Height (`h-`) is a
+ * dimension, not a scale step (`h-11` tap floor, `h-14`, `h-64` are all
+ * legal), so it is out of scope by design. Hand-rolled shells sweep to
+ * `p-4 md:p-6` (or `p-3` for a dense inner tile); new surfaces compose
+ * ``.
*
* Both checks collect strings from the whole `className` subtree, so
* `cn("…", cond && "…")`, ternaries, and template literals are all seen.
@@ -63,12 +72,20 @@ const SLOT_NAMES = new Set(["CardHeader", "CardContent"]);
const SHELL_BG_RE = /\bbg-card\b/;
const SHELL_BORDER_RE = /\bborder(?:-|\b)/;
-// The banned `5` step (20 px) on a shell — the subset §7.2 scopes to an
-// error-clean rule: p-5 / px-5 / py-5 / space-y-5 / gap-5, with optional
-// modifier prefixes (`sm:p-5`). Directional insets (`pl-5`/`pr-5`) and
-// half-steps are intentionally OUT of scope (see the header).
-const SHELL_STEP5_RE =
- /(?:^|\s)(?:[^\s"']*:)*((?:p|px|py|space-y|gap)-5)(?=\s|$)/;
+// An off-scale spacing step on a shell — the banned `5` / `7` integer steps
+// and any half-step other than `1.5` (`2.5`, `3.5`, …), across the full
+// spacing prefix family (padding, margin, gap, space), with optional modifier
+// prefixes (`sm:p-5`). A leading `-` (negative margin) is never matched
+// because the prefix has to sit at a `(?:^|\s)` / modifier boundary. Height
+// (`h-`) is a dimension, not a scale step, and is intentionally OUT of scope.
+const SPACING_PREFIX =
+ "space-x|space-y|gap-x|gap-y|gap|px|py|pt|pb|pl|pr|p|mx|my|mt|mb|ml|mr|m";
+// `5` or `7`; or a half-step whose integer part is anything but `1`
+// (`0.5`, `2.5`…`9.5`, `10.5`+), so the sanctioned `1.5` is left alone.
+const OFF_SCALE_STEP = "5|7|(?:[02-9]|\\d\\d+)\\.5";
+const SHELL_OFFSCALE_RE = new RegExp(
+ `(?:^|\\s)(?:[^\\s"']*:)*((?:${SPACING_PREFIX})-(?:${OFF_SCALE_STEP}))(?=\\s|$)`,
+);
function toPosix(filename) {
return filename.replace(/\\/g, "/");
@@ -148,14 +165,14 @@ const spacingScaleRule = {
type: "problem",
docs: {
description:
- "Spacing-scale discipline — no pt-*/pb-* overrides on gap-based Card slots, and no off-scale `5` step on a bg-card+border shell.",
+ "Spacing-scale discipline — no pt-*/pb-* overrides on gap-based Card slots, and no off-scale spacing step (`5`/`7`/half-steps) on a bg-card+border shell.",
},
schema: [],
messages: {
slotPadding:
'"{{match}}" on <{{element}}> fights the gap-based Card contract — the header→body distance comes from the parent flex gap, so slot padding stacks on top of it (or is a no-op). Tune density on the Card itself instead: .',
- 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 (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 .',
+ cardShellOffScale:
+ 'Off-scale "{{match}}" on a bg-card+border shell — the scale is 1/1.5/2/3/4/6/8/10, so the `5`/`7` steps and half-steps other than 1.5 are banned (UI-STANDARDS §2) and read off next to every sibling (p-4 md:p-6). Sweep the shell to an on-scale step (`p-4 md:p-6`, or `p-3` for a dense inner tile); new surfaces should compose .',
},
},
create(context) {
@@ -197,11 +214,11 @@ const spacingScaleRule = {
// seen together even when split across cn() args, then report once.
const joined = strings.map((s) => s.text).join(" ");
if (SHELL_BG_RE.test(joined) && SHELL_BORDER_RE.test(joined)) {
- const m = SHELL_STEP5_RE.exec(joined);
+ const m = SHELL_OFFSCALE_RE.exec(joined);
if (m) {
context.report({
node: classNameAttr,
- messageId: "cardShellStep5",
+ messageId: "cardShellOffScale",
data: { match: m[1] },
});
}
diff --git a/src/app/coach/conversations/page.tsx b/src/app/coach/conversations/page.tsx
index d037d070a..1d276bc48 100644
--- a/src/app/coach/conversations/page.tsx
+++ b/src/app/coach/conversations/page.tsx
@@ -282,7 +282,7 @@ function CoachConversationsBody() {
aria-hidden="true"
/>
{(c.attachments?.length ?? 0) > 1 ? (
-
+
{c.attachments?.length}
) : null}
diff --git a/src/app/notifications/page.tsx b/src/app/notifications/page.tsx
index db199f853..ba17b08c5 100644
--- a/src/app/notifications/page.tsx
+++ b/src/app/notifications/page.tsx
@@ -8,6 +8,7 @@ import { Label } from "@/components/ui/label";
import { Button } from "@/components/ui/button";
import { PageHeader } from "@/components/ui/page-header";
import { Skeleton } from "@/components/ui/skeleton";
+import { EmptyState } from "@/components/ui/empty-state";
import { QueryErrorCard } from "@/components/ui/query-error-card";
import Link from "next/link";
import { Bell, Settings, AlertCircle } from "lucide-react";
@@ -201,22 +202,21 @@ export default function NotificationsPage() {
description={t("notifications.subtitle")}
actions={headerActions}
/>
-
-
-
-
- {t("notifications.noChannels")}
-
- {/* Channel setup lives in Settings → Integrations since v1.25.7;
- point the CTA at that anchor, not the reminder-types screen. */}
+ }
+ title={t("notifications.noChannels")}
+ /* Channel setup lives in Settings → Integrations since v1.25.7;
+ point the CTA at that anchor, not the reminder-types screen. */
+ action={
-
-
+ }
+ />
);
}
diff --git a/src/app/onboarding/[step]/page.tsx b/src/app/onboarding/[step]/page.tsx
index 393118a6f..14b825d5d 100644
--- a/src/app/onboarding/[step]/page.tsx
+++ b/src/app/onboarding/[step]/page.tsx
@@ -101,6 +101,7 @@ export default async function OnboardingStepPage({ params }: PageProps) {
aria-labelledby="onboarding-welcomeback-title"
className="space-y-4"
>
+ {/* Onboarding hero H1: intentionally semibold, not the app-wide bold PageHeader H1 (UI-STANDARDS §5 hero exception). Do not sweep to font-bold. */}
-
- {displayTitle}
-
+ {displayTitle}
{activeBucket !== "day" && (
-
+
{t(
activeBucket === "week"
? "charts.bucketWeekly"
: "charts.bucketMonthly",
)}
-
+
)}
{/* v1.4.16 B8 — comparison caption (mood).
v1.4.19 A2 — hidden on mobile to free up the title row. */}
diff --git a/src/components/custom-metrics/custom-metric-list.tsx b/src/components/custom-metrics/custom-metric-list.tsx
index 4bc676958..9363e4511 100644
--- a/src/components/custom-metrics/custom-metric-list.tsx
+++ b/src/components/custom-metrics/custom-metric-list.tsx
@@ -11,6 +11,7 @@ import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { QueryErrorCard } from "@/components/ui/query-error-card";
import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
+import { SectionHeading } from "@/components/ui/section-heading";
import { Skeleton } from "@/components/ui/skeleton";
import { apiGet } from "@/lib/api/api-fetch";
import { formatDate } from "@/lib/format";
@@ -63,26 +64,23 @@ export function CustomMetricList() {
className="border-border space-y-3 border-t pt-6"
data-slot="custom-metric-list"
>
-