diff --git a/Cargo.lock b/Cargo.lock
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -491,6 +491,7 @@ dependencies = [
  "chrono",
  "cookie",
  "cookie_store_wrapper",
+ "craft-ui",
  "directories",
  "eframe",
  "egui",
@@ -553,6 +554,15 @@ version = "1.0.1"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "175571dd1d178ced59193a6fc02dde1b972eb0bc56c892cde9beeceac5bf0f6b"
 
+[[package]]
+name = "ash"
+version = "0.38.0+1.3.281"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0bb44936d800fea8f016d7f2311c6a4f97aebd5dc86f09906139ec848cf3a46f"
+dependencies = [
+ "libloading 0.8.6",
+]
+
 [[package]]
 name = "ashpd"
 version = "0.11.1"
@@ -1554,6 +1564,8 @@ version = "0.13.1"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "af491d569909a7e4dee0ad7db7f5341fef5c614d5b8ec8cf765732aba3cff681"
 dependencies = [
+ "serde",
+ "termcolor",
  "unicode-width",
 ]
 
@@ -1812,6 +1824,14 @@ dependencies = [
  "libc",
 ]
 
+[[package]]
+name = "craft-ui"
+version = "0.1.0"
+source = "git+https://github.com/storytold/craft-libs?rev=8134693a69e0fca8ec7913ad5a95dedc05daad74#8134693a69e0fca8ec7913ad5a95dedc05daad74"
+dependencies = [
+ "egui",
+]
+
 [[package]]
 name = "crc"
 version = "3.0.1"
@@ -2352,6 +2372,7 @@ dependencies = [
  "objc2-foundation 0.3.2",
  "parking_lot",
  "percent-encoding",
+ "pollster 1.0.1",
  "profiling",
  "raw-window-handle",
  "ron",
@@ -2360,6 +2381,7 @@ dependencies = [
  "wasm-bindgen",
  "web-sys",
  "web-time",
+ "wgpu",
  "windows-sys 0.61.2",
  "winit",
 ]
@@ -2471,11 +2493,16 @@ version = "0.36.2"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "5eb3f3effb2726fee23449293e67acbf6cc6ce35525b1722bd5743bb348114d5"
 dependencies = [
+ "eframe",
  "egui",
+ "egui-wgpu",
+ "image",
  "kittest",
  "log",
+ "pollster 1.0.1",
  "serde",
  "toml 1.1.8+spec-1.1.0",
+ "wgpu",
 ]
 
 [[package]]
@@ -3569,6 +3596,20 @@ dependencies = [
  "system-deps",
 ]
 
+[[package]]
+name = "gpu-allocator"
+version = "0.28.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "51255ea7cfaadb6c5f1528d43e92a82acb2b96c43365989a28b2d44ee38f8795"
+dependencies = [
+ "ash",
+ "hashbrown 0.16.1",
+ "log",
+ "presser",
+ "thiserror 2.0.18",
+ "windows 0.54.0",
+]
+
 [[package]]
 name = "grok_api_client"
 version = "0.0.1"
@@ -3763,6 +3804,8 @@ version = "0.16.1"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100"
 dependencies = [
+ "allocator-api2",
+ "equivalent",
  "foldhash 0.2.0",
 ]
 
@@ -4608,6 +4651,17 @@ dependencies = [
  "unicode-segmentation",
 ]
 
+[[package]]
+name = "khronos-egl"
+version = "6.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6aae1df220ece3c0ada96b8153459b67eebe9ae9212258bb0134ae60416fdf76"
+dependencies = [
+ "libc",
+ "libloading 0.8.6",
+ "pkg-config",
+]
+
 [[package]]
 name = "khronos_api"
 version = "3.1.0"
@@ -5141,6 +5195,7 @@ dependencies = [
  "num-traits",
  "once_cell",
  "rustc-hash 1.1.0",
+ "spirv",
  "thiserror 2.0.18",
  "unicode-ident",
 ]
@@ -5516,7 +5571,7 @@ dependencies = [
  "block2 0.5.1",
  "objc2 0.5.2",
  "objc2-foundation 0.2.2",
- "objc2-metal",
+ "objc2-metal 0.2.2",
 ]
 
 [[package]]
@@ -5639,6 +5694,18 @@ dependencies = [
  "objc2-foundation 0.2.2",
 ]
 
+[[package]]
+name = "objc2-metal"
+version = "0.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a0125f776a10d00af4152d74616409f0d4a2053a6f57fa5b7d6aa2854ac04794"
+dependencies = [
+ "bitflags 2.13.2",
+ "block2 0.6.2",
+ "objc2 0.6.4",
+ "objc2-foundation 0.3.2",
+]
+
 [[package]]
 name = "objc2-quartz-core"
 version = "0.2.2"
@@ -5649,7 +5716,7 @@ dependencies = [
  "block2 0.5.1",
  "objc2 0.5.2",
  "objc2-foundation 0.2.2",
- "objc2-metal",
+ "objc2-metal 0.2.2",
 ]
 
 [[package]]
@@ -5661,7 +5728,9 @@ dependencies = [
  "bitflags 2.13.2",
  "objc2 0.6.4",
  "objc2-core-foundation",
+ "objc2-core-graphics",
  "objc2-foundation 0.3.2",
+ "objc2-metal 0.3.2",
 ]
 
 [[package]]
@@ -5923,6 +5992,15 @@ dependencies = [
  "num-traits",
 ]
 
+[[package]]
+name = "ordered-float"
+version = "5.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8c7c9e0d9b23589f26070720bac724174bfec1083e82f7854cdd0267518343c0"
+dependencies = [
+ "num-traits",
+]
+
 [[package]]
 name = "ordered-stream"
 version = "0.2.0"
@@ -6278,6 +6356,12 @@ version = "0.4.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "2f3a9f18d041e6d0e102a0a46750538147e5e8992d3b4873aaafee2520b00ce3"
 
+[[package]]
+name = "pollster"
+version = "1.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bc6355899e1c9462875b6757c79f3caa011a1fdae12bbb1a2e72dd1f234f8336"
+
 [[package]]
 name = "polycool"
 version = "0.4.0"
@@ -6320,6 +6404,12 @@ version = "0.1.1"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c"
 
+[[package]]
+name = "presser"
+version = "0.3.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e8cf8e6a8aa66ce33f63993ffc4ea4271eb5b0530a9002db8455ea6050c77bfa"
+
 [[package]]
 name = "pretty_assertions"
 version = "1.4.1"
@@ -6681,6 +6771,12 @@ dependencies = [
  "getrandom 0.3.1",
 ]
 
+[[package]]
+name = "range-alloc"
+version = "0.1.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ca45419789ae5a7899559e9512e58ca889e41f04f1f2445e9f4b290ceccd1d08"
+
 [[package]]
 name = "rav1e"
 version = "0.7.1"
@@ -6737,6 +6833,18 @@ version = "0.6.2"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539"
 
+[[package]]
+name = "raw-window-metal"
+version = "1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "40d213455a5f1dc59214213c7330e074ddf8114c9a42411eb890c767357ce135"
+dependencies = [
+ "objc2 0.6.4",
+ "objc2-core-foundation",
+ "objc2-foundation 0.3.2",
+ "objc2-quartz-core 0.3.2",
+]
+
 [[package]]
 name = "rayon"
 version = "1.10.0"
@@ -7000,7 +7108,7 @@ dependencies = [
  "objc2-app-kit 0.3.2",
  "objc2-core-foundation",
  "objc2-foundation 0.3.2",
- "pollster",
+ "pollster 0.4.0",
  "raw-window-handle",
  "urlencoding",
  "wasm-bindgen",
@@ -7542,7 +7650,7 @@ version = "0.7.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "f3a1a3341211875ef120e117ea7fd5228530ae7e7036a779fdc9117be6b3282c"
 dependencies = [
- "ordered-float",
+ "ordered-float 2.10.1",
  "serde",
 ]
 
@@ -8037,6 +8145,15 @@ dependencies = [
  "lock_api",
 ]
 
+[[package]]
+name = "spirv"
+version = "0.4.0+sdk-1.4.341.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d9571ea910ebd84c86af4b3ed27f9dbdc6ad06f17c5f96146b2b671e2976744f"
+dependencies = [
+ "bitflags 2.13.2",
+]
+
 [[package]]
 name = "spki"
 version = "0.7.3"
@@ -9110,6 +9227,15 @@ dependencies = [
  "utf-8",
 ]
 
+[[package]]
+name = "termcolor"
+version = "1.4.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "06794f8f6c5c898b3275aebefa6b8a1cb24cd2c6c79397ab15774837a0bc5755"
+dependencies = [
+ "winapi-util",
+]
+
 [[package]]
 name = "test_data"
 version = "0.0.1"
@@ -10587,6 +10713,8 @@ dependencies = [
  "hashbrown 0.17.1",
  "js-sys",
  "log",
+ "naga",
+ "parking_lot",
  "portable-atomic",
  "profiling",
  "raw-window-handle",
@@ -10626,12 +10754,42 @@ dependencies = [
  "rustc-hash 1.1.0",
  "smallvec",
  "thiserror 2.0.18",
+ "wgpu-core-deps-apple",
+ "wgpu-core-deps-emscripten",
+ "wgpu-core-deps-wasm",
  "wgpu-core-deps-windows-linux-android",
  "wgpu-hal",
  "wgpu-naga-bridge",
  "wgpu-types",
 ]
 
+[[package]]
+name = "wgpu-core-deps-apple"
+version = "30.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "061f3d319a40d39d00b1ecc2c33b89fe21d4e6fe01859df3500a3a8ecccd6b68"
+dependencies = [
+ "wgpu-hal",
+]
+
+[[package]]
+name = "wgpu-core-deps-emscripten"
+version = "30.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d98b86cf4abf524a902dd35f18ca6a3f08fc2ae9847c8f10b48e30491b1f0b86"
+dependencies = [
+ "wgpu-hal",
+]
+
+[[package]]
+name = "wgpu-core-deps-wasm"
+version = "30.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d260ca2ca8adc654f513a869bf3cac0b47f5b8d3f87803d6942015f8de597aae"
+dependencies = [
+ "wgpu-hal",
+]
+
 [[package]]
 name = "wgpu-core-deps-windows-linux-android"
 version = "30.0.1"
@@ -10647,22 +10805,54 @@ version = "30.0.1"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "b6b7fb58561a792bc237628ba0792e332de418fefe145f13b5ed8201e6d52f58"
 dependencies = [
+ "android_system_properties",
+ "arrayvec",
+ "ash",
+ "bit-set 0.10.0",
  "bitflags 2.13.2",
+ "block2 0.6.2",
+ "bytemuck",
  "cfg-if",
  "cfg_aliases",
+ "glow",
+ "glutin_wgl_sys",
+ "gpu-allocator",
  "hashbrown 0.17.1",
+ "js-sys",
+ "khronos-egl",
+ "libc",
  "libloading 0.8.6",
  "log",
  "naga",
  "naga-types",
+ "ndk-sys 0.6.0+11769913",
+ "objc2 0.6.4",
+ "objc2-core-foundation",
+ "objc2-core-graphics",
+ "objc2-foundation 0.3.2",
+ "objc2-metal 0.3.2",
+ "objc2-quartz-core 0.3.2",
+ "once_cell",
+ "ordered-float 5.5.0",
+ "parking_lot",
  "portable-atomic",
  "portable-atomic-util",
+ "profiling",
+ "range-alloc",
  "raw-window-handle",
+ "raw-window-metal",
  "renderdoc-sys",
+ "smallvec",
  "static_assertions",
  "thiserror 2.0.18",
+ "wasm-bindgen",
+ "wayland-sys",
+ "web-sys",
  "wgpu-naga-bridge",
  "wgpu-types",
+ "windows 0.62.2",
+ "windows-core 0.62.2",
+ "windows-result 0.4.1",
 ]
 
 [[package]]
diff --git a/artcraft-native/Cargo.toml b/artcraft-native/Cargo.toml
--- a/artcraft-native/Cargo.toml
+++ b/artcraft-native/Cargo.toml
@@ -10,6 +10,7 @@ name = "artcraft-native"
 path = "src/main.rs"
 
 [dependencies]
+craft-ui = { git = "https://github.com/storytold/craft-libs", rev = "8134693a69e0fca8ec7913ad5a95dedc05daad74" }
 # Internal (Tauri-free) API crates shared with the Tauri app.
 artcraft_api_defs.workspace = true
 artcraft_client.workspace = true
@@ -50,4 +51,4 @@ arboard = { version = "3", default-features = false, features = ["image-data"] }
 wry = "0.55"
 
 [dev-dependencies]
-egui_kittest = "0.36"
+egui_kittest = { version = "0.36", features = ["wgpu"] }
diff --git a/artcraft-native/src/feed/grid.rs b/artcraft-native/src/feed/grid.rs
--- a/artcraft-native/src/feed/grid.rs
+++ b/artcraft-native/src/feed/grid.rs
@@ -259,7 +259,7 @@ fn item_card(ui: &mut Ui, view: &FeedView<'_>, rect: Rect, item: &FeedItem, cach
     let (fill, border) = if selected { (theme::ACCENT_400, theme::ACCENT_400) } else { (Color32::from_black_alpha(102), theme::fade(Color32::WHITE, 0.6)) };
     ui.painter().rect(chip, theme::RADIUS, fill, Stroke::new(1.0, border), egui::StrokeKind::Inside);
     if selected {
-      icons::paint(ui.painter(), chip.shrink(4.0), Icon::Check, Color32::WHITE);
+      icons::paint(ui.painter(), chip.shrink(4.0), Icon::Check, theme::ACCENT_ON);
     }
   }
   if view.last_viewed == Some(item.token.as_str()) {
diff --git a/artcraft-native/src/main.rs b/artcraft-native/src/main.rs
--- a/artcraft-native/src/main.rs
+++ b/artcraft-native/src/main.rs
@@ -13,6 +13,9 @@ mod shell;
 mod theme;
 mod ui;
 
+#[cfg(test)]
+mod theme_render_tests;
+
 const APP_ID: &str = "ai.artcraft.native";
 
 fn main() -> eframe::Result {
diff --git a/artcraft-native/src/overlays/library_picker.rs b/artcraft-native/src/overlays/library_picker.rs
--- a/artcraft-native/src/overlays/library_picker.rs
+++ b/artcraft-native/src/overlays/library_picker.rs
@@ -189,7 +189,7 @@ fn tile_ui(ui: &mut egui::Ui, rect: Rect, item: &MediaFile, kind: RefKind, attac
     p.rect_stroke(rect, 0.0, Stroke::new(2.0, theme::ACCENT_400), egui::StrokeKind::Inside);
     let chip = Rect::from_min_size(rect.min + vec2(8.0, 8.0), vec2(22.0, 22.0));
     p.rect_filled(chip, theme::RADIUS, theme::ACCENT_400);
-    p.text(chip.center(), Align2::CENTER_CENTER, (i + 1).to_string(), FontId::new(11.0, theme::semibold()), Color32::WHITE);
+    p.text(chip.center(), Align2::CENTER_CENTER, (i + 1).to_string(), FontId::new(11.0, theme::semibold()), theme::ACCENT_ON);
   } else if resp.hovered() {
     p.rect_stroke(rect, 0.0, Stroke::new(2.0, theme::fade(theme::ACCENT_400, 0.6)), egui::StrokeKind::Inside);
   }
diff --git a/artcraft-native/src/prompt_box/deck.rs b/artcraft-native/src/prompt_box/deck.rs
--- a/artcraft-native/src/prompt_box/deck.rs
+++ b/artcraft-native/src/prompt_box/deck.rs
@@ -117,7 +117,7 @@ pub fn reference_deck(ui: &mut Ui, id: Id, refs: &References, limits: &DeckLimit
       let badge_text = items.len().to_string();
       let badge = Rect::from_center_size(pos2(stack.right() + 2.0, stack.top() + 2.0), vec2(8.0 + 7.0 * badge_text.len() as f32, 18.0));
       painter.rect_filled(badge, CornerRadius::same(9), theme::ACCENT);
-      painter.text(badge.center(), Align2::CENTER_CENTER, badge_text, FontId::new(10.0, theme::semibold()), Color32::WHITE);
+      painter.text(badge.center(), Align2::CENTER_CENTER, badge_text, FontId::new(10.0, theme::semibold()), theme::ACCENT_ON);
     }
     if !open_kinds.is_empty() {
       let plus = Rect::from_min_size(pos2(stack.right() - 12.0, stack.bottom() - 20.0), vec2(24.0, 24.0));
diff --git a/artcraft-native/src/shell.rs b/artcraft-native/src/shell.rs
--- a/artcraft-native/src/shell.rs
+++ b/artcraft-native/src/shell.rs
@@ -280,7 +280,7 @@ fn account_row(ui: &mut Ui, name: &str, credits: Option<u64>) -> egui::Response
   let avatar = Rect::from_center_size(pos2(rect.left() + 22.0, rect.center().y), vec2(28.0, 28.0));
   p.circle_filled(avatar.center(), 14.0, theme::ACCENT);
   let initial = name.chars().next().map(|c| c.to_uppercase().to_string()).unwrap_or_default();
-  p.text(avatar.center(), Align2::CENTER_CENTER, initial, FontId::new(13.0, theme::semibold()), Color32::WHITE);
+  p.text(avatar.center(), Align2::CENTER_CENTER, initial, FontId::new(13.0, theme::semibold()), theme::ACCENT_ON);
   p.text(pos2(avatar.right() + 10.0, rect.center().y - 8.0), Align2::LEFT_CENTER, name, FontId::new(13.5, theme::medium()), theme::INK);
   let sub = credits.map_or_else(|| "Account".to_owned(), |c| format!("{} credits", group_thousands(c)));
   p.text(pos2(avatar.right() + 10.0, rect.center().y + 9.0), Align2::LEFT_CENTER, sub, FontId::new(11.5, egui::FontFamily::Proportional), theme::MUTED);
diff --git a/artcraft-native/src/theme.rs b/artcraft-native/src/theme.rs
--- a/artcraft-native/src/theme.rs
+++ b/artcraft-native/src/theme.rs
@@ -1,10 +1,11 @@
 //! Colours, type and shapes of the ArtCraft webapp's brutalist system
-//! (`artcraft-services/frontend/apps/artcraft-webapp`): near-black surfaces, white/15 hairlines,
+//! (`artcraft-services/frontend/apps/artcraft-webapp`): near-black surfaces, fine hairlines,
 //! Archivo display type, Inter body text, Geist Mono labels and 3 px corners on anything
 //! pressable. Large surfaces (panels, cards, media tiles) stay square.
 
 use std::sync::Arc;
 
+use craft_ui::theme::SuitePalette;
 use egui::epaint::text::VariationCoords;
 use egui::{Color32, CornerRadius, FontData, FontDefinitions, FontFamily, FontId, FontTweak, Stroke, TextStyle, Visuals};
 
@@ -19,51 +20,55 @@ const fn alpha(rgb: u32, a: u8) -> Color32 {
 }
 
 /// `ui-background`: the page behind the feed.
-pub const BG: Color32 = hex(0x121316);
+pub const BG: Color32 = SuitePalette::ARTCRAFT_DARK.window;
 /// `ui-panel`: the top bar, modals and the lightbox.
-pub const PANEL: Color32 = hex(0x101014);
+pub const PANEL: Color32 = SuitePalette::ARTCRAFT_DARK.panel;
 /// `ui-controls`: the sidebar, the prompt box and toolbar buttons.
-pub const CONTROLS: Color32 = hex(0x1e1f22);
+pub const CONTROLS: Color32 = SuitePalette::ARTCRAFT_DARK.raised;
 /// The window chrome behind the content panel: the title bar and the sidebar.
-pub const CHROME: Color32 = hex(0x0a0a0c);
+pub const CHROME: Color32 = SuitePalette::ARTCRAFT_DARK.chrome;
 /// `ui-sunken`: wells and inputs.
-pub const SUNKEN: Color32 = hex(0x060607);
+pub const SUNKEN: Color32 = SuitePalette::ARTCRAFT_DARK.input;
 
 /// `base-fg`: body text.
-pub const INK: Color32 = hex(0xf2f1ee);
-pub const INK_STRONG: Color32 = Color32::WHITE;
-/// `base-fg/60`: secondary text and placeholders.
-pub const MUTED: Color32 = alpha(0xf2f1ee, 153);
+pub const INK: Color32 = SuitePalette::ARTCRAFT_DARK.text;
+pub const INK_STRONG: Color32 = SuitePalette::ARTCRAFT_DARK.text_strong;
+/// Secondary text and placeholders in the standard dark palette.
+pub const MUTED: Color32 = SuitePalette::ARTCRAFT_DARK.text_secondary;
 /// `base-fg/50`: idle toolbar icons.
 pub const DIM: Color32 = alpha(0xf2f1ee, 128);
-/// `base-fg/40`: counters and captions.
-pub const FAINT: Color32 = alpha(0xf2f1ee, 102);
+/// Disabled labels and captions.
+pub const FAINT: Color32 = SuitePalette::ARTCRAFT_DARK.text_disabled;
 /// The sidebar's link colour (`white/72`).
 pub const SIDEBAR_INK: Color32 = alpha(0xffffff, 184);
 
-/// `white/15`: every hairline.
-pub const LINE: Color32 = alpha(0xffffff, 38);
+/// Border used by the shared standard dark interface.
+pub const LINE: Color32 = SuitePalette::ARTCRAFT_DARK.border;
 /// `white/25`: dashed drop targets.
 pub const LINE_DASHED: Color32 = alpha(0xffffff, 64);
-/// `base-fg/40`: outlined buttons and corner marks.
-pub const LINE_STRONG: Color32 = alpha(0xf2f1ee, 102);
+/// Strong border on controls and focusable fields.
+pub const LINE_STRONG: Color32 = SuitePalette::ARTCRAFT_DARK.border_strong;
 /// `white/5`: quiet fills.
 pub const WASH: Color32 = alpha(0xffffff, 13);
-/// `white/8`: the selected sidebar row.
-pub const WASH_SELECTED: Color32 = alpha(0xffffff, 20);
-/// `white/10`: hovered menu rows.
-pub const WASH_HOVER: Color32 = alpha(0xffffff, 26);
+/// Selected sidebar row.
+pub const WASH_SELECTED: Color32 = SuitePalette::ARTCRAFT_DARK.selection;
+/// Hovered menu rows.
+pub const WASH_HOVER: Color32 = SuitePalette::ARTCRAFT_DARK.hover;
+/// Pressed controls and open menus.
+pub const WASH_PRESSED: Color32 = SuitePalette::ARTCRAFT_DARK.pressed;
 
 /// The brand blue (`primary`).
-pub const ACCENT: Color32 = hex(0x2d81ff);
+pub const ACCENT: Color32 = SuitePalette::ARTCRAFT_DARK.accent;
 /// `primary-400`: progress bars and selection rings.
-pub const ACCENT_400: Color32 = hex(0x59a7ff);
+pub const ACCENT_400: Color32 = SuitePalette::ARTCRAFT_DARK.accent_hover;
 /// `ui-accent-ink`: blue text and focus rings.
-pub const ACCENT_INK: Color32 = hex(0x74aaff);
+pub const ACCENT_INK: Color32 = SuitePalette::ARTCRAFT_DARK.focus;
+/// Text and symbols on a solid blue accent fill.
+pub const ACCENT_ON: Color32 = SuitePalette::ARTCRAFT_DARK.on_accent;
 
 /// The inverted primary button (white block, near-black text).
-pub const INVERT_BG: Color32 = Color32::WHITE;
-pub const INVERT_FG: Color32 = hex(0x0b0b0c);
+pub const INVERT_BG: Color32 = SuitePalette::ARTCRAFT_DARK.primary;
+pub const INVERT_FG: Color32 = SuitePalette::ARTCRAFT_DARK.on_primary;
 
 /// `red-400`: failure text.
 pub const BAD: Color32 = hex(0xf87171);
@@ -137,13 +142,13 @@ fn visuals() -> Visuals {
   v.popup_shadow = shadow();
   v.override_text_color = Some(INK);
   v.hyperlink_color = ACCENT_INK;
-  v.selection.bg_fill = ACCENT;
-  v.selection.stroke = Stroke::new(1.0, Color32::WHITE);
+  v.selection.bg_fill = WASH_SELECTED;
+  v.selection.stroke = Stroke::new(1.0, SuitePalette::ARTCRAFT_DARK.selection_text);
   v.text_cursor.stroke = Stroke::new(1.5, Color32::WHITE);
   v.warn_fg_color = BADGE_BETA;
   v.error_fg_color = BAD;
   v.striped = false;
-  for (w, fill, stroke, fg) in [(&mut v.widgets.noninteractive, CONTROLS, LINE, MUTED), (&mut v.widgets.inactive, CONTROLS, LINE, INK), (&mut v.widgets.hovered, WASH_HOVER, LINE_STRONG, INK_STRONG), (&mut v.widgets.active, WASH_HOVER, LINE_STRONG, INK_STRONG), (&mut v.widgets.open, WASH_HOVER, LINE_STRONG, INK_STRONG)] {
+  for (w, fill, stroke, fg) in [(&mut v.widgets.noninteractive, CONTROLS, LINE, MUTED), (&mut v.widgets.inactive, CONTROLS, LINE, INK), (&mut v.widgets.hovered, WASH_HOVER, LINE_STRONG, INK_STRONG), (&mut v.widgets.active, WASH_PRESSED, LINE_STRONG, INK_STRONG), (&mut v.widgets.open, WASH_PRESSED, LINE_STRONG, INK_STRONG)] {
     w.bg_fill = fill;
     w.weak_bg_fill = fill;
     w.bg_stroke = Stroke::new(1.0, stroke);
@@ -191,3 +196,55 @@ fn install_fonts(ctx: &egui::Context) {
   }
   ctx.set_fonts(fonts);
 }
+
+#[cfg(test)]
+mod tests {
+  use super::*;
+
+  #[test]
+  fn shared_roles_preserve_inverted_controls_media_and_semantic_colors() {
+    let roles = SuitePalette::ARTCRAFT_DARK;
+    assert_eq!(BG, roles.window);
+    assert_eq!(PANEL, roles.panel);
+    assert_eq!(CONTROLS, roles.raised);
+    assert_eq!(CHROME, roles.chrome);
+    assert_eq!(SUNKEN, roles.input);
+    assert_eq!(INK, roles.text);
+    assert_eq!(INK_STRONG, roles.text_strong);
+    assert_eq!(MUTED, roles.text_secondary);
+    assert_eq!(FAINT, roles.text_disabled);
+    assert_eq!(LINE, roles.border);
+    assert_eq!(LINE_STRONG, roles.border_strong);
+    assert_eq!(WASH_SELECTED, roles.selection);
+    assert_eq!(WASH_HOVER, roles.hover);
+    assert_eq!(ACCENT, roles.accent);
+    assert_eq!(ACCENT_400, roles.accent_hover);
+    assert_eq!(ACCENT_INK, roles.focus);
+    assert_eq!(ACCENT_ON, roles.on_accent);
+    assert_eq!(INVERT_BG, roles.primary);
+    assert_eq!(INVERT_FG, roles.on_primary);
+    let v = visuals();
+    assert_eq!(v.widgets.hovered.bg_fill, roles.hover);
+    assert_eq!(v.widgets.active.bg_fill, roles.pressed);
+    assert_eq!(v.widgets.open.bg_fill, roles.pressed);
+    assert_eq!(v.selection.stroke.color, roles.selection_text);
+    assert_eq!((BAD, DANGER, BADGE_BETA), (hex(0xf87171), hex(0xd33242), hex(0xffc14d)));
+    assert_eq!(DIM, alpha(0xf2f1ee, 128));
+    assert_eq!(LINE_DASHED, alpha(0xffffff, 64));
+    assert_eq!(RADIUS, CornerRadius::same(3));
+    assert_eq!(v.text_edit_bg_color, Some(Color32::TRANSPARENT));
+    assert_eq!(v.text_cursor.stroke.color, Color32::WHITE);
+  }
+
+  #[test]
+  fn standard_dark_roles_keep_selected_rows_distinct_from_blue_actions() {
+    let v = visuals();
+    assert_eq!(BG, hex(0x121316));
+    assert_eq!(PANEL, hex(0x101014));
+    assert_eq!(ACCENT, hex(0x2d81ff));
+    assert_eq!(ACCENT_ON, hex(0x060607));
+    assert_eq!(v.selection.bg_fill, hex(0x203b65));
+    assert_eq!(v.selection.stroke.color, INK);
+    assert_eq!(v.widgets.inactive.bg_stroke.color, hex(0x36373a));
+  }
+}
diff --git a/artcraft-native/src/theme_render_tests.rs b/artcraft-native/src/theme_render_tests.rs
new file mode 100644
--- /dev/null
+++ b/artcraft-native/src/theme_render_tests.rs
@@ -0,0 +1,84 @@
+//! Opt-in source render of production shell/widgets; never starts the backend or reads accounts.
+
+use egui_kittest::Harness;
+use crate::shell::{self, Account, Page};
+use crate::theme;
+use crate::ui::widgets::{self, Kind, Leading, RowLead};
+
+#[derive(Default)]
+struct State {
+  fonts_ready: bool,
+  prompt: String,
+}
+
+/// `ARTCRAFT_COMPONENT_SCREENSHOT=/absolute/file.png cargo test -p artcraft_native
+/// theme_render_tests::dark_components_source_render -- --exact`.
+/// This is a production-component review fixture, not a full app/session screenshot.
+#[test]
+fn dark_components_source_render() {
+  let Some(path) = std::env::var_os("ARTCRAFT_COMPONENT_SCREENSHOT") else {
+    return;
+  };
+  let mut harness = Harness::builder().with_size(egui::vec2(1100.0, 900.0)).build_ui_state(
+    |ui, state: &mut State| {
+      if !state.fonts_ready {
+        theme::apply(ui.ctx());
+        state.fonts_ready = true;
+        return;
+      }
+      ui.painter().rect_filled(ui.max_rect(), 0, theme::CHROME);
+      ui.horizontal_top(|ui| {
+        ui.allocate_ui_with_layout(egui::vec2(shell::SIDEBAR_WIDTH, 860.0), egui::Layout::top_down(egui::Align::Min), |ui| {
+          shell::sidebar(ui, Page::CreateImage, &Account { username: None, display_name: None, credits: None });
+          // Production sidebar paints into child UIs; reserve its extent in this review layout.
+          ui.allocate_space(egui::vec2(shell::SIDEBAR_WIDTH, 860.0));
+        });
+        egui::Frame::new().fill(theme::BG).inner_margin(24).show(ui, |ui| {
+          ui.with_layout(egui::Layout::top_down(egui::Align::Min), |ui| {
+            ui.set_width(770.0);
+            ui.heading("ArtCraft native source components");
+            ui.label("Production sidebar and widget render • backend disconnected");
+            ui.add_space(24.0);
+            egui::Frame::new().fill(theme::PANEL).inner_margin(16).show(ui, |ui| {
+              ui.set_width(720.0);
+              widgets::hud(ui, "Image prompt", theme::MUTED);
+              ui.add_space(8.0);
+              ui.add(egui::TextEdit::multiline(&mut state.prompt).desired_width(700.0).desired_rows(4).hint_text("Describe what you want in the image...")).request_focus();
+              ui.add_space(12.0);
+              ui.horizontal(|ui| {
+                widgets::pill(ui, Leading::None, "Nano Banana Pro", false);
+                widgets::pill(ui, Leading::Aspect(Some((1.0, 1.0))), "Square", true);
+                widgets::generate_button(ui, true, false, "Generate");
+              });
+            });
+            ui.add_space(24.0);
+            ui.horizontal(|ui| {
+              widgets::button(ui, None, "Continue", Kind::Accent, 150.0, 38.0);
+              widgets::button(ui, None, "Download", Kind::Primary, 150.0, 38.0);
+              ui.add_enabled_ui(false, |ui| {
+                widgets::button(ui, None, "Unavailable", Kind::Secondary, 150.0, 38.0);
+              });
+            });
+            ui.add_space(24.0);
+            widgets::popover_frame().show(ui, |ui| {
+              ui.with_layout(egui::Layout::top_down(egui::Align::Min), |ui| {
+                ui.set_width(340.0);
+                widgets::menu_header(ui, "Aspect ratio");
+                widgets::menu_row(ui, RowLead::Aspect(Some((1.0, 1.0))), "Square", Some("Selected option"), true, false, false);
+                widgets::menu_row(ui, RowLead::Aspect(Some((16.0, 9.0))), "16:9 (Wide)", Some("Hovered option"), false, false, true);
+                ui.add_enabled_ui(false, |ui| {
+                  widgets::menu_row(ui, RowLead::None, "Unavailable", None, false, false, false);
+                });
+              });
+            });
+          });
+        });
+      });
+    },
+    State::default(),
+  );
+  harness.run();
+  let image = harness.render().expect("source component render");
+  assert_eq!(image.dimensions(), (1100, 900));
+  image.save(path).expect("save source component screenshot");
+}
diff --git a/artcraft-native/src/ui/widgets.rs b/artcraft-native/src/ui/widgets.rs
--- a/artcraft-native/src/ui/widgets.rs
+++ b/artcraft-native/src/ui/widgets.rs
@@ -161,7 +161,7 @@ pub fn generate_button(ui: &mut Ui, enabled: bool, loading: bool, tip: &str) ->
     (true, false) => theme::ACCENT,
   };
   ui.painter().circle_filled(rect.center(), 18.0, fill);
-  let ink = if enabled { Color32::WHITE } else { theme::fade(Color32::WHITE, 0.5) };
+  let ink = if enabled { theme::ACCENT_ON } else { theme::fade(theme::ACCENT_ON, 0.5) };
   let inner = Rect::from_center_size(rect.center(), vec2(18.0, 18.0));
   if loading {
     icons::paint_spinner(ui, inner, ink);
@@ -201,7 +201,7 @@ pub fn button(ui: &mut Ui, icon: Option<Icon>, text: &str, kind: Kind, width: f3
   let hovered = resp.hovered() && enabled;
   let (fill, stroke, ink) = match kind {
     Kind::Primary => (theme::fade(theme::INVERT_BG, if hovered { 0.88 } else { 1.0 }), Stroke::NONE, theme::INVERT_FG),
-    Kind::Accent => (if hovered { theme::ACCENT_400 } else { theme::ACCENT }, Stroke::NONE, Color32::WHITE),
+    Kind::Accent => (if hovered { theme::ACCENT_400 } else { theme::ACCENT }, Stroke::NONE, theme::ACCENT_ON),
     Kind::Danger => (theme::fade(theme::DANGER, if hovered { 0.85 } else { 1.0 }), Stroke::NONE, Color32::WHITE),
     Kind::Secondary if hovered => (theme::INVERT_BG, Stroke::NONE, theme::INVERT_FG),
     Kind::Secondary => (Color32::TRANSPARENT, Stroke::new(1.0, theme::LINE_STRONG), theme::INK),
diff --git a/frontend/apps/artcraft/app/src/styles/base.css b/frontend/apps/artcraft/app/src/styles/base.css
--- a/frontend/apps/artcraft/app/src/styles/base.css
+++ b/frontend/apps/artcraft/app/src/styles/base.css
@@ -93,8 +93,8 @@ body {
 }
 
 ::selection {
-  background: #2d81ff;
-  color: #ffffff;
+  background: var(--st-selection-bg, #2d81ff);
+  color: var(--st-selection-fg, #ffffff);
 }
 
 :focus-visible {
@@ -129,20 +129,20 @@ body {
 
 /* ===================== THEME VARIABLES ===================== */
 /* The default (the "gray" theme id) is the brutalist system shared with
-   artcraft-webapp: near-black surfaces, white/15 hairline borders. */
+   artcraft-webapp: near-black surfaces and fine hairline borders. */
 :root {
   --st-bg: #121316;
   --st-panel: #101014;
-  --st-panel-border: rgba(255, 255, 255, 0.15);
+  --st-panel-border: #36373a;
   --st-modal: #101014;
   /* default modal surface (can be gradient per theme) */
   --st-controls: #1e1f22;
   --st-controls-rgb: 30 31 34;
-  --st-controls-border: rgba(255, 255, 255, 0.15);
+  --st-controls-border: #36373a;
   --st-sunken: #060607;
   --st-fg: #f2f1ee;
   --st-fg-rgb: 242 241 238;
-  --st-divider: rgba(255, 255, 255, 0.15);
+  --st-divider: #36373a;
   --st-gallery-vignette-alpha: 0.6;
   /* Photo tint (default neutral) */
   --st-photo-tint-rgb: 0 0 0;
@@ -151,8 +151,7 @@ body {
   /* Theme-aware badge alpha (default dark) */
   --st-badge-alpha: 0.4;
 
-  /* shadcn design tokens consumed by @storyteller/ui-video-editor.
-     Same values as artcraft-webapp's styles.css. */
+  /* Existing video-editor defaults also apply to the alternate themes. */
   --background: 225 10% 8%;
   --foreground: 0 0% 98%;
   --card: 240 11% 7%;
@@ -218,23 +217,43 @@ body {
 
 /* Default dark (brutalist, matches artcraft-webapp) */
 .theme-gray {
+  --st-selection-bg: #203b65;
+  --st-selection-fg: #f2f1ee;
   --st-bg: #121316;
   --st-panel: #101014;
-  --st-panel-border: rgba(255, 255, 255, 0.15);
+  --st-panel-border: #36373a;
   --st-modal: #101014;
   --st-controls: #1e1f22;
   --st-controls-rgb: 30 31 34;
-  --st-controls-border: rgba(255, 255, 255, 0.15);
+  --st-controls-border: #36373a;
   --st-sunken: #060607;
   --st-fg: #f2f1ee;
   --st-fg-rgb: 242 241 238;
-  --st-divider: rgba(255, 255, 255, 0.15);
+  --st-divider: #36373a;
   --st-color-scheme: dark;
   --st-gallery-vignette-alpha: 0.3;
   --st-photo-tint-rgb: 0 0 0;
   --st-photo-tint-alpha: 0.6;
   --st-photo-grayscale: 1;
   --st-badge-alpha: 0.4;
+  /* Canonical dark HSL counterparts used by @storyteller/ui-video-editor. */
+  --background: 225 10% 7.8431%;
+  --foreground: 45 13.3333% 94.1176%;
+  --card: 240 11.1111% 7.0588%;
+  --card-foreground: 45 13.3333% 94.1176%;
+  --popover: 240 11.1111% 7.0588%;
+  --popover-foreground: 45 13.3333% 94.1176%;
+  --primary: 216 100% 58.8235%;
+  --primary-foreground: 240 7.6923% 2.549%;
+  --secondary: 225 6.25% 12.549%;
+  --secondary-foreground: 45 13.3333% 94.1176%;
+  --muted: 225 6.25% 12.549%;
+  --muted-foreground: 240 1.1364% 65.4902%;
+  --accent: 228 4.7619% 20.5882%;
+  --accent-foreground: 45 13.3333% 94.1176%;
+  --border: 225 3.5714% 21.9608%;
+  --input: 225 3.5714% 21.9608%;
+  --ring: 216.6906 100% 72.7451%;
 }
 
 /* Full black */
