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:
1
Cargo.lock
generated
1
Cargo.lock
generated
@@ -2015,6 +2015,7 @@ dependencies = [
|
||||
"tracing",
|
||||
"tracing-subscriber",
|
||||
"wasm-bindgen",
|
||||
"web-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
@@ -32,6 +32,7 @@ ssr = [
|
||||
hydrate = [
|
||||
"dep:console_error_panic_hook",
|
||||
"dep:wasm-bindgen",
|
||||
"dep:web-sys",
|
||||
"leptos/hydrate",
|
||||
]
|
||||
|
||||
@@ -79,6 +80,8 @@ sqlx = { version = "0.8", features = ["runtime-tokio", "tls-rustls", "sqlite", "
|
||||
console_error_panic_hook = { version = "0.1", optional = true }
|
||||
# Pont entre Rust/WASM et JavaScript : permet d'appeler du JS depuis Rust
|
||||
wasm-bindgen = { version = "0.2", optional = true }
|
||||
# Bindings aux APIs du navigateur : window, document, localStorage, Element...
|
||||
web-sys = { version = "0.3", features = ["Window", "Document", "Element", "Storage"], optional = true }
|
||||
|
||||
# Configuration Leptos lue par get_configuration(Some("Cargo.toml"))
|
||||
# Définit les chemins des fichiers compilés et l'adresse du serveur.
|
||||
|
||||
@@ -15,6 +15,12 @@
|
||||
data-target-name correspond au nom du crate avec underscores (convention Rust).
|
||||
-->
|
||||
<link data-trunk rel="rust" data-target-name="rust_ipam" />
|
||||
<!--
|
||||
Compile style/rust-ipam.css → target/site/pkg/rust-ipam.css
|
||||
Served by Axum at /pkg/rust-ipam.css and loaded by the <Stylesheet>
|
||||
component in app.rs.
|
||||
-->
|
||||
<link data-trunk rel="css" href="style/rust-ipam.css" />
|
||||
</head>
|
||||
<body>
|
||||
<!--
|
||||
|
||||
10
src/app.rs
10
src/app.rs
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
136
src/client/theme.rs
Normal 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(¤t);
|
||||
// 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>
|
||||
}
|
||||
}
|
||||
718
style/rust-ipam.css
Normal file
718
style/rust-ipam.css
Normal file
@@ -0,0 +1,718 @@
|
||||
/* ============================================================
|
||||
DESIGN TOKENS
|
||||
All visual properties live as CSS custom properties.
|
||||
To add a new theme, define a [data-theme="my-theme"] block
|
||||
with the same variable set below.
|
||||
============================================================ */
|
||||
|
||||
:root {
|
||||
/* --- Shared tokens (identical across all themes) --- */
|
||||
--font-sans: -apple-system, BlinkMacSystemFont, "SF Pro Text", "Segoe UI",
|
||||
Roboto, "Helvetica Neue", Arial, sans-serif;
|
||||
--font-mono: "SF Mono", "Fira Code", "Cascadia Code", "Consolas", monospace;
|
||||
|
||||
--size-xs: 4px;
|
||||
--size-sm: 8px;
|
||||
--size-md: 16px;
|
||||
--size-lg: 24px;
|
||||
--size-xl: 40px;
|
||||
--size-2xl: 64px;
|
||||
|
||||
--radius-sm: 6px;
|
||||
--radius-md: 10px;
|
||||
--radius-lg: 14px;
|
||||
--radius-pill: 999px;
|
||||
|
||||
--font-xs: 12px;
|
||||
--font-sm: 13px;
|
||||
--font-base: 15px;
|
||||
--font-lg: 17px;
|
||||
--font-xl: 22px;
|
||||
--font-2xl: 28px;
|
||||
--font-3xl: 34px;
|
||||
|
||||
--transition-fast: 100ms ease;
|
||||
--transition-base: 200ms ease;
|
||||
|
||||
--nav-height: 52px;
|
||||
--content-width: 1000px;
|
||||
|
||||
/* --- Light theme (default) --- */
|
||||
--bg: #f5f5f7;
|
||||
--bg-surface: #ffffff;
|
||||
--bg-surface2: #f9f9fb;
|
||||
--bg-hover: rgba(0, 0, 0, 0.04);
|
||||
--bg-overlay: rgba(255, 255, 255, 0.8);
|
||||
|
||||
--border: rgba(0, 0, 0, 0.12);
|
||||
--border-focus: #0071e3;
|
||||
|
||||
--text: #1d1d1f;
|
||||
--text-secondary: #6e6e73;
|
||||
--text-tertiary: #aeaeb2;
|
||||
--text-on-accent: #ffffff;
|
||||
|
||||
--accent: #0071e3;
|
||||
--accent-hover: #0077ed;
|
||||
--accent-light: rgba(0, 113, 227, 0.10);
|
||||
|
||||
--danger: #ff3b30;
|
||||
--danger-hover: #ff2d20;
|
||||
--danger-light: rgba(255, 59, 48, 0.10);
|
||||
|
||||
--success: #34c759;
|
||||
|
||||
--shadow-sm: 0 1px 2px rgba(0,0,0,.06), 0 1px 4px rgba(0,0,0,.04);
|
||||
--shadow-md: 0 4px 12px rgba(0,0,0,.08), 0 2px 4px rgba(0,0,0,.04);
|
||||
--shadow-lg: 0 8px 24px rgba(0,0,0,.12), 0 4px 8px rgba(0,0,0,.06);
|
||||
}
|
||||
|
||||
/* System dark mode — only applies when no explicit data-theme is set */
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root:not([data-theme]) {
|
||||
--bg: #000000;
|
||||
--bg-surface: #1c1c1e;
|
||||
--bg-surface2: #2c2c2e;
|
||||
--bg-hover: rgba(255, 255, 255, 0.06);
|
||||
--bg-overlay: rgba(28, 28, 30, 0.85);
|
||||
|
||||
--border: rgba(255, 255, 255, 0.12);
|
||||
--border-focus: #0a84ff;
|
||||
|
||||
--text: #f5f5f7;
|
||||
--text-secondary: #aeaeb2;
|
||||
--text-tertiary: #636366;
|
||||
--text-on-accent: #ffffff;
|
||||
|
||||
--accent: #0a84ff;
|
||||
--accent-hover: #409cff;
|
||||
--accent-light: rgba(10, 132, 255, 0.15);
|
||||
|
||||
--danger: #ff453a;
|
||||
--danger-hover: #ff6961;
|
||||
--danger-light: rgba(255, 69, 58, 0.15);
|
||||
|
||||
--success: #32d74b;
|
||||
|
||||
--shadow-sm: 0 1px 2px rgba(0,0,0,.3), 0 1px 4px rgba(0,0,0,.2);
|
||||
--shadow-md: 0 4px 12px rgba(0,0,0,.4), 0 2px 4px rgba(0,0,0,.2);
|
||||
--shadow-lg: 0 8px 24px rgba(0,0,0,.5), 0 4px 8px rgba(0,0,0,.3);
|
||||
}
|
||||
}
|
||||
|
||||
/* Explicit light theme */
|
||||
[data-theme="light"] {
|
||||
--bg: #f5f5f7;
|
||||
--bg-surface: #ffffff;
|
||||
--bg-surface2: #f9f9fb;
|
||||
--bg-hover: rgba(0, 0, 0, 0.04);
|
||||
--bg-overlay: rgba(255, 255, 255, 0.8);
|
||||
|
||||
--border: rgba(0, 0, 0, 0.12);
|
||||
--border-focus: #0071e3;
|
||||
|
||||
--text: #1d1d1f;
|
||||
--text-secondary: #6e6e73;
|
||||
--text-tertiary: #aeaeb2;
|
||||
--text-on-accent: #ffffff;
|
||||
|
||||
--accent: #0071e3;
|
||||
--accent-hover: #0077ed;
|
||||
--accent-light: rgba(0, 113, 227, 0.10);
|
||||
|
||||
--danger: #ff3b30;
|
||||
--danger-hover: #ff2d20;
|
||||
--danger-light: rgba(255, 59, 48, 0.10);
|
||||
|
||||
--success: #34c759;
|
||||
|
||||
--shadow-sm: 0 1px 2px rgba(0,0,0,.06), 0 1px 4px rgba(0,0,0,.04);
|
||||
--shadow-md: 0 4px 12px rgba(0,0,0,.08), 0 2px 4px rgba(0,0,0,.04);
|
||||
--shadow-lg: 0 8px 24px rgba(0,0,0,.12), 0 4px 8px rgba(0,0,0,.06);
|
||||
}
|
||||
|
||||
/* Explicit dark theme */
|
||||
[data-theme="dark"] {
|
||||
--bg: #000000;
|
||||
--bg-surface: #1c1c1e;
|
||||
--bg-surface2: #2c2c2e;
|
||||
--bg-hover: rgba(255, 255, 255, 0.06);
|
||||
--bg-overlay: rgba(28, 28, 30, 0.85);
|
||||
|
||||
--border: rgba(255, 255, 255, 0.12);
|
||||
--border-focus: #0a84ff;
|
||||
|
||||
--text: #f5f5f7;
|
||||
--text-secondary: #aeaeb2;
|
||||
--text-tertiary: #636366;
|
||||
--text-on-accent: #ffffff;
|
||||
|
||||
--accent: #0a84ff;
|
||||
--accent-hover: #409cff;
|
||||
--accent-light: rgba(10, 132, 255, 0.15);
|
||||
|
||||
--danger: #ff453a;
|
||||
--danger-hover: #ff6961;
|
||||
--danger-light: rgba(255, 69, 58, 0.15);
|
||||
|
||||
--success: #32d74b;
|
||||
|
||||
--shadow-sm: 0 1px 2px rgba(0,0,0,.3), 0 1px 4px rgba(0,0,0,.2);
|
||||
--shadow-md: 0 4px 12px rgba(0,0,0,.4), 0 2px 4px rgba(0,0,0,.2);
|
||||
--shadow-lg: 0 8px 24px rgba(0,0,0,.5), 0 4px 8px rgba(0,0,0,.3);
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
RESET & BASE
|
||||
============================================================ */
|
||||
|
||||
*, *::before, *::after {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
html {
|
||||
font-size: var(--font-base);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
text-rendering: optimizeLegibility;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: var(--font-sans);
|
||||
font-size: var(--font-base);
|
||||
line-height: 1.6;
|
||||
color: var(--text);
|
||||
background: var(--bg);
|
||||
min-height: 100vh;
|
||||
transition: background var(--transition-base), color var(--transition-base);
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
NAVIGATION
|
||||
============================================================ */
|
||||
|
||||
nav {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 100;
|
||||
height: var(--nav-height);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--size-sm);
|
||||
padding: 0 var(--size-lg);
|
||||
background: var(--bg-overlay);
|
||||
border-bottom: 1px solid var(--border);
|
||||
backdrop-filter: blur(20px) saturate(180%);
|
||||
-webkit-backdrop-filter: blur(20px) saturate(180%);
|
||||
}
|
||||
|
||||
nav a {
|
||||
font-size: var(--font-sm);
|
||||
font-weight: 500;
|
||||
color: var(--text-secondary);
|
||||
text-decoration: none;
|
||||
padding: var(--size-xs) var(--size-sm);
|
||||
border-radius: var(--radius-sm);
|
||||
transition: color var(--transition-fast), background var(--transition-fast);
|
||||
}
|
||||
|
||||
nav a:hover {
|
||||
color: var(--text);
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
nav a.active {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
/* Spacer pushes theme toggle to the right */
|
||||
nav .nav-spacer {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
LAYOUT
|
||||
============================================================ */
|
||||
|
||||
main {
|
||||
max-width: var(--content-width);
|
||||
margin: 0 auto;
|
||||
padding: var(--size-xl) var(--size-lg);
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
TYPOGRAPHY
|
||||
============================================================ */
|
||||
|
||||
h1 {
|
||||
font-size: var(--font-3xl);
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.5px;
|
||||
color: var(--text);
|
||||
margin-bottom: var(--size-sm);
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: var(--font-xl);
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.3px;
|
||||
color: var(--text);
|
||||
margin-bottom: var(--size-md);
|
||||
}
|
||||
|
||||
h3 {
|
||||
font-size: var(--font-lg);
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
margin-bottom: var(--size-sm);
|
||||
}
|
||||
|
||||
p {
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
code {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.9em;
|
||||
background: var(--bg-surface2);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 1px 6px;
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
BUTTONS
|
||||
============================================================ */
|
||||
|
||||
button,
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--size-xs);
|
||||
font-family: var(--font-sans);
|
||||
font-size: var(--font-sm);
|
||||
font-weight: 500;
|
||||
line-height: 1;
|
||||
padding: 8px 16px;
|
||||
border-radius: var(--radius-sm);
|
||||
border: 1px solid transparent;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background var(--transition-fast),
|
||||
color var(--transition-fast),
|
||||
box-shadow var(--transition-fast),
|
||||
transform var(--transition-fast);
|
||||
text-decoration: none;
|
||||
white-space: nowrap;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
button:active,
|
||||
.btn:active {
|
||||
transform: scale(0.98);
|
||||
}
|
||||
|
||||
/* Primary — filled accent */
|
||||
button[type="submit"],
|
||||
.btn-primary {
|
||||
background: var(--accent);
|
||||
color: var(--text-on-accent);
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
button[type="submit"]:hover,
|
||||
.btn-primary:hover {
|
||||
background: var(--accent-hover);
|
||||
border-color: var(--accent-hover);
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
/* Secondary — outlined */
|
||||
.btn-secondary {
|
||||
background: transparent;
|
||||
color: var(--text);
|
||||
border-color: var(--border);
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
/* Danger — for delete actions */
|
||||
.btn-danger {
|
||||
background: transparent;
|
||||
color: var(--danger);
|
||||
border-color: transparent;
|
||||
font-size: var(--font-xs);
|
||||
padding: 4px 10px;
|
||||
}
|
||||
|
||||
.btn-danger:hover {
|
||||
background: var(--danger-light);
|
||||
border-color: var(--danger);
|
||||
}
|
||||
|
||||
/* Theme toggle */
|
||||
.theme-toggle {
|
||||
background: var(--bg-hover);
|
||||
color: var(--text-secondary);
|
||||
border: 1px solid var(--border);
|
||||
font-size: var(--font-xs);
|
||||
font-weight: 600;
|
||||
padding: 4px 10px;
|
||||
border-radius: var(--radius-pill);
|
||||
letter-spacing: 0.3px;
|
||||
}
|
||||
|
||||
.theme-toggle:hover {
|
||||
color: var(--text);
|
||||
background: var(--bg-surface2);
|
||||
border-color: var(--border-focus);
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
FORMS & INPUTS
|
||||
============================================================ */
|
||||
|
||||
.form-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--size-xs);
|
||||
margin-bottom: var(--size-md);
|
||||
}
|
||||
|
||||
label {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--size-xs);
|
||||
font-size: var(--font-sm);
|
||||
font-weight: 500;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
input[type="text"],
|
||||
input[type="email"],
|
||||
input[type="number"],
|
||||
input[type="password"],
|
||||
input[type="search"],
|
||||
select,
|
||||
textarea {
|
||||
font-family: var(--font-sans);
|
||||
font-size: var(--font-base);
|
||||
color: var(--text);
|
||||
background: var(--bg-surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 9px 12px;
|
||||
width: 100%;
|
||||
transition:
|
||||
border-color var(--transition-fast),
|
||||
box-shadow var(--transition-fast);
|
||||
outline: none;
|
||||
-webkit-appearance: none;
|
||||
}
|
||||
|
||||
input::placeholder,
|
||||
textarea::placeholder {
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
input:focus,
|
||||
select:focus,
|
||||
textarea:focus {
|
||||
border-color: var(--border-focus);
|
||||
box-shadow: 0 0 0 3px var(--accent-light);
|
||||
}
|
||||
|
||||
/* Inline form layout (label + input + button on one line) */
|
||||
.form-inline {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
gap: var(--size-sm);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.form-inline label {
|
||||
flex: 1;
|
||||
min-width: 200px;
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
CARDS & SECTIONS
|
||||
============================================================ */
|
||||
|
||||
.card {
|
||||
background: var(--bg-surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: var(--shadow-sm);
|
||||
padding: var(--size-lg);
|
||||
transition: box-shadow var(--transition-base);
|
||||
}
|
||||
|
||||
.card:hover {
|
||||
box-shadow: var(--shadow-md);
|
||||
}
|
||||
|
||||
/* Section spacing between cards */
|
||||
section + section {
|
||||
margin-top: var(--size-lg);
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
TABLES
|
||||
============================================================ */
|
||||
|
||||
.table-container {
|
||||
background: var(--bg-surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: var(--shadow-sm);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: var(--font-sm);
|
||||
}
|
||||
|
||||
thead {
|
||||
background: var(--bg-surface2);
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
thead th {
|
||||
padding: 10px var(--size-md);
|
||||
text-align: left;
|
||||
font-size: var(--font-xs);
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.5px;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
tbody tr {
|
||||
border-bottom: 1px solid var(--border);
|
||||
transition: background var(--transition-fast);
|
||||
}
|
||||
|
||||
tbody tr:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
tbody tr:hover {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
tbody td {
|
||||
padding: 12px var(--size-md);
|
||||
color: var(--text);
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
tbody td:last-child {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
STATUS MESSAGES
|
||||
============================================================ */
|
||||
|
||||
.error {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--size-sm);
|
||||
font-size: var(--font-sm);
|
||||
color: var(--danger);
|
||||
background: var(--danger-light);
|
||||
border: 1px solid var(--danger);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: var(--size-sm) var(--size-md);
|
||||
margin-bottom: var(--size-md);
|
||||
}
|
||||
|
||||
.empty {
|
||||
font-size: var(--font-sm);
|
||||
color: var(--text-tertiary);
|
||||
text-align: center;
|
||||
padding: var(--size-xl);
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
HOME PAGE — Dashboard
|
||||
============================================================ */
|
||||
|
||||
.home-page {
|
||||
max-width: 860px;
|
||||
margin: 0 auto;
|
||||
padding-top: var(--size-2xl);
|
||||
}
|
||||
|
||||
.home-header {
|
||||
text-align: center;
|
||||
margin-bottom: var(--size-2xl);
|
||||
}
|
||||
|
||||
.home-header h1 {
|
||||
font-size: var(--font-3xl);
|
||||
margin-bottom: var(--size-xs);
|
||||
}
|
||||
|
||||
.home-header p {
|
||||
font-size: var(--font-lg);
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
/* Summary grid — one card per entity */
|
||||
.summary-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: var(--size-md);
|
||||
}
|
||||
|
||||
/* Clickable summary card */
|
||||
.summary-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--size-sm);
|
||||
padding: var(--size-xl) var(--size-lg);
|
||||
background: var(--bg-surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: var(--shadow-sm);
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
box-shadow var(--transition-base),
|
||||
border-color var(--transition-base),
|
||||
transform var(--transition-base),
|
||||
background var(--transition-base);
|
||||
}
|
||||
|
||||
.summary-card:hover {
|
||||
box-shadow: var(--shadow-md);
|
||||
border-color: var(--accent);
|
||||
background: var(--accent-light);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.summary-card:active {
|
||||
transform: translateY(0);
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
.summary-card__count {
|
||||
font-size: var(--font-3xl);
|
||||
font-weight: 700;
|
||||
letter-spacing: -1px;
|
||||
color: var(--text);
|
||||
line-height: 1;
|
||||
/* Animate count changes gracefully */
|
||||
transition: color var(--transition-base);
|
||||
}
|
||||
|
||||
.summary-card:hover .summary-card__count {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.summary-card__label {
|
||||
font-size: var(--font-sm);
|
||||
font-weight: 500;
|
||||
color: var(--text-secondary);
|
||||
letter-spacing: 0.3px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
NETWORKS PAGE
|
||||
============================================================ */
|
||||
|
||||
.networks-page h1 {
|
||||
margin-bottom: var(--size-lg);
|
||||
}
|
||||
|
||||
.networks-page .add-form {
|
||||
background: var(--bg-surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: var(--shadow-sm);
|
||||
padding: var(--size-lg);
|
||||
margin-bottom: var(--size-lg);
|
||||
}
|
||||
|
||||
.networks-page .add-form h2 {
|
||||
margin-bottom: var(--size-md);
|
||||
}
|
||||
|
||||
.networks-page .add-form form {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
gap: var(--size-sm);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.networks-page .add-form label {
|
||||
flex: 1;
|
||||
min-width: 220px;
|
||||
}
|
||||
|
||||
.networks-page .add-form input {
|
||||
margin-top: var(--size-xs);
|
||||
}
|
||||
|
||||
.networks-page .list h2 {
|
||||
margin-bottom: var(--size-md);
|
||||
}
|
||||
|
||||
.networks-page .list .table-container {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
/* Delete button inside table */
|
||||
.networks-page td button {
|
||||
background: transparent;
|
||||
color: var(--danger);
|
||||
border: 1px solid transparent;
|
||||
font-size: var(--font-xs);
|
||||
padding: 3px 10px;
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
transition: background var(--transition-fast), border-color var(--transition-fast);
|
||||
}
|
||||
|
||||
.networks-page td button:hover {
|
||||
background: var(--danger-light);
|
||||
border-color: var(--danger);
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
404 PAGE
|
||||
============================================================ */
|
||||
|
||||
.not-found {
|
||||
text-align: center;
|
||||
padding-top: var(--size-2xl);
|
||||
}
|
||||
|
||||
.not-found h1 {
|
||||
font-size: var(--font-2xl);
|
||||
margin-bottom: var(--size-md);
|
||||
}
|
||||
|
||||
.not-found a {
|
||||
color: var(--accent);
|
||||
text-decoration: none;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.not-found a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
Reference in New Issue
Block a user