# Kaizen App Architecture map for the native Android client. Source of truth for any agent or human picking up this codebase. ## Goal Make the **native Android app** (`kaizen-app`) a valid daily-driver that talks to the **Kaizen backend** (`kaizen`). One binary, deployment-agnostic: one uniform `Authorization: Bearer ` contract, same against the official server or any self-host. Design constraint (hard): backend complexity stays invisible to the user. ## Repos (absolute paths) - Backend (Next.js 16, Drizzle, Postgres): `/Users/brunodeanoz/Kaizen/kaizen` — git remote `origin` = `git@git.kryptomrx.de:krypto/kaizen.git`, branch `main`. Read its `CLAUDE.md` first; it is the architecture map. - Android app (Kotlin, Jetpack Compose, package `dev.kaizen.app`): `/Users/brunodeanoz/Kaizen/kaizen-app` — built/run from Windows Android Studio on a real S25 Ultra; everything else (backend, Postgres, Docker) runs on the dev Mac + dedicated servers. - **Note:** `/Users/brunodeanoz/Kaizen` itself is NOT a git repo. Only `kaizen` and `kaizen-app` are separate git repos. ## Platform strategy - Android: Kotlin + Jetpack Compose (native, no WebView) - iOS (planned): Swift + SwiftUI + SwiftData (native, separate codebase) - No KMP — each platform uses its native stack for best feel ## The API contract the app uses (source of truth — verify, don't guess) - **Auth:** every request carries `Authorization: Bearer kzn_...`. Backend resolves via `requireActiveUserOrToken` (`lib/auth/guard.ts`). - **Token acquisition:** in-app login (`POST {baseUrl}/api/v1/auth/token` with `{ email, password, name }`). Consumer "Sign in" pattern — no copy-paste. - **Chat:** `POST {baseUrl}/api/v1/chat` — `{ model, messages: [{ role, content }], reasoningEffort?, preferThroughput? }`. Response is `text/plain` chunked stream (NOT SSE). Parsed by `StreamConsumer.Incremental` in `net/StreamConsumer.kt`. - **Sentinels** (`lib/chat/constants.ts`): `\x00` USAGE, `\x01` REASONING, `\x02` SOURCES, `\x03` TOOL, `\x04` QUERY. App currently strips all sentinels and renders only visible content. - **Models:** `GET {baseUrl}/api/v1/models` → `{ models: [{ id, name?, provider? }] }`. - **Conversations:** `GET /api/v1/conversations` → `{ conversations, hasMore }`. `POST /api/v1/conversations` → create. `GET /api/v1/conversations/[id]` → `{ messages }`. `POST /api/v1/conversations/[id]/messages` → persist. `POST /api/v1/conversations/[id]/title` → auto-title. - **Identity:** `GET /api/v1/me` → `{ id, email, name, role, defaultModel }`. - **Capability handshake:** `GET /api/v1/meta` → `{ app, apiVersion, features }` (unauth). - **Upload:** `POST /api/v1/upload` — multipart file → `{ url, kind, name, mimeType, size, fileId }`. ## App architecture ### Package structure (`app/src/main/java/dev/kaizen/app/`) ``` auth/ LoginScreen, BiometricUnlock (KeyStore + CryptoObject) chat/ ChatScreen, ChatViewModel, Sidebar, ChatComponents, ChatModels, ModelSheet, Markdown, Background db/ Room offline cache: KaizenDatabase, Entities, DAOs, Repositories, Converters, Mappers haptics/ Phase-aware haptics net/ KaizenApi (HTTP), SecureStore (encrypted prefs), SessionViewModel, ServerConfig, StreamConsumer settings/ SettingsScreen, SettingsViewModel, SettingsComponents ui/ theme/ Color (P3), Theme (multi-theme), Type, Oklab, Dither theme/themes/ AccentScheme, AmberTheme, BlauTheme, MonoTheme, AuroraTheme, ThemeRegistry shape/ SquircleShape (superellipse), KaizenShapes (token system) effect/ GlassSurface (frosted glass), Shadow (multi-layer stack) motion/ Motion (easing curves, spring specs, duration tokens) sensor/ TiltState (gyro/accel parallax, lifecycle-aware) ``` ### Key components - **KaizenApi** (`net/KaizenApi.kt`) — singleton `object`, all HTTP calls via OkHttp. Streaming chat returns `Flow` with incremental sentinel-stripping. - **StreamConsumer** (`net/StreamConsumer.kt`) — O(chunk-size) incremental parser, each character visited exactly once across the entire stream. - **SessionViewModel** (`net/SessionViewModel.kt`) — Compose snapshot state for auth session. Holds `ServerConfig` (baseUrl, token, model, email, speed). Persisted in `EncryptedSharedPreferences` via `SecureStore`. - **ChatViewModel** (`chat/ChatViewModel.kt`) — owns Room database instance + repositories. Created in `MainActivity`, passed to `ChatScreen`. - **ChatScreen** (`chat/ChatScreen.kt`) — main composable, orchestrates sidebar, messages, streaming, file uploads. ### Design System v2 (`ui/`) — added 2026-06-21 Full spec: `DESIGN.md`. **Six pillars** (load-bearing — if any regress, the design regresses): 1. **P3 color pipeline** — all tokens in DisplayP3 color space (`Color.kt`). `window.colorMode = COLOR_MODE_HDR` (API 34+) / `COLOR_MODE_WIDE_COLOR_GAMUT` (API 26+) in `MainActivity`. No `Color(0xFF…)` for brand colors outside `Color.kt`. 2. **Squircle shapes** — iOS-style superellipse with G2 continuity (`ui/shape/Squircle.kt`). Token system `KaizenShapes.{xs,sm,md,lg,xl,pill,circle}`. Replaces all `RoundedCornerShape`. 3. **GlassSurface** — frosted-glass composable (gradient tint + specular border + inner highlight + multi-layer shadow). Android has no backdrop-filter; glass effect works via semi-transparent tint over the blob layer. Opacity tiers per component in `GlassTiers`. 4. **Multi-layer shadows** — `ShadowStack` with 2–4 overlapping layers (`KaizenShadows.level0..4`). Dark mode multiplies alphas ×1.8. Replaces all `Modifier.shadow()`. 5. **4-theme system** — Amber/Blau/Mono/Aurora. Each theme = `KaizenAccentScheme` (primary + 4 blob colors). Neutrals are theme-independent. `LocalKaizenAccent` CompositionLocal. Theme selection wired end-to-end: `SettingsViewModel.selectedTheme` → `MainActivity` → `KaizenTheme(themeId=...)`. No server sync (web stores in localStorage, app stores locally). Appearance (Light/Dark/System) also wired end-to-end. 6. **Sensor-reactive motion** — `rememberTiltState()` provides smoothed tilt `Offset(-1..1)`. Uses `TYPE_GAME_ROTATION_VECTOR` (fused, no drift). Auto-disables on battery saver, reduced motion, background, extreme tilt. **Motion tokens** (`ui/motion/Motion.kt`): `EaseSpring` (overshoot), `EaseSmooth` (expo-out), `EaseEmphasized` (M3), `SpringSnappy/Default/Gentle`. All durations centralised in `Durations` object. **Migration status:** All UI components migrated to the design system. Sidebar, ChatInput, ChatScreen, LoginScreen, ModelSheet, SettingsCard, SettingsScreen use `GlassSurface` + `KaizenShapes` + `kaizenShadow`. Only Markdown code-block shapes remain as `RoundedCornerShape` (intentionally rectangular for code). **Theme switching fully wired:** All 7 files that previously imported hardcoded `Amber`/`AmberDeep`/`OnAmber`/`BlobX` now read from `LocalKaizenAccent.current`. Changing theme in Settings immediately updates: message bubbles, send button, orb, mesh background blobs, sidebar highlights, model sheet, stream blocks, login screen, settings toggles. No hardcoded amber imports remain outside `Color.kt` and theme definitions. **Floating bar scrims:** Top bar (hamburger + model pill) and bottom dock (mode pills + input) have vertical gradient scrims (`MaterialTheme.colorScheme.background` → transparent) to prevent message content from bleeding through. Model pill opacity increased to 0.92. **KaizenOrb** (`chat/Background.kt`) uses `rememberTiltState()` from `ui/sensor/` instead of inline sensor code. Breath animation uses `Durations.ORB_BREATH` / `ORB_BREATH_STREAMING` from `ui/motion/`. **Typography** — Inter Variable (`res/font/inter_variable.ttf`, 859 KB, SIL OFL). Full type scale from DESIGN.md §8: `displayLarge` (32sp/W600) through `labelSmall` (11sp/W500). All 9 weight axes registered (W100–W900). Negative tracking at display sizes, positive at label sizes. `@OptIn(ExperimentalTextApi::class)` for `FontVariation.Settings`. **Oklab** (`ui/theme/Oklab.kt`) — Double-precision matrix math for accurate sRGB↔Oklab round-trips. Used by theme picker's Oklab gradient swatches and `generateGradientStops()`. Forward LMS matrix corrected to Björn Ottosson reference. ### Internationalization (i18n) - **de** (default), **en** — same as web (`messages/de.json`, `messages/en.json`). - Strings: `res/values/strings.xml` (de) + `res/values-en/strings.xml` (en). - All UI strings use `stringResource(R.string.xxx)` in `@Composable` functions, or `context.getString(R.string.xxx)` in non-composable contexts (error handlers, coroutines). - Date/time: `DateTimeFormatter` with `Locale.getDefault()`. - Greeting: time-of-day-based (Morgen/Tag/Abend → Morning/Afternoon/Evening), boundaries 5/12/18. - ChatModels: `Suggestion.labelRes`/`promptRes` and `ChatMode.labelRes` are resource IDs (Int), not hardcoded strings. - `ModePillsRow`: `selected` state is `Int` (resource ID), not `String`. ### Settings persistence + server sync - **SettingsViewModel** takes `SecureStore` — theme, appearance, username, locale survive app restart. - Theme selection persisted as `KaizenThemeId.value` string ("amber"/"blue"/"mono"/"aurora"). - Appearance persisted as `AppAppearance.name` ("Light"/"Dark"/"System"). - On login + on each foreground: `KaizenApi.fetchMe()` → `MeResponse(name, email, defaultModel)` populates SettingsViewModel name/email. - **Language selector** in Settings: System / Deutsch / English. Stored as `locale` in SecureStore. Note: currently only stores the preference; runtime locale switching (AppCompatDelegate.setApplicationLocales) needs wiring in MainActivity on next iteration. - No server-side theme storage — web uses localStorage, app uses SecureStore. Both are client-local. ### Sentinel parsing — reasoning, sources, tools (added 2026-06-21) The `/api/v1/chat` stream embeds control sections via sentinel code points (from `lib/chat/constants.ts`): | Sentinel | Code | What | App UI | |----------|------|------|--------| | `\x00` USAGE | trailing | Usage/cost JSON | Stripped (display TODO) | | `\x01` REASONING | toggle | Model's chain-of-thought | `ReasoningBlock` — collapsible "Gedankengang" | | `\x02` SOURCES | trailing | Web search sources JSON | `SourcesBlock` — compact cards with domain | | `\x03` TOOL | leading | Tool-call events (JSON+newline) | `ToolEventsBlock` — state machine (thinking/analyzing/start/end/error) | | `\x04` QUERY | trailing | Search query string | Shown above sources | **StreamConsumer.Incremental** (`net/StreamConsumer.kt`) returns `StreamState` (not `String`): - `content` — visible text (reasoning stripped, tool events stripped) - `reasoning` — full reasoning text - `tools` — `List` (type, name, args, result, error, elapsed) - `query` — search query string - `sources` — `List` (title, url, snippet) **KaizenApi.chat()** returns `Flow`. ChatScreen maps all fields to `Message` during streaming. **UI files:** `chat/StreamBlocks.kt` — `ReasoningBlock`, `ToolEventsBlock`, `SourcesBlock`, `SourceChip`. **ToolEventsBlock state machine** (matches web frontend `LiveToolSteps`): - `type: "thinking"` → "✨ Verarbeitet …" (only shown when no other tool activity) - `type: "analyzing"` → "📄 Analysiert {name} …" (file processing) - `type: "start"` → wrench icon + tool name (in progress) - `type: "end"` → ✅ checkmark + tool name + elapsed ms (completed) - `type: "error"` → ❌ error icon + tool name (failed) - Empty/thinking-only events are hidden (no generic "Tool" fallback). ### App icon Adaptive icon: amber orb with multi-layer radial gradients (body, depth shadow, specular highlight, warm bounce light, crisp rim). Light mode: warm off-white background. Dark mode: Obsidian gradient. Monochrome layer for Android 13+ themed icons. Splash screen uses orb foreground (API 31+). Vector-only — no raster WebPs. ### Offline-first cache layer (Room) — added 2026-06-21 Architecture decision document: `OFFLINE_CACHE_ADD.md`. **Principle:** Read-cache, not sync engine. Server is source of truth. No offline-queue, no conflict resolution. **Stack:** Room 2.8.4 + KSP 2.2.10-2.0.2. `room-ktx` merged into `room-runtime` since Room 2.7 (no separate dep needed). `exportSchema = false`, `fallbackToDestructiveMigration()` — it's a cache, not a DB. On schema changes the SQLite is wiped; cache rebuilds on next server fetch. **Schema:** - `conversations` — id, title, locked, pinned, updatedAt, cachedAt - `messages` — id, conversationId (FK CASCADE), role, content, attachments (JSON via TypeConverter), sortOrder **Data flow:** ``` App start → Room (instant) → UI shows cached data → Background: server fetch → Room update → Flow emits → UI updates → Background: prefetch messages for all uncached conversations Chat open → Room (instant) → Background: server fetch → Room update Send message → in-memory during streaming → Room flush after stream ends ``` **Background prefetch:** After conversation list sync, `MessageRepository.prefetchMissing()` iterates all unlocked conversations and fetches messages for any not yet cached. This ensures all chats are readable offline, not just previously opened ones. Already-cached conversations are skipped. **Streaming hybrid model:** During active streaming, messages are mutated in-memory (60x/s). Room is NOT written during streaming — that would be absurd I/O. After stream completion, user + assistant messages are upserted into Room. This avoids SQLite lock contention while keeping the cache fresh. **Files:** - `db/Entities.kt` — `ConversationEntity`, `MessageEntity` - `db/ConversationDao.kt` — `observeAll()` (Flow), `replaceAll()` (transaction: deleteAll + upsertAll, avoids SQLite 999-variable limit), `lastCachedAt()`, `allUnlockedIds()` - `db/MessageDao.kt` — `observeByConversation()`, `getByConversation()`, `upsertAll()`, `deleteByConversation()`, `cachedConversationIds()` - `db/KaizenDatabase.kt` — singleton via `companion object` (double-checked locking), thread-safe - `db/ConversationRepository.kt` — `observeAll()` (Flow of ConversationSummary), `refresh()` (server → Room), `allUnlockedIds()` - `db/MessageRepository.kt` — `observeMessages()`, `getCached()`, `fetchAndCache()`, `cacheMessages()`, `prefetchMissing()` (background prefetch for offline) - `db/Converters.kt` — `List` + `List` ↔ JSON string via kotlinx.serialization - `db/Mappers.kt` — `ConversationSummary` ↔ `ConversationEntity`, `StoredMessage` ↔ `MessageEntity` **AGP 9.x compat:** `android.disallowKotlinSourceSets=false` in `gradle.properties` (KSP issue [#2729](https://github.com/google/ksp/issues/2729)). ## What's DONE and verified ### Backend — all complete, deployed, tested All backend work for the app is done. `pnpm test:run` green (774+), `tsc` + `lint` clean. - **`api_tokens` table** + SHA-256 hash storage (never raw). Migration `0024`. - **Bearer auth path** (`getTokenSession` / `requireActiveUserOrToken` in `lib/auth/guard.ts`). - **Token mint endpoint** `POST /api/v1/auth/token` — unauthenticated, Argon2id verify, anti-enumeration, rate-limited. - **`GET /api/v1/me`** — identity + `defaultModel` for native clients. - **`GET /api/v1/models`** — Bearer-authed, returns filtered model catalog. - **`/api/v1/conversations`** — thin re-exports of canonical handlers, Bearer-authed. - **`POST /api/v1/auth/unlock`** — Bearer-authed, returns unlock token in response body (not cookie) for native clients. Rate-limited 10/5min per user. - **`GET /api/v1/conversations/[id]`** — accepts `X-Unlock-Token` header alongside the existing `kaizen_unlock` cookie for locked conversations. - **`GET /api/v1/meta`** — capability handshake. - **App-devices card** (`components/settings/app-tokens-card.tsx`) — see/revoke signed-in devices on `/settings/security`. - **API versioning** — `/api/v1/` for outward contract, additive-only within v1. Web-internal endpoints stay unversioned. ### App — shipped features (on-device verified on S25 Ultra) All committed on `main`. Compile-verified on Mac (`./gradlew :app:compileDebugKotlin` clean). | Commit | Feature | |--------|---------| | `44c17b6` | In-app login (email+password → `POST /api/v1/auth/token`), `EncryptedSharedPreferences`, real streaming via `StreamConsumer.Incremental`, `ServerConfig` persistence | | `aa0a97b` | Dither overlay to kill 8-bit sRGB banding on orb + mesh gradients | | `d2f269f` | Model + speed picker (top pill → bottom sheet), response-speed segment, favorites, provider badges | | `ea5e625` | Sidebar wired to real conversations, persistence (create/save/load/auto-title), date-grouped history | | `7399c73` | Model sheet redesign with OpenRouter/Vertex badging, offline-first caching | | `d81c6ff` | Robust JSON parsing (malformed response handling) | | `827a2f3` | Streaming performance, error handling | | `423abc0` | **Specific error diagnostics** — `FetchResult` replaces null-returns; error banner shows exact cause (HTTP status, DNS, SSL, timeout, JSON parsing) instead of generic "Verbindung zum Server fehlgeschlagen" | | `4616ef2` | **Markdown rendering with syntax-highlighted code blocks** — native Compose renderer: headings, bold/italic, inline code, fenced code blocks with keyword highlighting (15+ languages), ordered/unordered lists, streaming cursor integration | | `d8dcb25` | **Attachment support — display, upload, and send files** — parse + render message attachments from loaded conversations (images inline via OkHttp + in-memory cache, other kinds as icon chips); + button wired to Android file picker with upload to `/api/v1/upload`, progress chips, send attachments with chat requests + persist in saved messages. Also: Markdown horizontal rules, blockquotes, links, code block copy button | | `9759d47` | **Offline-first cache layer (Room)** — sidebar + messages load instantly from SQLite cache, background refresh syncs with server, streaming hybrid model (in-memory during stream, Room flush after), ChatViewModel, repositories, mappers, TypeConverters, unit tests | | `5804b33` | **Gradient scrims on floating top/bottom bars** — content no longer bleeds through the model pill or mode pills | | `78f03ad` | **Mode pills cleanup** — removed muddy amber tint, solid surface fills, neutral borders | | `3560665` | **Mode pills multi-select** — modes are now independent toggles, not mutually exclusive | | `e4c6e7f` | **EmptyHero centered** — orb + greeting vertically centered between top/bottom bars | | `fe5e93c` | **Settings layout fix** — option rows stack title above controls instead of cramming side-by-side | | `9186594` | **Background prefetch** — all conversation messages prefetched for offline access on app start | | `d468ec8` | **Theme switching wired** — all 7 files migrated from hardcoded Amber to `LocalKaizenAccent.current` | | `cad445f` | **i18n cleanup** — replaced all remaining hardcoded German strings with `stringResource()` | | `5c54b9b` | **ToolEventsBlock rewrite** — state machine matching web frontend (thinking/analyzing/start/end/error) | | `da01b57` | **Attachment picker menu** — three floating GlassSurface pills (camera/gallery/file) with colored round icons, slide animation, dismiss-on-tap-outside. Positioned bottom-left above the + button. Camera uses `TakePicturePreview`, gallery filters `image/*`, file allows `*/*` | **Earlier UI/feel work (Phase 0 prototype → feel-first):** - Liquid glass styling, floating panels, glassmorphic sidebar - KaizenOrb with 3D sphere rendering, streaming animation - Mesh gradient background with dithered blobs - Phase-aware haptics (click → thinkingStart → responseStart → responseEnd) - Tablet/landscape optimization (width capping) - Dynamic keyboard handling (suggestion pills hide, spacer shrinks) - Settings screen with SettingsViewModel ### Bugs fixed (2026-06-19/20/21) - **"Verbindung zum Server fehlgeschlagen" on S25** (06-19) — server running old build without v1 routes. Fix: `git pull && docker compose up -d --build`. - **Server 500 on conversation load** (06-21) — Drizzle migration 0025 (`model_id` column) skipped due to rogue watermark entry (`created_at = 1781600000000`) in `drizzle.__drizzle_migrations` table, caused by hand-typed fake timestamps. Fix: lowered rogue entry's timestamp, re-ran `node scripts/migrate.mjs`. - **Deploy gotcha (migrations):** fake timestamps in journal entries 0017–0025 (60-second increments) can cause watermark conflicts. Future migrations via `pnpm db:generate` use real timestamps and won't hit this. ## What's NOT done yet (app, priority order) ### 1. Remaining visual/UX gaps (daily-drive blockers) These are what separate "prototype" from "daily-driver": - [ ] **Markdown rendering during streaming** — currently renders live but performance with long code blocks during high-speed streaming needs real-device verification - [x] **Code block copy button** — tap-to-copy on fenced code blocks (clipboard + haptic feedback) - [x] **Horizontal rule / blockquote rendering** — `---` and `> quote` not yet parsed - [ ] **Table rendering** — Markdown tables render as raw text - [x] **Link rendering** — styled visually, not yet clickable (tap-to-open pending) ### 2. Conversation management from the app - [ ] **Conversation rename/delete** from the sidebar (currently read-only) - [ ] **Conversation pin/unpin** from the sidebar - [x] **In-app unlock for locked conversations** — BiometricPrompt with CryptoObject (AES key in TEE/StrongBox), hardware-enforced biometric auth → server unlock token via `POST /api/v1/auth/unlock` → fetch messages with `X-Unlock-Token` header ### 3. Security hardening - [ ] **Auto-logout on 401** — if the token is rejected, route to login screen instead of showing error banner - [ ] **Server version check** — call `/api/v1/meta` after login to detect server skew and warn cleanly - [x] **Biometric lock** (`BiometricPrompt` + KeyStore/CryptoObject) — implemented for conversation unlock; `setUserAuthenticationRequired(true)` + `setInvalidatedByBiometricEnrollment(true)` ensures hardware-enforced biometric, not bypassable even on rooted devices - [ ] **`FLAG_SECURE`** on sensitive screens — see `LATER.md` §1B ### 4. Features for parity with the web - [x] **Attachments** — image/file upload, attachment preview in messages, display in loaded conversations, camera capture, attachment picker menu (camera/gallery/file) - [x] **Reasoning/thinking display** — `ReasoningBlock` (collapsible), parses `\x01` sentinel - [x] **Web search sources** — `SourcesBlock` + `SourceChip`, parses `\x02`/`\x04` sentinels - [x] **Tool call display** — `ToolEventsBlock`, parses `\x03` sentinel, status icons per step - [ ] **Native passkey login** (WebAuthn/FIDO2 on Android) - [ ] **WebSocket streaming** (replace chunked HTTP for lower latency on mobile) - [ ] **Usage/cost display** - [ ] **User context / KI-Profil** ### 5. App distribution - [ ] **Push notifications** (FCM) - [ ] **Play Store / F-Droid listing** - [ ] **Auto-update mechanism** ### 6. Offline-cache enhancements (future iterations, see `OFFLINE_CACHE_ADD.md` §11) - [ ] **Stale-check enforcement** — use `cachedAt` to trigger background refresh when older than N minutes - [ ] **Offline-queue for sends** — queue messages locally, send when network returns (needs conflict resolution) - [ ] **Incremental sync** — only fetch changed conversations (needs server-side `since` parameter) - [ ] **Media cache** — cache images/files locally (Coil or custom disk cache) - [ ] **Full-text search** — FTS4/FTS5 over cached messages (Room supports this natively) - [ ] **Attachment queries** — "show me all chats with images/PDFs" via Room DB ## Dependencies ```toml # gradle/libs.versions.toml (key versions) agp = "9.2.1" kotlin = "2.2.10" ksp = "2.2.10-2.0.2" room = "2.8.4" composeBom = "2026.02.01" okhttp = "4.12.0" kotlinxSerialization = "1.7.3" securityCrypto = "1.1.0-alpha06" ``` ## Tests **Unit tests** (`app/src/test/`): JVM-only, no emulator needed. - `SettingsViewModelTest` — state transitions, all 4 themes, appearance modes, name updates with store-less default - `StreamConsumerTest` — batch + incremental parser, sentinel stripping, reasoning/sources/tools parsing, char-by-char edge cases - `OklabTest` — round-trip conversion, interpolation midpoint, gradient generation (all green since Double-precision fix) - `MappersTest` — `ConversationSummary` ↔ `ConversationEntity`, `StoredMessage` ↔ `MessageEntity`, round-trip integrity, attachment preservation - `ConvertersTest` — `List` ↔ JSON round-trips, malformed JSON returns empty list, unknown fields tolerance via `ignoreUnknownKeys` - `ColorPipelineTest` — all tokens in DisplayP3, approximate value ranges, no pure black/white in neutrals - `SquircleShapeTest` — KaizenShapes token existence/distinctness, SquircleShape toString - `ThemeRegistryTest` — 4 themes present, forId lookup, fromString parsing + fallback, distinct blob colors - `ShadowStackTest` — layer counts per level, alpha range validation, blur/alpha progression ## Constraints & gotchas - Local dev Mac has **no RustFS** (storage on server only); upload/storage flows fail locally with `ECONNREFUSED :9000`. - Live OpenRouter key is encrypted in the DB (`api_keys`), `.env.local` is stale. - Never hand-write migrations or fake timestamps — use `pnpm db:generate`. Always `git pull` before `docker build` on prod. - App philosophy: the real driver is *feel*, not features; native Compose (no WebView). Backend complexity stays invisible. - **Deploy gotcha (learned 2026-06-19):** `git pull` BEFORE `docker compose up --build`, else the image bakes stale code and the app gets 404/401 against endpoints that exist in the repo but aren't in the running build. - Headless compile on Mac: `JAVA_HOME=/opt/homebrew/opt/openjdk@21/libexec/openjdk.jdk/Contents/Home ./gradlew :app:compileDebugKotlin` — JDK 21 via Homebrew, Android SDK at `/opt/homebrew/share/android-commandlinetools`. - **AGP 9.x + KSP:** requires `android.disallowKotlinSourceSets=false` in `gradle.properties`. - **Room is a cache:** `fallbackToDestructiveMigration()` means the SQLite DB is wiped on schema changes. No data loss — server is source of truth, cache rebuilds on next fetch.