diff --git a/src/channels/web/server.rs b/src/channels/web/server.rs
index 27ef7cdce9..d15c44f451 100644
--- a/src/channels/web/server.rs
+++ b/src/channels/web/server.rs
@@ -319,6 +319,7 @@ pub async fn start_server(
.route("/", get(index_handler))
.route("/style.css", get(css_handler))
.route("/app.js", get(js_handler))
+ .route("/theme-init.js", get(theme_init_handler))
.route("/favicon.ico", get(favicon_handler))
.route("/i18n/index.js", get(i18n_index_handler))
.route("/i18n/en.js", get(i18n_en_handler))
@@ -440,6 +441,16 @@ async fn js_handler() -> impl IntoResponse {
)
}
+async fn theme_init_handler() -> impl IntoResponse {
+ (
+ [
+ (header::CONTENT_TYPE, "application/javascript"),
+ (header::CACHE_CONTROL, "no-cache"),
+ ],
+ include_str!("static/theme-init.js"),
+ )
+}
+
async fn favicon_handler() -> impl IntoResponse {
(
[
diff --git a/src/channels/web/static/app.js b/src/channels/web/static/app.js
index 9d931500cd..1fbd8406ae 100644
--- a/src/channels/web/static/app.js
+++ b/src/channels/web/static/app.js
@@ -1,5 +1,60 @@
// IronClaw Web Gateway - Client
+// --- Theme Management (dark / light / system) ---
+// Icon switching is handled by pure CSS via data-theme-mode on .
+
+function getSystemTheme() {
+ return window.matchMedia('(prefers-color-scheme: light)').matches ? 'light' : 'dark';
+}
+
+function getThemeMode() {
+ return localStorage.getItem('ironclaw-theme') || 'system';
+}
+
+function resolveTheme(mode) {
+ return mode === 'system' ? getSystemTheme() : mode;
+}
+
+function applyTheme(mode) {
+ const resolved = resolveTheme(mode);
+ document.documentElement.setAttribute('data-theme', resolved);
+ document.documentElement.setAttribute('data-theme-mode', mode);
+ const titles = { dark: 'Theme: Dark (click for Light)', light: 'Theme: Light (click for System)', system: 'Theme: System (click for Dark)' };
+ const btn = document.getElementById('theme-toggle');
+ if (btn) btn.title = titles[mode] || '';
+ const announce = document.getElementById('theme-announce');
+ if (announce) announce.textContent = 'Theme: ' + mode;
+}
+
+function toggleTheme() {
+ const cycle = { dark: 'light', light: 'system', system: 'dark' };
+ const current = getThemeMode();
+ const next = cycle[current] || 'dark';
+ localStorage.setItem('ironclaw-theme', next);
+ applyTheme(next);
+}
+
+// Apply theme immediately (FOUC prevention is done via inline script in
,
+// but we call again here to ensure tooltip is set after DOM is ready).
+applyTheme(getThemeMode());
+
+// Delay enabling theme transition to avoid flash on initial load.
+requestAnimationFrame(function() {
+ requestAnimationFrame(function() {
+ document.body.classList.add('theme-transition');
+ });
+});
+
+// Listen for OS theme changes — only re-apply when in 'system' mode.
+window.matchMedia('(prefers-color-scheme: light)').addEventListener('change', function() {
+ if (getThemeMode() === 'system') {
+ applyTheme('system');
+ }
+});
+
+// Bind theme toggle button (CSP-compliant — no inline onclick).
+document.getElementById('theme-toggle').addEventListener('click', toggleTheme);
+
let token = '';
let eventSource = null;
let logEventSource = null;
diff --git a/src/channels/web/static/index.html b/src/channels/web/static/index.html
index 4e1074d08e..f94e48b565 100644
--- a/src/channels/web/static/index.html
+++ b/src/channels/web/static/index.html
@@ -25,6 +25,8 @@
integrity="sha384-pN9zSKOnTZwXRtYZAu0PBPEgR2B7DOC1aeLxQ33oJ0oy5iN1we6gm57xldM2irDG"
crossorigin="anonymous"
>
+
+
@@ -110,6 +112,18 @@ Restart IronClaw Instance
+
+