diff --git a/CHANGELOG.md b/CHANGELOG.md index a583a8562..29a4daf39 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,28 @@ ## [Unreleased] +## [1.32.34] — 2026-07-25 + +Housekeeping on the interface. Most of it you will not see. + +The sections on the insights overview had each been given their own card frame +by hand, and the copies had drifted apart on the one thing nobody thought to +write down: how much room to leave between the blocks inside them. Some used 8 +pixels, some 12, one 16, all sitting one under the other in the same column. +They now share a single frame with a single rhythm, so a few of those cards +open or tighten very slightly and all of them pick up the same faint shadow +their neighbours already had. + +When a metric page cannot load, the message now reads in the normal text +colour with a warning glyph beside it, instead of the grey reserved for +timestamps and units. A failure is what that surface has to say at that moment, +so it should not look like a footnote. + +The rest is invisible from the outside. The sleep composition card uses the +same title row as every other card, a handful of one-off text sizes and +paddings went back onto the scale the rest of the app uses, and the spacing +rule that was already enforced in the build now has nothing left to catch. + ## [1.32.33] — 2026-07-25 The moodLog integration is gone. It bridged mood entries to and from a separate diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 971c22353..289e03009 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -1,7 +1,7 @@ openapi: 3.1.0 info: title: HealthLog API - version: 1.32.33 + version: 1.32.34 description: >- Self-hosted personal-health-tracking PWA — public API surface for the iOS native client and external ingest. diff --git a/package.json b/package.json index 407580a43..e73c58a5f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "healthlog", - "version": "1.32.33", + "version": "1.32.34", "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", diff --git a/public/sw.js b/public/sw.js index 385e4bc2b..c42b23c88 100644 --- a/public/sw.js +++ b/public/sw.js @@ -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.32.33"; + /* @sw-version-fallback */ "v1.32.34"; 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/*` diff --git a/src/app/globals.css b/src/app/globals.css index 441b14be6..3e1e61a6d 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -71,6 +71,14 @@ --color-popover: var(--popover); --color-card-foreground: var(--card-foreground); --color-card: var(--card); + /* The one sanctioned step below `text-xs` (12px). Minted so badges, + tag chips, and tabular-nums micro-labels stop hand-rolling + `text-[0.6875rem]` / `text-[0.65rem]` arbitrary values that drift by a + third of a pixel per site. UI-STANDARDS §5 pins the usage: never for + muted body or meta text (the muted floor stays `text-xs`) — only for + chips, badges, and tabular micro-labels with verified contrast. */ + --text-2xs: 0.6875rem; + --text-2xs--line-height: 1rem; --radius-sm: calc(var(--radius) - 4px); --radius-md: calc(var(--radius) - 2px); --radius-lg: var(--radius); diff --git a/src/app/not-found.tsx b/src/app/not-found.tsx index 368492db0..0413a3f69 100644 --- a/src/app/not-found.tsx +++ b/src/app/not-found.tsx @@ -49,7 +49,7 @@ export default async function NotFound() { {t("notFound.backToDashboard")} diff --git a/src/components/insights/__tests__/insight-section-card.test.tsx b/src/components/insights/__tests__/insight-section-card.test.tsx new file mode 100644 index 000000000..487e93b63 --- /dev/null +++ b/src/components/insights/__tests__/insight-section-card.test.tsx @@ -0,0 +1,122 @@ +import { readFileSync, readdirSync } from "node:fs"; +import path from "node:path"; + +import { describe, expect, it } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; + +import { InsightSectionCard } from "../insight-section-card"; + +const INSIGHTS_DIR = path.join(process.cwd(), "src/components/insights"); + +/** + * The overview sections that compose the shared shell, by the `data-slot` + * each one keeps. A section that drifts back onto a hand-rolled literal — or + * a rename that silently drops one off the primitive — fails here instead of + * surviving until the next audit. + */ +const MIGRATED_SLOTS = [ + "health-status-card", + "breathing-screening-card", + "coincident-deviation-card", + "rhythm-events-card", + "period-narrative-card", + "labs-changes-card", + "ecg-card", + "daily-briefing", +]; + +/** The shell literal the primitive replaces. */ +function paintsHandRolledShell(line: string): boolean { + return ( + /className=/.test(line) && + /\bbg-card\b/.test(line) && + /\brounded-xl\b/.test(line) && + /\bborder\b/.test(line) && + /\bp-4 md:p-6\b/.test(line) + ); +} + +function insightsSources(): { file: string; src: string }[] { + return readdirSync(INSIGHTS_DIR) + .filter((f) => f.endsWith(".tsx")) + .map((f) => ({ + file: f, + src: readFileSync(path.join(INSIGHTS_DIR, f), "utf8"), + })); +} + +describe("", () => { + it("composes Card with the pinned overview rhythm", () => { + const html = renderToStaticMarkup( + +

body

+
, + ); + expect(html).toContain('data-slot="insight-section-card"'); + expect(html).toContain('data-slot="card-content"'); + // Frame from the Card contract, body inset from CardContent. + expect(html).toContain("bg-card"); + expect(html).toContain("rounded-xl"); + expect(html).toContain("px-4"); + expect(html).toContain("md:px-6"); + // One inner rhythm — not the 8/12/16 px the copies had drifted to. + expect(html).toContain("gap-3"); + expect(html).not.toContain("gap-2"); + expect(html).not.toContain("space-y-4"); + }); + + it("takes a slot override and merges caller classes onto the frame", () => { + const html = renderToStaticMarkup( + +

body

+
, + ); + expect(html).toContain('data-slot="rhythm-events-card"'); + expect(html).toContain("min-h-32"); + expect(html).not.toContain('data-slot="insight-section-card"'); + }); + + it("flush drops the body inset for an edge-to-edge divided list", () => { + const html = renderToStaticMarkup( + +
row
+
, + ); + expect(html).toContain('data-slot="labs-changes-card"'); + expect(html).toContain("divide-y"); + // No CardContent wrapper — the rows own their own inset. + expect(html).not.toContain('data-slot="card-content"'); + expect(html).toContain("py-0"); + }); + + it("every migrated overview section renders through the primitive", () => { + // Collapse whitespace so a prettier reflow onto multiple lines still + // matches the ` src.replace(/\s+/g, " ")) + .join("\n"); + + for (const slot of MIGRATED_SLOTS) { + expect( + sources, + `${slot} should be rendered through `, + ).toContain(` { + const owners = insightsSources().filter(({ src }) => + MIGRATED_SLOTS.some((slot) => src.includes(`slot="${slot}"`)), + ); + // Sanity: the scan actually found the files it means to guard. + expect(owners.length).toBeGreaterThanOrEqual(7); + + const offenders = owners.flatMap(({ file, src }) => + src + .split("\n") + .filter(paintsHandRolledShell) + .map((line) => `${file}: ${line.trim()}`), + ); + expect(offenders).toEqual([]); + }); +}); diff --git a/src/components/insights/__tests__/sleep-stage-stacked-bar.test.tsx b/src/components/insights/__tests__/sleep-stage-stacked-bar.test.tsx index 9aedff7a4..b8e48baf1 100644 --- a/src/components/insights/__tests__/sleep-stage-stacked-bar.test.tsx +++ b/src/components/insights/__tests__/sleep-stage-stacked-bar.test.tsx @@ -51,6 +51,9 @@ describe("", () => { const html = render(); expect(html).toContain("Stage composition"); expect(html).toContain("Last 23 nights"); + // The title routes through the canonical tile header rather than a + // hand-rolled title row (UI-STANDARDS §1 / §8). + expect(html).toContain('data-slot="tile-header"'); }); it("exposes an accessible label that announces the nights covered", () => { diff --git a/src/components/insights/breathing-screening-card.tsx b/src/components/insights/breathing-screening-card.tsx index 0da2d22fc..4ef705c67 100644 --- a/src/components/insights/breathing-screening-card.tsx +++ b/src/components/insights/breathing-screening-card.tsx @@ -13,6 +13,7 @@ import type { BreathingClassification, BreathingTrend, } from "@/lib/insights/breathing-screening"; +import { InsightSectionCard } from "./insight-section-card"; /** * v1.25 — sleep-breathing screening card. @@ -83,10 +84,7 @@ export function BreathingScreeningCard({ title={t("insights.breathingScreening.sectionTitle")} subtitle={t("insights.breathingScreening.subtitle")} /> -
+ {classificationLabel ? (

{t("insights.breathingScreening.disclaimer")}

-
+
); } diff --git a/src/components/insights/coincident-deviation-card.tsx b/src/components/insights/coincident-deviation-card.tsx index d7ddcbd24..74fc12075 100644 --- a/src/components/insights/coincident-deviation-card.tsx +++ b/src/components/insights/coincident-deviation-card.tsx @@ -14,6 +14,7 @@ import { useDerivedMetric } from "@/components/insights/derived/use-derived-metr // (the v1.9.0 lesson, mirrored at vitals-dashboard.tsx). import type { CoincidentDeviationValue } from "@/lib/insights/derived/coincident-deviation"; import type { DerivedProvenance } from "@/lib/insights/derived/types"; +import { InsightSectionCard } from "./insight-section-card"; /** * v1.10.3 — "Today's signal" headline card. @@ -113,11 +114,10 @@ function CardShell({ ) : undefined } /> -
{children} -
+
); } @@ -155,14 +155,14 @@ function CardSkeleton({ className }: { className?: string }) { icon={Activity} title={t("insights.derived.coincident.cardTitle")} /> -