From 89c6d4555fbecd688f3e005a34716e03bf32c378 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Sun, 8 Nov 2026 13:04:15 +0100 Subject: [PATCH 1/4] fix(web): quiet the overview's open anomalies to a muted link Open anomalies no longer get their own amber card on the overview. They show as a small muted link to the anomaly inbox, beside the firing strip when incidents fire, or alone on a line when nothing fires. Anomalies are often not urgent, and a tinted banner for each one wears attention down. --- .../src/page/overview/overview.scene.test.ts | 6 +- apps/web/src/page/overview/view-hero.ts | 93 ++++++++----------- 2 files changed, 40 insertions(+), 59 deletions(-) diff --git a/apps/web/src/page/overview/overview.scene.test.ts b/apps/web/src/page/overview/overview.scene.test.ts index 1c55ece980..0bc4fc9ebd 100644 --- a/apps/web/src/page/overview/overview.scene.test.ts +++ b/apps/web/src/page/overview/overview.scene.test.ts @@ -292,8 +292,7 @@ describe("overview", () => { given(loaded([snapshotRow("checkout")], [anomaly])), themeResolved(), cursorMounted(), - expectView(text("Open:")).toExist(), - expectView(text("1 anomaly")).toExist(), + expectView(role("link", { name: /1 open anomaly/ })).toExist(), expectView(text("Firing now:")).not.toExist(), ) scene( @@ -311,8 +310,7 @@ describe("overview", () => { cursorMounted(), expectView(text("Firing now:")).toExist(), expectView(text("1 incident · 1 warning")).toExist(), - expectView(text("Open:")).toExist(), - expectView(text("1 anomaly")).toExist(), + expectView(role("link", { name: /1 open anomaly/ })).toExist(), ) }) diff --git a/apps/web/src/page/overview/view-hero.ts b/apps/web/src/page/overview/view-hero.ts index 1e3f1f5467..398d7976f1 100644 --- a/apps/web/src/page/overview/view-hero.ts +++ b/apps/web/src/page/overview/view-hero.ts @@ -4,11 +4,13 @@ import { card, cardPanel } from "@maple/views/ui/card" import { statusDot } from "@maple/views/ui/status-dot" import { typography } from "@maple/views/ui/typography" import { hstack } from "@maple/views/ui/stack" +import { textLink } from "@maple/views/ui/text-link" +import { alertsHref } from "../alerts/shared/links" import { Array as Arr } from "effect" import type { Incident } from "./model" // The one-line alert strip above the health rail; nothing when all is clear. -// Open anomalies count apart from firing incidents. +// Open anomalies count apart from firing incidents and stay quiet. export type HeroCounts = Readonly<{ /** Open alert incidents, anomaly rules' excluded. */ @@ -38,14 +40,6 @@ export const heroCountsOf = ( } } -type Tone = "crit" | "warn" - -const TONE_TEXT = { crit: "text-severity-error", warn: "text-severity-warn" } satisfies Record -const TONE_CARD = { - crit: "border-severity-error-a3 bg-severity-error/[0.04]", - warn: "border-severity-warn-a3 bg-severity-warn/[0.04]", -} satisfies Record - const joinParts = (parts: ReadonlyArray): string => Arr.join( Arr.filter(parts, (part) => part !== ""), @@ -55,70 +49,59 @@ const joinParts = (parts: ReadonlyArray): string => const countOf = (count: number, singular: string, plural?: string): string => count > 0 ? `${count} ${pluralize(count, singular, plural)}` : "" -type Segment = Readonly<{ tone: Tone; title: string; detail: string }> +// Anomalies are hints, not pages: a quiet link to the inbox, never a tinted card of their own. +const anomalyLink = (h: HtmlBuilder, count: number): Html => + textLink(h, { href: alertsHref({ signal: "anomaly" }), tone: "muted", className: "shrink-0 text-xs" }, [ + statusDot(h, { tone: "warn" }), + `${countOf(count, "open anomaly", "open anomalies")} →`, + ]) -// One segment of the strip: its dot, its title in its tone, then the muted detail. -const segment = (h: HtmlBuilder, { tone, title, detail }: Segment): Html => - hstack(h, { gap: "2", shrink: true }, [ - statusDot(h, { tone }), - typography(h, { size: "sm", truncate: true }, [ - typography(h, { weight: "medium", className: TONE_TEXT[tone] }, [title]), - typography(h, { tone: "muted", className: "ml-1" }, [detail]), - ]), +const rulesCount = (h: HtmlBuilder, counts: HeroCounts): Html => + typography(h, { size: "xs", tone: "muted", numeric: true, className: "shrink-0" }, [ + typography(h, { tone: "foreground", weight: "medium" }, [String(counts.rulesEnabled)]), + typography(h, { tone: "subtle" }, ["/"]), + `${counts.rulesTotal} rules`, ]) -const strip = ( - h: HtmlBuilder, - segments: Arr.NonEmptyReadonlyArray, - counts: HeroCounts, -): Html => +const firingStrip = (h: HtmlBuilder, counts: HeroCounts): Html => card( h, [ cardPanel( h, [ - hstack(h, { gap: "6", shrink: true, wrap: true, className: "gap-y-1" }, [ - ...Arr.map(segments, (part) => segment(h, part)), - ]), - typography(h, { size: "xs", tone: "muted", numeric: true, className: "shrink-0" }, [ - typography(h, { tone: "foreground", weight: "medium" }, [ - String(counts.rulesEnabled), + hstack(h, { gap: "2", shrink: true }, [ + statusDot(h, { tone: "crit" }), + typography(h, { size: "sm", truncate: true }, [ + typography(h, { weight: "medium", className: "text-severity-error" }, [ + "Firing now:", + ]), + typography(h, { tone: "muted", className: "ml-1" }, [ + joinParts([ + countOf(counts.openCount, "incident"), + countOf(counts.criticalCount, "critical", "critical"), + countOf(counts.warningCount, "warning"), + ]), + ]), ]), - typography(h, { tone: "subtle" }, ["/"]), - `${counts.rulesTotal} rules`, + ]), + hstack(h, { gap: "4", shrink: true }, [ + ...(counts.anomalyCount > 0 ? [anomalyLink(h, counts.anomalyCount)] : []), + rulesCount(h, counts), ]), ], "flex items-center justify-between gap-4 px-4 py-2", ), ], - // The strip takes the worst segment's tone. - TONE_CARD[segments[0].tone], + "border-severity-error-a3 bg-severity-error/[0.04]", ) /** - * The alert strip, absent when all clear: firing incidents (red) and open anomalies (amber) side by - * side, one rules count. Anomaly rules' incidents are open anomalies, never firing. + * The alert strip, absent when all clear. Firing incidents get the red card; open anomalies (anomaly + * rules' incidents, never firing) are a muted link beside it, or alone on a line when nothing fires. */ export const alertFiringHero = (h: HtmlBuilder, counts: HeroCounts): ReadonlyArray => { - const firing: ReadonlyArray = - counts.openCount > 0 - ? [ - { - tone: "crit", - title: "Firing now:", - detail: joinParts([ - countOf(counts.openCount, "incident"), - countOf(counts.criticalCount, "critical", "critical"), - countOf(counts.warningCount, "warning"), - ]), - }, - ] - : [] - const anomalies: ReadonlyArray = - counts.anomalyCount > 0 - ? [{ tone: "warn", title: "Open:", detail: countOf(counts.anomalyCount, "anomaly", "anomalies") }] - : [] - const segments = [...firing, ...anomalies] - return Arr.isReadonlyArrayNonEmpty(segments) ? [strip(h, segments, counts)] : [] + if (counts.openCount > 0) return [firingStrip(h, counts)] + if (counts.anomalyCount > 0) return [hstack(h, { justify: "end" }, [anomalyLink(h, counts.anomalyCount)])] + return [] } From 93fd5389b39e0fae276d5665fc0898c338377883 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Sun, 8 Nov 2026 13:08:19 +0100 Subject: [PATCH 2/4] fix(web): keep the firing strip's right group at its content width The right group took min-w-0 while its link and rules count are shrink-0, so at narrow widths they painted past the card. Without it the firing counts truncate first, as before. --- apps/web/src/page/overview/view-hero.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/src/page/overview/view-hero.ts b/apps/web/src/page/overview/view-hero.ts index 398d7976f1..5311d5d54a 100644 --- a/apps/web/src/page/overview/view-hero.ts +++ b/apps/web/src/page/overview/view-hero.ts @@ -85,7 +85,7 @@ const firingStrip = (h: HtmlBuilder, counts: HeroCounts): Html => ]), ]), ]), - hstack(h, { gap: "4", shrink: true }, [ + hstack(h, { gap: "4" }, [ ...(counts.anomalyCount > 0 ? [anomalyLink(h, counts.anomalyCount)] : []), rulesCount(h, counts), ]), From 15e6ac35214ed9beffd14df5bc8b4d0ce81b27fd Mon Sep 17 00:00:00 2001 From: Makisuo Date: Sun, 8 Nov 2026 13:11:01 +0100 Subject: [PATCH 3/4] fix(web): wrap the firing strip on narrow screens The anomaly link and rules count move to a second line instead of truncating the firing counts. --- apps/web/src/page/overview/view-hero.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/src/page/overview/view-hero.ts b/apps/web/src/page/overview/view-hero.ts index 5311d5d54a..88d7ff0af7 100644 --- a/apps/web/src/page/overview/view-hero.ts +++ b/apps/web/src/page/overview/view-hero.ts @@ -90,7 +90,7 @@ const firingStrip = (h: HtmlBuilder, counts: HeroCounts): Html => rulesCount(h, counts), ]), ], - "flex items-center justify-between gap-4 px-4 py-2", + "flex flex-wrap items-center justify-between gap-x-4 gap-y-1 px-4 py-2", ), ], "border-severity-error-a3 bg-severity-error/[0.04]", From 67a58226083136a7dd11bea6b65e0152daa6525f Mon Sep 17 00:00:00 2001 From: Makisuo Date: Sun, 8 Nov 2026 13:11:24 +0100 Subject: [PATCH 4/4] test(web): assert the overview anomaly link's destination --- apps/web/src/page/overview/overview.scene.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/web/src/page/overview/overview.scene.test.ts b/apps/web/src/page/overview/overview.scene.test.ts index 0bc4fc9ebd..b349b4927a 100644 --- a/apps/web/src/page/overview/overview.scene.test.ts +++ b/apps/web/src/page/overview/overview.scene.test.ts @@ -292,7 +292,7 @@ describe("overview", () => { given(loaded([snapshotRow("checkout")], [anomaly])), themeResolved(), cursorMounted(), - expectView(role("link", { name: /1 open anomaly/ })).toExist(), + expectView(role("link", { name: /1 open anomaly/ })).toHaveAttr("href", "/alerts?signal=anomaly"), expectView(text("Firing now:")).not.toExist(), ) scene( @@ -310,7 +310,7 @@ describe("overview", () => { cursorMounted(), expectView(text("Firing now:")).toExist(), expectView(text("1 incident · 1 warning")).toExist(), - expectView(role("link", { name: /1 open anomaly/ })).toExist(), + expectView(role("link", { name: /1 open anomaly/ })).toHaveAttr("href", "/alerts?signal=anomaly"), ) })