feat(theme): ship Settings picker with system/light/dark and Vojo light palette reshading sidebar, chat, bubbles, horseshoe void and PWA chrome

This commit is contained in:
heaven 2026-05-15 01:06:49 +03:00
parent 8e2db986b4
commit 81d23be61f
10 changed files with 310 additions and 44 deletions

View file

@ -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 `<meta theme-color>` 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`.

View file

@ -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."
/>
<meta name="theme-color" content="#000000" />
<meta name="theme-color" content="#0d0e11" media="(prefers-color-scheme: dark)" />
<meta name="theme-color" content="#f2f2f7" media="(prefers-color-scheme: light)" />
<link id="favicon" rel="shortcut icon" type="image/svg+xml" href="./public/res/svg/vojo.svg" />

View file

@ -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",

View file

@ -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<ThemeChoice[]>(
() => ['system', LightTheme.id, DarkTheme.id],
[]
);
const choiceLabel = useMemo<Record<ThemeChoice, string>>(
() => ({
system: t('Settings.system_theme'),
'light-theme': t('Settings.theme_light'),
'dark-theme': t('Settings.theme_dark'),
}),
[t]
);
const [menuCords, setMenuCords] = useState<RectCords>();
const handleMenu: MouseEventHandler<HTMLButtonElement> = (evt) => {
setMenuCords(evt.currentTarget.getBoundingClientRect());
};
const handleSelect = (next: ThemeChoice) => {
setMenuCords(undefined);
if (next === 'system') {
setUseSystemTheme(true);
return;
}
setUseSystemTheme(false);
setThemeId(next);
};
return (
<Text size="T300" priority="300">
{t('Settings.theme_dark')}
</Text>
<>
<Button
size="300"
variant="Secondary"
outlined
fill="Soft"
radii="300"
after={<Icon size="300" src={Icons.ChevronBottom} />}
onClick={handleMenu}
>
<Text size="T300">{choiceLabel[choice]}</Text>
</Button>
<PopOut
anchor={menuCords}
offset={5}
position="Bottom"
align="End"
content={
<FocusTrap
focusTrapOptions={{
initialFocus: false,
onDeactivate: () => setMenuCords(undefined),
clickOutsideDeactivates: true,
isKeyForward: (evt: KeyboardEvent) =>
evt.key === 'ArrowDown' || evt.key === 'ArrowRight',
isKeyBackward: (evt: KeyboardEvent) =>
evt.key === 'ArrowUp' || evt.key === 'ArrowLeft',
escapeDeactivates: stopPropagation,
}}
>
<Menu>
<Box direction="Column" gap="100" style={{ padding: config.space.S100 }}>
{choices.map((c) => (
<MenuItem
key={c}
size="300"
variant="Surface"
aria-selected={c === choice}
radii="300"
onClick={() => handleSelect(c)}
>
<Box grow="Yes">
<Text size="T300">{choiceLabel[c]}</Text>
</Box>
</MenuItem>
))}
</Box>
</Menu>
</FocusTrap>
}
/>
</>
);
}

View file

@ -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<ThemeKind>(

View file

@ -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 = {

View file

@ -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

View file

@ -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

View file

@ -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);

View file

@ -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%;