From 4424db5acb98601e341beb35dcf12631f5b59b4f Mon Sep 17 00:00:00 2001 From: Sharang Parnerkar <30073382+mighty840@users.noreply.github.com> Date: Wed, 13 May 2026 13:26:37 +0200 Subject: [PATCH] feat(dashboard): add light/dark theme with sidebar toggle Introduces a light theme alongside the existing dark Obsidian Control look, plus a sun/moon toggle in the sidebar footer. The dashboard's CSS already drove every surface through custom properties on :root, so the light theme is added as a second token set under `:root[data-theme="light"]` and, in parallel, inside a `@media (prefers-color-scheme: light)` block guarded by `:not([data-theme="dark"])`. Net effect: - A user with no stored preference gets their OS theme via the media query (no flash, no JS required). - A user who clicked the toggle gets `data-theme="light|dark"` set on ``, which wins over the media query. The toggle component (`theme_toggle.rs`) reads `localStorage` first then `prefers-color-scheme` on mount, and writes both the DOM attribute and `localStorage` on click. All `web_sys` calls are gated behind `#[cfg(feature = "web")]` so the server build stays clean. Three CSS rules that hardcoded near-black hex values (the page dot grid, `.code-block`, and the graph stabilization overlay) get explicit light-mode overrides so they don't render as dark patches on white. web-sys feature list extended with Storage, MediaQueryList, and Element so the toggle can read the media query and set the attribute. Co-Authored-By: Claude Sonnet 4.6 --- compliance-dashboard/Cargo.toml | 2 +- compliance-dashboard/assets/main.css | 139 ++++++++++++++++++ compliance-dashboard/src/components/mod.rs | 1 + .../src/components/sidebar.rs | 2 + .../src/components/theme_toggle.rs | 104 +++++++++++++ 5 files changed, 247 insertions(+), 1 deletion(-) create mode 100644 compliance-dashboard/src/components/theme_toggle.rs diff --git a/compliance-dashboard/Cargo.toml b/compliance-dashboard/Cargo.toml index 24e0d23..6eb7b84 100644 --- a/compliance-dashboard/Cargo.toml +++ b/compliance-dashboard/Cargo.toml @@ -51,7 +51,7 @@ thiserror = { workspace = true } # Web-only reqwest = { workspace = true, optional = true } -web-sys = { version = "0.3", optional = true, features = ["Blob", "BlobPropertyBag", "HtmlAnchorElement", "Url", "Document", "Window"] } +web-sys = { version = "0.3", optional = true, features = ["Blob", "BlobPropertyBag", "HtmlAnchorElement", "Url", "Document", "Element", "Window", "Storage", "MediaQueryList"] } js-sys = { version = "0.3", optional = true } wasm-bindgen = { version = "0.2", optional = true } gloo-timers = { version = "0.3", features = ["futures"], optional = true } diff --git a/compliance-dashboard/assets/main.css b/compliance-dashboard/assets/main.css index aed1f8c..4a9ab50 100644 --- a/compliance-dashboard/assets/main.css +++ b/compliance-dashboard/assets/main.css @@ -61,6 +61,77 @@ --ease-spring: cubic-bezier(0.34, 1.56, 0.64, 1); } +/* ── Light theme tokens ── + Applied when the user has explicitly chosen light (`data-theme="light"`) + OR when their OS prefers light AND they have made no explicit choice. */ +:root[data-theme="light"] { + --bg-primary: #f5f7fb; + --bg-secondary: #ffffff; + --bg-card: rgba(255, 255, 255, 0.85); + --bg-card-solid: #ffffff; + --bg-card-hover: #f1f5fb; + --bg-elevated: #f8fafc; + + --text-primary: #0c1426; + --text-secondary: #475569; + --text-tertiary: #8a9bb4; + + --accent: #0070d4; + --accent-hover: #0080f0; + --accent-muted: rgba(0, 112, 212, 0.10); + --accent-glow: 0 0 20px rgba(0, 112, 212, 0.10); + + --border: #e2e8f0; + --border-bright: #cbd5e1; + --border-accent: rgba(0, 112, 212, 0.30); + + --danger: #dc2626; + --danger-bg: rgba(220, 38, 38, 0.08); + --warning: #d97706; + --warning-bg: rgba(217, 119, 6, 0.08); + --success: #16a34a; + --success-bg: rgba(22, 163, 74, 0.08); + --info: #2563eb; + --info-bg: rgba(37, 99, 235, 0.08); + --orange: #ea580c; + --orange-bg: rgba(234, 88, 12, 0.08); +} + +@media (prefers-color-scheme: light) { + :root:not([data-theme="dark"]) { + --bg-primary: #f5f7fb; + --bg-secondary: #ffffff; + --bg-card: rgba(255, 255, 255, 0.85); + --bg-card-solid: #ffffff; + --bg-card-hover: #f1f5fb; + --bg-elevated: #f8fafc; + + --text-primary: #0c1426; + --text-secondary: #475569; + --text-tertiary: #8a9bb4; + + --accent: #0070d4; + --accent-hover: #0080f0; + --accent-muted: rgba(0, 112, 212, 0.10); + --accent-glow: 0 0 20px rgba(0, 112, 212, 0.10); + + --border: #e2e8f0; + --border-bright: #cbd5e1; + --border-accent: rgba(0, 112, 212, 0.30); + + --danger: #dc2626; + --danger-bg: rgba(220, 38, 38, 0.08); + --warning: #d97706; + --warning-bg: rgba(217, 119, 6, 0.08); + --success: #16a34a; + --success-bg: rgba(22, 163, 74, 0.08); + --info: #2563eb; + --info-bg: rgba(37, 99, 235, 0.08); + --orange: #ea580c; + --orange-bg: rgba(234, 88, 12, 0.08); + } +} + /* ── Reset & Base ── */ @@ -396,6 +467,44 @@ code { background: rgba(0, 200, 255, 0.06); } +.theme-toggle { + background: none; + border: none; + border-top: 1px solid var(--border); + color: var(--text-secondary); + padding: 11px 18px; + cursor: pointer; + display: flex; + align-items: center; + gap: 11px; + font-family: var(--font-body); + font-size: 13.5px; + font-weight: 500; + transition: color 0.2s, background 0.2s; + width: 100%; + text-align: left; +} + +.theme-toggle:hover { + color: var(--accent); + background: var(--accent-muted); +} + +.theme-toggle svg { + flex-shrink: 0; + opacity: 0.75; + transition: opacity 0.2s; +} + +.theme-toggle:hover svg { + opacity: 1; +} + +.sidebar.collapsed .theme-toggle { + justify-content: center; + padding: 11px 0; +} + .sidebar.collapsed .sidebar-header { padding: 22px 0; justify-content: center; @@ -3889,3 +3998,33 @@ tbody tr:last-child td { .copyable code, .copyable .mono { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .code-snippet-wrapper { position: relative; } .code-snippet-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 4px; gap: 8px; } + + +/* ═══════════════════════════════════════════════════════════════ + LIGHT THEME — surface overrides for the few hardcoded dark + colors that don't go through CSS custom properties. + ═══════════════════════════════════════════════════════════════ */ + +:root[data-theme="light"] .main-content { + background-image: radial-gradient(circle at 1px 1px, rgba(100, 116, 139, 0.18) 1px, transparent 0); +} +:root[data-theme="light"] .code-block { + background: #f8fafc; + color: #0c1426; +} +:root[data-theme="light"] .graph-stab-overlay { + background: radial-gradient(ellipse at center, rgba(245, 247, 251, 0.92) 0%, rgba(245, 247, 251, 0.98) 100%); +} + +@media (prefers-color-scheme: light) { + :root:not([data-theme="dark"]) .main-content { + background-image: radial-gradient(circle at 1px 1px, rgba(100, 116, 139, 0.18) 1px, transparent 0); + } + :root:not([data-theme="dark"]) .code-block { + background: #f8fafc; + color: #0c1426; + } + :root:not([data-theme="dark"]) .graph-stab-overlay { + background: radial-gradient(ellipse at center, rgba(245, 247, 251, 0.92) 0%, rgba(245, 247, 251, 0.98) 100%); + } +} diff --git a/compliance-dashboard/src/components/mod.rs b/compliance-dashboard/src/components/mod.rs index 6deaa91..a4b67a3 100644 --- a/compliance-dashboard/src/components/mod.rs +++ b/compliance-dashboard/src/components/mod.rs @@ -12,4 +12,5 @@ pub mod pentest_wizard; pub mod severity_badge; pub mod sidebar; pub mod stat_card; +pub mod theme_toggle; pub mod toast; diff --git a/compliance-dashboard/src/components/sidebar.rs b/compliance-dashboard/src/components/sidebar.rs index 227ccaa..cceb196 100644 --- a/compliance-dashboard/src/components/sidebar.rs +++ b/compliance-dashboard/src/components/sidebar.rs @@ -4,6 +4,7 @@ use dioxus_free_icons::icons::bs_icons::*; use dioxus_free_icons::Icon; use crate::app::Route; +use crate::components::theme_toggle::ThemeToggle; struct NavItem { label: &'static str, @@ -106,6 +107,7 @@ pub fn Sidebar() -> Element { } // Spacer pushes footer to the bottom div { class: "sidebar-spacer" } + ThemeToggle { collapsed: collapsed() } button { class: "sidebar-toggle", onclick: move |_| collapsed.set(!collapsed()), diff --git a/compliance-dashboard/src/components/theme_toggle.rs b/compliance-dashboard/src/components/theme_toggle.rs new file mode 100644 index 0000000..5b1aa34 --- /dev/null +++ b/compliance-dashboard/src/components/theme_toggle.rs @@ -0,0 +1,104 @@ +use dioxus::prelude::*; +use dioxus_free_icons::icons::bs_icons::{BsMoonStars, BsSun}; +use dioxus_free_icons::Icon; + +#[cfg(feature = "web")] +const STORAGE_KEY: &str = "compliance-scanner.theme"; + +/// Sidebar-footer theme toggle. Reads the initial state on mount from +/// localStorage (explicit user choice) or `prefers-color-scheme` (OS default), +/// then writes back to both the `` attribute and +/// localStorage on every click. +#[component] +pub fn ThemeToggle(collapsed: bool) -> Element { + // `None` until the on-mount effect resolves the real value, so SSR doesn't + // render the wrong icon for the user's actual theme. + let mut is_dark = use_signal(|| None::); + + use_effect(move || { + let (dark, from_storage) = initial_theme(); + is_dark.set(Some(dark)); + // If the user already made an explicit choice (in localStorage), assert it + // on the DOM so an OS-vs-stored mismatch can't briefly show the wrong theme. + if from_storage { + apply_theme(dark); + } + }); + + let label = if collapsed { + "" + } else if is_dark().unwrap_or(true) { + "Light mode" + } else { + "Dark mode" + }; + + let title = if is_dark().unwrap_or(true) { + "Switch to light mode" + } else { + "Switch to dark mode" + }; + + rsx! { + button { + class: "theme-toggle", + r#type: "button", + title: "{title}", + "aria-label": "{title}", + onclick: move |_| { + let next_dark = !is_dark().unwrap_or(true); + is_dark.set(Some(next_dark)); + apply_theme(next_dark); + }, + if is_dark().unwrap_or(true) { + Icon { icon: BsSun, width: 16, height: 16 } + } else { + Icon { icon: BsMoonStars, width: 16, height: 16 } + } + if !collapsed { + span { class: "theme-toggle-label", "{label}" } + } + } + } +} + +/// Returns `(is_dark, from_storage)`. `from_storage` is true when an explicit +/// user choice is in localStorage; false when we fell back to OS preference +/// (or to the dark default). +#[cfg(feature = "web")] +fn initial_theme() -> (bool, bool) { + if let Some(window) = web_sys::window() { + if let Ok(Some(storage)) = window.local_storage() { + if let Ok(Some(value)) = storage.get_item(STORAGE_KEY) { + return (value == "dark", true); + } + } + if let Ok(Some(mql)) = window.match_media("(prefers-color-scheme: dark)") { + return (mql.matches(), false); + } + } + (true, false) +} + +#[cfg(not(feature = "web"))] +fn initial_theme() -> (bool, bool) { + (true, false) +} + +#[cfg(feature = "web")] +fn apply_theme(dark: bool) { + let theme = if dark { "dark" } else { "light" }; + if let Some(window) = web_sys::window() { + if let Some(document) = window.document() { + if let Some(root) = document.document_element() { + let _ = root.set_attribute("data-theme", theme); + } + } + if let Ok(Some(storage)) = window.local_storage() { + let _ = storage.set_item(STORAGE_KEY, theme); + } + } +} + +#[cfg(not(feature = "web"))] +fn apply_theme(_dark: bool) {}