AkurAI Build
Menu

AkurAI-FrameworkCommit e2f0de77

Commit e2f0de77

e2f0de773e72a80765be4f47d3c8c67c66907169 by Ólafur Búi Ólafsson

diff --git a/CHANGELOG.md b/CHANGELOG.md
index c4587cd..39745ea 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -14,6 +14,20 @@ Pre-1.0, minor versions may include breaking changes.
 
 ## [Unreleased]
 
+## [0.8.0] - 2026-06-26
+
+### Added
+- **Named multi-theme system** — `crates/css::theme` generates `/themes.css` with
+  17 themes (Nord, Claude Code, Catppuccin, Solarized, Gruvbox, Tokyo Night,
+  Rosé Pine, Dracula, AkurAI) in dark and light variants, each a
+  `:root[data-theme="…"]` block over the existing semantic tokens. Themes are
+  [base16](https://github.com/tinted-theming/schemes) schemes mapped to the token
+  set once; the mapping is polarity-agnostic, so one rule covers light and dark.
+  Registry served at `/themes.json`; a zero-dependency ESM switcher (`theme.js`)
+  persists the choice and defaults to the OS `prefers-color-scheme`. Components are
+  unchanged — they read the same token names. Pure `std`, generated at request
+  time, no build step.
+
 ### Docs
 - Added the AkurAI-RustAgent agent OS UI contract, covering Hermes-style channels,
   confirmations, questions, edit previews, artifacts, receipts, and the Framework
diff --git a/Cargo.toml b/Cargo.toml
index 3175a98..7fb2755 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -8,7 +8,7 @@ members = ["crates/http", "crates/json", "crates/sql", "crates/sqlexec", "crates
 # never ends up in the binary.
 
 [workspace.package]
-version = "0.7.0"
+version = "0.8.0"
 edition = "2021"
 license = "MIT"
 repository = "https://github.com/olibuijr/AkurAI-Framework"
diff --git a/VERSION b/VERSION
index faef31a..a3df0a6 100644
--- a/VERSION
+++ b/VERSION
@@ -1 +1 @@
-0.7.0
+0.8.0
diff --git a/crates/cli/src/cmd_serve.rs b/crates/cli/src/cmd_serve.rs
index 9f089ef..305ab7b 100644
--- a/crates/cli/src/cmd_serve.rs
+++ b/crates/cli/src/cmd_serve.rs
@@ -182,6 +182,10 @@ pub fn run(cfg: Config) -> io::Result<()> {
     println!("  serving {}", root.display());
     println!("  → http://{local}");
     println!("  utilities.css: served at /utilities.css");
+    println!(
+        "  themes.css: {} named themes at /themes.css (registry at /themes.json)",
+        akurai_css::theme::registry().len()
+    );
     if !cfg.dev {
         println!(
             "  asset pipeline: {} fingerprinted asset(s), manifest at /api/_assets",
@@ -314,6 +318,20 @@ fn dispatch(
             "text/css; charset=utf-8",
             assets.utilities_css(root).into_bytes(),
         ))),
+        // The named multi-theme stylesheet — every theme as a
+        // `:root[data-theme="…"]` block over the framework's semantic tokens.
+        // Fully static (project-independent); link it once and switch themes by
+        // setting `data-theme` on `<html>`.
+        "/themes.css" => Reply::Response(with_no_cache(Response::ok().with_body(
+            "text/css; charset=utf-8",
+            akurai_css::theme::themes_css().into_bytes(),
+        ))),
+        // The theme registry as JSON (slug/family/label/variant) so the picker is
+        // data-driven and never drifts from the generated CSS.
+        "/themes.json" => Reply::Response(with_no_cache(Response::ok().with_body(
+            "application/json; charset=utf-8",
+            akurai_css::theme::registry_json().into_bytes(),
+        ))),
         // Asset fingerprint manifest: plain path → hashed path. Empty in dev.
         "/api/_assets" => Reply::Response(json(200, &assets.manifest.to_json())),
         // SEO essentials, derived from backend/routes.json + page.json.
diff --git a/crates/css/src/lib.rs b/crates/css/src/lib.rs
index 0af5bdc..d52dcd8 100644
--- a/crates/css/src/lib.rs
+++ b/crates/css/src/lib.rs
@@ -29,6 +29,7 @@
 
 pub mod generate;
 pub mod scan;
+pub mod theme;
 pub mod utilities;
 
 /// Scan every source for used class tokens and return a minimal stylesheet
diff --git a/crates/css/src/schemes/akurai-light.yaml b/crates/css/src/schemes/akurai-light.yaml
new file mode 100644
index 0000000..f062330
--- /dev/null
+++ b/crates/css/src/schemes/akurai-light.yaml
@@ -0,0 +1,21 @@
+system: "base16"
+name: "AkurAI Light"
+author: "AkurAI (light sibling of the default theme)"
+variant: "light"
+palette:
+  base00: "#f7f8fb"
+  base01: "#eef1f6"
+  base02: "#e2e7f0"
+  base03: "#cdd5e2"
+  base04: "#5b657d"
+  base05: "#1d2430"
+  base06: "#131820"
+  base07: "#0a0c11"
+  base08: "#d92d20"
+  base09: "#c4630a"
+  base0A: "#b7791f"
+  base0B: "#1f9d57"
+  base0C: "#2570d4"
+  base0D: "#2e56b8"
+  base0E: "#6d4bd8"
+  base0F: "#b54708"
diff --git a/crates/css/src/schemes/akurai.yaml b/crates/css/src/schemes/akurai.yaml
new file mode 100644
index 0000000..728d7f5
--- /dev/null
+++ b/crates/css/src/schemes/akurai.yaml
@@ -0,0 +1,21 @@
+system: "base16"
+name: "AkurAI"
+author: "AkurAI (default — mirrors the framework :root tokens)"
+variant: "dark"
+palette:
+  base00: "#0a0c11"
+  base01: "#0f131b"
+  base02: "#141925"
+  base03: "#1a2030"
+  base04: "#8a93a8"
+  base05: "#e7ebf3"
+  base06: "#f0f3fa"
+  base07: "#ffffff"
+  base08: "#ff6b6b"
+  base09: "#ff8f6b"
+  base0A: "#f5b945"
+  base0B: "#3ddc84"
+  base0C: "#58b2ff"
+  base0D: "#5b8cff"
+  base0E: "#8a6bff"
+  base0F: "#ff6b9d"
diff --git a/crates/css/src/schemes/catppuccin-latte.yaml b/crates/css/src/schemes/catppuccin-latte.yaml
new file mode 100644
index 0000000..1986588
--- /dev/null
+++ b/crates/css/src/schemes/catppuccin-latte.yaml
@@ -0,0 +1,21 @@
+system: "base16"
+name: "Catppuccin Latte"
+author: "https://github.com/catppuccin/catppuccin"
+variant: "light"
+palette:
+  base00: "#eff1f5" # base
+  base01: "#e6e9ef" # mantle
+  base02: "#ccd0da" # surface0
+  base03: "#bcc0cc" # surface1
+  base04: "#acb0be" # surface2
+  base05: "#4c4f69" # text
+  base06: "#dc8a78" # rosewater
+  base07: "#7287fd" # lavender
+  base08: "#d20f39" # red
+  base09: "#fe640b" # peach
+  base0A: "#df8e1d" # yellow
+  base0B: "#40a02b" # green
+  base0C: "#179299" # teal
+  base0D: "#1e66f5" # blue
+  base0E: "#8839ef" # mauve
+  base0F: "#dd7878" # flamingo
diff --git a/crates/css/src/schemes/catppuccin-mocha.yaml b/crates/css/src/schemes/catppuccin-mocha.yaml
new file mode 100644
index 0000000..998e7c0
--- /dev/null
+++ b/crates/css/src/schemes/catppuccin-mocha.yaml
@@ -0,0 +1,21 @@
+system: "base16"
+name: "Catppuccin Mocha"
+author: "https://github.com/catppuccin/catppuccin"
+variant: "dark"
+palette:
+  base00: "#1e1e2e" # base
+  base01: "#181825" # mantle
+  base02: "#313244" # surface0
+  base03: "#45475a" # surface1
+  base04: "#585b70" # surface2
+  base05: "#cdd6f4" # text
+  base06: "#f5e0dc" # rosewater
+  base07: "#b4befe" # lavender
+  base08: "#f38ba8" # red
+  base09: "#fab387" # peach
+  base0A: "#f9e2af" # yellow
+  base0B: "#a6e3a1" # green
+  base0C: "#94e2d5" # teal
+  base0D: "#89b4fa" # blue
+  base0E: "#cba6f7" # mauve
+  base0F: "#f2cdcd" # flamingo
diff --git a/crates/css/src/schemes/claude-code-light.yaml b/crates/css/src/schemes/claude-code-light.yaml
new file mode 100644
index 0000000..0633e27
--- /dev/null
+++ b/crates/css/src/schemes/claude-code-light.yaml
@@ -0,0 +1,21 @@
+system: "base16"
+name: "Claude Code Light"
+author: "AkurAI (Anthropic-inspired: warm cream + coral clay accent)"
+variant: "light"
+palette:
+  base00: "#f5f1e8"
+  base01: "#ece7da"
+  base02: "#e0dac9"
+  base03: "#cfc8b5"
+  base04: "#6b6457"
+  base05: "#3d3a34"
+  base06: "#2a2823"
+  base07: "#1f1e1b"
+  base08: "#bf4d43"
+  base09: "#b07a2e"
+  base0A: "#b07a2e"
+  base0B: "#5f7a3a"
+  base0C: "#3f7a80"
+  base0D: "#c15f3c"
+  base0E: "#8a5a7e"
+  base0F: "#a0522d"
diff --git a/crates/css/src/schemes/claude-code.yaml b/crates/css/src/schemes/claude-code.yaml
new file mode 100644
index 0000000..69145a1
--- /dev/null
+++ b/crates/css/src/schemes/claude-code.yaml
@@ -0,0 +1,21 @@
+system: "base16"
+name: "Claude Code"
+author: "AkurAI (Anthropic-inspired: warm near-black + coral clay accent)"
+variant: "dark"
+palette:
+  base00: "#1f1e1b"
+  base01: "#262320"
+  base02: "#2f2b27"
+  base03: "#3a352f"
+  base04: "#8a8279"
+  base05: "#e6e0d6"
+  base06: "#f0ebe2"
+  base07: "#faf6ef"
+  base08: "#bf4d43"
+  base09: "#d9a05b"
+  base0A: "#d9a05b"
+  base0B: "#8a9a5b"
+  base0C: "#5b9aa0"
+  base0D: "#cc785c"
+  base0E: "#a87b9e"
+  base0F: "#b0623a"
diff --git a/crates/css/src/schemes/dracula.yaml b/crates/css/src/schemes/dracula.yaml
new file mode 100644
index 0000000..5001d78
--- /dev/null
+++ b/crates/css/src/schemes/dracula.yaml
@@ -0,0 +1,29 @@
+system: "base16"
+name: "Dracula"
+author: "clach04 (https://github.com/clach04)"
+description: "based on https://github.com/dracula/draculatheme.com/blob/main/content/spec.mdx"
+variant: "dark"
+# Subset of base24 Dracula
+# https://github.com/tinted-theming/base24/blob/main/styling.md
+# https://github.com/tinted-theming/home/blob/main/styling.md
+# https://github.com/chriskempson/base16/blob/main/styling.md
+# Typically expect order of base00-base07 (dark to light versus light to dark) to differ depending on variant
+# based on https://github.com/dracula/draculatheme.com/blob/main/content/spec.mdx
+palette:
+  base00: "#282a36"  # Default Background  # Dracula Background
+  base01: "#21222c"  # Darker Background (Darkest Gray) (Used for status bars, line number and folding marks) # Dracula Background Dark / AnsiBlack
+  base02: "#44475A"  # Selection Background (Bright Black) # Dracula Selection
+  base03: "#6272a4"  # Comments, Invisibles, Line Highlighting (Grey) # Dracula Current Line / Comment / AnsiBrightBlack
+  base04: "#9ea8c7"  # Dark/Light Foreground (Used for status bars) (Light Grey) # NOT a Dracula color!
+  base05: "#f8f8f2"  # Default Foreground, Caret, Delimiters, Operators (Foreground) # Dracula Foreground / AnsiWhite
+  base06: "#f8f8f2"  # Dark Foreground (Not often used) (White)  # TODO review, currently same as above base05
+  base07: "#ffffff"  # Dark Background (Not often used) (Bright White) # Dracula AnsiBrightWhite
+  # ANSI8 (without Black/White) + 2 (Orange + Dark Red/Brown)
+  base08: "#ff5555"  # Red
+  base09: "#FFB86C"  # Orange
+  base0A: "#f1fa8c"  # Yellow
+  base0B: "#50fa7b"  # Green
+  base0C: "#8be9fd"  # Aqua / Cyan
+  base0D: "#bd93f9"  # Blue
+  base0E: "#ff79c6"  # Purple / Magenta
+  base0F: "#993333"  # Dark Red or Brown, derive dark red from base08 - derived via https://www.color-hex.com/color/ff5555
diff --git a/crates/css/src/schemes/gruvbox-dark-medium.yaml b/crates/css/src/schemes/gruvbox-dark-medium.yaml
new file mode 100644
index 0000000..79660d2
--- /dev/null
+++ b/crates/css/src/schemes/gruvbox-dark-medium.yaml
@@ -0,0 +1,21 @@
+system: "base16"
+name: "Gruvbox dark, medium"
+author: "Dawid Kurek (dawikur@gmail.com), morhetz (https://github.com/morhetz/gruvbox)"
+variant: "dark"
+palette:
+  base00: "#282828" # ----
+  base01: "#3c3836" # ---
+  base02: "#504945" # --
+  base03: "#665c54" # -
+  base04: "#bdae93" # +
+  base05: "#d5c4a1" # ++
+  base06: "#ebdbb2" # +++
+  base07: "#fbf1c7" # ++++
+  base08: "#fb4934" # red
+  base09: "#fe8019" # orange
+  base0A: "#fabd2f" # yellow
+  base0B: "#b8bb26" # green
+  base0C: "#8ec07c" # aqua/cyan
+  base0D: "#83a598" # blue
+  base0E: "#d3869b" # purple
+  base0F: "#d65d0e" # brown
diff --git a/crates/css/src/schemes/gruvbox-light-medium.yaml b/crates/css/src/schemes/gruvbox-light-medium.yaml
new file mode 100644
index 0000000..9b09ca8
--- /dev/null
+++ b/crates/css/src/schemes/gruvbox-light-medium.yaml
@@ -0,0 +1,21 @@
+system: "base16"
+name: "Gruvbox light, medium"
+author: "Dawid Kurek (dawikur@gmail.com), morhetz (https://github.com/morhetz/gruvbox)"
+variant: "light"
+palette:
+  base00: "#fbf1c7" # ----
+  base01: "#ebdbb2" # ---
+  base02: "#d5c4a1" # --
+  base03: "#bdae93" # -
+  base04: "#665c54" # +
+  base05: "#504945" # ++
+  base06: "#3c3836" # +++
+  base07: "#282828" # ++++
+  base08: "#9d0006" # red
+  base09: "#af3a03" # orange
+  base0A: "#b57614" # yellow
+  base0B: "#79740e" # green
+  base0C: "#427b58" # aqua/cyan
+  base0D: "#076678" # blue
+  base0E: "#8f3f71" # purple
+  base0F: "#d65d0e" # brown
diff --git a/crates/css/src/schemes/nord-light.yaml b/crates/css/src/schemes/nord-light.yaml
new file mode 100644
index 0000000..25f4a85
--- /dev/null
+++ b/crates/css/src/schemes/nord-light.yaml
@@ -0,0 +1,21 @@
+system: "base16"
+name: "Nord Light"
+author: "AkurAI (derived — community light inversion of Nord; not an official Nord theme)"
+variant: "light"
+palette:
+  base00: "#eceff4"
+  base01: "#e5e9f0"
+  base02: "#d8dee9"
+  base03: "#aebacf"
+  base04: "#4c566a"
+  base05: "#2e3440"
+  base06: "#3b4252"
+  base07: "#434c5e"
+  base08: "#b1444e"
+  base09: "#bf6a3f"
+  base0A: "#a9863a"
+  base0B: "#6e8b4f"
+  base0C: "#2d7d8a"
+  base0D: "#5e81ac"
+  base0E: "#8a5a7e"
+  base0F: "#5e81ac"
diff --git a/crates/css/src/schemes/nord.yaml b/crates/css/src/schemes/nord.yaml
new file mode 100644
index 0000000..1111ac8
--- /dev/null
+++ b/crates/css/src/schemes/nord.yaml
@@ -0,0 +1,21 @@
+system: "base16"
+name: "Nord"
+author: "arcticicestudio"
+variant: "dark"
+palette:
+  base00: "#2E3440"
+  base01: "#3B4252"
+  base02: "#434C5E"
+  base03: "#4C566A"
+  base04: "#D8DEE9"
+  base05: "#E5E9F0"
+  base06: "#ECEFF4"
+  base07: "#8FBCBB"
+  base08: "#BF616A"
+  base09: "#D08770"
+  base0A: "#EBCB8B"
+  base0B: "#A3BE8C"
+  base0C: "#88C0D0"
+  base0D: "#81A1C1"
+  base0E: "#B48EAD"
+  base0F: "#5E81AC"
diff --git a/crates/css/src/schemes/rose-pine-dawn.yaml b/crates/css/src/schemes/rose-pine-dawn.yaml
new file mode 100644
index 0000000..8a2cdf3
--- /dev/null
+++ b/crates/css/src/schemes/rose-pine-dawn.yaml
@@ -0,0 +1,22 @@
+system: "base16"
+name: "Rosé Pine Dawn"
+author: "Emilia Dunfelt <edun@dunfelt.se>"
+slug: "rose-pine-dawn"
+variant: "light"
+palette:
+  base00: "#faf4ed"
+  base01: "#fffaf3"
+  base02: "#f2e9de"
+  base03: "#9893a5"
+  base04: "#797593"
+  base05: "#575279"
+  base06: "#575279"
+  base07: "#cecacd"
+  base08: "#b4637a"
+  base09: "#ea9d34"
+  base0A: "#d7827e"
+  base0B: "#286983"
+  base0C: "#56949f"
+  base0D: "#907aa9"
+  base0E: "#ea9d34"
+  base0F: "#cecacd"
diff --git a/crates/css/src/schemes/rose-pine.yaml b/crates/css/src/schemes/rose-pine.yaml
new file mode 100644
index 0000000..ed48e79
--- /dev/null
+++ b/crates/css/src/schemes/rose-pine.yaml
@@ -0,0 +1,22 @@
+system: "base16"
+name: "Rosé Pine"
+author: "Emilia Dunfelt <edun@dunfelt.se>"
+slug: "rose-pine"
+variant: "dark"
+palette:
+  base00: "#191724"
+  base01: "#1f1d2e"
+  base02: "#26233a"
+  base03: "#6e6a86"
+  base04: "#908caa"
+  base05: "#e0def4"
+  base06: "#e0def4"
+  base07: "#524f67"
+  base08: "#eb6f92"
+  base09: "#f6c177"
+  base0A: "#ebbcba"
+  base0B: "#31748f"
+  base0C: "#9ccfd8"
+  base0D: "#c4a7e7"
+  base0E: "#f6c177"
+  base0F: "#524f67"
diff --git a/crates/css/src/schemes/solarized-dark.yaml b/crates/css/src/schemes/solarized-dark.yaml
new file mode 100644
index 0000000..bd367f7
--- /dev/null
+++ b/crates/css/src/schemes/solarized-dark.yaml
@@ -0,0 +1,21 @@
+system: "base16"
+name: "Solarized Dark"
+author: "Ethan Schoonover (modified by aramisgithub)"
+variant: "dark"
+palette:
+  base00: "#002b36"
+  base01: "#073642"
+  base02: "#586e75"
+  base03: "#657b83"
+  base04: "#839496"
+  base05: "#93a1a1"
+  base06: "#eee8d5"
+  base07: "#fdf6e3"
+  base08: "#dc322f"
+  base09: "#cb4b16"
+  base0A: "#b58900"
+  base0B: "#859900"
+  base0C: "#2aa198"
+  base0D: "#268bd2"
+  base0E: "#6c71c4"
+  base0F: "#d33682"
diff --git a/crates/css/src/schemes/solarized-light.yaml b/crates/css/src/schemes/solarized-light.yaml
new file mode 100644
index 0000000..7c040d5
--- /dev/null
+++ b/crates/css/src/schemes/solarized-light.yaml
@@ -0,0 +1,21 @@
+system: "base16"
+name: "Solarized Light"
+author: "Ethan Schoonover (modified by aramisgithub)"
+variant: "light"
+palette:
+  base00: "#fdf6e3"
+  base01: "#eee8d5"
+  base02: "#93a1a1"
+  base03: "#839496"
+  base04: "#657b83"
+  base05: "#586e75"
+  base06: "#073642"
+  base07: "#002b36"
+  base08: "#dc322f"
+  base09: "#cb4b16"
+  base0A: "#b58900"
+  base0B: "#859900"
+  base0C: "#2aa198"
+  base0D: "#268bd2"
+  base0E: "#6c71c4"
+  base0F: "#d33682"
diff --git a/crates/css/src/schemes/tokyo-night-dark.yaml b/crates/css/src/schemes/tokyo-night-dark.yaml
new file mode 100644
index 0000000..0db9962
--- /dev/null
+++ b/crates/css/src/schemes/tokyo-night-dark.yaml
@@ -0,0 +1,21 @@
+system: "base16"
+name: "Tokyo Night Dark"
+author: "Michaël Ball"
+variant: "dark"
+palette:
+  base00: "#1A1B26"
+  base01: "#16161E"
+  base02: "#2F3549"
+  base03: "#444B6A"
+  base04: "#787C99"
+  base05: "#A9B1D6"
+  base06: "#CBCCD1"
+  base07: "#D5D6DB"
+  base08: "#C0CAF5"
+  base09: "#A9B1D6"
+  base0A: "#0DB9D7"
+  base0B: "#9ECE6A"
+  base0C: "#B4F9F8"
+  base0D: "#2AC3DE"
+  base0E: "#BB9AF7"
+  base0F: "#F7768E"
diff --git a/crates/css/src/schemes/tokyo-night-light.yaml b/crates/css/src/schemes/tokyo-night-light.yaml
new file mode 100644
index 0000000..c437fea
--- /dev/null
+++ b/crates/css/src/schemes/tokyo-night-light.yaml
@@ -0,0 +1,21 @@
+system: "base16"
+name: "Tokyo Night Light"
+author: "Michaël Ball"
+variant: "light"
+palette:
+  base00: "#D5D6DB"
+  base01: "#CBCCD1"
+  base02: "#DFE0E5"
+  base03: "#9699A3"
+  base04: "#4C505E"
+  base05: "#343B59"
+  base06: "#1A1B26"
+  base07: "#1A1B26"
+  base08: "#343B58"
+  base09: "#965027"
+  base0A: "#166775"
+  base0B: "#485E30"
+  base0C: "#3E6968"
+  base0D: "#34548A"
+  base0E: "#5A4A78"
+  base0F: "#8C4351"
diff --git a/crates/css/src/theme.rs b/crates/css/src/theme.rs
new file mode 100644
index 0000000..d42070e
--- /dev/null
+++ b/crates/css/src/theme.rs
@@ -0,0 +1,421 @@
+//! Named multi-theme generator — pure `std`, zero dependencies.
+//!
+//! Emits one `themes.css` holding every theme as a `:root[data-theme="…"]`
+//! token block over the framework's existing semantic tokens (`--bg`, `--fg`,
+//! `--accent`, `--ok`, `--danger`, …). Components never change: they read the
+//! same token names, the active `data-theme` swaps the values.
+//!
+//! Themes are [base16](https://github.com/tinted-theming/schemes) colour
+//! schemes — 16 perceptual slots (`base00`–`base0F`). The mapping below is
+//! **polarity-agnostic**: a base16 *light* scheme inverts the `base00`→`base07`
+//! ramp itself, so the same slot→token mapping produces a correct light theme
+//! with no special-casing. That is what lets one rule cover dark and light.
+//!
+//! Slot → token (base16 convention):
+//! `base00` background · `base01` recessed · `base02` panel · `base03` raised /
+//! border / dim · `base04` muted text · `base05` text · `base08` red/danger ·
+//! `base0A` yellow/warn · `base0B` green/ok · `base0C` cyan/info ·
+//! `base0D` blue/accent · `base0E` magenta/accent-2.
+//!
+//! The first registered scheme (`akurai`) is also emitted as the bare `:root`
+//! default so a page with no `data-theme` set still themes correctly.
+
+/// A parsed base16 scheme: display name, light/dark polarity, and the 16 colours
+/// (`palette[0]` = `base00` … `palette[15]` = `base0F`), each a CSS hex string
+/// including the leading `#`.
+#[derive(Clone)]
+pub struct Scheme {
+    pub name: String,
+    pub variant: Variant,
+    pub palette: [String; 16],
+}
+
+/// Theme polarity. Drives `color-scheme:` (so form controls/scrollbars match)
+/// and the default shadow depth.
+#[derive(Clone, Copy, PartialEq, Eq)]
+pub enum Variant {
+    Dark,
+    Light,
+}
+
+impl Variant {
+    fn css(self) -> &'static str {
+        match self {
+            Variant::Dark => "dark",
+            Variant::Light => "light",
+        }
+    }
+}
+
+/// One registry row: stable `slug` (the `data-theme` value and switcher key),
+/// human `family` for grouping in the picker, and the raw vendored scheme text.
+struct Entry {
+    slug: &'static str,
+    family: &'static str,
+    raw: &'static str,
+}
+
+/// The shipped themes, in picker order. The first row is the default `:root`.
+/// Adding a theme is data-only: vendor a base16 scheme and add a line here.
+const SCHEMES: &[Entry] = &[
+    Entry {
+        slug: "akurai",
+        family: "AkurAI",
+        raw: include_str!("schemes/akurai.yaml"),
+    },
+    Entry {
+        slug: "akurai-light",
+        family: "AkurAI",
+        raw: include_str!("schemes/akurai-light.yaml"),
+    },
+    Entry {
+        slug: "claude-code",
+        family: "Claude Code",
+        raw: include_str!("schemes/claude-code.yaml"),
+    },
+    Entry {
+        slug: "claude-code-light",
+        family: "Claude Code",
+        raw: include_str!("schemes/claude-code-light.yaml"),
+    },
+    Entry {
+        slug: "nord",
+        family: "Nord",
+        raw: include_str!("schemes/nord.yaml"),
+    },
+    Entry {
+        slug: "nord-light",
+        family: "Nord",
+        raw: include_str!("schemes/nord-light.yaml"),
+    },
+    Entry {
+        slug: "catppuccin-mocha",
+        family: "Catppuccin",
+        raw: include_str!("schemes/catppuccin-mocha.yaml"),
+    },
+    Entry {
+        slug: "catppuccin-latte",
+        family: "Catppuccin",
+        raw: include_str!("schemes/catppuccin-latte.yaml"),
+    },
+    Entry {
+        slug: "solarized-dark",
+        family: "Solarized",
+        raw: include_str!("schemes/solarized-dark.yaml"),
+    },
+    Entry {
+        slug: "solarized-light",
+        family: "Solarized",
+        raw: include_str!("schemes/solarized-light.yaml"),
+    },
+    Entry {
+        slug: "gruvbox-dark",
+        family: "Gruvbox",
+        raw: include_str!("schemes/gruvbox-dark-medium.yaml"),
+    },
+    Entry {
+        slug: "gruvbox-light",
+        family: "Gruvbox",
+        raw: include_str!("schemes/gruvbox-light-medium.yaml"),
+    },
+    Entry {
+        slug: "tokyo-night",
+        family: "Tokyo Night",
+        raw: include_str!("schemes/tokyo-night-dark.yaml"),
+    },
+    Entry {
+        slug: "tokyo-night-light",
+        family: "Tokyo Night",
+        raw: include_str!("schemes/tokyo-night-light.yaml"),
+    },
+    Entry {
+        slug: "rose-pine",
+        family: "Rosé Pine",
+        raw: include_str!("schemes/rose-pine.yaml"),
+    },
+    Entry {
+        slug: "rose-pine-dawn",
+        family: "Rosé Pine",
+        raw: include_str!("schemes/rose-pine-dawn.yaml"),
+    },
+    Entry {
+        slug: "dracula",
+        family: "Dracula",
+        raw: include_str!("schemes/dracula.yaml"),
+    },
+];
+
+/// Public metadata for one theme — everything the front-end switcher needs.
+pub struct ThemeMeta {
+    pub slug: &'static str,
+    pub family: &'static str,
+    pub label: String,
+    pub variant: Variant,
+}
+
+/// The theme registry as structured metadata, in picker order.
+pub fn registry() -> Vec<ThemeMeta> {
+    SCHEMES
+        .iter()
+        .map(|e| {
+            let s = parse(e.raw);
+            ThemeMeta {
+                slug: e.slug,
+                family: e.family,
+                label: s.name,
+                variant: s.variant,
+            }
+        })
+        .collect()
+}
+
+/// The registry as JSON, served at `/themes.json` so the switcher is data-driven
+/// and never drifts from the generated CSS. Shape:
+/// `[{"slug","family","label","variant"}, …]`.
+pub fn registry_json() -> String {
+    let mut out = String::from("[");
+    for (i, m) in registry().iter().enumerate() {
+        if i > 0 {
+            out.push(',');
+        }
+        out.push_str(&format!(
+            r#"{{"slug":"{}","family":"{}","label":"{}","variant":"{}"}}"#,
+            esc(m.slug),
+            esc(m.family),
+            esc(&m.label),
+            m.variant.css()
+        ));
+    }
+    out.push(']');
+    out
+}
+
+/// Render the full multi-theme stylesheet. The first scheme is emitted as the
+/// bare `:root` default; every scheme (including the first) is also emitted as
+/// `:root[data-theme="slug"]` so an explicit selection always wins.
+pub fn themes_css() -> String {
+    let mut out = String::new();
+    out.push_str(
+        "/* AkurAI-Framework themes — generated by akurai-css::theme. Do not edit by hand. */\n",
+    );
+    for (i, e) in SCHEMES.iter().enumerate() {
+        let s = parse(e.raw);
+        if i == 0 {
+            out.push_str(":root{");
+            out.push_str(&vars(&s));
+            out.push_str("}\n");
+        }
+        out.push_str(&format!(":root[data-theme=\"{}\"]{{", e.slug));
+        out.push_str(&vars(&s));
+        out.push_str("}\n");
+    }
+    out
+}
+
+/// Map a parsed scheme's 16 slots to the framework's semantic token set.
+fn vars(s: &Scheme) -> String {
+    let p = &s.palette; // p[0]=base00 … p[15]=base0F
+    let shadow = match s.variant {
+        Variant::Dark => "0 24px 60px -28px rgba(0,0,0,.55)",
+        Variant::Light => "0 18px 45px -24px rgba(20,22,30,.16)",
+    };
+    format!(
+        "--bg:{bg};--bg-2:{bg2};--panel:{panel};--panel-2:{panel2};\
+         --border:{border};--border-2:{border2};\
+         --fg:{fg};--muted:{muted};--dim:{dim};\
+         --accent:{accent};--accent-2:{accent2};\
+         --ok:{ok};--warn:{warn};--info:{info};--danger:{danger};\
+         --shadow:{shadow};color-scheme:{scheme};",
+        bg = p[0],       // base00
+        bg2 = p[1],      // base01
+        panel = p[2],    // base02
+        panel2 = p[3],   // base03
+        border = p[3],   // base03
+        border2 = p[4],  // base04
+        fg = p[5],       // base05
+        muted = p[4],    // base04
+        dim = p[3],      // base03
+        accent = p[13],  // base0D
+        accent2 = p[14], // base0E
+        ok = p[11],      // base0B
+        warn = p[10],    // base0A
+        info = p[12],    // base0C
+        danger = p[8],   // base08
+        shadow = shadow,
+        scheme = s.variant.css(),
+    )
+}
+
+/// Parse a flat base16 scheme document. Tolerant by design: it reads only the
+/// `name:`, `variant:`, and `base00`–`base0F` lines, ignores everything else
+/// (`system`, `author`, blank lines, `# comments` trailing a value), and never
+/// panics on a malformed line. Unknown/missing slots stay `#000000`.
+pub fn parse(text: &str) -> Scheme {
+    let mut name = String::new();
+    let mut variant = Variant::Dark;
+    let mut palette: [String; 16] = Default::default();
+    for slot in palette.iter_mut() {
+        *slot = "#000000".to_string();
+    }
+
+    for line in text.lines() {
+        let line = line.trim();
+        if let Some(rest) = line.strip_prefix("name:") {
+            name = unquote(rest);
+        } else if let Some(rest) = line.strip_prefix("variant:") {
+            variant = match unquote(rest).to_ascii_lowercase().as_str() {
+                "light" => Variant::Light,
+                _ => Variant::Dark,
+            };
+        } else if let Some(rest) = line.strip_prefix("base") {
+            // `00: "#2E3440"  # comment`  →  index 0, value #2E3440
+            let Some((idx_str, val_str)) = rest.split_once(':') else {
+                continue;
+            };
+            let Some(idx) = slot_index(idx_str.trim()) else {
+                continue;
+            };
+            let hex = unquote(val_str);
+            if !hex.is_empty() {
+                palette[idx] = normalize_hex(&hex);
+            }
+        }
+    }
+
+    Scheme {
+        name,
+        variant,
+        palette,
+    }
+}
+
+/// Two-hex-digit base16 slot id (`"00"`..="0F", case-insensitive) → array index.
+fn slot_index(id: &str) -> Option<usize> {
+    if id.len() != 2 {
+        return None;
+    }
+    usize::from_str_radix(id, 16).ok().filter(|&n| n < 16)
+}
+
+/// Take the first double-quoted token on the line, else the first bare token,
+/// stripping a trailing `# comment`. Returns the value without surrounding
+/// quotes or whitespace.
+fn unquote(s: &str) -> String {
+    let s = s.trim();
+    if let Some(start) = s.find('"') {
+        if let Some(end) = s[start + 1..].find('"') {
+            return s[start + 1..start + 1 + end].trim().to_string();
+        }
+    }
+    // No quotes: take up to a comment marker.
+    s.split('#')
+        .next()
+        .unwrap_or("")
+        .trim()
+        .trim_matches('"')
+        .to_string()
+}
+
+/// Ensure a leading `#` and lowercase the hex so output is uniform.
+fn normalize_hex(h: &str) -> String {
+    let h = h.trim();
+    let body = h.strip_prefix('#').unwrap_or(h);
+    format!("#{}", body.to_ascii_lowercase())
+}
+
+/// Minimal JSON string escaping for the registry payload.
+fn esc(s: &str) -> String {
+    s.replace('\\', "\\\\").replace('"', "\\\"")
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    #[test]
+    fn parses_simple_scheme() {
+        let s = parse(super::SCHEMES[0].raw);
+        assert_eq!(s.name, "AkurAI");
+        assert!(matches!(s.variant, Variant::Dark));
+        assert_eq!(s.palette[0], "#0a0c11"); // base00
+        assert_eq!(s.palette[13], "#5b8cff"); // base0D / accent
+    }
+
+    #[test]
+    fn parses_value_with_trailing_comment() {
+        // Dracula's vendored file has `base00: "#282a36"  # Default Background`.
+        let dracula = parse(include_str!("schemes/dracula.yaml"));
+        assert_eq!(dracula.palette[0], "#282a36");
+        assert_eq!(dracula.palette[13], "#bd93f9"); // accent stays clean of comment
+    }
+
+    #[test]
+    fn light_variant_detected() {
+        let latte = parse(include_str!("schemes/catppuccin-latte.yaml"));
+        assert!(matches!(latte.variant, Variant::Light));
+    }
+
+    #[test]
+    fn default_root_and_every_theme_block_present() {
+        let css = themes_css();
+        assert!(css.contains(":root{--bg:#0a0c11"), "default :root missing");
+        // Every registered slug has a data-theme block.
+        for e in SCHEMES {
+            let needle = format!(":root[data-theme=\"{}\"]{{", e.slug);
+            assert!(css.contains(&needle), "missing block for {}", e.slug);
+        }
+        // Semantic mapping reaches the accent slot.
+        assert!(
+            css.contains("--accent:#81a1c1"),
+            "Nord accent (base0D) not mapped"
+        );
+    }
+
+    #[test]
+    fn color_scheme_tracks_variant() {
+        let css = themes_css();
+        assert!(css.contains("data-theme=\"claude-code-light\"]{"));
+        // The light block must declare color-scheme:light.
+        let block = css
+            .split("data-theme=\"claude-code-light\"]{")
+            .nth(1)
+            .unwrap();
+        let block = block.split('}').next().unwrap();
+        assert!(
+            block.contains("color-scheme:light"),
+            "light theme not marked"
+        );
+    }
+
+    #[test]
+    fn registry_json_is_wellformed() {
+        let json = registry_json();
+        assert!(json.starts_with('['));
+        assert!(json.ends_with(']'));
+        assert!(json.contains(r#""slug":"nord""#));
+        assert!(json.contains(r#""variant":"light""#));
+        assert!(json.contains(r#""family":"Claude Code""#));
+    }
+
+    #[test]
+    fn every_theme_maps_all_required_tokens() {
+        for e in SCHEMES {
+            let s = parse(e.raw);
+            let v = vars(&s);
+            for tok in [
+                "--bg:",
+                "--fg:",
+                "--accent:",
+                "--ok:",
+                "--warn:",
+                "--danger:",
+                "--panel:",
+                "--border:",
+                "--muted:",
+                "color-scheme:",
+            ] {
+                assert!(v.contains(tok), "{} missing {}", e.slug, tok);
+            }
+        }
+    }
+}
diff --git a/site/backend/page.json b/site/backend/page.json
index 81b91b9..2e5fe49 100644
--- a/site/backend/page.json
+++ b/site/backend/page.json
@@ -2,7 +2,7 @@
   "site_url": "https://akurai-framework.olibuijr.com",
   "title": "AkurAI/Framework — a single-binary, pure-Rust web framework",
   "description": "A pure-Rust web framework: embedded database, auto-generated APIs, server-side templates, and built-in semantic search — in one static binary with no runtime dependencies and no build step.",
-  "version": "v0.7.0",
+  "version": "v0.8.0",
   "year": 2026,
   "nav": [
     {
@@ -334,7 +334,7 @@
     },
     {
       "kind": "muted",
-      "label": "v0.7.0"
+      "label": "v0.8.0"
     },
     {
       "kind": "muted",
diff --git a/site/content/changelog.md b/site/content/changelog.md
index bae2845..9d806a7 100644
--- a/site/content/changelog.md
+++ b/site/content/changelog.md
@@ -6,6 +6,15 @@ Notable changes to AkurAI/Framework. The format follows
 
 ## Unreleased
 
+## 0.8.0 - 2026-06-26
+
+### Added
+- **Named themes** — pick from 17 colour themes (Nord, Claude Code, Catppuccin,
+  Solarized, Gruvbox, Tokyo Night, Rosé Pine, Dracula, AkurAI) in light and dark,
+  generated by the Rust theme engine and served at `/themes.css`. Switch with the
+  header picker; your choice is remembered and the OS light/dark setting is the
+  default. Any app on the framework gets the same themes for free.
+
 ### Docs
 - Added an AkurAI-RustAgent agent OS UI contract for channel-aware timelines,
   confirmations, questions, edit previews, artifacts, and typed receipts.
diff --git a/site/frontend/header.html b/site/frontend/header.html
index f57e2b5..8f05970 100644
--- a/site/frontend/header.html
+++ b/site/frontend/header.html
@@ -9,11 +9,26 @@
     <meta property="og:title" content="{% if meta_title %}{{ meta_title }}{% else %}{{ title }}{% endif %}" />
     <meta property="og:description" content="{% if meta_description %}{{ meta_description }}{% else %}{{ description }}{% endif %}" />
     <link rel="stylesheet" href="/styles.css" />
+    <!-- Named multi-theme tokens (:root[data-theme]); must come after styles.css
+         so the active theme's values win. -->
+    <link rel="stylesheet" href="/themes.css" />
+    <!-- Flash-guard: set the active theme before first paint. -->
+    <script>
+      (function () {
+        try {
+          var t = localStorage.getItem("akurai-theme");
+          if (!t) t = matchMedia("(prefers-color-scheme: light)").matches ? "akurai-light" : "akurai";
+          document.documentElement.setAttribute("data-theme", t);
+        } catch (e) {}
+      })();
+    </script>
+    <script type="module" src="/theme.js"></script>
   </head>
   <body>
     <header class="nav">
       <a class="brand" href="/"><span class="mark">◆</span> AkurAI<span class="brand-dim">/Framework</span></a>
       <nav class="nav-links">
         {% for link in nav %}<a href="{{ link.href }}">{{ link.label }}</a>{% endfor %}
+        <span class="theme-picker" data-theme-picker></span>
       </nav>
     </header>
diff --git a/site/frontend/styles.css b/site/frontend/styles.css
index 591917b..f601dca 100644
--- a/site/frontend/styles.css
+++ b/site/frontend/styles.css
@@ -12,8 +12,8 @@
 html, body { margin: 0; }
 html { scroll-behavior: smooth; }
 body {
-  background: radial-gradient(1100px 560px at 75% -10%, #1a2236, transparent),
-              radial-gradient(820px 440px at 5% 0%, #181433, transparent), var(--bg);
+  background: radial-gradient(1100px 560px at 75% -10%, color-mix(in srgb, var(--accent) 10%, transparent), transparent),
+              radial-gradient(820px 440px at 5% 0%, color-mix(in srgb, var(--accent-2) 9%, transparent), transparent), var(--bg);
   color: var(--fg);
   font: 16px/1.65 ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
   min-height: 100vh;
@@ -1756,3 +1756,29 @@ summary { cursor: pointer; font-weight: 600; font-size: 14px; }
 .tc-content-geomap__markers { color: var(--warn); }
 .tc-content-geomap__note { color: var(--dim); font-style: italic; }
 .tc-content-geomap__note code { font-family: var(--mono); font-size: 11px; background: var(--bg); border: 1px solid var(--border); padding: 1px 5px; border-radius: 4px; }
+
+/* Theme picker (header) — token-driven so it re-themes with everything else. */
+.theme-picker { display: inline-flex; align-items: center; }
+.theme-select {
+  appearance: none;
+  -webkit-appearance: none;
+  font: inherit;
+  font-size: 13px;
+  font-weight: 500;
+  color: var(--fg);
+  background: var(--panel);
+  border: 1px solid var(--border);
+  border-radius: 8px;
+  padding: 6px 28px 6px 11px;
+  cursor: pointer;
+  line-height: 1.2;
+  background-image: linear-gradient(45deg, transparent 50%, var(--muted) 50%), linear-gradient(135deg, var(--muted) 50%, transparent 50%);
+  background-position: calc(100% - 15px) center, calc(100% - 10px) center;
+  background-size: 5px 5px, 5px 5px;
+  background-repeat: no-repeat;
+  transition: border-color .15s ease, background-color .15s ease;
+}
+.theme-select:hover { border-color: var(--border-2); }
+.theme-select:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }
+.theme-select optgroup { color: var(--fg); background: var(--panel); font-weight: 700; }
+.theme-select option { color: var(--fg); background: var(--panel); font-weight: 500; }
diff --git a/site/frontend/theme.js b/site/frontend/theme.js
new file mode 100644
index 0000000..5f37166
--- /dev/null
+++ b/site/frontend/theme.js
@@ -0,0 +1,96 @@
+// AkurAI theme switcher — native ESM, zero deps.
+//
+// Themes are generated by the framework (`/themes.css`, registry `/themes.json`)
+// as `:root[data-theme="…"]` token blocks. This module only chooses the active
+// `data-theme` on <html>, persists it, and builds a small picker. To avoid a
+// flash of the wrong theme, set the attribute as early as possible — the inline
+// snippet in <head> (see `theme-init.js` contents below) runs before paint; this
+// module then renders the UI.
+
+const STORAGE_KEY = "akurai-theme";
+// Default per polarity when the user has never chosen — honors the OS setting.
+const DEFAULT_DARK = "akurai";
+const DEFAULT_LIGHT = "akurai-light";
+
+export function prefersLight() {
+  return window.matchMedia && window.matchMedia("(prefers-color-scheme: light)").matches;
+}
+
+export function defaultTheme() {
+  return prefersLight() ? DEFAULT_LIGHT : DEFAULT_DARK;
+}
+
+export function currentTheme() {
+  return localStorage.getItem(STORAGE_KEY) || document.documentElement.getAttribute("data-theme") || defaultTheme();
+}
+
+export function applyTheme(slug) {
+  document.documentElement.setAttribute("data-theme", slug);
+  try {
+    localStorage.setItem(STORAGE_KEY, slug);
+  } catch {
+    /* storage may be unavailable (private mode); theme still applies for the session */
+  }
+}
+
+// Fetch the registry and build a grouped picker. `mount` is the element the
+// <select> (and label) are appended to. Returns the registry array.
+export async function mountPicker(mount) {
+  if (!mount) return [];
+  let themes = [];
+  try {
+    const res = await fetch("/themes.json", { headers: { accept: "application/json" } });
+    themes = await res.json();
+  } catch {
+    return [];
+  }
+  if (!Array.isArray(themes) || themes.length === 0) return [];
+
+  const select = document.createElement("select");
+  select.className = "theme-select";
+  select.setAttribute("aria-label", "Color theme");
+
+  // Group by family so light/dark siblings sit together.
+  const families = [];
+  const byFamily = new Map();
+  for (const t of themes) {
+    if (!byFamily.has(t.family)) {
+      byFamily.set(t.family, []);
+      families.push(t.family);
+    }
+    byFamily.get(t.family).push(t);
+  }
+  for (const family of families) {
+    const group = document.createElement("optgroup");
+    group.label = family;
+    for (const t of byFamily.get(family)) {
+      const opt = document.createElement("option");
+      opt.value = t.slug;
+      // Label already carries the name; append a polarity hint when ambiguous.
+      opt.textContent = t.label;
+      group.appendChild(opt);
+    }
+    select.appendChild(group);
+  }
+
+  const active = currentTheme();
+  select.value = active;
+  applyTheme(active);
+
+  select.addEventListener("change", () => applyTheme(select.value));
+  mount.appendChild(select);
+  return themes;
+}
+
+// Auto-mount: if a `[data-theme-picker]` element exists, fill it on load.
+if (typeof document !== "undefined") {
+  const boot = () => {
+    const slot = document.querySelector("[data-theme-picker]");
+    if (slot) mountPicker(slot);
+  };
+  if (document.readyState === "loading") {
+    document.addEventListener("DOMContentLoaded", boot);
+  } else {
+    boot();
+  }
+}