From 74b737fb52bfbb2cc7afa0b0288ae59e67232320 Mon Sep 17 00:00:00 2001 From: justinzeus Date: Sun, 8 Nov 2026 00:12:17 +0100 Subject: [PATCH] Add the shared polish tokens and motion helpers --- wolfskin-design/shell-tokens.json | 1 + .../wolfskin/design/FocusRingHost.kt | 10 +++ .../wolfskin/design/PlaybackHandoff.kt | 53 +++++++++++++ .../wolfskin/design/PolishMotion.kt | 75 +++++++++++++++++++ .../wolfskin/design/RecoveryToast.kt | 31 +++++++- .../wolfskin/design/WolfskinTokens.kt | 3 + wolfskin-design/tokens.css | 3 + wolfskin-design/tokens.json | 2 + wolfskin-design/tokens.sha256 | 8 +- 9 files changed, 180 insertions(+), 6 deletions(-) create mode 100644 wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/FocusRingHost.kt create mode 100644 wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/PlaybackHandoff.kt create mode 100644 wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/PolishMotion.kt diff --git a/wolfskin-design/shell-tokens.json b/wolfskin-design/shell-tokens.json index 3c29b7ae..47a40ac2 100644 --- a/wolfskin-design/shell-tokens.json +++ b/wolfskin-design/shell-tokens.json @@ -30,6 +30,7 @@ "receiptPaddingX": { "$type": "dimension", "px": 30, "dp": 15, "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" }, + "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" }, "skeletonTitleWidth": { "$type": "dimension", "px": 360, "dp": 180, "source": "index.html:1902" }, "skeletonTitleHeight": { "$type": "dimension", "px": 34, "dp": 17, "source": "index.html:1902" }, diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/FocusRingHost.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/FocusRingHost.kt new file mode 100644 index 00000000..6175d6f9 --- /dev/null +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/FocusRingHost.kt @@ -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() +} diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/PlaybackHandoff.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/PlaybackHandoff.kt new file mode 100644 index 00000000..197cea7e --- /dev/null +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/PlaybackHandoff.kt @@ -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(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)) }) +} diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/PolishMotion.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/PolishMotion.kt new file mode 100644 index 00000000..393a25e5 --- /dev/null +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/PolishMotion.kt @@ -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 { + 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) + } + } +} diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/RecoveryToast.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/RecoveryToast.kt index fac5305f..30e19c61 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/RecoveryToast.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/RecoveryToast.kt @@ -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) diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/WolfskinTokens.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/WolfskinTokens.kt index c927d9c9..9770b26d 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/WolfskinTokens.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/WolfskinTokens.kt @@ -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 diff --git a/wolfskin-design/tokens.css b/wolfskin-design/tokens.css index b02ec55d..463122e5 100644 --- a/wolfskin-design/tokens.css +++ b/wolfskin-design/tokens.css @@ -142,6 +142,8 @@ --motion-focusMove: 180ms; --motion-captionDelay: 60ms; --motion-captionOut: 100ms; + --motion-sheetIn: 160ms; + --motion-sheetOut: 100ms; --motion-artReveal: 160ms; --motion-heroFade: 320ms; --motion-heroCopyOut: 120ms; @@ -192,6 +194,7 @@ --requests-receiptPaddingX: 30.0px; --requests-receiptGap: 10.0px; --requests-receiptGlyph: 36.0px; + --requests-thumbGlyph: 40.0px; --requests-buttonMargin: 12.0px; --requests-skeletonTitleWidth: 360.0px; --requests-skeletonTitleHeight: 34.0px; diff --git a/wolfskin-design/tokens.json b/wolfskin-design/tokens.json index b04160f1..f84690cc 100644 --- a/wolfskin-design/tokens.json +++ b/wolfskin-design/tokens.json @@ -1408,6 +1408,8 @@ "focusMove": { "$type": "duration", "$value": "180ms" }, "captionDelay": { "$type": "duration", "$value": "60ms" }, "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" }, "heroFade": { "$type": "duration", "$value": "320ms" }, "heroCopyOut": { "$type": "duration", "$value": "120ms" }, diff --git a/wolfskin-design/tokens.sha256 b/wolfskin-design/tokens.sha256 index d3684c03..59039c0f 100644 --- a/wolfskin-design/tokens.sha256 +++ b/wolfskin-design/tokens.sha256 @@ -1,9 +1,9 @@ b3f096b692c395534177cc82ac68f5b801870bc7e28f0dd5cd7c17e2103920a3 discover-tokens.json 993d933950e17fb72d0902ffc440e1e4ed4dc088c96a8d12974431c77dc31634 generate_tokens.py 72b67ad9ba95e7bbc17cec29cc676b06b7e9d81c546022b5a100d6d4e2a27c64 home-tokens.json -192beb887fc34005db99c87ffbcbaf4a057ee2728475fc96224cf89445019b51 shell-tokens.json -42399062a6a22566e26adb71eaa4052b28be93d28d0eb46d9d4b87f3142a2944 src/main/java/org/justinvisser/wolfskin/design/WolfskinTokens.kt +9562bd6e6d8fb4d6ef380380e621d6c32401387560437eb4f999ebecbcda160e shell-tokens.json +68a94b81bb0300aa05d241f11d88ff4843caa0b1b16e0ed09f62674b336e69fe src/main/java/org/justinvisser/wolfskin/design/WolfskinTokens.kt 98fa6414cc6663624d09329615cdf0e6599baec570a8d878f5214c95cb33cb48 title-tokens.json -731ff25d6ec3ba12d0840523a1ebd65a81b3474cf74040f13bbd1f54985a3053 tokens.css -12d33b7b5740443e068cbb06528d88c42ad3bb1df0d4d45dd6ee6797d3dfc401 tokens.json +e93d868d6fdf9ad939f1fd18c452732fb3afa76bbba8fe6fdd0c1007aeff3b01 tokens.css +e729efd2efd6bc52a35480038d64ff0199ce166e6e794332e9cfa7dc84ed98c9 tokens.json 19728f99b90bc40c0ebec37c5bcc45b383b89b023211490bb3235c1690237ae5 update-tokens.json