Add the shared polish tokens and motion helpers

This commit is contained in:
justinzeus 2026-11-08 00:12:17 +01:00
parent 5f5b5ffd54
commit 74b737fb52
9 changed files with 180 additions and 6 deletions

View file

@ -0,0 +1,10 @@
// SPDX-License-Identifier: GPL-2.0-only
package org.justinvisser.wolfskin.design
import androidx.compose.runtime.Composable
/** A shell surface draws one shared card focus ring above its content and below the nav and sheets. */
@Composable
fun FocusRingHost(content: @Composable () -> Unit) {
content()
}

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
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.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
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.Modifier
import androidx.compose.ui.draw.dropShadow
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.platform.testTag
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.Home as H
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. */
@Composable
fun RecoveryToast(message: String, modifier: Modifier = Modifier) {
fun RecoveryToast(message: String, modifier: Modifier = Modifier, opacity: () -> Float = { 1f }) {
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)
.widthIn(max = H.toastWidth)
.graphicsLayer { alpha = opacity(); compositingStrategy = CompositingStrategy.ModulateAlpha }
.dropShadow(shape, Shadow(radius = H.toastShadowBlur, color = Color.Black.copy(alpha = H.toastShadowOpacity),
offset = DpOffset(WolfskinTokens.Shell.zero, H.toastShadowY)))
.background(C.surface2, shape)

View file

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