redesign(p0): land Dawn dark-theme foundation with one-shot migration and edge-to-edge polish

This commit is contained in:
v.lagerev 2026-04-26 21:30:22 +03:00
parent 0404965f44
commit b41fbfabec
12 changed files with 201 additions and 179 deletions

View file

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

10
package-lock.json generated
View file

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

View file

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

View file

@ -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<string, string> = {
[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<RectCords>();
const themeName = (id: string) => t(THEME_I18N_KEYS[id] ?? id);
const currentLabel = systemTheme
? t('Settings.system_theme')
: themeName(themeId ?? LightTheme.id);
const handleOpenMenu: MouseEventHandler<HTMLButtonElement> = (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 (
<>
<Button
size="300"
variant="Primary"
outlined
fill="Soft"
radii="300"
after={<Icon size="300" src={Icons.ChevronBottom} />}
onClick={handleOpenMenu}
>
<Text size="T300">{currentLabel}</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 }}>
<MenuItem
size="300"
variant={selectedId === SYSTEM_THEME_ID ? 'Primary' : 'Surface'}
radii="300"
onClick={() => handleSelect(SYSTEM_THEME_ID)}
>
<Text size="T300">{t('Settings.system_theme')}</Text>
</MenuItem>
{themes.map((theme) => (
<MenuItem
key={theme.id}
size="300"
variant={selectedId === theme.id ? 'Primary' : 'Surface'}
radii="300"
onClick={() => handleSelect(theme.id)}
>
<Text size="T300">{themeName(theme.id)}</Text>
</MenuItem>
))}
</Box>
</Menu>
</FocusTrap>
}
/>
</>
<Text size="T300" priority="300">
{t('Settings.theme_dark')}
</Text>
);
}

View file

@ -16,7 +16,11 @@ export function WelcomePage() {
<PageHero
icon={<img width="70" height="70" src={VojoSVG} alt="Vojo Logo" />}
title="Welcome to Vojo"
subTitle={<span>{__APP_VERSION__}</span>}
subTitle={
<span style={{ fontFamily: '"JetBrains Mono Variable", ui-monospace, monospace' }}>
{__APP_VERSION__}
</span>
}
/>
</PageHeroSection>
</Box>

View file

@ -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 <Navigate to={getDirectRoomPath(alias, eventId)} replace />;
}

View file

@ -44,8 +44,12 @@ export interface Settings {
dateFormatString: string;
developerTools: boolean;
migrationsApplied?: Record<string, boolean>;
}
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<Settings> | null = null;
if (raw !== null) {
try {
parsed = JSON.parse(raw) as Partial<Settings>;
} 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<Settings>(getSettings());
export const settingsAtom = atom<Settings, [Settings], undefined>(
(get) => get(baseSettings),

View file

@ -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',
},
});

View file

@ -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 `<div ... background: DAWN.bg2 ...>` ===
// DawnDesktopV3 line 220 `<WindowFrame bg={DAWN.bg2} ...>`).
// 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)',
},
};

View file

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

View file

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

View file

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