Skip to content

Commit 767d88b

Browse files
feat(ui): add dark mode support for server dashboard (#450)
* feat(ui): add dark mode support with slate palette - Add Tailwind CSS 4 dark variant (class-based via .dark toggle) - Add system preference detection + localStorage persistence - Add 🌙/☀️ toggle button in nav bar - Apply dark: variants to all 14 components/pages - Configure Recharts theme for dark mode (axis stroke, tooltip style) - Use slate palette: slate-950 bg, slate-900 cards, slate-800 borders Lint, typecheck, build, and all 11 tests pass. * fix: dark mode a11y and visual polish - Fix invisible checkmark in All Caught Up (add text-green-600/dark:text-green-400) - Fix login page missing dark mode (add ThemeInitializer to App.tsx) - Replace unicode toggle with animated switch + icon labels * fix: address CodeRabbit review — deduplicate, simplify init, fix Recharts colors - Extract confidenceColor to shared utils.ts, remove duplicate from KnowledgeUnitModal and ReviewCard - Remove first useEffect in Layout; use lazy useState initializer for dark mode - Make Recharts colors (axis, tooltip, legend) respond to current theme * fix: address PR review feedback — useLayoutEffect, localStorage guard, a11y, cleanup * fix: eliminate race condition — ThemeInitializer handles init, Layout handles toggle only * fix(ReviewCard): add missing dark-mode text variant to neutral action-badge style * fix(Layout): guard localStorage.setItem in toggle handler against blocked-storage contexts * refactor(DashboardPage): extract useIsDark to shared hook under hooks/ * style(frontend): apply Biome formatting to satisfy CI lint Biome reformats these files (import ordering and JSX line wrapping); the committed versions were unformatted, which fails the CI lint check (scripts/lint-frontend.sh runs git diff --exit-code under CI). No behavior change. * fix(Layout): seed dark-mode toggle state from the applied theme Layout initialised its dark state to false regardless of the theme the ThemeInitializer had already applied from localStorage or the system preference. For users defaulting to dark, the toggle rendered the light state and the first click only re-applied the already-present class, so it appeared to do nothing. Seed the state from the document element's dark class so the control reflects the active theme on first paint. * feat(Layout): replace emoji toggle with icon button and add user menu Swap the emoji-and-pill dark-mode switch for a single Heroicons sun/moon icon button, and move the username and Logout into a dropdown menu so the top-right controls read as two clear elements rather than a loose cluster. The menu closes on outside click and Escape. Focus rings use focus-visible so they appear on keyboard navigation but not after a mouse click. --------- Co-authored-by: Peter Wilson <peter@mozilla.ai>
1 parent 14616c2 commit 767d88b

17 files changed

Lines changed: 456 additions & 215 deletions

server/frontend/src/App.tsx

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { useLayoutEffect } from "react"
12
import { BrowserRouter, Navigate, Route, Routes } from "react-router"
23
import { AuthProvider, useAuth } from "./auth"
34
import { Layout } from "./components/Layout"
@@ -33,11 +34,32 @@ function AppRoutes() {
3334
)
3435
}
3536

37+
function ThemeInitializer({ children }: { children: React.ReactNode }) {
38+
useLayoutEffect(() => {
39+
try {
40+
const stored = localStorage.getItem("cq-dark-mode")
41+
const dark =
42+
stored !== null
43+
? stored === "true"
44+
: window.matchMedia("(prefers-color-scheme: dark)").matches
45+
document.documentElement.classList.toggle("dark", dark)
46+
} catch {
47+
// localStorage blocked (privacy mode, embedded context)
48+
if (window.matchMedia("(prefers-color-scheme: dark)").matches) {
49+
document.documentElement.classList.add("dark")
50+
}
51+
}
52+
}, [])
53+
return <>{children}</>
54+
}
55+
3656
export default function App() {
3757
return (
3858
<BrowserRouter>
3959
<AuthProvider>
40-
<AppRoutes />
60+
<ThemeInitializer>
61+
<AppRoutes />
62+
</ThemeInitializer>
4163
</AuthProvider>
4264
</BrowserRouter>
4365
)
Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
export function ConfidenceBadge({ confidence }: { confidence: number }) {
22
return (
3-
<span className="text-sm text-gray-500">
3+
<span className="text-sm text-gray-500 dark:text-slate-400">
44
Confidence:{" "}
5-
<strong className="text-gray-800">{confidence.toFixed(2)}</strong>
5+
<strong className="text-gray-800 dark:text-slate-200">
6+
{confidence.toFixed(2)}
7+
</strong>
68
</span>
79
)
810
}

server/frontend/src/components/DomainTags.tsx

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
11
import type { Selection } from "../types"
22

33
const TAG_STYLES: Record<string, string> = {
4-
neutral: "bg-indigo-100 text-indigo-700",
5-
approve: "bg-green-100 text-green-700",
6-
reject: "bg-red-100 text-red-700",
7-
skip: "bg-slate-100 text-slate-700",
4+
neutral:
5+
"bg-indigo-100 dark:bg-indigo-500/20 text-indigo-700 dark:text-indigo-300",
6+
approve:
7+
"bg-green-100 dark:bg-green-500/20 text-green-700 dark:text-green-300",
8+
reject: "bg-red-100 dark:bg-red-500/20 text-red-700 dark:text-red-300",
9+
skip: "bg-slate-100 dark:bg-slate-700 text-slate-700 dark:text-slate-300",
810
}
911

1012
interface Props {

server/frontend/src/components/DragIndicators.tsx

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ export function DragIndicators({ drag }: Props) {
3838
>
3939
{INDICATORS.approve.symbol}
4040
</div>
41-
<span className="text-xs font-semibold text-green-600">
41+
<span className="text-xs font-semibold text-green-600 dark:text-green-400">
4242
{INDICATORS.approve.label}
4343
</span>
4444
</div>
@@ -56,7 +56,7 @@ export function DragIndicators({ drag }: Props) {
5656
>
5757
{INDICATORS.reject.symbol}
5858
</div>
59-
<span className="text-xs font-semibold text-red-600">
59+
<span className="text-xs font-semibold text-red-600 dark:text-red-400">
6060
{INDICATORS.reject.label}
6161
</span>
6262
</div>
@@ -74,7 +74,7 @@ export function DragIndicators({ drag }: Props) {
7474
>
7575
{INDICATORS.skip.symbol}
7676
</div>
77-
<span className="text-xs font-semibold text-slate-600">
77+
<span className="text-xs font-semibold text-slate-600 dark:text-slate-400">
7878
{INDICATORS.skip.label}
7979
</span>
8080
</div>
@@ -92,7 +92,7 @@ export function DragIndicators({ drag }: Props) {
9292
>
9393
{INDICATORS.skip.symbol}
9494
</div>
95-
<span className="text-xs font-semibold text-slate-600">
95+
<span className="text-xs font-semibold text-slate-600 dark:text-slate-400">
9696
{INDICATORS.skip.label}
9797
</span>
9898
</div>

server/frontend/src/components/FilteredListModal.tsx

Lines changed: 13 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -78,27 +78,27 @@ export function FilteredListModal({ filter, onClose, onSelectUnit }: Props) {
7878
tabIndex={-1}
7979
aria-hidden="true"
8080
onClick={onClose}
81-
className="absolute inset-0 bg-black/40 cursor-default"
81+
className="absolute inset-0 bg-black/40 dark:bg-black/60 cursor-default"
8282
/>
8383
<div
8484
ref={dialogRef}
8585
role="dialog"
8686
aria-modal="true"
8787
aria-labelledby={MODAL_TITLE_ID}
8888
tabIndex={-1}
89-
className="relative bg-white rounded-lg shadow-xl w-full max-w-2xl mx-4 max-h-[90vh] flex flex-col outline-none"
89+
className="relative bg-white dark:bg-slate-900 rounded-lg shadow-xl w-full max-w-2xl mx-4 max-h-[90vh] flex flex-col outline-none"
9090
>
91-
<div className="flex items-center justify-between p-4 border-b border-gray-200">
91+
<div className="flex items-center justify-between p-4 border-b border-gray-200 dark:border-slate-800">
9292
<h2
9393
id={MODAL_TITLE_ID}
94-
className="text-lg font-semibold text-gray-900"
94+
className="text-lg font-semibold text-gray-900 dark:text-slate-100"
9595
>
9696
{filter.title}
9797
</h2>
9898
<button
9999
type="button"
100100
onClick={onClose}
101-
className="text-gray-400 hover:text-gray-600 text-xl leading-none"
101+
className="text-gray-400 hover:text-gray-600 dark:text-slate-400 dark:hover:text-slate-200 text-xl leading-none"
102102
aria-label="Close"
103103
>
104104
&times;
@@ -107,22 +107,24 @@ export function FilteredListModal({ filter, onClose, onSelectUnit }: Props) {
107107

108108
<div className="flex-1 overflow-y-auto p-4">
109109
{error && (
110-
<p className="text-red-600 text-sm text-center py-4">{error}</p>
110+
<p className="text-red-600 dark:text-red-400 text-sm text-center py-4">
111+
{error}
112+
</p>
111113
)}
112114

113115
{!items && !error && (
114116
<div className="space-y-3">
115117
{[1, 2, 3].map((i) => (
116118
<div
117119
key={i}
118-
className="h-16 animate-pulse bg-gray-100 rounded-lg"
120+
className="h-16 animate-pulse bg-gray-100 dark:bg-slate-800 rounded-lg"
119121
/>
120122
))}
121123
</div>
122124
)}
123125

124126
{items && items.length === 0 && (
125-
<p className="text-gray-400 text-sm text-center py-8">
127+
<p className="text-gray-400 dark:text-slate-500 text-sm text-center py-8">
126128
No knowledge units found.
127129
</p>
128130
)}
@@ -133,18 +135,18 @@ export function FilteredListModal({ filter, onClose, onSelectUnit }: Props) {
133135
<button
134136
type="button"
135137
key={item.knowledge_unit.id}
136-
className="w-full text-left p-3 rounded-lg border border-gray-200 hover:border-indigo-300 hover:bg-indigo-50/50 transition-colors"
138+
className="w-full text-left p-3 rounded-lg border border-gray-200 dark:border-slate-800 hover:border-indigo-300 dark:hover:border-indigo-600 hover:bg-indigo-50/50 dark:hover:bg-indigo-950/30 transition-colors"
137139
onClick={() => onSelectUnit(item.knowledge_unit.id)}
138140
>
139141
<div className="flex items-center gap-2 mb-1">
140142
<StatusBadge status={item.status} />
141-
<span className="text-sm font-medium text-gray-900 truncate">
143+
<span className="text-sm font-medium text-gray-900 dark:text-slate-100 truncate">
142144
{item.knowledge_unit.insight.summary}
143145
</span>
144146
</div>
145147
<div className="flex items-center gap-3">
146148
<DomainTags domains={item.knowledge_unit.domains} />
147-
<span className="text-xs text-gray-400 ml-auto shrink-0">
149+
<span className="text-xs text-gray-400 dark:text-slate-500 ml-auto shrink-0">
148150
{confidenceLabel(item.knowledge_unit.evidence.confidence)}
149151
</span>
150152
</div>

server/frontend/src/components/KnowledgeUnitModal.tsx

Lines changed: 22 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { useEffect, useRef, useState } from "react"
22
import { ApiError, api } from "../api"
33
import type { ReviewItem } from "../types"
4-
import { timeAgo } from "../utils"
4+
import { confidenceColor, timeAgo } from "../utils"
55
import { DomainTags } from "./DomainTags"
66
import { StatusBadge } from "./StatusBadge"
77

@@ -10,13 +10,6 @@ interface Props {
1010
onClose: () => void
1111
}
1212

13-
function confidenceColor(c: number): string {
14-
if (c < 0.3) return "text-red-600"
15-
if (c < 0.5) return "text-amber-600"
16-
if (c < 0.7) return "text-yellow-500"
17-
return "text-green-600"
18-
}
19-
2013
const MODAL_TITLE_ID = "ku-modal-title"
2114

2215
export function KnowledgeUnitModal({ unitId, onClose }: Props) {
@@ -71,15 +64,15 @@ export function KnowledgeUnitModal({ unitId, onClose }: Props) {
7164
aria-modal="true"
7265
aria-labelledby={item ? MODAL_TITLE_ID : undefined}
7366
tabIndex={-1}
74-
className="relative bg-white rounded-lg shadow-xl w-full max-w-lg mx-4 max-h-[90vh] overflow-y-auto outline-none"
67+
className="relative bg-white dark:bg-slate-900 rounded-lg shadow-xl w-full max-w-lg mx-4 max-h-[90vh] overflow-y-auto outline-none"
7568
>
7669
{error && (
7770
<div className="p-6 text-center">
78-
<p className="text-red-600 text-sm">{error}</p>
71+
<p className="text-red-600 dark:text-red-400 text-sm">{error}</p>
7972
<button
8073
type="button"
8174
onClick={onClose}
82-
className="mt-3 text-sm text-gray-500 hover:text-gray-700"
75+
className="mt-3 text-sm text-gray-500 hover:text-gray-700 dark:text-slate-400 dark:hover:text-slate-200"
8376
>
8477
Close
8578
</button>
@@ -88,9 +81,9 @@ export function KnowledgeUnitModal({ unitId, onClose }: Props) {
8881

8982
{!item && !error && (
9083
<div className="p-6 space-y-3">
91-
<div className="h-4 w-32 animate-pulse bg-gray-200 rounded" />
92-
<div className="h-6 w-48 animate-pulse bg-gray-200 rounded" />
93-
<div className="h-16 w-full animate-pulse bg-gray-200 rounded" />
84+
<div className="h-4 w-32 animate-pulse bg-gray-200 dark:bg-slate-700 rounded" />
85+
<div className="h-6 w-48 animate-pulse bg-gray-200 dark:bg-slate-700 rounded" />
86+
<div className="h-16 w-full animate-pulse bg-gray-200 dark:bg-slate-700 rounded" />
9487
</div>
9588
)}
9689

@@ -99,14 +92,14 @@ export function KnowledgeUnitModal({ unitId, onClose }: Props) {
9992
<div className="flex items-start justify-between gap-3">
10093
<h2
10194
id={MODAL_TITLE_ID}
102-
className="text-lg font-semibold text-gray-900"
95+
className="text-lg font-semibold text-gray-900 dark:text-slate-100"
10396
>
10497
{item.knowledge_unit.insight.summary}
10598
</h2>
10699
<button
107100
type="button"
108101
onClick={onClose}
109-
className="text-gray-400 hover:text-gray-600 text-xl leading-none shrink-0"
102+
className="text-gray-400 hover:text-gray-600 dark:text-slate-400 dark:hover:text-slate-200 text-xl leading-none shrink-0"
110103
aria-label="Close"
111104
>
112105
&times;
@@ -116,35 +109,35 @@ export function KnowledgeUnitModal({ unitId, onClose }: Props) {
116109
<div className="flex items-center gap-2">
117110
<StatusBadge status={item.status} />
118111
{item.reviewed_by && (
119-
<span className="text-xs text-gray-500">
112+
<span className="text-xs text-gray-500 dark:text-slate-400">
120113
by {item.reviewed_by}
121114
</span>
122115
)}
123116
{item.reviewed_at && (
124-
<span className="text-xs text-gray-400">
117+
<span className="text-xs text-gray-400 dark:text-slate-500">
125118
{timeAgo(item.reviewed_at)}
126119
</span>
127120
)}
128121
</div>
129122

130123
<DomainTags domains={item.knowledge_unit.domains} />
131124

132-
<p className="text-gray-600 leading-relaxed">
125+
<p className="text-gray-600 dark:text-slate-300 leading-relaxed">
133126
{item.knowledge_unit.insight.detail}
134127
</p>
135128

136-
<div className="border-l-3 rounded-r-lg px-4 py-3 bg-indigo-50 border-indigo-500">
137-
<span className="text-xs font-semibold uppercase tracking-wide text-indigo-500">
129+
<div className="border-l-3 rounded-r-lg px-4 py-3 bg-indigo-50 dark:bg-indigo-950/40 border-indigo-500">
130+
<span className="text-xs font-semibold uppercase tracking-wide text-indigo-500 dark:text-indigo-400">
138131
Action
139132
</span>
140-
<p className="text-gray-800 text-sm mt-1">
133+
<p className="text-gray-800 dark:text-slate-200 text-sm mt-1">
141134
{item.knowledge_unit.insight.action}
142135
</p>
143136
</div>
144137

145138
<div className="grid grid-cols-2 gap-3 text-sm">
146-
<div className="bg-gray-50 rounded-lg p-3">
147-
<span className="text-xs text-gray-500 uppercase">
139+
<div className="bg-gray-50 dark:bg-slate-800 rounded-lg p-3">
140+
<span className="text-xs text-gray-500 dark:text-slate-400 uppercase">
148141
Confidence
149142
</span>
150143
<p
@@ -153,19 +146,19 @@ export function KnowledgeUnitModal({ unitId, onClose }: Props) {
153146
{item.knowledge_unit.evidence.confidence.toFixed(2)}
154147
</p>
155148
</div>
156-
<div className="bg-gray-50 rounded-lg p-3">
157-
<span className="text-xs text-gray-500 uppercase">
149+
<div className="bg-gray-50 dark:bg-slate-800 rounded-lg p-3">
150+
<span className="text-xs text-gray-500 dark:text-slate-400 uppercase">
158151
Confirmations
159152
</span>
160-
<p className="font-semibold text-gray-800">
153+
<p className="font-semibold text-gray-800 dark:text-slate-200">
161154
{item.knowledge_unit.evidence.confirmations}
162155
</p>
163156
</div>
164157
</div>
165158

166159
{(item.knowledge_unit.context.languages.length > 0 ||
167160
item.knowledge_unit.context.frameworks.length > 0) && (
168-
<div className="text-sm text-gray-500">
161+
<div className="text-sm text-gray-500 dark:text-slate-400">
169162
{item.knowledge_unit.context.languages.length > 0 && (
170163
<span>
171164
Languages:{" "}
@@ -185,7 +178,7 @@ export function KnowledgeUnitModal({ unitId, onClose }: Props) {
185178
</div>
186179
)}
187180

188-
<div className="flex items-center justify-between text-xs text-gray-400 pt-2 border-t border-gray-100">
181+
<div className="flex items-center justify-between text-xs text-gray-400 dark:text-slate-500 pt-2 border-t border-gray-100 dark:border-slate-800">
189182
<span className="font-mono">{item.knowledge_unit.id}</span>
190183
{item.knowledge_unit.evidence.first_observed && (
191184
<span>

0 commit comments

Comments
 (0)