From 0c89e9fda00225b2866aab05df9f8786367634e8 Mon Sep 17 00:00:00 2001 From: heaven Date: Sun, 26 Apr 2026 21:30:22 +0300 Subject: [PATCH] redesign(p0): land Dawn dark-theme foundation with one-shot migration and edge-to-edge polish --- .../main/java/chat/vojo/app/MainActivity.java | 10 ++ package-lock.json | 10 ++ package.json | 1 + src/app/features/settings/general/General.tsx | 102 +----------- src/app/pages/client/WelcomePage.tsx | 6 +- src/app/pages/client/home/RoomProvider.tsx | 14 +- src/app/state/settings.ts | 45 ++++- src/app/styles/global.css.ts | 27 +++ src/colors.css.ts | 154 ++++++++++-------- src/index.css | 2 +- src/index.tsx | 2 + vite.config.js | 7 +- 12 files changed, 201 insertions(+), 179 deletions(-) create mode 100644 src/app/styles/global.css.ts diff --git a/android/app/src/main/java/chat/vojo/app/MainActivity.java b/android/app/src/main/java/chat/vojo/app/MainActivity.java index de7811be..2a463ae9 100644 --- a/android/app/src/main/java/chat/vojo/app/MainActivity.java +++ b/android/app/src/main/java/chat/vojo/app/MainActivity.java @@ -4,6 +4,8 @@ import android.os.Bundle; import android.os.Handler; import android.os.Looper; import androidx.activity.EdgeToEdge; +import androidx.core.view.WindowCompat; +import androidx.core.view.WindowInsetsControllerCompat; import com.getcapacitor.BridgeActivity; public class MainActivity extends BridgeActivity { @@ -40,6 +42,14 @@ public class MainActivity extends BridgeActivity { registerPlugin(CallForegroundPlugin.class); EdgeToEdge.enable(this); super.onCreate(savedInstanceState); + // Force light icons on both system bars: our CSS is permanently dark + // (Dawn redesign), but EdgeToEdge.enable auto-detects icon tint from + // the device uiMode — on a light-mode device that gives dark icons + // over our dark bars and they vanish. + WindowInsetsControllerCompat controller = + WindowCompat.getInsetsController(getWindow(), getWindow().getDecorView()); + controller.setAppearanceLightStatusBars(false); + controller.setAppearanceLightNavigationBars(false); } @Override diff --git a/package-lock.json b/package-lock.json index d5b3a403..ae49f4f7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -20,6 +20,7 @@ "@capacitor/preferences": "8.0.1", "@capacitor/push-notifications": "8.0.3", "@capacitor/toast": "8.0.1", + "@fontsource-variable/jetbrains-mono": "5.2.5", "@fontsource/inter": "4.5.14", "@tanstack/react-query": "5.24.1", "@tanstack/react-query-devtools": "5.24.1", @@ -2427,6 +2428,15 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/@fontsource-variable/jetbrains-mono": { + "version": "5.2.5", + "resolved": "https://registry.npmjs.org/@fontsource-variable/jetbrains-mono/-/jetbrains-mono-5.2.5.tgz", + "integrity": "sha512-G3sN1xq1moZd0JL+hFaA4MEdsiQS+JXC/z7m+EqA5/Fzn5CQlXGUaaNKFGQdDsFuLTnCfW0KOOSWHjygNfjEPw==", + "license": "OFL-1.1", + "funding": { + "url": "https://github.com/sponsors/ayuhito" + } + }, "node_modules/@fontsource/inter": { "version": "4.5.14", "resolved": "https://registry.npmjs.org/@fontsource/inter/-/inter-4.5.14.tgz", diff --git a/package.json b/package.json index 0fdea4ce..8b01fc1d 100644 --- a/package.json +++ b/package.json @@ -52,6 +52,7 @@ "@capacitor/preferences": "8.0.1", "@capacitor/push-notifications": "8.0.3", "@capacitor/toast": "8.0.1", + "@fontsource-variable/jetbrains-mono": "5.2.5", "@fontsource/inter": "4.5.14", "@tanstack/react-query": "5.24.1", "@tanstack/react-query-devtools": "5.24.1", diff --git a/src/app/features/settings/general/General.tsx b/src/app/features/settings/general/General.tsx index 5c5a547c..87863597 100644 --- a/src/app/features/settings/general/General.tsx +++ b/src/app/features/settings/general/General.tsx @@ -36,111 +36,21 @@ import { DateFormat, MessageLayout, MessageSpacing, settingsAtom } from '../../. import { SettingTile } from '../../../components/setting-tile'; import { KeySymbol } from '../../../utils/key-symbol'; import { isMacOS } from '../../../utils/user-agent'; -import { - DarkTheme, - LightTheme, - useThemes, -} from '../../../hooks/useTheme'; import { stopPropagation } from '../../../utils/keyboard'; import { useMessageLayoutItems } from '../../../hooks/useMessageLayout'; import { useMessageSpacingItems } from '../../../hooks/useMessageSpacing'; import { useDateFormatItems } from '../../../hooks/useDateFormat'; import { SequenceCardStyle } from '../styles.css'; -const SYSTEM_THEME_ID = 'system'; - -const THEME_I18N_KEYS: Record = { - [LightTheme.id]: 'Settings.theme_light', - [DarkTheme.id]: 'Settings.theme_dark', -}; - 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 themes = useThemes(); - const [systemTheme, setSystemTheme] = useSetting(settingsAtom, 'useSystemTheme'); - const [themeId, setThemeId] = useSetting(settingsAtom, 'themeId'); - const [menuCords, setMenuCords] = useState(); - - const themeName = (id: string) => t(THEME_I18N_KEYS[id] ?? id); - - const currentLabel = systemTheme - ? t('Settings.system_theme') - : themeName(themeId ?? LightTheme.id); - - const handleOpenMenu: MouseEventHandler = (evt) => { - setMenuCords(evt.currentTarget.getBoundingClientRect()); - }; - - const handleSelect = (id: string) => { - if (id === SYSTEM_THEME_ID) { - setSystemTheme(true); - } else { - setSystemTheme(false); - setThemeId(id); - } - setMenuCords(undefined); - }; - - const selectedId = systemTheme ? SYSTEM_THEME_ID : (themeId ?? LightTheme.id); - return ( - <> - - setMenuCords(undefined), - clickOutsideDeactivates: true, - isKeyForward: (evt: KeyboardEvent) => - evt.key === 'ArrowDown' || evt.key === 'ArrowRight', - isKeyBackward: (evt: KeyboardEvent) => - evt.key === 'ArrowUp' || evt.key === 'ArrowLeft', - escapeDeactivates: stopPropagation, - }} - > - - - handleSelect(SYSTEM_THEME_ID)} - > - {t('Settings.system_theme')} - - {themes.map((theme) => ( - handleSelect(theme.id)} - > - {themeName(theme.id)} - - ))} - - - - } - /> - + + {t('Settings.theme_dark')} + ); } diff --git a/src/app/pages/client/WelcomePage.tsx b/src/app/pages/client/WelcomePage.tsx index 78ab18ee..4657512a 100644 --- a/src/app/pages/client/WelcomePage.tsx +++ b/src/app/pages/client/WelcomePage.tsx @@ -16,7 +16,11 @@ export function WelcomePage() { } title="Welcome to Vojo" - subTitle={{__APP_VERSION__}} + subTitle={ + + {__APP_VERSION__} + + } /> diff --git a/src/app/pages/client/home/RoomProvider.tsx b/src/app/pages/client/home/RoomProvider.tsx index 56db4fb8..7f0401d7 100644 --- a/src/app/pages/client/home/RoomProvider.tsx +++ b/src/app/pages/client/home/RoomProvider.tsx @@ -10,6 +10,7 @@ import { useSearchParamsViaServers } from '../../../hooks/router/useSearchParams import { mDirectAtom } from '../../../state/mDirectList'; import { getDirectRoomPath } from '../../pathUtils'; import { getCanonicalAliasOrRoomId } from '../../../utils/matrix'; +import { isDirectInvite } from '../../../utils/room'; export function HomeRouteRoomProvider({ children }: { children: ReactNode }) { const mx = useMatrixClient(); @@ -21,7 +22,18 @@ export function HomeRouteRoomProvider({ children }: { children: ReactNode }) { const roomId = useSelectedRoom(); const room = mx.getRoom(roomId); - if (room && mDirects.has(room.roomId)) { + // Cold-start push routing lands on /home/{roomId} (sw.ts has no access to + // mDirectAtom). For DM rooms we redirect to /direct/. mDirectAtom is hydrated + // from useEffect so it can still be empty on the first frame after a fresh + // invite — fall back to the synchronous SDK signals (invite-state DMInviter + // or m.room.member content with is_direct: true) so the redirect lands on + // the first paint instead of one frame later. See plan §6.5 / §6.7. + if ( + room && + (mDirects.has(room.roomId) || + !!room.getDMInviter() || + isDirectInvite(room, mx.getUserId())) + ) { const alias = getCanonicalAliasOrRoomId(mx, room.roomId); return ; } diff --git a/src/app/state/settings.ts b/src/app/state/settings.ts index 08ee294b..303e71f6 100644 --- a/src/app/state/settings.ts +++ b/src/app/state/settings.ts @@ -44,8 +44,12 @@ export interface Settings { dateFormatString: string; developerTools: boolean; + + migrationsApplied?: Record; } +const DAWN_MIGRATION_KEY = 'dawn-redesign-v1'; + const defaultSettings: Settings = { themeId: undefined, useSystemTheme: true, @@ -77,19 +81,42 @@ const defaultSettings: Settings = { developerTools: false, }; -export const getSettings = () => { - const settings = localStorage.getItem(STORAGE_KEY); - if (settings === null) return defaultSettings; - return { - ...defaultSettings, - ...(JSON.parse(settings) as Settings), - }; -}; - export const setSettings = (settings: Settings) => { localStorage.setItem(STORAGE_KEY, JSON.stringify(settings)); }; +export const getSettings = (): Settings => { + const raw = localStorage.getItem(STORAGE_KEY); + + let parsed: Partial | null = null; + if (raw !== null) { + try { + parsed = JSON.parse(raw) as Partial; + } catch { + parsed = null; + } + } + + 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]) { + merged.useSystemTheme = false; + merged.themeId = 'dark-theme'; + merged.migrationsApplied = { + ...(merged.migrationsApplied ?? {}), + [DAWN_MIGRATION_KEY]: true, + }; + setSettings(merged); + } + + return merged; +}; + const baseSettings = atom(getSettings()); export const settingsAtom = atom( (get) => get(baseSettings), diff --git a/src/app/styles/global.css.ts b/src/app/styles/global.css.ts new file mode 100644 index 00000000..7b12a424 --- /dev/null +++ b/src/app/styles/global.css.ts @@ -0,0 +1,27 @@ +import { globalStyle } from '@vanilla-extract/css'; + +// Vojo-owned safe-area background. Used by `body { background-color: ... }` +// in `src/index.css` to paint Android edge-to-edge cutout / nav-bar zones. +// Matches `Background.Container` from the Dawn palette in `src/colors.css.ts` +// (`#0d0e11`, canon DAWN.bg2). The safe-area zone reads as a continuation of +// the sidebar / nav panels — no visible color seam at the system-bar boundary. +// +// Why a custom var (and not `color.Background.Container`): +// 1. Folds emits its color tokens scoped to a theme class (e.g. `--oq6d070` +// lives inside the `.lightTheme` selector). Before useTheme appends a +// theme class to body, `var(--oq6d070)` resolves to its initial value +// and `background-color` falls back to transparent — system bars show +// through unpainted on Android edge-to-edge. A `:root`-level var here +// resolves from the very first paint. +// 2. `--oq6d070` is a folds@2.6 internal vanilla-extract debugId hash and +// will rotate on any folds upgrade. +// +// The hardcoded #0d0e11 in `index.css` is the matching fallback for the +// instant before this CSS file is parsed. +// +// See docs/plans/dm_1x1_redesign.md §6.6 / R13. +globalStyle(':root', { + vars: { + '--vojo-safe-area-bg': '#0d0e11', + }, +}); diff --git a/src/colors.css.ts b/src/colors.css.ts index c3a23cdd..db1e7f17 100644 --- a/src/colors.css.ts +++ b/src/colors.css.ts @@ -1,104 +1,118 @@ import { createTheme } from '@vanilla-extract/css'; import { color } from 'folds'; -// Базовая тёмная палитра приложения -const navDark = '#121314'; // левая панель (навигация) -const contentDark = '#0d0d0e'; // правая часть (контент/чат) +// Dawn / Stream-v2 palette — see docs/plans/dm_1x1_redesign.md §1, §6 and +// docs/design/new-direct-messages-design/project/stream-v2-dawn.jsx const DAWN. +// +// Tier mapping vs. the canon (DAWN.{bg2, bg, surface}): +// bg2 #0d0e11 — app/window deepest level. The DM list panel AND the chat +// content area share this tone in the canon (ChatListDawn +// line 130 `
` === +// DawnDesktopV3 line 220 ``). +// We use it for both `Background.Container` (sidebar / nav +// panels) and `Surface.Container` (room view) so the two big +// areas read as one uniform surface, divided only by a +// 1-pixel line — matches the canon. +// bg #181a20 — raised one notch. In the canon this paints chat bubbles, +// file cards, the composer container. Mapped to +// `SurfaceVariant.Container`. +// surface #21232b — raised two notches. Used for chips, reactions, active / +// hover rows. Mapped to `*.ContainerActive` family. const darkThemeData = { Background: { - Container: navDark, - ContainerHover: '#262626', - ContainerActive: '#333333', - ContainerLine: '#404040', - OnContainer: '#F2F2F2', + Container: '#0d0e11', + ContainerHover: '#181a20', + ContainerActive: '#21232b', + ContainerLine: '#1f2228', + OnContainer: '#e6e6e9', }, Surface: { - Container: contentDark, - ContainerHover: '#333333', - ContainerActive: '#404040', - ContainerLine: '#4D4D4D', - OnContainer: '#F2F2F2', + Container: '#0d0e11', + ContainerHover: '#181a20', + ContainerActive: '#21232b', + ContainerLine: '#1f2228', + OnContainer: '#e6e6e9', }, SurfaceVariant: { - Container: '#333333', - ContainerHover: '#404040', - ContainerActive: '#4D4D4D', - ContainerLine: '#595959', - OnContainer: '#F2F2F2', + Container: '#181a20', + ContainerHover: '#21232b', + ContainerActive: '#2a2d36', + ContainerLine: '#2f3340', + OnContainer: '#e6e6e9', }, Primary: { - Main: '#BDB6EC', - MainHover: '#B2AAE9', - MainActive: '#ADA3E8', - MainLine: '#A79DE6', - OnMain: '#2C2843', - Container: '#413C65', - ContainerHover: '#494370', - ContainerActive: '#50497B', - ContainerLine: '#575086', - OnContainer: '#E3E1F7', + Main: '#9580ff', + MainHover: '#a59cff', + MainActive: '#b0a8ff', + MainLine: '#bcb5ff', + OnMain: '#0c0c0e', + Container: '#3a3260', + ContainerHover: '#443878', + ContainerActive: '#4d3f87', + ContainerLine: '#564796', + OnContainer: '#e0dcff', }, Secondary: { - Main: '#FFFFFF', - MainHover: '#E5E5E5', - MainActive: '#D9D9D9', - MainLine: '#CCCCCC', - OnMain: '#1A1A1A', - Container: '#404040', - ContainerHover: '#4D4D4D', - ContainerActive: '#595959', - ContainerLine: '#666666', - OnContainer: '#F2F2F2', + Main: '#e6e6e9', + MainHover: '#d6d6d9', + MainActive: '#c6c6c9', + MainLine: '#b6b6b9', + OnMain: '#181a20', + Container: '#21232b', + ContainerHover: '#2a2d36', + ContainerActive: '#34384a', + ContainerLine: '#3a3e54', + OnContainer: '#e6e6e9', }, Success: { - Main: '#85E0BA', - MainHover: '#70DBAF', - MainActive: '#66D9A9', - MainLine: '#5CD6A3', - OnMain: '#0F3D2A', - Container: '#175C3F', - ContainerHover: '#1A6646', - ContainerActive: '#1C704D', - ContainerLine: '#1F7A54', - OnContainer: '#CCF2E2', + Main: '#7dd3a8', + MainHover: '#90dcb5', + MainActive: '#9ee0bf', + MainLine: '#abe5c8', + OnMain: '#0e2e1f', + Container: '#1a4a32', + ContainerHover: '#1f5638', + ContainerActive: '#23613f', + ContainerLine: '#286c46', + OnContainer: '#caf2d9', }, Warning: { - Main: '#E3BA91', - MainHover: '#DFAF7E', - MainActive: '#DDA975', - MainLine: '#DAA36C', - OnMain: '#3F2A15', - Container: '#5E3F20', - ContainerHover: '#694624', - ContainerActive: '#734D27', - ContainerLine: '#7D542B', - OnContainer: '#F3E2D1', + Main: '#d4b88a', + MainHover: '#dabf95', + MainActive: '#dfc59e', + MainLine: '#e3cba8', + OnMain: '#3a2c14', + Container: '#5a4422', + ContainerHover: '#664e27', + ContainerActive: '#71562b', + ContainerLine: '#7c5e30', + OnContainer: '#f3e2c5', }, Critical: { - Main: '#E69D9D', - MainHover: '#E28D8D', - MainActive: '#E08585', - MainLine: '#DE7D7D', - OnMain: '#401C1C', - Container: '#602929', - ContainerHover: '#6B2E2E', - ContainerActive: '#763333', - ContainerLine: '#803737', - OnContainer: '#F5D6D6', + Main: '#c08e7b', + MainHover: '#c89a88', + MainActive: '#cea591', + MainLine: '#d4ad9a', + OnMain: '#3a1f17', + Container: '#592e22', + ContainerHover: '#653527', + ContainerActive: '#6f3a2a', + ContainerLine: '#7a402d', + OnContainer: '#f0d4c8', }, Other: { - FocusRing: 'rgba(255, 255, 255, 0.5)', + FocusRing: 'rgba(149, 128, 255, 0.5)', Shadow: 'rgba(0, 0, 0, 1)', - Overlay: 'rgba(0, 0, 0, 0.8)', + Overlay: 'rgba(0, 0, 0, 0.85)', }, }; diff --git a/src/index.css b/src/index.css index c5f6a6f9..e0c9e146 100644 --- a/src/index.css +++ b/src/index.css @@ -47,7 +47,7 @@ body { padding: 0; height: 100%; overflow: hidden; - background-color: var(--oq6d070); + background-color: var(--vojo-safe-area-bg, #0d0e11); font-family: var(--font-secondary); font-size: 16px; font-weight: 400; diff --git a/src/index.tsx b/src/index.tsx index cf8defe8..e1585c60 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -3,11 +3,13 @@ import React from 'react'; import { createRoot } from 'react-dom/client'; import { enableMapSet } from 'immer'; import '@fontsource/inter/variable.css'; +import '@fontsource-variable/jetbrains-mono/index.css'; import 'folds/dist/style.css'; import { configClass, varsClass } from 'folds'; enableMapSet(); +import './app/styles/global.css'; import './index.css'; import { trimTrailingSlash } from './app/utils/common'; diff --git a/vite.config.js b/vite.config.js index c053c7d2..7bd0c0fe 100644 --- a/vite.config.js +++ b/vite.config.js @@ -15,7 +15,12 @@ import buildConfig from './build.config'; function resolveAppVersion() { if (process.env.VITE_APP_VERSION) return process.env.VITE_APP_VERSION; try { - const raw = execSync('git describe --tags --always --dirty', { + // --match 'v*' filters out non-semver tags (e.g. `redesign-p0-done`, + // `pre-redesign`) so they never shadow the version-derivation chain. + // Without it, `git describe` would pick the nearest tag of any shape and + // the regex below would fall through to `return raw`, leaking the human + // tag name into __APP_VERSION__ on the Welcome screen. + const raw = execSync("git describe --tags --match 'v*' --always --dirty", { stdio: ['ignore', 'pipe', 'ignore'], }) .toString()