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