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')} - + <> + + setMenuCords(undefined), + clickOutsideDeactivates: true, + isKeyForward: (evt: KeyboardEvent) => + evt.key === 'ArrowDown' || evt.key === 'ArrowRight', + isKeyBackward: (evt: KeyboardEvent) => + evt.key === 'ArrowUp' || evt.key === 'ArrowLeft', + escapeDeactivates: stopPropagation, + }} + > + + + {choices.map((c) => ( + handleSelect(c)} + > + + {choiceLabel[c]} + + + ))} + + + + } + /> + ); } 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%;