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

+ +