Skip to content

Commit efeab56

Browse files
committed
Add web dark mode toggle
1 parent 7f8f157 commit efeab56

5 files changed

Lines changed: 262 additions & 13 deletions

File tree

src/CodexWidget.Web/wwwroot/css/app.css

Lines changed: 123 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,17 @@
1-
:root {
1+
:root,
2+
:root[data-theme="Light"] {
23
color-scheme: light;
3-
--page-bg: #eef6f6;
4+
--page-bg: #f7f9fc;
45
--widget-bg: #e9edf2;
5-
--widget-border: #d7e0ea;
6+
--widget-card-bg: rgba(255, 255, 255, 0.18);
7+
--widget-border: #d9e1eb;
8+
--header-bg: #e9edf2;
69
--text: #101820;
710
--muted: #4d5968;
11+
--muted-strong: #5f6e80;
812
--active: #087047;
913
--active-dot: #17a35b;
14+
--active-badge-bg: #f3fbf6;
1015
--badge-bg: #e8f7ef;
1116
--track: #6b7280;
1217
--fill-normal: #18a24a;
@@ -16,7 +21,42 @@
1621
--fill-red: #dc2626;
1722
--fill-unavailable: #8a94a3;
1823
--marker: #111111;
19-
--shadow: 0 8px 24px rgba(16, 24, 32, 0.12);
24+
--error: #b42318;
25+
--focus: #0b5caa;
26+
--switch-bg: #d8e0ea;
27+
--switch-thumb: #ffffff;
28+
--switch-border: #b7c2ce;
29+
--shadow: 0 1px 6px rgba(0, 0, 0, 0.07);
30+
}
31+
32+
:root[data-theme="Dark"] {
33+
color-scheme: dark;
34+
--page-bg: #11161d;
35+
--widget-bg: #151a20;
36+
--widget-card-bg: #1b222b;
37+
--widget-border: #2c3947;
38+
--header-bg: #151a20;
39+
--text: #e8eef6;
40+
--muted: #8793a3;
41+
--muted-strong: #b5c0ce;
42+
--active: #78e6a0;
43+
--active-dot: #35d07f;
44+
--active-badge-bg: #10271d;
45+
--badge-bg: #143225;
46+
--track: #344253;
47+
--fill-normal: #35d07f;
48+
--fill-blue: #60a5fa;
49+
--fill-pink: #f472b6;
50+
--fill-yellow: #facc15;
51+
--fill-red: #ff6b6b;
52+
--fill-unavailable: #536171;
53+
--marker: #e8eef6;
54+
--error: #ff9c8f;
55+
--focus: #83d5ff;
56+
--switch-bg: #202a35;
57+
--switch-thumb: #e8eef6;
58+
--switch-border: #455363;
59+
--shadow: 0 2px 12px rgba(0, 0, 0, 0.4);
2060
}
2161

2262
* {
@@ -32,20 +72,31 @@ body {
3272
line-height: 1.25;
3373
}
3474

75+
button {
76+
font: inherit;
77+
}
78+
3579
.skip-link {
3680
position: absolute;
3781
top: -4rem;
3882
left: 1rem;
3983
z-index: 10;
4084
padding: 0.55rem 0.75rem;
4185
border-radius: 6px;
42-
color: #ffffff;
43-
background: #12343b;
86+
color: var(--page-bg);
87+
background: var(--text);
4488
text-decoration: none;
4589
}
4690

4791
.skip-link:focus {
4892
top: 1rem;
93+
outline: 2px solid var(--focus);
94+
outline-offset: 2px;
95+
}
96+
97+
.theme-toggle:focus-visible {
98+
outline: 2px solid var(--focus);
99+
outline-offset: 2px;
49100
}
50101

51102
.widget-shell {
@@ -73,6 +124,13 @@ body {
73124
min-height: 60px;
74125
padding: 10px 18px;
75126
border-bottom: 1px solid var(--widget-border);
127+
background: var(--header-bg);
128+
}
129+
130+
.header-copy {
131+
display: grid;
132+
gap: 2px;
133+
min-width: 0;
76134
}
77135

78136
h1 {
@@ -90,12 +148,62 @@ h1 {
90148
margin: 0;
91149
color: var(--muted);
92150
font-size: 12px;
93-
text-align: right;
94151
white-space: nowrap;
95152
}
96153

97154
.status-line[data-tone="error"] {
98-
color: #8e281f;
155+
color: var(--error);
156+
}
157+
158+
.theme-toggle {
159+
display: inline-grid;
160+
grid-template-columns: auto auto;
161+
align-items: center;
162+
gap: 7px;
163+
min-height: 30px;
164+
padding: 4px 7px;
165+
border: 1px solid var(--switch-border);
166+
border-radius: 999px;
167+
color: var(--muted-strong);
168+
background: var(--widget-card-bg);
169+
cursor: pointer;
170+
}
171+
172+
.theme-toggle:hover {
173+
color: var(--text);
174+
border-color: var(--muted);
175+
}
176+
177+
.theme-toggle-track {
178+
position: relative;
179+
width: 30px;
180+
height: 16px;
181+
border: 1px solid var(--switch-border);
182+
border-radius: 999px;
183+
background: var(--switch-bg);
184+
}
185+
186+
.theme-toggle-thumb {
187+
position: absolute;
188+
top: 2px;
189+
left: 2px;
190+
width: 10px;
191+
height: 10px;
192+
border-radius: 50%;
193+
background: var(--switch-thumb);
194+
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.24);
195+
}
196+
197+
.theme-toggle[aria-checked="true"] .theme-toggle-thumb {
198+
transform: translateX(14px);
199+
}
200+
201+
.theme-toggle-label {
202+
min-width: 31px;
203+
font-size: 12px;
204+
font-weight: 700;
205+
line-height: 1;
206+
text-align: left;
99207
}
100208

101209
.status-board {
@@ -109,6 +217,7 @@ h1 {
109217
gap: 6px;
110218
padding: 8px 0;
111219
border-bottom: 1px solid var(--widget-border);
220+
background: var(--widget-card-bg);
112221
}
113222

114223
.profile-card:last-child {
@@ -144,7 +253,7 @@ h1 {
144253
color: var(--active);
145254
font-size: 14px;
146255
font-weight: 600;
147-
background: rgba(255, 255, 255, 0.18);
256+
background: var(--active-badge-bg);
148257
white-space: nowrap;
149258
}
150259

@@ -171,7 +280,7 @@ h1 {
171280

172281
.bucket-label {
173282
width: 20px;
174-
color: var(--muted);
283+
color: var(--muted-strong);
175284
font-size: 13px;
176285
font-weight: 600;
177286
line-height: 1;
@@ -205,6 +314,9 @@ h1 {
205314
position: relative;
206315
height: 14px;
207316
min-width: 84px;
317+
overflow: hidden;
318+
border: 1px solid var(--widget-border);
319+
border-radius: 2px;
208320
background: var(--track);
209321
}
210322

@@ -258,7 +370,7 @@ h1 {
258370

259371
.reset-time {
260372
overflow: hidden;
261-
color: var(--muted);
373+
color: var(--muted-strong);
262374
font-size: 15px;
263375
text-overflow: ellipsis;
264376
white-space: nowrap;

src/CodexWidget.Web/wwwroot/index.html

Lines changed: 27 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,23 @@
55
<meta name="viewport" content="width=device-width, initial-scale=1">
66
<title>Codex Usage</title>
77
<meta name="description" content="Compact Codex profile usage status.">
8+
<script>
9+
(() => {
10+
"use strict";
11+
12+
const storageKey = "codexWidget.webTheme";
13+
const lightTheme = "Light";
14+
const darkTheme = "Dark";
15+
16+
try {
17+
document.documentElement.dataset.theme = window.localStorage.getItem(storageKey) === darkTheme
18+
? darkTheme
19+
: lightTheme;
20+
} catch {
21+
document.documentElement.dataset.theme = lightTheme;
22+
}
23+
})();
24+
</script>
825
<link rel="stylesheet" href="/css/app.css">
926
<script src="/js/app.js" defer></script>
1027
</head>
@@ -14,8 +31,16 @@
1431
<main id="main-content" class="widget-shell" aria-busy="true">
1532
<section class="usage-widget" aria-labelledby="usage-title">
1633
<header class="widget-header">
17-
<h1 id="usage-title">Codex Usage</h1>
18-
<p id="status-line" class="status-line" role="status" aria-live="polite">Loading usage...</p>
34+
<div class="header-copy">
35+
<h1 id="usage-title">Codex Usage</h1>
36+
<p id="status-line" class="status-line" role="status" aria-live="polite">Loading usage...</p>
37+
</div>
38+
<button id="theme-toggle" class="theme-toggle" type="button" role="switch" aria-checked="false" aria-label="Switch to dark theme">
39+
<span class="theme-toggle-track" aria-hidden="true">
40+
<span class="theme-toggle-thumb"></span>
41+
</span>
42+
<span id="theme-toggle-label" class="theme-toggle-label">Light</span>
43+
</button>
1944
</header>
2045

2146
<div id="status-board" class="status-board" aria-live="polite">

src/CodexWidget.Web/wwwroot/js/app.js

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,9 @@
22
"use strict";
33

44
const presentationEndpoint = "/api/status/presentation";
5+
const themeStorageKey = "codexWidget.webTheme";
6+
const lightTheme = "Light";
7+
const darkTheme = "Dark";
58
const pollingIntervalMilliseconds = 15000;
69
const unavailablePercentText = "--";
710
const unavailableResetText = "-- --:--";
@@ -17,18 +20,73 @@
1720
pollingTimerId: null,
1821
teardownRequested: false,
1922
teardownController: new AbortController(),
23+
theme: lightTheme,
2024
};
2125

2226
const elements = {
2327
mainContent: document.getElementById("main-content"),
2428
statusBoard: document.getElementById("status-board"),
2529
statusLine: document.getElementById("status-line"),
30+
themeToggle: document.getElementById("theme-toggle"),
31+
themeToggleLabel: document.getElementById("theme-toggle-label"),
2632
liveAnnouncements: document.getElementById("live-announcements"),
2733
};
2834

35+
initializeTheme();
2936
wireLifecycleHandlers();
3037
void boot();
3138

39+
function initializeTheme() {
40+
state.theme = readStoredTheme();
41+
applyTheme(state.theme);
42+
persistTheme(state.theme);
43+
syncThemeToggle();
44+
45+
if (elements.themeToggle) {
46+
elements.themeToggle.addEventListener("click", toggleTheme);
47+
}
48+
}
49+
50+
function toggleTheme() {
51+
state.theme = state.theme === darkTheme ? lightTheme : darkTheme;
52+
applyTheme(state.theme);
53+
persistTheme(state.theme);
54+
syncThemeToggle();
55+
}
56+
57+
function readStoredTheme() {
58+
try {
59+
return window.localStorage.getItem(themeStorageKey) === darkTheme ? darkTheme : lightTheme;
60+
} catch {
61+
return lightTheme;
62+
}
63+
}
64+
65+
function persistTheme(theme) {
66+
try {
67+
window.localStorage.setItem(themeStorageKey, theme === darkTheme ? darkTheme : lightTheme);
68+
} catch {
69+
// Theme changes should still work for this page view when storage is blocked.
70+
}
71+
}
72+
73+
function applyTheme(theme) {
74+
document.documentElement.dataset.theme = theme === darkTheme ? darkTheme : lightTheme;
75+
}
76+
77+
function syncThemeToggle() {
78+
const isDark = state.theme === darkTheme;
79+
80+
if (elements.themeToggle) {
81+
elements.themeToggle.setAttribute("aria-checked", isDark ? "true" : "false");
82+
elements.themeToggle.setAttribute("aria-label", isDark ? "Switch to light theme" : "Switch to dark theme");
83+
}
84+
85+
if (elements.themeToggleLabel) {
86+
elements.themeToggleLabel.textContent = isDark ? darkTheme : lightTheme;
87+
}
88+
}
89+
3290
async function boot() {
3391
renderLoading();
3492
await refreshPresentation("startup");

tests/CodexWidget.Web.Tests/FrontendShellTests.cs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,8 @@ public async Task GetRoot_ReturnsStaticFrontendShell()
5858
Assert.Equal("text/html", response.Content.Headers.ContentType?.MediaType);
5959
Assert.Contains("<main id=\"main-content\"", payload, StringComparison.Ordinal);
6060
Assert.Contains("id=\"status-board\"", payload, StringComparison.Ordinal);
61+
Assert.Contains("id=\"theme-toggle\"", payload, StringComparison.Ordinal);
62+
Assert.Contains("role=\"switch\"", payload, StringComparison.Ordinal);
6163
Assert.Contains("Codex Usage", payload, StringComparison.Ordinal);
6264
Assert.Contains("id=\"live-announcements\"", payload, StringComparison.Ordinal);
6365
Assert.DoesNotContain("id=\"compact-overview\"", payload, StringComparison.Ordinal);

0 commit comments

Comments
 (0)