@actuate-media/cms-admin 0.16.1 → 0.17.0

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.
@@ -6,6 +6,34 @@ export interface UseApiDataOptions {
6
6
  maxRetries?: number
7
7
  /** Base delay in ms for exponential backoff (default 1000). Actual delay = base * 2^attempt. */
8
8
  retryBaseMs?: number
9
+ /**
10
+ * When set, successful responses are cached in-memory keyed by endpoint
11
+ * for this many ms and reused on the next mount, skipping the network
12
+ * (cache-first). Opt-in — omit to keep the default always-fetch
13
+ * behaviour. `refetch()` always bypasses the cache. Useful for views
14
+ * that remount frequently (e.g. dashboard navigation) so they don't
15
+ * re-issue the same requests on every visit.
16
+ */
17
+ cacheMs?: number
18
+ }
19
+
20
+ /**
21
+ * Process-wide response cache for `cacheMs` consumers. Keyed by endpoint
22
+ * (note: the locale query param is appended downstream by `cmsApi`, so a
23
+ * locale switch is not reflected here — only enable `cacheMs` for
24
+ * locale-invariant endpoints like `/stats`).
25
+ */
26
+ const responseCache = new Map<string, { data: unknown; at: number }>()
27
+
28
+ function readCache<T>(endpoint: string, cacheMs: number): { value: T | null } | null {
29
+ const hit = responseCache.get(endpoint)
30
+ if (hit && Date.now() - hit.at < cacheMs) return { value: hit.data as T | null }
31
+ return null
32
+ }
33
+
34
+ /** Test-only: drop all cached responses so cases don't bleed into each other. */
35
+ export function __clearApiDataCache(): void {
36
+ responseCache.clear()
9
37
  }
10
38
 
11
39
  export interface UseApiDataResult<T> {
@@ -22,9 +50,12 @@ export function useApiData<T>(
22
50
  defaultValue: T | null = null,
23
51
  options: UseApiDataOptions = {},
24
52
  ): UseApiDataResult<T> {
25
- const { maxRetries = 3, retryBaseMs = 1000 } = options
26
- const [data, setData] = useState<T | null>(defaultValue)
27
- const [loading, setLoading] = useState(endpoint !== null)
53
+ const { maxRetries = 3, retryBaseMs = 1000, cacheMs } = options
54
+ // Read the cache synchronously for the initial state so a warm cache
55
+ // hit paints immediately instead of flashing a loading spinner.
56
+ const cachedInitial = endpoint && cacheMs ? readCache<T>(endpoint, cacheMs) : null
57
+ const [data, setData] = useState<T | null>(cachedInitial ? cachedInitial.value : defaultValue)
58
+ const [loading, setLoading] = useState(endpoint !== null && !cachedInitial)
28
59
  const [error, setError] = useState<string | null>(null)
29
60
  const [exhausted, setExhausted] = useState(false)
30
61
  const abortRef = useRef<AbortController | null>(null)
@@ -32,12 +63,24 @@ export function useApiData<T>(
32
63
  const retryTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
33
64
 
34
65
  const fetchData = useCallback(
35
- async (isRetry = false) => {
66
+ async (isRetry = false, forceRefresh = false) => {
36
67
  if (!endpoint) {
37
68
  setLoading(false)
38
69
  return
39
70
  }
40
71
 
72
+ // Cache-first: a fresh cached response short-circuits the network.
73
+ // `refetch()` passes `forceRefresh` to bypass this.
74
+ if (!isRetry && !forceRefresh && cacheMs) {
75
+ const hit = readCache<T>(endpoint, cacheMs)
76
+ if (hit) {
77
+ setData(hit.value)
78
+ setError(null)
79
+ setLoading(false)
80
+ return
81
+ }
82
+ }
83
+
41
84
  abortRef.current?.abort()
42
85
  const controller = new AbortController()
43
86
  abortRef.current = controller
@@ -65,12 +108,14 @@ export function useApiData<T>(
65
108
  setExhausted(true)
66
109
  setLoading(false)
67
110
  } else {
68
- setData(res.data ?? null)
111
+ const value = res.data ?? null
112
+ setData(value)
69
113
  setLoading(false)
70
114
  retryCountRef.current = 0
115
+ if (cacheMs) responseCache.set(endpoint, { data: value, at: Date.now() })
71
116
  }
72
117
  },
73
- [endpoint, maxRetries, retryBaseMs],
118
+ [endpoint, maxRetries, retryBaseMs, cacheMs],
74
119
  )
75
120
 
76
121
  const manualRefetch = useCallback(() => {
@@ -80,7 +125,7 @@ export function useApiData<T>(
80
125
  }
81
126
  retryCountRef.current = 0
82
127
  setExhausted(false)
83
- fetchData(false)
128
+ fetchData(false, true)
84
129
  }, [fetchData])
85
130
 
86
131
  useEffect(() => {
@@ -18,6 +18,27 @@
18
18
  --accent-foreground: #030213;
19
19
  --destructive: #d4183d;
20
20
  --destructive-foreground: #ffffff;
21
+ /*
22
+ * Semantic status + brand tokens.
23
+ * ----------------------------------------------------------------
24
+ * `destructive` already covers the "error/danger" state; these add
25
+ * the remaining semantic states the admin needs (success / warning /
26
+ * info) plus the violet brand accent used for links and primary
27
+ * affordances. Values are tuned to read as TEXT on both the card
28
+ * surface and their own `/10` tint (badge backgrounds), and as a
29
+ * solid fill (dots, progress bars). The `*-foreground` variants are
30
+ * for text placed on a SOLID fill. Light values match the previous
31
+ * emerald/amber/blue/violet-600 palette so tokenizing is a no-op
32
+ * visually; dark values track the *-300/*-400 tints used before.
33
+ */
34
+ --success: oklch(0.5 0.13 155);
35
+ --success-foreground: #ffffff;
36
+ --warning: oklch(0.52 0.13 73);
37
+ --warning-foreground: oklch(0.205 0 0);
38
+ --info: oklch(0.5 0.17 254);
39
+ --info-foreground: #ffffff;
40
+ --brand: oklch(0.54 0.23 293);
41
+ --brand-foreground: #ffffff;
21
42
  --border: rgba(0, 0, 0, 0.1);
22
43
  --input: transparent;
23
44
  --input-background: #f3f3f5;
@@ -59,6 +80,14 @@
59
80
  --accent-foreground: oklch(0.985 0 0);
60
81
  --destructive: oklch(0.396 0.141 25.723);
61
82
  --destructive-foreground: oklch(0.637 0.237 25.331);
83
+ --success: oklch(0.72 0.15 155);
84
+ --success-foreground: oklch(0.205 0 0);
85
+ --warning: oklch(0.8 0.13 80);
86
+ --warning-foreground: oklch(0.205 0 0);
87
+ --info: oklch(0.72 0.14 252);
88
+ --info-foreground: oklch(0.205 0 0);
89
+ --brand: oklch(0.72 0.18 293);
90
+ --brand-foreground: oklch(0.205 0 0);
62
91
  --border: oklch(0.269 0 0);
63
92
  --input: oklch(0.269 0 0);
64
93
  --input-background: oklch(0.205 0 0);
@@ -98,6 +127,14 @@
98
127
  --color-accent-foreground: var(--accent-foreground);
99
128
  --color-destructive: var(--destructive);
100
129
  --color-destructive-foreground: var(--destructive-foreground);
130
+ --color-success: var(--success);
131
+ --color-success-foreground: var(--success-foreground);
132
+ --color-warning: var(--warning);
133
+ --color-warning-foreground: var(--warning-foreground);
134
+ --color-info: var(--info);
135
+ --color-info-foreground: var(--info-foreground);
136
+ --color-brand: var(--brand);
137
+ --color-brand-foreground: var(--brand-foreground);
101
138
  --color-border: var(--border);
102
139
  --color-input: var(--input);
103
140
  --color-input-background: var(--input-background);
@@ -217,33 +217,32 @@ function authorAvatar(name: string | null | undefined): { color: string; initial
217
217
  return { color, initials }
218
218
  }
219
219
 
220
+ /**
221
+ * Semantic state styling uses theme tokens (`success` / `warning` / `info`
222
+ * / `brand`) so the dashboard tracks the design system in both light and
223
+ * dark mode. Two deliberate exceptions remain as raw values:
224
+ * - Error states keep `text-red-*` / `bg-red-*`. The `--destructive`
225
+ * token's dark value is intentionally dark (low contrast as TEXT on a
226
+ * dark card), so tokenizing error text here would regress contrast.
227
+ * Promoting error to a token is a separate, admin-wide change.
228
+ * - `AVATAR_COLORS` are dynamic per-author hashes (allowed: truly
229
+ * dynamic values, applied via inline style).
230
+ */
220
231
  function statusBadge(status: string): {
221
232
  label: string
222
233
  cls: string
223
234
  } {
224
235
  switch (status) {
225
236
  case 'PUBLISHED':
226
- return {
227
- label: 'Published',
228
- cls: 'bg-emerald-100 text-emerald-800 dark:bg-emerald-950/60 dark:text-emerald-300',
229
- }
237
+ return { label: 'Published', cls: 'bg-success/10 text-success' }
230
238
  case 'DRAFT':
231
- return {
232
- label: 'Draft',
233
- cls: 'bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-300',
234
- }
239
+ return { label: 'Draft', cls: 'bg-muted text-muted-foreground' }
235
240
  case 'SCHEDULED':
236
- return {
237
- label: 'Scheduled',
238
- cls: 'bg-amber-100 text-amber-800 dark:bg-amber-950/60 dark:text-amber-300',
239
- }
241
+ return { label: 'Scheduled', cls: 'bg-warning/10 text-warning' }
240
242
  case 'IN_REVIEW':
241
- return {
242
- label: 'In Review',
243
- cls: 'bg-blue-100 text-blue-800 dark:bg-blue-950/60 dark:text-blue-300',
244
- }
243
+ return { label: 'In Review', cls: 'bg-info/10 text-info' }
245
244
  default:
246
- return { label: status, cls: 'bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-300' }
245
+ return { label: status, cls: 'bg-muted text-muted-foreground' }
247
246
  }
248
247
  }
249
248
 
@@ -263,12 +262,31 @@ interface StatCardData {
263
262
 
264
263
  export function Dashboard({ config, session, onNavigate }: DashboardProps) {
265
264
  const nav = (path: string) => onNavigate?.(path)
266
- const { data: stats, loading, error, exhausted, refetch } = useApiData<DashboardStats>('/stats')
267
- const { data: health } = useApiData<HealthData>('/health')
268
- const { data: delivery, loading: deliveryLoading } =
269
- useApiData<DeliveryStats>('/stats/api-delivery')
270
- const { data: contentHealthData, loading: contentHealthLoading } =
271
- useApiData<ContentHealthData>('/stats/content-health')
265
+ // Cache responses for 30s so navigating away and back doesn't re-fire
266
+ // all four dashboard requests on every visit. First load is unaffected
267
+ // (cold cache) and each request still streams independently; `refetch`
268
+ // (the Retry button) bypasses the cache.
269
+ const DASH_CACHE_MS = 30_000
270
+ const {
271
+ data: stats,
272
+ loading,
273
+ error,
274
+ exhausted,
275
+ refetch,
276
+ } = useApiData<DashboardStats>('/stats', null, {
277
+ cacheMs: DASH_CACHE_MS,
278
+ })
279
+ const { data: health } = useApiData<HealthData>('/health', null, { cacheMs: DASH_CACHE_MS })
280
+ const { data: delivery, loading: deliveryLoading } = useApiData<DeliveryStats>(
281
+ '/stats/api-delivery',
282
+ null,
283
+ { cacheMs: DASH_CACHE_MS },
284
+ )
285
+ const { data: contentHealthData, loading: contentHealthLoading } = useApiData<ContentHealthData>(
286
+ '/stats/content-health',
287
+ null,
288
+ { cacheMs: DASH_CACHE_MS },
289
+ )
272
290
 
273
291
  // A slow ticker (60s) so the greeting, calendar date, and "x minutes
274
292
  // ago" relative times stay correct on a dashboard left open for hours
@@ -311,8 +329,8 @@ export function Dashboard({ config, session, onNavigate }: DashboardProps) {
311
329
  label: collectionLabel(primary[0]),
312
330
  value: String(counts[primary[0].slug] ?? 0),
313
331
  icon: primary[0].type === 'page' ? FileIcon : FileText,
314
- iconBg: 'bg-violet-100 dark:bg-violet-950/60',
315
- iconColor: 'text-violet-600 dark:text-violet-300',
332
+ iconBg: 'bg-chart-1/15',
333
+ iconColor: 'text-chart-1',
316
334
  href: `/${primary[0].slug}`,
317
335
  })
318
336
  } else {
@@ -320,8 +338,8 @@ export function Dashboard({ config, session, onNavigate }: DashboardProps) {
320
338
  label: 'Documents',
321
339
  value: String(stats?.totalDocuments ?? 0),
322
340
  icon: FileText,
323
- iconBg: 'bg-violet-100 dark:bg-violet-950/60',
324
- iconColor: 'text-violet-600 dark:text-violet-300',
341
+ iconBg: 'bg-chart-1/15',
342
+ iconColor: 'text-chart-1',
325
343
  })
326
344
  }
327
345
 
@@ -330,8 +348,8 @@ export function Dashboard({ config, session, onNavigate }: DashboardProps) {
330
348
  label: collectionLabel(primary[1]),
331
349
  value: String(counts[primary[1].slug] ?? 0),
332
350
  icon: primary[1].type === 'page' ? FileIcon : FileText,
333
- iconBg: 'bg-cyan-100 dark:bg-cyan-950/60',
334
- iconColor: 'text-cyan-600 dark:text-cyan-300',
351
+ iconBg: 'bg-chart-2/15',
352
+ iconColor: 'text-chart-2',
335
353
  href: `/${primary[1].slug}`,
336
354
  })
337
355
  }
@@ -344,8 +362,8 @@ export function Dashboard({ config, session, onNavigate }: DashboardProps) {
344
362
  ? `${stats.totalMedia} file${stats.totalMedia === 1 ? '' : 's'}`
345
363
  : undefined,
346
364
  icon: ImageIcon,
347
- iconBg: 'bg-emerald-100 dark:bg-emerald-950/60',
348
- iconColor: 'text-emerald-600 dark:text-emerald-300',
365
+ iconBg: 'bg-chart-3/15',
366
+ iconColor: 'text-chart-3',
349
367
  href: '/media',
350
368
  })
351
369
 
@@ -358,8 +376,8 @@ export function Dashboard({ config, session, onNavigate }: DashboardProps) {
358
376
  value: String(formsCount),
359
377
  hint: submissions > 0 ? `${submissions} response${submissions === 1 ? '' : 's'}` : undefined,
360
378
  icon: ClipboardList,
361
- iconBg: 'bg-amber-100 dark:bg-amber-950/60',
362
- iconColor: 'text-amber-600 dark:text-amber-300',
379
+ iconBg: 'bg-chart-4/15',
380
+ iconColor: 'text-chart-4',
363
381
  href: '/forms',
364
382
  })
365
383
 
@@ -371,8 +389,8 @@ export function Dashboard({ config, session, onNavigate }: DashboardProps) {
371
389
  hint: seo > 0 ? (seo >= 70 ? 'Good' : seo >= 40 ? 'Fair' : 'Needs work') : 'No content yet',
372
390
  hintUp: seo >= 70,
373
391
  icon: Search,
374
- iconBg: 'bg-indigo-100 dark:bg-indigo-950/60',
375
- iconColor: 'text-indigo-600 dark:text-indigo-300',
392
+ iconBg: 'bg-chart-5/15',
393
+ iconColor: 'text-chart-5',
376
394
  href: '/seo',
377
395
  })
378
396
 
@@ -517,7 +535,7 @@ export function Dashboard({ config, session, onNavigate }: DashboardProps) {
517
535
  if (loading && !stats) {
518
536
  return (
519
537
  <div className="flex h-64 items-center justify-center p-4 sm:p-6">
520
- <Loader2 className="h-6 w-6 animate-spin text-violet-600" />
538
+ <Loader2 className="text-brand h-6 w-6 animate-spin" />
521
539
  </div>
522
540
  )
523
541
  }
@@ -526,13 +544,11 @@ export function Dashboard({ config, session, onNavigate }: DashboardProps) {
526
544
  <div className="w-full space-y-5 p-4 sm:p-6 lg:px-8">
527
545
  {/* Health banners ────────────────────────────────────────────── */}
528
546
  {health && health.status === 'degraded' && (
529
- <div className="flex items-start gap-3 rounded-lg border border-blue-200 bg-blue-50 p-3 dark:border-blue-900 dark:bg-blue-950/40">
530
- <Database className="mt-0.5 h-5 w-5 shrink-0 text-blue-600 dark:text-blue-400" />
547
+ <div className="border-info/30 bg-info/10 flex items-start gap-3 rounded-lg border p-3">
548
+ <Database className="text-info mt-0.5 h-5 w-5 shrink-0" />
531
549
  <div className="min-w-0 flex-1">
532
- <span className="text-sm font-medium text-blue-900 dark:text-blue-200">
533
- Database setup required
534
- </span>
535
- <p className="mt-0.5 text-xs text-blue-700 dark:text-blue-300">
550
+ <span className="text-info text-sm font-medium">Database setup required</span>
551
+ <p className="text-muted-foreground mt-0.5 text-xs">
536
552
  {!health.databaseConnected
537
553
  ? 'Cannot connect to the database. Check your DATABASE_URL environment variable.'
538
554
  : !health.secretConfigured
@@ -547,14 +563,14 @@ export function Dashboard({ config, session, onNavigate }: DashboardProps) {
547
563
  )}
548
564
 
549
565
  {error && exhausted && (
550
- <div className="flex items-center gap-3 rounded-lg border border-amber-200 bg-amber-50 p-3 dark:border-amber-900 dark:bg-amber-950/40">
551
- <AlertTriangle className="h-5 w-5 shrink-0 text-amber-600 dark:text-amber-400" />
552
- <span className="flex-1 text-sm text-amber-800 dark:text-amber-200">
566
+ <div className="border-warning/30 bg-warning/10 flex items-center gap-3 rounded-lg border p-3">
567
+ <AlertTriangle className="text-warning h-5 w-5 shrink-0" />
568
+ <span className="text-warning flex-1 text-sm">
553
569
  Some dashboard data may be unavailable.
554
570
  </span>
555
571
  <button
556
572
  onClick={refetch}
557
- className="rounded-lg border border-amber-300 px-3 py-1 text-sm text-amber-700 transition-colors hover:bg-amber-100 dark:border-amber-700 dark:text-amber-200 dark:hover:bg-amber-900/40"
573
+ className="border-warning/40 text-warning hover:bg-warning/10 rounded-lg border px-3 py-1 text-sm transition-colors"
558
574
  >
559
575
  Retry
560
576
  </button>
@@ -588,7 +604,7 @@ export function Dashboard({ config, session, onNavigate }: DashboardProps) {
588
604
  <button
589
605
  key={a.label}
590
606
  onClick={a.onClick}
591
- className="border-border bg-card inline-flex shrink-0 items-center gap-1.5 rounded-lg border px-3.5 py-2 text-sm shadow-sm transition-colors hover:border-violet-400 hover:bg-violet-50 hover:text-violet-700 dark:hover:bg-violet-950/40 dark:hover:text-violet-300"
607
+ className="border-border bg-card hover:border-brand/50 hover:bg-brand/10 hover:text-brand inline-flex shrink-0 items-center gap-1.5 rounded-lg border px-3.5 py-2 text-sm shadow-sm transition-colors"
592
608
  >
593
609
  <a.icon className="h-3.5 w-3.5" />
594
610
  {a.label}
@@ -617,7 +633,7 @@ export function Dashboard({ config, session, onNavigate }: DashboardProps) {
617
633
  <p className="text-muted-foreground mt-1 text-xs">{card.label}</p>
618
634
  {card.hint && (
619
635
  <p
620
- className={`mt-1.5 text-[11px] ${card.hintUp ? 'text-emerald-600 dark:text-emerald-400' : 'text-muted-foreground'}`}
636
+ className={`mt-1.5 text-[11px] ${card.hintUp ? 'text-success' : 'text-muted-foreground'}`}
621
637
  >
622
638
  {card.hint}
623
639
  </p>
@@ -653,7 +669,7 @@ export function Dashboard({ config, session, onNavigate }: DashboardProps) {
653
669
  </div>
654
670
  {(stats?.recentDocuments?.length ?? 0) > 8 && (
655
671
  <button
656
- className="text-xs font-medium text-violet-600 hover:underline dark:text-violet-400"
672
+ className="text-brand text-xs font-medium hover:underline"
657
673
  onClick={() => setActivityLimit((n) => (n >= 20 ? 8 : 20))}
658
674
  >
659
675
  {activityLimit >= 20 ? 'Show less' : 'Show more'}
@@ -721,7 +737,7 @@ export function Dashboard({ config, session, onNavigate }: DashboardProps) {
721
737
  </p>
722
738
  </div>
723
739
  <button
724
- className="text-xs font-medium text-violet-600 hover:underline disabled:cursor-default disabled:no-underline disabled:opacity-50 dark:text-violet-400"
740
+ className="text-brand text-xs font-medium hover:underline disabled:cursor-default disabled:no-underline disabled:opacity-50"
725
741
  disabled={publishQueue.length === 0}
726
742
  onClick={() => {
727
743
  // We don't have a dedicated "scheduled" admin page, so deep-
@@ -750,7 +766,7 @@ export function Dashboard({ config, session, onNavigate }: DashboardProps) {
750
766
  onClick={() => nav(q.editPath)}
751
767
  className="hover:bg-accent/50 focus-visible:bg-accent/50 flex w-full min-w-0 cursor-pointer items-center gap-2.5 px-4 py-2.5 text-left transition-colors"
752
768
  >
753
- <span className="h-2 w-2 shrink-0 rounded-full bg-amber-500" aria-hidden />
769
+ <span className="bg-warning h-2 w-2 shrink-0 rounded-full" aria-hidden />
754
770
  <div className="min-w-0 flex-1">
755
771
  <p className="text-foreground truncate text-sm font-medium">{q.title}</p>
756
772
  <p className="text-muted-foreground truncate text-[11px]">
@@ -775,7 +791,7 @@ export function Dashboard({ config, session, onNavigate }: DashboardProps) {
775
791
  <p className="text-muted-foreground mt-0.5 text-xs">SEO &amp; quality issues</p>
776
792
  </div>
777
793
  <button
778
- className="text-xs font-medium text-violet-600 hover:underline dark:text-violet-400"
794
+ className="text-brand text-xs font-medium hover:underline"
779
795
  onClick={() => nav('/seo')}
780
796
  >
781
797
  Fix issues
@@ -792,9 +808,9 @@ export function Dashboard({ config, session, onNavigate }: DashboardProps) {
792
808
  <span
793
809
  className={`text-xs font-medium ${
794
810
  contentHealth.tone === 'ok'
795
- ? 'text-emerald-600 dark:text-emerald-400'
811
+ ? 'text-success'
796
812
  : contentHealth.tone === 'warn'
797
- ? 'text-amber-600 dark:text-amber-400'
813
+ ? 'text-warning'
798
814
  : contentHealth.tone === 'err'
799
815
  ? 'text-red-600 dark:text-red-400'
800
816
  : 'text-muted-foreground'
@@ -807,9 +823,9 @@ export function Dashboard({ config, session, onNavigate }: DashboardProps) {
807
823
  <div
808
824
  className={`h-full rounded-full transition-all ${
809
825
  contentHealth.tone === 'ok'
810
- ? 'bg-emerald-500'
826
+ ? 'bg-success'
811
827
  : contentHealth.tone === 'warn'
812
- ? 'bg-amber-500'
828
+ ? 'bg-warning'
813
829
  : contentHealth.tone === 'err'
814
830
  ? 'bg-red-500'
815
831
  : 'bg-muted-foreground/40'
@@ -836,7 +852,7 @@ export function Dashboard({ config, session, onNavigate }: DashboardProps) {
836
852
  iss.tone === 'err'
837
853
  ? 'bg-red-500'
838
854
  : iss.tone === 'warn'
839
- ? 'bg-amber-500'
855
+ ? 'bg-warning'
840
856
  : 'bg-muted-foreground'
841
857
  }`}
842
858
  aria-hidden
@@ -849,7 +865,7 @@ export function Dashboard({ config, session, onNavigate }: DashboardProps) {
849
865
  iss.tone === 'err'
850
866
  ? 'text-red-600 dark:text-red-400'
851
867
  : iss.tone === 'warn'
852
- ? 'text-amber-600 dark:text-amber-400'
868
+ ? 'text-warning'
853
869
  : 'text-muted-foreground'
854
870
  }`}
855
871
  >
@@ -882,7 +898,7 @@ export function Dashboard({ config, session, onNavigate }: DashboardProps) {
882
898
  </p>
883
899
  </div>
884
900
  <button
885
- className="inline-flex items-center gap-1 text-xs font-medium text-violet-600 hover:underline dark:text-violet-400"
901
+ className="text-brand inline-flex items-center gap-1 text-xs font-medium hover:underline"
886
902
  onClick={() => nav('/api-keys')}
887
903
  >
888
904
  API docs <ExternalLink className="h-3 w-3" />
@@ -1037,9 +1053,9 @@ function DeliveryTile({
1037
1053
  }) {
1038
1054
  const subTone =
1039
1055
  tone === 'ok'
1040
- ? 'text-emerald-600 dark:text-emerald-400'
1056
+ ? 'text-success'
1041
1057
  : tone === 'warn'
1042
- ? 'text-amber-600 dark:text-amber-400'
1058
+ ? 'text-warning'
1043
1059
  : tone === 'err'
1044
1060
  ? 'text-red-600 dark:text-red-400'
1045
1061
  : 'text-muted-foreground'