@checkstack/anomaly-frontend 0.5.13 → 0.5.14

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,97 @@
1
1
  # @checkstack/anomaly-frontend
2
2
 
3
+ ## 0.5.14
4
+
5
+ ### Patch Changes
6
+
7
+ - 8cad340: Design-system rework: a premium, consistent UI language across the platform.
8
+
9
+ Foundation (`@checkstack/ui` + the shared Tailwind preset):
10
+
11
+ - A token system wired into the shared preset so it generates app-wide: a
12
+ surface elevation ramp (`surface` / `surface-2` / `surface-inset`), the
13
+ aurora gradient stops, a colorblind-safe `status` triad, and `grid-line`.
14
+ - A density model (`comfortable` / `compact`) via `--d-*` vars + `DensityProvider`
15
+ / `useDensity`, with a user-menu density toggle, plus the polished
16
+ skeleton / empty / error state set.
17
+ - Honest, token-driven chart primitives (`TimeSeriesChart`, `Sparkline`,
18
+ `RadialGauge` / aurora hero, `RequestWaterfall`, `UptimeRibbon`).
19
+ - A signature aurora moment per page: `PageHeader` paints its icon strokes with
20
+ the aurora gradient and adds a hairline; `Card` gains soft layered depth.
21
+
22
+ Shell + surfaces:
23
+
24
+ - The app shell adopts the elevation ramp (header `surface-2`, sidebar
25
+ `surface`, content on the ambient base).
26
+ - The system-health dashboard, health-check latency / single-run views, and the
27
+ SLO dashboard are reskinned onto the primitives (aurora confidence gauge,
28
+ honest p50/p95 latency, request waterfall, number-led status cards).
29
+
30
+ App-wide adoption + premium rework:
31
+
32
+ - Every plugin frontend adopts the tokens, status triad, density, and elevation.
33
+ - The highest-impact surfaces in each plugin are then redesigned to a premium
34
+ bar: real depth, number-led hierarchy, multi-encoded status (pill + dot +
35
+ accent stripe), and refined list/table density. Several plugins extract pure
36
+ tone/label/format logic into unit-tested modules.
37
+
38
+ Alerts:
39
+
40
+ - Every alert/callout is unified onto a single premium `Alert` (depth surface +
41
+ status-accent stripe + toned icon chip, variant-driven).
42
+
43
+ BREAKING CHANGE: the duplicate `InfoBanner` component (and its sub-components)
44
+ is removed; use `Alert` instead - it is a drop-in replacement with the same
45
+ variants and composable parts.
46
+
47
+ - 8cad340: Enlarge the per-field mute/unmute icon button in the anomaly field mute list to a
48
+ 40px touch target (`h-10 w-10`) so it is reliably tappable on touch devices.
49
+ - 8cad340: Adopt the canonical `toastError` helper from `@checkstack/ui` for error toasts.
50
+
51
+ Error toasts that previously called `toast.error(extractErrorMessage(error, "Failed to X"))`
52
+ (or interpolated `Failed to X: ${extractErrorMessage(error)}` strings) now use
53
+ `toastError(toast, "Failed to X", error)`. This centralizes the
54
+ "Failed to <action>: <message>" voice and applies the shared 100-character
55
+ truncation. Error toasts that did not previously prefix the action now gain the
56
+ canonical prefix; success toasts and terse validation one-liners are unchanged.
57
+
58
+ - Updated dependencies [8cad340]
59
+ - Updated dependencies [8cad340]
60
+ - Updated dependencies [8cad340]
61
+ - Updated dependencies [8cad340]
62
+ - Updated dependencies [8cad340]
63
+ - Updated dependencies [8cad340]
64
+ - Updated dependencies [8cad340]
65
+ - Updated dependencies [8cad340]
66
+ - Updated dependencies [8cad340]
67
+ - Updated dependencies [8cad340]
68
+ - Updated dependencies [8cad340]
69
+ - Updated dependencies [8cad340]
70
+ - Updated dependencies [8cad340]
71
+ - Updated dependencies [8cad340]
72
+ - Updated dependencies [8cad340]
73
+ - Updated dependencies [8cad340]
74
+ - Updated dependencies [8cad340]
75
+ - Updated dependencies [8cad340]
76
+ - Updated dependencies [8cad340]
77
+ - Updated dependencies [8cad340]
78
+ - Updated dependencies [8cad340]
79
+ - Updated dependencies [8cad340]
80
+ - Updated dependencies [8cad340]
81
+ - Updated dependencies [8cad340]
82
+ - Updated dependencies [8cad340]
83
+ - Updated dependencies [8cad340]
84
+ - @checkstack/ui@1.17.0
85
+ - @checkstack/healthcheck-frontend@0.25.0
86
+ - @checkstack/notification-frontend@0.6.0
87
+ - @checkstack/notification-common@1.4.0
88
+ - @checkstack/healthcheck-common@1.8.0
89
+ - @checkstack/common@0.17.0
90
+ - @checkstack/frontend-api@0.11.1
91
+ - @checkstack/anomaly-common@1.5.2
92
+ - @checkstack/catalog-common@2.4.2
93
+ - @checkstack/signal-frontend@0.2.6
94
+
3
95
  ## 0.5.13
4
96
 
5
97
  ### Patch Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@checkstack/anomaly-frontend",
3
- "version": "0.5.13",
3
+ "version": "0.5.14",
4
4
  "license": "Elastic-2.0",
5
5
  "type": "module",
6
6
  "main": "src/index.tsx",
@@ -13,16 +13,16 @@
13
13
  "lint:code": "eslint . --max-warnings 0"
14
14
  },
15
15
  "dependencies": {
16
- "@checkstack/anomaly-common": "1.5.1",
17
- "@checkstack/catalog-common": "2.4.1",
18
- "@checkstack/common": "0.16.0",
19
- "@checkstack/frontend-api": "0.11.0",
20
- "@checkstack/healthcheck-common": "1.7.1",
21
- "@checkstack/healthcheck-frontend": "0.24.1",
22
- "@checkstack/notification-common": "1.3.4",
23
- "@checkstack/notification-frontend": "0.5.9",
24
- "@checkstack/signal-frontend": "0.2.5",
25
- "@checkstack/ui": "1.16.2",
16
+ "@checkstack/anomaly-common": "1.5.2",
17
+ "@checkstack/catalog-common": "2.4.2",
18
+ "@checkstack/common": "0.17.0",
19
+ "@checkstack/frontend-api": "0.11.1",
20
+ "@checkstack/healthcheck-common": "1.8.0",
21
+ "@checkstack/healthcheck-frontend": "0.25.0",
22
+ "@checkstack/notification-common": "1.4.0",
23
+ "@checkstack/notification-frontend": "0.6.0",
24
+ "@checkstack/signal-frontend": "0.2.6",
25
+ "@checkstack/ui": "1.17.0",
26
26
  "date-fns": "^4.4.0",
27
27
  "lucide-react": "^1.17.0",
28
28
  "react": "19.2.7",
@@ -30,7 +30,7 @@
30
30
  "zod": "^4.2.1"
31
31
  },
32
32
  "devDependencies": {
33
- "@checkstack/scripts": "0.6.2",
33
+ "@checkstack/scripts": "0.6.3",
34
34
  "@checkstack/tsconfig": "0.0.7",
35
35
  "@types/react": "^19.0.0",
36
36
  "typescript": "^5.0.0"
@@ -1,9 +1,8 @@
1
1
  import React from "react";
2
2
  import { Bell, BellOff } from "lucide-react";
3
- import { Button, useToast } from "@checkstack/ui";
3
+ import { Button, useToast, toastError } from "@checkstack/ui";
4
4
  import { usePluginClient, useApi, accessApiRef } from "@checkstack/frontend-api";
5
5
  import { AnomalyApi } from "@checkstack/anomaly-common";
6
- import { extractErrorMessage } from "@checkstack/common";
7
6
 
8
7
  /**
9
8
  * Sub-controls panel rendered inside the generic SubscriptionRow when a
@@ -50,12 +49,12 @@ export const AnomalyFieldMuteList: React.FC<{
50
49
  const muteMutation = anomalyClient.muteAnomalyNotification.useMutation({
51
50
  onSuccess: () => void refetchMutes(),
52
51
  onError: (error) =>
53
- toast.error(extractErrorMessage(error, "Failed to mute notifications")),
52
+ toastError(toast, "Failed to mute notifications", error),
54
53
  });
55
54
  const unmuteMutation = anomalyClient.unmuteAnomalyNotification.useMutation({
56
55
  onSuccess: () => void refetchMutes(),
57
56
  onError: (error) =>
58
- toast.error(extractErrorMessage(error, "Failed to unmute notifications")),
57
+ toastError(toast, "Failed to unmute notifications", error),
59
58
  });
60
59
 
61
60
  const isPending = muteMutation.isPending || unmuteMutation.isPending;
@@ -109,11 +108,11 @@ export const AnomalyFieldMuteList: React.FC<{
109
108
 
110
109
  {fieldPaths.length === 0 ? (
111
110
  <div className="text-xs text-muted-foreground italic">
112
- No tracked fields yet — mute will become available once anomalies
111
+ No tracked fields yet - mute will become available once anomalies
113
112
  are observed.
114
113
  </div>
115
114
  ) : (
116
- <div className="flex flex-col divide-y rounded-md border">
115
+ <div className="flex flex-col divide-y rounded-md border bg-surface">
117
116
  {fieldPaths.map((fp) => {
118
117
  const muted = mutedFields.has(fp) || isSystemMuted;
119
118
  return (
@@ -127,7 +126,7 @@ export const AnomalyFieldMuteList: React.FC<{
127
126
  type="button"
128
127
  variant="ghost"
129
128
  size="icon"
130
- className="h-6 w-6"
129
+ className="h-10 w-10 shrink-0"
131
130
  disabled={isPending || isSystemMuted}
132
131
  title={muted ? "Unmute this field" : "Mute this field"}
133
132
  onClick={() => handleToggle(fp, mutedFields.has(fp))}
@@ -107,7 +107,7 @@ const PRESET_COPY: Record<
107
107
  balanced: {
108
108
  label: "Balanced",
109
109
  description:
110
- "Recommended. Uses the plugin's tuned defaults — alerts on sustained, meaningful deviations.",
110
+ "Recommended. Uses the plugin's tuned defaults - alerts on sustained, meaningful deviations.",
111
111
  },
112
112
  relaxed: {
113
113
  label: "Relaxed",
@@ -465,12 +465,12 @@ function FieldAccordionItem({
465
465
  <AccordionContent className="px-5 pt-2 pb-5 border-t border-border/30">
466
466
  <div className="mt-4 space-y-5">
467
467
  {/* Plain-language summary of effective config */}
468
- <p className="text-xs leading-relaxed text-muted-foreground bg-muted/30 border border-border/40 rounded-md px-3 py-2">
468
+ <p className="text-xs leading-relaxed text-muted-foreground bg-surface-inset border border-border/40 rounded-md px-3 py-2">
469
469
  {summary}
470
470
  </p>
471
471
 
472
472
  {/* Enable/disable */}
473
- <div className="flex items-center justify-between p-3 border rounded-lg bg-muted/30">
473
+ <div className="flex items-center justify-between p-3 border rounded-lg bg-surface-inset">
474
474
  <div className="space-y-0.5">
475
475
  <Label className="text-sm font-medium">
476
476
  Monitor this field
@@ -554,7 +554,7 @@ function PresetSelector({ value, onChange, disabled }: PresetSelectorProps) {
554
554
  const options: Preset[] = ["strict", "balanced", "relaxed", "custom"];
555
555
  const description =
556
556
  value === "custom"
557
- ? "Custom — fine-tuned values that don't match a preset. Edit them in the advanced section below."
557
+ ? "Custom - fine-tuned values that don't match a preset. Edit them in the advanced section below."
558
558
  : PRESET_COPY[value].description;
559
559
 
560
560
  return (
@@ -565,7 +565,7 @@ function PresetSelector({ value, onChange, disabled }: PresetSelectorProps) {
565
565
  </Label>
566
566
  <Tooltip content="Presets bundle sensitivity, confirmation window, and noise floors into one choice. Pick Custom to fine-tune each value." />
567
567
  </div>
568
- <div className="inline-flex items-stretch p-1 border rounded-lg bg-muted/30 gap-0.5">
568
+ <div className="inline-flex items-stretch p-1 border rounded-lg bg-surface-inset gap-0.5">
569
569
  {options.map((opt) => {
570
570
  const selected = value === opt;
571
571
  return (
@@ -578,7 +578,7 @@ function PresetSelector({ value, onChange, disabled }: PresetSelectorProps) {
578
578
  "px-4 py-1.5 text-xs font-semibold rounded-md",
579
579
  !isLowPower && "transition-all",
580
580
  selected
581
- ? "bg-background text-foreground shadow-sm"
581
+ ? "bg-surface-2 text-foreground shadow-sm"
582
582
  : "text-muted-foreground hover:text-foreground",
583
583
  "disabled:opacity-50 disabled:cursor-not-allowed capitalize",
584
584
  )}
@@ -625,7 +625,7 @@ function BehaviorSelector({
625
625
  onValueChange={(val) => onChange(val as AnomalyDirection | "auto")}
626
626
  disabled={disabled}
627
627
  >
628
- <SelectTrigger className="h-10 transition-colors bg-background/50 focus:bg-background">
628
+ <SelectTrigger className="h-10 transition-colors bg-surface focus:bg-surface-2">
629
629
  <SelectValue placeholder="Auto-detect">
630
630
  <div className="flex items-center gap-2 min-w-0">
631
631
  <Icon className="w-4 h-4 text-muted-foreground shrink-0" />
@@ -674,7 +674,7 @@ function AdvancedDisclosure({
674
674
  }: AdvancedDisclosureProps) {
675
675
  const { isLowPower } = usePerformance();
676
676
  return (
677
- <div className="border border-border/40 rounded-md bg-background/30">
677
+ <div className="border border-border/40 rounded-md bg-surface-inset">
678
678
  <button
679
679
  type="button"
680
680
  onClick={onToggle}
@@ -813,7 +813,7 @@ function AdvancedControls({
813
813
  onChange(field, "minAbsoluteDelta", next);
814
814
  }}
815
815
  disabled={disabled}
816
- className="h-10 transition-colors bg-background/50 focus:bg-background"
816
+ className="h-10 transition-colors bg-surface focus:bg-surface-2"
817
817
  />
818
818
  <p className="text-[10.5px] text-muted-foreground leading-relaxed">
819
819
  Anomaly only fires when the value differs from the recent average
@@ -856,7 +856,7 @@ function AdvancedControls({
856
856
  onChange(field, "minRelativeDelta", next);
857
857
  }}
858
858
  disabled={disabled}
859
- className="h-10 transition-colors bg-background/50 focus:bg-background"
859
+ className="h-10 transition-colors bg-surface focus:bg-surface-2"
860
860
  />
861
861
  <p className="text-[10.5px] text-muted-foreground leading-relaxed">
862
862
  Anomaly only fires when the proportional change exceeds this
@@ -883,7 +883,7 @@ function AdvancedControls({
883
883
  </Label>
884
884
  <Tooltip content="Catches gradual degradation that never triggers a single-spike alert." />
885
885
  </div>
886
- <div className="flex items-center justify-between p-2 border rounded-md bg-muted/30">
886
+ <div className="flex items-center justify-between p-2 border rounded-md bg-surface-inset">
887
887
  <span className="text-xs text-muted-foreground">
888
888
  {effective.driftEnabled ? "Detecting drift" : "Drift muted"}
889
889
  </span>
@@ -103,7 +103,7 @@ export function AnomalySettingsForm({
103
103
 
104
104
  return (
105
105
  <div className="space-y-4">
106
- <div className="flex items-center justify-between p-4 border rounded-md">
106
+ <div className="flex items-center justify-between rounded-[var(--d-card-r)] border border-border/70 bg-gradient-to-b from-surface-2 to-surface p-[var(--d-pad)] shadow-[0_1px_2px_hsl(var(--foreground)/0.04)]">
107
107
  <div className="space-y-0.5">
108
108
  <Label className="text-base font-medium">{copy.enabledLabel}</Label>
109
109
  <div className="text-sm text-muted-foreground">
@@ -122,7 +122,7 @@ export function AnomalySettingsForm({
122
122
  </div>
123
123
  </div>
124
124
 
125
- <div className="grid gap-6 md:grid-cols-2 p-4 border rounded-md">
125
+ <div className="grid gap-6 md:grid-cols-2 rounded-[var(--d-card-r)] border border-border/70 bg-gradient-to-b from-surface-2 to-surface p-[var(--d-pad)] shadow-[0_1px_2px_hsl(var(--foreground)/0.04)]">
126
126
  <div className="space-y-2">
127
127
  <Label htmlFor="baselineWindow" className="text-sm font-medium">
128
128
  Baseline window
@@ -18,8 +18,8 @@ import {
18
18
  CardHeader,
19
19
  CardTitle,
20
20
  CardContent,
21
- Badge,
22
21
  Button,
22
+ cn,
23
23
  useToast,
24
24
  } from "@checkstack/ui";
25
25
  import {
@@ -36,6 +36,11 @@ import {
36
36
  } from "lucide-react";
37
37
  import { formatDistanceToNow } from "date-fns";
38
38
  import { Link } from "react-router-dom";
39
+ import {
40
+ anomalyToneStyles,
41
+ toneForState,
42
+ widgetTone,
43
+ } from "./anomalyTone.logic";
39
44
 
40
45
  type Props = SlotContext<typeof SystemDetailsSlot>;
41
46
 
@@ -144,6 +149,8 @@ function AnomalyRow({
144
149
  const deviationValue = anomaly.deviation?.toFixed(1) ?? "—";
145
150
  const isAbove = anomaly.direction === "above";
146
151
 
152
+ const tone = anomalyToneStyles(toneForState(anomaly.state));
153
+
147
154
  const detailLink = resolveRoute(healthcheckRoutes.routes.historyDetail, {
148
155
  systemId,
149
156
  configurationId: anomaly.configurationId,
@@ -155,19 +162,30 @@ function AnomalyRow({
155
162
  ? HelpCircle
156
163
  : AlertTriangle;
157
164
 
165
+ const stateLabel = isDrift
166
+ ? "drift"
167
+ : isSuspicious
168
+ ? "suspicious"
169
+ : "confirmed";
170
+
171
+ const DirectionIcon = isAbove ? TrendingUp : TrendingDown;
172
+
158
173
  return (
159
174
  <Link
160
175
  to={detailLink}
161
- className="group flex items-center gap-3 px-4 py-3 hover:bg-muted/50 transition-colors cursor-pointer"
176
+ className="group flex items-center gap-4 px-4 py-3 hover:bg-surface-inset transition-colors cursor-pointer"
162
177
  >
163
- {/* State icon */}
164
- <div className={`shrink-0 rounded-full p-1.5 ${
165
- isSuspicious
166
- ? "bg-warning/10 text-warning/70"
167
- : "bg-warning/15 text-warning"
168
- }`}>
169
- <StateIcon className="h-3.5 w-3.5" />
170
- </div>
178
+ {/* Status column — multi-encoded: hue + dot + icon + label. */}
179
+ <span
180
+ className={cn(
181
+ "inline-flex shrink-0 items-center gap-1.5 rounded-full px-2.5 py-1 text-xs font-medium",
182
+ tone.pill,
183
+ )}
184
+ >
185
+ <span className={cn("size-1.5 rounded-full", tone.dot)} aria-hidden />
186
+ <StateIcon className="h-3 w-3" />
187
+ {stateLabel}
188
+ </span>
171
189
 
172
190
  {/* Content */}
173
191
  <div className="flex-1 min-w-0">
@@ -176,15 +194,10 @@ function AnomalyRow({
176
194
  {parsed.label}
177
195
  </span>
178
196
  {parsed.source ? (
179
- <span className="text-[10px] text-muted-foreground bg-muted/80 px-1.5 py-0.5 rounded font-medium shrink-0">
197
+ <span className="text-xs text-muted-foreground shrink-0">
180
198
  {parsed.source}
181
199
  </span>
182
200
  ) : undefined}
183
- {isDrift && (
184
- <span className="text-[10px] text-muted-foreground bg-muted/40 px-1.5 py-0.5 rounded font-medium shrink-0">
185
- drift
186
- </span>
187
- )}
188
201
  {isMuted && (
189
202
  <span className="text-[10px] text-muted-foreground bg-muted/60 px-1.5 py-0.5 rounded font-medium shrink-0 inline-flex items-center gap-0.5">
190
203
  <BellOff className="h-2.5 w-2.5" />
@@ -216,27 +229,28 @@ function AnomalyRow({
216
229
  </div>
217
230
  </div>
218
231
 
219
- {/* Deviation badge + mute toggle + arrow */}
220
- <div className="flex items-center gap-2 shrink-0">
221
- <Badge
222
- variant={isSuspicious ? "outline" : "warning"}
223
- className={`text-[10px] h-5 px-1.5 font-mono tabular-nums gap-1 ${
224
- isSuspicious ? "border-warning/40 text-warning" : ""
225
- }`}
226
- >
227
- {isAbove ? (
228
- <TrendingUp className="h-2.5 w-2.5" />
229
- ) : (
230
- <TrendingDown className="h-2.5 w-2.5" />
231
- )}
232
- {deviationValue}σ
233
- </Badge>
232
+ {/* Deviation hero figure */}
233
+ <div className="flex shrink-0 flex-col items-end leading-none">
234
+ <span className="flex items-baseline gap-0.5">
235
+ <span className={cn("text-2xl font-bold tabular-nums", tone.text)}>
236
+ {deviationValue}
237
+ </span>
238
+ <span className={cn("text-sm font-semibold", tone.text)}>σ</span>
239
+ </span>
240
+ <span className="mt-1 inline-flex items-center gap-0.5 text-[11px] text-muted-foreground">
241
+ <DirectionIcon className="h-3 w-3" />
242
+ vs baseline
243
+ </span>
244
+ </div>
245
+
246
+ {/* Row actions — calm at rest, revealed on hover. */}
247
+ <div className="flex items-center gap-1 shrink-0">
234
248
  {canMute && (
235
249
  <Button
236
250
  type="button"
237
251
  variant="ghost"
238
252
  size="icon"
239
- className="h-7 w-7 text-muted-foreground hover:text-foreground"
253
+ className="h-7 w-7 text-muted-foreground hover:text-foreground opacity-0 group-hover:opacity-100 focus-visible:opacity-100 transition-opacity"
240
254
  disabled={isToggling}
241
255
  title={
242
256
  isMuted
@@ -261,7 +275,7 @@ function AnomalyRow({
261
275
  type="button"
262
276
  variant="ghost"
263
277
  size="icon"
264
- className="h-7 w-7 text-muted-foreground hover:text-foreground"
278
+ className="h-7 w-7 text-muted-foreground hover:text-foreground opacity-0 group-hover:opacity-100 focus-visible:opacity-100 transition-opacity"
265
279
  disabled={isSuppressing}
266
280
  title="Suppress this anomaly until it changes again"
267
281
  onClick={(event) => {
@@ -367,7 +381,7 @@ export const SystemAnomalyWidget: React.FC<Props> = ({ system }) => {
367
381
 
368
382
  if (isLoading) {
369
383
  return (
370
- <Card>
384
+ <Card className="relative overflow-hidden rounded-[var(--d-card-r)] border border-border/70 bg-gradient-to-b from-surface-2 to-surface shadow-[0_1px_2px_hsl(var(--foreground)/0.04),0_10px_30px_-14px_hsl(var(--foreground)/0.12)]">
371
385
  <CardHeader>
372
386
  <CardTitle className="flex items-center gap-2 text-base">
373
387
  <Activity className="h-4 w-4" />
@@ -389,32 +403,67 @@ export const SystemAnomalyWidget: React.FC<Props> = ({ system }) => {
389
403
 
390
404
  const confirmedCount = confirmedAnomalies.length;
391
405
  const suspiciousCount = suspiciousAnomalies.length;
406
+ const activeCount = activeAnomalies.length;
392
407
 
393
408
  const isToggling = muteMutation.isPending || unmuteMutation.isPending;
394
409
 
410
+ const accent = anomalyToneStyles(widgetTone({ confirmedCount }));
411
+ const confirmedStyles = anomalyToneStyles("warn");
412
+ const suspiciousStyles = anomalyToneStyles("unknown");
413
+
395
414
  return (
396
- <Card>
397
- <CardHeader>
398
- <div className="flex items-center justify-between">
399
- <CardTitle className="flex items-center gap-2 text-base">
400
- <Activity className="h-4 w-4 text-warning" />
401
- System Anomalies
402
- </CardTitle>
403
- <div className="flex items-center gap-1.5">
415
+ <Card className="relative overflow-hidden rounded-[var(--d-card-r)] border border-border/70 bg-gradient-to-b from-surface-2 to-surface shadow-[0_1px_2px_hsl(var(--foreground)/0.04),0_10px_30px_-14px_hsl(var(--foreground)/0.12)] transition-all">
416
+ {/* Status accent stripe: worst active state encoded by hue + position. */}
417
+ <span
418
+ className={cn("absolute inset-y-0 left-0 w-1", accent.accent)}
419
+ aria-hidden
420
+ />
421
+ <CardHeader className="pl-6">
422
+ <div className="flex items-start justify-between gap-3">
423
+ <div className="flex items-center gap-3">
424
+ <span className="text-3xl font-bold tabular-nums leading-none text-foreground">
425
+ {activeCount}
426
+ </span>
427
+ <div className="min-w-0">
428
+ <CardTitle className="flex items-center gap-1.5 text-base">
429
+ <Activity className="h-4 w-4 text-status-warn" />
430
+ System Anomalies
431
+ </CardTitle>
432
+ <p className="mt-0.5 text-xs text-muted-foreground">
433
+ active signals
434
+ </p>
435
+ </div>
436
+ </div>
437
+ <div className="flex flex-wrap items-center justify-end gap-1.5">
404
438
  {confirmedCount > 0 && (
405
- <Badge variant="warning" className="text-[10px] h-5 px-1.5 gap-1">
406
- <AlertTriangle className="h-2.5 w-2.5" />
439
+ <span
440
+ className={cn(
441
+ "inline-flex items-center gap-1.5 rounded-full px-2.5 py-1 text-xs font-medium",
442
+ confirmedStyles.pill,
443
+ )}
444
+ >
445
+ <span
446
+ className={cn("size-1.5 rounded-full", confirmedStyles.dot)}
447
+ aria-hidden
448
+ />
449
+ <AlertTriangle className="h-3 w-3" />
407
450
  {confirmedCount}
408
- </Badge>
451
+ </span>
409
452
  )}
410
453
  {suspiciousCount > 0 && (
411
- <Badge
412
- variant="outline"
413
- className="text-[10px] h-5 px-1.5 gap-1 border-warning/40 text-warning"
454
+ <span
455
+ className={cn(
456
+ "inline-flex items-center gap-1.5 rounded-full px-2.5 py-1 text-xs font-medium",
457
+ suspiciousStyles.pill,
458
+ )}
414
459
  >
415
- <HelpCircle className="h-2.5 w-2.5" />
460
+ <span
461
+ className={cn("size-1.5 rounded-full", suspiciousStyles.dot)}
462
+ aria-hidden
463
+ />
464
+ <HelpCircle className="h-3 w-3" />
416
465
  {suspiciousCount}
417
- </Badge>
466
+ </span>
418
467
  )}
419
468
  {canMute && (
420
469
  <Button
@@ -447,7 +496,7 @@ export const SystemAnomalyWidget: React.FC<Props> = ({ system }) => {
447
496
  </div>
448
497
  </CardHeader>
449
498
  <CardContent className="p-0">
450
- <div className="flex flex-col divide-y">
499
+ <div className="flex flex-col divide-y divide-border/60">
451
500
  {activeAnomalies.map((anomaly) => (
452
501
  <AnomalyRow
453
502
  key={anomaly.id}
@@ -0,0 +1,44 @@
1
+ import { describe, expect, it } from "bun:test";
2
+ import {
3
+ anomalyToneStyles,
4
+ toneForState,
5
+ widgetTone,
6
+ } from "./anomalyTone.logic";
7
+
8
+ describe("toneForState", () => {
9
+ it("maps suspicious to unknown", () => {
10
+ expect(toneForState("suspicious")).toBe("unknown");
11
+ });
12
+
13
+ it("maps confirmed (anomaly) to warn", () => {
14
+ expect(toneForState("anomaly")).toBe("warn");
15
+ expect(toneForState("confirmed")).toBe("warn");
16
+ });
17
+ });
18
+
19
+ describe("widgetTone", () => {
20
+ it("is warn when any confirmed anomaly is present", () => {
21
+ expect(widgetTone({ confirmedCount: 1 })).toBe("warn");
22
+ expect(widgetTone({ confirmedCount: 5 })).toBe("warn");
23
+ });
24
+
25
+ it("falls back to unknown when only suspicious rows exist", () => {
26
+ expect(widgetTone({ confirmedCount: 0 })).toBe("unknown");
27
+ });
28
+ });
29
+
30
+ describe("anomalyToneStyles", () => {
31
+ it("returns the warn triad classes", () => {
32
+ const styles = anomalyToneStyles("warn");
33
+ expect(styles.accent).toContain("bg-status-warn");
34
+ expect(styles.pill).toContain("text-status-warn");
35
+ expect(styles.dot).toBe("bg-status-warn");
36
+ });
37
+
38
+ it("returns the unknown triad classes", () => {
39
+ const styles = anomalyToneStyles("unknown");
40
+ expect(styles.accent).toContain("bg-status-unknown");
41
+ expect(styles.pill).toContain("text-status-unknown");
42
+ expect(styles.dot).toBe("bg-status-unknown");
43
+ });
44
+ });
@@ -0,0 +1,51 @@
1
+ /**
2
+ * Maps anomaly states to the colorblind-safe status triad used across the
3
+ * design system. Confirmed anomalies read as `warn`; suspicious (not yet
4
+ * confirmed) ones read as `unknown`. Spelled out as full literal class strings
5
+ * (not interpolated) so Tailwind's JIT keeps them.
6
+ */
7
+ export type AnomalyTone = "warn" | "unknown";
8
+
9
+ export interface AnomalyToneStyles {
10
+ pill: string;
11
+ dot: string;
12
+ accent: string;
13
+ text: string;
14
+ }
15
+
16
+ const TONE_STYLES: Record<AnomalyTone, AnomalyToneStyles> = {
17
+ warn: {
18
+ pill: "bg-status-warn/10 text-status-warn",
19
+ dot: "bg-status-warn",
20
+ accent: "bg-status-warn",
21
+ text: "text-status-warn",
22
+ },
23
+ unknown: {
24
+ pill: "bg-status-unknown/10 text-status-unknown",
25
+ dot: "bg-status-unknown",
26
+ accent: "bg-status-unknown",
27
+ text: "text-status-unknown",
28
+ },
29
+ };
30
+
31
+ /** The tone for a single anomaly state string. */
32
+ export function toneForState(state: string): AnomalyTone {
33
+ return state === "suspicious" ? "unknown" : "warn";
34
+ }
35
+
36
+ /** Resolve the full class-set for a tone. */
37
+ export function anomalyToneStyles(tone: AnomalyTone): AnomalyToneStyles {
38
+ return TONE_STYLES[tone];
39
+ }
40
+
41
+ /**
42
+ * The accent stripe / overall card tone for the widget: `warn` when any
43
+ * confirmed anomaly is present, otherwise `unknown` (only suspicious rows).
44
+ */
45
+ export function widgetTone({
46
+ confirmedCount,
47
+ }: {
48
+ confirmedCount: number;
49
+ }): AnomalyTone {
50
+ return confirmedCount > 0 ? "warn" : "unknown";
51
+ }