Add the shared polish tokens and motion helpers
This commit is contained in:
parent
5f5b5ffd54
commit
74b737fb52
9 changed files with 180 additions and 6 deletions
|
|
@ -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" },
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
|
@ -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)) })
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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" },
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue