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"} /> )}