# Kaizen Android — Visual Design > Native Android client for the Kaizen self-hosted AI chat platform. > This document defines the visual language: color pipeline, type, surfaces, motion, and the rationale behind every choice. Read it before you change anything visible. --- ## 0. TL;DR — the high-end stack If you skim nothing else, skim this. These are the six pillars that make Kaizen Android look like a 2026 product and not a 2019 Material app. | # | Pillar | Tech | | - | ----------------------- | ----------------------------------------------------------------------------------------------------- | | 1 | **Real glass** | `RenderEffect.createBlurEffect` (API 31+) on every floating surface. API 26–30 fallback: pre-blurred WebP. | | 2 | **Wide-gamut color** | `ACONFIGURATION_COLOR_MODE_WIDE_COLOR_GAMUT` window mode. All brand tokens declared in DisplayP3. | | 3 | **HDR highlights** | `COLOR_MODE_HDR` (API 34+) on capable displays (S25 Ultra, Pixel 8 Pro+). Orb specular peaks >1.0 nit. | | 4 | **Squircle geometry** | iOS-style superellipse (`n ≈ 4.5`) as a Compose `Shape`. Replaces every `RoundedCornerShape`. | | 5 | **Sensor-reactive** | Gyro/accelerometer drives orb specular direction + sidebar parallax. 60 Hz, gated by power state. | | 6 | **Variable typography** | Inter Variable as a single TTF (~330 kB). Optical sizing for the hero headline. | If any of these regress, the design regresses. Treat them like load-bearing walls. --- ## 1. Design intent Kaizen Android is the mobile companion to Kaizen Web — a self-hosted, multi-provider AI chat platform (`/Users/brunodeanoz/Kaizen/kaizen/CLAUDE.md`). The Android client is not a webview wrapper; it is a native Compose UI that **inherits the web's identity and elevates it** for a hardware-rich mobile target. ### 1.1 What we are aiming for - **Premium, calm, intentional.** The user opens the app to think with an AI, not to be sold to. No marketing copy, no badges, no "Pro" prompts. The empty state is a greeting, a question, and an input. - **Real liquid glass.** Surfaces actually refract what's behind them via `RenderEffect`. The blob layer is what they refract. - **Apple-Intelligence-style hero**, not Google-Assistant-style. The orb is the visual anchor. The orb *is* Kaizen at every scale (hero → 28dp avatar → 200dp call-mode). - **Brand color survives every theme switch.** Amber/Blau/Mono/Aurora are *user choices*, not wallpaper-derived. No `dynamicColor`. - **Conversation first.** Once messages exist, chrome recedes. ### 1.2 What we explicitly reject | Pattern | Why not | | ------------------------------------------------------ | --------------------------------------------------------------------------------------------- | | Gemini-style full-bleed pastel gradient + serif | Reads as a Google product. Steals identity. (See `ui-gemini.jpg`.) | | Claude-style centered serif greeting on near-white | Bland. No depth, no brand color. Input bar reads as a separate sheet. (See `ui-claude.jpg`.) | | Material You `dynamicColor = true` | Wallpaper-derived palettes erase the user's chosen Kaizen theme. We never enable it. | | Bottom navigation bar | Chat is the single primary surface. Drawer instead. | | Card-on-card "M3 expressive" layouts | One floating glass surface class. Let it breathe. | | Suggestion-chip rows in the mobile empty state | Redundant with the mode pills directly above the input. Eats vertical real estate. (Web keeps them; mobile drops them.) | | Lottie / framer-style scripted animations | Every motion is `AnimationSpec` + `Animatable`. Predictable, GPU-cheap, accessibility-aware. | | Pure black `#000` backgrounds, pure white `#FFFFFF` text | Both create hard edges that fight the glass aesthetic. Off-black and off-white only. | ### 1.3 Reference `ui-kaizen.jpg` (workspace root) is the canonical target for the empty state, with the modification that the four suggestion chips are **removed on mobile** (see §6.1). `ui-claude.jpg` and `ui-gemini.jpg` are *anti-references* — they show patterns we deliberately diverge from. --- ## 2. Color pipeline Kaizen ships in **DisplayP3** end-to-end, with optional **HDR extended-range highlights** on capable displays. This is the single biggest visual upgrade over a stock Material app and it costs almost nothing if you set it up correctly. ### 2.1 Why P3 (and why now) | Fact | Consequence | | --------------------------------------------------------------------------------------------- | -------------------------------------------------------- | | Every Pixel since the 4, every Samsung Galaxy S/Note since 2018, every iPhone since the 7 | Hardware supports the DisplayP3 gamut (≈25% wider than sRGB) | | Android opts apps **out** of P3 by default — `Window.colorMode` defaults to `COLOR_MODE_DEFAULT` (sRGB) | Without our opt-in, the OS color-manages our amber back down to sRGB and the saturation is gone | | Our brand color is `oklch(0.72 0.14 83)` — chroma `0.14` is **outside** sRGB at that hue | The web app already renders this in P3 via OKLCH. The Android app must match or it will look washed out next to the web | ### 2.2 Opt-in ```kotlin // MainActivity.onCreate(), before setContent {} window.colorMode = ActivityInfo.COLOR_MODE_WIDE_COLOR_GAMUT if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { // API 34+: enables extended-range (HDR) drawing on capable displays window.colorMode = ActivityInfo.COLOR_MODE_HDR } ``` Compose then draws into a P3-tagged surface. **All `Color(0xFF…)` constructors are interpreted as sRGB unless you say otherwise.** So we declare every brand color with an explicit color space: ```kotlin import androidx.compose.ui.graphics.colorspace.ColorSpaces val AmberP3 = Color(red = 0.878f, green = 0.635f, blue = 0.243f, colorSpace = ColorSpaces.DisplayP3) ``` Hex-literal Color constructors are banned everywhere except `Color.Transparent` and pure debug tooling. Tokens live in `ui/theme/Color.kt` and are all float-component P3 colors. ### 2.3 HDR extended-range highlights This is what makes the orb actually *glow* on your S25 Ultra (2,600 nits HDR peak) instead of just looking bright. ```kotlin // Brush for the orb's top-left specular val orbSpecular = Brush.radialGradient( colors = listOf( Color(red = 4f, green = 4f, blue = 4f, // >1.0 = extended range colorSpace = ColorSpaces.LinearExtendedSrgb), Color.Transparent, ), center = Offset(...), radius = ..., ) ``` Compose canvases on API 34+ with `colorMode = COLOR_MODE_HDR` honor color components above `1.0` and route them to the display's HDR layer. On non-HDR displays the values clamp to `1.0` gracefully — no visual regression, just no HDR pop. We use this in exactly two places: 1. **Hero orb specular** — top-left rim highlight, up to ~3.5× SDR white. 2. **Send button "armed" state** — when the user has typed text and the send icon turns full amber, the amber gains a faint HDR halo (peak ~1.8×). Subtle. The user notices it without naming it. We do **not** HDR-pop the headline, the model name, or anything text. HDR text is fatiguing and accessibility-hostile. ### 2.4 Color tokens Defined in `ui/theme/Color.kt`. Each token has a **P3 float** form (canonical) and an **sRGB hex** form (fallback documentation, used for the Studio preview and design handoff). #### Brand (constant across all themes — they ARE the brand) | Token | P3 float (R, G, B) | sRGB ≈ | Use | | ----------- | ---------------------------- | -------- | ------------------------------------------------ | | `Amber` | `(0.878, 0.635, 0.243)` | `#E0A23E` | Orb, send button, focus ring, primary in dark | | `AmberDeep` | `(0.784, 0.525, 0.165)` | `#C8862A` | Primary in light mode (better contrast on white) | | `OnAmber` | `(0.106, 0.071, 0.024)` | `#1B1206` | Foreground on amber fills | > The amber survives dark/light. The theme selector (§3) swaps the *accent* palette around it. #### Light scheme — neutrals | Role | Token | P3 float | sRGB ≈ | | ------------------ | --------------------- | ----------------------- | --------- | | `background` | `LightBackground` | `(0.965, 0.969, 0.976)` | `#F6F7F9` | | `surface` | `LightSurface` | `(1.000, 1.000, 1.000)` | `#FFFFFF` | | `surfaceVariant` | `LightSurfaceVariant` | `(0.933, 0.941, 0.953)` | `#EEF0F3` | | `onBackground` | `LightText` | `(0.102, 0.122, 0.161)` | `#1A1F29` | | `onSurfaceVariant` | `LightMuted` | `(0.357, 0.392, 0.439)` | `#5B6470` | | `outline` | `LightStroke` | `(0, 0, 0, α=0.08)` | — | #### Dark scheme — neutrals (Obsidian Steel-Blue) | Role | Token | P3 float | sRGB ≈ | | ------------------ | ------------------ | ----------------------- | --------- | | `background` | `Obsidian` | `(0.039, 0.055, 0.086)` | `#0A0E16` | | `surface` | `ObsidianElevated` | `(0.078, 0.106, 0.153)` | `#141B27` | | `surfaceVariant` | `ObsidianInput` | `(0.067, 0.094, 0.141)` | `#111824` | | `onBackground` | `DarkText` | `(0.910, 0.922, 0.945)` | `#E8EBF1` | | `onSurfaceVariant` | `DarkMuted` | `(0.545, 0.580, 0.643)` | `#8B94A4` | | `outline` | `DarkStroke` | `(1, 1, 1, α=0.09)` | — | #### Banned - `Color(0xFF000000)` as background. We use `Obsidian` (an off-black with blue cast). - `Color(0xFFFFFFFF)` as body text. We use `DarkText` (slightly cool, easier on the eye on OLED). - Any color literal without an explicit `colorSpace` argument outside `Color.kt` itself. ### 2.5 Color-space pitfalls These will bite you the first week. Document and lint for them. 1. **`@Composable` images don't auto-convert.** A JPEG with an embedded sRGB profile rendered into a P3 canvas will look fine, but a JPEG with no profile will be assumed sRGB and may shift. Tag user-uploaded images with their declared profile when displaying. 2. **`Brush.linearGradient` interpolates in the canvas color space.** Good for us (P3 interpolation gives smoother gradients than sRGB), but means a gradient `from sRGB-red to sRGB-blue` rendered in P3 will pass through a slightly different midpoint. Define gradient stops in P3 directly. 3. **Screenshot tests** must capture in P3 too, otherwise CI fails on real-device output. Use `Bitmap.Config.RGBA_F16` + `ColorSpace.get(ColorSpace.Named.DISPLAY_P3)`. 4. **The IDE preview is sRGB.** Studio's `@Preview` does NOT show P3. Don't trust the preview for color decisions — verify on device. --- ## 3. Theme system Kaizen Web ships four themes (`UiThemeId = "amber" | "blue" | "mono" | "aurora"`, see `kaizen/lib/theme/ui-themes.ts`). Android ships the **same four**, native, server-synced. ### 3.1 Structure Each theme is a Kotlin object implementing `KaizenTheme`: ```kotlin data class KaizenAccentScheme( val primary: Color, val primaryDeep: Color, // light-mode primary val onPrimary: Color, val ring: Color, val blob1: Color, val blob2: Color, val blob3: Color, val blob4: Color, // theme-specific accent (teal for amber, peach for blue…) ) interface KaizenTheme { val id: KaizenThemeId val label: String val light: KaizenAccentScheme val dark: KaizenAccentScheme } ``` Neutrals (`background`, `surface`, …) are theme-**independent** — same Obsidian / Light off-white across all four themes. Only the accent + the four background blob colors swap. This matches the web architecture (`globals.css` ships neutrals, `app/themes/.css` ships only the accent tokens). ### 3.2 The four themes (accent + blobs) | Theme | Primary (dark) | Primary (light) | Blob 1 | Blob 2 | Blob 3 | Blob 4 (accent) | | -------- | --------------------- | -------------------- | -------- | ------ | ------ | --------------- | | Amber | Amber `(0.878,0.635,0.243)` | AmberDeep `(0.784,0.525,0.165)` | Amber | Blue | Indigo | Teal | | Blau | Sky-500 P3 | Indigo-600 P3 | Blue | Indigo | Cyan | Amber (contrast)| | Mono | Slate-100 | Slate-800 | Slate-300| Slate-400 | Slate-200 | Warm gray | | Aurora | Magenta P3 | Violet-600 P3 | Magenta | Cyan | Violet | Lime (accent) | (Concrete P3 floats live in `ui/theme/themes/*.kt`. Each file ≤ 60 lines.) ### 3.3 Server sync 1. On launch, the app calls `GET /api/v1/me`. The response carries the user's `uiTheme` and `appearance` ("light" / "dark" / "system") — same values as the web reads from `localStorage.kaizen-ui-theme`. 2. Local storage: `EncryptedSharedPreferences` (`kaizen-ui-theme`, `kaizen-appearance`). This is the source of truth for offline launches. 3. When the user changes theme in the **app**, we PATCH `/api/v1/me`. The web app picks it up on next page load (via `next-intl` request cycle). 4. When the user changes theme in the **web**, the app picks it up on next foreground (re-fetch `/me` on `ON_RESUME`). ### 3.4 Crossfading themes A theme switch animates over 600 ms with `animateColorAsState(spec = tween(600, easing = EaseSmooth))` on every color token. The accent and blob colors crossfade simultaneously. The neutrals don't change so the layout stays anchored. This matches the web's behavior — `globals.css` registers `--blob-1..4` via `@property { syntax: ""; inherits: true }` so they're CSS-Houdini-interpolatable. Same effect, different runtime. > **Critical detail:** the `inherits: true` bug from the web (`CLAUDE.md` line 333) doesn't apply here — Compose state propagates directly. But the analogous trap: `remember { … }` of a color *outside* of `CompositionLocal` will not recompose on theme change. Always read theme colors via `MaterialTheme.colorScheme.*` or `LocalKaizenTheme.current.*`, never cache. --- ## 4. The Liquid-Glass surface This is the heart of the design. Everything floating — sidebar, input bar, model pill, suggestion chips, dropdowns, message input — is a `GlassSurface`. It is **real refractive blur**, not a translucent rectangle. ### 4.1 Anatomy ``` ┌──────────────────────────────────────────────┐ │ │ │ ░ Blurred capture of what's behind ░ │ ← RenderEffect.createBlurEffect background → │ ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ │ (24f, 24f, EDGE_CLAMP) blob layer │ │ │ ◤ Inset top highlight (1dp, white α 0.08) │ ← inner specular │ │ │ Content (icons / text / chip label) │ ← drawn over the blur │ │ │ ◣ Multi-layer drop shadow │ ← see §6 (Shadow stack) └──────────────────────────────────────────────┘ ↑ 1 dp hairline (outline @ 60%) — final perimeter ``` ### 4.2 Implementation ```kotlin @Composable fun GlassSurface( modifier: Modifier = Modifier, shape: Shape = KaizenShapes.medium, tint: Color = MaterialTheme.colorScheme.surface, tintAlpha: Float = 0.72f, // see opacity tiers, §4.4 blurRadius: Dp = 24.dp, content: @Composable BoxScope.() -> Unit, ) { val density = LocalDensity.current val supportsBlur = Build.VERSION.SDK_INT >= Build.VERSION_CODES.S Box( modifier .clip(shape) .then( if (supportsBlur) Modifier.graphicsLayer { renderEffect = RenderEffect.createBlurEffect( with(density) { blurRadius.toPx() }, with(density) { blurRadius.toPx() }, Shader.TileMode.CLAMP, ).asComposeRenderEffect() } else Modifier // API 26–30 falls back to pre-blurred WebP (see §4.6) ) .drawBehind { // The "frost" tint that gives the glass body drawRect(tint.copy(alpha = tintAlpha)) } .innerTopHighlight() // 1dp inset white α=0.08 .border(1.dp, MaterialTheme.colorScheme.outline.copy(alpha = 0.6f), shape) .squircleShadow(level = 2), // multi-layer shadow, see §6 content = content, ) } ``` **Three things to internalize:** 1. `graphicsLayer { renderEffect = … }` is the GPU primitive. It rasterizes the underlying composition into a layer, blurs that layer, and composites it back. Cost is proportional to the layer's pixel area, not the content complexity. Sidebar (~220 × 800 dp on phone) ≈ 0.6 ms per frame on a Snapdragon 8 Gen 3. Acceptable budget. 2. The `tint` is what gives the surface its color identity (Obsidian vs white). Without it the blur would just show a wash of whatever's behind, with no surface identity. Tint alpha controls how "milky" the glass is (see §4.4). 3. The blur is **not** a backdrop blur in the iOS sense — Android does not have a `Modifier.backdropBlur`. It blurs *the surface itself*, but because the surface is mostly transparent (`tintAlpha 0.72`), the blur of the content *behind* shows through. Same visual effect, different code path. The trick is that the content behind already drew before us (correct paint order). ### 4.3 The capture layer For the blur to capture the blob layer behind, the blob layer **must paint into the same compositing context** that our blurred layer reads from. This means: - The blob `Canvas` composable is the first child of the root `Box`. - It must NOT have `.graphicsLayer()` itself (no double-buffering — defeats the read-back). - The glass surface is a sibling later in the same `Box`. Verified pattern in `chat/Background.kt`. If you ever wrap the blob layer in its own `GraphicsLayer` for animation perf, the glass surfaces will go gray. Don't. ### 4.4 Opacity tiers (mirror of web `globals.css`) | Component | Tint alpha (light) | Tint alpha (dark) | Blur radius | Notes | | ----------------------- | ------------------ | ----------------- | ----------- | ---------------------------------------------------- | | Sidebar | 0.68 | 0.72 | 28 dp | Floats. Most translucent. The "frosted" reference. | | Input bar | 0.74 | 0.75 | 24 dp | Slightly more body — reads as a real input surface. | | Mode pill, model pill | 0.82 | 0.80 | 20 dp | Small surfaces need more body to read. | | Suggestion chip (hero) | 0.90 | 0.85 | 16 dp | Tap targets. Must look solid. | | Settings card | 0.95 | 0.94 | 16 dp | Reading surface. Near-opaque. | | User bubble (amber-tinted) | 0.88 | 0.86 | 12 dp | Tinted glass via `tint = primary` (not surface). | | Assistant bubble | — (no surface) | — (no surface) | — | Text on background. No card. | | Dropdown / sheet | 0.85 | 0.82 | 32 dp | The deepest blur — pops the menu off the surface. | > These numbers are derived empirically on a Pixel 8 Pro and a Galaxy S25 Ultra. Re-tune if you ship to e-paper or low-bit-depth panels (none planned). ### 4.5 Chromatic-aberration rim (`refraction = true`) For premium surfaces (hero orb, send button armed state, model pill on hover-equivalent), we add a one-pixel chromatic aberration at the rim. This is what makes glass look like *real* glass — light passing through a curved edge separates into R/G/B by tiny amounts. ```kotlin Modifier.drawWithCache { val ringWidth = 1.dp.toPx() onDrawWithContent { drawContent() // Three single-pixel offset strokes in pure R/G/B at the perimeter drawOutline(outline, color = Color.Red.copy(alpha = 0.10f), style = Stroke(ringWidth), translateX = -0.5f) drawOutline(outline, color = Color.Green.copy(alpha = 0.06f), style = Stroke(ringWidth)) drawOutline(outline, color = Color.Blue.copy(alpha = 0.10f), style = Stroke(ringWidth), translateX = +0.5f) } } ``` Used sparingly — on every glass surface this is noise. Only the orb and the armed send button. Toggleable via `KaizenTheme.LocalRefraction.current`. ### 4.6 API 26–30 fallback `RenderEffect.createBlurEffect` requires API 31+. `minSdk` is 26. Fallback strategy: 1. At build time, generate **pre-blurred snapshots** of the blob layer for each theme × mode (8 PNGs total, ~150 kB each at 540 × 1200, WebP at quality 85). These live in `res/drawable/blob_baked__.webp`. 2. On API 26–30, `Background.kt` renders the static WebP instead of the animated canvas (no drift animation — the static look is "old phone, less polish, still good"). 3. `GlassSurface` on those APIs skips `RenderEffect` and uses a higher tint alpha (+0.10 across all tiers) plus a slightly larger inner highlight to fake depth. Penalty: no animated blobs, no real blur. Acceptable for ~5% of installs (Android distribution stats Jun 2026: API 31+ ≈ 92%). Do not invest in a `RenderScript` blur fallback — `RenderScript` is deprecated and gone in API 35. --- ## 5. Shape — squircles, not rounded rectangles The default `RoundedCornerShape` is a rectangle with quarter-circle corners. iOS, macOS, and modern web design (Tailwind 4 `rounded-squircle`) use the **continuous superellipse** — corners that ease into the straight edges with G2 continuity (curvature also matches, not just position). The difference is small at 4 dp radii. At 16+ dp it's the difference between "round rectangle" and "iPhone icon." ### 5.1 The `KaizenSquircleShape` ```kotlin class SquircleShape( private val radius: Dp, private val smoothness: Float = 4.5f, // n in the superellipse equation; 2 = circle, ∞ = rectangle ) : Shape { override fun createOutline(size: Size, layoutDirection: LayoutDirection, density: Density): Outline { val r = with(density) { radius.toPx() }.coerceAtMost(min(size.width, size.height) / 2f) val path = Path().apply { // Sample 32 points per corner along (x/r)^n + (y/r)^n = 1 // Detailed implementation in ui/shape/Squircle.kt … } return Outline.Generic(path) } } ``` ### 5.2 Shape tokens (replace `Shapes`) | Token | Radius | Smoothness | Used for | | -------------------- | ------ | ---------- | ----------------------------------------------------- | | `KaizenShapes.xs` | 8 dp | 4.0 | Inline chips inside chips | | `KaizenShapes.sm` | 12 dp | 4.2 | Dropdown items, inline action buttons | | `KaizenShapes.md` | 16 dp | 4.5 | Sidebar surface, settings groups | | `KaizenShapes.lg` | 24 dp | 5.0 | Hero suggestion chips, message bubbles | | `KaizenShapes.xl` | 28 dp | 5.0 | Input bar, model pill, mode pills | | `KaizenShapes.pill` | 999 dp | 4.5 | Send button, "Standard" badge | | `KaizenShapes.circle`| — | — | Avatars, FAB, orb-bounding box | > Smoothness `n` from `4.0` to `5.0` is the sweet spot. Below `3.0` it's a rounded square. Above `7.0` it's visually indistinguishable from a rectangle. ### 5.3 Liquid morph on press When the user presses any squircle (button, chip, pill), the shape briefly *liquefies* — radius increases by ~20% and smoothness drops to ~3.5 over 80 ms with `spring(dampingRatio = 0.55f)`. Released → returns to base. Same idea as iOS 26's liquid buttons. Implementation: animate `radius` and `smoothness` as `Animatable`, recompose the shape. Cost: shape recreation is allocation-heavy if done naively. Pool shapes by `(radius_bucket, smoothness_bucket)` with 0.5 dp / 0.1 buckets. ~30 shape instances total in steady state. --- ## 6. Shadow stack Single drop-shadow looks 2014. Premium UI uses **layered shadows** (also called "long shadow" in design tools) — three to five overlapping shadows at increasing blur radii and decreasing opacity, summing to a soft, realistic falloff. ### 6.1 Token ```kotlin data class ShadowLayer(val offsetY: Dp, val blur: Dp, val spread: Dp, val alpha: Float) data class ShadowStack(val layers: List) object KaizenShadows { val level0 = ShadowStack(emptyList()) val level1 = ShadowStack(listOf( ShadowLayer(1.dp, 2.dp, 0.dp, 0.04f), ShadowLayer(1.dp, 4.dp, 0.dp, 0.02f), )) val level2 = ShadowStack(listOf( ShadowLayer(2.dp, 4.dp, 0.dp, 0.05f), ShadowLayer(4.dp, 12.dp, 0.dp, 0.04f), ShadowLayer(8.dp, 24.dp, 0.dp, 0.03f), )) val level3 = ShadowStack(listOf( ShadowLayer(2.dp, 4.dp, 0.dp, 0.06f), ShadowLayer(8.dp, 16.dp, 0.dp, 0.05f), ShadowLayer(16.dp,32.dp, 0.dp, 0.04f), ShadowLayer(24.dp,48.dp, 0.dp, 0.03f), )) val level4 = ShadowStack(listOf( // dialogs, call-mode overlay ShadowLayer(4.dp, 8.dp, 0.dp, 0.07f), ShadowLayer(12.dp,24.dp, 0.dp, 0.06f), ShadowLayer(24.dp,48.dp, 0.dp, 0.05f), ShadowLayer(48.dp,96.dp, 0.dp, 0.04f), )) } ``` Dark mode multiplies all alphas by ~1.8 — shadows need to be darker on dark surfaces to read at all. ### 6.2 Implementation `Modifier.squircleShadow(level: ShadowStack)` draws each layer in order, before the content, using `Canvas.drawRoundRect` with a blurred paint. Five drawcalls per surface at level 3 — cheap. Compose's built-in `Modifier.shadow()` does NOT support custom shapes or stacks. We implement our own. Found in `ui/effect/Shadow.kt`. ### 6.3 Inner-shadow / inset top highlight The companion to the drop shadow. Sells "light from above": ```kotlin Modifier.drawWithContent { drawContent() // 1dp inset top highlight, blurred 1dp val highlightColor = if (isDarkTheme) Color.White.copy(alpha = 0.08f) else Color.White.copy(alpha = 0.95f) drawLine(highlightColor, Offset(0f, 1f), Offset(size.width, 1f), strokeWidth = 1.dp.toPx()) } ``` Applied via `Modifier.innerTopHighlight()`. Together with the drop shadow, this is what makes glass look *lit* instead of *floating*. --- ## 7. Sensor-reactive motion This is the single most "wow"-y interaction in the app. Tilt your phone slightly and the orb's specular highlight tracks your viewing angle as if it were a real glass sphere. The blob layer drifts a parallactically different amount than the foreground. Done right, the user notices but cannot name it — it just feels *alive*. ### 7.1 The signal ```kotlin @Composable fun rememberTiltState(): State { val sensorManager = … val rotationVector = sensorManager.getDefaultSensor(Sensor.TYPE_GAME_ROTATION_VECTOR) val tilt = remember { mutableStateOf(Offset.Zero) } DisposableEffect(rotationVector) { val listener = object : SensorEventListener { override fun onSensorChanged(event: SensorEvent) { // Convert quaternion to pitch+roll, low-pass filter (α = 0.18) // Clamp to ±15° → normalize to Offset(-1f..1f, -1f..1f) tilt.value = filtered } … } sensorManager.registerListener(listener, rotationVector, SensorManager.SENSOR_DELAY_GAME) // 50Hz onDispose { sensorManager.unregisterListener(listener) } } return tilt } ``` Uses **`TYPE_GAME_ROTATION_VECTOR`**, not the bare accelerometer. Game rotation vector is fused (gyro + accel + magnetometer) and pre-stabilized — gives stable 50 Hz delta with no drift correction needed. Lower power than full rotation vector. ### 7.2 What reacts to tilt | Element | Effect | | -------------------------- | ------------------------------------------------------------------------ | | **Hero orb specular** | Top-left highlight moves opposite to tilt (`offset = -tilt * 8.dp`). Range ~±12 dp. | | **Send button (armed)** | Subtle. `offset = -tilt * 2.dp`. Just enough to feel responsive. | | **Blob layer** | Translates `tilt * 32.dp` (slower than foreground = parallax). | | **Mesh-gradient highlight**| Subtly shifts color stops along tilt vector. ~3° hue rotation max. | | **Conversation list rows** | NOT tilt-reactive. Lists must stay still for legibility. | | **Message text** | NOT tilt-reactive. Same reason. | ### 7.3 When to disable - `Settings.Global.ANIMATOR_DURATION_SCALE == 0f` (system "Remove animations") - In-app "Reduce motion" setting (mirrors web `appearance.reduceMotion` once implemented) - `PowerManager.isPowerSaveMode == true` — battery saver disables tilt - Device tilted past ±25° (user is probably lying down or holding sideways — keeps the orb from snapping to a corner) - `LifecycleOwner.lifecycle.currentState != STARTED` — never listen in background ### 7.4 Frame-rate budget 50 Hz sensor → state update → recompose only on `Modifier.graphicsLayer { translationX = … }` (which doesn't invalidate measure/layout). Total recomposition cost ≈ 0.2 ms per tilt event. Effectively free. --- ## 8. Typography — Inter Variable System fonts are good. Inter is **better** at the sizes we use (16–32 sp body, 32–48 sp hero), and as a Variable Font it costs roughly the same as a single static weight. ### 8.1 Why Inter - **Designed for screens.** Tall x-height, open apertures, large counters — readable at small sizes on low-DPI fallback devices. - **Variable axes:** `wght` (100–900) and `opsz` (14–32) plus subtle `slnt`. We use `wght` everywhere and `opsz` on the hero headline. - **Open license** (SIL OFL). Ship freely. - **Matches the web app's intent** — the web defaults to system, but switching it to Inter has been on the backlog. The app can lead. ### 8.2 Shipping - One file: `res/font/inter_variable.ttf` (~330 kB compressed in APK). - Declared as a `FontFamily` with variation settings: ```kotlin val InterVariable = FontFamily( Font(R.font.inter_variable, weight = FontWeight.W400), // Compose 1.7+ supports variation settings; pre-1.7 falls back to weight axis ) ``` - On API 26+ all Variable Font axes are supported by Android's font renderer. No fallback needed. ### 8.3 Type scale | Role | Size | Weight (`wght`) | Optical (`opsz`) | Line height | Tracking | Used for | | --------------- | ----- | --------------- | ---------------- | ----------- | --------- | ------------------------------------- | | `displayLarge` | 32 sp | 640 | 32 | 38 sp | −1.0% | Hero headline ("Was liegt an?") | | `displayMedium` | 24 sp | 600 | 24 | 30 sp | −0.5% | Settings section titles | | `titleLarge` | 20 sp | 560 | 20 | 26 sp | 0 | Card titles | | `titleMedium` | 16 sp | 540 | 16 | 22 sp | +0.3% | Model name, sidebar items | | `bodyLarge` | 16 sp | 420 | 16 | 24 sp | +0.5% | Chat message body | | `bodyMedium` | 14 sp | 420 | 14 | 20 sp | +0.8% | Secondary text, settings descriptions | | `labelLarge` | 14 sp | 540 | 14 | 20 sp | +1.0% | Buttons, chip labels | | `labelMedium` | 12 sp | 540 | 14 | 16 sp | +2.0% | Pills ("Standard") | | `labelSmall` | 11 sp | 520 | 14 | 16 sp | +3.0% | Timestamps, date subtitle | | `code` | 14 sp | 460 (mono) | — | 22 sp | 0 | Code blocks (JetBrains Mono Variable) | Two principles: 1. **Weight tracks size.** At small sizes Inter needs slightly heavier weight (520–560) to stay crisp. Hero size uses `640` not `700` — `700` is too display-y at 32 sp on glass. 2. **Tracking is positive at small sizes, negative at large.** Inter ships with default tracking that's optimized for ~16 sp body. We loosen below 14 sp and tighten above 24 sp. ### 8.4 Optical sizing (`opsz`) The big trick. At display sizes (24–32 sp) we set `opsz = 24–32`, which makes Inter pick its "Display" outlines — slightly narrower, tighter, more elegant. At body sizes (14–16 sp) we set `opsz = 14–16` → "Text" outlines: wider, more open, more legible. This is automatic if you pass `FontVariation.Setting("opsz", size.toFloat())` to the `Font(...)` definition. Compose 1.7+ supports this directly. ### 8.5 Mono for code JetBrains Mono Variable (~280 kB) for inline `code` and code blocks. Tabular figures, ligatures off (programmer aesthetics divide on this — off is the conservative default and we can flip per-user later). --- ## 9. The Kaizen Orb (premium tier) The orb is the brand mark, the empty-state hero, the assistant avatar, and the call-mode focal point. It is the single most-engineered visual in the app. Here is the full premium recipe. ### 9.1 Sizes & use | Size | Where | Premium features active | | --------- | -------------------------------------- | --------------------------------------------- | | 96–160 dp | Hero (`EmptyHero`) | All: HDR specular, tilt, breath, refraction | | 28 dp | Assistant avatar in message list | Static recipe (no real blur — too small) | | 28 dp + streaming | Avatar while answer streams | Fast breath + triple-shadow glow | | 200 dp | Call-mode overlay | All + amplitude-reactive scale (mic input) | ### 9.2 The seven layers (hero) Top to bottom (paint order): 1. **Drop-shadow halo** — `ShadowStack` level 3 + an additional `primary @ 30%` blur at 40 dp. Sells "this thing is hovering." 2. **Outer wrapper** — owns the `breath` animation (1.0 → 1.025 → 1.0, 7 s sinusoidal). On streaming: faster (1.8 s, scale 1.0 → 1.12). 3. **Outer rim — chromatic aberration** — §4.5 recipe. Three 1px R/G/B strokes. 4. **Inner sphere — refractive blur** — `RenderEffect.createBlurEffect(28f, 28f, CLAMP)` reading the blob layer. THIS is what makes the orb a glass marble rather than a painted ball. The blob colors visibly shift as the orb moves (or the user tilts the device). 5. **Internal sweep gradient** — `Brush.sweepGradient(0° = primary @ 60%, 90° = primary-deep @ 80%, 180° = primary @ 60%, 270° = white @ 30%)`. Rotates with `tilt.x * 30°` — gives the orb internal chromatic depth that responds to viewing angle. 6. **Specular highlight (top-left)** — radial gradient, `LinearExtendedSrgb(4, 4, 4) → transparent`, ~30% of orb radius, center offset by `-tilt * 8.dp`. **This is the HDR pop.** On a 2,600-nit panel this is what makes the user say "huh, why is that brighter than the rest of my screen." 7. **Bottom inner glow** — radial gradient, `primary @ 40% → transparent`, ~60% of orb radius, anchored to bottom-center. Suggests internal light source. ### 9.3 Streaming variant When `isStreaming == true`, the 28 dp avatar swaps state. Visually critical that the user can tell "Kaizen is thinking" without reading text: - Breath → `hero-orb-breath-fast` (1.8 s, scale 1.0 → 1.12) - Triple-stacked shadow: `primary @ 30/20/10%` at blur `8/16/24 dp` - The internal sweep gradient rotates continuously at 90°/s (only in streaming state — static otherwise) When streaming ends, the orb returns to the calm breath over 400 ms with `spring(dampingRatio = 0.6f)`. ### 9.4 Call mode (200 dp) In `CallView`, the orb is the entire UI. Three additional behaviors: - **Amplitude scaling.** Mic input RMS over the last 100 ms drives `scale = 1.0 + amplitude * 0.3` (capped at 1.3). When the user speaks, the orb breathes with their voice. - **Color temperature shift.** While the TTS plays back (assistant speaking), the orb shifts ~10° towards warm. While the user speaks, ~10° towards cool. Subliminal: warm = listening, cool = speaking. Same idea as Siri's modes but quieter. - **Pulse on tool-call.** When the assistant fires a tool (web search, etc.), a one-shot pulse — single scale 1.0 → 1.15 → 1.0 over 250 ms. Tells the user "something happened" without text. --- ## 10. Animation system Every motion in the app uses the same easing/spec primitives. Defined in `ui/motion/Motion.kt`. ### 10.1 Easing tokens ```kotlin val EaseSpring = CubicBezierEasing(0.34f, 1.56f, 0.64f, 1f) // overshoot, for entrances val EaseSmooth = CubicBezierEasing(0.22f, 1f, 0.36f, 1f) // expo-out, for transitions val EaseStandard = FastOutSlowInEasing // system-aligned val EaseEmphasized = CubicBezierEasing(0.05f, 0.7f, 0.1f, 1f) // M3 emphasized val SpringSnappy = spring(dampingRatio = 0.55f, stiffness = 800f) val SpringDefault = spring(dampingRatio = 0.75f, stiffness = 400f) val SpringGentle = spring(dampingRatio = 0.85f, stiffness = 200f) ``` ### 10.2 Spec assignments | Use case | Spec | | ------------------------- | ------------------------------------------------------------ | | Bubble entrance | `tween(350ms, easing = EaseSpring)` | | Page transition (slide+fade) | `tween(280ms, easing = EaseEmphasized)` | | Dropdown open | `tween(150ms, easing = EaseSmooth)` | | Sidebar slide | `spring(dampingRatio = 0.85f, stiffness = 300f)` | | Theme color crossfade | `tween(600ms, easing = EaseSmooth)` | | Orb breath | `infiniteRepeatable(tween(7000ms, EaseStandard), reverseDirection)` | | Orb breath (streaming) | `infiniteRepeatable(tween(1800ms, EaseStandard), reverseDirection)` | | Squircle press | `SpringSnappy` | | Tilt response (sensor) | None — direct binding. Sensor's own low-pass handles smoothing. | | Thinking dots | `infiniteRepeatable(tween(1000ms, LinearEasing))` × 3 with 160 ms phase offsets | ### 10.3 Shared-element transitions Compose 1.7+ supports `SharedTransitionScope` with `Modifier.sharedElement(...)`. We use it for two transitions: 1. **Conversation list row → chat screen.** The conversation title in the sidebar shared-element-transitions into the header. Feels like "tapping into" the chat. 2. **Empty-state orb → first assistant avatar.** When the user sends their first message, the hero orb shrinks from 120 dp to 28 dp and docks left of the first assistant bubble. 600 ms with `EaseEmphasized`. Conceptually: "the thing you were looking at is now answering you." This is the single most magical moment in the app. ### 10.4 Haptics (`haptics/Haptics.kt`) Sparingly. Overuse desensitizes. | Trigger | Effect | | -------------------------------- | -------------------------------------------------------- | | Send message | `HapticFeedbackConstants.CONFIRM` (API 30+) / `VIRTUAL_KEY` | | Long-press conversation row | `LONG_PRESS` | | Toggle mode pill | `SEGMENT_TICK` (API 30+) / `CLOCK_TICK` | | Theme switch | `GESTURE_END` once when the crossfade completes | | Stream complete (>5s wait) | `GESTURE_END` | | Error | `REJECT` (API 30+) / double `VIRTUAL_KEY` | --- ## 11. Screen-by-screen ### 11.1 Empty state / new chat (`EmptyHero`) — mobile **No suggestion chips.** Different from `ui-kaizen.jpg`. The chips are redundant with the mode pills above the input (same function: "set mode before typing"). On a phone with a keyboard open, the chip row eats 20% of the visible height for no gain. Web keeps them (different ergonomics, hover, more room). Mobile drops them. ``` ┌─────────────────────────────────────────┐ │ ☰ ⌃ Gemini 3.5 Flash ▾ │ │ │ │ │ │ │ │ ●●●●●●●● │ │ ● ● │ │ ● ORB ● (120 dp) │ │ ● ● │ │ ●●●●●●●● │ │ │ │ Guten Tag, Bruno │ │ │ │ Was liegt an? │ │ │ │ So., 21. Juni · 13:34 │ │ │ │ │ │ │ ← intentional whitespace │ │ │ │ │ │ │ ┌─────────────────────────────────────┐ │ │ │ ⊕ Standard Sampling Bild Suchen │ │ ← Mode pills (horizontal scroll) │ └─────────────────────────────────────┘ │ │ ┌─────────────────────────────────────┐ │ │ │ ⊕ Nachricht eingeben … ☎ ↑ │ │ ← Input bar │ └─────────────────────────────────────┘ │ └─────────────────────────────────────────┘ ``` **Layout details** - Vertical alignment: `Top` with `paddingTop = max(32.dp, 8% screenHeight)`. Not `Center`. Centered drifts up when the keyboard opens. - Greeting: time-of-day-based (Morgen / Tag / Abend, boundaries 5/12/18). First name from `/api/v1/me`. Falls back to bare "Guten Tag" if name missing. - Subtitle: localized short-date + time, live every minute, `tabular-nums`. - All three sections (orb / greeting / subtitle) animate in staggered: `fadeIn + slideInVertically` with delays 0 / 100 / 200 ms. Total entrance time 700 ms. ### 11.2 Active conversation (`ChatScreen`) ``` ┌─────────────────────────────────────────┐ │ ☰ Gemini 3.5 Flash ▾ ⋯ │ ├─────────────────────────────────────────┤ │ │ │ ╭─────────────────────╮│ │ │ Was ist Kaizen? ││ ← User bubble (amber-tinted glass) │ ╰─────────────────────╯│ │ │ │ ◉ Kaizen ist eine japanische │ ← Assistant: orb + plain markdown │ Philosophie der kontinu- │ no surface around the text │ ierlichen Verbesserung … │ │ │ │ ▸ Gedankengang (3s) │ ← Collapsible ThinkingBlock │ │ │ ╭─────────────────────╮│ │ │ Erzähl mir mehr ││ │ ╰─────────────────────╯│ │ │ │ ◉ ● ● ● │ ← Streaming dots before first token │ │ ├─────────────────────────────────────────┤ │ [mode pills row] │ │ ⊕ Erzähl mir mehr … ☎ ▣ │ ← Stop while streaming └─────────────────────────────────────────┘ ``` - Autoscroll only if user is within 80 dp of the bottom. Otherwise show a "↓ Neue Nachricht" pill bottom-right. - Bubble entrance: `bubble-in` (translateY 8 dp + scale 0.98, 350 ms spring overshoot). Stable `key={node.id}` ensures it fires once per insert. - ThinkingBlock auto-opens during streaming, auto-collapses 300 ms after completion. Tappable to re-open. ### 11.3 Sidebar (`Sidebar.kt`) `ModalNavigationDrawer` on phones, permanent rail on ≥600 dp width. ``` ┌──────────────────────┐ │ ⌕ Suchen │ ├──────────────────────┤ │ + Neuer Chat │ ├──────────────────────┤ │ Heute │ │ ▸ Was ist Kaizen? │ ← active row: primary @ 8% bg tint │ Python sorting │ │ UI Design … │ │ │ │ Gestern │ │ Docker setup │ │ ──── │ │ ▸ Älter │ ├──────────────────────┤ │ 👤 Bruno ⋯ │ ← UserCard: tap → settings menu └──────────────────────┘ ``` - Whole sidebar is one `GlassSurface` tier 1 (most translucent). 8 dp margin all sides — "floating drawer" not "edge-attached panel." - Long-press → inline action sheet (Rename / Lock / Delete). 250 ms hold, haptic on commit. - Search is in-memory against the offline Room cache. Never hits the server. ### 11.4 Settings (`SettingsScreen`) Sectioned, single-screen-per-section, navigation via the sidebar (replaces conversation list when route is `settings/*`). Each section is a stack of `SettingsCard`s (opacity tier 6, near-opaque) with `Field` rows. Mirrors the web `components/settings/section-ui.tsx` Card + Field primitives. --- ## 12. Component library ### 12.1 Buttons | Variant | Composable | When to use | | --------------- | ----------------------- | ------------------------------------------------- | | Primary (FAB) | `KaizenSendButton` | Send message (amber pill-circle, 44 dp) | | Primary (text) | `KaizenButton` | "Speichern", "Anmelden" — full-width in dialogs | | Secondary | `KaizenOutlinedButton` | "Abbrechen", "Verwerfen" | | Tertiary | `KaizenTextButton` | Inline "Mehr anzeigen", "Bearbeiten" | | Icon | `KaizenIconButton` | Toolbar icons, chip close X | | Pill | `ModePill`, `OptionPill`| Mode selector, model selector, image options | All buttons: - Min tap 44 dp. - Pressed: `scale 0.94` via `SpringSnappy`. No M3 ripple over glass — the scale + opacity tweak is the feedback. - Disabled: `alpha = 0.4`, no scale, no haptic. ### 12.2 Inputs - `ChatInputBar` — Material 3 `TextField` with custom decoration. `GlassSurface` container, no underline, multi-line up to 6 rows. - `KaizenTextField` — Settings dialog text inputs. Outlined Material 3 with brand-overridden colors. - Placeholder color: `onSurfaceVariant @ 60%` (M3 default is too prominent on glass). ### 12.3 Chips - `SuggestionChip` (hero) — `GlassSurface` tier 4, icon left, label right, `KaizenShapes.lg`, 14 sp label. - `AttachmentChip` (input bar) — file-type icon + filename + close X, `KaizenShapes.sm`. - `QuoteChip` (input bar) — quote icon + `line-clamp-3` text + close X, displayed above attachments. From "Ask about selection." ### 12.4 Lists - Conversation row: 48 dp height, 16 dp horizontal padding, label + optional lock badge. - Settings field row: 56 dp height. - Long-press: 250 ms hold → `LONG_PRESS` haptic → contextual action sheet. ### 12.5 Dropdowns / sheets Portal-style (`Popup`) with the entrance pattern: - `fadeIn(150ms) + scaleIn(0.95, anchor) + slideInVertically(initialOffsetY = { it / 8 })` - Matches web `animate-in fade-in zoom-in-95 slide-in-from-top-2`. Bottom sheets on phones use `ModalBottomSheet` with `GlassSurface` as the container shape. ### 12.6 Snackbar (`KaizenSnackbar`) No native `Toast` (looks wrong on glass). `GlassSurface` tier 3, bottom-anchored, 4 s auto-dismiss, manual close. Same recipe as the web app's `createPortal` toast in `users-card.tsx`. --- ## 13. Dark mode Dark mode is not "recolor light mode." It is a **different spatial system**. ### 13.1 What changes - Background luminance: L=0.96 → L=0.10. - Blob `BlendMode`: normal → `BlendMode.Plus` (additive — makes blobs glow on dark). - Glass top highlight inset: white α 0.95 → white α 0.08 (stronger on dark to fake light). - Shadow alpha: ×1.8 (shadows need to be darker on dark surfaces to read). - Code-block highlight theme: `github` → `github-dark-dimmed`. - Brand amber: `AmberDeep` → `Amber` (pure amber has more weight on Obsidian). ### 13.2 What does NOT change - Orb recipe (a sphere is a sphere — only the background reflection differs). - Theme identity (Amber stays Amber). - Shapes, sizes, line heights, tracking. - Layout — never reflow on mode change. ### 13.3 Switching System default by default. User override in Settings → Erscheinungsbild (mirrors web `appearance.design`). Crossfade 600 ms with `tween(EaseSmooth)` on every color. Status bar + nav bar adopt the new appearance via `WindowCompat.getInsetsController(...).isAppearanceLightStatusBars`. --- ## 14. Accessibility | Requirement | How we meet it | | ---------------------------------------- | ---------------------------------------------------------------- | | Touch target ≥ 44 dp | All interactive components. | | Contrast ≥ 4.5:1 body text | `LightText` on `LightBackground` = 13.2:1; `DarkText` on `Obsidian` = 14.8:1. | | Contrast ≥ 3:1 large text | Hero headline passes both modes. | | Contrast ≥ 3:1 UI components | `AmberDeep` on white = 3.1:1; `Amber` on Obsidian = 8.4:1. | | `contentDescription` on icon buttons | Lint via `composeRules` (`MissingContentDescription`). | | Respect `fontScale` | All text in `sp`, layouts in `dp`. Verified up to 1.3×. | | Respect reduced motion | `Settings.Global.ANIMATOR_DURATION_SCALE == 0f` → instant transitions, static blobs, no tilt, no orb breath. | | TalkBack labels for the orb | `"Kaizen"` (idle), `"Kaizen denkt nach"` (streaming). | | HDR opt-out | `Settings.Global.HDR_TONE_MAPPING_DISABLED` (where applicable) → fall back to SDR specular. | | Keyboard nav (external KB) | Verified focus order: Login, Settings, Chat input. | | RTL | Out of scope (de/en only). | --- ## 15. Internationalization - de (default), en. Same as web (`messages/de.json`, `messages/en.json`). - Strings: `res/values/strings.xml` (en) + `res/values-de/strings.xml` (de). - Date/time: `java.time.format.DateTimeFormatter.ofLocalizedDate(MEDIUM)` with `Locale.getDefault()`. - Greeting boundaries localized — English uses Morning/Afternoon/Evening. --- ## 16. Performance budget If we lose any of these, the design has degraded. Wire them into Macrobench in CI. | Metric | Budget (S25 Ultra, default theme) | Budget (Pixel 6a, low-end) | | ------------------------------------------------- | --------------------------------- | --------------------------- | | Cold start (Activity creation → first frame) | ≤ 600 ms | ≤ 1100 ms | | Frame time, idle empty state with blobs | ≤ 4 ms (sustained 120 fps) | ≤ 14 ms (60 fps) | | Frame time, scrolling conversation (50 msgs) | ≤ 6 ms | ≤ 16 ms | | Frame time, theme switch crossfade peak | ≤ 9 ms | ≤ 22 ms | | Memory, idle on chat screen | ≤ 130 MB | ≤ 95 MB | | Battery drain, 10 min foreground idle (chat open) | ≤ 3% (HDR active) / ≤ 2% (SDR) | ≤ 2.5% | Run via Macrobenchmark with `androidx.benchmark.compilation.mode=Partial` (Baseline Profiles). Baseline Profiles are mandatory — without them cold start nearly doubles. --- ## 17. Asset inventory `app/src/main/res/`: | Asset | Format | Notes | | ---------------------------------- | -------------------- | --------------------------------------- | | `mipmap-*/ic_launcher` | Adaptive icon XML | Orb on amber background | | `mipmap-anydpi/ic_launcher_round` | Adaptive icon | API 25+ round launcher | | `drawable/noise_64.png` | 64×64 PNG, alpha | Film-grain overlay (§4.1) | | `drawable/blob_baked__.webp` | Pre-blurred | API 26–30 blob layer fallback (×8 total) | | `font/inter_variable.ttf` | Variable Font | ~330 kB | | `font/jetbrains_mono_variable.ttf` | Variable Font | ~280 kB, code blocks | No Lottie, no SVG at runtime (Compose `ImageVector` instead), no remote font fetch. --- ## 18. Open design questions Capture decisions here as they're made. 1. **Sources / citation rendering on mobile.** Web uses an inline collapsed `SourcesBlock`. Mobile competes for vertical room. Lean: a single "5 Quellen" pill that opens a bottom sheet. 2. **Tablet layout** — permanent sidebar at ≥600 dp width, or stays modal? Currently modal everywhere. Revisit during foldable testing. 3. **Image-gen viewer** — full-screen pinch-zoom (planned) vs inline expandable. 4. **Call-mode landscape** — portrait-only initially. Landscape later. 5. **JetBrains Mono ligatures** — off by default. Per-user toggle in Settings → Erscheinungsbild later? 6. **Refraction (chromatic aberration) global toggle.** Currently only on orb + armed send button. Should there be a "subtle / standard / off" setting? --- ## 19. Implementation checklist (in order) For someone picking up the project tomorrow: 1. **Color pipeline.** Convert all `Color(0xFF…)` in `ui/theme/Color.kt` to P3 float form. Add `window.colorMode = COLOR_MODE_WIDE_COLOR_GAMUT` (and `HDR` on API 34+) in `MainActivity.onCreate`. Test on a P3 device — amber should look visibly more saturated than before. 2. **Squircle shape.** Implement `SquircleShape` in `ui/shape/Squircle.kt`. Replace all `RoundedCornerShape(...)` usages. 3. **Inter Variable.** Drop `inter_variable.ttf` into `res/font/`. Replace `FontFamily.Default` in `Type.kt`. Wire `opsz` axis per size. 4. **Shadow stack.** Implement `Modifier.squircleShadow(level)` in `ui/effect/Shadow.kt`. Replace all `Modifier.shadow(...)` usages. 5. **GlassSurface composable** with `RenderEffect` + API fallback. Migrate all "card-like" Compose surfaces to it. 6. **Background blobs** with animated `Canvas` + WebP fallback (`chat/Background.kt`). 7. **Orb premium layers** — refractive blur, sweep gradient, HDR specular, tilt-bound highlight. 8. **Sensor tilt state** — `rememberTiltState()` plus the four consumers (orb, send-armed, blobs, mesh). 9. **Theme system** — four `KaizenTheme` objects, server sync via `/api/v1/me`, crossfade on switch. 10. **Shared element transitions** — sidebar row → header, hero orb → first assistant avatar. 11. **Baseline Profile** generation — wire into release build. 12. **Macrobenchmark** suite covering §16 budgets. --- ## 20. Changelog | Date | Change | | ---------- | -------------------------------------------------------------------------------------------- | | 2026-06-21 | Initial draft — tokens, components, motion. | | 2026-06-21 | v2 — full premium spec. P3 + HDR color pipeline, real RenderEffect glass, squircle shapes, Inter Variable, sensor-reactive motion, four-theme server sync. Mobile empty state drops suggestion chips. |