kaizen-app/DESIGN.md
Bruno Deanoz 6465b47bbb 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
2026-06-21 14:32:25 +02:00

58 KiB
Raw Permalink Blame History

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

// 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:

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.

// 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:

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

@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.

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

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

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":

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

@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:
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 700700 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 haloShadowStack 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 blurRenderEffect.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 gradientBrush.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

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 SettingsCards (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: githubgithub-dark-dimmed.
  • Brand amber: AmberDeepAmber (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 staterememberTiltState() 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.