Match Discover shadows, text rhythm and asynchronous focus
Some checks failed
review/design-claude Changes requested: 3 blocking design deviations (st-032 sheet rhythm, st-036 button squeeze, hero stack offset)

This commit is contained in:
justinzeus 2026-11-05 19:39:13 +01:00
parent 4e62aad638
commit 1299034991
21 changed files with 293 additions and 80 deletions

View file

@ -0,0 +1,101 @@
// SPDX-License-Identifier: GPL-2.0-only
package org.justinvisser.wolfskin.tv
import android.app.Application
import androidx.activity.ComponentActivity
import androidx.compose.ui.input.key.Key
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.performKeyInput
import androidx.compose.ui.test.pressKey
import kotlinx.coroutines.CompletableDeferred
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertTrue
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.justinvisser.wolfskin.design.DiscoverFixture
import org.justinvisser.wolfskin.design.DiscoverScreen
import org.justinvisser.wolfskin.design.DiscoverState
import org.justinvisser.wolfskin.design.DiscoverStatus
import org.justinvisser.wolfskin.design.ShellPerson
import org.justinvisser.wolfskin.design.ShellState
import org.justinvisser.wolfskin.design.ShellSurface
import org.justinvisser.wolfskin.design.WolfskinTheme
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
import org.robolectric.annotation.GraphicsMode
/** The real asynchronous host must return focus from its loading nav to the first loaded action. */
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi")
@GraphicsMode(GraphicsMode.Mode.NATIVE)
class DiscoverLoadingFocusTest {
@get:Rule val rule = createAndroidComposeRule<ComponentActivity>()
@Test fun firstOkAfterLoadingOpensTheHeroInsteadOfSelectingTheNav() {
val pending = CompletableDeferred<DiscoverFixture>()
val shell = ShellState(listOf(ShellPerson("justin", "Justin", "J", true)), "justin").apply {
pickerVisible = false
enterDestination("discover")
}
lateinit var current: DiscoverState
rule.setContent {
val state = rememberDiscoverState(shell, shell::enterDestination, { shell.enterDestination("discover") }, loadFixture = { pending.await() })
current = state
WolfskinTheme { ShellSurface(shell) { DiscoverScreen(state, it) } }
}
rule.runOnIdle {
assertEquals(DiscoverStatus.Loading, current.snapshot.status)
assertTrue(shell.navFocused)
val json = rule.activity.assets.open("wolfskin-discover/fixture.json").bufferedReader().use { it.readText() }
pending.complete(DiscoverFixture(json) { "file:///android_asset/wolfskin-discover/$it" })
}
rule.waitForIdle()
rule.runOnIdle {
assertEquals(DiscoverStatus.Ready, current.snapshot.status)
assertFalse(shell.navFocused)
assertEquals(0, current.focusedRow)
}
rule.onNodeWithTag("wolfskin-shell").performKeyInput { pressKey(Key.DirectionCenter) }
rule.runOnIdle {
assertNotNull(current.title)
assertEquals("The Amazing Spider-Man", current.title!!.title)
assertEquals("discover-title/${current.title!!.key}", shell.route)
}
}
@Test fun finishingLoadPreservesANavTargetChosenWhileLoading() {
val pending = CompletableDeferred<DiscoverFixture>()
val shell = ShellState(listOf(ShellPerson("justin", "Justin", "J", true)), "justin").apply {
pickerVisible = false
enterDestination("discover")
}
lateinit var current: DiscoverState
rule.setContent {
val state = rememberDiscoverState(shell, shell::enterDestination, { shell.enterDestination("discover") }, loadFixture = { pending.await() })
current = state
WolfskinTheme { ShellSurface(shell) { DiscoverScreen(state, it) } }
}
rule.onNodeWithTag("wolfskin-shell").performKeyInput { pressKey(Key.DirectionRight) }
rule.runOnIdle {
assertEquals(DiscoverStatus.Loading, current.snapshot.status)
assertEquals(2, shell.navIndex)
val json = rule.activity.assets.open("wolfskin-discover/fixture.json").bufferedReader().use { it.readText() }
pending.complete(DiscoverFixture(json) { "file:///android_asset/wolfskin-discover/$it" })
}
rule.waitForIdle()
rule.runOnIdle {
assertEquals(DiscoverStatus.Ready, current.snapshot.status)
assertTrue(shell.navFocused)
assertEquals(2, shell.navIndex)
}
rule.onNodeWithTag("wolfskin-shell").performKeyInput { pressKey(Key.DirectionCenter) }
rule.runOnIdle {
assertEquals("movies", shell.route)
assertEquals(null, current.title)
}
}
}

View file

@ -29,15 +29,23 @@ internal val LocalDiscoverState = compositionLocalOf<DiscoverState> { error("Dis
/** No client or endpoint is constructed before TV sign-in exists; release stays explicitly signed out. */
@Composable
internal fun rememberDiscoverState(shell: ShellState, push: (String) -> Unit, pop: () -> Unit): DiscoverState {
internal fun rememberDiscoverState(
shell: ShellState,
push: (String) -> Unit,
pop: () -> Unit,
loadFixture: suspend (Context) -> DiscoverFixture = { context -> withContext(Dispatchers.IO) { readDiscoverFixture(context) } },
): DiscoverState {
val context = LocalContext.current
val scope = rememberCoroutineScope()
var state by remember(shell, shell.person.key) { mutableStateOf(DiscoverState(shell, DiscoverSnapshot(status = if (BuildConfig.DEBUG) DiscoverStatus.Loading else DiscoverStatus.NeedsSignIn), SignedOutDiscoverOperations, scope, push, pop)) }
LaunchedEffect(shell, shell.person.key) {
if (BuildConfig.DEBUG) {
val fixture = withContext(Dispatchers.IO) { readDiscoverFixture(context) }
val fixture = loadFixture(context)
val snapshot = fixture.snapshot(shell.person.key)
state = DiscoverState(shell, snapshot, DebugDiscoverOperations(snapshot, shell.person.name), scope, push, pop, fixture::service)
val resumeContent = shell.route == "discover" && (!shell.navFocused || shell.navIndex == shell.currentTab)
val loaded = DiscoverState(shell, snapshot, DebugDiscoverOperations(snapshot, shell.person.name), scope, push, pop, fixture::service)
state = loaded
if (resumeContent) loaded.focus(if (loaded.hasHero || !loaded.canRequest) 0 else 1)
}
}
LaunchedEffect(shell.route, state) { state.enterRoute(shell.route) }

View file

@ -122,9 +122,18 @@ separator token and the shell's single hold guard. Both frames share the 180 ms
The TV keeps platform Roboto with the approved type sizes and weights. Browser
system-font wrapping is not a per-title contract: no bundled font, forced title
breaks or width compensation is applied. Native text-layout measurements accompany
each acceptance capture; font-driven wrapping and text-block positions must be
listed explicitly in its comparison report.
breaks or width compensation is applied. Discover's own stacked text preserves
untrimmed CSS line boxes without changing merged shell/library typography. Hero,
sheet and shared focused-content shadows use tokenized drop shadows, not Material
elevation; their alpha animates in the draw phase. Native text-layout measurements
accompany each acceptance capture; remaining font-driven wrapping and text-block
positions must be listed explicitly in its comparison report.
The asynchronous debug fixture seam hands loading focus back to the first loaded
action when Discover is still the selected destination. A JVM regression releases
the actual host's suspended fixture load and presses Android OK: it must open the
hero rather than activate the nav. Fixture completion does not steal a different
destination or nav selection.
Native Roborazzi captures use Robolectric SDK 34 and
`w960dp-h540dp-xhdpi` (1920×1080 at density 2.0). `make test` writes capture PNGs and

View file

@ -267,6 +267,9 @@
"frameBottomMin": {"$type": "dimension", "px": 80, "dp": 40, "source": "locked index.html:1042"},
"anchorFitTop": {"$type": "dimension", "px": 700, "dp": 350, "source": "locked index.html:1062"},
"anchorBottomClearance": {"$type": "dimension", "px": 40, "dp": 20, "source": "locked index.html:1062"},
"reasonOpacity": {"$type": "number", "$value": 0.8, "source": "locked index.html:240"},
"stageGap": {"$type": "dimension", "px": 10, "dp": 5, "source": "locked index.html:220"},
"entryBorderOpacity": {"$type": "number", "$value": 0.08, "source": "locked index.html:149"},
"ambientInset": {"$type": "dimension", "px": 80, "dp": 40, "source": "locked index.html:96"},
"ambientBlur": {"$type": "dimension", "px": 60, "dp": 30, "source": "locked index.html:96"},
"ambientScale": {"$type": "number", "$value": 1.1, "source": "locked index.html:96"},

View file

@ -368,5 +368,19 @@
"fallbackSaturationBottom": {"$type":"number","$value":0.26,"source":"index.html:130"},
"fallbackLightnessBottom": {"$type":"number","$value":0.1,"source":"index.html:130"},
"fallbackIconOpacity": {"$type":"number","$value":0.45,"source":"index.html:132"}
},
"lift": {
"cardBlur": {"$type":"dimension","px":50,"dp":25,"source":"index.html:134; spec 4.5"},
"cardY": {"$type":"dimension","px":18,"dp":9,"source":"index.html:134"},
"cardOpacity": {"$type":"number","$value":0.65,"source":"index.html:134"},
"buttonBlur": {"$type":"dimension","px":40,"dp":20,"source":"index.html:110; spec 4.5"},
"buttonY": {"$type":"dimension","px":12,"dp":6,"source":"spec 4.5"},
"buttonOpacity": {"$type":"number","$value":0.5,"source":"spec 4.5"},
"heroBlur": {"$type":"dimension","px":80,"dp":40,"source":"index.html:97"},
"heroY": {"$type":"dimension","px":30,"dp":15,"source":"index.html:97"},
"heroOpacity": {"$type":"number","$value":0.6,"source":"index.html:97"},
"sheetBlur": {"$type":"dimension","px":80,"dp":40,"source":"index.html:227"},
"sheetX": {"$type":"dimension","px":-30,"dp":-15,"source":"index.html:227"},
"sheetOpacity": {"$type":"number","$value":0.5,"source":"index.html:227"}
}
}

View file

@ -23,7 +23,7 @@ fun CatalogueRecoveryCard(retry: Boolean, focused: Boolean, onClick: () -> Unit,
val shape = RoundedCornerShape(WolfskinTokens.Radius.card)
Column(modifier.size(WolfskinTokens.Card.posterW, WolfskinTokens.Card.posterH)
.focusScale(focused, WolfskinTokens.Focus.scaleCard)
.focusRing(focused, WolfskinTokens.Radius.card, WolfskinTokens.Focus.ringCardGap, WolfskinTokens.Focus.ringCardRing, C.accent)
.focusRing(focused, WolfskinTokens.Radius.card, WolfskinTokens.Focus.ringCardGap, WolfskinTokens.Focus.ringCardRing, C.accent, FocusLift.Card)
.background(C.surface, shape).border(G.borderWidth, C.border, shape).clickable(onClick = onClick).padding(G.tailPadding),
verticalArrangement = Arrangement.spacedBy(G.tailGap, Alignment.CenterVertically), horizontalAlignment = Alignment.CenterHorizontally) {
Glyph(if (retry) "alert" else "down", if (retry) C.warn else C.accentText, Modifier.size(G.tailIcon))

View file

@ -17,6 +17,9 @@ import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.blur
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.dropShadow
import androidx.compose.ui.graphics.shadow.Shadow
import androidx.compose.ui.unit.DpOffset
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
@ -71,7 +74,11 @@ fun DiscoverBackdrop(title: DiscoverTitle, detail: Boolean = false, modifier: Mo
DiscoverArtwork(title.poster, Modifier.offset(
WolfskinTokens.Shell.stageWidth - D.heroSheetRight - if (detail) L.heroSheetWidth else D.heroSheetWidth,
if (detail) L.navBand else D.heroSheetTop,
).size(if (detail) L.heroSheetWidth else D.heroSheetWidth, if (detail) L.heroSheetHeight else D.heroSheetHeight).clip(RoundedCornerShape(WolfskinTokens.Radius.heroSheet)), poster = true)
).size(if (detail) L.heroSheetWidth else D.heroSheetWidth, if (detail) L.heroSheetHeight else D.heroSheetHeight)
.dropShadow(RoundedCornerShape(WolfskinTokens.Radius.heroSheet), Shadow(
radius = WolfskinTokens.Lift.heroBlur, color = Color.Black.copy(alpha = WolfskinTokens.Lift.heroOpacity),
offset = DpOffset(WolfskinTokens.Shell.zero, WolfskinTokens.Lift.heroY)))
.clip(RoundedCornerShape(WolfskinTokens.Radius.heroSheet)), poster = true)
} else DiscoverArtwork(title.backdrop ?: title.poster, Modifier.fillMaxSize())
Box(Modifier.fillMaxSize().drawWithCache {
val scrim = Brush.horizontalGradient(0f to C.bg.copy(alpha = .92f), .42f to C.bg.copy(alpha = .55f), .70f to C.bg.copy(alpha = 0f), endX = size.width)

View file

@ -8,7 +8,6 @@ import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
@ -22,9 +21,13 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.dropShadow
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.RectangleShape
import androidx.compose.ui.graphics.shadow.Shadow
import androidx.compose.ui.unit.DpOffset
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.layout.layout
import androidx.compose.ui.text.font.FontWeight
import androidx.tv.material3.Text
import org.justinvisser.wolfskin.design.WolfskinTokens.Color as C
import org.justinvisser.wolfskin.design.WolfskinTokens.Discover as D
@ -36,20 +39,24 @@ fun DiscoverOverlay(state: DiscoverState) {
val overlay = state.overlay
if (overlay != null && !state.shell.pickerVisible) {
Box(Modifier.fillMaxSize().background(C.sheetScrim).clickable(onClick = {}).testTag("discover-overlay")) {
Column(Modifier.align(Alignment.CenterEnd).width(L.sheetWidth).fillMaxSize().background(C.surface).padding(D.sheetPaddingX, D.sheetPaddingY), verticalArrangement = Arrangement.spacedBy(D.sheetGap)) {
Column(Modifier.align(Alignment.CenterEnd).width(L.sheetWidth).fillMaxSize()
.dropShadow(RectangleShape, Shadow(radius = WolfskinTokens.Lift.sheetBlur,
color = Color.Black.copy(alpha = WolfskinTokens.Lift.sheetOpacity),
offset = DpOffset(WolfskinTokens.Lift.sheetX, WolfskinTokens.Shell.zero)))
.background(C.surface).padding(D.sheetPaddingX, D.sheetPaddingY), verticalArrangement = Arrangement.spacedBy(D.sheetGap)) {
when (overlay) {
is DiscoverOverlayState.Request -> RequestSheet(state, overlay.state)
is DiscoverOverlayState.Hidden -> {
ReceiptPanel("Hidden from your picks", "${overlay.title.title} leaves every row of ${state.shell.person.name}’s picks. It waits under Hidden picks at the end of Discover.")
Row(horizontalArrangement = Arrangement.spacedBy(L.buttonGap)) {
Row(Modifier.padding(top = D.sheetButtonTop), horizontalArrangement = Arrangement.spacedBy(L.buttonGap)) {
DiscoverButton("Undo", overlay.focus == 0) { state.restore(overlay.title) }
DiscoverButton("Done", overlay.focus == 1, primary = false) { overlay.focus = 1; state.key(ShellKey.Ok) }
}
}
is DiscoverOverlayState.Restore -> {
Text(overlay.title.title, color = C.fg, style = WolfskinTokens.Type.sheetTitle)
Text("Hidden from ${state.shell.person.name}’s picks.", color = C.muted, style = WolfskinTokens.Type.body)
Row(horizontalArrangement = Arrangement.spacedBy(L.buttonGap)) {
Text(overlay.title.title, color = C.fg, style = DiscoverType.sheetTitle)
Text("Hidden from ${state.shell.person.name}’s picks.", color = C.muted, style = DiscoverType.body)
Row(Modifier.padding(top = D.sheetButtonTop), horizontalArrangement = Arrangement.spacedBy(L.buttonGap)) {
DiscoverButton("Show again", overlay.focus == 0) { state.restore(overlay.title) }
DiscoverButton("Back", overlay.focus == 1, primary = false) { state.overlay = null }
}
@ -60,16 +67,16 @@ fun DiscoverOverlay(state: DiscoverState) {
}
state.toast?.let { text ->
Box(Modifier.offset(WolfskinTokens.Shell.stageWidth - L.safeSide - WolfskinTokens.Library.captionWidth, L.navBand).width(WolfskinTokens.Library.captionWidth).background(C.surface2, RoundedCornerShape(WolfskinTokens.Radius.panel)).padding(L.buttonGap)) {
Text(text, color = C.fg, style = WolfskinTokens.Type.body)
Text(text, color = C.fg, style = DiscoverType.body)
}
}
}
@Composable
private fun RequestSheet(state: DiscoverState, sheet: RequestSheetState) {
Text(if (sheet.held) "Add for someone" else "Request", color = C.fg, style = WolfskinTokens.Type.sheetTitle)
Text(if (sheet.held) "Add for someone" else "Request", color = C.fg, style = DiscoverType.sheetTitle)
Text("${sheet.title.title} · ${sheet.title.typeWord}${sheet.title.year?.let { " · $it" }.orEmpty()}",
Modifier.offset(y = D.sheetSubTop).padding(bottom = D.sheetSubGap + D.sheetSubTop), color = C.muted, style = WolfskinTokens.Type.body)
Modifier.offset(y = D.sheetSubTop).padding(bottom = D.sheetSubGap + D.sheetSubTop), color = C.muted, style = DiscoverType.body)
if (state.isSignedIn) DiscoverRatings(sheet.title.ratings, Modifier.layout { measurable, constraints ->
val placeable = measurable.measure(constraints)
layout(placeable.width, placeable.height + (D.sheetRatingTop + D.sheetRatingBottom).roundToPx()) {
@ -79,15 +86,17 @@ private fun RequestSheet(state: DiscoverState, sheet: RequestSheetState) {
when (sheet.phase) {
RequestPhase.Choose, RequestPhase.Sending -> RequestChoices(state, sheet)
RequestPhase.LoadingRecipients -> {
Text("For", Modifier.padding(top = D.sheetLabelTop), color = C.dim, style = WolfskinTokens.Type.label)
Text("For", Modifier.padding(top = D.sheetLabelTop), color = C.dim, style = DiscoverType.label)
repeat(3) {
Row(Modifier.fillMaxWidth().height(L.sheetRow).background(C.listRowRest, RoundedCornerShape(WolfskinTokens.Radius.listRow)).padding(horizontal = D.sheetRowPadding),
horizontalArrangement = Arrangement.spacedBy(D.sheetPersonGap), verticalAlignment = Alignment.CenterVertically) {
Box(Modifier.size(D.sheetAvatar).background(C.surface2, CircleShape))
Box(Modifier.width(D.skeletonNameWidth).height(D.skeletonNameHeight).background(C.surface2, RoundedCornerShape(WolfskinTokens.Shell.skeletonRadius)))
Box(Modifier.size(D.sheetAvatar).background(C.surface, CircleShape))
Box(Modifier.width(D.skeletonNameWidth).height(D.skeletonNameHeight).background(C.surface, RoundedCornerShape(WolfskinTokens.Shell.skeletonRadius)))
}
}
DiscoverButton("Close", true) { state.overlay = null }
Box(Modifier.padding(top = D.sheetButtonTop)) {
DiscoverButton("Close", true, primary = false) { state.overlay = null }
}
}
else -> RequestOutcome(state, sheet)
}
@ -95,7 +104,7 @@ private fun RequestSheet(state: DiscoverState, sheet: RequestSheetState) {
@Composable
private fun RequestChoices(state: DiscoverState, sheet: RequestSheetState) {
Text("For", Modifier.padding(top = D.sheetLabelTop), color = C.dim, style = WolfskinTokens.Type.label)
Text("For", Modifier.padding(top = D.sheetLabelTop), color = C.dim, style = DiscoverType.label)
sheet.people.forEachIndexed { index, person ->
val focused = sheet.focus == index
val disabled = person.key in sheet.title.heldBy
@ -105,18 +114,18 @@ private fun RequestChoices(state: DiscoverState, sheet: RequestSheetState) {
.clickable(enabled = !disabled && sheet.phase == RequestPhase.Choose) { sheet.selected = index; sheet.focus = sheet.buttonIndex }
.padding(horizontal = D.sheetRowPadding), horizontalArrangement = Arrangement.spacedBy(D.sheetPersonGap), verticalAlignment = Alignment.CenterVertically) {
Avatar(person, D.sheetAvatar, WolfskinTokens.Type.avatarLetterSheetRow)
Text(person.name + if (person.key == sheet.viewer.key) " (you)" else "", Modifier.weight(1f), color = if (focused) C.onFocus else C.fg, style = WolfskinTokens.Type.listTitle)
if (disabled) Text("In library", color = if (focused) C.onFocus else C.muted, style = WolfskinTokens.Type.caption)
Text(person.name + if (person.key == sheet.viewer.key) " (you)" else "", Modifier.weight(1f), color = if (focused) C.onFocus else C.fg, style = DiscoverType.listTitle)
if (disabled) Text("In library", color = (if (focused) C.onFocus else C.fg).copy(alpha = D.reasonOpacity), style = DiscoverType.reason)
else Box(Modifier.size(D.sheetRadio).background(if (sheet.selected == index) C.accent else C.listRowRest.copy(alpha = 0f), CircleShape)
.border(if (sheet.selected != index || focused) D.sheetRadioRing else WolfskinTokens.Shell.zero, if (focused) C.onFocus.copy(alpha = .5f) else C.fg.copy(alpha = .35f), CircleShape), contentAlignment = Alignment.Center) {
if (sheet.selected == index) Box(Modifier.size(D.sheetRadioDot).background(C.onFocus, CircleShape))
}
}
}
if (sheet.blocked) Text("Season details are unknown right now, so this series can’t be requested yet. Nothing was sent.", color = C.warn, style = WolfskinTokens.Type.caption)
else if (sheet.title.type == "tv" && !sheet.held) Text("Whole series: every listed season. Other seasons may download too.", color = C.dim, style = WolfskinTokens.Type.caption)
if (sheet.blocked) Text("Season details are unknown right now, so this series can’t be requested yet. Nothing was sent.", color = C.warn, style = DiscoverType.caption)
else if (sheet.title.type == "tv" && !sheet.held) Text("Whole series: every listed season. Other seasons may download too.", color = C.dim, style = DiscoverType.caption)
Box(Modifier.padding(top = D.sheetButtonTop)) {
DiscoverButton(if (sheet.blocked) "Close" else sheet.buttonLabel, sheet.focus == sheet.buttonIndex, glyph = if (sheet.blocked) null else "plus") { sheet.focus = sheet.buttonIndex; state.key(ShellKey.Ok) }
DiscoverButton(if (sheet.blocked) "Close" else sheet.buttonLabel, sheet.focus == sheet.buttonIndex, glyph = if (sheet.blocked) null else "plus", primary = !sheet.blocked) { sheet.focus = sheet.buttonIndex; state.key(ShellKey.Ok) }
}
}
@ -141,8 +150,8 @@ private fun RequestOutcome(state: DiscoverState, sheet: RequestSheetState) {
}
ReceiptPanel(heading, text, if (phase == RequestPhase.Receipt) C.fg else if (phase == RequestPhase.Refused) C.fail else C.warn,
if (phase == RequestPhase.Receipt) sheet.result?.stage ?: "Waiting" else null)
if (phase == RequestPhase.Receipt) Text("Follow it under Requests in Settings.", color = C.dim, style = WolfskinTokens.Type.caption)
Row(horizontalArrangement = Arrangement.spacedBy(L.buttonGap)) {
if (phase == RequestPhase.Receipt) Text("Follow it under Requests in Settings.", color = C.dim, style = DiscoverType.caption)
Row(Modifier.padding(top = D.sheetButtonTop), horizontalArrangement = Arrangement.spacedBy(L.buttonGap)) {
if (phase in setOf(RequestPhase.NotConfirmed, RequestPhase.Pending, RequestPhase.RecipientsUnavailable)) {
DiscoverButton(if (phase == RequestPhase.RecipientsUnavailable) "Try again" else "Check again", sheet.focus == sheet.buttonIndex) { sheet.focus = sheet.buttonIndex; state.key(ShellKey.Ok) }
DiscoverButton("Close", sheet.focus != sheet.buttonIndex, primary = false) { state.overlay = null }
@ -155,12 +164,13 @@ private fun ReceiptPanel(title: String, text: String, color: androidx.compose.ui
Column(Modifier.fillMaxWidth().background(C.listRowRest, RoundedCornerShape(WolfskinTokens.Radius.panel)).padding(WolfskinTokens.Shell.panelPaddingX, WolfskinTokens.Shell.panelPaddingY), verticalArrangement = Arrangement.spacedBy(WolfskinTokens.Shell.panelGap)) {
Row(horizontalArrangement = Arrangement.spacedBy(L.badgeInset), verticalAlignment = Alignment.CenterVertically) {
Glyph(if (title == "Requested" || title == "Hidden from your picks" || title.startsWith("Adding")) "check" else if (title == "Request unresolved") "clock" else "alert", color, Modifier.size(WolfskinTokens.Shell.panelIcon))
Text(title, color = color, style = WolfskinTokens.Type.stateTitle.copy(fontWeight = FontWeight.Bold))
Text(title, color = color, style = DiscoverType.stateTitle)
}
Text(text, color = C.muted, style = WolfskinTokens.Type.body)
if (stage != null) Row(horizontalArrangement = Arrangement.spacedBy(L.navBlobAvatarPad), verticalAlignment = Alignment.CenterVertically) {
Glyph("clock", C.muted, Modifier.size(L.buttonIcon))
Text(stage, color = C.muted, style = WolfskinTokens.Type.label)
Text(text, color = C.muted, style = DiscoverType.body)
if (stage != null) Row(horizontalArrangement = Arrangement.spacedBy(D.stageGap), verticalAlignment = Alignment.CenterVertically) {
val stageColor = when (stage) { "Ready" -> C.ok; "Failed" -> C.fail; "Downloading", "Almost" -> C.accent; else -> C.muted }
Glyph("clock", stageColor, Modifier.size(L.buttonIcon))
Text(stage, color = stageColor, style = DiscoverType.label)
}
}
}

View file

@ -29,7 +29,7 @@ fun DiscoverRatings(ratings: List<DiscoverRating>, modifier: Modifier = Modifier
if (ratings.isEmpty()) return
val locale = LocalConfiguration.current.locales[0]
val labels = remember(ratings, locale) { ratings.map { formatDiscoverRating(it, locale) } }
val style = remember { WolfskinTokens.Type.ratingPlate.copy(fontFeatureSettings = "tnum") }
val style = DiscoverType.ratings
val markSize = with(LocalDensity.current) { style.fontSize.toDp() }
val desaturated = remember { ColorFilter.colorMatrix(ColorMatrix().apply { setToSaturation(0f) }) }
Row(modifier, horizontalArrangement = Arrangement.spacedBy(markSize * .7142857f), verticalAlignment = Alignment.CenterVertically) {

View file

@ -106,7 +106,7 @@ fun DiscoverScreen(state: DiscoverState, modifier: Modifier = Modifier) {
Row(Modifier.offset(L.safeSide, D.noteTop), horizontalArrangement = Arrangement.spacedBy(D.noteGap), verticalAlignment = Alignment.CenterVertically) {
val color = if (state.snapshot.status == DiscoverStatus.NoSeeds) C.muted else C.warn
Glyph(if (state.snapshot.status == DiscoverStatus.NoSeeds) "eye" else "alert", color, Modifier.size(D.noteIcon))
Text(if (state.snapshot.status == DiscoverStatus.NoSeeds) "Watch something to get picks for ${shell.person.name}." else "Picks unavailable right now.", color = color, style = WolfskinTokens.Type.body)
Text(if (state.snapshot.status == DiscoverStatus.NoSeeds) "Watch something to get picks for ${shell.person.name}." else "Picks unavailable right now.", color = color, style = DiscoverType.body)
}
}
}
@ -122,19 +122,19 @@ private fun DiscoverHero(state: DiscoverState) {
Column(Modifier.fillMaxWidth().height(L.row_phero).padding(start = L.safeSide, bottom = L.heroCopyBottom), verticalArrangement = Arrangement.Bottom) {
Row(horizontalArrangement = Arrangement.spacedBy(WolfskinTokens.Card.posterGap), verticalAlignment = Alignment.CenterVertically) {
Glyph("seerr", C.accentText, Modifier.size(L.buttonIcon))
Text(if (state.snapshot.status == DiscoverStatus.Ready) "For you" else "Trending", color = C.accentText, style = WolfskinTokens.Type.label)
Text(if (state.snapshot.status == DiscoverStatus.Ready) "For you" else "Trending", color = C.accentText, style = DiscoverType.label)
}
Spacer(Modifier.height(D.heroKickerGap))
Text(title.title, Modifier.width(L.heroCopyWidth), color = C.fg, style = WolfskinTokens.Type.displayHero, maxLines = 2, overflow = TextOverflow.Ellipsis)
Text(title.title, Modifier.width(L.heroCopyWidth), color = C.fg, style = DiscoverType.hero, maxLines = 2, overflow = TextOverflow.Ellipsis)
Spacer(Modifier.height(D.heroTitleGap))
Row(Modifier.height(D.heroFactsHeight), horizontalArrangement = Arrangement.spacedBy(D.heroFactsGap), verticalAlignment = Alignment.CenterVertically) {
Text("${title.typeWord}${title.year?.let { " · $it" }.orEmpty()}", color = C.muted, style = WolfskinTokens.Type.body)
if (title.genres.isNotEmpty()) Text(title.genres.take(2).joinToString(", "), color = C.muted, style = WolfskinTokens.Type.body)
Text("${title.typeWord}${title.year?.let { " · $it" }.orEmpty()}", color = C.muted, style = DiscoverType.body)
if (title.genres.isNotEmpty()) Text(title.genres.take(2).joinToString(", "), color = C.muted, style = DiscoverType.body)
if (state.isSignedIn) DiscoverRatings(title.ratings)
if (state.shell.person.key in title.heldBy) Text("In your library", color = C.accentText, style = WolfskinTokens.Type.body)
if (state.shell.person.key in title.heldBy) Text("In your library", color = C.accentText, style = DiscoverType.body)
}
Spacer(Modifier.height(D.heroMetaGap))
Text(title.overview, Modifier.width(L.heroOverviewMax), color = C.muted, style = WolfskinTokens.Type.body, maxLines = 2, overflow = TextOverflow.Ellipsis)
Text(title.overview, Modifier.width(L.heroOverviewMax), color = C.muted, style = DiscoverType.body, maxLines = 2, overflow = TextOverflow.Ellipsis)
Spacer(Modifier.height(D.heroOverviewGap))
Row(verticalAlignment = Alignment.CenterVertically) {
DiscoverButton("Details", state.contentFocused && state.focusedRow == 0, "about") { state.openTitle(title) }
@ -156,7 +156,7 @@ private fun DiscoverChips(state: DiscoverState) {
.background(if (focused) C.fg else if (state.filter == value) C.accentSoft else C.chipRest, RoundedCornerShape(WolfskinTokens.Radius.chip))
.border(if (!focused && state.filter == value) WolfskinTokens.Focus.ringCardRing / 2 else WolfskinTokens.Shell.zero, if (!focused && state.filter == value) C.accent else C.chipRest.copy(alpha = 0f), RoundedCornerShape(WolfskinTokens.Radius.chip))
.clickable { state.focus(1, index); state.key(ShellKey.Ok) }.padding(horizontal = D.chipPadding), contentAlignment = Alignment.Center) {
Text(label, color = if (focused) C.onFocus else if (state.filter == value) C.accentText else C.fg, style = WolfskinTokens.Type.label)
Text(label, color = if (focused) C.onFocus else if (state.filter == value) C.accentText else C.fg, style = DiscoverType.label)
}
}
}
@ -166,17 +166,17 @@ private fun DiscoverChips(state: DiscoverState) {
private fun DiscoverStatePanel(state: DiscoverState) {
val signIn = state.snapshot.status == DiscoverStatus.NeedsSignIn
Box(Modifier.fillMaxWidth().height(L.row_state)) {
Column(Modifier.padding(start = L.safeSide, top = D.stateTop).width(L.stateWidth)
.background(C.surface, RoundedCornerShape(WolfskinTokens.Radius.panel))
.border(WolfskinTokens.Library.borderWidth, C.border, RoundedCornerShape(WolfskinTokens.Radius.panel))
.padding(WolfskinTokens.Library.borderWidth).padding(D.statePaddingX, D.statePaddingY)) {
Row(horizontalArrangement = Arrangement.spacedBy(L.badgeInset), verticalAlignment = Alignment.CenterVertically) {
Glyph(if (signIn) "profile" else "alert", if (signIn) C.accentText else C.warn, Modifier.size(L.buttonIcon))
Text(if (signIn) "Discover needs sign-in" else "Discover is resting", color = C.fg, style = WolfskinTokens.Type.stateTitle)
Column(Modifier.padding(start = L.safeSide, top = D.stateTop).width(L.stateWidth)
.background(C.surface, RoundedCornerShape(WolfskinTokens.Radius.panel))
.border(WolfskinTokens.Library.borderWidth, C.border, RoundedCornerShape(WolfskinTokens.Radius.panel))
.padding(WolfskinTokens.Library.borderWidth).padding(D.statePaddingX, D.statePaddingY)) {
Row(horizontalArrangement = Arrangement.spacedBy(L.badgeInset), verticalAlignment = Alignment.CenterVertically) {
Glyph(if (signIn) "profile" else "alert", if (signIn) C.accentText else C.warn, Modifier.size(L.buttonIcon))
Text(if (signIn) "Discover needs sign-in" else "Discover is resting", color = C.fg, style = DiscoverType.stateTitle)
}
Text(if (signIn) "Sign this TV in to Conductarr to see picks and request titles. Your library plays as usual." else "Conductarr can’t be reached. Your library still plays.", Modifier.padding(top = L.navBlobAvatarPad, bottom = D.stateTextGap), color = C.muted, style = DiscoverType.body)
DiscoverButton("Try again", state.contentFocused, primary = signIn) { state.refresh() }
}
Text(if (signIn) "Sign this TV in to Conductarr to see picks and request titles. Your library plays as usual." else "Conductarr can’t be reached. Your library still plays.", Modifier.padding(top = L.navBlobAvatarPad, bottom = D.stateTextGap), color = C.muted, style = WolfskinTokens.Type.body)
DiscoverButton("Try again", state.contentFocused) { state.refresh() }
}
}
}
@ -184,10 +184,10 @@ private fun DiscoverStatePanel(state: DiscoverState) {
@Composable
fun DiscoverButton(label: String, focused: Boolean, glyph: String? = null, primary: Boolean = true, onClick: () -> Unit) {
Row(Modifier.height(L.buttonHeight).focusScale(focused, WolfskinTokens.Focus.scaleControl)
.focusRing(focused, WolfskinTokens.Radius.button, WolfskinTokens.Focus.ringButtonGap, WolfskinTokens.Focus.ringButtonRing, C.accent)
.focusRing(focused, WolfskinTokens.Radius.button, WolfskinTokens.Focus.ringButtonGap, WolfskinTokens.Focus.ringButtonRing, C.accent, FocusLift.Button)
.background(if (primary) C.fg else if (focused) C.buttonSecondaryFocused else C.buttonSecondary, RoundedCornerShape(WolfskinTokens.Radius.button))
.clickable(onClick = onClick).padding(horizontal = L.buttonPaddingX), horizontalArrangement = Arrangement.spacedBy(L.badgeInset), verticalAlignment = Alignment.CenterVertically) {
if (glyph != null) Glyph(glyph, if (primary) C.onFocus else C.fg, Modifier.size(L.buttonIcon))
Text(label, color = if (primary) C.onFocus else C.fg, style = WolfskinTokens.Type.button, maxLines = 1)
Text(label, color = if (primary) C.onFocus else C.fg, style = DiscoverType.button, maxLines = 1)
}
}

View file

@ -9,9 +9,7 @@ import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.offset
@ -21,7 +19,6 @@ import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
@ -31,12 +28,9 @@ 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.clip
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.zIndex
import androidx.tv.material3.Text
import org.justinvisser.wolfskin.design.WolfskinTokens.Card as K
@ -69,18 +63,18 @@ fun DiscoverShelf(state: DiscoverState, lane: DiscoverLane, row: Int) {
initialized = true
}
Box(Modifier.fillMaxWidth().height(L.rowHeadingBand + height + (if (services) WolfskinTokens.Shell.zero else K.posterCaptionBand) + L.rowGapAfter).testTag("discover-lane-${lane.id}")) {
Text(lane.title, Modifier.offset(L.safeSide), color = C.fg, style = WolfskinTokens.Type.rowHeading, maxLines = 1)
Text(lane.title, Modifier.offset(L.safeSide), color = C.fg, style = DiscoverType.rowHeading, maxLines = 1)
LazyRow(state = cards, userScrollEnabled = false,
contentPadding = PaddingValues(start = L.safeSide, end = WolfskinTokens.Shell.stageWidth - L.safeSide - (K.posterW * slots + K.posterGap * (slots - 1)), top = K.posterGap, bottom = K.posterGap),
horizontalArrangement = Arrangement.spacedBy(K.posterGap),
modifier = Modifier.offset(y = L.rowHeadingBand - K.posterGap).height(height + K.posterGap * 2)) {
if (services) itemsIndexed(state.snapshot.services, key = { _, service -> "service:${service.id}" }, contentType = { _, _ -> "entry" }) { index, service ->
Box(Modifier.size(K.entryW, K.entryH).focusScale(active && index == column, WolfskinTokens.Focus.scaleCard)
.focusRing(active && index == column, WolfskinTokens.Radius.card, WolfskinTokens.Focus.ringCardGap, WolfskinTokens.Focus.ringCardRing, C.accent)
.focusRing(active && index == column, WolfskinTokens.Radius.card, WolfskinTokens.Focus.ringCardGap, WolfskinTokens.Focus.ringCardRing, C.accent, FocusLift.Card)
.background(Brush.linearGradient(listOf(Color.hsl(service.hue, .30f, .26f), Color.hsl(service.hue, .30f, .12f))), RoundedCornerShape(WolfskinTokens.Radius.card))
.border(WolfskinTokens.Focus.ringCardRing / 2, C.border, RoundedCornerShape(WolfskinTokens.Radius.card))
.border(WolfskinTokens.Library.borderWidth, C.border.copy(alpha = D.entryBorderOpacity), RoundedCornerShape(WolfskinTokens.Radius.card))
.clickable { state.focus(row, index); state.openService(service) }, contentAlignment = Alignment.BottomStart) {
Text(service.name, Modifier.padding(start = D.entryPaddingX, bottom = D.entryPaddingY), style = D.entryText, color = C.fg)
Text(service.name, Modifier.padding(start = D.entryPaddingX, bottom = D.entryPaddingY), style = DiscoverType.entry, color = C.fg)
}
} else {
itemsIndexed(lane.titles, key = { _, item -> item.key }, contentType = { _, _ -> "poster" }) { index, item ->

View file

@ -0,0 +1,24 @@
// SPDX-License-Identifier: GPL-2.0-only
package org.justinvisser.wolfskin.design
import androidx.compose.ui.text.PlatformTextStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.LineHeightStyle
/** CSS line boxes survive native first/last-line trimming only within Discover's text stacks. */
internal object DiscoverType {
private val lineBox = LineHeightStyle(LineHeightStyle.Alignment.Center, LineHeightStyle.Trim.None)
private val platform = PlatformTextStyle(includeFontPadding = false)
val hero = WolfskinTokens.Type.displayHero.copy(lineHeightStyle = lineBox, platformStyle = platform)
val body = WolfskinTokens.Type.body.copy(lineHeightStyle = lineBox, platformStyle = platform)
val label = WolfskinTokens.Type.label.copy(lineHeightStyle = lineBox, platformStyle = platform)
val button = WolfskinTokens.Type.button.copy(lineHeightStyle = lineBox, platformStyle = platform)
val sheetTitle = WolfskinTokens.Type.sheetTitle.copy(lineHeightStyle = lineBox, platformStyle = platform)
val stateTitle = WolfskinTokens.Type.stateTitle.copy(fontWeight = FontWeight.Bold, lineHeightStyle = lineBox, platformStyle = platform)
val listTitle = WolfskinTokens.Type.listTitle.copy(lineHeightStyle = lineBox, platformStyle = platform)
val caption = WolfskinTokens.Type.caption.copy(lineHeightStyle = lineBox, platformStyle = platform)
val reason = WolfskinTokens.Type.caption.copy(fontWeight = FontWeight.Medium, lineHeightStyle = lineBox, platformStyle = platform)
val rowHeading = WolfskinTokens.Type.rowHeading.copy(lineHeightStyle = lineBox, platformStyle = platform)
val entry = WolfskinTokens.Discover.entryText.copy(lineHeightStyle = lineBox, platformStyle = platform)
val ratings = WolfskinTokens.Type.ratingPlate.copy(fontFeatureSettings = "tnum", lineHeightStyle = lineBox, platformStyle = platform)
}

View file

@ -5,6 +5,10 @@ import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.tween
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.ui.draw.dropShadow
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.geometry.CornerRadius
@ -25,16 +29,31 @@ fun Modifier.focusScale(focused: Boolean, scale: Float): Modifier {
return graphicsLayer { scaleX = animated; scaleY = animated }
}
/** Content lifts are distinct from the white person/gear rings. */
enum class FocusLift { None, Card, Button }
/** Draw the ring outside the slot; neither the gap nor the ring obscures content. */
@Composable
fun Modifier.focusRing(focused: Boolean, radius: Dp, gap: Dp, width: Dp, color: Color): Modifier {
val alpha by animateFloatAsState(if (focused) 1f else 0f,
fun Modifier.focusRing(focused: Boolean, radius: Dp, gap: Dp, width: Dp, color: Color, lift: FocusLift = FocusLift.None): Modifier {
val ringAlpha by animateFloatAsState(if (focused) 1f else 0f,
tween(WolfskinTokens.Motion.fast, easing = CubicBezierEasing(.25f, .1f, .25f, 1f)), label = "focus-ring")
return drawBehind {
if (alpha > 0f) {
val shadow = if (lift == FocusLift.None) this else {
val shape = remember(radius) { RoundedCornerShape(radius) }
val density = LocalDensity.current
val blur = with(density) { (if (lift == FocusLift.Card) WolfskinTokens.Lift.cardBlur else WolfskinTokens.Lift.buttonBlur).toPx() }
val shift = with(density) { (if (lift == FocusLift.Card) WolfskinTokens.Lift.cardY else WolfskinTokens.Lift.buttonY).toPx() }
dropShadow(shape) {
this.radius = blur
offset = Offset(0f, shift)
this.color = Color.Black
alpha = ringAlpha * if (lift == FocusLift.Card) WolfskinTokens.Lift.cardOpacity else WolfskinTokens.Lift.buttonOpacity
}
}
return shadow.drawBehind {
if (ringAlpha > 0f) {
val outer = (gap + width).toPx()
val inner = gap.toPx()
drawRoundRect(color.copy(alpha = color.alpha * alpha), Offset(-outer, -outer), Size(size.width + 2 * outer, size.height + 2 * outer), CornerRadius(radius.toPx() + outer))
drawRoundRect(color.copy(alpha = color.alpha * ringAlpha), Offset(-outer, -outer), Size(size.width + 2 * outer, size.height + 2 * outer), CornerRadius(radius.toPx() + outer))
drawRoundRect(WolfskinTokens.Color.bg, Offset(-inner, -inner), Size(size.width + 2 * inner, size.height + 2 * inner), CornerRadius(radius.toPx() + inner))
}
}

View file

@ -43,7 +43,7 @@ fun LibraryPoster(
val shape = RoundedCornerShape(WolfskinTokens.Radius.card)
Box(modifier.size(WolfskinTokens.Card.posterW, WolfskinTokens.Card.posterH)
.focusScale(focused, WolfskinTokens.Focus.scaleCard)
.focusRing(focused, WolfskinTokens.Radius.card, WolfskinTokens.Focus.ringCardGap, WolfskinTokens.Focus.ringCardRing, C.accent)
.focusRing(focused, WolfskinTokens.Radius.card, WolfskinTokens.Focus.ringCardGap, WolfskinTokens.Focus.ringCardRing, C.accent, FocusLift.Card)
.testTag("poster-${title.id}")) {
Box(Modifier.fillMaxSize().clip(shape).background(C.surface2)) {
val bitmap = remember(title.poster) { title.poster?.let { offlineArt?.invoke(it) } }

View file

@ -5,7 +5,6 @@ import androidx.compose.foundation.border
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.withStyle
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn

View file

@ -17,7 +17,7 @@ fun LibrarySeeAllCard(count: Int, focused: Boolean, onClick: () -> Unit, modifie
width: Dp = WolfskinTokens.Card.posterW, height: Dp = WolfskinTokens.Card.posterH) {
val shape = RoundedCornerShape(WolfskinTokens.Radius.card)
Column(modifier.size(width, height).focusScale(focused, WolfskinTokens.Focus.scaleCard)
.focusRing(focused, WolfskinTokens.Radius.card, WolfskinTokens.Focus.ringCardGap, WolfskinTokens.Focus.ringCardRing, WolfskinTokens.Color.accent)
.focusRing(focused, WolfskinTokens.Radius.card, WolfskinTokens.Focus.ringCardGap, WolfskinTokens.Focus.ringCardRing, WolfskinTokens.Color.accent, FocusLift.Card)
.background(WolfskinTokens.Color.surface, shape).border(WolfskinTokens.Library.borderWidth, WolfskinTokens.Color.border, shape)
.clickable(onClick = onClick), verticalArrangement = Arrangement.spacedBy(WolfskinTokens.Library.seeAllGap, Alignment.CenterVertically), horizontalAlignment = Alignment.CenterHorizontally) {
Glyph("interface", WolfskinTokens.Color.accent, Modifier.size(WolfskinTokens.Layout.badge))

View file

@ -105,7 +105,7 @@ private fun RecoveryPicker(state: ShellState) {
fun PickerButton(label: String, focused: Boolean, primary: Boolean, onClick: () -> Unit) {
val background = if (primary) C.fg else if (focused) C.buttonSecondaryFocused else C.buttonSecondary
Box(Modifier.height(L.buttonHeight).focusScale(focused, WolfskinTokens.Focus.scaleControl)
.focusRing(focused, WolfskinTokens.Radius.button, WolfskinTokens.Focus.ringButtonGap, WolfskinTokens.Focus.ringButtonRing, C.accent)
.focusRing(focused, WolfskinTokens.Radius.button, WolfskinTokens.Focus.ringButtonGap, WolfskinTokens.Focus.ringButtonRing, C.accent, FocusLift.Button)
.background(background, RoundedCornerShape(WolfskinTokens.Radius.button)).clickable(onClick = onClick)
.padding(horizontal = L.buttonPaddingX), contentAlignment = Alignment.Center) {
Text(label, color = if (primary) C.onFocus else C.fg, style = WolfskinTokens.Type.button)

View file

@ -337,6 +337,20 @@ object WolfskinTokens {
const val fallbackLightnessBottom = 0.1f
const val fallbackIconOpacity = 0.45f
}
object Lift {
val cardBlur = 25.0.dp
val cardY = 9.0.dp
const val cardOpacity = 0.65f
val buttonBlur = 20.0.dp
val buttonY = 6.0.dp
const val buttonOpacity = 0.5f
val heroBlur = 40.0.dp
val heroY = 15.0.dp
const val heroOpacity = 0.6f
val sheetBlur = 40.0.dp
val sheetX = -15.0.dp
const val sheetOpacity = 0.5f
}
object Discover {
val sheetPaddingX = 32.0.dp
val sheetPaddingY = 40.0.dp
@ -389,6 +403,9 @@ object WolfskinTokens {
val frameBottomMin = 40.0.dp
val anchorFitTop = 350.0.dp
val anchorBottomClearance = 20.0.dp
const val reasonOpacity = 0.8f
val stageGap = 5.0.dp
const val entryBorderOpacity = 0.08f
val ambientInset = 40.0.dp
val ambientBlur = 30.0.dp
const val ambientScale = 1.1f

View file

@ -8,7 +8,6 @@ import android.view.WindowManager
import androidx.activity.ComponentActivity
import androidx.compose.foundation.layout.requiredSize
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.graphics.ImageBitmap

View file

@ -209,6 +209,14 @@
--library-headerSubtitle: 30.0px;
--library-fallbackPadding: 22.0px;
--library-fallbackIcon: 44.0px;
--lift-cardBlur: 50.0px;
--lift-cardY: 18.0px;
--lift-buttonBlur: 40.0px;
--lift-buttonY: 12.0px;
--lift-heroBlur: 80.0px;
--lift-heroY: 30.0px;
--lift-sheetBlur: 80.0px;
--lift-sheetX: -30.0px;
--discover-sheetPaddingX: 64.0px;
--discover-sheetPaddingY: 80.0px;
--discover-sheetGap: 18.0px;
@ -260,6 +268,7 @@
--discover-frameBottomMin: 80.0px;
--discover-anchorFitTop: 700.0px;
--discover-anchorBottomClearance: 40.0px;
--discover-stageGap: 10.0px;
--discover-ambientInset: 80.0px;
--discover-ambientBlur: 60.0px;
}

View file

@ -1,6 +1,6 @@
ffc070ec958ece59f2e8eecd1144f43cdaf7fdb53fe51b178f1b17457ea4151a discover-tokens.json
1f22175c1ce3987c4efa14d573601641a61e1a915e2c464c943b6c400356adb4 discover-tokens.json
fcff646d59fb39e859c34c05037e05a7fb7583de2fe07315ec0176cf4d597980 generate_tokens.py
050823bdd2bb71181a7b7c849e412129f312a150a27c2e5b9b3fbc1f5ccece0f shell-tokens.json
ab20caec74fcbedadfbc59449b227a11a62cf79e8bc862d5f95ec95d1b8574db src/main/java/org/justinvisser/wolfskin/design/WolfskinTokens.kt
4b741830cda8b905d71f97bf88ee8d6cca3e1cf0f881bc06c5525cbf05378808 tokens.css
b1553e7dd694e5bafb770f6a583c969fe738fa20085b3e7e46e6555f46d0cbdc shell-tokens.json
0818af386cc057730c08328f828e9859afa4ad8a40eb665ca1f6dff90146a670 src/main/java/org/justinvisser/wolfskin/design/WolfskinTokens.kt
d8c55bef3d7a911466d90902ad144d8283f5f0a73367ade1a0fcc3bd640722e0 tokens.css
5b939f1f3eb90b428008fda75720f6e3a4e844c486668931db3d52bffb987b53 tokens.json