diff --git a/.gitignore b/.gitignore
index 40ae35a2..6037464b 100644
--- a/.gitignore
+++ b/.gitignore
@@ -11,3 +11,4 @@ devAssets
docs/ai/desired_features.md
docs/ai/bugs.md
docs/plans
+docs
\ No newline at end of file
diff --git a/docs/ai/architecture.md b/docs/ai/architecture.md
index 99632fbc..a404a546 100644
--- a/docs/ai/architecture.md
+++ b/docs/ai/architecture.md
@@ -11,11 +11,15 @@ npm run typecheck # tsc --noEmit
Build: **Vite 5.4** with vanilla-extract, WASM, PWA plugins.
+> **Note:** `.husky/pre-commit` is currently commented out. Absolute `npm run typecheck` and `npm run check:eslint` are **already red** by known tech debt (~835 typecheck + 39 eslint errors, see `docs/known-tech-debt-lint/`). Use `bash docs/known-tech-debt-lint/diff.sh` to verify your changes added no new errors, then `npm run build` for the green build check.
+
## Source Layout
```
src/
├── index.tsx # Entry point
+├── colors.css.ts # Custom dark-theme via createTheme(color, …); no light override (uses folds.lightTheme as-is)
+├── config.css.ts # fontWeight overrides
├── client/
│ ├── initMatrix.ts # Matrix SDK init (createClient, startClient, logout)
│ └── secretStorageKeys.js # Crypto callbacks
@@ -23,7 +27,9 @@ src/
└── app/
├── i18n.ts # i18next config
├── pages/
- │ └── App.tsx # Root component (providers, config loader)
+ │ ├── App.tsx # Root component (providers, config loader)
+ │ ├── Router.tsx # createBrowserRouter / createHashRouter, all routes
+ │ └── MobileFriendly.tsx # MobileFriendlyClientNav + MobileFriendlyPageNav (responsive split)
├── features/ # Feature modules
├── components/ # Shared components
├── hooks/ # ~117 custom hooks
@@ -35,99 +41,193 @@ src/
## Pages & Routing (`src/app/pages/`)
-Router in `Router.tsx` — `createBrowserRouter` / `createHashRouter`.
+Router in `Router.tsx`. Each top-level tab (`/home/`, `/direct/`, `/space/...`, `/explore/`, `/inbox/`) is wrapped in `PageRoot` with a `nav` prop (the tab's PageNav — e.g. `Home`, `Direct`) and an `` for the active room/sub-route.
-- **Auth**: `auth/login/`, `auth/register/`, `auth/reset-password/`
-- **Client**: `client/` — main layout after login
+- **Auth** (`auth/login/`, `auth/register/`, `auth/reset-password/`) — NOTE: bistable layout fragility, see `bugs.md`. Recent fixes: c466848, e6623b2, cf5ee9a, 9b41cbb. **Don't change `body`/`#root` background CSS-vars** — auth uses computed sizes for safe-area painting.
+- **Client** (`client/`) — main layout after login (wrapped by `ClientLayout` = nav + content)
- `home/` — Home timeline (path: `/home/`, root `/` redirects here)
- `direct/` — DMs (path: `/direct/`)
- `space/` — Space view (path: `/:spaceIdOrAlias/`)
- `explore/` — Public rooms (path: `/explore/`)
- `inbox/` — Notifications, invites (path: `/inbox/`)
- `create/` — New room/space (path: `/create/`)
- - `sidebar/` — Tab components for sidebar nav
- - `WelcomePage.tsx` — Empty state
+ - `sidebar/` — Tab components (`HomeTab`, `DirectTab`, `SpaceTabs`, `ExploreTab`, `CreateTab`, `SearchTab`, `UnverifiedTab`, `InboxTab`, `SettingsTab`)
+ - `WelcomePage.tsx` — Empty state (vojo SVG + version). **Mobile is intentionally null** at the index route (`{mobile ? null : } />}`) — by design, not a bug.
+ - `SidebarNav.tsx` — global 66px icon-rail (DirectTab, HomeTab, SpaceTabs, …, sticky bottom: SearchTab, UnverifiedTab, InboxTab, SettingsTab)
- `SyncStatus.tsx`, `SpecVersions.tsx` — Connection status
+### DM-specific routing path
+
+```
+/direct/:roomIdOrAlias
+ → PageRoot (nav=Direct, outlet=…)
+ → DirectRouteRoomProvider (sets IsDirectRoomProvider=true, runs useAutoDirectSync)
+ → Room.tsx (RoomViewHeader + RoomView, screen-size branching for MembersDrawer)
+ → RoomTimeline + RoomViewTyping + RoomInput
+```
+
+`useAutoDirectSync` (commit 84eeac9) round-trips `m.direct` on join — **important**: for invited users the room renders first as non-direct, then flips to direct when sync resolves. Any DM-only branching must therefore use the `useIsDirectStream(room)` hook (introduced in DM redesign P3a) which combines three synchronous sources: `mDirectAtom.has(roomId)` (returning user — populated on first paint via `IndexedDBStore.startup()` await before `startClient()`) + `room.getDMInviter()` (matrix-js-sdk SDK helper for invite-state with `is_direct: true` in member content's `prev_content`) + `isDirectInvite(room, myUserId)` ([src/app/utils/room.ts:67](../../src/app/utils/room.ts), reads current member content for `is_direct: true`). Don't gate on `useIsDirectRoom()` alone — it misses the invited-user case. Bridged Telegram DMs that lack `is_direct: true` on invite are deferred to a future plan (separate Telegram tab/namespace). See `docs/plans/dm_1x1_redesign.md` §6.5 for the full rationale.
+
## Features (`src/app/features/`)
| Dir | Purpose |
|-----|---------|
-| `room/` | Core room view — **RoomTimeline.tsx** (~63KB), **RoomInput.tsx** (~24KB), **RoomViewHeader.tsx** (~18KB), MembersDrawer, MessageEditor, RoomTombstone |
-| `room-nav/` | Room list navigation items & categories |
+| `room/` | Core room view — **RoomTimeline.tsx** (~1890 LOC), **RoomInput.tsx** (~691 LOC), **RoomViewHeader.tsx** (~587 LOC), MembersDrawer, MessageEditor, RoomTombstone, RoomViewTyping, CallChatView, CommandAutocomplete |
+| `room/message/` | `Message.tsx` (~1335 LOC) — selects layout (Compact/Bubble/Modern) per `messageLayout` setting, renders edit/delete/react menu, mention/hashtag links, reactions viewer |
+| `room-nav/` | `RoomNavItem.tsx` (~435 LOC) — list-row component, used by Home/Direct/Spaces. Carries call-room behaviour (`useCallSession`, `useCallMembers`, `useCallStart`) |
| `room-settings/` | Room-specific settings page |
| `common-settings/` | Shared settings: general, members, permissions, emojis-stickers, developer-tools |
| `space-settings/` | Space-specific settings |
-| `settings/` | User settings (general, account, notifications, devices, emojis, about, dev-tools) |
+| `settings/` | User settings (general, account, notifications, devices, emojis, about, dev-tools). General has the `MessageLayout` dropdown. **Logout lives here only.** |
| `lobby/` | Space/room lobby view |
| `search/` | Global search |
| `message-search/` | In-room message search |
-| `create-chat/` | DM creation flow |
+| `create-chat/` | DM creation flow (recent commit 58ec12d split it into username + server fields) |
| `create-room/` | Room creation |
| `create-space/` | Space creation |
| `add-existing/` | Join existing rooms |
| `join-before-navigate/` | Pre-join navigation logic |
-| `call/` | Element Call integration |
-| `call-status/` | Call state display |
+| `call/` | Element Call integration. **Vojo-DM voice call lifecycle lives here** (phases 0..5.35). Don't touch unless redesigning calls. |
+| `call-status/` | Call state display + IncomingCallStrip (mounted as sibling in `Router.tsx:184`, **not** inside RoomViewHeader). |
+
+### Virtualization in features/room/
+
+`RoomTimeline.tsx` does **not** use `@tanstack/react-virtual`. It uses **`useVirtualPaginator`** + `IntersectionObserver` for pagination + scroll-anchoring. There is no `estimateSize` to retune; row heights are measured live. (The tanstack `useVirtualizer` is used elsewhere — e.g. `Direct.tsx:199` for the DM-list column with `estimateSize: () => 38`.)
## Key Components (`src/app/components/`)
-- `message/` — Message rendering (layout variants: compact, bubble, modern)
-- `editor/` — Slate-based rich text editor
+- `message/` — Message rendering. Layout variants live in `message/layout/{Modern,Compact,Bubble}.tsx` + `layout.css.ts` recipes. Selected per `MessageLayout` enum in `state/settings.ts` (Modern=0, Compact=1, Bubble=2). `EventContent.tsx` has its own layout switch for membership/state events.
+- `message/MessageStatus.tsx` — **Vojo-specific**: WhatsApp-style delivery checkmarks (commit 0c4cfb9). Pairs with `hooks/useMessageStatus.ts` (receipt-derivation).
+- `editor/` — Slate-based rich text editor. Autocomplete: `RoomMentionAutocomplete`, `UserMentionAutocomplete`, `EmoticonAutocomplete`, `CommandAutocomplete`. `Editor.tsx` is the Slate root — preserve.
- `emoji-board/` — Emoji picker
- `image-pack-view/` — Custom emoji pack management
- `image-viewer/`, `Pdf-viewer/` — Media viewers
-- `sidebar/` — Sidebar navigation
+- `sidebar/` — `Sidebar.tsx` (66px wrapper), `SidebarItem.tsx`, `SidebarStack.tsx`, `SidebarContent.tsx`, `Sidebar.css.ts`
- `user-profile/` — User info, power chips, moderation
- `member-tile/` — Member list items
- `power/` — Power level UI
- `upload-card/` — Upload progress cards
- `url-preview/` — Link previews
-- `page/` — Page layout wrapper (Page, PageHeader, PageContent)
+- `page/` — Page layout wrapper (`Page`, `PageRoot`, `PageNav`, `PageHeader`, `PageContent`, `PageHero`)
- `setting-tile/` — Settings list item pattern
- `sequence-card/`, `cutout-card/` — Card layouts
- `uia-stages/` — User-interactive auth stages (email, captcha, token)
- `room-intro/` — Room introduction card
- `invite-user-prompt/`, `join-address-prompt/`, `leave-room-prompt/` — Dialogs
+- `BackRouteHandler.tsx` — Web back-button → back-stack collapse via `replace` (commit dce6be9)
+
+## Vojo-specific code paths (preserve when redesigning)
+
+These are vojo additions on top of stock Cinny — they crossed many recent stabilization commits and are now load-bearing.
+
+| Path | Commits | Notes |
+|---|---|---|
+| `features/room/RoomViewHeader.tsx::DmCallButton` | calls phases 0-5.35 | Outgoing DM voice call entry point |
+| `features/call/*` (CallEmbed, CallView) | 91afffc, e8188d7, fab533e | Element Call widget; **don't unmount/remount the widget root on header redesign** — Android FGS is keyed on `joined` state |
+| `features/call-status/IncomingCallStrip.tsx` | dabe5ca, 91afffc | Mounted as sibling in `Router.tsx:184` (not inside header). Decline / accept / mid-ring backgrounding handled here |
+| `state/callEmbed.ts` (`callEmbedAtom`, `callChatAtom`) | call phases | Embed lifecycle |
+| `components/message/MessageStatus.tsx` + `hooks/useMessageStatus.ts` | 0c4cfb9 | WhatsApp checkmarks |
+| `hooks/usePushNotifications.ts` | dabe5ca, 84eeac9 | Push tap routing: warm web/native paths resolve DMs to Direct via `navigateRoom` / `getDirectRoomPath` (lines 309 / 387); SW cold-start opens `/home/{roomId}/` and `HomeRouteRoomProvider` redirects to `/direct/` when `mDirectAtom` has the room. FSI hand-off |
+| `hooks/useRoomNavigate.ts` | dce6be9 | Back-stack collapse via `replace`. **Path-based** (`pathnameRef.current === target ? replace : push`, line 49-55) — not tab-ID-keyed, so removing `*Tab.tsx` rendering does not break back-stack |
+| `hooks/useAndroidBackButton.ts`, `components/BackRouteHandler.tsx` | dce6be9 | Hardware back integration |
+| `hooks/useAutoDirectSync.ts` + `utils/matrix.ts` | 84eeac9 | m.direct sync on join (DM rooms shown correctly for invited users) |
+| `pages/auth/*` | c466848, e6623b2, cf5ee9a, 9b41cbb | Bistable layout — don't change body/root background CSS-vars; see `bugs.md` |
+| `MainActivity.java::EdgeToEdge.enable(this)` + `src/index.css` lines 45-65 + `styles.xml` `windowLayoutInDisplayCutoutMode=shortEdges` | 1edaf60 | **Android edge-to-edge compensation** (white-bar fix). WebView draws under status/nav bars; compensation triad: `body { background-color: var(--oq6d070) }` (currently folds `color.Background.Container` — **but `--oq6d070` is folds@2.6.2 internal implementation detail, not public API**; can break on folds upgrade), `#root { padding: env(safe-area-inset-*) }`, and the manifest cutout mode. Break any one and white bars return or status text overlaps content. **DM redesign P0 replaces with Vojo-owned `--vojo-safe-area-bg` variable** + matching `WindowCompat.setAppearanceLight{Status,Navigation}Bars(false)` in `MainActivity.onCreate` to keep system-bar icons visible across uiMode. See `dm_1x1_redesign.md` §6.6 / R13 (safe-area var) and R19 (Android icon tint) for full risk matrix. |
+| `scripts/gen-push-strings.mjs` + `android/app/build.gradle` | 19d3dc0, 7af04a4 | Gradle task generates `push_strings.xml` from i18n `Push.json`. **Don't put non-push i18n keys in the `Push` namespace** or they leak into lockscreen XML. EN+RU must be added together (web SW falls back to EN if RU missing) |
+| `android/app/src/main/java/**` | calls phases 5.35 | FGS, FCM, ring registry, declined-IDs tracker |
## State Management
**Jotai** atoms in `src/app/state/`:
-- `settings.ts` — User preferences (MessageLayout, DateFormat, …)
+- `settings.ts` — User preferences (`MessageLayout` enum: Modern=0, Compact=1, Bubble=2; `themeId`, `useSystemTheme`, `monochromeMode`, …). Persisted to `localStorage['settings']`. The `MessageLayout` enum is canonical here.
- `sessions.ts` — Active session
-- `upload.ts` — Upload progress
-- `room/` — `roomInputDrafts`, `roomToParents`, `roomToUnread`
-- `room-list/` — `roomList`, `inviteList`, sorting/filtering
+- `upload.ts` — Upload progress (in-memory)
+- `room/` — `roomInputDrafts` (in-memory), `roomToParents`, `roomToUnread`
+- `room-list/` — `roomList`, `mDirectAtom`, `inviteList`, sorting/filtering
+- `callEmbed.ts` — `callEmbedAtom`, `callChatAtom` (call lifecycle)
+- `closedNavCategories.ts` — `closedNavCategoriesAtom` (collapsed/expanded folders)
Some atoms persist to localStorage (e.g. `settings.ts`, `navToActivePath.ts`), others are in-memory only (e.g. `upload.ts`, `roomInputDrafts.ts`). Access via hooks in `state/hooks/`.
+## Theming
+
+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).
+- 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.
+
+## Responsive design
+
+**No CSS media queries** for layout. Responsive behaviour goes through `hooks/useScreenSize.ts`:
+
+```ts
+ScreenSize.Mobile // ≤750px
+ScreenSize.Tablet // >750 ≤1124px
+ScreenSize.Desktop // >1124px
+```
+
+`useScreenSizeContext()` returns the current size; components branch their JSX on it.
+
+`pages/MobileFriendly.tsx` provides two wrappers:
+
+- **`MobileFriendlyClientNav`** — hides global `SidebarNav` on Mobile unless URL matches a top-level route (`/home/`, `/direct/`, `/{spaceId}/`, `/explore/`, `/inbox/`)
+- **`MobileFriendlyPageNav`** — hides per-tab `PageNav` (e.g. the Direct chat list) on Mobile unless URL matches the exact root path (e.g. exactly `/direct/`)
+
+Result: on Mobile, navigating to a room hides both nav levels — only RoomView is visible. Back-button via `useRoomNavigate` collapses these into the back-stack via `history.replace` (commit dce6be9).
+
+The only CSS `@media` queries in the app target `(hover: hover) and (pointer: fine)` to disable hover-only effects on touch devices.
+
## Matrix SDK Patterns
```tsx
const mx = useMatrixClient(); // Get SDK instance
const room = useRoom(); // Current room
+const isDirect = useIsDirectRoom(); // From IsDirectRoomProvider context
const stateEvent = useStateEvent(room, StateEvent.Type); // Room state
const powerLevels = usePowerLevels(room); // Permissions
```
+## i18n
+
+i18next + `react-i18next`. Translations in `public/locales/{en,ru}/*.json`, organised by namespace (`Direct`, `Room`, `Settings`, `Push`, …).
+
+- **`Push` namespace is special**: `scripts/gen-push-strings.mjs` (run as a Gradle task, commit 19d3dc0) reads it and emits `android/app/src/main/res/values{,-ru}/push_strings.xml` for Android lockscreen. **Don't put non-push UI keys in `Push`.** UI strings → `Direct`/`Room`/`User`/etc.
+- Web SW push falls back to **EN** if a key is missing in RU. Always add EN + RU in the same commit.
+- Russian-language quality: see `i18n.md` for tone, register, verb-vs-noun rules.
+
## Key Libraries
- **React 18.2** + React Router DOM 6
- **matrix-js-sdk 38.2** — Matrix protocol
- **folds 2.6** — UI component library
- **jotai 2.6** — State management
-- **vanilla-extract** — Type-safe CSS
+- **vanilla-extract** — Type-safe CSS (compile-time → no runtime theme switching without reload)
- **slate 0.123** — Rich text editor
- **@tanstack/react-query 5** — Data fetching
-- **@tanstack/react-virtual 3** — Virtual scrolling
+- **@tanstack/react-virtual 3** — Virtual scrolling — used for **list panels** (`Direct.tsx`, `Home`, etc.). Note: `RoomTimeline.tsx` does NOT use this; it uses an in-house `useVirtualPaginator` + `IntersectionObserver`.
- **i18next 23 + react-i18next 15** — Localisation
- **Capacitor 8.3** — Native Android wrapper
- **@capacitor/browser 8.0** — External link handling in native
+- **vite-plugin-pwa** — Custom service worker via `injectManifest` strategy with `injectionPoint: undefined` (`vite.config.js:132-140`). **No automatic Workbox precache** — the SW is hand-written in `src/sw.ts`, vite-pwa just builds and registers it. There is no precache size limit to worry about; new font assets are loaded on demand via fetch, not bundled into a precache manifest.
+- **`@fontsource/inter`** in `dependencies`. Other `@fontsource/*` additions must also go in `dependencies` (not dev) to land in production bundle.
## Git
- Main branch: `dev`
-- Current work branch: `vojo/dev`
+- Current vojo work branch: `vojo/dev`
- Semantic-release on `dev` branch
- CI: GitHub Actions (build, deploy, docker, netlify)
+- **Husky pre-commit is currently disabled** — `npm run typecheck` and `npm run check:eslint` do not run automatically. Both are **already red** by known tech debt; use `bash docs/known-tech-debt-lint/diff.sh` to check your changes don't add new errors
+- **Android `versionCode` is monotonic** (commit 8064760, derived from commit count). Don't squash or rebase across release boundaries — Play store rejects downgrades
+- **Commit message style** (vojo memory): one sentence ≤25 words; no body; no Co-Authored-By trailer
+
+## Build & deploy
+
+- Web: `npm run build` → `dist/` → `scp` to `~/vojo/cinny/` on the production VPS (Caddy serves it)
+- Android debug: `npm run build:android:debug` (composite of `npm run build && npm run android:sync && cd android && ./gradlew assembleDebug`)
+- Android release / AAB: similar but `assembleRelease` / `bundleRelease`
+- VSCode task `Deploy to vojo.chat` (Ctrl+Shift+D) automates web deploy
+- Android-specific build chain, edge-to-edge, safe-area, FGS, FCM ring registry, push-strings Gradle task — see `android.md`