Candidate 3: merge polish-lane-e

This commit is contained in:
root 2026-11-08 06:50:16 +01:00
commit f29ecaace0
17 changed files with 903 additions and 29 deletions

View file

@ -30,6 +30,7 @@
"receiptPaddingX": { "$type": "dimension", "px": 30, "dp": 15, "source": "index.html:243" }, "receiptPaddingX": { "$type": "dimension", "px": 30, "dp": 15, "source": "index.html:243" },
"receiptGap": { "$type": "dimension", "px": 10, "dp": 5, "source": "index.html:243" }, "receiptGap": { "$type": "dimension", "px": 10, "dp": 5, "source": "index.html:243" },
"receiptGlyph": { "$type": "dimension", "px": 36, "dp": 18, "source": "index.html:245" }, "receiptGlyph": { "$type": "dimension", "px": 36, "dp": 18, "source": "index.html:245" },
"thumbGlyph": { "$type": "dimension", "px": 40, "dp": 20, "source": "ui-polish P12, approved 2026-11-07: film/TV glyph centred in a request thumbnail without a poster" },
"buttonMargin": { "$type": "dimension", "px": 12, "dp": 6, "source": "index.html:241" }, "buttonMargin": { "$type": "dimension", "px": 12, "dp": 6, "source": "index.html:241" },
"skeletonTitleWidth": { "$type": "dimension", "px": 360, "dp": 180, "source": "index.html:1902" }, "skeletonTitleWidth": { "$type": "dimension", "px": 360, "dp": 180, "source": "index.html:1902" },
"skeletonTitleHeight": { "$type": "dimension", "px": 34, "dp": 17, "source": "index.html:1902" }, "skeletonTitleHeight": { "$type": "dimension", "px": 34, "dp": 17, "source": "index.html:1902" },

View file

@ -7,6 +7,8 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.SideEffect
import androidx.compose.foundation.hoverable import androidx.compose.foundation.hoverable
import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsHoveredAsState import androidx.compose.foundation.interaction.collectIsHoveredAsState
@ -18,6 +20,9 @@ import androidx.compose.ui.draw.drawWithCache
import androidx.compose.ui.geometry.CornerRadius import androidx.compose.ui.geometry.CornerRadius
import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.graphics.drawscope.Stroke import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.Dp
@ -37,7 +42,7 @@ fun Modifier.focusScale(focused: Boolean, scale: Float): Modifier {
/** Lift shadows remain specific to cards and buttons. */ /** Lift shadows remain specific to cards and buttons. */
enum class FocusLift { None, Card, Button } enum class FocusLift { None, Card, Button }
/** Every focus ring straddles the surface edge above content, without a background gap. */ /** Hosted cards keep their lift and share the surface stroke; standalone rings straddle the surface edge. */
@Composable @Composable
fun Modifier.focusRing(focused: Boolean, radius: Dp, width: Dp, lift: FocusLift = FocusLift.None): Modifier { fun Modifier.focusRing(focused: Boolean, radius: Dp, width: Dp, lift: FocusLift = FocusLift.None): Modifier {
val reduced = reducedMotion() val reduced = reducedMotion()
@ -55,6 +60,23 @@ fun Modifier.focusRing(focused: Boolean, radius: Dp, width: Dp, lift: FocusLift
alpha = ringAlpha * if (lift == FocusLift.Card) WolfskinTokens.Lift.cardOpacity else WolfskinTokens.Lift.buttonOpacity alpha = ringAlpha * if (lift == FocusLift.Card) WolfskinTokens.Lift.cardOpacity else WolfskinTokens.Lift.buttonOpacity
} }
} }
val host = LocalCardRing.current.takeIf { lift == FocusLift.Card }
if (host != null) {
val owner = remember { Any() }
val density = LocalDensity.current
val scale = WolfskinTokens.Focus.scaleCard
val corner = with(density) { radius.toPx() } * scale
val stroke = with(density) { width.toPx() } * scale
DisposableEffect(host, focused) {
onDispose { if (focused) host.release(owner) }
}
SideEffect { if (focused) host.retainFocus() }
return if (!focused) shadow else shadow.onGloballyPositioned { coordinates ->
val center = coordinates.localToRoot(Offset(coordinates.size.width / 2f, coordinates.size.height / 2f))
val size = Size(coordinates.size.width * scale, coordinates.size.height * scale)
host.update(owner, CardRingGeometry(Rect(center - Offset(size.width / 2, size.height / 2), size), corner, stroke))
}
}
return shadow.drawWithCache { return shadow.drawWithCache {
val stroke = Stroke(width.toPx()) val stroke = Stroke(width.toPx())
val corner = CornerRadius(radius.toPx()) val corner = CornerRadius(radius.toPx())

View file

@ -0,0 +1,136 @@
// SPDX-License-Identifier: GPL-2.0-only
package org.justinvisser.wolfskin.design
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.AnimationVector4D
import androidx.compose.animation.core.TwoWayConverter
import androidx.compose.animation.core.spring
import androidx.compose.animation.core.tween
import androidx.compose.foundation.layout.Box
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawWithContent
import androidx.compose.ui.geometry.CornerRadius
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.drawscope.DrawScope
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.layout.onPlaced
import androidx.compose.ui.layout.positionInRoot
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.CoroutineStart
import kotlinx.coroutines.Job
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.launch
import kotlinx.coroutines.yield
import org.justinvisser.wolfskin.design.WolfskinTokens.Motion as M
private val boundsConverter = TwoWayConverter<Rect, AnimationVector4D>(
{ AnimationVector4D(it.left, it.top, it.right, it.bottom) },
{ Rect(it.v1, it.v2, it.v3, it.v4) },
)
internal data class CardRingGeometry(val bounds: Rect, val radius: Float, val stroke: Float)
private data class CardRingTarget(val owner: Any, val geometry: CardRingGeometry)
internal val LocalCardRing = staticCompositionLocalOf<CardRingMotion?> { null }
/** Target geometry follows layout; only the displacement from it carries spring velocity. */
@Stable
internal class CardRingMotion(private val scope: CoroutineScope) {
var reduced = false
private var target by mutableStateOf<CardRingTarget?>(null)
private var retained by mutableStateOf<CardRingGeometry?>(null)
private val displacement = Animatable(Rect.Zero, boundsConverter)
private val radiusDisplacement = Animatable(0f)
val opacity = Animatable(0f)
private var geometryJob: Job? = null
private var fadeJob: Job? = null
private var releaseJob: Job? = null
private var stroke = Stroke(0f)
fun geometry(): CardRingGeometry? {
val destination = target?.geometry ?: return retained
if (reduced) return destination
return CardRingGeometry(destination.bounds + displacement.value,
(destination.radius + radiusDisplacement.value).coerceAtLeast(0f), destination.stroke)
}
fun update(owner: Any, geometry: CardRingGeometry) {
releaseJob?.cancel()
if (stroke.width != geometry.stroke) stroke = Stroke(geometry.stroke)
val previous = target
if (previous?.owner === owner) {
target = CardRingTarget(owner, geometry)
return
}
val current = this.geometry()
val velocity = displacement.velocity
val radiusVelocity = radiusDisplacement.velocity
geometryJob?.cancel()
target = CardRingTarget(owner, geometry)
geometryJob = scope.launch(start = CoroutineStart.UNDISPATCHED) {
displacement.snapTo(if (previous != null && current != null && !reduced) current.bounds - geometry.bounds else Rect.Zero)
radiusDisplacement.snapTo(if (previous != null && current != null && !reduced) current.radius - geometry.radius else 0f)
if (!reduced) coroutineScope {
launch { displacement.animateTo(Rect.Zero, spring(M.navBlobDampingRatio, M.navBlobStiffness), initialVelocity = velocity) }
launch { radiusDisplacement.animateTo(0f, spring(M.navBlobDampingRatio, M.navBlobStiffness), initialVelocity = radiusVelocity) }
}
}
if (previous == null) fade(true)
}
fun drawRing(scope: DrawScope, origin: Offset) {
val geometry = target?.geometry ?: retained ?: return
val alpha = opacity.value
if (alpha <= 0f) return
val delta = if (target != null && !reduced) displacement.value else Rect.Zero
val radius = geometry.radius + if (target != null && !reduced) radiusDisplacement.value else 0f
scope.drawRoundRect(WolfskinTokens.Color.accent.copy(alpha = alpha),
topLeft = geometry.bounds.topLeft + Offset(delta.left, delta.top) - origin,
size = Size(geometry.bounds.width + delta.right - delta.left, geometry.bounds.height + delta.bottom - delta.top),
cornerRadius = CornerRadius(radius.coerceAtLeast(0f)), style = stroke)
}
fun retainFocus() { releaseJob?.cancel() }
fun release(owner: Any) {
// A replacement card registers during layout in the same composition; it must not fade the ring.
releaseJob?.cancel()
releaseJob = scope.launch {
yield()
if (target?.owner === owner) {
retained = geometry()
target = null
geometryJob?.cancel()
fade(false)
}
}
}
private fun fade(visible: Boolean) {
fadeJob?.cancel()
fadeJob = scope.launch(start = CoroutineStart.UNDISPATCHED) {
opacity.animateTo(if (visible) 1f else 0f, tween(motionDuration(M.focusMove, reduced), easing = M.easeOut))
}
}
}
private operator fun Rect.plus(other: Rect) = Rect(left + other.left, top + other.top, right + other.right, bottom + other.bottom)
private operator fun Rect.minus(other: Rect) = Rect(left - other.left, top - other.top, right - other.right, bottom - other.bottom)
/** One draw-only card ring sits above surface content without changing layout or focus ownership. */
@Composable
fun FocusRingHost(content: @Composable () -> Unit) {
val scope = rememberCoroutineScope()
val motion = remember(scope) { CardRingMotion(scope) }
val reduced = reducedMotion()
SideEffect { motion.reduced = reduced }
var origin by remember { mutableStateOf(Offset.Zero) }
Box(Modifier.onPlaced { origin = it.positionInRoot() }.drawWithContent {
drawContent()
motion.drawRing(this, origin)
}) {
CompositionLocalProvider(LocalCardRing provides motion, content = content)
}
}

View file

@ -1,19 +1,72 @@
// SPDX-License-Identifier: GPL-2.0-only // SPDX-License-Identifier: GPL-2.0-only
package org.justinvisser.wolfskin.design package org.justinvisser.wolfskin.design
import androidx.compose.animation.core.animate import androidx.compose.animation.core.AnimationState
import androidx.compose.animation.core.tween import androidx.compose.animation.core.animateTo
import androidx.compose.animation.core.spring
import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.runtime.withFrameNanos
import kotlin.coroutines.CoroutineContext
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.CoroutineStart
import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.currentCoroutineContext
import kotlinx.coroutines.ensureActive
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import java.util.WeakHashMap
import kotlin.math.abs
import kotlin.math.sign
import kotlin.math.sqrt
/** Every frame follows focus with the same bounded scroll tween. */ private class ScrollMomentum {
internal suspend fun animateFrameScroll(list: LazyListState, distance: Float) { var velocity = 0f
if (distance == 0f) return var frameTime = Long.MIN_VALUE
list.scroll { var generation = 0L
var previous = 0f fun reset() { velocity = 0f; frameTime = Long.MIN_VALUE }
animate(0f, distance, animationSpec = tween(if (NetworkArtTransition.reduced) 0 else WolfskinTokens.Motion.scroll,
easing = WolfskinTokens.Motion.easeOut)) { value, _ -> fun release(context: CoroutineContext, generation: Long) {
scrollBy(value - previous) if (this.generation != generation) return
previous = value if (context.isActive) { reset(); return }
// A replacement must claim the carry before the next frame, without keeping the old mutation alive.
CoroutineScope(context + NonCancellable).launch(start = CoroutineStart.UNDISPATCHED) {
try { withFrameNanos { } }
finally { if (this@ScrollMomentum.generation == generation) reset() }
} }
} }
} }
// Scroll mutations and animation callbacks run on the UI thread; values never retain their weak keys.
private val scrollMomentum = WeakHashMap<LazyListState, ScrollMomentum>()
private val scrollAngularFrequency = sqrt(WolfskinTokens.Motion.navBlobStiffness)
/** Key-driven frames and shelves share a bounded nav spring and carry same-direction velocity. */
internal suspend fun animateFrameScroll(list: LazyListState, distance: Float) {
val momentum = scrollMomentum.getOrPut(list) { ScrollMomentum() }
val generation = ++momentum.generation
if (distance == 0f) { momentum.reset(); return }
try {
list.scroll {
if (NetworkArtTransition.reduced) {
momentum.reset()
scrollBy(distance)
} else {
val maximum = scrollAngularFrequency * abs(distance)
val velocity = if (momentum.velocity.sign == distance.sign) momentum.velocity.coerceIn(-maximum, maximum) else 0f
var previous = 0f
// A fresh frame-clock start would insert a stationary frame on every held-key repeat.
AnimationState(0f, velocity, lastFrameTimeNanos = momentum.frameTime).animateTo(distance,
animationSpec = spring(WolfskinTokens.Motion.navBlobDampingRatio, WolfskinTokens.Motion.navBlobStiffness),
sequentialAnimation = momentum.frameTime != Long.MIN_VALUE) {
scrollBy(value - previous)
previous = value
momentum.velocity = this.velocity
momentum.frameTime = lastFrameTimeNanos
}
}
}
} finally {
val context = currentCoroutineContext()
momentum.release(context, generation)
context.ensureActive()
}
}

View file

@ -1,7 +1,5 @@
package org.justinvisser.wolfskin.design package org.justinvisser.wolfskin.design
import androidx.compose.animation.core.tween
import androidx.compose.foundation.gestures.animateScrollBy
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.itemsIndexed
@ -69,7 +67,7 @@ fun HomeScreen(
(heights.take(view.row).fold(topInset) { total, height -> total + height } - if (snapshot.rows.getOrNull(view.row)?.kind == HomeRowKind.Hero) WolfskinTokens.Shell.zero else L.pinnedLine).toPx() (heights.take(view.row).fold(topInset) { total, height -> total + height } - if (snapshot.rows.getOrNull(view.row)?.kind == HomeRowKind.Hero) WolfskinTokens.Shell.zero else L.pinnedLine).toPx()
}.coerceAtLeast(0f) }.coerceAtLeast(0f)
val current = with(density) { heights.take(view.listState.firstVisibleItemIndex).fold(WolfskinTokens.Shell.zero) { total, height -> total + height }.toPx() } + view.listState.firstVisibleItemScrollOffset val current = with(density) { heights.take(view.listState.firstVisibleItemIndex).fold(WolfskinTokens.Shell.zero) { total, height -> total + height }.toPx() } + view.listState.firstVisibleItemScrollOffset
view.listState.animateScrollBy(target - current, tween(if (NetworkArtTransition.reduced) 0 else WolfskinTokens.Motion.scroll, easing = WolfskinTokens.Motion.easeOut)) animateFrameScroll(view.listState, target - current)
view.home.positionedKey = key view.home.positionedKey = key
view.home.positionedReset = view.home.resetScroll view.home.positionedReset = view.home.resetScroll
} }

View file

@ -0,0 +1,53 @@
// SPDX-License-Identifier: GPL-2.0-only
package org.justinvisser.wolfskin.design
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.tween
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.Stable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.graphics.Color
import org.justinvisser.wolfskin.design.WolfskinTokens.Motion as M
/** One pending playback start per surface; the player opens only after the page has faded to black. */
@Stable
class PlaybackStart {
/** The control that reads "Starting…" while the stream is prepared, or null. */
var starting by mutableStateOf<String?>(null)
private set
/** Set once the player is ready; the veil runs it exactly once, when the screen is black. */
var launch by mutableStateOf<(() -> Unit)?>(null)
private set
fun begin(control: String) { starting = control; launch = null }
fun fail() { starting = null; launch = null }
fun ready(open: () -> Unit) { launch = open }
internal fun finish() { starting = null; launch = null }
}
/** The hand-off is one black rectangle drawn over the page, never a second screen layer. */
@Composable
fun PlaybackHandoffVeil(start: PlaybackStart, modifier: Modifier = Modifier) {
val pending = start.launch
val reduced = reducedMotion()
val alpha = remember { Animatable(0f) }
LaunchedEffect(pending, reduced) {
if (pending == null) {
alpha.snapTo(0f)
return@LaunchedEffect
}
alpha.animateTo(1f, tween(motionDuration(M.base, reduced), easing = M.ease))
start.finish()
pending()
}
if (pending != null || alpha.value > 0f) Box(modifier.fillMaxSize().drawBehind { drawRect(Color.Black.copy(alpha = alpha.value)) })
}

View file

@ -0,0 +1,75 @@
// SPDX-License-Identifier: GPL-2.0-only
package org.justinvisser.wolfskin.design
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.tween
import androidx.compose.foundation.layout.Box
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.State
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.CompositingStrategy
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.layout.Layout
import androidx.compose.ui.semantics.clearAndSetSemantics
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.unit.Constraints
import androidx.tv.material3.Text
import org.justinvisser.wolfskin.design.WolfskinTokens.Motion as M
/** Side panels enter over sheetIn and leave over sheetOut on the shared ease-out; reduced motion caps both at the fade limit. */
@Composable
internal fun rememberSheetFade(visible: Boolean, initial: Float = 0f): State<Float> {
val reduced = reducedMotion()
val progress = remember { Animatable(initial) }
LaunchedEffect(visible, reduced) {
progress.animateTo(if (visible) 1f else 0f,
tween(motionDuration(if (visible) M.sheetIn else M.sheetOut, reduced), easing = M.easeOut))
}
return progress.asState()
}
/** The scrim stays a draw-only colour, so a fading sheet never adds a full-screen layer. */
internal fun Modifier.sheetScrim(progress: () -> Float): Modifier = drawBehind {
val scrim = WolfskinTokens.Color.sheetScrim
drawRect(scrim.copy(alpha = scrim.alpha * progress()))
}
/** The bounded panel modulates each draw, so its outside shadow fades with it and no offscreen buffer is allocated. */
internal fun Modifier.sheetPanelFade(progress: () -> Float): Modifier = graphicsLayer {
alpha = progress()
compositingStrategy = CompositingStrategy.ModulateAlpha
}
/** Focus and selection colours retarget over focusMove on the ease-out; reduced motion caps the fade. */
@Composable
internal fun animatedFocusColor(target: Color, label: String = "focus-colour"): Color {
val reduced = reducedMotion()
return animateColorAsState(target, tween(motionDuration(M.focusMove, reduced), easing = M.easeOut), label = label).value
}
/** A pending action keeps its button's resting width: the resting content fades out while the busy label fades in over base. */
@Composable
internal fun BusyLabelSwap(busy: Boolean, busyLabel: String, color: Color, style: TextStyle, content: @Composable () -> Unit) {
val reduced = reducedMotion()
val progress = remember { Animatable(if (busy) 1f else 0f) }
LaunchedEffect(busy, reduced) {
progress.animateTo(if (busy) 1f else 0f, tween(motionDuration(M.base, reduced), easing = M.ease))
}
val showLabel = busy || progress.value > 0f
Layout(content = {
Box(Modifier.graphicsLayer { alpha = 1f - progress.value }.then(if (busy) Modifier.clearAndSetSemantics { } else Modifier)) { content() }
if (showLabel) Text(busyLabel, Modifier.graphicsLayer { alpha = progress.value }, color = color, style = style, maxLines = 1, softWrap = false)
}) { measurables, constraints ->
val resting = measurables[0].measure(constraints)
val label = measurables.getOrNull(1)?.measure(Constraints())
layout(resting.width, resting.height) {
resting.place(0, 0)
if (label != null) label.place((resting.width - label.width) / 2, (resting.height - label.height) / 2)
}
}
}

View file

@ -1,14 +1,24 @@
// SPDX-License-Identifier: GPL-2.0-only // SPDX-License-Identifier: GPL-2.0-only
package org.justinvisser.wolfskin.design package org.justinvisser.wolfskin.design
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.tween
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.SideEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.dropShadow import androidx.compose.ui.draw.dropShadow
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.CompositingStrategy
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.graphics.shadow.Shadow import androidx.compose.ui.graphics.shadow.Shadow
import androidx.compose.ui.platform.testTag import androidx.compose.ui.platform.testTag
import androidx.compose.ui.unit.DpOffset import androidx.compose.ui.unit.DpOffset
@ -16,14 +26,31 @@ import androidx.tv.material3.Text
import org.justinvisser.wolfskin.design.WolfskinTokens.Color as C import org.justinvisser.wolfskin.design.WolfskinTokens.Color as C
import org.justinvisser.wolfskin.design.WolfskinTokens.Home as H import org.justinvisser.wolfskin.design.WolfskinTokens.Home as H
import org.justinvisser.wolfskin.design.WolfskinTokens.Layout as L import org.justinvisser.wolfskin.design.WolfskinTokens.Layout as L
import org.justinvisser.wolfskin.design.WolfskinTokens.Motion as M
/** Callers pass their current message; a cleared message fades out with its last text instead of vanishing. */
@Composable
fun RecoveryToastHost(message: String?, modifier: Modifier = Modifier) {
val reduced = reducedMotion()
var last by remember { mutableStateOf(message) }
val opacity = remember { Animatable(0f) }
SideEffect { if (message != null) last = message }
LaunchedEffect(message != null, reduced) {
opacity.animateTo(if (message != null) 1f else 0f, tween(motionDuration(M.base, reduced), easing = M.ease))
}
val text = message ?: last ?: return
if (message == null && opacity.value == 0f) return
RecoveryToast(text, modifier) { opacity.value }
}
/** Recovery messages remain transient and never take focus from the action that failed. */ /** Recovery messages remain transient and never take focus from the action that failed. */
@Composable @Composable
fun RecoveryToast(message: String, modifier: Modifier = Modifier) { fun RecoveryToast(message: String, modifier: Modifier = Modifier, opacity: () -> Float = { 1f }) {
Box(modifier.fillMaxSize()) { Box(modifier.fillMaxSize()) {
val shape = androidx.compose.runtime.remember { RoundedCornerShape(WolfskinTokens.Radius.panel) } val shape = remember { RoundedCornerShape(WolfskinTokens.Radius.panel) }
Row(Modifier.align(Alignment.TopEnd).offset(-L.safeSide, H.toastTop) Row(Modifier.align(Alignment.TopEnd).offset(-L.safeSide, H.toastTop)
.widthIn(max = H.toastWidth) .widthIn(max = H.toastWidth)
.graphicsLayer { alpha = opacity(); compositingStrategy = CompositingStrategy.ModulateAlpha }
.dropShadow(shape, Shadow(radius = H.toastShadowBlur, color = Color.Black.copy(alpha = H.toastShadowOpacity), .dropShadow(shape, Shadow(radius = H.toastShadowBlur, color = Color.Black.copy(alpha = H.toastShadowOpacity),
offset = DpOffset(WolfskinTokens.Shell.zero, H.toastShadowY))) offset = DpOffset(WolfskinTokens.Shell.zero, H.toastShadowY)))
.background(C.surface2, shape) .background(C.surface2, shape)

View file

@ -1,7 +1,5 @@
package org.justinvisser.wolfskin.design package org.justinvisser.wolfskin.design
import androidx.compose.animation.core.tween
import androidx.compose.foundation.gestures.animateScrollBy
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
@ -57,13 +55,12 @@ val SETTINGS_GROUPS = listOf(
@Composable @Composable
fun SettingsList(state: ShellState, modifier: Modifier = Modifier, requestCount: Int? = null, installedVersion: String = "") { fun SettingsList(state: ShellState, modifier: Modifier = Modifier, requestCount: Int? = null, installedVersion: String = "") {
val list = state.view.listState val list = state.view.listState
val reduced = reducedMotion()
val density = LocalDensity.current val density = LocalDensity.current
LaunchedEffect(state.settingsRow, state.navFocused) { LaunchedEffect(state.settingsRow, state.navFocused) {
if (state.navFocused) return@LaunchedEffect if (state.navFocused) return@LaunchedEffect
val target = (state.settingsRow - WolfskinTokens.Update.settingsVisibleRows.toInt() + 1).coerceAtLeast(0) val target = (state.settingsRow - WolfskinTokens.Update.settingsVisibleRows.toInt() + 1).coerceAtLeast(0)
val distance = with(density) { (L.row_srow * (target - list.firstVisibleItemIndex)).toPx() } - list.firstVisibleItemScrollOffset val distance = with(density) { (L.row_srow * (target - list.firstVisibleItemIndex)).toPx() } - list.firstVisibleItemScrollOffset
list.animateScrollBy(distance, tween(if (reduced) 0 else WolfskinTokens.Motion.scroll, easing = WolfskinTokens.Motion.easeOut)) animateFrameScroll(list, distance)
} }
Box(modifier.fillMaxSize()) { Box(modifier.fillMaxSize()) {
Row(Modifier.offset(L.safeSide, WolfskinTokens.Update.headerTop).height(WolfskinTokens.Update.headerHeight), Row(Modifier.offset(L.safeSide, WolfskinTokens.Update.headerTop).height(WolfskinTokens.Update.headerHeight),

View file

@ -1,7 +1,5 @@
package org.justinvisser.wolfskin.design package org.justinvisser.wolfskin.design
import androidx.compose.animation.core.tween
import androidx.compose.foundation.gestures.animateScrollBy
import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.LazyListState
/** Preserve one lookahead card and clamp short shelves and row ends to their real contents. */ /** Preserve one lookahead card and clamp short shelves and row ends to their real contents. */
@ -15,7 +13,6 @@ internal fun shelfFirst(column: Int, previous: Int, slots: Int, count: Int): Int
/** Reconcile the actual track on every focus transition, including interrupted or restored offsets. */ /** Reconcile the actual track on every focus transition, including interrupted or restored offsets. */
internal suspend fun LazyListState.scrollShelfTo(first: Int, pitch: Float) { internal suspend fun LazyListState.scrollShelfTo(first: Int, pitch: Float) {
val distance = (first - firstVisibleItemIndex) * pitch - firstVisibleItemScrollOffset val distance = (first - firstVisibleItemIndex) * pitch - firstVisibleItemScrollOffset
if (distance != 0f) animateScrollBy(distance, tween(if (NetworkArtTransition.reduced) 0 else WolfskinTokens.Motion.base, animateFrameScroll(this, distance)
easing = WolfskinTokens.Motion.ease))
if (firstVisibleItemIndex != first || firstVisibleItemScrollOffset != 0) scrollToItem(first) if (firstVisibleItemIndex != first || firstVisibleItemScrollOffset != 0) scrollToItem(first)
} }

View file

@ -229,6 +229,8 @@ object WolfskinTokens {
const val focusMove = 180 const val focusMove = 180
const val captionDelay = 60 const val captionDelay = 60
const val captionOut = 100 const val captionOut = 100
const val sheetIn = 160
const val sheetOut = 100
const val artReveal = 160 const val artReveal = 160
const val heroFade = 320 const val heroFade = 320
const val heroCopyOut = 120 const val heroCopyOut = 120
@ -319,6 +321,7 @@ object WolfskinTokens {
val receiptPaddingX = 15.0.dp val receiptPaddingX = 15.0.dp
val receiptGap = 5.0.dp val receiptGap = 5.0.dp
val receiptGlyph = 18.0.dp val receiptGlyph = 18.0.dp
val thumbGlyph = 20.0.dp
val buttonMargin = 6.0.dp val buttonMargin = 6.0.dp
val skeletonTitleWidth = 180.0.dp val skeletonTitleWidth = 180.0.dp
val skeletonTitleHeight = 17.0.dp val skeletonTitleHeight = 17.0.dp

View file

@ -0,0 +1,251 @@
// SPDX-License-Identifier: GPL-2.0-only
package org.justinvisser.wolfskin.design
import android.app.Application
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.graphics.Rect
import android.view.View
import android.view.WindowManager
import androidx.activity.ComponentActivity
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.runtime.*
import androidx.compose.runtime.snapshots.Snapshot
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.test.*
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.unit.dp
import com.github.takahirom.roborazzi.captureRoboImage
import java.io.File
import kotlin.math.abs
import org.junit.Assert.*
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
import org.robolectric.annotation.GraphicsMode
/** Native ring pixels cover translation, shape morphing, scroll tracking and legacy parity. */
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi")
@GraphicsMode(GraphicsMode.Mode.NATIVE)
class FocusGlideTest {
@get:Rule val rule = createAndroidComposeRule<ComponentActivity>()
private val directory get() = File(requireNotNull(System.getProperty("wolfskin.captureDirectory")), "ui-polish/E02").apply { mkdirs() }
private val accentArgb = WolfskinTokens.Color.accent.toArgb()
private var pixels = IntArray(0)
@Before fun fullscreen() {
rule.activity.window.setFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN, WindowManager.LayoutParams.FLAG_FULLSCREEN)
@Suppress("DEPRECATION")
rule.activity.window.decorView.systemUiVisibility = View.SYSTEM_UI_FLAG_FULLSCREEN or View.SYSTEM_UI_FLAG_HIDE_NAVIGATION or View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY
}
@Test fun shelfRightAndRowDownGlideAsOneShape() = shelves(false)
@Test fun reducedShelfGeometrySnapsInOneFrame() = shelves(true)
private fun shelves(reduced: Boolean) {
val row = mutableIntStateOf(0)
val column = mutableIntStateOf(0)
val memory = HomeViewMemory()
val cards = List(5) { HomeTitle("$it", "Film $it", "movie") }
val tiles = HomeRow("tiles", HomeRowKind.Shelf, cards = cards, cardKind = HomeCardKind.Tile)
val posters = tiles.copy(id = "posters", cardKind = HomeCardKind.Poster)
rule.setContent { CompositionLocalProvider(LocalReducedMotion provides reduced) { WolfskinTheme {
Box(Modifier.requiredSize(WolfskinTokens.Shell.stageWidth, WolfskinTokens.Shell.stageHeight).background(WolfskinTokens.Color.bg)) {
FocusRingHost { Column(Modifier.padding(top = 40.dp)) {
HomeShelf(tiles, memory, row.intValue == 0, column.intValue, emptyList(), {}, {})
HomeShelf(posters, memory, row.intValue == 1, column.intValue, emptyList(), {}, {})
} }
}
} } }
rule.mainClock.advanceTimeBy(1000)
rule.mainClock.autoAdvance = false
val suffix = if (reduced) "reduced/" else ""
val samples = mutableListOf<String>()
fun frame(phase: String, index: Int): Rect {
val image = capture("$suffix$phase-${index.toString().padStart(2, '0')}")
val bounds = accentBounds(image)
val runs = edgeRuns(image, bounds.centerY())
samples += "$phase,${index * 16},${bounds.left},${bounds.top},${bounds.width()},${bounds.height()},$runs"
image.recycle()
return bounds
}
val start = frame("shelf-right", 0)
commit { column.intValue = 1 }
val right = List(21) { index -> step(); frame("shelf-right", index + 1) }
commit { row.intValue = 1 }
val down = List(21) { index -> step(); frame("row-down", index) }
commit { row.intValue = -1 }
val fadeBudget = if (reduced) WolfskinTokens.Motion.reducedFade else WolfskinTokens.Motion.focusMove
repeat((fadeBudget + 15) / 16 + 1) { step() }
val hidden = capture("${suffix}nav-exit")
assertTrue("Leaving cards fades the ring completely", accentBounds(hidden).isEmpty)
val lastRing = down.last()
val background = WolfskinTokens.Color.bg.toArgb()
for (y in lastRing.top - 3..lastRing.top + 7) for (shift in listOf(0, 8, 16)) {
val pixel = hidden.getPixel(lastRing.centerX(), y)
assertTrue("No residual ring alpha after the fade deadline",
abs((pixel ushr shift and 255) - (background ushr shift and 255)) <= 2)
}
hidden.recycle()
File(directory, "${suffix}samples.csv").writeText("phase,timeMs,left,top,width,height,edgeRuns\n" + samples.joinToString("\n"))
assertTrue("Ring must travel to the next card", right.last().left > start.left)
monotonic(listOf(start.left) + right.map { it.left })
assertTrue("Nav spring must be visually settled by 300ms", abs(right.last().left - right[18].left) <= 2)
monotonic(listOf(start.width()) + down.map { it.width() })
monotonic(listOf(start.height()) + down.map { it.height() })
if (reduced) {
assertEquals("Reduced horizontal move snaps on frame one", right.last(), right.first())
assertEquals("Reduced shape change snaps on frame one", down.last(), down.first())
} else {
assertTrue("Horizontal move must glide, not jump", right.first().left < right.last().left)
assertTrue("Width must morph", down.first().width() != down.last().width())
assertTrue("Height must morph", down.first().height() != down.last().height())
}
samples.filter { it.startsWith("shelf-right") }.forEach { assertEquals("Exactly one ring crosses its midline: $it", 2, it.substringAfterLast(',').toInt()) }
}
@Test fun circleRadiusMorphsOnTheNavSpring() = circleRadius(false)
@Test fun reducedCircleRadiusSnapsInOneFrame() = circleRadius(true)
private fun circleRadius(reduced: Boolean) {
val selected = mutableIntStateOf(0)
rule.setContent { CompositionLocalProvider(LocalReducedMotion provides reduced) { WolfskinTheme {
Box(Modifier.requiredSize(320.dp).background(WolfskinTokens.Color.bg)) {
FocusRingHost {
for (index in 0..1) key(index) {
val radius = if (index == 0) WolfskinTokens.Radius.card else 60.dp
Box(Modifier.offset(80.dp, 80.dp).size(120.dp)
.focusScale(selected.intValue == index, WolfskinTokens.Focus.scaleCard)
.focusRing(selected.intValue == index, radius, WolfskinTokens.Focus.ringCardRing, FocusLift.Card)
.background(WolfskinTokens.Color.surface, androidx.compose.foundation.shape.RoundedCornerShape(radius)))
}
}
}
} } }
rule.mainClock.advanceTimeBy(1000)
rule.mainClock.autoAdvance = false
fun topSpan(name: String): Int {
val bitmap = capture(name)
val bounds = accentBounds(bitmap)
val pixels = (bounds.left until bounds.right).filter { accent(bitmap.getPixel(it, bounds.top + 3)) }
bitmap.recycle()
return if (pixels.isEmpty()) 0 else pixels.last() - pixels.first() + 1
}
val prefix = if (reduced) "reduced/circle" else "circle"
val start = topSpan("$prefix-start")
commit { selected.intValue = 1 }
val spans = List(22) { step(); topSpan("$prefix-$it") }
File(directory, "$prefix-samples.csv").writeText("timeMs,topEdgeSpanPx\n0,$start\n" +
spans.mapIndexed { index, span -> "${(index + 1) * 16},$span" }.joinToString("\n"))
assertTrue("Radius change must change corner pixels", start > spans.last() + 20)
monotonic(listOf(start) + spans)
if (reduced) assertEquals(spans.last(), spans.first())
else assertTrue("Radius must morph, not jump", spans.first() > spans.last())
}
@Test fun posterTileAndSeeAllSettledRingsMatchLegacyPixels() {
val hosted = mutableStateOf(false)
val kind = mutableIntStateOf(0)
rule.setContent { WolfskinTheme {
Box(Modifier.requiredSize(500.dp, 320.dp).background(WolfskinTokens.Color.bg)) {
@Composable fun card() {
val width = if (kind.intValue == 1) WolfskinTokens.Card.tileW else WolfskinTokens.Card.posterW
val height = if (kind.intValue == 1) WolfskinTokens.Card.tileH else WolfskinTokens.Card.posterH
val modifier = Modifier.offset(60.dp, 40.dp)
if (kind.intValue == 2) LibrarySeeAllCard(40, true, {}, modifier, width, height, HomeType.listTitle, HomeType.label)
else TitleCard(true, modifier, width, height) { Box(Modifier.fillMaxSize().background(WolfskinTokens.Color.surface)) }
}
if (hosted.value) FocusRingHost { card() } else card()
}
} }
for (shape in 0..2) {
rule.runOnIdle { hosted.value = false; kind.intValue = shape }
rule.mainClock.advanceTimeBy(1000)
val legacy = capture("legacy-$shape")
rule.runOnIdle { hosted.value = true }
rule.mainClock.advanceTimeBy(1000)
val shared = capture("settled-$shape")
assertEquals("Settled ring bounds for shape $shape", accentBounds(legacy), accentBounds(shared))
var maximumDifference = 0
for (y in 0 until legacy.height) for (x in 0 until legacy.width) {
val expected = legacy.getPixel(x, y)
val actual = shared.getPixel(x, y)
for (channel in 0..3) {
val shift = channel * 8
maximumDifference = maxOf(maximumDifference, abs((expected ushr shift and 255) - (actual ushr shift and 255)))
}
}
assertTrue("Settled shape $shape differs by $maximumDifference channel levels", maximumDifference <= 2)
legacy.recycle(); shared.recycle()
}
}
@Test fun libraryRingTracksFrameScrollAndRapidRetargets() = library(false)
@Test fun reducedLibraryRingTracksFrameScrollAndRapidRetargets() = library(true)
private fun library(reduced: Boolean) {
val shell = ShellState(fixturePeople(), "justin").apply { pickerVisible = false; enterDestination("movies"); navFocused = false }
val grid = LibraryGridState("Movies", List(60) { LibraryTitle("$it", "Film $it", "movie") })
rule.setContent { CompositionLocalProvider(LocalReducedMotion provides reduced) { WolfskinTheme { FocusRingHost { LibraryScreen(shell, grid, Modifier.requiredSize(WolfskinTokens.Shell.stageWidth, WolfskinTokens.Shell.stageHeight)) } } } }
rule.mainClock.advanceTimeBy(1000)
rule.mainClock.autoAdvance = false
rule.runOnIdle { grid.key(ShellKey.Down) }
repeat(3) { step() }
rule.runOnIdle { grid.key(ShellKey.Right) }
repeat(3) { step() }
rule.runOnIdle { grid.key(ShellKey.Down) }
repeat(30) { step() }
val image = capture(if (reduced) "reduced/library-settled" else "library-settled")
val ring = accentBounds(image)
image.recycle()
assertFalse(ring.isEmpty)
assertEquals("Scroll target stays on the pinned line", WolfskinTokens.Layout.pinnedLine.value * 2 - WolfskinTokens.Card.posterH.value * (WolfskinTokens.Focus.scaleCard - 1f), ring.top.toFloat(), 5f)
}
private fun commit(action: () -> Unit) = rule.runOnIdle { action(); Snapshot.sendApplyNotifications() }
private fun step() { rule.mainClock.advanceTimeByFrame(); rule.waitForIdle() }
private fun capture(name: String): Bitmap {
val file = File(directory, "$name.png").apply { parentFile!!.mkdirs() }
rule.onRoot().captureRoboImage(file.absolutePath)
return BitmapFactory.decodeFile(file.absolutePath)
}
private fun accent(pixel: Int): Boolean {
for (channel in 0..2) {
val shift = channel * 8
if (abs((pixel ushr shift and 255) - (accentArgb ushr shift and 255)) > 8) return false
}
return true
}
private fun accentBounds(image: Bitmap): Rect {
val bounds = Rect(image.width, image.height, 0, 0)
val count = image.width * image.height
if (pixels.size < count) pixels = IntArray(count)
image.getPixels(pixels, 0, image.width, 0, 0, image.width, image.height)
var index = 0
for (y in 0 until image.height) for (x in 0 until image.width) if (accent(pixels[index++])) {
bounds.left = minOf(bounds.left, x); bounds.top = minOf(bounds.top, y)
bounds.right = maxOf(bounds.right, x + 1); bounds.bottom = maxOf(bounds.bottom, y + 1)
}
return bounds
}
private fun edgeRuns(image: Bitmap, y: Int): Int {
var runs = 0
var inside = false
for (x in 0 until image.width) {
val next = accent(image.getPixel(x, y))
if (next && !inside) runs++
inside = next
}
return runs
}
private fun monotonic(values: List<Int>) {
val direction = if (values.last() >= values.first()) 1 else -1
values.zipWithNext().forEach { (before, after) -> assertTrue("Geometry reverses or overshoots: $values", (after - before) * direction >= -1) }
}
}

View file

@ -0,0 +1,112 @@
// SPDX-License-Identifier: GPL-2.0-only
package org.justinvisser.wolfskin.design
import android.app.Application
import android.graphics.BitmapFactory
import android.view.View
import android.view.WindowManager
import androidx.activity.ComponentActivity
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.requiredSize
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.snapshots.Snapshot
import androidx.compose.ui.Modifier
import androidx.compose.ui.test.*
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import com.github.takahirom.roborazzi.captureRoboImage
import java.io.File
import org.junit.Assert.*
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
import org.robolectric.annotation.GraphicsMode
/** Native toast pixels prove retention, interruption and bounded reduced-motion fades. */
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi")
@GraphicsMode(GraphicsMode.Mode.NATIVE)
class RecoveryToastMotionTest {
@get:Rule val rule = createAndroidComposeRule<ComponentActivity>()
@Test fun toastFadesInHoldsAndFadesOutWithoutStacking() = exercise(false)
@Test fun reducedToastFadesInHoldsAndFadesOutWithoutStacking() = exercise(true)
private fun exercise(reduced: Boolean) {
rule.activity.window.setFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN, WindowManager.LayoutParams.FLAG_FULLSCREEN)
@Suppress("DEPRECATION")
rule.activity.window.decorView.systemUiVisibility = View.SYSTEM_UI_FLAG_FULLSCREEN or View.SYSTEM_UI_FLAG_HIDE_NAVIGATION or View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY
val message = mutableStateOf<String?>(null)
rule.setContent { CompositionLocalProvider(LocalReducedMotion provides reduced) { WolfskinTheme {
Box(Modifier.requiredSize(WolfskinTokens.Shell.stageWidth, WolfskinTokens.Shell.stageHeight).background(WolfskinTokens.Color.bg)) {
RecoveryToastHost(message.value)
}
} } }
rule.mainClock.autoAdvance = false
val directory = File(requireNotNull(System.getProperty("wolfskin.captureDirectory")), "ui-polish/P02${if (reduced) "/reduced" else ""}").apply { mkdirs() }
val samples = mutableListOf<String>()
var sampleX = 0
var sampleY = 0
fun frame(phase: String, index: Int): Int {
val file = File(directory, "$phase-${index.toString().padStart(2, '0')}.png")
rule.onRoot().captureRoboImage(file.absolutePath)
val bitmap = BitmapFactory.decodeFile(file.absolutePath)
val pixel = bitmap.getPixel(sampleX, sampleY)
bitmap.recycle()
val brightness = android.graphics.Color.red(pixel) + android.graphics.Color.green(pixel) + android.graphics.Color.blue(pixel)
samples += "$phase,${index * 16},$brightness"
return brightness
}
fun step() { rule.mainClock.advanceTimeByFrame(); rule.waitForIdle() }
fun count() = rule.onAllNodesWithTag("recovery-toast", useUnmergedTree = true).fetchSemanticsNodes().size
fun show(value: String?) = rule.runOnIdle { message.value = value; Snapshot.sendApplyNotifications() }
show(RecoveryMessage.StillUnreachable)
step()
var inserted = 0
while (count() == 0 && inserted++ < 3) step()
val bounds = rule.onNodeWithTag("recovery-toast", useUnmergedTree = true).fetchSemanticsNode().boundsInRoot
sampleX = (bounds.left + 40).toInt()
sampleY = (bounds.center.y - 6).toInt()
val entering = List(14) { index -> frame("toast-in", index).also { step() } }
assertTrue("Fade-in must change native toast pixels: $entering", entering.last() > entering.first())
entering.zipWithNext().forEach { (before, after) -> assertTrue("Fade-in reverses: $entering", after >= before - 1) }
val deadline = (if (reduced) WolfskinTokens.Motion.reducedFade else WolfskinTokens.Motion.base) / 16 + 1
assertEquals("Fade-in must settle by its token deadline", entering.last(), entering[deadline])
rule.mainClock.advanceTimeBy(WolfskinTokens.Motion.toast.toLong())
assertEquals(entering.last(), frame("hold", 0))
show(RecoveryMessage.PlaybackUnavailable)
step()
assertEquals(1, count())
rule.onNodeWithText(RecoveryMessage.PlaybackUnavailable).assertExists()
assertEquals("Replacing a visible message must not restart its fade", entering.last(), frame("replace", 0))
show(RecoveryMessage.StillUnreachable)
step()
show(null)
step()
assertEquals("Retain the row while fading out", 1, count())
val leaving = List(14) { index -> frame("toast-out", index).also { step() } }
assertTrue("Fade-out must change native toast pixels: $leaving", leaving.first() > leaving.last())
leaving.zipWithNext().forEach { (before, after) -> assertTrue("Fade-out reverses: $leaving", after <= before + 1) }
assertEquals(leaving.last(), leaving[deadline])
rule.onNodeWithTag("recovery-toast").assertDoesNotExist()
show(RecoveryMessage.StillUnreachable)
repeat(14) { step() }
show(null)
step(); step(); step()
val interrupted = frame("interrupted", 0)
assertTrue(interrupted < entering.last())
show(RecoveryMessage.StillUnreachable)
step()
val resumed = List(14) { index -> frame("resume", index).also { step() } }
// Recomposition may consume one final outgoing frame before retargeting.
assertTrue("Re-entry retains current opacity instead of resetting or snapping",
resumed.first() >= leaving[3] - 1 && resumed.first() <= interrupted + 1)
resumed.zipWithNext().forEach { (before, after) -> assertTrue(after >= before - 1) }
assertEquals(entering.last(), resumed.last())
assertEquals(1, count())
File(directory, "samples.csv").writeText("phase,timeMs,brightness\n" + samples.joinToString("\n"))
}
}

View file

@ -0,0 +1,144 @@
// SPDX-License-Identifier: GPL-2.0-only
package org.justinvisser.wolfskin.design
import android.app.Application
import android.view.View
import android.view.WindowManager
import androidx.activity.ComponentActivity
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.size
import androidx.compose.ui.unit.dp
import androidx.compose.runtime.snapshots.Snapshot
import androidx.compose.ui.Modifier
import androidx.compose.foundation.layout.requiredSize
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.test.onRoot
import com.github.takahirom.roborazzi.captureRoboImage
import java.io.File
import org.junit.Assert.*
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
import org.robolectric.annotation.GraphicsMode
/** Held keys exercise real shelf/frame targets and measure velocity across interrupted scrolls. */
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi")
@GraphicsMode(GraphicsMode.Mode.NATIVE)
class ScrollMomentumTest {
@get:Rule val rule = createAndroidComposeRule<ComponentActivity>()
@Test fun heldRightCarriesVelocityAndSettlesOnShelfFirst() = exercise(false, false)
@Test fun heldDownCarriesVelocityAndSettlesOnLibraryRow() = exercise(false, true)
@Test fun reducedShelfScrollSnapsInOneFrame() = exercise(true, false)
@Test fun reducedLibraryScrollSnapsInOneFrame() = exercise(true, true)
private fun exercise(reduced: Boolean, library: Boolean) {
rule.activity.window.setFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN, WindowManager.LayoutParams.FLAG_FULLSCREEN)
@Suppress("DEPRECATION")
rule.activity.window.decorView.systemUiVisibility = View.SYSTEM_UI_FLAG_FULLSCREEN or View.SYSTEM_UI_FLAG_HIDE_NAVIGATION or View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY
val shell = ShellState(fixturePeople(), "justin").apply {
pickerVisible = false
if (library) enterDestination("movies")
navFocused = false
}
val row = HomeRow("long", HomeRowKind.Shelf, cards = List(30) { HomeTitle("$it", "Film $it", "movie") })
shell.view.cards[row.id] = 4
val grid = LibraryGridState("Movies", List(120) { LibraryTitle("$it", "Film $it", "movie") })
rule.setContent { CompositionLocalProvider(LocalReducedMotion provides reduced) { WolfskinTheme {
ShellSurface(shell, Modifier.requiredSize(WolfskinTokens.Shell.stageWidth, WolfskinTokens.Shell.stageHeight)) { modifier ->
if (library) LibraryScreen(shell, grid, modifier)
else HomeScreen(shell, HomeSnapshot(listOf(row)), modifier) {}
}
} } }
rule.mainClock.advanceTimeBy(1000)
rule.mainClock.autoAdvance = false
val track = if (library) shell.view.listState else shell.view.home.tracks.getValue(row.id)
val pitch = (WolfskinTokens.Card.posterW + WolfskinTokens.Card.posterGap).value * 2
val framePitch = (WolfskinTokens.Card.posterH + WolfskinTokens.Card.posterCaptionBand + WolfskinTokens.Layout.gridRowGap).value * 2
fun offset(): Float {
val first = track.firstVisibleItemIndex
if (!library) return first * pitch + track.firstVisibleItemScrollOffset
val top = if (first == 0) 0f else WolfskinTokens.Layout.row_lhead.value * 2 +
if (first < 2) 0f else WolfskinTokens.Layout.row_chipsGrid.value * 2 + framePitch * (first - 2)
return top + track.firstVisibleItemScrollOffset
}
fun target(): Float = if (!library) shell.view.home.firstCards.getValue(row.id) * pitch else
if (grid.index / grid.columns == 0) 0f else (WolfskinTokens.Layout.row_lhead.value + WolfskinTokens.Layout.row_chipsGrid.value - WolfskinTokens.Layout.pinnedLine.value) * 2 + framePitch * (grid.index / grid.columns)
fun press() = rule.runOnIdle { shell.key(if (library) ShellKey.Down else ShellKey.Right); Snapshot.sendApplyNotifications() }
val directory = File(requireNotNull(System.getProperty("wolfskin.captureDirectory")), "ui-polish/E05${if (library) "/library" else ""}${if (reduced) "/reduced" else ""}").apply { mkdirs() }
val samples = mutableListOf<String>()
val offsets = mutableListOf<Float>()
val velocities = mutableListOf<Float>()
val repeats = mutableListOf<Int>()
press()
// Start sampling at the animation's zero-time frame, after composition commits the new focus.
repeat(2) { rule.mainClock.advanceTimeByFrame(); rule.waitForIdle() }
val singleTarget = target()
for (index in 0..80) {
if (index >= 32 && index <= 47 && (index - 32) % 3 == 0) { repeats += index; press() }
rule.mainClock.advanceTimeByFrame()
rule.waitForIdle()
val position = offset()
offsets += position
velocities += if (index == 0) position / .016f else (position - offsets[index - 1]) / .016f
samples += "${(index + 1) * 16},$position,${velocities.last()},${target()}"
val file = File(directory, "held-${if (library) "down" else "right"}-${index.toString().padStart(2, '0')}.png")
rule.onRoot().captureRoboImage(file.absolutePath)
}
File(directory, "samples.csv").writeText("timeMs,offsetPx,velocityPxPerSecond,targetPx\n" + samples.joinToString("\n"))
offsets.zipWithNext().forEach { (before, after) -> assertTrue("Track reversed: $offsets", after >= before) }
assertTrue("No overshoot of final target", offsets.all { it <= target() + 1 })
assertEquals("Final target must reconcile exactly", target(), offsets.last(), 1f)
assertTrue("Single press must reach 95 percent by 200ms: $offsets", offsets[12] >= singleTarget * .95f)
assertEquals("Single press is visually settled by 320ms", singleTarget, offsets[19], 2f)
if (reduced) assertEquals("Reduced scroll lands in one frame", singleTarget, offsets.first(), 1f)
else repeats.drop(2).forEach { index ->
assertTrue("Repeated key must not stop the track", velocities[index] > 0f)
assertTrue("Retarget must carry velocity instead of restarting: ${velocities.subList(index - 1, index + 2)}", velocities[index + 1] >= velocities[index - 1] * .5f)
}
}
@Test fun cancelledScrollCannotReuseAnAbandonedFrameClock() = cancelledScroll(false)
@Test fun reducedCancelledScrollStillSnapsOnReentry() = cancelledScroll(true)
private fun cancelledScroll(reduced: Boolean) {
val visible = mutableStateOf(true)
val target = mutableFloatStateOf(0f)
val list = LazyListState()
fun offset() = list.firstVisibleItemIndex * 200f + list.firstVisibleItemScrollOffset
rule.setContent { CompositionLocalProvider(LocalReducedMotion provides reduced) { WolfskinTheme {
if (visible.value) {
LaunchedEffect(target.floatValue) { animateFrameScroll(list, target.floatValue - offset()) }
LazyRow(Modifier.requiredSize(500.dp, 100.dp), state = list) {
items(30) { Box(Modifier.size(100.dp)) }
}
} else Box(Modifier.requiredSize(500.dp, 100.dp))
} } }
rule.mainClock.advanceTimeBy(1000)
rule.mainClock.autoAdvance = false
rule.runOnIdle { target.floatValue = 600f; Snapshot.sendApplyNotifications() }
repeat(5) { rule.mainClock.advanceTimeByFrame(); rule.waitForIdle() }
rule.runOnIdle { visible.value = false; Snapshot.sendApplyNotifications() }
rule.mainClock.advanceTimeByFrame()
rule.waitForIdle()
rule.mainClock.advanceTimeBy(1000)
val resumedTarget = offset() + 600f
rule.runOnIdle { target.floatValue = resumedTarget; visible.value = true; Snapshot.sendApplyNotifications() }
repeat(3) { rule.mainClock.advanceTimeByFrame(); rule.waitForIdle() }
if (reduced) assertEquals(resumedTarget, offset(), 1f)
else assertTrue("An abandoned frame timestamp must not turn a fresh scroll into a snap", offset() < resumedTarget - 300f)
rule.mainClock.advanceTimeBy(1000)
rule.waitForIdle()
assertEquals(resumedTarget, offset(), 1f)
}
}

View file

@ -142,6 +142,8 @@
--motion-focusMove: 180ms; --motion-focusMove: 180ms;
--motion-captionDelay: 60ms; --motion-captionDelay: 60ms;
--motion-captionOut: 100ms; --motion-captionOut: 100ms;
--motion-sheetIn: 160ms;
--motion-sheetOut: 100ms;
--motion-artReveal: 160ms; --motion-artReveal: 160ms;
--motion-heroFade: 320ms; --motion-heroFade: 320ms;
--motion-heroCopyOut: 120ms; --motion-heroCopyOut: 120ms;
@ -192,6 +194,7 @@
--requests-receiptPaddingX: 30.0px; --requests-receiptPaddingX: 30.0px;
--requests-receiptGap: 10.0px; --requests-receiptGap: 10.0px;
--requests-receiptGlyph: 36.0px; --requests-receiptGlyph: 36.0px;
--requests-thumbGlyph: 40.0px;
--requests-buttonMargin: 12.0px; --requests-buttonMargin: 12.0px;
--requests-skeletonTitleWidth: 360.0px; --requests-skeletonTitleWidth: 360.0px;
--requests-skeletonTitleHeight: 34.0px; --requests-skeletonTitleHeight: 34.0px;

View file

@ -1408,6 +1408,8 @@
"focusMove": { "$type": "duration", "$value": "180ms" }, "focusMove": { "$type": "duration", "$value": "180ms" },
"captionDelay": { "$type": "duration", "$value": "60ms" }, "captionDelay": { "$type": "duration", "$value": "60ms" },
"captionOut": { "$type": "duration", "$value": "100ms" }, "captionOut": { "$type": "duration", "$value": "100ms" },
"sheetIn": { "$type": "duration", "$value": "160ms", "source": "ui-polish P06, approved 2026-11-07: side panel and its scrim fade in, easeOut" },
"sheetOut": { "$type": "duration", "$value": "100ms", "source": "ui-polish P06, approved 2026-11-07: side panel and its scrim fade out, easeOut" },
"artReveal": { "$type": "duration", "$value": "160ms" }, "artReveal": { "$type": "duration", "$value": "160ms" },
"heroFade": { "$type": "duration", "$value": "320ms" }, "heroFade": { "$type": "duration", "$value": "320ms" },
"heroCopyOut": { "$type": "duration", "$value": "120ms" }, "heroCopyOut": { "$type": "duration", "$value": "120ms" },

View file

@ -1,9 +1,9 @@
b3f096b692c395534177cc82ac68f5b801870bc7e28f0dd5cd7c17e2103920a3 discover-tokens.json b3f096b692c395534177cc82ac68f5b801870bc7e28f0dd5cd7c17e2103920a3 discover-tokens.json
993d933950e17fb72d0902ffc440e1e4ed4dc088c96a8d12974431c77dc31634 generate_tokens.py 993d933950e17fb72d0902ffc440e1e4ed4dc088c96a8d12974431c77dc31634 generate_tokens.py
72b67ad9ba95e7bbc17cec29cc676b06b7e9d81c546022b5a100d6d4e2a27c64 home-tokens.json 72b67ad9ba95e7bbc17cec29cc676b06b7e9d81c546022b5a100d6d4e2a27c64 home-tokens.json
192beb887fc34005db99c87ffbcbaf4a057ee2728475fc96224cf89445019b51 shell-tokens.json 9562bd6e6d8fb4d6ef380380e621d6c32401387560437eb4f999ebecbcda160e shell-tokens.json
42399062a6a22566e26adb71eaa4052b28be93d28d0eb46d9d4b87f3142a2944 src/main/java/org/justinvisser/wolfskin/design/WolfskinTokens.kt 68a94b81bb0300aa05d241f11d88ff4843caa0b1b16e0ed09f62674b336e69fe src/main/java/org/justinvisser/wolfskin/design/WolfskinTokens.kt
98fa6414cc6663624d09329615cdf0e6599baec570a8d878f5214c95cb33cb48 title-tokens.json 98fa6414cc6663624d09329615cdf0e6599baec570a8d878f5214c95cb33cb48 title-tokens.json
731ff25d6ec3ba12d0840523a1ebd65a81b3474cf74040f13bbd1f54985a3053 tokens.css e93d868d6fdf9ad939f1fd18c452732fb3afa76bbba8fe6fdd0c1007aeff3b01 tokens.css
12d33b7b5740443e068cbb06528d88c42ad3bb1df0d4d45dd6ee6797d3dfc401 tokens.json e729efd2efd6bc52a35480038d64ff0199ce166e6e794332e9cfa7dc84ed98c9 tokens.json
19728f99b90bc40c0ebec37c5bcc45b383b89b023211490bb3235c1690237ae5 update-tokens.json 19728f99b90bc40c0ebec37c5bcc45b383b89b023211490bb3235c1690237ae5 update-tokens.json