Round 2 release candidate 3: polish lanes A-E, season reel and trailer return #82

Merged
justinzeus merged 27 commits from fm/tv-round2-candidate3 into fm/tv-round2 2026-11-08 13:58:41 +01:00
9 changed files with 180 additions and 6 deletions
Showing only changes of commit 74b737fb52 - Show all commits

View file

@ -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" },

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

View file

@ -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;

View file

@ -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" },

View file

@ -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