diff --git a/crates/ironclaw_gateway/static/index.html b/crates/ironclaw_gateway/static/index.html
index 493358089c2..54096f918ac 100644
--- a/crates/ironclaw_gateway/static/index.html
+++ b/crates/ironclaw_gateway/static/index.html
@@ -7,7 +7,7 @@
-
+
diff --git a/crates/ironclaw_gateway/static/theme.css b/crates/ironclaw_gateway/static/theme.css
index 4980279250f..77adbdbb566 100644
--- a/crates/ironclaw_gateway/static/theme.css
+++ b/crates/ironclaw_gateway/static/theme.css
@@ -1,187 +1,388 @@
-/* Shared IronClaw theme tokens */
+/* IronClaw theme tokens.
+ *
+ * Phase A of the design-system adoption ported from Defuse
+ * (defuse-protocol/defuse-frontend) introduces Radix Themes-style
+ * 12-step scales as the substrate underneath the semantic tokens the
+ * split surface stylesheets consume.
+ *
+ * --sand-{1..12} neutral / surface / text — OmniSwap gray scale
+ * --accent-{1..12} brand emerald-green — OmniSwap accent scale
+ * --accent-a{1..12} alpha variants of the accent scale (dark mode)
+ *
+ * Radix convention:
+ * 1–2 app background / subtle surface
+ * 3 component background
+ * 4 hovered component background
+ * 5 active / selected component background
+ * 6 subtle border
+ * 7 UI element border
+ * 8 hovered UI element border
+ * 9 solid (brand) background <- `--accent` anchors here
+ * 10 hovered solid background
+ * 11 low-contrast text
+ * 12 high-contrast text
+ *
+ * Every legacy token (`--bg`, `--text`, `--accent`, …) is kept, but
+ * redefined below as an alias onto the new scales — so the 20 split
+ * surface stylesheets under `styles/` require zero changes. Future
+ * components should consume the semantic `--color-*`, `--shadow-*`,
+ * `--radius-*` tokens directly. */
:root {
- --bg: #09090b;
- --bg-secondary: #0f0f11;
- --bg-tertiary: #1a1a1e;
- --border: rgba(255, 255, 255, 0.08);
- --text: #fafafa;
- --text-secondary: #a1a1aa;
- --accent: #34d399;
- --accent-hover: #2fc48d;
- --accent-soft: rgba(52, 211, 153, 0.15);
- --success: #34d399;
- --info: #60a5fa;
- --warning: #F5A623;
- --danger: #E64C4C;
- --code-bg: #111113;
- --radius: 8px;
- --radius-lg: 12px;
- --shadow: 0 2px 8px rgba(0, 0, 0, 0.4);
- --font-mono: 'IBM Plex Mono', 'SF Mono', 'Fira Code', Consolas, monospace;
- --bg-overlay: rgba(0, 0, 0, 0.5);
- --bg-modal: #1a1a1a;
- --border-modal: #333;
- --border-soft: #2a2a2a;
- --text-tertiary: #e0e0e0;
- --text-muted: #888;
- --text-dimmed: #666;
- --text-on-accent: #09090b;
- --accent-brand: #00D894;
- --accent-brand-hover: #00be82;
- --warning-bg: #1e1400;
- --warning-border: #3a2a00;
- --warning-text: #facc15;
- --tab-bg: rgba(9, 9, 11, 0.75);
- --popover-bg: rgba(15, 15, 17, 0.9);
- --badge-sandbox-bg: rgba(136, 132, 216, 0.15);
- --badge-sandbox-text: #b4b0e8;
- --hover-surface: rgba(255, 255, 255, 0.03);
- --focus-ring: rgba(52, 211, 153, 0.1);
- --accent-subtle: rgba(52, 211, 153, 0.15);
- --accent-border-subtle: rgba(52, 211, 153, 0.3);
- --danger-subtle: rgba(230, 76, 76, 0.15);
- --danger-border-subtle: rgba(230, 76, 76, 0.3);
- --warning-subtle: rgba(245, 166, 35, 0.15);
- --border-hover: rgba(255, 255, 255, 0.15);
- --user-msg-bg: rgba(52, 211, 153, 0.08);
- --user-msg-border: rgba(52, 211, 153, 0.2);
- --danger-error-bg: rgba(230, 76, 76, 0.1);
- --accent-tee-bg: rgba(52, 211, 153, 0.1);
- --accent-tee-border: rgba(52, 211, 153, 0.25);
- --accent-tee-hover: rgba(52, 211, 153, 0.18);
- --text-on-danger: #fff;
- --shadow-card: 0 4px 24px rgba(0, 0, 0, 0.4);
- --shadow-toast: 0 4px 12px rgba(0, 0, 0, 0.4);
- --danger-error-border: rgba(230, 76, 76, 0.2);
- --note-bg: rgba(255, 255, 255, 0.04);
- --overlay-heavy: rgba(0, 0, 0, 0.6);
- --highlight-bg: rgba(52, 211, 153, 0.3);
- --hover-subtle: rgba(255, 255, 255, 0.06);
- --transition-fast: 150ms ease;
- --transition-base: 0.2s ease;
- --shadow-sm: 0 1px 2px rgba(0,0,0,0.3), 0 1px 3px rgba(0,0,0,0.15);
- --shadow-md: 0 4px 12px rgba(0,0,0,0.4), 0 2px 4px rgba(0,0,0,0.2);
- --shadow-lg: 0 12px 40px rgba(0,0,0,0.5), 0 4px 12px rgba(0,0,0,0.3);
- --glow-accent: 0 0 20px rgba(52,211,153,0.1);
- --glass-bg: rgba(9,9,11,0.72);
- --glass-blur: blur(16px) saturate(180%);
- --ease-spring: cubic-bezier(0.34, 1.56, 0.64, 1);
- --ease-spring-gentle: cubic-bezier(0.22, 1.2, 0.36, 1);
- --ease-out-expo: cubic-bezier(0.16, 1, 0.3, 1);
- --surface-highlight: inset 0 1px 0 rgba(255,255,255,0.05);
+ /* ===========================================================
+ * 12-step neutral scale (dark theme default).
+ * Adopted wholesale from Defuse OmniSwap `--os*d`. Green-tinged
+ * gray that pairs with the emerald accent.
+ * =========================================================== */
+ --sand-1: #080c0a;
+ --sand-2: #121614;
+ --sand-3: #1b1f1d;
+ --sand-4: #252927;
+ --sand-5: #3d4140;
+ --sand-6: #4e5251;
+ --sand-7: #6e7270;
+ --sand-8: #9b9f9e;
+ --sand-9: #cacecc;
+ --sand-10: #dadedc;
+ --sand-11: #e9edec;
+ --sand-12: #fcfdfc;
+
+ /* Alpha variants for overlays, dividers, and translucent surfaces.
+ * White-on-dark — these track the steps above but as rgba(255,255,255,a)
+ * so they composite correctly over any background. */
+ --sand-a1: rgba(255, 255, 255, 0.020);
+ --sand-a2: rgba(255, 255, 255, 0.032);
+ --sand-a3: rgba(255, 255, 255, 0.050);
+ --sand-a4: rgba(255, 255, 255, 0.080);
+ --sand-a5: rgba(255, 255, 255, 0.120);
+ --sand-a6: rgba(255, 255, 255, 0.170);
+ --sand-a7: rgba(255, 255, 255, 0.260);
+ --sand-a8: rgba(255, 255, 255, 0.420);
+ --sand-a9: rgba(255, 255, 255, 0.620);
+ --sand-a10: rgba(255, 255, 255, 0.720);
+ --sand-a11: rgba(255, 255, 255, 0.850);
+ --sand-a12: rgba(255, 255, 255, 0.980);
+
+ /* ===========================================================
+ * 12-step accent scale (emerald / mint family).
+ *
+ * Adopted from Defuse OmniSwap `--osa*d`. In dark mode the
+ * OmniSwap accent is the light-mode scale reversed (1↔12), so the
+ * "solid" brand color at step 9 is a light mint. Current IronClaw
+ * emerald (#34d399) lives between steps 8 and 9 in this palette —
+ * very close but slightly more mint than before.
+ * =========================================================== */
+ --accent-1: #031c12;
+ --accent-2: #042a1b;
+ --accent-3: #094b30;
+ --accent-4: #0a5b38;
+ --accent-5: #097345;
+ --accent-6: #0b9053;
+ --accent-7: #17b267;
+ --accent-8: #3bcc81;
+ --accent-9: #73e2a5; /* brand anchor (interactive solid) */
+ --accent-10: #aaf0c5;
+ --accent-11: #d3f8df;
+ --accent-12: #edfcf3;
+
+ /* Alpha variants — emerald composited over any background. */
+ --accent-a1: rgba(115, 226, 165, 0.050);
+ --accent-a2: rgba(115, 226, 165, 0.080);
+ --accent-a3: rgba(115, 226, 165, 0.120);
+ --accent-a4: rgba(115, 226, 165, 0.170);
+ --accent-a5: rgba(115, 226, 165, 0.230);
+ --accent-a6: rgba(115, 226, 165, 0.320);
+ --accent-a7: rgba(115, 226, 165, 0.450);
+ --accent-a8: rgba(115, 226, 165, 0.620);
+ --accent-a9: rgba(115, 226, 165, 0.820);
+ --accent-a10: rgba(170, 240, 197, 0.870);
+ --accent-a11: rgba(211, 248, 223, 0.920);
+ --accent-a12: rgba(237, 252, 243, 0.970);
+
+ /* Contrast text-on-solid-accent — used for button foregrounds when the
+ * background is `--accent-9`. Defuse sets this to the inverted step 1. */
+ --accent-contrast: #031c12;
+
+ /* ===========================================================
+ * Semantic role aliases.
+ *
+ * New components should consume THESE, not raw scale steps.
+ * Roles outlive palette swaps.
+ * =========================================================== */
+ --color-bg: var(--sand-1);
+ --color-surface: var(--sand-2);
+ --color-surface-elevated: var(--sand-3);
+ --color-surface-hover: var(--sand-a3);
+ --color-border: var(--sand-a5);
+ --color-border-hover: var(--sand-a7);
+ --color-text: var(--sand-12);
+ --color-text-muted: var(--sand-10);
+ --color-text-subtle: var(--sand-8);
+ --color-label: var(--sand-12);
+ --color-warning: rgba(245, 166, 35, 0.22);
+ --color-warning-foreground: #facc15;
+
+ /* ===========================================================
+ * Layered shadow system.
+ *
+ * Replaces the earlier single `--shadow` with named elevations
+ * matching Defuse's paper/widget/card hierarchy.
+ * =========================================================== */
+ --shadow-paper: 0 1px 0 var(--sand-a4), 0 8px 24px -8px rgba(0, 0, 0, 0.35);
+ --shadow-widget: 0 0 0 1px var(--sand-a4), 0 12px 32px -12px rgba(0, 0, 0, 0.50);
+ --shadow-card: 0 1px 2px var(--sand-a3), 0 4px 16px -6px rgba(0, 0, 0, 0.40);
+ --shadow-inset: inset 0 1px 0 var(--sand-a3);
+
+ /* ===========================================================
+ * Extended radius scale — picks up Defuse's `4xl` (30 px) for
+ * the big rounded-card / paper aesthetic.
+ * =========================================================== */
+ --radius-xl: 16px;
+ --radius-2xl: 20px;
+ --radius-4xl: 30px;
+
+ /* ===========================================================
+ * Extended type scale — display sizes for hero / widget headers.
+ * Small UI sizes remain unchanged (defined below).
+ * =========================================================== */
+ --text-4xl: 32px;
+ --text-5xl: 48px;
+ --text-6xl: 64px;
+ --tracking-display: 0.02em;
+ --tracking-6xl: 0.04em;
+
+ /* ===========================================================
+ * Font stacks.
+ *
+ * Geist (Vercel, free via Google Fonts) is a geometric humanist
+ * sans that's the closest free lookalike of Defuse's proprietary
+ * CircularXXSub. DM Sans and IBM Plex Mono stay in the fallback
+ * chain so unstyled renders before the webfont loads still use a
+ * similar-feeling face. The Google Fonts is in index.html
+ * / admin.html — CSP already allows `fonts.googleapis.com` and
+ * `fonts.gstatic.com`.
+ * =========================================================== */
+ --font-sans: 'Geist', -apple-system, BlinkMacSystemFont, 'DM Sans', 'Segoe UI', sans-serif;
+ --font-mono: 'Geist Mono', 'IBM Plex Mono', 'SF Mono', 'Fira Code', Consolas, monospace;
+
+ /* ===========================================================
+ * Legacy semantic tokens — kept for backward compatibility with
+ * the 20 split surface stylesheets under `styles/`. Each one is
+ * now an alias onto a scale step or role token above, so touching
+ * the palette here instantly reskins the whole app without
+ * changing a single line in any surface stylesheet.
+ *
+ * NOTE: do not write "<>//.css" inside a CSS
+ * comment — the embedded `/` sequence closes the comment
+ * early and the following declarations get dropped. (This file
+ * was bitten by exactly that once — keep glob patterns out of
+ * comment bodies.)
+ * =========================================================== */
+ --bg: var(--color-bg);
+ --bg-secondary: var(--color-surface);
+ --bg-tertiary: var(--color-surface-elevated);
+ --bg-overlay: rgba(0, 0, 0, 0.50);
+ --bg-modal: var(--sand-3);
+ --bg-hover: var(--color-surface-hover);
+ --border: var(--color-border);
+ --border-modal: var(--sand-a7);
+ --border-soft: var(--sand-a4);
+ --border-hover: var(--color-border-hover);
+ --text: var(--color-text);
+ --text-secondary: var(--sand-8);
+ --text-tertiary: var(--sand-10);
+ --text-muted: var(--sand-7);
+ --text-dimmed: var(--sand-6);
+ --text-on-accent: var(--accent-contrast);
+ --text-on-danger: #ffffff;
+
+ --accent: var(--accent-9);
+ --accent-hover: var(--accent-8);
+ --accent-brand: var(--accent-9);
+ --accent-brand-hover: var(--accent-8);
+ --accent-soft: var(--accent-a3);
+ --accent-subtle: var(--accent-a3);
+ --accent-border-subtle: var(--accent-a6);
+ --accent-dim: var(--accent-a2);
+ --accent-tee-bg: var(--accent-a2);
+ --accent-tee-border: var(--accent-a5);
+ --accent-tee-hover: var(--accent-a4);
+
+ --success: var(--accent-9);
+ --info: #60a5fa;
+ --warning: #F5A623;
+ --warning-bg: rgba(245, 166, 35, 0.05);
+ --warning-border: rgba(245, 166, 35, 0.25);
+ --warning-text: #facc15;
+ --warning-subtle: rgba(245, 166, 35, 0.15);
+ --warning-soft: rgba(245, 166, 35, 0.15);
+ --danger: #E64C4C;
+ --danger-subtle: rgba(230, 76, 76, 0.15);
+ --danger-border-subtle: rgba(230, 76, 76, 0.30);
+ --danger-error-bg: rgba(230, 76, 76, 0.10);
+ --danger-error-border: rgba(230, 76, 76, 0.20);
+ --danger-soft: rgba(230, 76, 76, 0.15);
+
+ --code-bg: var(--sand-2);
+ --tab-bg: rgba(8, 12, 10, 0.75);
+ --popover-bg: rgba(18, 22, 20, 0.92);
+ --badge-sandbox-bg: rgba(136, 132, 216, 0.15);
+ --badge-sandbox-text: #b4b0e8;
+
+ --hover-surface: var(--sand-a1);
+ --hover-subtle: var(--sand-a2);
+ --focus-ring: var(--accent-a3);
+
+ --user-msg-bg: var(--accent-a2);
+ --user-msg-border: var(--accent-a5);
+
+ --note-bg: var(--sand-a1);
+ --overlay-heavy: rgba(0, 0, 0, 0.60);
+ --highlight-bg: var(--accent-a6);
+ --surface-highlight: inset 0 1px 0 var(--sand-a2);
+
+ /* Radius + shadow legacy aliases. */
+ --radius: 8px;
+ --radius-lg: 12px;
+ --shadow: 0 2px 8px rgba(0, 0, 0, 0.40);
+ --shadow-sm: 0 1px 2px rgba(0,0,0,0.30), 0 1px 3px rgba(0,0,0,0.15);
+ --shadow-md: 0 4px 12px rgba(0,0,0,0.40), 0 2px 4px rgba(0,0,0,0.20);
+ --shadow-lg: 0 12px 40px rgba(0,0,0,0.50), 0 4px 12px rgba(0,0,0,0.30);
+ --shadow-toast: 0 4px 12px rgba(0, 0, 0, 0.40);
+ --glow-accent: 0 0 20px var(--accent-a4);
+ --glass-bg: rgba(8, 12, 10, 0.72);
+ --glass-blur: blur(16px) saturate(180%);
+
+ /* Motion tokens. */
+ --transition-fast: 150ms ease;
+ --transition-base: 0.2s ease;
+ --transition-slow: 300ms ease;
+ --duration-instant: 100ms;
+ --duration-fast: 150ms;
+ --duration-base: 250ms;
+ --duration-slow: 400ms;
+ --ease-spring: cubic-bezier(0.34, 1.56, 0.64, 1);
+ --ease-spring-gentle: cubic-bezier(0.22, 1.20, 0.36, 1);
+ --ease-out-expo: cubic-bezier(0.16, 1.00, 0.30, 1);
+ --ease-in-out: cubic-bezier(0.40, 0, 0.20, 1);
+
+ /* Spacing + small-UI type scale — unchanged. */
--space-1: 4px;
--space-2: 8px;
--space-3: 12px;
--space-4: 16px;
--space-6: 24px;
--space-8: 32px;
- --text-xs: 11px;
- --text-sm: 13px;
+ --text-xs: 11px;
+ --text-sm: 13px;
--text-base: 14px;
- --text-lg: 16px;
- --text-xl: 20px;
- --text-2xl: 24px;
- --text-3xl: 36px;
- --transition-slow: 300ms ease;
- --ease-in-out: cubic-bezier(0.4, 0, 0.2, 1);
- --duration-instant: 100ms;
- --duration-fast: 150ms;
- --duration-base: 250ms;
- --duration-slow: 400ms;
- --accent-soft: var(--accent-subtle);
- --accent-dim: var(--accent-subtle);
- --bg-hover: var(--hover-surface);
- --danger-soft: var(--danger-subtle);
- --warning-soft: var(--warning-subtle);
+ --text-lg: 16px;
+ --text-xl: 20px;
+ --text-2xl: 24px;
+ --text-3xl: 36px;
}
+/* ==============================================================
+ * Light theme — the OmniSwap scales are literally reversed
+ * between modes, so the role tokens stay defined in :root above
+ * and we redefine only the scales + a couple of alpha overlays.
+ * ============================================================== */
[data-theme="light"] {
- --bg: #ffffff;
- --bg-secondary: #f5f5f7;
- --bg-tertiary: #ebebed;
- --border: rgba(0, 0, 0, 0.1);
- --text: #1a1a2e;
- --text-secondary: #555555;
- --accent: #059669;
- --accent-hover: #047857;
- --success: #059669;
- --info: #2563eb;
- --warning: #d97706;
- --danger: #dc2626;
- --code-bg: #f0f0f2;
- --shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
- --bg-overlay: rgba(0, 0, 0, 0.3);
- --bg-modal: #ffffff;
- --border-modal: #e0e0e0;
- --border-soft: #e5e5e5;
- --text-tertiary: #333333;
- --text-muted: #777777;
- --text-dimmed: #999999;
- --text-on-accent: #ffffff;
- --accent-brand: #059669;
- --accent-brand-hover: #047857;
- --warning-bg: #fffbeb;
- --warning-border: #fde68a;
- --warning-text: #92400e;
- --tab-bg: rgba(255, 255, 255, 0.9);
- --popover-bg: rgba(255, 255, 255, 0.95);
- --badge-sandbox-bg: rgba(136, 132, 216, 0.1);
+ /* Reversed neutral scale (sand). */
+ --sand-1: #fcfdfc;
+ --sand-2: #e9edec;
+ --sand-3: #dadedc;
+ --sand-4: #cacecc;
+ --sand-5: #9b9f9e;
+ --sand-6: #6e7270;
+ --sand-7: #4e5251;
+ --sand-8: #3d4140;
+ --sand-9: #252927;
+ --sand-10: #1b1f1d;
+ --sand-11: #121614;
+ --sand-12: #080c0a;
+
+ --sand-a1: rgba(0, 0, 0, 0.020);
+ --sand-a2: rgba(0, 0, 0, 0.032);
+ --sand-a3: rgba(0, 0, 0, 0.050);
+ --sand-a4: rgba(0, 0, 0, 0.080);
+ --sand-a5: rgba(0, 0, 0, 0.120);
+ --sand-a6: rgba(0, 0, 0, 0.170);
+ --sand-a7: rgba(0, 0, 0, 0.260);
+ --sand-a8: rgba(0, 0, 0, 0.420);
+ --sand-a9: rgba(0, 0, 0, 0.620);
+ --sand-a10: rgba(0, 0, 0, 0.720);
+ --sand-a11: rgba(0, 0, 0, 0.850);
+ --sand-a12: rgba(0, 0, 0, 0.980);
+
+ /* Reversed accent scale (osa — light mode is the literal scale). */
+ --accent-1: #edfcf3;
+ --accent-2: #d3f8df;
+ --accent-3: #aaf0c5;
+ --accent-4: #73e2a5;
+ --accent-5: #3bcc81;
+ --accent-6: #17b267;
+ --accent-7: #0b9053;
+ --accent-8: #097345;
+ --accent-9: #0a5b38; /* brand anchor — forest emerald on light */
+ --accent-10: #094b30;
+ --accent-11: #042a1b;
+ --accent-12: #031c12;
+
+ --accent-a1: rgba(10, 91, 56, 0.040);
+ --accent-a2: rgba(10, 91, 56, 0.070);
+ --accent-a3: rgba(10, 91, 56, 0.110);
+ --accent-a4: rgba(10, 91, 56, 0.160);
+ --accent-a5: rgba(10, 91, 56, 0.220);
+ --accent-a6: rgba(10, 91, 56, 0.310);
+ --accent-a7: rgba(10, 91, 56, 0.440);
+ --accent-a8: rgba(10, 91, 56, 0.600);
+ --accent-a9: rgba(10, 91, 56, 0.800);
+ --accent-a10: rgba(9, 75, 48, 0.870);
+ --accent-a11: rgba(4, 42, 27, 0.920);
+ --accent-a12: rgba(3, 28, 18, 0.970);
+
+ --accent-contrast: #edfcf3;
+
+ /* Light-mode overrides for the few legacy tokens that aren't a
+ * pure scale-step reference. */
+ --bg-overlay: rgba(0, 0, 0, 0.30);
+ --bg-modal: var(--sand-1);
+ --border-modal: var(--sand-a6);
+ --text-secondary: var(--sand-8);
+ --text-tertiary: var(--sand-10);
+ --text-muted: var(--sand-7);
+ --text-dimmed: var(--sand-6);
+ --text-on-accent: #ffffff;
+
+ --accent-hover: var(--accent-10);
+
+ --tab-bg: rgba(255, 255, 255, 0.90);
+ --popover-bg: rgba(255, 255, 255, 0.95);
+ --badge-sandbox-bg: rgba(136, 132, 216, 0.10);
--badge-sandbox-text: #6b67b0;
- --hover-surface: rgba(0, 0, 0, 0.03);
- --focus-ring: rgba(5, 150, 105, 0.15);
- --accent-subtle: rgba(5, 150, 105, 0.1);
- --accent-border-subtle: rgba(5, 150, 105, 0.3);
- --danger-subtle: rgba(220, 38, 38, 0.1);
- --danger-border-subtle: rgba(220, 38, 38, 0.2);
- --warning-subtle: rgba(217, 119, 6, 0.1);
- --border-hover: rgba(0, 0, 0, 0.15);
- --user-msg-bg: rgba(5, 150, 105, 0.08);
- --user-msg-border: rgba(5, 150, 105, 0.2);
- --danger-error-bg: rgba(220, 38, 38, 0.06);
- --accent-tee-bg: rgba(5, 150, 105, 0.08);
- --accent-tee-border: rgba(5, 150, 105, 0.2);
- --accent-tee-hover: rgba(5, 150, 105, 0.15);
- --text-on-danger: #fff;
- --shadow-card: 0 4px 24px rgba(0, 0, 0, 0.08);
- --shadow-toast: 0 4px 12px rgba(0, 0, 0, 0.08);
- --shadow-lg: 0 25px 50px -12px rgba(0, 0, 0, 0.1);
+
+ --warning-bg: #fffbeb;
+ --warning-border: #fde68a;
+ --warning-text: #92400e;
+ --warning-subtle: rgba(217, 119, 6, 0.10);
+ --warning-soft: rgba(217, 119, 6, 0.10);
+ --danger: #dc2626;
+ --danger-subtle: rgba(220, 38, 38, 0.10);
+ --danger-border-subtle: rgba(220, 38, 38, 0.20);
+ --danger-error-bg: rgba(220, 38, 38, 0.06);
--danger-error-border: rgba(220, 38, 38, 0.15);
- --note-bg: rgba(0, 0, 0, 0.02);
- --overlay-heavy: rgba(0, 0, 0, 0.4);
- --highlight-bg: rgba(5, 150, 105, 0.2);
- --hover-subtle: rgba(0, 0, 0, 0.04);
- --shadow-sm: 0 1px 2px rgba(0,0,0,0.06), 0 1px 3px rgba(0,0,0,0.04);
- --shadow-md: 0 4px 12px rgba(0,0,0,0.08), 0 2px 4px rgba(0,0,0,0.04);
- --glow-accent: 0 0 20px rgba(5,150,105,0.08);
- --glass-bg: rgba(255,255,255,0.85);
- --glass-blur: blur(16px) saturate(180%);
- --ease-spring: cubic-bezier(0.34, 1.56, 0.64, 1);
- --ease-spring-gentle: cubic-bezier(0.22, 1.2, 0.36, 1);
- --ease-out-expo: cubic-bezier(0.16, 1, 0.3, 1);
- --surface-highlight: inset 0 1px 0 rgba(255,255,255,0.8);
- --space-1: 4px;
- --space-2: 8px;
- --space-3: 12px;
- --space-4: 16px;
- --space-6: 24px;
- --space-8: 32px;
- --text-xs: 11px;
- --text-sm: 13px;
- --text-base: 14px;
- --text-lg: 16px;
- --text-xl: 20px;
- --text-2xl: 24px;
- --text-3xl: 36px;
- --transition-slow: 300ms ease;
- --ease-in-out: cubic-bezier(0.4, 0, 0.2, 1);
- --duration-instant: 100ms;
- --duration-fast: 150ms;
- --duration-base: 250ms;
- --duration-slow: 400ms;
- --accent-soft: var(--accent-subtle);
- --accent-dim: var(--accent-subtle);
- --bg-hover: var(--hover-surface);
- --danger-soft: var(--danger-subtle);
- --warning-soft: var(--warning-subtle);
+ --danger-soft: rgba(220, 38, 38, 0.10);
+
+ --info: #2563eb;
+ --warning: #d97706;
+
+ --shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
+ --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.06), 0 1px 3px rgba(0, 0, 0, 0.04);
+ --shadow-md: 0 4px 12px rgba(0, 0, 0, 0.08), 0 2px 4px rgba(0, 0, 0, 0.04);
+ --shadow-lg: 0 25px 50px -12px rgba(0, 0, 0, 0.10);
+ --shadow-toast: 0 4px 12px rgba(0, 0, 0, 0.08);
+ --glow-accent: 0 0 20px var(--accent-a3);
+ --glass-bg: rgba(255, 255, 255, 0.85);
+ --surface-highlight: inset 0 1px 0 rgba(255, 255, 255, 0.80);
}
diff --git a/docs/design-system/phase-a/README.md b/docs/design-system/phase-a/README.md
new file mode 100644
index 00000000000..8df9bfef6a5
--- /dev/null
+++ b/docs/design-system/phase-a/README.md
@@ -0,0 +1,85 @@
+# Phase A — Token foundation
+
+First visible step of the design-system adoption tracked in
+`/home/illia/.claude/plans/quiet-nibbling-moth.md`. Restructures
+`theme.css` with Radix Themes-style 12-step scales anchored on
+Defuse's OmniSwap whitelabel palette, introduces semantic role tokens,
+a layered shadow system, extended radii + type scale, and swaps the
+webfont from DM Sans → Geist (plus Geist Mono for monospace).
+
+## What changed
+
+- **Accent scale (`--accent-1..12` + `--accent-a1..12`)** — OmniSwap
+ accent green, 12-step mint→forest gradient. Brand anchor at
+ `--accent-9` = `#73e2a5` (dark) / `#0a5b38` (light). Replaces the
+ single `--accent: #34d399` token.
+- **Neutral scale (`--sand-1..12` + `--sand-a1..12`)** — OmniSwap
+ gray, green-tinged warm neutrals.
+- **Semantic role aliases** — `--color-bg`, `--color-surface`,
+ `--color-surface-elevated`, `--color-border`, `--color-border-hover`,
+ `--color-text`, `--color-text-muted`, `--color-text-subtle`,
+ `--color-label`, `--color-warning(-foreground)`, `--accent-contrast`.
+- **Layered shadow set** — `--shadow-paper`, `--shadow-widget`,
+ `--shadow-card`, `--shadow-inset` replace the earlier single
+ `--shadow`.
+- **Radius** — adds `--radius-xl: 16px`, `--radius-2xl: 20px`,
+ `--radius-4xl: 30px` (Defuse's big rounded-paper aesthetic).
+- **Type scale** — adds `--text-4xl: 32px`, `--text-5xl: 48px`,
+ `--text-6xl: 64px` + `--tracking-display`, `--tracking-6xl`.
+- **Typography** — `--font-sans: 'Geist'`, `--font-mono: 'Geist Mono'`.
+ Fallback chain preserves DM Sans / IBM Plex Mono so partial renders
+ still look close to the target.
+- **Legacy tokens preserved** — every existing name (`--bg`, `--text`,
+ `--accent`, `--shadow-card`, …) is now an alias onto the new scales
+ so the 20 split surface stylesheets under `styles/` require **zero
+ changes**.
+
+Everything else (spacing, motion, easings) is unchanged.
+
+## Screenshots
+
+Captured with `tests/e2e/scenarios/capture_design_surfaces.py` at
+viewport 1280×800, Chromium. The `before/` set is the tree immediately
+before Phase A; the `after/` set is the same tree with `theme.css`
+restructured and the Geist font loaded.
+
+### Dark mode
+
+| Surface | Before | After |
+|---|---|---|
+| Chat (with seeded turn) |  |  |
+| Workspace / memory |  |  |
+| Jobs |  |  |
+| Missions |  |  |
+| Routines |  |  |
+| Settings |  |  |
+| Logs |  |  |
+| Auth screen |  |  |
+
+### Light mode (sampled surfaces)
+
+| Surface | Before | After |
+|---|---|---|
+| Chat |  |  |
+| Settings |  |  |
+| Auth screen |  |  |
+
+## Reproducing
+
+```bash
+# Baseline (before these changes)
+git checkout staging
+cargo build --no-default-features --features libsql --bin ironclaw
+cd tests/e2e && source .venv/bin/activate
+CAPTURE_SCREENSHOTS=1 SCREENSHOT_DIR=/tmp/before pytest scenarios/capture_design_surfaces.py
+
+# After
+git checkout feat/phase-a-tokens-defuse
+cargo build --no-default-features --features libsql --bin ironclaw
+cd tests/e2e && source .venv/bin/activate
+CAPTURE_SCREENSHOTS=1 SCREENSHOT_DIR=/tmp/after pytest scenarios/capture_design_surfaces.py
+```
+
+The capture scenario is opt-in (env var gated) and will be skipped by
+the default `pytest scenarios/` run — it exists solely to produce
+visual diffs for design-system PRs.
diff --git a/docs/design-system/phase-a/after/dark-auth.png b/docs/design-system/phase-a/after/dark-auth.png
new file mode 100644
index 00000000000..64fa021e8d5
Binary files /dev/null and b/docs/design-system/phase-a/after/dark-auth.png differ
diff --git a/docs/design-system/phase-a/after/dark-chat.png b/docs/design-system/phase-a/after/dark-chat.png
new file mode 100644
index 00000000000..01aff44a52e
Binary files /dev/null and b/docs/design-system/phase-a/after/dark-chat.png differ
diff --git a/docs/design-system/phase-a/after/dark-jobs.png b/docs/design-system/phase-a/after/dark-jobs.png
new file mode 100644
index 00000000000..3ec2c405d80
Binary files /dev/null and b/docs/design-system/phase-a/after/dark-jobs.png differ
diff --git a/docs/design-system/phase-a/after/dark-logs.png b/docs/design-system/phase-a/after/dark-logs.png
new file mode 100644
index 00000000000..fa49aa78ebb
Binary files /dev/null and b/docs/design-system/phase-a/after/dark-logs.png differ
diff --git a/docs/design-system/phase-a/after/dark-memory.png b/docs/design-system/phase-a/after/dark-memory.png
new file mode 100644
index 00000000000..a2071bad110
Binary files /dev/null and b/docs/design-system/phase-a/after/dark-memory.png differ
diff --git a/docs/design-system/phase-a/after/dark-missions.png b/docs/design-system/phase-a/after/dark-missions.png
new file mode 100644
index 00000000000..c0b249a067e
Binary files /dev/null and b/docs/design-system/phase-a/after/dark-missions.png differ
diff --git a/docs/design-system/phase-a/after/dark-routines.png b/docs/design-system/phase-a/after/dark-routines.png
new file mode 100644
index 00000000000..c0b249a067e
Binary files /dev/null and b/docs/design-system/phase-a/after/dark-routines.png differ
diff --git a/docs/design-system/phase-a/after/dark-settings.png b/docs/design-system/phase-a/after/dark-settings.png
new file mode 100644
index 00000000000..e73d7a2c8b0
Binary files /dev/null and b/docs/design-system/phase-a/after/dark-settings.png differ
diff --git a/docs/design-system/phase-a/after/light-auth.png b/docs/design-system/phase-a/after/light-auth.png
new file mode 100644
index 00000000000..e79009dacec
Binary files /dev/null and b/docs/design-system/phase-a/after/light-auth.png differ
diff --git a/docs/design-system/phase-a/after/light-chat.png b/docs/design-system/phase-a/after/light-chat.png
new file mode 100644
index 00000000000..1d72f0b4035
Binary files /dev/null and b/docs/design-system/phase-a/after/light-chat.png differ
diff --git a/docs/design-system/phase-a/after/light-settings.png b/docs/design-system/phase-a/after/light-settings.png
new file mode 100644
index 00000000000..7e8f2b91742
Binary files /dev/null and b/docs/design-system/phase-a/after/light-settings.png differ
diff --git a/docs/design-system/phase-a/before/dark-auth.png b/docs/design-system/phase-a/before/dark-auth.png
new file mode 100644
index 00000000000..a0031700888
Binary files /dev/null and b/docs/design-system/phase-a/before/dark-auth.png differ
diff --git a/docs/design-system/phase-a/before/dark-chat.png b/docs/design-system/phase-a/before/dark-chat.png
new file mode 100644
index 00000000000..b85299f18ec
Binary files /dev/null and b/docs/design-system/phase-a/before/dark-chat.png differ
diff --git a/docs/design-system/phase-a/before/dark-jobs.png b/docs/design-system/phase-a/before/dark-jobs.png
new file mode 100644
index 00000000000..4f440cb5569
Binary files /dev/null and b/docs/design-system/phase-a/before/dark-jobs.png differ
diff --git a/docs/design-system/phase-a/before/dark-logs.png b/docs/design-system/phase-a/before/dark-logs.png
new file mode 100644
index 00000000000..946c2a1025b
Binary files /dev/null and b/docs/design-system/phase-a/before/dark-logs.png differ
diff --git a/docs/design-system/phase-a/before/dark-memory.png b/docs/design-system/phase-a/before/dark-memory.png
new file mode 100644
index 00000000000..0b049848c80
Binary files /dev/null and b/docs/design-system/phase-a/before/dark-memory.png differ
diff --git a/docs/design-system/phase-a/before/dark-missions.png b/docs/design-system/phase-a/before/dark-missions.png
new file mode 100644
index 00000000000..7d10fcd632e
Binary files /dev/null and b/docs/design-system/phase-a/before/dark-missions.png differ
diff --git a/docs/design-system/phase-a/before/dark-routines.png b/docs/design-system/phase-a/before/dark-routines.png
new file mode 100644
index 00000000000..7d10fcd632e
Binary files /dev/null and b/docs/design-system/phase-a/before/dark-routines.png differ
diff --git a/docs/design-system/phase-a/before/dark-settings.png b/docs/design-system/phase-a/before/dark-settings.png
new file mode 100644
index 00000000000..cd098d381c2
Binary files /dev/null and b/docs/design-system/phase-a/before/dark-settings.png differ
diff --git a/docs/design-system/phase-a/before/light-auth.png b/docs/design-system/phase-a/before/light-auth.png
new file mode 100644
index 00000000000..e9473a09ae4
Binary files /dev/null and b/docs/design-system/phase-a/before/light-auth.png differ
diff --git a/docs/design-system/phase-a/before/light-chat.png b/docs/design-system/phase-a/before/light-chat.png
new file mode 100644
index 00000000000..a72971e76c0
Binary files /dev/null and b/docs/design-system/phase-a/before/light-chat.png differ
diff --git a/docs/design-system/phase-a/before/light-settings.png b/docs/design-system/phase-a/before/light-settings.png
new file mode 100644
index 00000000000..0e327e0b9d8
Binary files /dev/null and b/docs/design-system/phase-a/before/light-settings.png differ
diff --git a/tests/e2e/scenarios/capture_design_surfaces.py b/tests/e2e/scenarios/capture_design_surfaces.py
new file mode 100644
index 00000000000..f4457af713a
--- /dev/null
+++ b/tests/e2e/scenarios/capture_design_surfaces.py
@@ -0,0 +1,169 @@
+"""Capture before/after screenshots of every major UI surface.
+
+Opt-in — runs only when ``CAPTURE_SCREENSHOTS=1`` is set. Writes PNGs
+to ``SCREENSHOT_DIR`` (default ``/tmp/ironclaw-screenshots``).
+
+Used to produce visual diffs for design-system PRs (Phase A, B, C, …).
+The typical workflow:
+
+ # 1. Baseline (before design changes)
+ CAPTURE_SCREENSHOTS=1 SCREENSHOT_DIR=/tmp/before \
+ pytest tests/e2e/scenarios/capture_design_surfaces.py
+
+ # 2. Apply design changes on the same branch
+
+ # 3. After
+ CAPTURE_SCREENSHOTS=1 SCREENSHOT_DIR=/tmp/after \
+ pytest tests/e2e/scenarios/capture_design_surfaces.py
+
+Outputs two sets of 1280×800 PNGs per tab + theme; reference them in
+the PR body for visual review.
+"""
+
+import os
+import pathlib
+
+import pytest
+
+from helpers import AUTH_TOKEN, SEL
+
+# --- Surface inventory ----------------------------------------------------
+#
+# (tab_id, wait_for_selector) tuples. The wait_for selector is a best-effort
+# anchor — a missing selector is downgraded to a fixed wait so empty states
+# are still captured.
+
+DARK_SURFACES = [
+ ("chat", "#chat-input"),
+ ("memory", "#memory-tab-container, #memory-tree, .empty-state"),
+ ("jobs", ".jobs-container, .empty-state"),
+ ("missions", ".missions-container, .empty-state"),
+ ("routines", ".routines-container, .empty-state"),
+ ("settings", ".settings-layout, .settings-subtab"),
+ ("logs", "#tab-logs"),
+]
+
+# Capture the light-mode palette only for the two surfaces the user lands
+# on first — enough to verify the color-token shift without ballooning the
+# image count.
+LIGHT_SURFACES = [
+ ("chat", "#chat-input"),
+ ("settings", ".settings-layout, .settings-subtab"),
+]
+
+
+def _should_run() -> bool:
+ return os.environ.get("CAPTURE_SCREENSHOTS", "").strip() in ("1", "true")
+
+
+def _out_dir() -> pathlib.Path:
+ raw = os.environ.get("SCREENSHOT_DIR", "/tmp/ironclaw-screenshots")
+ p = pathlib.Path(raw)
+ p.mkdir(parents=True, exist_ok=True)
+ return p
+
+
+async def _set_theme(page, theme: str) -> None:
+ """Flip the theme attributes the app reads on load."""
+ await page.evaluate(
+ f"""
+ document.documentElement.setAttribute('data-theme', '{theme}');
+ document.documentElement.setAttribute('data-theme-mode', '{theme}');
+ """
+ )
+ # Give the CSS variable cascade a frame to settle.
+ await page.wait_for_timeout(300)
+
+
+async def _capture_tab(page, tab: str, wait_for: str, out: pathlib.Path, theme: str) -> None:
+ btn = SEL["tab_button"].format(tab=tab)
+ try:
+ await page.locator(btn).click(timeout=3000)
+ except Exception:
+ # Some tabs (missions on v1, projects on v1) are hidden. Force-navigate
+ # by setting the URL hash so the router promotes them.
+ await page.evaluate(f"window.location.hash = '#/{tab}'")
+
+ # Best-effort wait for the tab to populate.
+ try:
+ await page.wait_for_selector(wait_for, state="visible", timeout=3000)
+ except Exception:
+ pass # Empty states are valid screenshots too.
+
+ await page.wait_for_timeout(400) # Let skeletons/loaders dissolve.
+ await page.screenshot(path=str(out / f"{theme}-{tab}.png"))
+
+
+async def _seed_chat_conversation(page) -> None:
+ """Inject a one-turn conversation so chat screenshots show real bubbles.
+
+ Uses the same DOM helpers the app calls from its SSE handlers — no
+ new rendering path.
+ """
+ await page.evaluate(
+ """
+ () => {
+ if (typeof addMessage !== 'function') return;
+ addMessage('user', 'What is the current state of IronClaw?');
+ addMessage('assistant',
+ 'IronClaw is a secure personal AI assistant built around defense in ' +
+ 'depth, self-expanding tools, and multi-channel access. The engine v2 ' +
+ 'rollout is currently landing per-project sandboxing and cost tracking.');
+ }
+ """
+ )
+ await page.wait_for_timeout(200)
+
+
+async def test_capture_design_surfaces(page):
+ """Walk every main tab, capture dark + (subset) light screenshots."""
+ if not _should_run():
+ pytest.skip("Set CAPTURE_SCREENSHOTS=1 to run")
+
+ out = _out_dir()
+
+ # Make projects/missions available in the v2-only guard, so we capture
+ # them regardless of the baseline engine flag.
+ await page.evaluate(
+ """
+ () => {
+ if (typeof engineV2Enabled !== 'undefined') { engineV2Enabled = true; }
+ if (typeof applyEngineModeToTabs === 'function') { applyEngineModeToTabs(); }
+ }
+ """
+ )
+ await page.wait_for_timeout(200)
+
+ # Seed chat so the bubble styling is visible in both themes.
+ await _seed_chat_conversation(page)
+
+ # --- Dark mode ---
+ await _set_theme(page, "dark")
+ for tab, wait_for in DARK_SURFACES:
+ await _capture_tab(page, tab, wait_for, out, "dark")
+
+ # --- Light mode subset ---
+ await _set_theme(page, "light")
+ # Re-seed chat in case the tab switch cleared it.
+ await page.locator(SEL["tab_button"].format(tab="chat")).click(timeout=3000)
+ await _seed_chat_conversation(page)
+ for tab, wait_for in LIGHT_SURFACES:
+ await _capture_tab(page, tab, wait_for, out, "light")
+
+
+async def test_capture_auth_screen(ironclaw_server, browser):
+ """Capture the unauthenticated landing screen in both themes."""
+ if not _should_run():
+ pytest.skip("Set CAPTURE_SCREENSHOTS=1 to run")
+
+ out = _out_dir()
+ context = await browser.new_context(viewport={"width": 1280, "height": 800})
+ pg = await context.new_page()
+ try:
+ await pg.goto(f"{ironclaw_server}/") # no `?token=…` so auth screen stays
+ await pg.wait_for_selector("#auth-screen", state="visible", timeout=5000)
+ for theme in ("dark", "light"):
+ await _set_theme(pg, theme)
+ await pg.screenshot(path=str(out / f"{theme}-auth.png"))
+ finally:
+ await context.close()