feat: design system v2 — P3 colors, squircle shapes, glass surfaces, multi-theme, motion tokens

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
This commit is contained in:
Bruno Deanoz 2026-06-21 14:32:25 +02:00
parent 203316b1c3
commit 6465b47bbb
19 changed files with 2077 additions and 40 deletions

970
DESIGN.md Normal file
View file

@ -0,0 +1,970 @@
# 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 2630 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 2630 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 2630 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 2630, `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 (1632 sp body, 3248 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` (100900) and `opsz` (1432) 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 (520560) 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 (2432 sp) we set `opsz = 2432`, which makes Inter pick its "Display" outlines — slightly narrower, tighter, more elegant. At body sizes (1416 sp) we set `opsz = 1416` → "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 |
| --------- | -------------------------------------- | --------------------------------------------- |
| 96160 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 2630 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. |

View file

@ -0,0 +1,99 @@
package dev.kaizen.app.ui.effect
import android.os.Build
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxScope
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.BlurredEdgeTreatment
import androidx.compose.ui.draw.blur
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import dev.kaizen.app.ui.shape.KaizenShapes
/**
* A refractive glass surface the core visual primitive of the Kaizen design system.
*
* On API 31+ uses Compose's [Modifier.blur] (backed by RenderEffect.createBlurEffect)
* to blur the underlying composition through the surface, creating a real frosted-glass
* effect. On API 2630, the blur is skipped and the surface uses a higher tint alpha
* (+0.10) to fake depth.
*
* The [tint] color gives the surface its identity (Obsidian vs white). Without it
* the blur would show a wash of whatever's behind. [tintAlpha] controls the
* "milkiness" lower = more transparent/refractive, higher = more opaque/solid.
*
* See DESIGN.md §4 for the full anatomy and opacity tiers.
*/
@Composable
fun GlassSurface(
modifier: Modifier = Modifier,
shape: Shape = KaizenShapes.md,
tint: Color = MaterialTheme.colorScheme.surface,
tintAlpha: Float = 0.72f,
blurRadius: Dp = 24.dp,
shadowStack: ShadowStack = KaizenShadows.level2,
content: @Composable BoxScope.() -> Unit,
) {
val isDark = isSystemInDarkTheme()
val supportsBlur = Build.VERSION.SDK_INT >= Build.VERSION_CODES.S
val effectiveTintAlpha = if (supportsBlur) tintAlpha else (tintAlpha + 0.10f).coerceAtMost(1f)
val tintColor = remember(tint, effectiveTintAlpha) { tint.copy(alpha = effectiveTintAlpha) }
val outlineColor = MaterialTheme.colorScheme.outline
val outlineAlpha = if (isDark) 0.12f else 0.06f
Box(
modifier = modifier
.kaizenShadow(stack = shadowStack, cornerRadius = 16.dp)
.clip(shape)
.then(
if (supportsBlur) {
Modifier.blur(blurRadius, edgeTreatment = BlurredEdgeTreatment.Rectangle)
} else {
Modifier
}
)
.drawBehind { drawRect(tintColor) }
.innerTopHighlight()
.border(1.dp, outlineColor.copy(alpha = outlineAlpha), shape),
content = content,
)
}
/**
* Predefined opacity tiers matching DESIGN.md §4.4.
* Use these constants with [GlassSurface] instead of ad-hoc alpha values.
*/
object GlassTiers {
/** Sidebar — most translucent, strongest refraction */
fun sidebar(isDark: Boolean) = if (isDark) 0.72f else 0.68f
/** Chat input bar */
fun input(isDark: Boolean) = if (isDark) 0.75f else 0.74f
/** Small pills (mode pill, model pill) */
fun pill(isDark: Boolean) = if (isDark) 0.80f else 0.82f
/** Suggestion chips — tap targets, need body */
fun chip(isDark: Boolean) = if (isDark) 0.85f else 0.90f
/** Settings cards — reading surface, near-opaque */
fun card(isDark: Boolean) = if (isDark) 0.94f else 0.95f
/** User bubble — amber-tinted glass */
fun userBubble(isDark: Boolean) = if (isDark) 0.86f else 0.88f
/** Dropdowns / bottom sheets — deepest blur */
fun sheet(isDark: Boolean) = if (isDark) 0.82f else 0.85f
}

View file

@ -0,0 +1,148 @@
package dev.kaizen.app.ui.effect
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.geometry.CornerRadius
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Paint
import androidx.compose.ui.graphics.drawscope.DrawScope
import androidx.compose.ui.graphics.drawscope.drawIntoCanvas
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
/**
* A single layer in a multi-layer shadow stack.
*
* Premium UI uses 35 overlapping shadow layers at increasing blur radii
* and decreasing opacity, producing a soft, realistic falloff that a
* single [Modifier.shadow] cannot match.
*/
data class ShadowLayer(
val offsetY: Dp,
val blur: Dp,
val spread: Dp = 0.dp,
val alpha: Float,
)
/**
* A complete shadow stack of multiple [ShadowLayer]s.
*/
data class ShadowStack(val layers: List<ShadowLayer>)
/**
* Predefined shadow levels use these everywhere instead of ad-hoc
* `Modifier.shadow(elevation = ...)`.
*/
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(
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),
)
)
}
/**
* Draws a multi-layer shadow stack behind the content.
*
* [darkMultiplier] scales shadow alpha in dark mode (shadows need to be
* stronger on dark surfaces to read). Default 1.8× matches the DESIGN.md spec.
*/
@Composable
fun Modifier.kaizenShadow(
stack: ShadowStack,
cornerRadius: Dp = 16.dp,
darkMultiplier: Float = 1.8f,
): Modifier {
val isDark = isSystemInDarkTheme()
val effectiveMultiplier = if (isDark) darkMultiplier else 1f
val layers = remember(stack, effectiveMultiplier) {
stack.layers.map { layer ->
layer.copy(alpha = (layer.alpha * effectiveMultiplier).coerceAtMost(1f))
}
}
return this.drawBehind {
drawShadowLayers(layers, cornerRadius)
}
}
private fun DrawScope.drawShadowLayers(layers: List<ShadowLayer>, cornerRadius: Dp) {
val cr = cornerRadius.toPx()
drawIntoCanvas { canvas ->
layers.forEach { layer ->
val paint = Paint().apply {
val framework = asFrameworkPaint()
framework.color = Color.Black.copy(alpha = layer.alpha).toArgb()
framework.setShadowLayer(
layer.blur.toPx(),
0f,
layer.offsetY.toPx(),
Color.Black.copy(alpha = layer.alpha).toArgb(),
)
}
val spreadPx = layer.spread.toPx()
canvas.drawRoundRect(
left = -spreadPx,
top = -spreadPx,
right = size.width + spreadPx,
bottom = size.height + spreadPx,
radiusX = cr,
radiusY = cr,
paint = paint,
)
}
}
}
/**
* Inner top highlight 1dp inset white line that simulates "light from above."
* Together with the drop shadow, this makes glass surfaces look lit.
*/
@Composable
fun Modifier.innerTopHighlight(): Modifier {
val isDark = isSystemInDarkTheme()
val highlightAlpha = if (isDark) 0.08f else 0.95f
return this.drawBehind {
drawLine(
color = Color.White.copy(alpha = highlightAlpha),
start = Offset(0f, 1f),
end = Offset(size.width, 1f),
strokeWidth = 1.dp.toPx(),
)
}
}

View file

@ -0,0 +1,52 @@
package dev.kaizen.app.ui.motion
import androidx.compose.animation.core.CubicBezierEasing
import androidx.compose.animation.core.FastOutSlowInEasing
import androidx.compose.animation.core.SpringSpec
import androidx.compose.animation.core.spring
import androidx.compose.animation.core.tween
/**
* Kaizen animation primitives single source of truth for all motion.
*
* Every motion in the app references these tokens. Defined here so the
* feel stays consistent and tuning is centralised.
*/
// ── Easing Curves ──────────────────────────────────────────────────────────
/** Overshoot ease — entrance animations */
val EaseSpring = CubicBezierEasing(0.34f, 1.56f, 0.64f, 1f)
/** Expo-out — smooth transitions */
val EaseSmooth = CubicBezierEasing(0.22f, 1f, 0.36f, 1f)
/** System-aligned standard */
val EaseStandard = FastOutSlowInEasing
/** M3 emphasized — page transitions */
val EaseEmphasized = CubicBezierEasing(0.05f, 0.7f, 0.1f, 1f)
// ── Spring Specs ───────────────────────────────────────────────────────────
/** Fast, punchy — squircle press, quick reactions */
val SpringSnappy: SpringSpec<Float> = spring(dampingRatio = 0.55f, stiffness = 800f)
/** Balanced — general state changes */
val SpringDefault: SpringSpec<Float> = spring(dampingRatio = 0.75f, stiffness = 400f)
/** Slow, fluid — tilt response, sidebar slide */
val SpringGentle: SpringSpec<Float> = spring(dampingRatio = 0.85f, stiffness = 200f)
// ── Duration Constants ─────────────────────────────────────────────────────
object Durations {
const val DROPDOWN_OPEN = 150
const val PAGE_TRANSITION = 280
const val BUBBLE_ENTRANCE = 350
const val THEME_CROSSFADE = 600
const val ORB_BREATH = 7000
const val ORB_BREATH_STREAMING = 1800
const val THINKING_DOT_CYCLE = 600
const val THINKING_DOT_PHASE_OFFSET = 160
}

View file

@ -0,0 +1,145 @@
package dev.kaizen.app.ui.sensor
import android.content.Context
import android.hardware.Sensor
import android.hardware.SensorEvent
import android.hardware.SensorEventListener
import android.hardware.SensorManager
import android.os.PowerManager
import android.provider.Settings
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.State
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.platform.LocalContext
import androidx.lifecycle.compose.LocalLifecycleOwner
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
/**
* Provides a smoothed tilt [Offset] (x, y in range -1..1) from the device sensors.
*
* Uses [Sensor.TYPE_GAME_ROTATION_VECTOR] (fused gyro + accel, pre-stabilized,
* no drift). Falls back to [Sensor.TYPE_GRAVITY] then [Sensor.TYPE_ACCELEROMETER].
*
* Automatically disables when:
* - System animations are off (`ANIMATOR_DURATION_SCALE == 0`)
* - Battery saver is on
* - App is not in foreground
* - Device tilt exceeds ±25° (user lying down)
*
* See DESIGN.md §7 for full motion spec.
*/
@Composable
fun rememberTiltState(): State<Offset> {
val context = LocalContext.current
val lifecycle = LocalLifecycleOwner.current.lifecycle
val tilt = remember { mutableStateOf(Offset.Zero) }
DisposableEffect(lifecycle) {
val sensorManager = context.getSystemService(Context.SENSOR_SERVICE) as? SensorManager
?: return@DisposableEffect onDispose { }
val sensor = sensorManager.getDefaultSensor(Sensor.TYPE_GAME_ROTATION_VECTOR)
?: sensorManager.getDefaultSensor(Sensor.TYPE_GRAVITY)
?: sensorManager.getDefaultSensor(Sensor.TYPE_ACCELEROMETER)
?: return@DisposableEffect onDispose { }
val isRotationVector = sensor.type == Sensor.TYPE_GAME_ROTATION_VECTOR
val animScale = try {
Settings.Global.getFloat(context.contentResolver, Settings.Global.ANIMATOR_DURATION_SCALE)
} catch (_: Exception) { 1f }
if (animScale == 0f) return@DisposableEffect onDispose { }
var filteredX = 0f
var filteredY = 0f
val alpha = 0.18f
var registered = false
val listener = object : SensorEventListener {
override fun onSensorChanged(event: SensorEvent?) {
if (event == null) return
val pm = context.getSystemService(Context.POWER_SERVICE) as? PowerManager
if (pm?.isPowerSaveMode == true) {
tilt.value = Offset.Zero
return
}
val rawX: Float
val rawY: Float
if (isRotationVector) {
val rotationMatrix = FloatArray(9)
SensorManager.getRotationMatrixFromVector(rotationMatrix, event.values)
val orientation = FloatArray(3)
SensorManager.getOrientation(rotationMatrix, orientation)
val pitch = Math.toDegrees(orientation[1].toDouble()).toFloat()
val roll = Math.toDegrees(orientation[2].toDouble()).toFloat()
if (kotlin.math.abs(pitch) > 25f || kotlin.math.abs(roll) > 25f) {
tilt.value = Offset.Zero
return
}
rawX = (roll / 25f).coerceIn(-1f, 1f)
rawY = (pitch / 25f).coerceIn(-1f, 1f)
} else {
val x = event.values[0] / 9.81f
val y = event.values[1] / 9.81f
if (kotlin.math.abs(x) > 0.6f || kotlin.math.abs(y) > 0.6f) {
tilt.value = Offset.Zero
return
}
rawX = x.coerceIn(-1f, 1f)
rawY = y.coerceIn(-1f, 1f)
}
filteredX = filteredX + alpha * (rawX - filteredX)
filteredY = filteredY + alpha * (rawY - filteredY)
tilt.value = Offset(filteredX, filteredY)
}
override fun onAccuracyChanged(sensor: Sensor?, accuracy: Int) {}
}
val lifecycleObserver = LifecycleEventObserver { _, event ->
when (event) {
Lifecycle.Event.ON_START -> {
if (!registered) {
sensorManager.registerListener(
listener, sensor, SensorManager.SENSOR_DELAY_GAME
)
registered = true
}
}
Lifecycle.Event.ON_STOP -> {
sensorManager.unregisterListener(listener)
registered = false
tilt.value = Offset.Zero
}
else -> {}
}
}
lifecycle.addObserver(lifecycleObserver)
if (lifecycle.currentState.isAtLeast(Lifecycle.State.STARTED)) {
sensorManager.registerListener(listener, sensor, SensorManager.SENSOR_DELAY_GAME)
registered = true
}
onDispose {
lifecycle.removeObserver(lifecycleObserver)
if (registered) sensorManager.unregisterListener(listener)
}
}
return tilt
}

View file

@ -0,0 +1,21 @@
package dev.kaizen.app.ui.shape
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.unit.dp
/**
* Kaizen shape tokens squircle-based replacement for Material Shapes.
*
* Every surface, button, chip, and bubble references one of these tokens.
* Centralised here so a single change propagates everywhere.
*/
object KaizenShapes {
val xs: Shape = SquircleShape(radius = 8.dp, smoothness = 4.0f)
val sm: Shape = SquircleShape(radius = 12.dp, smoothness = 4.2f)
val md: Shape = SquircleShape(radius = 16.dp, smoothness = 4.5f)
val lg: Shape = SquircleShape(radius = 24.dp, smoothness = 5.0f)
val xl: Shape = SquircleShape(radius = 28.dp, smoothness = 5.0f)
val pill: Shape = SquircleShape(radius = 999.dp, smoothness = 4.5f)
val circle: Shape = CircleShape
}

View file

@ -0,0 +1,126 @@
package dev.kaizen.app.ui.shape
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Outline
import androidx.compose.ui.graphics.Path
import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp
import kotlin.math.abs
import kotlin.math.cos
import kotlin.math.min
import kotlin.math.pow
import kotlin.math.sign
import kotlin.math.sin
/**
* iOS-style continuous superellipse ("squircle").
*
* Unlike [RoundedCornerShape] which is a rectangle with quarter-circle arcs,
* a squircle has G2-continuous corners (curvature matches, not just position).
* The visual difference is subtle at small radii but unmistakable at 16+ dp
* corners ease into the straight edges instead of snapping.
*
* The superellipse equation: |x/a|^n + |y/b|^n = 1
* n = 2 circle/ellipse
* n = 45 iOS icon / Apple Liquid Glass sweet spot
* n rectangle
*
* [smoothness] controls the `n` exponent. Default 4.5 matches iOS 26.
* [samplesPerCorner] controls path resolution. 32 is invisible at any
* screen density; reduce to 16 for small shapes (< 32 dp).
*/
class SquircleShape(
private val radius: Dp,
private val smoothness: Float = 4.5f,
private val samplesPerCorner: Int = 32,
) : androidx.compose.ui.graphics.Shape {
override fun createOutline(
size: Size,
layoutDirection: LayoutDirection,
density: Density,
): Outline {
val r = with(density) { radius.toPx() }
.coerceAtMost(min(size.width, size.height) / 2f)
if (r <= 0f) return Outline.Rectangle(
androidx.compose.ui.geometry.Rect(0f, 0f, size.width, size.height)
)
val path = Path().apply {
buildSquirclePath(size.width, size.height, r, smoothness, samplesPerCorner)
}
return Outline.Generic(path)
}
override fun toString(): String = "SquircleShape(radius=$radius, smoothness=$smoothness)"
}
/**
* Builds a closed squircle path into [this] Path.
*
* Traces the superellipse corner arcs and straight edges clockwise
* starting from the top-left corner's right edge.
*/
private fun Path.buildSquirclePath(
w: Float,
h: Float,
r: Float,
n: Float,
samples: Int,
) {
val step = (Math.PI / 2.0) / samples
fun superX(angle: Double): Float {
val cosA = cos(angle)
return (abs(cosA).pow(2.0 / n) * sign(cosA) * r).toFloat()
}
fun superY(angle: Double): Float {
val sinA = sin(angle)
return (abs(sinA).pow(2.0 / n) * sign(sinA) * r).toFloat()
}
// Start at top edge, right of top-left corner
moveTo(r, 0f)
// Top edge → top-right corner
lineTo(w - r, 0f)
for (i in 0..samples) {
val angle = Math.PI / 2.0 - step * i
val sx = superX(angle)
val sy = superY(angle)
lineTo(w - r + sx, r - sy)
}
// Right edge → bottom-right corner
lineTo(w, h - r)
for (i in 0..samples) {
val angle = -step * i
val sx = superX(angle)
val sy = superY(angle)
lineTo(w - r + sx, h - r - sy)
}
// Bottom edge → bottom-left corner
lineTo(r, h)
for (i in 0..samples) {
val angle = -(Math.PI / 2.0) - step * i
val sx = superX(angle)
val sy = superY(angle)
lineTo(r + sx, h - r - sy)
}
// Left edge → top-left corner
lineTo(0f, r)
for (i in 0..samples) {
val angle = Math.PI - step * i
val sx = superX(angle)
val sy = superY(angle)
lineTo(r + sx, r - sy)
}
close()
}

View file

@ -1,32 +1,45 @@
package dev.kaizen.app.ui.theme package dev.kaizen.app.ui.theme
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.colorspace.ColorSpaces
// Kaizen palette — Obsidian steel-blue + amber accent. // ---------------------------------------------------------------------------
// OKLCH tokens from the web (globals.css) approximated to sRGB; fine-tuned on device. // Kaizen Color Tokens — DisplayP3 pipeline
// ---------------------------------------------------------------------------
// Every brand color is declared in the Display P3 color space so the full
// gamut is used on wide-color displays (every Galaxy since 2018, every Pixel
// since the 4). On sRGB-only panels the runtime clamps gracefully.
//
// The sRGB hex approximations are in trailing comments for design handoff.
// Never use Color(0xFF…) for brand colors outside this file.
// ---------------------------------------------------------------------------
// Brand amber — kept constant across light/dark (orb, accents, send button). private fun p3(r: Float, g: Float, b: Float, a: Float = 1f): Color =
val Amber = Color(0xFFE0A23E) // ~oklch(0.72 0.14 83) Color(red = r, green = g, blue = b, alpha = a, colorSpace = ColorSpaces.DisplayP3)
val AmberDeep = Color(0xFFC8862A)
val OnAmber = Color(0xFF1B1206)
// Dark scheme (Obsidian) // ── Brand Amber (constant across all themes — they ARE the brand) ──────────
val Obsidian = Color(0xFF0A0E16) // background ~oklch(0.10 0.025 245) val Amber = p3(0.878f, 0.635f, 0.243f) // ≈ #E0A23E
val ObsidianElevated = Color(0xFF141B27) // surface val AmberDeep = p3(0.784f, 0.525f, 0.165f) // ≈ #C8862A
val ObsidianInput = Color(0xFF111824) // input surface val OnAmber = p3(0.106f, 0.071f, 0.024f) // ≈ #1B1206
val DarkText = Color(0xFFE8EBF1)
val DarkMuted = Color(0xFF8B94A4)
val DarkStroke = Color(0x16FFFFFF) // subtle light hairline
// Light scheme // ── Dark scheme — Obsidian Steel-Blue ──────────────────────────────────────
val LightBackground = Color(0xFFF6F7F9) val Obsidian = p3(0.039f, 0.055f, 0.086f) // background ≈ #0A0E16
val LightSurface = Color(0xFFFFFFFF) val ObsidianElevated = p3(0.078f, 0.106f, 0.153f) // surface ≈ #141B27
val LightSurfaceVariant = Color(0xFFEEF0F3) val ObsidianInput = p3(0.067f, 0.094f, 0.141f) // surfaceVar ≈ #111824
val LightText = Color(0xFF1A1F29) val DarkText = p3(0.910f, 0.922f, 0.945f) // onBackground ≈ #E8EBF1
val LightMuted = Color(0xFF5B6470) val DarkMuted = p3(0.545f, 0.580f, 0.643f) // onSurfaceVar ≈ #8B94A4
val LightStroke = Color(0x14000000) // subtle dark hairline val DarkStroke = p3(1f, 1f, 1f, 0.09f) // outline
// Background blobs (.bg-blobs) // ── Light scheme ───────────────────────────────────────────────────────────
val BlobAmber = Color(0xFFE0A23E) val LightBackground = p3(0.965f, 0.969f, 0.976f) // ≈ #F6F7F9
val BlobBlue = Color(0xFF3C5FD6) val LightSurface = p3(1.000f, 1.000f, 1.000f) // ≈ #FFFFFF
val BlobTeal = Color(0xFF2BB6A6) val LightSurfaceVariant = p3(0.933f, 0.941f, 0.953f) // ≈ #EEF0F3
val LightText = p3(0.102f, 0.122f, 0.161f) // ≈ #1A1F29
val LightMuted = p3(0.357f, 0.392f, 0.439f) // ≈ #5B6470
val LightStroke = p3(0f, 0f, 0f, 0.08f) // outline
// ── Background blobs ───────────────────────────────────────────────────────
val BlobAmber = p3(0.878f, 0.635f, 0.243f) // ≈ Amber
val BlobBlue = p3(0.235f, 0.373f, 0.839f) // ≈ #3C5FD6
val BlobTeal = p3(0.169f, 0.714f, 0.651f) // ≈ #2BB6A6
val BlobIndigo = p3(0.290f, 0.290f, 0.690f) // 4th blob for Amber theme

View file

@ -5,14 +5,29 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.darkColorScheme import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.lightColorScheme import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import dev.kaizen.app.ui.theme.themes.AmberTheme
import dev.kaizen.app.ui.theme.themes.KaizenAccentScheme
import dev.kaizen.app.ui.theme.themes.KaizenThemeId
import dev.kaizen.app.ui.theme.themes.KaizenThemeSpec
import dev.kaizen.app.ui.theme.themes.ThemeRegistry
// Fixed Kaizen schemes (no dynamic color) so the glass look stays consistent. /**
private val KaizenDark = darkColorScheme( * CompositionLocal for the active Kaizen accent scheme.
primary = Amber, *
onPrimary = OnAmber, * Use `LocalKaizenAccent.current` to access theme-specific colors (primary,
secondary = Amber, * blobs, ring) that go beyond MaterialTheme.colorScheme. This is the bridge
onSecondary = OnAmber, * between the four Kaizen themes and the Material3 color system.
*/
val LocalKaizenAccent = staticCompositionLocalOf { AmberTheme.dark }
private fun buildDarkScheme(accent: KaizenAccentScheme) = darkColorScheme(
primary = accent.primary,
onPrimary = accent.onPrimary,
secondary = accent.primary,
onSecondary = accent.onPrimary,
background = Obsidian, background = Obsidian,
onBackground = DarkText, onBackground = DarkText,
surface = ObsidianElevated, surface = ObsidianElevated,
@ -22,11 +37,11 @@ private val KaizenDark = darkColorScheme(
outline = DarkStroke, outline = DarkStroke,
) )
private val KaizenLight = lightColorScheme( private fun buildLightScheme(accent: KaizenAccentScheme) = lightColorScheme(
primary = AmberDeep, primary = accent.primary,
onPrimary = Color.White, onPrimary = accent.onPrimary,
secondary = AmberDeep, secondary = accent.primary,
onSecondary = Color.White, onSecondary = accent.onPrimary,
background = LightBackground, background = LightBackground,
onBackground = LightText, onBackground = LightText,
surface = LightSurface, surface = LightSurface,
@ -38,12 +53,19 @@ private val KaizenLight = lightColorScheme(
@Composable @Composable
fun KaizenTheme( fun KaizenTheme(
themeId: KaizenThemeId = KaizenThemeId.AMBER,
darkTheme: Boolean = isSystemInDarkTheme(), darkTheme: Boolean = isSystemInDarkTheme(),
content: @Composable () -> Unit, content: @Composable () -> Unit,
) { ) {
val spec = ThemeRegistry.forId(themeId)
val accent = if (darkTheme) spec.dark else spec.light
val colorScheme = if (darkTheme) buildDarkScheme(accent) else buildLightScheme(accent)
CompositionLocalProvider(LocalKaizenAccent provides accent) {
MaterialTheme( MaterialTheme(
colorScheme = if (darkTheme) KaizenDark else KaizenLight, colorScheme = colorScheme,
typography = Typography, typography = Typography,
content = content, content = content,
) )
}
} }

View file

@ -0,0 +1,47 @@
package dev.kaizen.app.ui.theme.themes
import androidx.compose.ui.graphics.Color
/**
* The accent portion of a Kaizen theme.
*
* Neutrals (background, surface, text) are theme-independent same Obsidian /
* Light off-white across all four themes. Only the accent + blob colors swap.
* This matches the web architecture where `globals.css` ships neutrals and
* `app/themes/<id>.css` ships only accent tokens.
*/
data class KaizenAccentScheme(
val primary: Color,
val primaryDeep: Color,
val onPrimary: Color,
val ring: Color,
val blob1: Color,
val blob2: Color,
val blob3: Color,
val blob4: Color,
)
/**
* Theme identity server-synced via `/api/v1/me`.
*/
enum class KaizenThemeId(val value: String) {
AMBER("amber"),
BLAU("blue"),
MONO("mono"),
AURORA("aurora");
companion object {
fun fromString(s: String): KaizenThemeId =
entries.firstOrNull { it.value == s } ?: AMBER
}
}
/**
* A complete Kaizen theme light + dark accent schemes.
*/
data class KaizenThemeSpec(
val id: KaizenThemeId,
val label: String,
val light: KaizenAccentScheme,
val dark: KaizenAccentScheme,
)

View file

@ -0,0 +1,32 @@
package dev.kaizen.app.ui.theme.themes
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.colorspace.ColorSpaces
private fun p3(r: Float, g: Float, b: Float): Color =
Color(red = r, green = g, blue = b, colorSpace = ColorSpaces.DisplayP3)
val AmberTheme = KaizenThemeSpec(
id = KaizenThemeId.AMBER,
label = "Amber",
dark = KaizenAccentScheme(
primary = p3(0.878f, 0.635f, 0.243f), // Amber
primaryDeep = p3(0.784f, 0.525f, 0.165f),
onPrimary = p3(0.106f, 0.071f, 0.024f),
ring = p3(0.878f, 0.635f, 0.243f),
blob1 = p3(0.878f, 0.635f, 0.243f), // Amber
blob2 = p3(0.235f, 0.373f, 0.839f), // Blue
blob3 = p3(0.290f, 0.290f, 0.690f), // Indigo
blob4 = p3(0.169f, 0.714f, 0.651f), // Teal
),
light = KaizenAccentScheme(
primary = p3(0.784f, 0.525f, 0.165f), // AmberDeep (better contrast on white)
primaryDeep = p3(0.784f, 0.525f, 0.165f),
onPrimary = p3(1f, 1f, 1f),
ring = p3(0.878f, 0.635f, 0.243f),
blob1 = p3(0.878f, 0.635f, 0.243f),
blob2 = p3(0.235f, 0.373f, 0.839f),
blob3 = p3(0.290f, 0.290f, 0.690f),
blob4 = p3(0.169f, 0.714f, 0.651f),
),
)

View file

@ -0,0 +1,32 @@
package dev.kaizen.app.ui.theme.themes
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.colorspace.ColorSpaces
private fun p3(r: Float, g: Float, b: Float): Color =
Color(red = r, green = g, blue = b, colorSpace = ColorSpaces.DisplayP3)
val AuroraTheme = KaizenThemeSpec(
id = KaizenThemeId.AURORA,
label = "Aurora",
dark = KaizenAccentScheme(
primary = p3(0.850f, 0.310f, 0.750f), // Magenta P3
primaryDeep = p3(0.680f, 0.220f, 0.620f),
onPrimary = p3(0.106f, 0.024f, 0.090f),
ring = p3(0.850f, 0.310f, 0.750f),
blob1 = p3(0.850f, 0.310f, 0.750f), // Magenta
blob2 = p3(0.169f, 0.714f, 0.800f), // Cyan
blob3 = p3(0.545f, 0.361f, 0.859f), // Violet
blob4 = p3(0.520f, 0.850f, 0.310f), // Lime (accent)
),
light = KaizenAccentScheme(
primary = p3(0.490f, 0.270f, 0.690f), // Violet-600 P3
primaryDeep = p3(0.490f, 0.270f, 0.690f),
onPrimary = p3(1f, 1f, 1f),
ring = p3(0.850f, 0.310f, 0.750f),
blob1 = p3(0.850f, 0.310f, 0.750f),
blob2 = p3(0.169f, 0.714f, 0.800f),
blob3 = p3(0.545f, 0.361f, 0.859f),
blob4 = p3(0.520f, 0.850f, 0.310f),
),
)

View file

@ -0,0 +1,32 @@
package dev.kaizen.app.ui.theme.themes
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.colorspace.ColorSpaces
private fun p3(r: Float, g: Float, b: Float): Color =
Color(red = r, green = g, blue = b, colorSpace = ColorSpaces.DisplayP3)
val BlauTheme = KaizenThemeSpec(
id = KaizenThemeId.BLAU,
label = "Blau",
dark = KaizenAccentScheme(
primary = p3(0.337f, 0.600f, 0.988f), // Sky-500 P3
primaryDeep = p3(0.243f, 0.420f, 0.855f),
onPrimary = p3(0.024f, 0.047f, 0.106f),
ring = p3(0.337f, 0.600f, 0.988f),
blob1 = p3(0.235f, 0.373f, 0.839f), // Blue
blob2 = p3(0.290f, 0.290f, 0.690f), // Indigo
blob3 = p3(0.169f, 0.714f, 0.800f), // Cyan
blob4 = p3(0.878f, 0.635f, 0.243f), // Amber (contrast)
),
light = KaizenAccentScheme(
primary = p3(0.243f, 0.310f, 0.730f), // Indigo-600 P3
primaryDeep = p3(0.243f, 0.310f, 0.730f),
onPrimary = p3(1f, 1f, 1f),
ring = p3(0.337f, 0.600f, 0.988f),
blob1 = p3(0.235f, 0.373f, 0.839f),
blob2 = p3(0.290f, 0.290f, 0.690f),
blob3 = p3(0.169f, 0.714f, 0.800f),
blob4 = p3(0.878f, 0.635f, 0.243f),
),
)

View file

@ -0,0 +1,32 @@
package dev.kaizen.app.ui.theme.themes
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.colorspace.ColorSpaces
private fun p3(r: Float, g: Float, b: Float): Color =
Color(red = r, green = g, blue = b, colorSpace = ColorSpaces.DisplayP3)
val MonoTheme = KaizenThemeSpec(
id = KaizenThemeId.MONO,
label = "Mono",
dark = KaizenAccentScheme(
primary = p3(0.878f, 0.894f, 0.918f), // Slate-100
primaryDeep = p3(0.710f, 0.741f, 0.784f),
onPrimary = p3(0.063f, 0.082f, 0.118f),
ring = p3(0.710f, 0.741f, 0.784f),
blob1 = p3(0.710f, 0.741f, 0.784f), // Slate-300
blob2 = p3(0.580f, 0.620f, 0.680f), // Slate-400
blob3 = p3(0.820f, 0.845f, 0.878f), // Slate-200
blob4 = p3(0.600f, 0.580f, 0.560f), // Warm gray
),
light = KaizenAccentScheme(
primary = p3(0.188f, 0.224f, 0.286f), // Slate-800
primaryDeep = p3(0.188f, 0.224f, 0.286f),
onPrimary = p3(1f, 1f, 1f),
ring = p3(0.420f, 0.455f, 0.510f),
blob1 = p3(0.710f, 0.741f, 0.784f),
blob2 = p3(0.580f, 0.620f, 0.680f),
blob3 = p3(0.820f, 0.845f, 0.878f),
blob4 = p3(0.600f, 0.580f, 0.560f),
),
)

View file

@ -0,0 +1,18 @@
package dev.kaizen.app.ui.theme.themes
/**
* Central registry of all available themes.
*
* Adding a new theme = create a `*Theme.kt` file + add it here.
*/
object ThemeRegistry {
val all: List<KaizenThemeSpec> = listOf(
AmberTheme,
BlauTheme,
MonoTheme,
AuroraTheme,
)
fun forId(id: KaizenThemeId): KaizenThemeSpec =
all.firstOrNull { it.id == id } ?: AmberTheme
}

View file

@ -0,0 +1,68 @@
package dev.kaizen.app
import androidx.compose.ui.graphics.colorspace.ColorSpaces
import dev.kaizen.app.ui.theme.*
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
class ColorPipelineTest {
@Test
fun `brand amber is in DisplayP3 color space`() {
assertEquals(ColorSpaces.DisplayP3, Amber.colorSpace)
assertEquals(ColorSpaces.DisplayP3, AmberDeep.colorSpace)
assertEquals(ColorSpaces.DisplayP3, OnAmber.colorSpace)
}
@Test
fun `dark scheme colors are in DisplayP3`() {
listOf(Obsidian, ObsidianElevated, ObsidianInput, DarkText, DarkMuted, DarkStroke)
.forEach { color ->
assertEquals(
"Color $color should be DisplayP3",
ColorSpaces.DisplayP3,
color.colorSpace,
)
}
}
@Test
fun `light scheme colors are in DisplayP3`() {
listOf(LightBackground, LightSurface, LightSurfaceVariant, LightText, LightMuted, LightStroke)
.forEach { color ->
assertEquals(
"Color $color should be DisplayP3",
ColorSpaces.DisplayP3,
color.colorSpace,
)
}
}
@Test
fun `blob colors are in DisplayP3`() {
listOf(BlobAmber, BlobBlue, BlobTeal, BlobIndigo).forEach { color ->
assertEquals(ColorSpaces.DisplayP3, color.colorSpace)
}
}
@Test
fun `amber has expected approximate values`() {
assertTrue("Red should be around 0.878", Amber.red in 0.87f..0.89f)
assertTrue("Green should be around 0.635", Amber.green in 0.62f..0.65f)
assertTrue("Blue should be around 0.243", Amber.blue in 0.23f..0.26f)
assertEquals(1f, Amber.alpha, 0.001f)
}
@Test
fun `stroke colors have transparency`() {
assertTrue("DarkStroke should be transparent", DarkStroke.alpha < 1f)
assertTrue("LightStroke should be transparent", LightStroke.alpha < 1f)
}
@Test
fun `no pure black or pure white in neutrals`() {
assertTrue("Obsidian should not be pure black", Obsidian.red > 0f || Obsidian.green > 0f || Obsidian.blue > 0f)
assertTrue("LightBackground should not be pure white", LightBackground.red < 1f || LightBackground.green < 1f)
}
}

View file

@ -0,0 +1,70 @@
package dev.kaizen.app
import dev.kaizen.app.ui.effect.KaizenShadows
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
class ShadowStackTest {
@Test
fun `level0 has no layers`() {
assertTrue(KaizenShadows.level0.layers.isEmpty())
}
@Test
fun `level1 has 2 layers`() {
assertEquals(2, KaizenShadows.level1.layers.size)
}
@Test
fun `level2 has 3 layers`() {
assertEquals(3, KaizenShadows.level2.layers.size)
}
@Test
fun `level3 has 4 layers`() {
assertEquals(4, KaizenShadows.level3.layers.size)
}
@Test
fun `level4 has 4 layers`() {
assertEquals(4, KaizenShadows.level4.layers.size)
}
@Test
fun `all alphas are in valid range`() {
listOf(
KaizenShadows.level1,
KaizenShadows.level2,
KaizenShadows.level3,
KaizenShadows.level4,
).forEach { stack ->
stack.layers.forEach { layer ->
assertTrue("Alpha ${layer.alpha} should be in 0..1", layer.alpha in 0f..1f)
}
}
}
@Test
fun `blur increases with each layer in level3`() {
val blurs = KaizenShadows.level3.layers.map { it.blur }
for (i in 1 until blurs.size) {
assertTrue(
"Blur should increase: ${blurs[i - 1]} < ${blurs[i]}",
blurs[i] > blurs[i - 1],
)
}
}
@Test
fun `alpha decreases with each layer in level3`() {
val alphas = KaizenShadows.level3.layers.map { it.alpha }
for (i in 1 until alphas.size) {
assertTrue(
"Alpha should decrease: ${alphas[i - 1]} > ${alphas[i]}",
alphas[i] < alphas[i - 1],
)
}
}
}

View file

@ -0,0 +1,48 @@
package dev.kaizen.app
import dev.kaizen.app.ui.shape.KaizenShapes
import dev.kaizen.app.ui.shape.SquircleShape
import androidx.compose.ui.unit.dp
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertTrue
import org.junit.Test
class SquircleShapeTest {
@Test
fun `KaizenShapes has all expected tokens`() {
assertNotNull(KaizenShapes.xs)
assertNotNull(KaizenShapes.sm)
assertNotNull(KaizenShapes.md)
assertNotNull(KaizenShapes.lg)
assertNotNull(KaizenShapes.xl)
assertNotNull(KaizenShapes.pill)
assertNotNull(KaizenShapes.circle)
}
@Test
fun `KaizenShapes tokens are distinct instances`() {
val shapes = listOf(
KaizenShapes.xs, KaizenShapes.sm, KaizenShapes.md,
KaizenShapes.lg, KaizenShapes.xl, KaizenShapes.pill,
)
val unique = shapes.toSet()
assertEquals("All squircle shape tokens should be distinct", shapes.size, unique.size)
}
@Test
fun `SquircleShape toString contains parameters`() {
val shape = SquircleShape(radius = 16.dp, smoothness = 4.5f)
val str = shape.toString()
assertTrue("Should contain radius", str.contains("16"))
assertTrue("Should contain smoothness", str.contains("4.5"))
}
@Test
fun `SquircleShape with different smoothness creates different instances`() {
val a = SquircleShape(radius = 16.dp, smoothness = 4.0f)
val b = SquircleShape(radius = 16.dp, smoothness = 5.0f)
assertTrue("Different smoothness should produce different toString", a.toString() != b.toString())
}
}

View file

@ -0,0 +1,62 @@
package dev.kaizen.app
import dev.kaizen.app.ui.theme.themes.KaizenThemeId
import dev.kaizen.app.ui.theme.themes.ThemeRegistry
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
class ThemeRegistryTest {
@Test
fun `registry contains all four themes`() {
assertEquals(4, ThemeRegistry.all.size)
val ids = ThemeRegistry.all.map { it.id }.toSet()
assertTrue(ids.contains(KaizenThemeId.AMBER))
assertTrue(ids.contains(KaizenThemeId.BLAU))
assertTrue(ids.contains(KaizenThemeId.MONO))
assertTrue(ids.contains(KaizenThemeId.AURORA))
}
@Test
fun `forId returns correct theme`() {
KaizenThemeId.entries.forEach { id ->
val theme = ThemeRegistry.forId(id)
assertEquals(id, theme.id)
}
}
@Test
fun `all themes have non-empty labels`() {
ThemeRegistry.all.forEach { theme ->
assertTrue("Theme ${theme.id} should have a label", theme.label.isNotBlank())
}
}
@Test
fun `all themes have distinct blob colors per scheme`() {
ThemeRegistry.all.forEach { theme ->
listOf(theme.dark, theme.light).forEach { scheme ->
val blobs = setOf(scheme.blob1, scheme.blob2, scheme.blob3, scheme.blob4)
assertTrue(
"Theme ${theme.id} should have at least 3 distinct blob colors",
blobs.size >= 3,
)
}
}
}
@Test
fun `KaizenThemeId fromString parses all ids`() {
assertEquals(KaizenThemeId.AMBER, KaizenThemeId.fromString("amber"))
assertEquals(KaizenThemeId.BLAU, KaizenThemeId.fromString("blue"))
assertEquals(KaizenThemeId.MONO, KaizenThemeId.fromString("mono"))
assertEquals(KaizenThemeId.AURORA, KaizenThemeId.fromString("aurora"))
}
@Test
fun `KaizenThemeId fromString falls back to AMBER for unknown`() {
assertEquals(KaizenThemeId.AMBER, KaizenThemeId.fromString("unknown"))
assertEquals(KaizenThemeId.AMBER, KaizenThemeId.fromString(""))
}
}