Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a8cef58e02 | |||
| 927fbc8ecb |
@@ -51,7 +51,7 @@ thiserror = { workspace = true }
|
|||||||
|
|
||||||
# Web-only
|
# Web-only
|
||||||
reqwest = { workspace = true, optional = true }
|
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 }
|
js-sys = { version = "0.3", optional = true }
|
||||||
wasm-bindgen = { version = "0.2", optional = true }
|
wasm-bindgen = { version = "0.2", optional = true }
|
||||||
gloo-timers = { version = "0.3", features = ["futures"], optional = true }
|
gloo-timers = { version = "0.3", features = ["futures"], optional = true }
|
||||||
|
|||||||
@@ -61,6 +61,77 @@
|
|||||||
--ease-spring: cubic-bezier(0.34, 1.56, 0.64, 1);
|
--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 ── */
|
/* ── Reset & Base ── */
|
||||||
|
|
||||||
@@ -396,6 +467,44 @@ code {
|
|||||||
background: rgba(0, 200, 255, 0.06);
|
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 {
|
.sidebar.collapsed .sidebar-header {
|
||||||
padding: 22px 0;
|
padding: 22px 0;
|
||||||
justify-content: center;
|
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; }
|
.copyable code, .copyable .mono { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
.code-snippet-wrapper { position: relative; }
|
.code-snippet-wrapper { position: relative; }
|
||||||
.code-snippet-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 4px; gap: 8px; }
|
.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%);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -12,4 +12,5 @@ pub mod pentest_wizard;
|
|||||||
pub mod severity_badge;
|
pub mod severity_badge;
|
||||||
pub mod sidebar;
|
pub mod sidebar;
|
||||||
pub mod stat_card;
|
pub mod stat_card;
|
||||||
|
pub mod theme_toggle;
|
||||||
pub mod toast;
|
pub mod toast;
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ use dioxus_free_icons::icons::bs_icons::*;
|
|||||||
use dioxus_free_icons::Icon;
|
use dioxus_free_icons::Icon;
|
||||||
|
|
||||||
use crate::app::Route;
|
use crate::app::Route;
|
||||||
|
use crate::components::theme_toggle::ThemeToggle;
|
||||||
|
|
||||||
struct NavItem {
|
struct NavItem {
|
||||||
label: &'static str,
|
label: &'static str,
|
||||||
@@ -106,6 +107,7 @@ pub fn Sidebar() -> Element {
|
|||||||
}
|
}
|
||||||
// Spacer pushes footer to the bottom
|
// Spacer pushes footer to the bottom
|
||||||
div { class: "sidebar-spacer" }
|
div { class: "sidebar-spacer" }
|
||||||
|
ThemeToggle { collapsed: collapsed() }
|
||||||
button {
|
button {
|
||||||
class: "sidebar-toggle",
|
class: "sidebar-toggle",
|
||||||
onclick: move |_| collapsed.set(!collapsed()),
|
onclick: move |_| collapsed.set(!collapsed()),
|
||||||
|
|||||||
@@ -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 `<html data-theme="...">` 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::<bool>);
|
||||||
|
|
||||||
|
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) {}
|
||||||
Reference in New Issue
Block a user