diff --git a/ui/design/theme-study.html b/ui/design/theme-study.html new file mode 100644 index 00000000..23910bbc --- /dev/null +++ b/ui/design/theme-study.html @@ -0,0 +1,504 @@ + + + + + +OpenAlice — Theme Study · Daybreak / Midnight + + + + + + +
+ + +
+
+
+ +

OpenAlice Theme Study

+
+

One Alice · Two Lights — Daybreak & Midnight

+
+
+
+ + + +
+
+
+
+ + +
+
+ 01 +

Two Palettes, One Identity

+

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 +
+ + +
Build
+ + +
+ +
+ + +
+
+ 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.

+
+ + + +
TokenDaybreak (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 Alice

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/EmptyEditor.tsx b/ui/src/components/EmptyEditor.tsx index e1fdba9b..c014799f 100644 --- a/ui/src/components/EmptyEditor.tsx +++ b/ui/src/components/EmptyEditor.tsx @@ -10,7 +10,7 @@ export function EmptyEditor() { OpenAlice
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 +}