Merge the AA-contrast status rows on the admin page

This commit is contained in:
Marc-André Bombeck committed 2026-09-10 07:41:22 +02:00
commit 17671d9dc1
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>