From 9e1f64e733a428ac6b0ad3385a4329190275236e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 10 Sep 2026 06:56:50 +0200 Subject: [PATCH] fix(admin): keep the worker-status row readable with no worker present The admin snapshot paints its worker row in `--destructive` whenever the process serving the page is not the worker: a split deployment running `HEALTHLOG_PROCESS_TYPE=web`, or a worker that stopped answering. The token is AA on a card (4.85:1 in the light theme) but the stat tile lays a `bg-muted/50` wash over that card, and axe measured the tinted wording at 3.97:1 there, under the 4.5:1 AA floor. `e2e/a11y.spec.ts` went red in exactly that state and green in every other one, which is why the console could ship it. The status grids now name a tone instead of spelling a class at every call site. Success and warning keep their tint, both clear AA on the tile. Destructive takes the shape the other status rows already use, the medication detail row and the invite chips: the token paints a small indicator and the wording stays in `text-foreground`. The word itself ("Stopped", "Error") states the condition, so nothing hangs on the colour alone. The database, Umami and GlitchTip rows sit on the same surface with the same tint and move with it. The success-affordance guard's two pinned entries for the status grids went stale in the process: the tint they carried now lives once in the tone table, so the pins follow it there. Checked against a standalone build on a throwaway database: the whole a11y spec green in both themes and both viewports with `HEALTHLOG_PROCESS_TYPE` at `web` (the failing state) and at `all`. jsdom cannot measure a contrast ratio, so the new unit test pins the token choice for both arms instead. --- .../success-affordance-guard.test.ts | 7 +- .../system-status-worker-contrast.test.tsx | 108 ++++++++++++++++++ src/components/admin/_shared.tsx | 50 +++++++- .../admin/system-status-section.tsx | 28 ++--- .../admin/system-status-summary.tsx | 14 +-- 5 files changed, 168 insertions(+), 39 deletions(-) create mode 100644 src/components/admin/__tests__/system-status-worker-contrast.test.tsx diff --git a/src/__tests__/success-affordance-guard.test.ts b/src/__tests__/success-affordance-guard.test.ts index 8074d5d9b..b991b8cab 100644 --- a/src/__tests__/success-affordance-guard.test.ts +++ b/src/__tests__/success-affordance-guard.test.ts @@ -78,7 +78,10 @@ const PINNED_AFFORDANCES: Record< string, Partial> > = { - "src/components/admin/_shared.tsx": { "text-success": 2, "toast.success": 1 }, + // The third `text-success` is the success arm of StatusItem's tone table: + // the two status grids used to spell the tint at every call site and now + // name a tone instead, so the marker lives here once. + "src/components/admin/_shared.tsx": { "text-success": 3, "toast.success": 1 }, "src/components/admin/ai-server-key-section.tsx": { "toast.success": 1 }, "src/components/admin/api-token-overview-section.tsx": { "text-success": 1 }, "src/components/admin/app-log-preview-section.tsx": { @@ -115,8 +118,6 @@ const PINNED_AFFORDANCES: Record< "text-success": 4, "toast.success": 2, }, - "src/components/admin/system-status-section.tsx": { "text-success": 6 }, - "src/components/admin/system-status-summary.tsx": { "text-success": 3 }, "src/components/admin/umami-section.tsx": { "toast.success": 1 }, "src/components/admin/user-management-section.tsx": { "text-success": 2, diff --git a/src/components/admin/__tests__/system-status-worker-contrast.test.tsx b/src/components/admin/__tests__/system-status-worker-contrast.test.tsx new file mode 100644 index 000000000..a6c49734f --- /dev/null +++ b/src/components/admin/__tests__/system-status-worker-contrast.test.tsx @@ -0,0 +1,108 @@ +import { describe, expect, it, vi } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; + +/** + * The `/admin` snapshot renders its worker row red whenever the process + * serving the page is not the worker — a split deployment where the web + * container runs with `HEALTHLOG_PROCESS_TYPE=web`, or a worker that died. + * `text-destructive` on the tile's `bg-muted/50` wash measured 3.97:1, under + * the 4.5:1 floor, and `e2e/a11y.spec.ts` went red in exactly that state. + * + * jsdom cannot compute a contrast ratio, so this pins the token choice + * instead: the destructive token paints the indicator, the wording stays in + * `text-foreground`, and the running state keeps its `text-success` tint. + */ + +vi.mock("next/navigation", () => ({ + useRouter: () => ({ push: vi.fn(), replace: vi.fn() }), + usePathname: () => "/admin", +})); + +const workerRunning = { current: false }; + +vi.mock("@tanstack/react-query", () => ({ + useQuery: ({ queryKey }: { queryKey: readonly unknown[] }) => { + if (queryKey[0] === "admin" && queryKey[1] === "status") { + return { + data: { + version: "1.38.14", + nodeVersion: "v22.0.0", + gitCommit: "abc1234", + buildTime: "2026-09-10T00:00:00.000Z", + startTime: "2026-09-10T00:00:00.000Z", + database: "connected", + worker: { + running: workerRunning.current, + startedAt: null, + lastHeartbeat: null, + lastReminderCheck: null, + lastWithingsSync: null, + lastInsightsRun: null, + jobsProcessed: 0, + }, + failingJobs: null, + counts: { + users: 1, + measurements: 0, + medications: 0, + intakeEvents: 0, + activeTokens: 0, + activeSessions: 1, + }, + integrations: { umami: null, glitchtip: null, webPush: null }, + }, + isLoading: false, + isError: false, + refetch: vi.fn(), + }; + } + return { + data: null, + isLoading: false, + isError: false, + refetch: vi.fn(), + }; + }, + useQueryClient: () => ({ invalidateQueries: vi.fn() }), + useMutation: () => ({ + mutate: vi.fn(), + isPending: false, + isError: false, + error: null, + }), +})); + +import { I18nProvider } from "@/lib/i18n/context"; +import { SystemStatusSummary } from "../system-status-summary"; + +function render() { + return renderToStaticMarkup( + + + , + ); +} + +describe("admin snapshot worker row", () => { + it("states an absent worker in the foreground token with a destructive indicator", () => { + workerRunning.current = false; + const html = render(); + + expect(html).toContain("Stopped"); + // The state reads in the foreground token, which is AA on the tile wash. + expect(html).toContain("text-foreground"); + // The destructive token survives as the indicator, not as the wording. + expect(html).toContain('data-slot="status-item-indicator"'); + expect(html).toContain("bg-destructive"); + expect(html).not.toContain("text-destructive"); + }); + + it("keeps the success tint when the worker is running", () => { + workerRunning.current = true; + const html = render(); + + expect(html).toContain("Running"); + expect(html).toContain("text-success"); + expect(html).not.toContain('data-slot="status-item-indicator"'); + }); +}); diff --git a/src/components/admin/_shared.tsx b/src/components/admin/_shared.tsx index 82b5dbe4a..906262788 100644 --- a/src/components/admin/_shared.tsx +++ b/src/components/admin/_shared.tsx @@ -150,29 +150,67 @@ export function ConfiguredBadge() { ); } +/** The meaning a status tile's value carries. */ +export type StatusTone = "success" | "warning" | "destructive"; + +/** + * Tone → classes for a status tile's value line. + * + * `--success` and `--warning` clear AA as text on the tile's `bg-muted/50` + * surface, so they tint the wording directly. `--destructive` does not: it is + * AA on the card itself (4.85:1 in the light theme) but the tile paints a + * muted wash over that card, and the tinted value measures 3.97:1 there — + * under the 4.5:1 floor. That is what axe reports on `/admin` whenever the + * process serving the page is not the worker and the worker row goes red. + * + * So the destructive tone takes the shape the other status rows already use + * (the medication detail status row, the invite-token chips): the token + * paints a small indicator and the wording stays in `text-foreground`. The + * word itself ("Stopped", "Error") states the condition, so nothing hangs on + * the colour alone. + */ +const STATUS_TONE_CLASS: Record = { + success: "text-success", + warning: "text-warning", + destructive: "text-foreground", +}; + /** * The console's label/value tile. Four hand-rolled variants of this box used * to exist side by side; `StatTile` is the one, and this is the thin adapter - * the status grids call (the `className` they pass tints the VALUE, which is - * why it cannot just be `StatTile` under a different name). + * the status grids call (the `tone` they pass decides how the VALUE reads, + * which is why it cannot just be `StatTile` under a different name). */ export function StatusItem({ icon, label, value, - className, + tone, }: { icon: React.ComponentType<{ className?: string }>; label: string; value: string; - className?: string; + tone?: StatusTone; }) { return ( + + ) : ( + value + ) + } + valueClassName={tone ? STATUS_TONE_CLASS[tone] : undefined} /> ); } diff --git a/src/components/admin/system-status-section.tsx b/src/components/admin/system-status-section.tsx index 0747a8aff..a8a49569f 100644 --- a/src/components/admin/system-status-section.tsx +++ b/src/components/admin/system-status-section.tsx @@ -155,11 +155,7 @@ export function SystemStatusSection() { ? t("admin.databaseConnected") : t("admin.databaseError") } - className={ - status.database === "connected" - ? "text-success" - : "text-destructive" - } + tone={status.database === "connected" ? "success" : "destructive"} /> {status.worker.lastReminderCheck && ( )} @@ -230,10 +222,10 @@ export function SystemStatusSection() { ? t("common.active") : t("common.disabled") } - className={ + tone={ status.integrations.glitchtip.enabled - ? "text-success" - : "text-destructive" + ? "success" + : "destructive" } /> )} @@ -242,7 +234,7 @@ export function SystemStatusSection() { icon={BellRing} label={t("admin.integrationWebPush")} value={t("admin.configured")} - className="text-success" + tone="success" /> )} {/* v1.4.27 R5 — offline GeoLite2 availability. Renders only @@ -259,9 +251,7 @@ export function SystemStatusSection() { host: version.geoProviderHost ?? "ipwho.is", }) } - className={ - version.offlineGeoEnabled ? "text-success" : "text-warning" - } + tone={version.offlineGeoEnabled ? "success" : "warning"} /> )} diff --git a/src/components/admin/system-status-summary.tsx b/src/components/admin/system-status-summary.tsx index 051ba9f0e..f51a1c7a7 100644 --- a/src/components/admin/system-status-summary.tsx +++ b/src/components/admin/system-status-summary.tsx @@ -46,11 +46,7 @@ export function SystemStatusSummary() { ? t("admin.databaseConnected") : t("admin.databaseError") } - className={ - status.database === "connected" - ? "text-success" - : "text-destructive" - } + tone={status.database === "connected" ? "success" : "destructive"} /> )}