Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
72 changes: 61 additions & 11 deletions eslint-plugins/healthlog/__tests__/spacing-scale.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -60,20 +60,36 @@ ruleTester.run("spacing-scale", rule, {
code: '<CardHeader className="pb-2" />',
filename: "/repo/src/components/__tests__/example.test.tsx",
},
// The card-shell `5` check is scoped to the bg-card+border pair, so the
// The card-shell check is scoped to the bg-card+border pair, so the
// justified list-marker inset never trips it (no shell).
{
code: '<ul className="list-disc pl-5 space-y-1" />',
filename: APP_FILE,
},
// A directional inset ON a shell is out of scope (pl-5/pr-5 not banned).
// 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: '<div className="bg-card border-border rounded-xl border pl-5" />',
code: '<form className="space-y-5" />',
filename: APP_FILE,
},
// Form-body rhythm without a shell surface is fine.
{
code: '<form className="space-y-5" />',
code: '<div className="flex flex-col gap-3.5" />',
filename: APP_FILE,
},
// The sanctioned `1.5` half-step is on-scale, even on a shell.
{
code: '<div className="bg-card border-border rounded-xl border gap-1.5" />',
filename: APP_FILE,
},
// A deliberate negative margin is never matched (boundary anchored).
{
code: '<div className="bg-card border-border rounded-full border -mt-5" />',
filename: APP_FILE,
},
// Height is a dimension, not a scale step — `h-15` on a shell is out of
// this rule's scope by design.
{
code: '<div className="bg-card border-border rounded-xl border h-15" />',
filename: APP_FILE,
},
// A shell already on-scale is fine.
Expand Down Expand Up @@ -138,30 +154,64 @@ ruleTester.run("spacing-scale", rule, {
{
code: '<div className="bg-card border-border rounded-xl border p-5" />',
filename: APP_FILE,
errors: [{ messageId: "cardShellStep5" }],
errors: [{ messageId: "cardShellOffScale" }],
},
// …py-5, …space-y-5, …gap-5 on a shell all trip it.
{
code: '<section className="bg-card rounded-lg border py-5" />',
filename: APP_FILE,
errors: [{ messageId: "cardShellStep5" }],
errors: [{ messageId: "cardShellOffScale" }],
},
{
code: '<div className="bg-card border-border rounded-xl border gap-5 flex" />',
filename: APP_FILE,
errors: [{ messageId: "cardShellStep5" }],
errors: [{ messageId: "cardShellOffScale" }],
},
// The `7` step is banned with the `5` step.
{
code: '<div className="bg-card border-border rounded-xl border p-7" />',
filename: APP_FILE,
errors: [{ messageId: "cardShellOffScale" }],
},
// Half-steps other than 1.5 on a shell: gap-3.5, space-y-3.5, py-2.5.
{
code: '<div className="bg-card border-border rounded-xl border flex flex-col gap-3.5" />',
filename: APP_FILE,
errors: [{ messageId: "cardShellOffScale" }],
},
{
code: '<div className="bg-card border rounded-xl space-y-3.5" />',
filename: APP_FILE,
errors: [{ messageId: "cardShellOffScale" }],
},
{
code: '<div className="bg-card border-border rounded-lg border py-2.5" />',
filename: APP_FILE,
errors: [{ messageId: "cardShellOffScale" }],
},
// The directional `pl-`/`ml-` spellings that slipped the `5`-only check
// now trip it on a shell.
{
code: '<div className="bg-card border-border rounded-xl border pl-5" />',
filename: APP_FILE,
errors: [{ messageId: "cardShellOffScale" }],
},
{
code: '<div className="bg-card border-border rounded-xl border ml-5" />',
filename: APP_FILE,
errors: [{ messageId: "cardShellOffScale" }],
},
// Split across cn() args — the join still sees the shell + the step-5.
// Split across cn() args — the join still sees the shell + the step.
{
code: '<div className={cn("bg-card border rounded-xl", "p-5")} />',
filename: APP_FILE,
errors: [{ messageId: "cardShellStep5" }],
errors: [{ messageId: "cardShellOffScale" }],
},
// Modifier-prefixed step on a shell.
{
code: '<div className="bg-card border-border rounded-xl border sm:p-5" />',
filename: APP_FILE,
errors: [{ messageId: "cardShellStep5" }],
errors: [{ messageId: "cardShellOffScale" }],
},
],
});
59 changes: 38 additions & 21 deletions eslint-plugins/healthlog/spacing-scale.js
Original file line number Diff line number Diff line change
Expand Up @@ -17,16 +17,25 @@
* modifier-prefixed (`md:pb-2`) and arbitrary-value (`pb-[10px]`) forms.
* Non-numeric suffixes (`pb-safe`) never match.
*
* 2. `cardShellStep5` — no off-scale `5` step (20 px, banned by §2) on a
* hand-rolled card SHELL. The shell heuristic (§7.2) keys off the
* `bg-card` + `border` pair on ONE element: a `p-5` / `px-5` / `py-5` /
* `space-y-5` / `gap-5` there sits denser than every sibling `<Card>`
* (which is `p-4 md:p-6`), and the 4 px step reads next to a conformant
* card on the same viewport. Deliberately NARROW: it fires only when the
* same element paints a card surface, so justified `pl-5`/`pl-7`
* list-marker insets (no `bg-card`) and form-body `space-y-5` rhythm (no
* shell) never trip it. Hand-rolled shells sweep to `p-4 md:p-6`
* (or `p-3` for a dense inner tile); new surfaces compose `<Card>`.
* 2. `cardShellOffScale` — no off-scale spacing step on a hand-rolled card
* SHELL. The scale (§2) is `1 / 1.5 / 2 / 3 / 4 / 6 / 8 / 10`; the `5`
* (20 px) and `7` (28 px) steps and every half-step other than `1.5`
* (`2.5`, `3.5`, …) are banned. The shell heuristic (§7.2) keys off the
* `bg-card` + `border` pair on ONE element: a `p-5` / `py-2.5` /
* `space-y-3.5` / `gap-3.5` / `ml-5` there sits off the scale next to
* every sibling `<Card>` (which is `p-4 md:p-6`), and reads on the same
* viewport. The prefix set is the full spacing family — padding
* (`p/px/py/pt/pb/pl/pr`), margin (`m/mx/my/mt/mb/ml/mr`), gap
* (`gap/gap-x/gap-y`), and `space-x/space-y` — so the `pl-`/`ml-`/`gap-`
* spellings that slipped the earlier `5`-only check are covered.
* Deliberately NARROW: it fires only when the same element paints a card
* surface, so a justified `pl-5` list-marker inset (no `bg-card`), a
* form-body `space-y-5` rhythm (no shell), and a deliberate negative
* margin (`-mt-5`, never matched) never trip it. Height (`h-`) is a
* dimension, not a scale step (`h-11` tap floor, `h-14`, `h-64` are all
* legal), so it is out of scope by design. Hand-rolled shells sweep to
* `p-4 md:p-6` (or `p-3` for a dense inner tile); new surfaces compose
* `<Card>`.
*
* Both checks collect strings from the whole `className` subtree, so
* `cn("…", cond && "…")`, ternaries, and template literals are all seen.
Expand Down Expand Up @@ -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, "/");
Expand Down Expand Up @@ -148,14 +165,14 @@ const spacingScaleRule = {
type: "problem",
docs: {
description:
"Spacing-scale discipline — no pt-*/pb-* overrides on gap-based Card slots, and no off-scale `5` step on a bg-card+border shell.",
"Spacing-scale discipline — no pt-*/pb-* overrides on gap-based Card slots, and no off-scale spacing step (`5`/`7`/half-steps) on a bg-card+border shell.",
},
schema: [],
messages: {
slotPadding:
'"{{match}}" on <{{element}}> fights the gap-based Card contract — the header→body distance comes from the parent flex gap, so slot padding stacks on top of it (or is a no-op). Tune density on the Card itself instead: <Card className="gap-2 py-3 md:py-4">.',
cardShellStep5:
'Off-scale "{{match}}" (20 px) on a bg-card+border shell — the `5` step is banned (UI-STANDARDS §2) and reads denser than every sibling <Card> (p-4 md:p-6). Sweep the shell to `p-4 md:p-6` (or `p-3` for a dense inner tile); new surfaces should compose <Card>.',
cardShellOffScale:
'Off-scale "{{match}}" on a bg-card+border shell — the scale is 1/1.5/2/3/4/6/8/10, so the `5`/`7` steps and half-steps other than 1.5 are banned (UI-STANDARDS §2) and read off next to every sibling <Card> (p-4 md:p-6). Sweep the shell to an on-scale step (`p-4 md:p-6`, or `p-3` for a dense inner tile); new surfaces should compose <Card>.',
},
},
create(context) {
Expand Down Expand Up @@ -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] },
});
}
Expand Down
2 changes: 1 addition & 1 deletion src/app/coach/conversations/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -282,7 +282,7 @@ function CoachConversationsBody() {
aria-hidden="true"
/>
{(c.attachments?.length ?? 0) > 1 ? (
<span className="text-[10px] font-semibold tabular-nums">
<span className="text-2xs font-semibold tabular-nums">
{c.attachments?.length}
</span>
) : null}
Expand Down
20 changes: 10 additions & 10 deletions src/app/notifications/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -201,22 +202,21 @@ export default function NotificationsPage() {
description={t("notifications.subtitle")}
actions={headerActions}
/>
<div className="bg-card border-border max-w-2xl rounded-xl border p-6">
<div className="flex flex-col items-center gap-4 py-8 text-center">
<Bell className="text-muted-foreground h-12 w-12" />
<p className="text-muted-foreground text-sm">
{t("notifications.noChannels")}
</p>
{/* Channel setup lives in Settings → Integrations since v1.25.7;
point the CTA at that anchor, not the reminder-types screen. */}
<EmptyState
className="max-w-2xl"
icon={<Bell className="size-6" />}
title={t("notifications.noChannels")}
/* Channel setup lives in Settings → Integrations since v1.25.7;
point the CTA at that anchor, not the reminder-types screen. */
action={
<Button asChild variant="outline">
<Link href="/settings/integrations#channels">
<Settings className="h-4 w-4" />
{t("notifications.goToSettings")}
</Link>
</Button>
</div>
</div>
}
/>
</div>
);
}
Expand Down
1 change: 1 addition & 0 deletions src/app/onboarding/[step]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,7 @@ export default async function OnboardingStepPage({ params }: PageProps) {
aria-labelledby="onboarding-welcomeback-title"
className="space-y-4"
>
{/* Onboarding hero H1: intentionally semibold, not the app-wide bold PageHeader H1 (UI-STANDARDS §5 hero exception). Do not sweep to font-bold. */}
<h1
id="onboarding-welcomeback-title"
tabIndex={-1}
Expand Down
2 changes: 1 addition & 1 deletion src/components/charts/chart-tooltip.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,7 @@ export function RichChartTooltip({
return (
<div
data-slot="rich-chart-tooltip"
className="bg-card border-border min-w-[140px] rounded-xl border p-2.5 text-xs shadow-lg"
className="bg-card border-border min-w-[140px] rounded-xl border p-3 text-xs shadow-lg"
style={{
// The card itself is layered above the chart's own SVG; without
// an explicit background the gradient bleeds through the
Expand Down
9 changes: 4 additions & 5 deletions src/components/charts/mood-chart.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import { useEffect, useState, useMemo, useRef } from "react";
import { Button } from "@/components/ui/button";
import { Skeleton } from "@/components/ui/skeleton";
import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card";
import { TagChip } from "@/components/ui/tag-chip";
import { useTranslations } from "@/lib/i18n/context";
import { makeBucketLabelFormatters } from "@/lib/charts/bucket-label";
import { readStoredTimezone } from "@/lib/timezone-mirror";
Expand Down Expand Up @@ -1033,17 +1034,15 @@ export function MoodChart({
back to side-by-side. */}
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<div className="flex flex-wrap items-center gap-2">
<CardTitle className="text-base font-medium">
{displayTitle}
</CardTitle>
<CardTitle className="text-base">{displayTitle}</CardTitle>
{activeBucket !== "day" && (
<span className="bg-muted/40 text-muted-foreground hidden rounded-md px-1.5 py-0.5 text-[10px] font-medium tracking-wide uppercase sm:inline-flex">
<TagChip className="hidden tracking-wide uppercase sm:inline-flex">
{t(
activeBucket === "week"
? "charts.bucketWeekly"
: "charts.bucketMonthly",
)}
</span>
</TagChip>
)}
{/* v1.4.16 B8 — comparison caption (mood).
v1.4.19 A2 — hidden on mobile to free up the title row. */}
Expand Down
38 changes: 18 additions & 20 deletions src/components/custom-metrics/custom-metric-list.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -63,26 +64,23 @@ export function CustomMetricList() {
className="border-border space-y-3 border-t pt-6"
data-slot="custom-metric-list"
>
<div className="flex items-center justify-between gap-2">
<div className="min-w-0">
<h2 className="text-lg font-semibold tracking-tight">
{t("customMetrics.sectionTitle")}
</h2>
<p className="text-muted-foreground truncate text-xs sm:text-sm">
{t("customMetrics.sectionSubtitle")}
</p>
</div>
<Button
variant="outline"
size="sm"
className="min-h-11 shrink-0 sm:min-h-9"
onClick={() => setAddOpen(true)}
aria-label={t("customMetrics.add")}
>
<Plus className="h-4 w-4" />
<span className="hidden sm:inline">{t("customMetrics.add")}</span>
</Button>
</div>
<SectionHeading
icon={Gauge}
title={t("customMetrics.sectionTitle")}
subtitle={t("customMetrics.sectionSubtitle")}
action={
<Button
variant="outline"
size="sm"
className="min-h-11 shrink-0 sm:min-h-9"
onClick={() => setAddOpen(true)}
aria-label={t("customMetrics.add")}
>
<Plus className="h-4 w-4" />
<span className="hidden sm:inline">{t("customMetrics.add")}</span>
</Button>
}
/>

{isLoading ? (
<Card aria-hidden="true">
Expand Down
2 changes: 1 addition & 1 deletion src/components/cycle/cycle-calendar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -177,7 +177,7 @@ export function CycleCalendar({
<div
key={i}
role="columnheader"
className="text-muted-foreground pb-1 text-center text-[11px] font-medium uppercase"
className="text-muted-foreground text-2xs pb-1 text-center font-medium uppercase"
>
{wk}
</div>
Expand Down
Loading
Loading