diff --git a/docs/ai/architecture.md b/docs/ai/architecture.md
index 31d6ffa9..76c215e2 100644
--- a/docs/ai/architecture.md
+++ b/docs/ai/architecture.md
@@ -18,7 +18,7 @@ Build: **Vite 5.4** with vanilla-extract, WASM, PWA plugins.
```
src/
├── index.tsx # Entry point
-├── colors.css.ts # Custom dark-theme via createTheme(color, …); no light override (uses folds.lightTheme as-is)
+├── colors.css.ts # Vojo dark + light themes via createTheme(color, …) — both palettes are Vojo-owned, folds defaults are not used
├── config.css.ts # fontWeight overrides
├── client/
│ ├── initMatrix.ts # Matrix SDK init (createClient, startClient, logout)
@@ -162,10 +162,24 @@ Some atoms persist to localStorage (e.g. `settings.ts`, `navToActivePath.ts`), o
Stock Cinny had multiple themes; vojo simplified to System / Light / Dark (commit 00935ae).
-- `src/colors.css.ts` defines **only** `darkTheme` via `createTheme(color, darkThemeData)`. There is no separate light-theme override — light = stock `folds.lightTheme` imported as-is.
-- `src/app/hooks/useTheme.ts` selects `LightTheme` or `DarkTheme` based on `useSystemTheme` + `themeId` settings. Class-name-based application — **runtime theme switch requires page reload** (vanilla-extract is compile-time).
+- `src/colors.css.ts` defines both `darkTheme` (Dawn palette) and `lightTheme` (Vojo light palette) via `createTheme(color, …)`. The folds default `lightTheme` is no longer imported — both Vojo themes own their full token table.
+- `src/app/hooks/useTheme.ts` selects `LightTheme` or `DarkTheme` based on `useSystemTheme` + `themeId` settings. Class-name-based application — `ThemeManager` swaps the body class on `useActiveTheme` change, so runtime switching is live (no reload needed, but vanilla-extract still requires a rebuild to change the token tables themselves).
- Folds tokens (`color.*`, `config.space`, `config.radii`, `config.borderWidth`) are read-only inside folds compiled CSS. Re-skinning colours via `createTheme()` works; re-skinning radii or spacing requires CSS overrides outside folds.
-- Brand accent in v4.11.x: `Primary.Main = #BDB6EC` (lavender) — referenced in unread-badge, focus-ring, NavLink active state, MessageBase highlight keyframe.
+- Brand accent: dark `Primary.Main = #9580ff` (Dawn lavender), light `Primary.Main = #5b6aff` (indigo) — referenced in unread-badge, focus-ring, NavLink active state, MessageBase highlight keyframe.
+- The default theme picker (Settings → General → Appearance) offers System / Light / Dark. The `dawn-redesign-v1` one-shot migration in `state/settings.ts` pins **existing** users (with a stored settings JSON) to dark on first load post-migration; brand-new users skip the migration and keep `useSystemTheme: true` so they follow the OS preference out of the box.
+
+### Known follow-ups for light theme
+
+The web theme switch is wired end-to-end (palette, picker, runtime body-class swap, mxid colours, prism syntax highlighting, `--vojo-safe-area-bg`, cold-start `prefers-color-scheme` fallback in `src/index.css`, dual `` in `index.html`). Native and PWA chrome are NOT yet bound to the active theme — track these as separate tasks:
+
+- **Android system bars** — `MainActivity.java::onCreate` hardcodes `controller.setAppearanceLight{Status,Navigation}Bars(false)`. On light theme the icons are white over a light bar → invisible. Fix is a small JS↔Java bridge (custom Capacitor plugin, or `@capacitor/status-bar` for status-bar tint + custom plugin for nav-bar) driven from `ThemeManager`'s `useEffect`.
+- **Android native splash** — `android/app/src/main/res/values/colors.xml::splash_bg = #0d0e11` and `styles.xml::windowBackground` are dark. Light users see a dark splash → fade to white. Add `values-night/` variants or read the stored `themeId` from a SharedPreferences shim before paint.
+- **Capacitor WebView paint color** — `capacitor.config.ts::backgroundColor = '#0d0e11'` (mirrored in the built `capacitor.config.json`). Set at WebView init, cannot be re-themed at runtime via JS — needs the splash-fix above to land first.
+- **PWA manifest** — `public/manifest.json` `theme_color`/`background_color` are pinned to dark (`#0d0e11`). Manifest format does not support media queries, so the choice is one default; we keep dark because the migration also pins existing users to dark.
+- **AuthLayout** — `src/app/pages/auth/styles.css.ts` hardcodes dark backgrounds (`#0d0e11` etc.) for the bistable auth scaffold (see `bugs.md` for why the auth layout cannot be naively re-skinned). Light-theme users see a dark login/register/reset-password screen. Tied to the auth bistable-layout refactor.
+- **Bot widgets** — `BotShell.css.ts`, `BotWidgetMount.css.ts`, `BotCard.tsx` hardcode `#9580ff` / `#7ab6d9` / `#0c0c0e` accent + ink colors. Each bot widget is a separate Preact app so it doesn't share Vojo's folds tokens — needs its own theme passing through `apps/widget-*` or a CSS-var bridge from the parent.
+
+The horseshoe void seam reshades via the `--vojo-horseshoe-void` CSS variable: dark `#090909` (deep void against `#0d0e11` panel) and light `#d6d6e3` (soft lavender-grey against `#f2f2f7` panel). See `src/app/styles/horseshoe.ts` + `src/index.css`.
## Composer card geometry
@@ -257,7 +271,7 @@ i18next + `react-i18next`. Translations in `public/locales/{en,ru}/*.json`, orga
- **matrix-js-sdk 41.4** — Matrix protocol (exact pin, see `docs/plans/matrix_js_sdk_upgrade.md` for the M0..M4 bump trail)
- **folds 2.6** — UI component library
- **jotai 2.6** — State management
-- **vanilla-extract** — Type-safe CSS (compile-time → no runtime theme switching without reload)
+- **vanilla-extract** — Type-safe CSS. Tokens are compile-time, but theme switching is live at runtime: `ThemeManager` swaps the body class on `useActiveTheme` change and every `color.*` var reshades through the cascade. Adding new tokens still requires a rebuild.
- **slate 0.123** — Rich text editor
- **@tanstack/react-query 5** — Data fetching
- **@tanstack/react-virtual 3** — Virtual scrolling — used for **list panels** (`Direct.tsx`, space lists, etc.). Note: `RoomTimeline.tsx` does NOT use this; it uses an in-house `useVirtualPaginator` + `IntersectionObserver`.
diff --git a/index.html b/index.html
index e0d86df3..5e32702d 100644
--- a/index.html
+++ b/index.html
@@ -23,7 +23,8 @@
property="og:description"
content="A Matrix client where you can enjoy the conversation using simple, elegant and secure interface protected by e2ee with the power of open source."
/>
-
+
+
diff --git a/public/manifest.json b/public/manifest.json
index 73399b33..df4f768f 100644
--- a/public/manifest.json
+++ b/public/manifest.json
@@ -7,8 +7,8 @@
"display": "standalone",
"orientation": "portrait",
"start_url": "./",
- "background_color": "#000",
- "theme_color": "#000",
+ "background_color": "#0d0e11",
+ "theme_color": "#0d0e11",
"icons": [
{
"src": "./public/android/vojo.svg",
diff --git a/src/app/features/settings/general/General.tsx b/src/app/features/settings/general/General.tsx
index f792fd9e..50d87539 100644
--- a/src/app/features/settings/general/General.tsx
+++ b/src/app/features/settings/general/General.tsx
@@ -1,15 +1,28 @@
-import React, { ChangeEventHandler, KeyboardEventHandler, useState } from 'react';
+import React, {
+ ChangeEventHandler,
+ KeyboardEventHandler,
+ MouseEventHandler,
+ useMemo,
+ useState,
+} from 'react';
import {
Box,
+ Button,
+ config,
Icon,
IconButton,
Icons,
Input,
+ Menu,
+ MenuItem,
+ PopOut,
+ RectCords,
Scroll,
Switch,
Text,
toRem,
} from 'folds';
+import FocusTrap from 'focus-trap-react';
import { isKeyHotkey } from 'is-hotkey';
import { useTranslation } from 'react-i18next';
import { Page, PageContent, PageHeader } from '../../../components/page';
@@ -19,17 +32,109 @@ import { settingsAtom } from '../../../state/settings';
import { SettingTile } from '../../../components/setting-tile';
import { KeySymbol } from '../../../utils/key-symbol';
import { isMacOS } from '../../../utils/user-agent';
+import { stopPropagation } from '../../../utils/keyboard';
+import { DarkTheme, LightTheme } from '../../../hooks/useTheme';
import { SequenceCardStyle } from '../styles.css';
+type ThemeChoice = 'system' | typeof LightTheme.id | typeof DarkTheme.id;
+
function ThemeSelect() {
- // Theme switching is locked to dark while the Dawn redesign rolls out — Vojo
- // light is a separate plan. Kept as a read-only label so the existing
- // SettingTile layout in the Appearance section still renders.
const { t } = useTranslation();
+ const [useSystemTheme, setUseSystemTheme] = useSetting(settingsAtom, 'useSystemTheme');
+ const [themeId, setThemeId] = useSetting(settingsAtom, 'themeId');
+
+ let choice: ThemeChoice;
+ if (useSystemTheme) {
+ choice = 'system';
+ } else if (themeId === DarkTheme.id) {
+ choice = DarkTheme.id;
+ } else {
+ choice = LightTheme.id;
+ }
+
+ const choices = useMemo(
+ () => ['system', LightTheme.id, DarkTheme.id],
+ []
+ );
+
+ const choiceLabel = useMemo>(
+ () => ({
+ system: t('Settings.system_theme'),
+ 'light-theme': t('Settings.theme_light'),
+ 'dark-theme': t('Settings.theme_dark'),
+ }),
+ [t]
+ );
+
+ const [menuCords, setMenuCords] = useState();
+
+ const handleMenu: MouseEventHandler = (evt) => {
+ setMenuCords(evt.currentTarget.getBoundingClientRect());
+ };
+
+ const handleSelect = (next: ThemeChoice) => {
+ setMenuCords(undefined);
+ if (next === 'system') {
+ setUseSystemTheme(true);
+ return;
+ }
+ setUseSystemTheme(false);
+ setThemeId(next);
+ };
+
return (
-
- {t('Settings.theme_dark')}
-
+ <>
+ }
+ onClick={handleMenu}
+ >
+ {choiceLabel[choice]}
+
+ setMenuCords(undefined),
+ clickOutsideDeactivates: true,
+ isKeyForward: (evt: KeyboardEvent) =>
+ evt.key === 'ArrowDown' || evt.key === 'ArrowRight',
+ isKeyBackward: (evt: KeyboardEvent) =>
+ evt.key === 'ArrowUp' || evt.key === 'ArrowLeft',
+ escapeDeactivates: stopPropagation,
+ }}
+ >
+
+
+ }
+ />
+ >
);
}
diff --git a/src/app/hooks/useTheme.ts b/src/app/hooks/useTheme.ts
index 4eab41a1..bdb7fe13 100644
--- a/src/app/hooks/useTheme.ts
+++ b/src/app/hooks/useTheme.ts
@@ -1,8 +1,7 @@
-import { lightTheme } from 'folds';
import { createContext, useContext, useEffect, useMemo, useState } from 'react';
import { onDarkFontWeight, onLightFontWeight } from '../../config.css';
-import { darkTheme } from '../../colors.css';
-import { settingsAtom } from '../state/settings';
+import { darkTheme, lightTheme } from '../../colors.css';
+import { settingsAtom, ThemeId } from '../state/settings';
import { useSetting } from '../state/hooks/settings';
export enum ThemeKind {
@@ -11,7 +10,7 @@ export enum ThemeKind {
}
export type Theme = {
- id: string;
+ id: ThemeId;
kind: ThemeKind;
classNames: string[];
};
@@ -19,7 +18,7 @@ export type Theme = {
export const LightTheme: Theme = {
id: 'light-theme',
kind: ThemeKind.Light,
- classNames: [lightTheme, onLightFontWeight, 'prism-light'],
+ classNames: ['light-theme', lightTheme, onLightFontWeight, 'prism-light'],
};
export const DarkTheme: Theme = {
@@ -28,12 +27,6 @@ export const DarkTheme: Theme = {
classNames: ['dark-theme', darkTheme, onDarkFontWeight, 'prism-dark'],
};
-export const useThemes = (): Theme[] => {
- const themes: Theme[] = useMemo(() => [LightTheme, DarkTheme], []);
-
- return themes;
-};
-
export const useSystemThemeKind = (): ThemeKind => {
const darkModeQueryList = useMemo(() => window.matchMedia('(prefers-color-scheme: dark)'), []);
const [themeKind, setThemeKind] = useState(
diff --git a/src/app/state/settings.ts b/src/app/state/settings.ts
index c3bc49d5..b9cdda52 100644
--- a/src/app/state/settings.ts
+++ b/src/app/state/settings.ts
@@ -2,8 +2,10 @@ import { atom } from 'jotai';
const STORAGE_KEY = 'settings';
+export type ThemeId = 'light-theme' | 'dark-theme';
+
export interface Settings {
- themeId?: string;
+ themeId?: ThemeId;
useSystemTheme: boolean;
monochromeMode?: boolean;
isMarkdown: boolean;
@@ -78,12 +80,13 @@ export const getSettings = (): Settings => {
const merged: Settings = { ...defaultSettings, ...(parsed ?? {}) };
- // One-shot Dawn redesign migration: force the dark theme on every existing
- // user so they see the redesigned palette. Stamped so we run it exactly once;
- // future Vojo-light themes will not be overwritten on subsequent loads.
- // Synchronous — runs at settingsAtom creation, before useActiveTheme reads
- // the value during the first render.
- if (!merged.migrationsApplied?.[DAWN_MIGRATION_KEY]) {
+ // One-shot Dawn redesign migration: force the dark theme on existing users
+ // so they see the redesigned palette on their first load post-migration.
+ // Stamped so we run it exactly once. Brand-new users (no stored settings yet)
+ // skip this branch entirely and keep the `useSystemTheme: true` default —
+ // now that the Vojo light theme exists they should follow the OS preference
+ // out of the box rather than being pinned to dark.
+ if (raw !== null && !merged.migrationsApplied?.[DAWN_MIGRATION_KEY]) {
merged.useSystemTheme = false;
merged.themeId = 'dark-theme';
merged.migrationsApplied = {
diff --git a/src/app/styles/global.css.ts b/src/app/styles/global.css.ts
index f578a87a..409554b0 100644
--- a/src/app/styles/global.css.ts
+++ b/src/app/styles/global.css.ts
@@ -31,9 +31,10 @@ globalStyle(':root', {
// `#root` covers most of the layout edge-to-edge now.
//
// Bound to the folds `Background.Container` token (Dawn `#0d0e11` in
-// dark; will reshade automatically when a light theme is added). The
-// var lives on `body` rather than `:root` because folds attaches the
-// theme class (`.dark-theme` / `.lightTheme`) to `document.body` —
+// dark, Vojo `#f2f2f7` in light — reshades automatically on theme
+// switch). The var lives on `body` rather than `:root` because the
+// theme class (`.dark-theme` / `.light-theme`) is attached to
+// `document.body` —
// `var(--folds-token)` only resolves at scopes the theme class
// dominates. A `:root` host would freeze the var at the folds token's
// initial value (`transparent` in dark / `#fff` in light) and the
diff --git a/src/app/styles/horseshoe.ts b/src/app/styles/horseshoe.ts
index e11bc756..ec163197 100644
--- a/src/app/styles/horseshoe.ts
+++ b/src/app/styles/horseshoe.ts
@@ -1,13 +1,19 @@
// Shared constants for the Vojo "horseshoe" design language.
//
-// The void colour is fixed in design and not theme-driven — the same
-// #090909 is painted between every pair of horseshoe surfaces (top
-// user-card silhouette, bottom call rail, settings/profile rail, etc.)
-// so the seam reads as the same "near-black" no matter where it shows
-// up. Originally introduced with the bottom call rail (commit 7054ca2 /
-// `HorseshoeContainer.css.ts`); centralised here so consumers don't
-// duplicate the literal and the value can be tuned in one place.
-export const VOJO_HORSESHOE_VOID_COLOR = '#090909';
+// The void seam between every pair of horseshoe surfaces (top user-card
+// silhouette, bottom call rail, settings/profile rail, page-nav <-> chat
+// split, etc.) is painted through the `--vojo-horseshoe-void` CSS
+// variable so it reshades with the active theme:
+// * dark theme — near-black `#090909` so the seam reads as a deep
+// void against `Background.Container` `#0d0e11`.
+// * light theme — soft lavender-grey `#d6d6e3` so the seam stays
+// visible against the panel bg `#f2f2f7` without
+// feeling like a harsh black slit.
+// Bindings live in `src/index.css` (`:root` light default + `.dark-theme`
+// override). Originally introduced with the bottom call rail
+// (commit 7054ca2 / `HorseshoeContainer.css.ts`); centralised here so
+// consumers don't duplicate the literal.
+export const VOJO_HORSESHOE_VOID_COLOR = 'var(--vojo-horseshoe-void)';
// Width (and height where vertical) of the void gap separating two
// horseshoe surfaces — kept identical between the bottom call rail
diff --git a/src/colors.css.ts b/src/colors.css.ts
index db1e7f17..2b99e87e 100644
--- a/src/colors.css.ts
+++ b/src/colors.css.ts
@@ -117,3 +117,129 @@ const darkThemeData = {
};
export const darkTheme = createTheme(color, darkThemeData);
+
+// Vojo light palette — companion to the Dawn dark theme. Source colors come
+// from the user-supplied light design pack:
+// sidebarBg #f2f2f7 — Background.Container (DM list, nav panels)
+// sidebarSel #e2e2ef — Background.ContainerActive (selected row)
+// mainBg #ffffff — Surface.Container (chat content surface)
+// bubbleBg #ededf6 — SurfaceVariant.Container (bubbles, composer card)
+// inputBg #f2f2f7 — informational; reuses Background.Container
+// headerBorder #e8e8f2 — *.ContainerLine (1-pixel seam between panels)
+// accent #5b6aff — Primary.Main (badges, focus ring, brand)
+// textPrimary #111118 — *.OnContainer
+// textSecondary #7878a0 — Secondary.Main (muted body text)
+// textMuted #a8a8c0 — Secondary.MainLine (placeholder, captions)
+// dot #c4c4d8 — neutral dot for stream rail
+// dotAccent #5b6aff — own/accent dot — same as Primary.Main
+//
+// Unlike dark, Background.Container (`#f2f2f7`) and Surface.Container
+// (`#ffffff`) intentionally differ in light: sidebar reads as a tinted panel,
+// chat surface reads as the lightest. The 1-pixel seam (`headerBorder`)
+// between them maps to `ContainerLine`.
+const lightThemeData = {
+ Background: {
+ Container: '#f2f2f7',
+ ContainerHover: '#ededf3',
+ ContainerActive: '#e2e2ef',
+ ContainerLine: '#e8e8f2',
+ OnContainer: '#111118',
+ },
+
+ Surface: {
+ Container: '#ffffff',
+ ContainerHover: '#f7f7fb',
+ ContainerActive: '#ededf6',
+ ContainerLine: '#e8e8f2',
+ OnContainer: '#111118',
+ },
+
+ SurfaceVariant: {
+ Container: '#ededf6',
+ ContainerHover: '#e2e2ef',
+ ContainerActive: '#d7d7e4',
+ // ContainerLine is intentionally lighter than ContainerActive in
+ // light theme — it's a 1-pixel seam between raised surfaces, not a
+ // "fully pressed" tier, and the dark theme's monotone progression
+ // would make the line too prominent on a light background.
+ ContainerLine: '#dcdce6',
+ OnContainer: '#111118',
+ },
+
+ Primary: {
+ Main: '#5b6aff',
+ MainHover: '#6e7bff',
+ MainActive: '#8190ff',
+ MainLine: '#a0acff',
+ OnMain: '#ffffff',
+ Container: '#e0e3ff',
+ ContainerHover: '#d2d7ff',
+ ContainerActive: '#c3caff',
+ ContainerLine: '#b3bbff',
+ OnContainer: '#2a3399',
+ },
+
+ Secondary: {
+ Main: '#7878a0',
+ MainHover: '#6b6b92',
+ MainActive: '#5e5e84',
+ // MainLine intentionally inverts the Main→MainActive darkening run:
+ // in light theme it doubles as the muted/placeholder ink (the user-
+ // supplied `textMuted #a8a8c0`), which has to be lighter than
+ // Main (#7878a0 = textSecondary) to actually read as muted.
+ MainLine: '#a8a8c0',
+ OnMain: '#ffffff',
+ Container: '#e2e2ef',
+ ContainerHover: '#d7d7e4',
+ ContainerActive: '#ccccd9',
+ ContainerLine: '#c4c4d8',
+ OnContainer: '#111118',
+ },
+
+ Success: {
+ Main: '#2ea874',
+ MainHover: '#34b97f',
+ MainActive: '#3bc789',
+ MainLine: '#46d699',
+ OnMain: '#ffffff',
+ Container: '#d4f5e3',
+ ContainerHover: '#c0eed5',
+ ContainerActive: '#abe6c8',
+ ContainerLine: '#9adfba',
+ OnContainer: '#0e3a23',
+ },
+
+ Warning: {
+ Main: '#c08a36',
+ MainHover: '#cb953f',
+ MainActive: '#d4a049',
+ MainLine: '#dcab57',
+ OnMain: '#ffffff',
+ Container: '#fbe9c8',
+ ContainerHover: '#f9e0b3',
+ ContainerActive: '#f6d69e',
+ ContainerLine: '#f3cb8a',
+ OnContainer: '#553a14',
+ },
+
+ Critical: {
+ Main: '#c44c3a',
+ MainHover: '#cf5645',
+ MainActive: '#d6614f',
+ MainLine: '#dc6e5b',
+ OnMain: '#ffffff',
+ Container: '#fadcd5',
+ ContainerHover: '#f6cabf',
+ ContainerActive: '#f3b8ab',
+ ContainerLine: '#efa898',
+ OnContainer: '#5a1f17',
+ },
+
+ Other: {
+ FocusRing: 'rgba(91, 106, 255, 0.5)',
+ Shadow: 'rgba(17, 17, 24, 0.15)',
+ Overlay: 'rgba(17, 17, 24, 0.5)',
+ },
+};
+
+export const lightTheme = createTheme(color, lightThemeData);
diff --git a/src/index.css b/src/index.css
index fa4a0e13..550614f9 100644
--- a/src/index.css
+++ b/src/index.css
@@ -17,6 +17,10 @@
--mx-uc-7: hsl(242, 100%, 45%);
--mx-uc-8: hsl(94, 100%, 35%);
+ /* Horseshoe void seam — see `src/app/styles/horseshoe.ts`. The :root
+ default is the light-theme value; `.dark-theme` overrides below. */
+ --vojo-horseshoe-void: #d6d6e3;
+
--font-emoji: 'Twemoji_DISABLED';
--font-secondary: 'InterVariable', var(--font-emoji), sans-serif;
}
@@ -33,6 +37,8 @@
--mx-uc-7: hsl(243, 100%, 80%);
--mx-uc-8: hsl(94, 100%, 80%);
+ --vojo-horseshoe-void: #090909;
+
--font-secondary: 'InterVariable', var(--font-emoji), sans-serif;
}
@@ -46,6 +52,11 @@ body {
padding: 0;
height: 100%;
overflow: hidden;
+ /* Cold-start fallback before `app/styles/global.css.ts` parses + the
+ ThemeManager mounts and binds `--vojo-safe-area-bg` to the active
+ palette's Background.Container token. The literal `#0d0e11` matches
+ dark; the @media block below mirrors it for light so a light-theme
+ user doesn't see a dark flash in the safe-area zone. */
background-color: var(--vojo-safe-area-bg, #0d0e11);
font-family: var(--font-secondary);
font-size: 16px;
@@ -54,6 +65,12 @@ body {
/*Why font-variant-ligatures => https://github.com/rsms/inter/issues/222 */
font-variant-ligatures: no-contextual;
}
+
+@media (prefers-color-scheme: light) {
+ body {
+ background-color: var(--vojo-safe-area-bg, #f2f2f7);
+ }
+}
#root {
width: 100%;
height: 100%;