From 163d6596c93359db68f9ffc566a9cad6737726f5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Orzech?= Date: Thu, 19 Mar 2026 10:33:20 +0100 Subject: [PATCH] docs: update micro-animations spec after review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address all review findings: fix Bouncy spring damping (0.55→0.65), add BouncyQuick for press feedback, document stagger pattern and already-animated tracking, add reduced motion support, fix nav transitions with popEnter/popExit, add disconnect confirmation dialog as new behavior, and add PulsingPlaceholder component. --- .../2026-03-19-micro-animations-design.md | 160 ++++++++++++------ 1 file changed, 111 insertions(+), 49 deletions(-) diff --git a/docs/superpowers/specs/2026-03-19-micro-animations-design.md b/docs/superpowers/specs/2026-03-19-micro-animations-design.md index 55512c3..b8f6a63 100644 --- a/docs/superpowers/specs/2026-03-19-micro-animations-design.md +++ b/docs/superpowers/specs/2026-03-19-micro-animations-design.md @@ -14,69 +14,112 @@ Central object in a new file `ui/animation/SwooshMotion.kt` providing reusable a | Name | Type | Parameters | Use case | |------|------|-----------|----------| -| `Bouncy` | Spring | dampingRatio=0.55, stiffness=400 | FAB, buttons, chips | +| `Bouncy` | Spring | dampingRatio=0.65, stiffness=400 | FAB entrance, chips, badges | +| `BouncyQuick` | Spring | dampingRatio=0.7, stiffness=1000 | Press feedback (settles in ~150ms) | | `Snappy` | Spring | dampingRatio=0.7, stiffness=800 | Expand/collapse, dialogs | | `Gentle` | Spring | dampingRatio=0.8, stiffness=300 | Cards, content reveal | | `Quick` | Tween | 200ms, FastOutSlowInEasing | Fade, color transitions | | `StaggerDelay` | Offset | 50ms per item | List item entrances | +### Reduced Motion Support + +`SwooshMotion` exposes `val isReducedMotion: Boolean` that reads the system accessibility setting (`Settings.Global.ANIMATOR_DURATION_SCALE`). When `true`, all spring specs fall back to `snap()` (instant) and all tweens use 0ms duration. Every animation in this spec must route through `SwooshMotion` so the reduced-motion check is centralized. + +### Stagger Pattern + +For staggered entrance animations (#3, #13, #14), each item uses a local `MutableState` controlled by a `LaunchedEffect`: + +```kotlin +val visible = remember { mutableStateOf(false) } +LaunchedEffect(Unit) { + delay(SwooshMotion.StaggerDelay * index) + visible.value = true +} +AnimatedVisibility(visible = visible.value, ...) { ... } +``` + +`AnimatedVisibility` has no built-in delay parameter — this pattern is the canonical approach. + +### Already-Animated Tracking (LazyColumn) + +For staggered items inside `LazyColumn` (#3), items re-enter composition on scroll. To prevent replaying the entrance animation, hoist a `mutableStateSetOf()` of already-animated item keys in `FeedScreen`: + +```kotlin +val animatedKeys = remember { mutableStateSetOf() } +// Inside each item: +val shouldAnimate = post.id !in animatedKeys +LaunchedEffect(Unit) { + if (shouldAnimate) { + delay(SwooshMotion.StaggerDelay * index) + animatedKeys.add(post.id) + } + visible.value = true // instant if already animated +} +``` + +Only animate the first batch visible on initial load (cap at ~8 items). Items appended via infinite scroll appear without stagger. + ## Feed Screen — 7 animations ### 1. FAB entrance (SPRING/Bouncy) -When the feed screen opens, the FAB scales from 0 to 1 with `Bouncy` spring. Visible overshoot — the FAB "pops" onto screen. +When the feed screen opens, the FAB scales from 0 to 1 with `Bouncy` spring. One visible overshoot — the FAB "pops" onto screen. - Compose API: `animateFloatAsState` with `Bouncy` spring on scale modifier - Trigger: `LaunchedEffect(Unit)` sets target to 1f -### 2. FAB press (SPRING/Bouncy) -On tap, FAB shrinks to 85% and springs back to 100%. +### 2. FAB press (SPRING/BouncyQuick) +On tap, FAB shrinks to 85% and snaps back to 100%. Must settle in ~150ms so it completes before navigation fires. - Compose API: `Modifier.pointerInput` detecting press → `animateFloatAsState` scale 0.85f → 1f -- Spring spec: `Bouncy` +- Spring spec: `BouncyQuick` (dampingRatio=0.7, stiffness=1000) +- Navigation fires immediately on tap — the spring animation is interrupted by the screen transition, which is fine ### 3. Post cards staggered entrance (SLIDE/Gentle) Cards slide in from bottom with cascading delay — 50ms per item. "Waterfall" effect. -- Compose API: `AnimatedVisibility` per item with `slideInVertically` + `fadeIn` -- Delay: `StaggerDelay * index` (capped at visible items, ~8 max) -- Only on initial load, not on scroll-append +- Compose API: `AnimatedVisibility` per item with `slideInVertically` + `fadeIn` using `Gentle` spring +- Delay: Stagger pattern (see shared section). Capped at first ~8 visible items +- Already-animated tracking via `animatedKeys` set (see shared section) +- Items appended via infinite scroll appear without stagger (instant `visible = true`) ### 4. "Show more" expand (SPRING/Snappy) -Card height animates with spring when text expands/collapses. -- Compose API: `Modifier.animateContentSize(animationSpec = Snappy)` -- Text content enters with `fadeIn` +Card content transitions between truncated and full text with animated height change. +- Compose API: `AnimatedContent(targetState = expanded)` with `fadeIn + expandVertically` / `fadeOut + shrinkVertically` as content transform +- Height spring: `Snappy` +- This replaces the current discrete text swap with a smooth animated transition ### 5. Empty state (FADE/Quick + scale) "No posts yet" icon and text fade in with subtle scale from 0.9 to 1.0. - Compose API: `AnimatedVisibility` with `fadeIn + scaleIn(initialScale = 0.9f)` ### 6. Queue status chip (BOUNCE/Bouncy) -During upload: chip pulses (infinite alpha animation 0.6→1.0). On status change (success/fail): bounce scale + color crossfade. +During upload: chip pulses (infinite alpha animation 0.6→1.0). On status change (success/fail): bounce scale + color crossfade. On FAILED: shake animation (horizontal offset oscillation) to draw attention. - Compose API: `rememberInfiniteTransition` for pulse; `animateColorAsState` + scale bounce on status change +- Shake on FAILED: `Animatable` with `animateTo` offset -4dp → 4dp → 0dp (3 oscillations) - Color transition: `Quick` tween +- Note: Each chip in UPLOADING state creates its own `rememberInfiniteTransition`. If many posts queue simultaneously, this is fine for ~5 items but could cause jank beyond that (unlikely in normal use) ### 7. Snackbar error (SLIDE/Snappy) Slides in from bottom with slight overshoot. Fades out on timeout. - Compose API: `AnimatedVisibility` with `slideInVertically(initialOffsetY = { it })` + `Snappy` spring - Exit: `fadeOut` with `Quick` tween -## Composer Screen — 6 animations +## Composer Screen — 7 animations ### 8. Image preview (SCALE/Bouncy) -After picking an image, the preview scales from 0 with bouncy spring. Close button ("X") rotates in (0°→360° with fade). +After picking an image, the preview scales from 0 with bouncy spring. Close button fades in with scale. - Compose API: `AnimatedVisibility` with `scaleIn` using `Bouncy` spring -- Close button: `animateFloatAsState` on rotation + `fadeIn` +- Close button: `fadeIn + scaleIn(initialScale = 0.5f)` — no rotation (an "X" icon should feel stable and immediately tappable) ### 9. Link preview card (SLIDE/Gentle) -After link loads, card slides up from below + fades in. While loading: shimmer placeholder. +After link loads, card slides up from below + fades in. While loading: pulsing placeholder card. - Compose API: `AnimatedVisibility` with `slideInVertically + fadeIn` using `Gentle` spring -- Shimmer: infinite `rememberInfiniteTransition` on a translucent gradient offset +- Loading state: Replace current `LinearProgressIndicator` with a pulsing placeholder card using `rememberInfiniteTransition` on alpha (0.3→0.7). Simpler than a full shimmer gradient — just a fading placeholder shape ### 10. Schedule chip (SPRING/Bouncy) -After picking date, chip pops in with bouncy spring. Clock icon subtly rotates. +After picking date, chip pops in with bouncy spring. - Compose API: `AnimatedVisibility` with `scaleIn` using `Bouncy` -- Icon: `animateFloatAsState` rotation 0→360 -### 11. Publish button (BOUNCE/Bouncy) -Subtle bounce on activation. During publishing: loading pulse (alpha animation). On success: checkmark icon scales in. -- Compose API: Scale bounce on click via `animateFloatAsState`; `rememberInfiniteTransition` for pulse; `AnimatedContent` for icon swap with `scaleIn` +### 11. Publish button (BOUNCE/BouncyQuick) +Subtle bounce on activation. During publishing: loading pulse. On success: checkmark icon scales in. +- Compose API: Scale bounce on click via `animateFloatAsState` with `BouncyQuick`; `rememberInfiniteTransition` for pulse; `AnimatedContent` for icon swap with `scaleIn` ### 12. Character counter color (FADE/Quick) Smooth color crossfade when exceeding 280 characters — neutral → red. @@ -85,59 +128,75 @@ Smooth color crossfade when exceeding 280 characters — neutral → red. ### 13. Action buttons staggered entrance (SCALE/Gentle) Draft, Schedule, Publish buttons — cascading scale-in with 50ms delay each. -- Compose API: `AnimatedVisibility` per button with `scaleIn` + `StaggerDelay * index` +- Compose API: Stagger pattern (see shared section) with `AnimatedVisibility` + `scaleIn + fadeIn` + +### NEW: 13b. Error text (SLIDE/Snappy) +Composer error text slides in from left + fades in, consistent with snackbar style. +- Compose API: `AnimatedVisibility` with `slideInHorizontally + fadeIn` using `Snappy` spring ## Detail Screen — 4 animations ### 14. Content reveal (FADE/Gentle) Elements appear sequentially: status badge → text → image → metadata. 80ms delay each. -- Compose API: `AnimatedVisibility` per section with `fadeIn + slideInVertically(initialOffsetY = { 20 })` -- Delay: `LaunchedEffect` with `delay(80 * index)` +- Compose API: Stagger pattern (see shared section) with `AnimatedVisibility` per section using `fadeIn + slideInVertically(initialOffsetY = { 20 })` +- Delay: 80ms per element (4 elements total = 320ms cascade) ### 15. Status badge entrance (SPRING/Bouncy) -Badge scales from 0 with bounce — first visible element, draws attention. +Badge scales from 0 with bounce — first visible element, draws attention. Part of the content reveal sequence (index 0, no delay). - Compose API: `animateFloatAsState` scale with `Bouncy` spring ### 16. Delete confirmation dialog (SCALE/Snappy) Dialog scales from center (0.8→1.0) with spring + backdrop fades in. -- Compose API: Custom dialog wrapper with `scaleIn(initialScale = 0.8f)` + `fadeIn` backdrop +- Compose API: Custom `AnimatedDialog` wrapper with `scaleIn(initialScale = 0.8f)` + `fadeIn` backdrop - Spring spec: `Snappy` ### 17. Metadata section (SLIDE/Gentle) -Bottom metadata slides up — last in the reveal sequence. -- Compose API: `AnimatedVisibility` with `slideInVertically` + `Gentle` spring +Bottom metadata slides up — last in the content reveal sequence (index 3, 240ms delay). +- Compose API: Part of stagger pattern in #14 -## Settings Screen — 2 animations +## Settings Screen — 3 animations -### 18. "Saved!" feedback (SPRING/Bouncy) -Green "Saved!" text pops in with bounce (scale 0→1). After 2 seconds, fades out. +### 18. "Settings saved" feedback (SPRING/Bouncy) +Existing "Settings saved" text (currently static conditional) gets animated: pops in with bounce (scale 0→1). After 2 seconds, fades out. - Compose API: `AnimatedVisibility` with `scaleIn` using `Bouncy` spring; `LaunchedEffect` → `delay(2000)` → hide - Exit: `fadeOut` with `Quick` tween +- Text and color remain as-is: "Settings saved" with `MaterialTheme.colorScheme.primary` -### 19. Disconnect dialog (FADE+SCALE/Snappy) -Same pattern as delete dialog — scale from center + backdrop fade. "Disconnect" button has subtle red pulse. -- Compose API: Same custom dialog wrapper as #16 +### 19. Disconnect confirmation dialog (NEW behavior + FADE+SCALE/Snappy) +**New behavior:** Add a confirmation dialog before disconnect (currently the button directly clears credentials). The dialog uses the same `AnimatedDialog` wrapper as #16. "Disconnect" button has subtle red pulse. +- Compose API: Same `AnimatedDialog` wrapper as #16 - Red pulse: `rememberInfiniteTransition` on alpha of error color +### NEW: 19b. Disconnect dialog behavior +The `OutlinedButton` currently calls `credentials.clear()` + `onLogout()` directly. After this change, it shows a confirmation dialog first. Confirm action triggers the existing clear + logout flow. + ## Navigation Transitions — 5 +Each `composable()` call in `NavGraph.kt` receives `enterTransition`, `exitTransition`, `popEnterTransition`, and `popExitTransition` lambdas. Back navigation uses `popEnterTransition`/`popExitTransition` (the reverse of the enter animation) — these are distinct parameters, not automatic. + ### Feed → Composer -Slide up from bottom + fade. Conceptually: FAB transforms into full screen. +Slide up from bottom + fade. - `enterTransition = slideInVertically(initialOffsetY = { it }) + fadeIn()` -- `exitTransition = slideOutVertically(targetOffsetY = { it }) + fadeOut()` +- `exitTransition = fadeOut()` +- `popEnterTransition = fadeIn()` +- `popExitTransition = slideOutVertically(targetOffsetY = { it }) + fadeOut()` ### Feed → Detail -Slide in from right + fade. Post card expands into full view. +Slide in from right + fade. - `enterTransition = slideInHorizontally(initialOffsetX = { it }) + fadeIn()` -- `exitTransition = slideOutHorizontally(targetOffsetX = { it }) + fadeOut()` +- `exitTransition = fadeOut()` +- `popEnterTransition = fadeIn()` +- `popExitTransition = slideOutHorizontally(targetOffsetX = { it }) + fadeOut()` + +### Detail → Composer (via Edit) +Same as Feed → Composer (slide up from bottom). The Composer always enters with the same transition regardless of source. ### Feed → Settings Standard slide from right. - `enterTransition = slideInHorizontally(initialOffsetX = { it })` -- `exitTransition = slideOutHorizontally(targetOffsetX = { it })` - -### Back (all screens) -Reverse of the entry animation for each screen. +- `exitTransition = fadeOut()` +- `popEnterTransition = fadeIn()` +- `popExitTransition = slideOutHorizontally(targetOffsetX = { it })` ### Setup → Feed Crossfade — smooth transition from animated setup background to feed. @@ -149,24 +208,26 @@ Crossfade — smooth transition from animated setup background to feed. ``` ui/ ├── animation/ -│ └── SwooshMotion.kt # Shared animation specs object +│ └── SwooshMotion.kt # Shared animation specs object + reduced motion check ├── components/ -│ └── AnimatedDialog.kt # Reusable animated dialog wrapper (used by #16, #19) +│ ├── AnimatedDialog.kt # Reusable animated dialog wrapper (used by #16, #19) +│ └── PulsingPlaceholder.kt # Pulsing loading placeholder (used by #9) ├── feed/ │ └── FeedScreen.kt # Modified: #1-#7 ├── composer/ -│ └── ComposerScreen.kt # Modified: #8-#13 +│ └── ComposerScreen.kt # Modified: #8-#13, #13b ├── detail/ │ └── DetailScreen.kt # Modified: #14-#17 ├── settings/ -│ └── SettingsScreen.kt # Modified: #18-#19 +│ └── SettingsScreen.kt # Modified: #18-#19, #19b (new disconnect dialog) └── navigation/ - └── NavGraph.kt # Modified: navigation transitions + └── NavGraph.kt # Modified: navigation transitions with enter/exit/popEnter/popExit ``` -## New files: 2 +## New files: 3 - `ui/animation/SwooshMotion.kt` - `ui/components/AnimatedDialog.kt` +- `ui/components/PulsingPlaceholder.kt` ## Modified files: 5 - `FeedScreen.kt`, `ComposerScreen.kt`, `DetailScreen.kt`, `SettingsScreen.kt`, `NavGraph.kt` @@ -176,3 +237,4 @@ ui/ - Existing unit tests should pass unchanged (animations don't affect business logic) - Manual verification: each animation visually correct on emulator - Compose Preview for individual animated components where feasible +- Test with system "Remove animations" setting enabled to verify reduced-motion fallback