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.
This commit is contained in:
Marc-André Bombeck committed 2026-09-10 06:56:50 +02:00
1 parent 297395372b
commit 9e1f64e733
5 files changed
+168 -39

No files matched your search

@@ -78,7 +78,10 @@ const PINNED_AFFORDANCES: Record<
string,
Partial<Record<SuccessMarker, number>>
> = {
"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,
@@ -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(
<I18nProvider initialLocale="en">
<SystemStatusSummary />
</I18nProvider>,
);
}
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"');
});
});
+44 -6
View File
@@ -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<StatusTone, string> = {
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 (
<StatTile
icon={icon}
label={label}
value={value}
valueClassName={className}
value={
tone === "destructive" ? (
<span className="flex items-center gap-1.5">
<span
aria-hidden="true"
data-slot="status-item-indicator"
className="bg-destructive size-2 shrink-0 rounded-full"
/>
{value}
</span>
) : (
value
)
}
valueClassName={tone ? STATUS_TONE_CLASS[tone] : undefined}
/>
);
}
+9 -19
View File
@@ -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"}
/>
<StatusItem
icon={Clock}
@@ -194,9 +190,7 @@ export function SystemStatusSection() {
? t("admin.workerRunning")
: t("admin.workerStopped")
}
className={
status.worker.running ? "text-success" : "text-destructive"
}
tone={status.worker.running ? "success" : "destructive"}
/>
{status.worker.lastReminderCheck && (
<StatusItem
@@ -214,10 +208,8 @@ export function SystemStatusSection() {
? t("common.active")
: t("common.disabled")
}
className={
status.integrations.umami.enabled
? "text-success"
: "text-destructive"
tone={
status.integrations.umami.enabled ? "success" : "destructive"
}
/>
)}
@@ -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"}
/>
)}
</div>
+3 -11
View File
@@ -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"}
/>
<StatusItem
icon={Cog}
@@ -60,9 +56,7 @@ export function SystemStatusSummary() {
? t("admin.workerRunning")
: t("admin.workerStopped")
}
className={
status.worker.running ? "text-success" : "text-destructive"
}
tone={status.worker.running ? "success" : "destructive"}
/>
<StatusItem
icon={Clock}
@@ -84,9 +78,7 @@ export function SystemStatusSummary() {
host: version.geoProviderHost ?? "ipwho.is",
})
}
className={
version.offlineGeoEnabled ? "text-success" : "text-warning"
}
tone={version.offlineGeoEnabled ? "success" : "warning"}
/>
)}
</div>