Alice-blue — the dress — is the brand accent in both modes. Day borrows the reference card's cream & ink; night stays a calm neutral charcoal-black (the #353 palette) so the full shell doesn't read as blue — alice-blue survives only as small highlights.
+
+
+
+
+
+
Light
+
Daybreak
+
Cream paper · engraved navy · alice-blue · a touch of gold
+
+
+
+
+
+
Dark
+
Midnight
+
Neutral charcoal-black (#353) · calm shell · alice-blue only as small brand highlights
+
+
+
+
+
+
+
+
+ 02
+
In Context
+
The shell below adopts the active theme — flip the toggle up top and watch it re-skin live.
+
+
+
+
+
+
+
+
+ OpenAlice
+
+
+
+ Ask Alice
+
+
+
+ Inbox
+
+
Build
+
+
+ Workspaces
+
+
+
+ Markets
+
+
+
+
+ Settings
+
+
+
+
+
+
+ Ask Alice
+
+
+
+
Today
+
CWhat's moving in semis today?
+
X解释美债收益率曲线倒挂
+
OBacktest a momentum sweep
+
Yesterday
+
πThesis on NVDA earnings
+
+
+
+
+
+
+
Ask Alice
+
chat-jun16
+
+
+
+
What are we trading today?
+
Type a message — Alice opens a fresh session already on it.
+
+
Ask anything, or describe a task…
+
+ Chat
+ Claude ▾
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 03
+
Variable Map
+
Drops straight into ui/src/index.css — one set for :root (day), one for the dark override.
+
+
+
Token
Daybreak (light)
Midnight (dark)
Drawn from
+
+
+
+
+
+
+
+
+
+
+
diff --git a/ui/index.html b/ui/index.html
index ca5aa410..48328db1 100644
--- a/ui/index.html
+++ b/ui/index.html
@@ -1,11 +1,26 @@
-
+
OpenAlice
-
+
+
diff --git a/ui/src/components/ActivityBar.tsx b/ui/src/components/ActivityBar.tsx
index eba47251..3e5fbb24 100644
--- a/ui/src/components/ActivityBar.tsx
+++ b/ui/src/components/ActivityBar.tsx
@@ -8,6 +8,7 @@ import { useUnreadInboxCount } from '../live/inbox-read'
import { usePendingPushCount } from '../live/trading-push'
import { useActivityBarCollapse } from '../live/activity-bar-collapse'
import { useTranslation } from 'react-i18next'
+import { ThemeToggle } from './ThemeToggle'
/**
* Map ActivityBar page enum (visual layout grouping) to the ActivitySection
@@ -197,7 +198,7 @@ export function ActivityBar({ open, onClose, onItemActivated }: ActivityBarProps
OpenAlice
@@ -274,8 +275,8 @@ export function ActivityBar({ open, onClose, onItemActivated }: ActivityBarProps
title={t(item.labelKey)}
className={`relative flex items-center gap-3 px-3 py-1.5 rounded-md text-[13px] transition-colors text-left ${
isActive
- ? 'bg-bg-tertiary text-text shadow-[inset_0_0_0_1px_rgba(255,255,255,0.045)]'
- : 'text-text-muted hover:text-text hover:bg-white/[0.035]'
+ ? 'bg-bg-tertiary text-text shadow-[inset_0_0_0_1px_var(--color-overlay)]'
+ : 'text-text-muted hover:text-text hover:bg-overlay'
}`}
>
{/* Active indicator — left vertical bar */}
@@ -315,6 +316,10 @@ export function ActivityBar({ open, onClose, onItemActivated }: ActivityBarProps
})}
+ {/* Footer — global toggles pinned to the bottom of the rail. */}
+
diff --git a/ui/src/components/TabStrip.tsx b/ui/src/components/TabStrip.tsx
index b97c9026..b39df302 100644
--- a/ui/src/components/TabStrip.tsx
+++ b/ui/src/components/TabStrip.tsx
@@ -153,7 +153,7 @@ function TabButton({ title, active, onSelect, onClose, onContextMenu }: TabButto
className={`group flex items-center gap-2 pl-3 pr-2 h-full text-[13px] cursor-pointer border-r border-border/80 transition-colors ${
active
? 'bg-bg-tertiary text-text'
- : 'text-text-muted hover:text-text hover:bg-white/[0.035]'
+ : 'text-text-muted hover:text-text hover:bg-overlay'
}`}
>
{title}
@@ -163,7 +163,7 @@ function TabButton({ title, active, onSelect, onClose, onContextMenu }: TabButto
e.stopPropagation()
onClose()
}}
- className="w-4 h-4 rounded flex items-center justify-center text-text-muted/60 hover:text-text hover:bg-white/[0.06]"
+ className="w-4 h-4 rounded flex items-center justify-center text-text-muted/60 hover:text-text hover:bg-overlay-strong"
aria-label={`Close ${title}`}
>
diff --git a/ui/src/components/ThemeToggle.tsx b/ui/src/components/ThemeToggle.tsx
new file mode 100644
index 00000000..92e4d959
--- /dev/null
+++ b/ui/src/components/ThemeToggle.tsx
@@ -0,0 +1,50 @@
+/**
+ * The color-theme toggle that lives in the ActivityBar footer. One button
+ * that cycles auto → light → dark → auto; the icon + label reflect the
+ * CURRENT mode, the tooltip names the NEXT one. Styled to match the nav rows
+ * above it (same height, padding, hover) so the rail reads as one column.
+ *
+ * State is the theme store (ui/src/theme/store); the side-effect module
+ * applies ``, CSS does the rest. No prop drilling.
+ */
+
+import { Monitor, Moon, Sun } from 'lucide-react'
+import type { LucideIcon } from 'lucide-react'
+import { useTranslation } from 'react-i18next'
+
+import { useThemeStore, type AppTheme } from '../theme/store'
+
+const ICON: Record = {
+ auto: Monitor,
+ light: Sun,
+ dark: Moon,
+}
+
+/** What the NEXT click switches to (auto → light → dark → auto). */
+const NEXT: Record = {
+ auto: 'light',
+ light: 'dark',
+ dark: 'auto',
+}
+
+export function ThemeToggle() {
+ const { t } = useTranslation()
+ const theme = useThemeStore((s) => s.theme)
+ const cycle = useThemeStore((s) => s.cycleTheme)
+ const Icon = ICON[theme]
+
+ return (
+
+ )
+}
diff --git a/ui/src/components/form.tsx b/ui/src/components/form.tsx
index f18cb8c7..f18948c3 100644
--- a/ui/src/components/form.tsx
+++ b/ui/src/components/form.tsx
@@ -3,7 +3,7 @@ import type { ReactNode } from 'react'
// ==================== Shared class constants ====================
export const inputClass =
- 'w-full px-3 py-2 bg-bg text-text border border-border rounded-lg font-sans text-sm outline-none transition-all duration-200 focus:border-accent/60 focus:shadow-[0_0_0_1px_rgba(88,166,255,0.1)]'
+ 'w-full px-3 py-2 bg-bg text-text border border-border rounded-lg font-sans text-sm outline-none transition-all duration-200 focus:border-accent/60 focus:shadow-[0_0_0_1px_var(--color-accent-dim)]'
// ==================== Card ====================
diff --git a/ui/src/i18n/locales/en.ts b/ui/src/i18n/locales/en.ts
index 7006b9c8..66665f48 100644
--- a/ui/src/i18n/locales/en.ts
+++ b/ui/src/i18n/locales/en.ts
@@ -137,6 +137,10 @@ export const en = {
ex2: 'Build a thesis on NVDA',
ex3: 'Scan the EV supply chain',
},
+ theme: {
+ mode: { auto: 'Auto', light: 'Light', dark: 'Dark' },
+ switchTo: 'Switch to {{mode}}',
+ },
dev: {
snapshots: 'Snapshots',
},
diff --git a/ui/src/i18n/locales/ja.ts b/ui/src/i18n/locales/ja.ts
index 146c4241..8b12fd1e 100644
--- a/ui/src/i18n/locales/ja.ts
+++ b/ui/src/i18n/locales/ja.ts
@@ -126,6 +126,10 @@ export const ja: Resources = {
ex2: 'NVDA の強気シナリオを作って',
ex3: 'EV サプライチェーンを整理して',
},
+ theme: {
+ mode: { auto: '自動', light: 'ライト', dark: 'ダーク' },
+ switchTo: '{{mode}}に切り替え',
+ },
dev: {
snapshots: 'スナップショット',
},
diff --git a/ui/src/i18n/locales/zh-Hant.ts b/ui/src/i18n/locales/zh-Hant.ts
index bc636468..e9c9cfd4 100644
--- a/ui/src/i18n/locales/zh-Hant.ts
+++ b/ui/src/i18n/locales/zh-Hant.ts
@@ -134,6 +134,10 @@ export const zhHant: Resources = {
ex2: '幫我做一個輝達的多頭邏輯',
ex3: '梳理一下電動車產業鏈',
},
+ theme: {
+ mode: { auto: '自動', light: '淺色', dark: '深色' },
+ switchTo: '切換到{{mode}}',
+ },
dev: {
snapshots: '快照',
},
diff --git a/ui/src/i18n/locales/zh.ts b/ui/src/i18n/locales/zh.ts
index 8af5f52d..545b7cfa 100644
--- a/ui/src/i18n/locales/zh.ts
+++ b/ui/src/i18n/locales/zh.ts
@@ -126,6 +126,10 @@ export const zh: Resources = {
ex2: '给我做一个英伟达的多头逻辑',
ex3: '梳理一下电动车产业链',
},
+ theme: {
+ mode: { auto: '自动', light: '浅色', dark: '深色' },
+ switchTo: '切换到{{mode}}',
+ },
dev: {
snapshots: '快照',
},
diff --git a/ui/src/index.css b/ui/src/index.css
index 1bb1c179..20d6e113 100644
--- a/ui/src/index.css
+++ b/ui/src/index.css
@@ -1,16 +1,70 @@
@import "tailwindcss";
-/* Linear dark Alice shell palette */
+/* ============================================================
+ Theme tokens — two palettes, one identity. Alice-blue (her dress)
+ is the brand accent in BOTH modes. @theme below is Daybreak (light)
+ and the DEFAULT; the [data-theme="dark"] block + the prefers-color-
+ scheme block flip the same token names, so every `bg-bg`/`text-accent`/
+ `border-border` utility follows the active theme with zero component
+ changes. Living color card: ui/design/theme-study.html. The toggle lives in the
+ ActivityBar footer (light / dark / auto), persisted by the theme store
+ (ui/src/theme) and mirrored pre-paint by the inline script in index.html.
+ ============================================================ */
@theme {
+ /* Daybreak — light (default). Cream paper · engraved navy · alice-blue. */
+ --color-bg: #f7f4ec;
+ --color-bg-secondary: #f0ebdd;
+ --color-bg-tertiary: #e5decb;
+ --color-border: #d8d0bc;
+ --color-text: #1c2a41;
+ --color-text-muted: #5e6573;
+ --color-accent: #2f62b0;
+ --color-accent-dim: rgba(47, 98, 176, 0.14);
+ --color-user-bubble: #2f62b0;
+ --color-assistant-bubble: #fbf8f0;
+ --color-notification-bg: #fbf1d6;
+ --color-notification-border: #c99a2e;
+ --color-green: #2e8b6f;
+ --color-red: #be4138;
+ --color-purple: #6b5bc2;
+ --color-purple-dim: rgba(107, 91, 194, 0.14);
+
+ /* Elevation overlays — theme-aware "subtle wash on hover / active".
+ Light darkens with navy ink; dark lightens with white. Components use
+ `bg-overlay` / `bg-overlay-strong`; raw CSS uses `var(--color-overlay)`. */
+ --color-overlay: rgba(28, 42, 65, 0.05);
+ --color-overlay-strong: rgba(28, 42, 65, 0.085);
+
+ --font-sans: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC",
+ "Microsoft YaHei", "Hiragino Sans GB", "Noto Sans CJK SC", sans-serif;
+ --font-mono: "SF Mono", "Fira Code", "Cascadia Code", Menlo, Consolas,
+ monospace;
+}
+
+/* Base resolution (no attribute / explicit light). */
+:root {
+ color-scheme: light;
+ --app-bg-wash: radial-gradient(circle at 50% 24%, rgba(47, 98, 176, 0.045), transparent 38rem);
+}
+
+/* ---- Midnight — dark (neutral charcoal-black) ----
+ Reverted to the #353 neutral-black palette: a navy-tinted dark read as
+ "too blue" once the full shell (rail + sidebar + tabs) was on screen — a
+ large neutral surface is calmer. Alice-blue survives ONLY as small brand
+ highlights (accent + the user's own bubble), so the large-area blue tint
+ is gone while the brand stays consistent with Daybreak.
+ Forced via data-theme="dark", AND the resolution for data-theme="auto"
+ when the OS is in dark mode (media block below). KEEP THE TWO IN SYNC. */
+:root[data-theme="dark"] {
--color-bg: #0b0c0e;
--color-bg-secondary: #0e0f12;
--color-bg-tertiary: #1a1b21;
--color-border: #24262c;
--color-text: #dfe1e6;
--color-text-muted: #8f929b;
- --color-accent: #23b99a;
- --color-accent-dim: rgba(35, 185, 154, 0.16);
- --color-user-bubble: #1f2026;
+ --color-accent: #3b82f6;
+ --color-accent-dim: rgba(59, 130, 246, 0.16);
+ --color-user-bubble: #2f62b0;
--color-assistant-bubble: #141519;
--color-notification-bg: #1d1610;
--color-notification-border: #8a5b2f;
@@ -18,11 +72,34 @@
--color-red: #e5484d;
--color-purple: #8f72ff;
--color-purple-dim: rgba(143, 114, 255, 0.16);
-
- --font-sans: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC",
- "Microsoft YaHei", "Hiragino Sans GB", "Noto Sans CJK SC", sans-serif;
- --font-mono: "SF Mono", "Fira Code", "Cascadia Code", Menlo, Consolas,
- monospace;
+ --color-overlay: rgba(255, 255, 255, 0.04);
+ --color-overlay-strong: rgba(255, 255, 255, 0.07);
+ color-scheme: dark;
+ --app-bg-wash: radial-gradient(circle at 50% 28%, rgba(255, 255, 255, 0.02), transparent 34rem);
+}
+@media (prefers-color-scheme: dark) {
+ :root[data-theme="auto"] {
+ --color-bg: #0b0c0e;
+ --color-bg-secondary: #0e0f12;
+ --color-bg-tertiary: #1a1b21;
+ --color-border: #24262c;
+ --color-text: #dfe1e6;
+ --color-text-muted: #8f929b;
+ --color-accent: #3b82f6;
+ --color-accent-dim: rgba(59, 130, 246, 0.16);
+ --color-user-bubble: #2f62b0;
+ --color-assistant-bubble: #141519;
+ --color-notification-bg: #1d1610;
+ --color-notification-border: #8a5b2f;
+ --color-green: #23b99a;
+ --color-red: #e5484d;
+ --color-purple: #8f72ff;
+ --color-purple-dim: rgba(143, 114, 255, 0.16);
+ --color-overlay: rgba(255, 255, 255, 0.04);
+ --color-overlay-strong: rgba(255, 255, 255, 0.07);
+ color-scheme: dark;
+ --app-bg-wash: radial-gradient(circle at 50% 28%, rgba(255, 255, 255, 0.02), transparent 34rem);
+ }
}
/* Japanese gets a JP-first sans stack so shared Han characters render in
@@ -52,14 +129,13 @@ body,
}
body {
- background:
- radial-gradient(circle at 50% 28%, rgba(255, 255, 255, 0.022), transparent 34rem),
- linear-gradient(180deg, #0e0f12 0%, #0b0c0e 46%, #090a0c 100%);
+ background: var(--app-bg-wash), var(--color-bg);
color: var(--color-text);
+ transition: background-color 0.4s ease, color 0.4s ease;
}
::selection {
- background: rgba(35, 185, 154, 0.28);
+ background: color-mix(in srgb, var(--color-accent) 28%, transparent);
color: var(--color-text);
}
diff --git a/ui/src/main.tsx b/ui/src/main.tsx
index f204d7a6..b9be27a5 100644
--- a/ui/src/main.tsx
+++ b/ui/src/main.tsx
@@ -6,6 +6,7 @@ import { ToastProvider } from './components/Toast'
import { AuthProvider } from './auth/AuthContext'
import { AuthGate } from './auth/AuthGate'
import './index.css'
+import './theme' // side-effect: apply persisted color theme to
import './i18n' // side-effect: init react-i18next + seed locale before first render
if (import.meta.env.VITE_DEMO_MODE) {
diff --git a/ui/src/pages/ChatLandingPage.tsx b/ui/src/pages/ChatLandingPage.tsx
index 2dbc9cc4..225d44b3 100644
--- a/ui/src/pages/ChatLandingPage.tsx
+++ b/ui/src/pages/ChatLandingPage.tsx
@@ -106,9 +106,9 @@ export function ChatLandingPage() {
dropped: they drift on portrait and read as pixel-placed art, not a
responsive surface. pointer-events-none so it never intercepts clicks. */}
-
-
-
+
+
+
diff --git a/ui/src/theme/index.ts b/ui/src/theme/index.ts
new file mode 100644
index 00000000..3158cdbc
--- /dev/null
+++ b/ui/src/theme/index.ts
@@ -0,0 +1,26 @@
+/**
+ * Theme bootstrap — side-effect module. `import './theme'` once in main.tsx,
+ * BEFORE first render, so `` matches the persisted choice.
+ *
+ * Wiring is one-directional, mirroring i18n/index.ts: the theme store is the
+ * source of truth; here we (a) apply the persisted value at boot and (b)
+ * subscribe so every later switch re-applies. The CSS in index.css resolves
+ * `data-theme` → the active palette (and `auto` follows prefers-color-scheme
+ * via a media query), so this module never touches CSS variables itself.
+ *
+ * A near-identical apply already ran from index.html's inline script to avoid
+ * a first-paint flash; re-applying here is cheap and self-heals any drift
+ * (e.g. the persisted key changing shape across a version bump).
+ */
+
+import { useThemeStore, readInitialTheme, type AppTheme } from './store'
+
+function applyTheme(theme: AppTheme): void {
+ document.documentElement.dataset.theme = theme
+}
+
+applyTheme(readInitialTheme())
+
+useThemeStore.subscribe((state, prev) => {
+ if (state.theme !== prev.theme) applyTheme(state.theme)
+})
diff --git a/ui/src/theme/store.ts b/ui/src/theme/store.ts
new file mode 100644
index 00000000..a23b880e
--- /dev/null
+++ b/ui/src/theme/store.ts
@@ -0,0 +1,53 @@
+import { create } from 'zustand'
+import { persist } from 'zustand/middleware'
+
+/**
+ * Color-theme preference store — single source of truth for light / dark.
+ *
+ * `'auto'` follows the OS (`prefers-color-scheme`); `'light'` / `'dark'` pin
+ * it. Default is `'auto'` — unlike the locale store (which deliberately does
+ * NOT auto-detect), a color theme SHOULD honor the user's system setting out
+ * of the box; that's the whole point of the mode.
+ *
+ * Persistence mirrors the locale store's loud-fail contract (i18n/store.ts):
+ * a `version` bump clears stored state, NO migrate function.
+ *
+ * Stays pure (no DOM imports) so the wiring is one-directional: ui/src/theme
+ * subscribes here and applies ``. The inline script in
+ * index.html reads the SAME persisted key to avoid a first-paint flash.
+ */
+
+export type AppTheme = 'light' | 'dark' | 'auto'
+
+/** Cycle order for the single toggle button: auto → light → dark → auto. */
+const CYCLE: readonly AppTheme[] = ['auto', 'light', 'dark']
+
+interface ThemeStore {
+ theme: AppTheme
+ setTheme: (theme: AppTheme) => void
+ /** Advance to the next mode (drives the ActivityBar toggle). */
+ cycleTheme: () => void
+}
+
+export const useThemeStore = create()(
+ persist(
+ (set, get) => ({
+ theme: 'auto',
+ setTheme: (theme) => set({ theme }),
+ cycleTheme: () => {
+ const i = CYCLE.indexOf(get().theme)
+ set({ theme: CYCLE[(i + 1) % CYCLE.length]! })
+ },
+ }),
+ {
+ // Keep this key in sync with the no-flash script in index.html.
+ name: 'openalice.theme.v1',
+ version: 1,
+ },
+ ),
+)
+
+/** Persisted theme at boot (zustand persist rehydrates localStorage sync). */
+export function readInitialTheme(): AppTheme {
+ return useThemeStore.getState().theme
+}