kaizen-app/CLAUDE.md
Bruno Deanoz 2e323f3609 docs: update CLAUDE.md with all session changes
Added: gradient scrims, mode pills multi-select, EmptyHero centering,
settings layout fix, theme switching wiring, i18n cleanup, ToolEventsBlock
state machine, attachment picker menu, background prefetch, migration
watermark fix documentation.
2026-06-21 18:48:52 +02:00

25 KiB
Raw Blame History

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 <token> 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
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<String> 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 shadowsShadowStack with 24 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.selectedThemeMainActivityKaizenTheme(themeId=...). No server sync (web stores in localStorage, app stores locally). Appearance (Light/Dark/System) also wired end-to-end.
  6. Sensor-reactive motionrememberTiltState() 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 (W100W900). 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
  • toolsList<ToolEvent> (type, name, args, result, error, elapsed)
  • query — search query string
  • sourcesList<SearchSource> (title, url, snippet)

KaizenApi.chat() returns Flow<StreamState>. ChatScreen maps all fields to Message during streaming.

UI files: chat/StreamBlocks.ktReasoningBlock, 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.ktConversationEntity, MessageEntity
  • db/ConversationDao.ktobserveAll() (Flow), replaceAll() (transaction: deleteAll + upsertAll, avoids SQLite 999-variable limit), lastCachedAt(), allUnlockedIds()
  • db/MessageDao.ktobserveByConversation(), getByConversation(), upsertAll(), deleteByConversation(), cachedConversationIds()
  • db/KaizenDatabase.kt — singleton via companion object (double-checked locking), thread-safe
  • db/ConversationRepository.ktobserveAll() (Flow of ConversationSummary), refresh() (server → Room), allUnlockedIds()
  • db/MessageRepository.ktobserveMessages(), getCached(), fetchAndCache(), cacheMessages(), prefetchMissing() (background prefetch for offline)
  • db/Converters.ktList<Attachment> ↔ JSON string via kotlinx.serialization
  • db/Mappers.ktConversationSummaryConversationEntity, StoredMessageMessageEntity

AGP 9.x compat: android.disallowKotlinSourceSets=false in gradle.properties (KSP issue #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.
  • 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 diagnosticsFetchResult<T> 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)
0948931 Attachment picker menu — camera capture, gallery (image/), and file picker (/*) via dropdown

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 00170025 (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
  • Code block copy button — tap-to-copy on fenced code blocks (clipboard + haptic feedback)
  • Horizontal rule / blockquote rendering--- and > quote not yet parsed
  • Table rendering — Markdown tables render as raw text
  • 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
  • In-app unlock for locked conversations (currently skipped with "Gesperrter Chat" label)

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
  • Biometric lock (BiometricPrompt + KeyStore binding) — see LATER.md §1C
  • FLAG_SECURE on sensitive screens — see LATER.md §1B

4. Features for parity with the web

  • Attachments — image/file upload, attachment preview in messages, display in loaded conversations, camera capture, attachment picker menu (camera/gallery/file)
  • Reasoning/thinking displayReasoningBlock (collapsible), parses \x01 sentinel
  • Web search sourcesSourcesBlock + SourceChip, parses \x02/\x04 sentinels
  • Tool call displayToolEventsBlock, 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

# 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)
  • MappersTestConversationSummaryConversationEntity, StoredMessageMessageEntity, round-trip integrity, attachment preservation
  • ConvertersTestList<Attachment> ↔ 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.