feat(style): add CSS theme system with light/dark mode toggle

Introduces a global design system using CSS custom properties as
design tokens. Light and dark themes are defined via [data-theme]
attribute on <html>; the system preference (prefers-color-scheme)
is the default when no explicit choice is stored.

ThemeToggle component (Auto → Light → Dark cycle) persists the
choice to localStorage and applies it on hydration without flash.
New themes can be added by defining a [data-theme="name"] CSS block
and adding a variant to ThemeChoice.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-15 22:49:17 +02:00
parent 3aeb74e5bc
commit 589aab7e3d
8 changed files with 901 additions and 39 deletions

View File

@@ -11,7 +11,7 @@ use leptos_router::{
path,
};
use crate::client::{home::HomePage, networks::NetworksPage};
use crate::client::{home::HomePage, networks::NetworksPage, theme::ThemeToggle};
// Shell — full HTML document rendered by the Axum server.
//
@@ -83,11 +83,11 @@ pub fn App() -> impl IntoView {
// Router handles client-side navigation without full page reloads.
// On the server, it determines which component to render for the requested URL.
<Router>
// Temporary navigation bar — will be replaced by task #9.
<nav>
<a href="/">"Home"</a>
" | "
<nav>
<a href="/">"Rust IPAM"</a>
<a href="/networks">"Networks"</a>
<span class="nav-spacer"/>
<ThemeToggle/>
</nav>
<main>

View File

@@ -11,3 +11,4 @@
pub mod home; // Home page
pub mod networks; // Networks list and creation
pub mod theme; // Theme toggle component (light / dark / system)

View File

@@ -109,40 +109,37 @@ pub fn NetworksPage() -> impl IntoView {
.into_any(),
Ok(list) => view! {
<table>
<thead>
<tr>
<th>"CIDR"</th>
<th>"Actions"</th>
</tr>
</thead>
<tbody>
// `collect_view()` turns an iterator of views
// into a single renderable fragment.
{list
.into_iter()
.map(|network| {
// Capture `id` as a plain i64 (Copy).
// Closures in event handlers must be 'static,
// so we can't move the full `network` struct.
let id = network.id;
view! {
<tr>
<td>{network.cidr}</td>
<td>
<button on:click=move |_| {
delete_action
.dispatch(DeleteNetwork { id });
}>
"Delete"
</button>
</td>
</tr>
}
})
.collect_view()}
</tbody>
</table>
<div class="table-container">
<table>
<thead>
<tr>
<th>"CIDR"</th>
<th>"Actions"</th>
</tr>
</thead>
<tbody>
{list
.into_iter()
.map(|network| {
let id = network.id;
view! {
<tr>
<td>{network.cidr}</td>
<td>
<button on:click=move |_| {
delete_action
.dispatch(DeleteNetwork { id });
}>
"Delete"
</button>
</td>
</tr>
}
})
.collect_view()}
</tbody>
</table>
</div>
}
.into_any(),
})

136
src/client/theme.rs Normal file
View File

@@ -0,0 +1,136 @@
// client/theme.rs — Theme toggle component
//
// Architecture for multi-theme support:
// - ThemeChoice enum: each variant maps to a `data-theme` attribute value
// (None for "System" = follow OS preference via CSS media query)
// - Adding a future theme: add a variant here + a CSS [data-theme="..."] block
// - The active theme is persisted in localStorage under "ipam-theme"
// - When no value is stored, the CSS media query `prefers-color-scheme` decides
//
// SSR vs WASM:
// - SSR renders the button with no data-theme on <html> (system default applies)
// - On hydration, the WASM Effect reads localStorage and applies the stored choice
// - The `#[cfg(target_arch = "wasm32")]` guards prevent DOM/localStorage calls
// from being compiled into the server binary
use leptos::prelude::*;
// Used only in WASM builds — suppress false-positive dead_code warnings from SSR
#[allow(dead_code)]
const STORAGE_KEY: &str = "ipam-theme";
// Each variant corresponds to a CSS `[data-theme]` value (None = follow OS).
// To add a theme: add a variant, implement the methods, add CSS variables.
#[allow(dead_code)]
#[derive(Clone, PartialEq, Debug, Default)]
pub enum ThemeChoice {
#[default]
System, // No data-theme attribute; CSS prefers-color-scheme decides
Light,
Dark,
}
#[allow(dead_code)]
impl ThemeChoice {
// The attribute value written to <html data-theme="...">, or None to remove it.
fn attr_value(&self) -> Option<&'static str> {
match self {
Self::System => None,
Self::Light => Some("light"),
Self::Dark => Some("dark"),
}
}
// Label shown in the toggle button.
fn label(&self) -> &'static str {
match self {
Self::System => "Auto",
Self::Light => "Light",
Self::Dark => "Dark",
}
}
// Cycles to the next theme. Extend this as new variants are added.
fn next(&self) -> Self {
match self {
Self::System => Self::Light,
Self::Light => Self::Dark,
Self::Dark => Self::System,
}
}
fn from_stored(s: &str) -> Self {
match s {
"light" => Self::Light,
"dark" => Self::Dark,
_ => Self::System,
}
}
}
// ─── DOM helpers (WASM only) ─────────────────────────────────────────────────
// Reads the stored theme name from localStorage.
#[cfg(target_arch = "wasm32")]
fn load_stored_theme() -> Option<ThemeChoice> {
let storage = web_sys::window()?.local_storage().ok()??;
let value = storage.get_item(STORAGE_KEY).ok()??;
Some(ThemeChoice::from_stored(&value))
}
// Applies `data-theme` attribute to <html> and persists to localStorage.
#[cfg(target_arch = "wasm32")]
fn apply_and_persist(choice: &ThemeChoice) {
let Some(window) = web_sys::window() else { return };
let Some(document) = window.document() else { return };
let Some(root) = document.document_element() else { return };
match choice.attr_value() {
Some(v) => { let _ = root.set_attribute("data-theme", v); }
None => { let _ = root.remove_attribute("data-theme"); }
}
if let Ok(Some(storage)) = window.local_storage() {
match choice.attr_value() {
Some(v) => { let _ = storage.set_item(STORAGE_KEY, v); }
None => { let _ = storage.remove_item(STORAGE_KEY); }
}
}
}
// ─── Component ───────────────────────────────────────────────────────────────
#[component]
pub fn ThemeToggle() -> impl IntoView {
let theme = RwSignal::new(ThemeChoice::System);
// Effect 1: runs once on mount — reads localStorage and initializes the signal.
// Does NOT track `theme`, so it never re-runs after the initial mount.
// Setting the signal here triggers Effect 2 below.
Effect::new(move |_| {
#[cfg(target_arch = "wasm32")]
if let Some(stored) = load_stored_theme() {
theme.set(stored);
}
});
// Effect 2: tracks `theme` — applies the choice to the DOM and localStorage
// whenever the signal changes (both on init and after user clicks).
Effect::new(move |_| {
let current = theme.get(); // tracked — re-runs when theme changes
#[cfg(target_arch = "wasm32")]
apply_and_persist(&current);
// Suppress unused variable warning when compiling for SSR
let _ = current;
});
view! {
<button
class="theme-toggle"
title="Toggle color theme"
on:click=move |_| theme.update(|t| *t = t.next())
>
{move || theme.get().label()}
</button>
}
}