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