diff --git a/CHANGELOG.md b/CHANGELOG.md index 63e109287..7850a2335 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 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. + ## [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/*` 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(/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", () => { 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..fada16f3a 100644 --- a/src/components/ui/segmented.tsx +++ b/src/components/ui/segmented.tsx @@ -1,10 +1,17 @@ "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, - * Paperless-ngx or Papra). One look everywhere: a muted track, the chosen - * segment raised on the background colour, never coloured. + * 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 + * 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: * @@ -19,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`. @@ -59,6 +67,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 +88,7 @@ export function Segmented({ mode = "radio", iconOnly = false, stretch = false, + size = "md", disabled = false, className, wrapOption, @@ -100,7 +111,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 +146,19 @@ 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-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-background text-foreground shadow-sm" - : "text-foreground/75 hover:text-foreground dark:text-tab-inactive dark:hover:text-foreground", + ? // 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", )} > {Icon ? (