From f9796858e600abe779275514de1879148b87a208 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 21 Oct 2026 09:32:58 +0200 Subject: [PATCH 1/3] fix(ui): give the segmented control back the chart tabs' look The shared control introduced a muted track with the chosen segment raised on the background colour. In a chart header it read heavier than the card around it, larger type and wider segments on a grey bar, and it dropped the primary fill the range tabs had carried. Segmented now draws the chart tabs' former look everywhere: no track, the chosen segment filled in the primary colour, the others plain until hovered. A small size keeps the range rows in chart headers at the former type and padding; the touch floor of 44 px on a coarse pointer stays. --- src/components/charts/chart-range-tabs.tsx | 1 + .../custom-metrics/custom-metric-chart.tsx | 1 + .../insights/mood/mood-dimension-trends.tsx | 1 + .../insights/period-narrative-card.tsx | 1 + .../insights/sleep-stage-stacked-bar.tsx | 1 + src/components/labs/lab-biomarker-chart.tsx | 1 + .../detail/efficacy/efficacy-tab.tsx | 1 + .../ui/__tests__/segmented.test.tsx | 21 +++++++++++++++ src/components/ui/segmented.tsx | 27 ++++++++++++++----- 9 files changed, 48 insertions(+), 7 deletions(-) diff --git a/src/components/charts/chart-range-tabs.tsx b/src/components/charts/chart-range-tabs.tsx index 4c8528606..79a4cc11f 100644 --- a/src/components/charts/chart-range-tabs.tsx +++ b/src/components/charts/chart-range-tabs.tsx @@ -51,6 +51,7 @@ export function ChartRangeTabs({ const { t } = useTranslations(); return ( "chart-range-tab"} diff --git a/src/components/custom-metrics/custom-metric-chart.tsx b/src/components/custom-metrics/custom-metric-chart.tsx index 3834a72f1..ddd7d7498 100644 --- a/src/components/custom-metrics/custom-metric-chart.tsx +++ b/src/components/custom-metrics/custom-metric-chart.tsx @@ -140,6 +140,7 @@ export function CustomMetricChart({
({ value: r.key, label: t(`customMetrics.chart.range.${r.key}`), diff --git a/src/components/insights/mood/mood-dimension-trends.tsx b/src/components/insights/mood/mood-dimension-trends.tsx index d3f187c72..efbf1ad07 100644 --- a/src/components/insights/mood/mood-dimension-trends.tsx +++ b/src/components/insights/mood/mood-dimension-trends.tsx @@ -141,6 +141,7 @@ export function MoodDimensionTrends({ return (
({ value: String(option), label: t("insights.mood.dimensions.window", { days: option }), diff --git a/src/components/insights/period-narrative-card.tsx b/src/components/insights/period-narrative-card.tsx index 8602b8bbc..e464b3ea4 100644 --- a/src/components/insights/period-narrative-card.tsx +++ b/src/components/insights/period-narrative-card.tsx @@ -177,6 +177,7 @@ export function PeriodNarrativeCard({
{/* Calm segmented control, no chart. */}
({ value: r.key, label: t(`labs.chart.range.${r.key}`), diff --git a/src/components/medications/detail/efficacy/efficacy-tab.tsx b/src/components/medications/detail/efficacy/efficacy-tab.tsx index 1e7b7e24c..a4d1cdde4 100644 --- a/src/components/medications/detail/efficacy/efficacy-tab.tsx +++ b/src/components/medications/detail/efficacy/efficacy-tab.tsx @@ -260,6 +260,7 @@ function TargetCard({
", () => { expect(out).toContain("min-h-9"); }); + it("fills the chosen segment in the primary colour, on no track", () => { + const out = html(); + const segments = out.match(/]*>/g) ?? []; + expect(segments).toHaveLength(2); + const chosen = segments.filter((b) => b.includes('aria-checked="true"')); + expect(chosen).toHaveLength(1); + expect(chosen[0]).toContain("bg-primary text-primary-foreground"); + const other = segments.find((b) => b.includes('aria-checked="false"')); + expect(other).not.toContain("bg-primary"); + const group = out.match(/]*role="radiogroup"[^>]*>/)?.[0]; + expect(group).not.toContain("bg-muted"); + }); + + it("sets a chart header's range row in the small type", () => { + expect(html()).toContain("text-sm"); + const small = html({ size: "sm" }); + expect(small).toContain("text-xs"); + expect(small).not.toContain("text-sm"); + expect(small.match(/pointer-coarse:min-h-11/g)?.length).toBe(2); + }); + it("renders pressed buttons in toggle mode", () => { const out = html({ mode: "toggle" }); expect(out).toContain('role="group"'); diff --git a/src/components/ui/segmented.tsx b/src/components/ui/segmented.tsx index cb4910f7a..3ce63c16b 100644 --- a/src/components/ui/segmented.tsx +++ b/src/components/ui/segmented.tsx @@ -3,8 +3,15 @@ /** * The segmented control: a short row of mutually exclusive choices on one * track (zoom level, chronicle grouping, cards or table, recent or all, - * Paperless-ngx or Papra). One look everywhere: a muted track, the chosen - * segment raised on the background colour, never coloured. + * Paperless-ngx or Papra). One look everywhere, the one the dashboard chart + * tabs always had: no track, the chosen segment filled in the primary colour, + * the others plain until hovered. A muted track with a raised segment was + * tried and read as heavier than the card around it. + * + * Two sizes: `md` (default) for a choice that is the content of its area (a + * view switch, a form sheet, a detail page's sections), `sm` for a range row + * in a chart header, where the segments sit beside the chart's title and + * must not outweigh it. * * Two semantics, one chrome: * @@ -59,6 +66,8 @@ export interface SegmentedProps { iconOnly?: boolean; /** Spread the segments over the full width. */ stretch?: boolean; + /** `sm` for a range row in a chart header; see the module comment. */ + size?: "sm" | "md"; disabled?: boolean; className?: string; /** @@ -78,6 +87,7 @@ export function Segmented({ mode = "radio", iconOnly = false, stretch = false, + size = "md", disabled = false, className, wrapOption, @@ -100,7 +110,7 @@ export function Segmented({ aria-disabled={disabled || undefined} data-slot={slot} className={cn( - "bg-muted inline-flex shrink-0 items-center gap-0.5 rounded-lg p-0.5", + "inline-flex shrink-0 items-center gap-1", stretch && "flex w-full", className, )} @@ -135,14 +145,17 @@ export function Segmented({ onChange(option.value); }} className={cn( - "focus-visible:ring-ring/50 inline-flex items-center justify-center gap-1.5 rounded-md text-sm font-medium transition-colors outline-none focus-visible:ring-[3px] disabled:pointer-events-none disabled:opacity-50 motion-reduce:transition-none", + "focus-visible:ring-ring/50 inline-flex items-center justify-center gap-1.5 rounded-md font-medium transition-colors outline-none focus-visible:ring-[3px] disabled:pointer-events-none disabled:opacity-50 motion-reduce:transition-none", + size === "sm" ? "text-xs" : "text-sm", iconOnly ? "size-9 pointer-coarse:size-11" - : "min-h-9 px-3 pointer-coarse:min-h-11", + : size === "sm" + ? "min-h-9 px-2 sm:px-3 pointer-coarse:min-h-11 pointer-coarse:min-w-11" + : "min-h-9 px-3 pointer-coarse:min-h-11", stretch && "flex-1", checked - ? "bg-background text-foreground shadow-sm" - : "text-foreground/75 hover:text-foreground dark:text-tab-inactive dark:hover:text-foreground", + ? "bg-primary text-primary-foreground hover:bg-primary/90" + : "text-foreground hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50", )} > {Icon ? ( From 3c4e07df94fe8c2ec22e11498739cf2b4d1efc5a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 21 Oct 2026 09:33:05 +0200 Subject: [PATCH 2/3] chore(release): v1.43.10 --- CHANGELOG.md | 13 +++++++++++++ docs/api/openapi.yaml | 2 +- package.json | 2 +- public/sw.js | 2 +- 4 files changed, 16 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 63e109287..789e762d7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,18 @@ # Changelog +## [1.43.10] — 2026-10-21 + +### Changed + +- **Range and view switches look the way the chart tabs used to.** Since + 1.43.8 every row of mutually exclusive choices shares one control, and it + drew them on a grey track with the chosen option raised. Above a chart that + read heavier than the chart itself. The shared control now draws what the + dashboard's range tabs always had: no track, the chosen option filled in + the accent colour. The range rows above charts are back to their smaller + type. Keyboard navigation and the touch size the shared control brought + stay as they are. + ## [1.43.9] — 2026-10-21 ### Fixed diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 1cc1af921..d38b30a9f 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.43.9 + version: 1.43.10 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 6207709fd..ddf92f179 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "healthlog", - "version": "1.43.9", + "version": "1.43.10", "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 81efa3b64..9bbd22085 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.43.9"; + /* @sw-version-fallback */ "v1.43.10"; 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/*` From 766b41387174aa449c66613ee918557092acd76c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 21 Oct 2026 09:36:05 +0200 Subject: [PATCH 3/3] fix(ui): keep a disabled chosen segment readable and the range rows at 44 px A faded primary fill reads about 2:1 in light mode, so a disabled chosen segment swaps to the token pair Button uses instead of the opacity. The small range rows keep 44 px under a mouse as well, as the chart tabs had, and the changelog names the fill by what it is. --- CHANGELOG.md | 2 +- src/components/ui/__tests__/segmented.test.tsx | 10 +++++++++- src/components/ui/segmented.tsx | 15 +++++++++------ 3 files changed, 19 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 789e762d7..7850a2335 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,7 +9,7 @@ drew them on a grey track with the chosen option raised. Above a chart that read heavier than the chart itself. The shared control now draws what the dashboard's range tabs always had: no track, the chosen option filled in - the accent colour. The range rows above charts are back to their smaller + the highlight colour. The range rows above charts are back to their smaller type. Keyboard navigation and the touch size the shared control brought stay as they are. diff --git a/src/components/ui/__tests__/segmented.test.tsx b/src/components/ui/__tests__/segmented.test.tsx index 91794e816..7f3770b4c 100644 --- a/src/components/ui/__tests__/segmented.test.tsx +++ b/src/components/ui/__tests__/segmented.test.tsx @@ -79,7 +79,15 @@ describe("", () => { const small = html({ size: "sm" }); expect(small).toContain("text-xs"); expect(small).not.toContain("text-sm"); - expect(small.match(/pointer-coarse:min-h-11/g)?.length).toBe(2); + expect(small.match(/min-h-11/g)?.length).toBe(2); + }); + + it("keeps a disabled chosen segment on the readable token pair", () => { + const chosen = html({ disabled: true }) + .match(/]*>/g) + ?.find((b) => b.includes('aria-checked="true"')); + expect(chosen).toContain("disabled:bg-primary-disabled"); + expect(chosen).toContain("disabled:opacity-100"); }); it("renders pressed buttons in toggle mode", () => { diff --git a/src/components/ui/segmented.tsx b/src/components/ui/segmented.tsx index 3ce63c16b..fada16f3a 100644 --- a/src/components/ui/segmented.tsx +++ b/src/components/ui/segmented.tsx @@ -1,8 +1,8 @@ "use client"; /** - * The segmented control: a short row of mutually exclusive choices on one - * track (zoom level, chronicle grouping, cards or table, recent or all, + * The segmented control: a short row of mutually exclusive choices (zoom + * level, chronicle grouping, cards or table, recent or all, * Paperless-ngx or Papra). One look everywhere, the one the dashboard chart * tabs always had: no track, the chosen segment filled in the primary colour, * the others plain until hovered. A muted track with a raised segment was @@ -26,8 +26,9 @@ * setter that saves a preference is not called twice. * * Touch: every segment is at least 44 px on a coarse pointer and 36 px under - * a mouse, the same floor `Button` carries. The four controls this replaced - * sat at 36, 36, 44 and 44. + * a mouse, the same floor `Button` carries; a `sm` range row keeps 44 px + * under a mouse too, as the chart tabs always had. The four controls this + * replaced sat at 36, 36, 44 and 44. * * An option with an `icon` and `iconOnly` on the control renders the icon and * names the segment through `aria-label` and `title`. @@ -150,11 +151,13 @@ export function Segmented({ iconOnly ? "size-9 pointer-coarse:size-11" : size === "sm" - ? "min-h-9 px-2 sm:px-3 pointer-coarse:min-h-11 pointer-coarse:min-w-11" + ? "min-h-11 px-2 sm:px-3 pointer-coarse:min-w-11" : "min-h-9 px-3 pointer-coarse:min-h-11", stretch && "flex-1", checked - ? "bg-primary text-primary-foreground hover:bg-primary/90" + ? // A faded fill reads ~2:1 in light mode; disabled swaps to + // the token pair `Button` uses instead of the opacity. + "bg-primary text-primary-foreground hover:bg-primary/90 disabled:bg-primary-disabled disabled:text-primary-disabled-foreground disabled:opacity-100" : "text-foreground hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50", )} >