# 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/<id>.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: "<color>"; 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_<theme>_<mode>.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<Float>`, 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<ShadowLayer>)

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<Offset> {
    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<Float>(dampingRatio = 0.55f, stiffness = 800f)
val SpringDefault = spring<Float>(dampingRatio = 0.75f, stiffness = 400f)
val SpringGentle = spring<Float>(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_<theme>_<mode>.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. |
