Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
504 changes: 504 additions & 0 deletions ui/design/theme-study.html

Large diffs are not rendered by default.

19 changes: 17 additions & 2 deletions ui/index.html
Original file line number Diff line number Diff line change
@@ -1,11 +1,26 @@
<!DOCTYPE html>
<html lang="en">
<html lang="en" data-theme="auto">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<link rel="icon" href="/alice.ico" />
<title>OpenAlice</title>
<meta name="color-scheme" content="dark" />
<meta name="color-scheme" content="light dark" />
<script>
// No-flash theme: mirror the persisted preference onto <html data-theme>
// before first paint. Source of truth is the zustand theme store
// (openalice.theme.v1); ui/src/theme re-applies + self-heals on boot.
(function () {
try {
var raw = localStorage.getItem('openalice.theme.v1');
var t = raw ? (JSON.parse(raw).state || {}).theme : 'auto';
document.documentElement.dataset.theme =
t === 'light' || t === 'dark' ? t : 'auto';
} catch (e) {
document.documentElement.dataset.theme = 'auto';
}
})();
</script>
</head>
<body class="h-full bg-bg text-text">
<div id="root"></div>
Expand Down
11 changes: 8 additions & 3 deletions ui/src/components/ActivityBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -197,7 +198,7 @@ export function ActivityBar({ open, onClose, onItemActivated }: ActivityBarProps
<img
src="/alice.ico"
alt="Alice"
className="w-7 h-7 rounded-full ring-1 ring-white/10 shadow-[0_0_14px_rgba(35,185,154,0.12)]"
className="w-7 h-7 rounded-full ring-1 ring-border shadow-[0_0_14px_var(--color-accent-dim)]"
draggable={false}
/>
<h1 className="min-w-0 flex-1 truncate text-[15px] font-semibold text-text">OpenAlice</h1>
Expand Down Expand Up @@ -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 */}
Expand Down Expand Up @@ -315,6 +316,10 @@ export function ActivityBar({ open, onClose, onItemActivated }: ActivityBarProps
})}
</nav>

{/* Footer — global toggles pinned to the bottom of the rail. */}
<div className="shrink-0 border-t border-border px-3 py-2">
<ThemeToggle />
</div>
</aside>
</>
)
Expand Down
2 changes: 1 addition & 1 deletion ui/src/components/EmptyEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ export function EmptyEditor() {
<img
src="/alice.ico"
alt="OpenAlice"
className="w-16 h-16 rounded-2xl ring-1 ring-accent/25 shadow-[0_0_18px_rgba(88,166,255,0.18)]"
className="w-16 h-16 rounded-2xl ring-1 ring-accent/25 shadow-[0_0_18px_var(--color-accent-dim)]"
draggable={false}
/>
<div className="space-y-2 max-w-md">
Expand Down
4 changes: 2 additions & 2 deletions ui/src/components/TabStrip.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
}`}
>
<span className="truncate max-w-[200px]">{title}</span>
Expand All @@ -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}`}
>
<X size={11} strokeWidth={2.5} />
Expand Down
50 changes: 50 additions & 0 deletions ui/src/components/ThemeToggle.tsx
Original file line number Diff line number Diff line change
@@ -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 `<html data-theme>`, 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<AppTheme, LucideIcon> = {
auto: Monitor,
light: Sun,
dark: Moon,
}

/** What the NEXT click switches to (auto → light → dark → auto). */
const NEXT: Record<AppTheme, AppTheme> = {
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 (
<button
type="button"
onClick={cycle}
title={t('theme.switchTo', { mode: t(`theme.mode.${NEXT[theme]}`) })}
aria-label={t('theme.switchTo', { mode: t(`theme.mode.${NEXT[theme]}`) })}
className="relative flex w-full items-center gap-3 rounded-md px-3 py-1.5 text-left text-[13px] text-text-muted transition-colors hover:bg-overlay hover:text-text"
>
<span className="relative flex h-5 w-5 shrink-0 items-center justify-center">
<Icon size={16} strokeWidth={1.75} />
</span>
<span className="flex-1 truncate">{t(`theme.mode.${theme}`)}</span>
</button>
)
}
2 changes: 1 addition & 1 deletion ui/src/components/form.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 ====================

Expand Down
4 changes: 4 additions & 0 deletions ui/src/i18n/locales/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
},
Expand Down
4 changes: 4 additions & 0 deletions ui/src/i18n/locales/ja.ts
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,10 @@ export const ja: Resources = {
ex2: 'NVDA の強気シナリオを作って',
ex3: 'EV サプライチェーンを整理して',
},
theme: {
mode: { auto: '自動', light: 'ライト', dark: 'ダーク' },
switchTo: '{{mode}}に切り替え',
},
dev: {
snapshots: 'スナップショット',
},
Expand Down
4 changes: 4 additions & 0 deletions ui/src/i18n/locales/zh-Hant.ts
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,10 @@ export const zhHant: Resources = {
ex2: '幫我做一個輝達的多頭邏輯',
ex3: '梳理一下電動車產業鏈',
},
theme: {
mode: { auto: '自動', light: '淺色', dark: '深色' },
switchTo: '切換到{{mode}}',
},
dev: {
snapshots: '快照',
},
Expand Down
4 changes: 4 additions & 0 deletions ui/src/i18n/locales/zh.ts
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,10 @@ export const zh: Resources = {
ex2: '给我做一个英伟达的多头逻辑',
ex3: '梳理一下电动车产业链',
},
theme: {
mode: { auto: '自动', light: '浅色', dark: '深色' },
switchTo: '切换到{{mode}}',
},
dev: {
snapshots: '快照',
},
Expand Down
102 changes: 89 additions & 13 deletions ui/src/index.css
Original file line number Diff line number Diff line change
@@ -1,28 +1,105 @@
@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;
--color-green: #23b99a;
--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
Expand Down Expand Up @@ -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);
}

Expand Down
1 change: 1 addition & 0 deletions ui/src/main.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 <html data-theme>
import './i18n' // side-effect: init react-i18next + seed locale before first render

if (import.meta.env.VITE_DEMO_MODE) {
Expand Down
6 changes: 3 additions & 3 deletions ui/src/pages/ChatLandingPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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. */}
<div className="pointer-events-none absolute inset-0 overflow-hidden">
<div className="absolute inset-x-0 top-0 h-40 bg-gradient-to-b from-white/[0.035] to-transparent" />
<div className="absolute inset-x-0 bottom-0 h-[38%] bg-gradient-to-t from-black/35 to-transparent" />
<div className="absolute inset-0 opacity-[0.06] [background-image:linear-gradient(to_right,#ffffff_1px,transparent_1px),linear-gradient(to_bottom,#ffffff_1px,transparent_1px)] [background-size:96px_96px]" />
<div className="absolute inset-x-0 top-0 h-40 bg-gradient-to-b from-overlay to-transparent" />
<div className="absolute inset-x-0 bottom-0 h-[38%] bg-gradient-to-t from-overlay-strong to-transparent" />
<div className="absolute inset-0 opacity-[0.06] [background-image:linear-gradient(to_right,var(--color-text)_1px,transparent_1px),linear-gradient(to_bottom,var(--color-text)_1px,transparent_1px)] [background-size:96px_96px]" />
</div>

<div className="relative z-10 w-full max-w-2xl flex flex-col gap-5">
Expand Down
26 changes: 26 additions & 0 deletions ui/src/theme/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
/**
* Theme bootstrap — side-effect module. `import './theme'` once in main.tsx,
* BEFORE first render, so `<html data-theme>` 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)
})
Loading
Loading