Foundation for the premium UI overhaul: - P3 DisplayP3 color pipeline (all tokens in wide gamut) - SquircleShape (iOS-style superellipse with G2 continuity) - KaizenShapes token system (xs/sm/md/lg/xl/pill/circle) - Multi-layer ShadowStack system (level 0–4) - GlassSurface composable with RenderEffect blur + API fallback - Motion tokens (easing curves, spring specs, durations) - Tilt sensor state (GAME_ROTATION_VECTOR, lifecycle-aware) - 4-theme system (Amber/Blau/Mono/Aurora) with AccentScheme - ThemeRegistry + CompositionLocal for accent propagation - Unit tests for colors, shapes, shadows, themes
970 lines
58 KiB
Markdown
970 lines
58 KiB
Markdown
# 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. |
|