Compare commits

..

No commits in common. "b54bcc148c362a7332c502d96643b541eb2f7b83" and "c534ea75c25ee189d132d7c4dd3283031de7214d" have entirely different histories.

19 changed files with 32 additions and 244 deletions

View file

@ -18,13 +18,11 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.toPixelMap import androidx.compose.ui.graphics.toPixelMap
import androidx.compose.ui.input.key.Key import androidx.compose.ui.input.key.Key
import androidx.compose.ui.platform.testTag import androidx.compose.ui.platform.testTag
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.assertIsFocused import androidx.compose.ui.test.assertIsFocused
import androidx.compose.ui.test.assertIsNotFocused import androidx.compose.ui.test.assertIsNotFocused
import androidx.compose.ui.test.captureToImage import androidx.compose.ui.test.captureToImage
import androidx.compose.ui.test.junit4.createAndroidComposeRule import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.onRoot import androidx.compose.ui.test.onRoot
import androidx.compose.ui.test.performKeyInput import androidx.compose.ui.test.performKeyInput
import androidx.compose.ui.test.pressKey import androidx.compose.ui.test.pressKey
@ -133,7 +131,6 @@ class UpstreamContentFocusTest {
rule.runOnIdle { WolfskinScreenPreference.enabled = true } rule.runOnIdle { WolfskinScreenPreference.enabled = true }
rule.mainClock.advanceTimeBy(1500) rule.mainClock.advanceTimeBy(1500)
rule.waitForIdle() rule.waitForIdle()
rule.onNodeWithText("Sign in as Justin to open their Jellyfin library").assertIsDisplayed()
val on = rule.onRoot().captureToImage().toPixelMap()[1700, 900] val on = rule.onRoot().captureToImage().toPixelMap()[1700, 900]
val background = org.justinvisser.wolfskin.design.WolfskinTokens.Color.bg val background = org.justinvisser.wolfskin.design.WolfskinTokens.Color.bg
org.junit.Assert.assertEquals("Owned background red", background.red, on.red, 1f / 255f) org.junit.Assert.assertEquals("Owned background red", background.red, on.red, 1f / 255f)

View file

@ -185,7 +185,7 @@ fun WolfskinApp(
val upstream = resolveUpstreamDestination(destination, WolfskinScreenPreference.enabled, viewModel.libraryDestinations) val upstream = resolveUpstreamDestination(destination, WolfskinScreenPreference.enabled, viewModel.libraryDestinations)
ShellSurface(state, handlesContentKeys = upstream == null) { contentModifier -> ShellSurface(state, handlesContentKeys = upstream == null) { contentModifier ->
when { when {
(upstream != null || destination is Destination.Home) && state.person.key != viewModel.signedInPerson -> ShellPlaceholder("Sign in as ${state.person.name} to open their Jellyfin library", contentModifier) upstream != null && state.person.key != viewModel.signedInPerson -> ShellPlaceholder("Sign in as ${state.person.name} to open their Jellyfin library", contentModifier)
upstream != null -> DestinationContent(upstream, preferences, viewModel::clearBackdrop, contentModifier) upstream != null -> DestinationContent(upstream, preferences, viewModel::clearBackdrop, contentModifier)
destination is Destination.Home -> HomeDestination(state, server, user, contentModifier) destination is Destination.Home -> HomeDestination(state, server, user, contentModifier)
destination is Destination.Wolfskin -> DestinationContent(destination, preferences, viewModel::clearBackdrop, contentModifier) destination is Destination.Wolfskin -> DestinationContent(destination, preferences, viewModel::clearBackdrop, contentModifier)

View file

@ -78,11 +78,6 @@ resolves consistently in native captures; the approved JSON weights stay unchang
Nav, picker names, labels and pill weights use that same mapping. Nav label colour Nav, picker names, labels and pill weights use that same mapping. Nav label colour
fades for 200 ms ease-out; the blob retains its critically damped spring. fades for 200 ms ease-out; the blob retains its critically damped spring.
Settings pills have a separate 1 dp stroke (2 px on the stage), not a focus ring.
The stroke is inset within the fixed 20 dp / 40 px height; horizontal border-box
padding includes it. The 6 px dash / 4 px gap follows the browser's 3:2 stroke-width
ratios. Resting borders are white at 22% opacity; focused borders use ink at 40%.
The small gear under the final person opens Settings with Requests focused. The small gear under the final person opens Settings with Requests focused.
Settings groups hand off to existing upstream pages; the Requests entry explicitly Settings groups hand off to existing upstream pages; the Requests entry explicitly
has no request data yet. **Advanced settings → Wolfskin screens** is the persistent has no request data yet. **Advanced settings → Wolfskin screens** is the persistent
@ -130,15 +125,12 @@ The final action opens Discover at its top. Discover (`discover`) and See all
later screens. Title navigation remains the shell's later-slice target. later screens. Title navigation remains the shell's later-slice target.
All Home components and immutable screen models live in `:wolfskin-design`. All Home components and immutable screen models live in `:wolfskin-design`.
The shell's remembered view owns vertical position, each row's card and first The shell's remembered view owns vertical position, each row's card and the
visible card, and the bounded six-hour ratings session cache. Back preserves the viewport; returning bounded six-hour ratings session cache. Back preserves the viewport; returning
to Home does not re-run focus-follow over a manually scrolled saved view. The to Home does not re-run focus-follow over a manually scrolled saved view. The
next arrow re-enters visible content when scrolling moved focus off-screen. next arrow re-enters visible content when scrolling moved focus off-screen.
Pointing changes focus without activation, cards activate on key-up, and held Pointing changes focus without activation, cards activate on key-up, and held
OK events cannot activate twice. OK events cannot activate twice.
Between shelves, an unvisited row selects the nearest visible card center even
when card widths differ. A visited row restores its column. Horizontal movement
keeps the track still while focus remains in its visible middle.
`HomeReadFacade` is the only Home code importing Jellyfin API clients. Release `HomeReadFacade` is the only Home code importing Jellyfin API clients. Release
variants read the selected user's Resume, Next up, newest titles and destination variants read the selected user's Resume, Next up, newest titles and destination
@ -147,8 +139,6 @@ disk cache. A missing saved library becomes a recovery panel; a saved library
stays visible when Jellyfin is unreachable. Playback resolves an actual film or stays visible when Jellyfin is unreachable. Playback resolves an actual film or
episode and checks Jellyfin before opening upstream's player. No season, episode and checks Jellyfin before opening upstream's player. No season,
recommendation stub or different person's session is promoted to playback. recommendation stub or different person's session is promoted to playback.
An unresolved person switch shows the same sign-in guard for Home as for native
library content, before mounting Home's source reads.
Debug variants additionally package the locked fixture and local artwork under Debug variants additionally package the locked fixture and local artwork under
`wolfskin-design/src/debug/assets/wolfskin-home/`; release variants exclude them. `wolfskin-design/src/debug/assets/wolfskin-home/`; release variants exclude them.
@ -163,11 +153,6 @@ the next shelf. Rating plates use the TV locale, start at 14 sp, shrink to 12 sp
and drop trailing entries rather than wrap. Their uncached read gate is 350 ms; and drop trailing entries rather than wrap. Their uncached read gate is 350 ms;
cached focus returns without that delay. cached focus returns without that delay.
Focus rings and native lift draw outside fixed slots. The Saved chip uses its
own measured geometry rather than the smaller Settings pill; caption separators
use the approved 40% opacity token. Vertical moves clamp to the short target row,
including a final See all card, rather than preserving an invalid column.
`make test` includes `HomeCaptureTest` (23 native acceptance states) and the `make test` includes `HomeCaptureTest` (23 native acceptance states) and the
data-driven paths in `wolfskin-home-presses.json`: play next 2, resume 3, Discover data-driven paths in `wolfskin-home-presses.json`: play next 2, resume 3, Discover
4, deep-row Back/Down 9, Trending 13 and Trending's See all card 33 presses. 4, deep-row Back/Down 9, Trending 13 and Trending's See all card 33 presses.

View file

@ -317,19 +317,6 @@
"px": 16, "px": 16,
"dp": 8.0, "dp": 8.0,
"source": "locked index.html:191" "source": "locked index.html:191"
}, }
"hairline": { "$type": "dimension", "px": 2, "dp": 1.0, "source": "locked index.html:87,146,194" },
"savedHeight": { "$type": "dimension", "px": 52, "dp": 26.0, "source": "locked index.html:87" },
"savedPadding": { "$type": "dimension", "px": 20, "dp": 10.0, "source": "locked index.html:87" },
"savedGap": { "$type": "dimension", "px": 10, "dp": 5.0, "source": "locked index.html:87" },
"savedIcon": { "$type": "dimension", "px": 28, "dp": 14.0, "source": "locked index.html:88" },
"savedRadius": { "$type": "dimension", "px": 28, "dp": 14.0, "source": "locked index.html:87" },
"cardLiftOffset": { "$type": "dimension", "px": 18, "dp": 9.0, "source": "locked index.html:134" },
"cardLiftBlur": { "$type": "dimension", "px": 50, "dp": 25.0, "source": "locked index.html:134" },
"cardLiftOpacity": { "$type": "number", "$value": 0.65, "source": "locked index.html:134" },
"buttonLiftOffset": { "$type": "dimension", "px": 12, "dp": 6.0, "source": "locked index.html:110" },
"buttonLiftBlur": { "$type": "dimension", "px": 40, "dp": 20.0, "source": "locked index.html:110" },
"buttonLiftOpacity": { "$type": "number", "$value": 0.5, "source": "locked index.html:110" },
"factSeparatorOpacity": { "$type": "number", "$value": 0.4, "source": "approved spec 4.4" }
} }
} }

View file

@ -215,32 +215,6 @@
"dp": 20.0, "dp": 20.0,
"source": "locked index.html:71-92, 256-263, 303-328; spec 6.1 and nav-shell measured bounds" "source": "locked index.html:71-92, 256-263, 303-328; spec 6.1 and nav-shell measured bounds"
}, },
"settingsPillStroke": {
"$type": "dimension",
"px": 2,
"dp": 1.0,
"source": "index.html:122; pill border 2 px inside its 40 px border-box"
},
"settingsPillDashRatio": {
"$type": "number",
"$value": 3,
"source": "st-091 frozen browser top border: 6 px dashes at 2 px stroke width"
},
"settingsPillDashGapRatio": {
"$type": "number",
"$value": 2,
"source": "st-091 frozen browser top border: 4 px gaps at 2 px stroke width"
},
"settingsPillBorder": {
"$type": "color",
"$value": "#ffffff38",
"source": "index.html:122; white at 22 percent opacity, rounded to 8-bit alpha"
},
"settingsPillFocusedBorder": {
"$type": "color",
"$value": "#0b0b1066",
"source": "index.html:328; focused pill ink at 40 percent opacity"
},
"settingsPillPadding": { "settingsPillPadding": {
"$type": "dimension", "$type": "dimension",
"px": 16, "px": 16,

View file

@ -25,7 +25,7 @@ fun Caption(title: String, facts: String, visible: Boolean, modifier: Modifier =
Row(Modifier.clipToBounds(), horizontalArrangement = Arrangement.spacedBy(WolfskinTokens.Layout.captionOffset)) { Row(Modifier.clipToBounds(), horizontalArrangement = Arrangement.spacedBy(WolfskinTokens.Layout.captionOffset)) {
val entries = facts.split(" · ") + listOfNotNull(availability) val entries = facts.split(" · ") + listOfNotNull(availability)
entries.forEachIndexed { index, fact -> entries.forEachIndexed { index, fact ->
if (index > 0) Text("·", color = WolfskinTokens.Color.fg.copy(alpha = WolfskinTokens.Home.factSeparatorOpacity), style = WolfskinTokens.Type.caption) if (index > 0) Text("·", color = WolfskinTokens.Color.fg.copy(alpha = .4f), style = WolfskinTokens.Type.caption)
Text(fact, color = if (availability != null && index == entries.lastIndex) WolfskinTokens.Color.accentText else WolfskinTokens.Color.muted, style = WolfskinTokens.Type.caption, maxLines = 1, softWrap = false) Text(fact, color = if (availability != null && index == entries.lastIndex) WolfskinTokens.Color.accentText else WolfskinTokens.Color.muted, style = WolfskinTokens.Type.caption, maxLines = 1, softWrap = false)
} }
} }

View file

@ -13,15 +13,11 @@ import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsHoveredAsState import androidx.compose.foundation.interaction.collectIsHoveredAsState
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawBehind import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.draw.drawWithCache
import androidx.compose.ui.geometry.CornerRadius import androidx.compose.ui.geometry.CornerRadius
import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.graphics.drawscope.drawIntoCanvas
import androidx.compose.ui.graphics.nativeCanvas
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.Dp
/** Only transforms animate: focus never changes measured geometry. */ /** Only transforms animate: focus never changes measured geometry. */
@ -50,26 +46,6 @@ fun Modifier.focusRing(focused: Boolean, radius: Dp, gap: Dp, width: Dp, color:
} }
} }
/** Native lift draws outside the slot without allocating an offscreen layer per card. */
@Composable
fun Modifier.focusLift(focused: Boolean, radius: Dp, offset: Dp, blur: Dp, opacity: Float): Modifier {
val alpha by animateFloatAsState(if (focused) 1f else 0f,
tween(WolfskinTokens.Motion.fast), label = "focus-lift")
return drawWithCache {
val paint = android.graphics.Paint(android.graphics.Paint.ANTI_ALIAS_FLAG).apply {
color = WolfskinTokens.Color.bg.toArgb()
setShadowLayer(blur.toPx(), 0f, offset.toPx(), WolfskinTokens.Color.sheetScrim.copy(alpha = opacity).toArgb())
}
val corner = radius.toPx()
onDrawBehind {
if (alpha > 0f) {
paint.alpha = (alpha * 255).toInt()
drawIntoCanvas { it.nativeCanvas.drawRoundRect(0f, 0f, size.width, size.height, corner, corner, paint) }
}
}
}
}
/** Pointing changes the same logical focus as the D-pad without activating an action. */ /** Pointing changes the same logical focus as the D-pad without activating an action. */
@Composable @Composable
fun Modifier.focusOnPointerEnter(onEnter: () -> Unit): Modifier { fun Modifier.focusOnPointerEnter(onEnter: () -> Unit): Modifier {

View file

@ -1,7 +1,5 @@
package org.justinvisser.wolfskin.design package org.justinvisser.wolfskin.design
import kotlin.math.abs
/** Home navigation mutates the shell's per-person view, including each row's remembered column. */ /** Home navigation mutates the shell's per-person view, including each row's remembered column. */
class HomeFocus( class HomeFocus(
private val shell: ShellState, private val shell: ShellState,
@ -17,10 +15,7 @@ class HomeFocus(
shell.navFocused = false shell.navFocused = false
shell.view.row = index shell.view.row = index
shell.view.home.focusedKey = rows[index].id shell.view.home.focusedKey = rows[index].id
val target = rows[index] shell.view.cards[rows[index].id] = column.coerceIn(0, (rows[index].count - 1).coerceAtLeast(0))
val bounded = column.coerceIn(0, (target.count - 1).coerceAtLeast(0))
shell.view.cards[target.id] = bounded
if (target.kind == HomeRowKind.Shelf) shell.view.home.firstCards[target.id] = target.adjustedFirst(bounded, shell.view.home.firstCards[target.id] ?: 0)
} }
fun key(key: ShellKey, down: Boolean, repeat: Int): Boolean { fun key(key: ShellKey, down: Boolean, repeat: Int): Boolean {
@ -48,7 +43,7 @@ class HomeFocus(
while (next in rows.indices && !rows[next].focusable) next += direction while (next in rows.indices && !rows[next].focusable) next += direction
if (next in rows.indices) { if (next in rows.indices) {
val target = rows[next] val target = rows[next]
val nearest = nearest(current, target) val nearest = (column * pitch(current) / pitch(target)).toInt()
focus(next, shell.view.cards[target.id] ?: nearest) focus(next, shell.view.cards[target.id] ?: nearest)
} else if (direction < 0) { } else if (direction < 0) {
shell.view.row = rows.indexOfFirst { it.focusable }.coerceAtLeast(0) shell.view.row = rows.indexOfFirst { it.focusable }.coerceAtLeast(0)
@ -89,38 +84,9 @@ class HomeFocus(
} }
} }
private fun nearest(from: HomeRow, to: HomeRow): Int {
if (to.kind != HomeRowKind.Shelf) return (column * pitch(from) / pitch(to)).toInt()
val fromFirst = if (from.kind == HomeRowKind.Shelf) shell.view.home.firstCards[from.id] ?: from.adjustedFirst(column, 0) else 0
val x = (column - fromFirst) * pitch(from) + from.cardWidth.value / 2
val first = (shell.view.home.firstCards[to.id] ?: 0).coerceIn(0, (to.count - to.focusSlots).coerceAtLeast(0))
val last = minOf(to.count - 1, first + to.focusSlots - 1)
var best = first
var distance = Float.POSITIVE_INFINITY
for (index in first..last) {
val candidate = abs((index - first) * pitch(to) + to.cardWidth.value / 2 - x)
if (candidate < distance) { best = index; distance = candidate }
}
return best
}
private fun pitch(row: HomeRow): Float = when (row.cardKind) { private fun pitch(row: HomeRow): Float = when (row.cardKind) {
HomeCardKind.Poster -> WolfskinTokens.Card.posterPitch.value HomeCardKind.Poster -> WolfskinTokens.Card.posterPitch.value
HomeCardKind.Tile -> WolfskinTokens.Card.tilePitch.value HomeCardKind.Tile -> WolfskinTokens.Card.tilePitch.value
HomeCardKind.Wide -> WolfskinTokens.Card.widePitch.value HomeCardKind.Wide -> WolfskinTokens.Card.widePitch.value
} }
} }
internal val HomeRow.focusSlots: Int get() = when (cardKind) {
HomeCardKind.Poster -> 6
HomeCardKind.Tile -> 4
HomeCardKind.Wide -> 3
}
/** Keep one card behind focus, but do not shift again while focus stays in the visible middle. */
internal fun HomeRow.adjustedFirst(column: Int, previous: Int): Int {
var first = previous
if (column > first + focusSlots - 2) first = column - (focusSlots - 2)
if (column < first + 1) first = (column - 1).coerceAtLeast(0)
return first.coerceIn(0, (count - focusSlots).coerceAtLeast(0))
}

View file

@ -44,8 +44,8 @@ fun HomeHero(row: HomeRow, focused: Boolean, column: Int, ratings: List<HomeRati
Spacer(Modifier.height(H.overviewBottom)) Spacer(Modifier.height(H.overviewBottom))
Row(horizontalArrangement = Arrangement.spacedBy(L.buttonGap)) { Row(horizontalArrangement = Arrangement.spacedBy(L.buttonGap)) {
val primary = if (row.heroKind == "pick") "Details" else (if (row.heroKind == "resume") "Resume" else "Play") + if (title.isEpisode) " ${title.episodeLabel}" else "" val primary = if (row.heroKind == "pick") "Details" else (if (row.heroKind == "resume") "Resume" else "Play") + if (title.isEpisode) " ${title.episodeLabel}" else ""
PickerButton(primary, focused && column == 0, true, if (row.heroKind == "pick") "about" else "playback", onFocus = { onHover(0) }, lift = true) { onButton(0) } PickerButton(primary, focused && column == 0, true, if (row.heroKind == "pick") "about" else "playback", onFocus = { onHover(0) }) { onButton(0) }
if (row.heroKind != "pick") PickerButton("Details", focused && column == 1, false, "about", onFocus = { onHover(1) }, lift = true) { onButton(1) } if (row.heroKind != "pick") PickerButton("Details", focused && column == 1, false, "about", onFocus = { onHover(1) }) { onButton(1) }
} }
} }
} else { } else {
@ -78,7 +78,7 @@ fun HomeSection(person: ShellPerson, savedAt: String?) {
@Composable @Composable
fun HomeRecovery(row: HomeRow, focused: Boolean, retry: () -> Unit, onHover: () -> Unit) { fun HomeRecovery(row: HomeRow, focused: Boolean, retry: () -> Unit, onHover: () -> Unit) {
Box(Modifier.fillMaxWidth().height(L.row_state)) { Box(Modifier.fillMaxWidth().height(L.row_state)) {
Column(Modifier.offset(L.safeSide, H.stateTop).width(L.stateWidth).background(C.surface, RoundedCornerShape(WolfskinTokens.Radius.panel)).border(H.hairline, C.border, RoundedCornerShape(WolfskinTokens.Radius.panel)).padding(horizontal = H.statePaddingX, vertical = H.statePaddingY)) { Column(Modifier.offset(L.safeSide, H.stateTop).width(L.stateWidth).background(C.surface, RoundedCornerShape(WolfskinTokens.Radius.panel)).border(WolfskinTokens.Focus.ringCardRing / 2, C.border, RoundedCornerShape(WolfskinTokens.Radius.panel)).padding(horizontal = H.statePaddingX, vertical = H.statePaddingY)) {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(H.plateTop)) { Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(H.plateTop)) {
Glyph(if (row.calm) "profile" else "alert", if (row.calm) C.accentText else C.warn, Modifier.size(H.stateIcon)) Glyph(if (row.calm) "profile" else "alert", if (row.calm) C.accentText else C.warn, Modifier.size(H.stateIcon))
Text(row.title, color = C.fg, style = T.stateTitle.copy(fontWeight = FontWeight.Bold)) Text(row.title, color = C.fg, style = T.stateTitle.copy(fontWeight = FontWeight.Bold))
@ -86,7 +86,7 @@ fun HomeRecovery(row: HomeRow, focused: Boolean, retry: () -> Unit, onHover: ()
Spacer(Modifier.height(H.stateGap)) Spacer(Modifier.height(H.stateGap))
Text(row.text, color = C.muted, style = T.body) Text(row.text, color = C.muted, style = T.body)
Spacer(Modifier.height(H.stateTextBottom)) Spacer(Modifier.height(H.stateTextBottom))
PickerButton("Try again", focused, row.calm, onFocus = onHover, lift = true, onClick = retry) PickerButton("Try again", focused, row.calm, onFocus = onHover, onClick = retry)
} }
} }
} }

View file

@ -36,9 +36,6 @@ fun HomeScreen(
onDispose { shell.contentInput = null } onDispose { shell.contentInput = null }
} }
LaunchedEffect(snapshot.rows, view) { LaunchedEffect(snapshot.rows, view) {
snapshot.rows.forEach { item ->
view.cards[item.id]?.let { view.cards[item.id] = it.coerceIn(0, (item.count - 1).coerceAtLeast(0)) }
}
val remembered = snapshot.rows.indexOfFirst { it.id == view.home.focusedKey && it.focusable } val remembered = snapshot.rows.indexOfFirst { it.id == view.home.focusedKey && it.focusable }
if (remembered >= 0) view.row = remembered if (remembered >= 0) view.row = remembered
if (snapshot.rows.none { it.focusable }) { shell.navFocused = true; shell.navIndex = shell.currentTab } if (snapshot.rows.none { it.focusable }) { shell.navFocused = true; shell.navIndex = shell.currentTab }
@ -89,7 +86,7 @@ fun HomeScreen(
fun activate(column: Int) { focus.focus(index, column); focus.key(ShellKey.Ok, true, 0); focus.key(ShellKey.Ok, false, 0) } fun activate(column: Int) { focus.focus(index, column); focus.key(ShellKey.Ok, true, 0); focus.key(ShellKey.Ok, false, 0) }
when (item.kind) { when (item.kind) {
HomeRowKind.Hero -> HomeHero(item, selected, focus.column, if (showRatings) heroRatings else emptyList(), ::activate) { focus.focus(index, it) } HomeRowKind.Hero -> HomeHero(item, selected, focus.column, if (showRatings) heroRatings else emptyList(), ::activate) { focus.focus(index, it) }
HomeRowKind.Shelf -> HomeShelf(item, view.home, selected, view.cards[item.id] ?: 0, if (showRatings && selected) focusedRatings else emptyList(), ::activate) { focus.focus(index, it) } HomeRowKind.Shelf -> HomeShelf(item, selected, view.cards[item.id] ?: 0, if (showRatings && selected) focusedRatings else emptyList(), ::activate) { focus.focus(index, it) }
HomeRowKind.Section -> HomeSection(shell.person, snapshot.savedAt) HomeRowKind.Section -> HomeSection(shell.person, snapshot.savedAt)
HomeRowKind.State -> HomeRecovery(item, selected, { activate(0) }) { focus.focus(index, 0) } HomeRowKind.State -> HomeRecovery(item, selected, { activate(0) }) { focus.focus(index, 0) }
HomeRowKind.Note -> Row(Modifier.fillMaxWidth().height(L.row_note).padding(start = L.safeSide, top = H.noteTop), horizontalArrangement = Arrangement.spacedBy(H.noteGap)) { HomeRowKind.Note -> Row(Modifier.fillMaxWidth().height(L.row_note).padding(start = L.safeSide, top = H.noteTop), horizontalArrangement = Arrangement.spacedBy(H.noteGap)) {
@ -97,7 +94,7 @@ fun HomeScreen(
Text(item.text, color = if (item.calm) C.muted else C.warn, style = WolfskinTokens.Type.body) Text(item.text, color = if (item.calm) C.muted else C.warn, style = WolfskinTokens.Type.body)
} }
HomeRowKind.Explore -> Box(Modifier.fillMaxWidth().height(L.row_endcta).padding(start = L.safeSide, top = H.endTop)) { HomeRowKind.Explore -> Box(Modifier.fillMaxWidth().height(L.row_endcta).padding(start = L.safeSide, top = H.endTop)) {
PickerButton("Explore more in Discover", selected, true, "seerr", onFocus = { focus.focus(index, 0) }, lift = true) { activate(0) } PickerButton("Explore more in Discover", selected, true, "seerr", onFocus = { focus.focus(index, 0) }) { activate(0) }
} }
} }
} }

View file

@ -14,7 +14,6 @@ import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.SideEffect
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
@ -32,12 +31,12 @@ import org.justinvisser.wolfskin.design.WolfskinTokens.Type as T
/** Every lazy row and card uses a stable source identity and an explicit content type. */ /** Every lazy row and card uses a stable source identity and an explicit content type. */
@Composable @Composable
fun HomeShelf(row: HomeRow, memory: HomeViewMemory, focused: Boolean, column: Int, ratings: List<HomeRating>, onCard: (Int) -> Unit, onHover: (Int) -> Unit) { fun HomeShelf(row: HomeRow, focused: Boolean, column: Int, ratings: List<HomeRating>, onCard: (Int) -> Unit, onHover: (Int) -> Unit) {
val width = row.cardWidth val width = row.cardWidth
val height = row.cardHeight val height = row.cardHeight
val caption = if (row.cardKind == HomeCardKind.Poster) K.posterCaptionBand else WolfskinTokens.Shell.zero val caption = if (row.cardKind == HomeCardKind.Poster) K.posterCaptionBand else WolfskinTokens.Shell.zero
val first = row.adjustedFirst(column, memory.firstCards[row.id] ?: 0) val slots = if (row.cardKind == HomeCardKind.Poster) 6 else if (row.cardKind == HomeCardKind.Tile) 4 else 3
SideEffect { if (memory.firstCards[row.id] != first) memory.firstCards[row.id] = first } val first = (column - (slots - 2)).coerceIn(0, (row.count - slots).coerceAtLeast(0))
val track = rememberLazyListState(first) val track = rememberLazyListState(first)
val density = LocalDensity.current val density = LocalDensity.current
LaunchedEffect(first) { LaunchedEffect(first) {
@ -50,7 +49,7 @@ fun HomeShelf(row: HomeRow, memory: HomeViewMemory, focused: Boolean, column: In
LazyRow(state = track, modifier = Modifier.fillMaxWidth().offset(y = L.rowHeadingBand - L.rowGapAfter).height(height + L.rowGapAfter * 2), contentPadding = PaddingValues(horizontal = L.safeSide, vertical = L.rowGapAfter), horizontalArrangement = Arrangement.spacedBy(K.posterGap), userScrollEnabled = !row.skeleton) { LazyRow(state = track, modifier = Modifier.fillMaxWidth().offset(y = L.rowHeadingBand - L.rowGapAfter).height(height + L.rowGapAfter * 2), contentPadding = PaddingValues(horizontal = L.safeSide, vertical = L.rowGapAfter), horizontalArrangement = Arrangement.spacedBy(K.posterGap), userScrollEnabled = !row.skeleton) {
itemsIndexed(cards, key = { index, title -> title?.id ?: "${row.id}:tail:$index" }, contentType = { _, title -> if (row.skeleton) "skeleton" else if (title == null) "seeAll" else row.cardKind.name.lowercase() }) { index, title -> itemsIndexed(cards, key = { index, title -> title?.id ?: "${row.id}:tail:$index" }, contentType = { _, title -> if (row.skeleton) "skeleton" else if (title == null) "seeAll" else row.cardKind.name.lowercase() }) { index, title ->
val selected = focused && column == index val selected = focused && column == index
Box(Modifier.size(width, height).zIndex(if (selected) 2f else 0f).focusScale(selected, WolfskinTokens.Focus.scaleCard).focusLift(selected, WolfskinTokens.Radius.card, H.cardLiftOffset, H.cardLiftBlur, H.cardLiftOpacity).focusRing(selected, WolfskinTokens.Radius.card, WolfskinTokens.Focus.ringCardGap, WolfskinTokens.Focus.ringCardRing, C.accent).testTag("home-card:${row.id}:$index").then(if (!row.skeleton) Modifier.focusOnPointerEnter { onHover(index) }.clickable { onCard(index) } else Modifier)) { Box(Modifier.size(width, height).zIndex(if (selected) 2f else 0f).focusScale(selected, WolfskinTokens.Focus.scaleCard).focusRing(selected, WolfskinTokens.Radius.card, WolfskinTokens.Focus.ringCardGap, WolfskinTokens.Focus.ringCardRing, C.accent).testTag("home-card:${row.id}:$index").then(if (!row.skeleton) Modifier.focusOnPointerEnter { onHover(index) }.clickable { onCard(index) } else Modifier)) {
Box(Modifier.fillMaxSize().clip(RoundedCornerShape(WolfskinTokens.Radius.card))) { Box(Modifier.fillMaxSize().clip(RoundedCornerShape(WolfskinTokens.Radius.card))) {
when { when {
row.skeleton -> Box(Modifier.fillMaxSize().background(C.surface)) row.skeleton -> Box(Modifier.fillMaxSize().background(C.surface))
@ -96,7 +95,7 @@ private fun TileOverlay(title: HomeTitle) {
@Composable @Composable
private fun SeeAll(total: Int) { private fun SeeAll(total: Int) {
Column(Modifier.fillMaxSize().background(C.surface).border(H.hairline, C.border, RoundedCornerShape(WolfskinTokens.Radius.card)), verticalArrangement = Arrangement.spacedBy(H.seeAllGap, Alignment.CenterVertically), horizontalAlignment = Alignment.CenterHorizontally) { Column(Modifier.fillMaxSize().background(C.surface).border(WolfskinTokens.Focus.ringCardRing, C.border, RoundedCornerShape(WolfskinTokens.Radius.card)), verticalArrangement = Arrangement.spacedBy(H.seeAllGap, Alignment.CenterVertically), horizontalAlignment = Alignment.CenterHorizontally) {
Glyph("interface", C.accent, Modifier.size(H.seeAllIcon)) Glyph("interface", C.accent, Modifier.size(H.seeAllIcon))
Text("See all", color = C.fg, style = T.listTitle) Text("See all", color = C.fg, style = T.listTitle)
Text("$total", color = C.muted, style = T.label) Text("$total", color = C.muted, style = T.label)

View file

@ -2,13 +2,11 @@ package org.justinvisser.wolfskin.design
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateMapOf
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
/** Focus-follow runs on a new row, never on re-entry to an already-positioned saved viewport. */ /** Focus-follow runs on a new row, never on re-entry to an already-positioned saved viewport. */
class HomeViewMemory { class HomeViewMemory {
val ratings = HomeRatingCache() val ratings = HomeRatingCache()
val firstCards = mutableStateMapOf<String, Int>()
var focusedKey: String? = null var focusedKey: String? = null
var positionedKey: String? = null var positionedKey: String? = null
var resetScroll by mutableIntStateOf(0) var resetScroll by mutableIntStateOf(0)

View file

@ -33,7 +33,6 @@ import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.Dp
import androidx.tv.material3.Text import androidx.tv.material3.Text
import org.justinvisser.wolfskin.design.WolfskinTokens.Color as C 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.Layout as L
import org.justinvisser.wolfskin.design.WolfskinTokens.Shell as S import org.justinvisser.wolfskin.design.WolfskinTokens.Shell as S
@ -101,10 +100,10 @@ fun NavBar(state: ShellState, scrolled: Boolean, modifier: Modifier = Modifier)
} }
} }
state.view.savedAt?.let { time -> state.view.savedAt?.let { time ->
Row(Modifier.align(Alignment.TopEnd).offset(-L.safeSide - L.navAvatar - L.navItemGap, S.navItemTop + (L.navItemHeight - H.savedHeight) / 2) Row(Modifier.align(Alignment.TopEnd).offset(-L.safeSide - L.navAvatar - L.buttonGap, S.navItemTop + (L.navItemHeight - S.settingsPillHeight) / 2)
.height(H.savedHeight).border(H.hairline, C.warn.copy(alpha = .5f), RoundedCornerShape(H.savedRadius)) .height(S.settingsPillHeight).border(WolfskinTokens.Focus.ringCardRing / 2, C.warn.copy(alpha = .45f), RoundedCornerShape(WolfskinTokens.Radius.pill))
.padding(horizontal = H.savedPadding), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(H.savedGap)) { .padding(horizontal = S.settingsPillPadding), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(S.settingsPillGap)) {
Glyph("wifi", C.warn, Modifier.size(H.savedIcon)) Glyph("wifi", C.warn, Modifier.size(S.settingsPillHeight - S.settingsPillGap))
Text("Saved $time", color = C.warn, style = WolfskinTokens.Type.caption) Text("Saved $time", color = C.warn, style = WolfskinTokens.Type.caption)
} }
} }

View file

@ -24,10 +24,8 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawWithCache import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.geometry.CornerRadius import androidx.compose.ui.geometry.CornerRadius
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.PathEffect import androidx.compose.ui.graphics.PathEffect
import androidx.compose.ui.graphics.drawscope.Stroke import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalDensity
@ -110,19 +108,10 @@ fun SettingsRow(group: SettingsGroup, focused: Boolean, openRequests: Int?, modi
@Composable @Composable
private fun SettingsPill(label: String, focused: Boolean) { private fun SettingsPill(label: String, focused: Boolean) {
val foreground = if (focused) C.onFocus else C.dim val foreground = if (focused) C.onFocus else C.dim
val border = if (focused) S.settingsPillFocusedBorder else S.settingsPillBorder Box(Modifier.height(S.settingsPillHeight).drawBehind {
Box(Modifier.height(S.settingsPillHeight).drawWithCache { val stroke = WolfskinTokens.Focus.ringButtonRing.toPx()
val stroke = S.settingsPillStroke.toPx() drawRoundRect(foreground.copy(alpha = .4f), cornerRadius = CornerRadius(WolfskinTokens.Radius.pill.toPx()), style = Stroke(stroke, pathEffect = PathEffect.dashPathEffect(floatArrayOf(stroke * 2, stroke * 2))))
val inset = Offset(stroke / 2, stroke / 2) }.padding(horizontal = S.settingsPillPadding), contentAlignment = Alignment.Center) {
val bounds = Size(size.width - stroke, size.height - stroke)
val outline = Stroke(stroke, pathEffect = PathEffect.dashPathEffect(
floatArrayOf(stroke * S.settingsPillDashRatio, stroke * S.settingsPillDashGapRatio),
))
val radius = CornerRadius(WolfskinTokens.Radius.pill.toPx())
onDrawBehind {
drawRoundRect(border, topLeft = inset, size = bounds, cornerRadius = radius, style = outline)
}
}.padding(horizontal = S.settingsPillPadding + S.settingsPillStroke), contentAlignment = Alignment.Center) {
Text(label, color = foreground, style = WolfskinTokens.Type.caption.copy(fontWeight = WolfskinTokens.Type.label.fontWeight)) Text(label, color = foreground, style = WolfskinTokens.Type.caption.copy(fontWeight = WolfskinTokens.Type.label.fontWeight))
} }
} }

View file

@ -102,10 +102,9 @@ private fun RecoveryPicker(state: ShellState) {
/** Recovery actions remain focusable while profiles cannot be read. */ /** Recovery actions remain focusable while profiles cannot be read. */
@Composable @Composable
fun PickerButton(label: String, focused: Boolean, primary: Boolean, icon: String? = null, onFocus: (() -> Unit)? = null, lift: Boolean = false, onClick: () -> Unit) { fun PickerButton(label: String, focused: Boolean, primary: Boolean, icon: String? = null, onFocus: (() -> Unit)? = null, onClick: () -> Unit) {
val background = if (primary) C.fg else if (focused) C.buttonSecondaryFocused else C.buttonSecondary val background = if (primary) C.fg else if (focused) C.buttonSecondaryFocused else C.buttonSecondary
Box(Modifier.height(L.buttonHeight).focusScale(focused, WolfskinTokens.Focus.scaleControl) Box(Modifier.height(L.buttonHeight).focusScale(focused, WolfskinTokens.Focus.scaleControl)
.then(if (lift) Modifier.focusLift(focused, WolfskinTokens.Radius.button, WolfskinTokens.Home.buttonLiftOffset, WolfskinTokens.Home.buttonLiftBlur, WolfskinTokens.Home.buttonLiftOpacity) else Modifier)
.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)
.then(if (onFocus != null) Modifier.focusOnPointerEnter(onFocus) else Modifier) .then(if (onFocus != null) Modifier.focusOnPointerEnter(onFocus) else Modifier)
.background(background, RoundedCornerShape(WolfskinTokens.Radius.button)).clickable(onClick = onClick) .background(background, RoundedCornerShape(WolfskinTokens.Radius.button)).clickable(onClick = onClick)

View file

@ -266,11 +266,6 @@ object WolfskinTokens {
val settingsRowGap = 12.0.dp val settingsRowGap = 12.0.dp
val settingsIcon = 18.0.dp val settingsIcon = 18.0.dp
val settingsPillHeight = 20.0.dp val settingsPillHeight = 20.0.dp
val settingsPillStroke = 1.0.dp
const val settingsPillDashRatio = 3.0f
const val settingsPillDashGapRatio = 2.0f
val settingsPillBorder = ComposeColor(0x38FFFFFF)
val settingsPillFocusedBorder = ComposeColor(0x660B0B10)
val settingsPillPadding = 8.0.dp val settingsPillPadding = 8.0.dp
val settingsPillGap = 6.0.dp val settingsPillGap = 6.0.dp
val settingsChevron = 15.0.dp val settingsChevron = 15.0.dp
@ -339,18 +334,5 @@ object WolfskinTokens {
val heroWashInset = 40.0.dp val heroWashInset = 40.0.dp
val noteTop = 7.0.dp val noteTop = 7.0.dp
val noteGap = 8.0.dp val noteGap = 8.0.dp
val hairline = 1.0.dp
val savedHeight = 26.0.dp
val savedPadding = 10.0.dp
val savedGap = 5.0.dp
val savedIcon = 14.0.dp
val savedRadius = 14.0.dp
val cardLiftOffset = 9.0.dp
val cardLiftBlur = 25.0.dp
const val cardLiftOpacity = 0.65f
val buttonLiftOffset = 6.0.dp
val buttonLiftBlur = 20.0.dp
const val buttonLiftOpacity = 0.5f
const val factSeparatorOpacity = 0.4f
} }
} }

View file

@ -105,53 +105,6 @@ class HomeKeysTest {
assertNull(action) assertNull(action)
} }
@Test fun downClampsToShortRowsAndNeverMovesPastTheFinalSeeAll() {
val shell = ShellState(fixturePeople(), "justin").apply { pickerVisible = false; navFocused = false }
val cards = HomeFixture(rule.activity).snapshot("justin").rows.first { it.id == "lib-movies" }.cards
val long = HomeRow(id = "long", kind = HomeRowKind.Shelf, cards = cards)
val short = long.copy(id = "short", cards = cards.take(2), total = 2)
val tail = short.copy(id = "tail", total = 20)
shell.view.cards[long.id] = cards.lastIndex
var action: HomeAction? = null
rule.setContent { WolfskinTheme {
ShellSurface(shell) { modifier -> HomeScreen(shell, HomeSnapshot(listOf(long, short, tail)), modifier) { action = it } }
} }
press("DOWN")
assertEquals(short.id, listOf(long, short, tail)[shell.view.row].id)
assertEquals(1, shell.view.cards[short.id])
rule.onNodeWithTag("home-card:short:1").assertIsDisplayed()
press("RIGHT")
assertEquals(1, shell.view.cards[short.id])
press("DOWN")
press("RIGHT")
press("DOWN")
assertEquals(2, shell.view.row)
assertEquals(2, shell.view.cards[tail.id])
rule.onNodeWithTag("home-card:tail:2").assertIsDisplayed()
press("OK")
assertEquals(tail.id, (action as HomeAction.SeeAll).row.id)
}
@Test fun mixedWidthRowsUseNearestCenterAndKeepTheTrackWhenMovingBackInsideItsMiddle() {
val shell = ShellState(fixturePeople(), "justin").apply { pickerVisible = false; navFocused = false }
val cards = HomeFixture(rule.activity).snapshot("justin").rows.first { it.id == "lib-movies" }.cards
val posters = HomeRow(id = "posters", kind = HomeRowKind.Shelf, cards = cards)
val tiles = posters.copy(id = "tiles", cardKind = HomeCardKind.Tile)
shell.view.cards[posters.id] = 3
rule.setContent { WolfskinTheme {
ShellSurface(shell) { modifier -> HomeScreen(shell, HomeSnapshot(listOf(posters, tiles)), modifier) {} }
} }
press("DOWN")
assertEquals("The third tile is nearest the fourth poster's horizontal center", 2, shell.view.cards[tiles.id])
repeat(4) { press("RIGHT") }
val left = rule.onNodeWithTag("home-card:tiles:4").fetchSemanticsNode().boundsInRoot.left
press("LEFT")
assertEquals("Moving back within the visible middle must not shift the track", left,
rule.onNodeWithTag("home-card:tiles:4").fetchSemanticsNode().boundsInRoot.left, .1f)
press("UP")
assertEquals("A visited row retains its column", 3, shell.view.cards[posters.id])
}
private fun press(name: String) { private fun press(name: String) {
val key = when (name) { val key = when (name) {
"UP" -> Key.DirectionUp; "DOWN" -> Key.DirectionDown "UP" -> Key.DirectionUp; "DOWN" -> Key.DirectionDown

View file

@ -163,9 +163,6 @@
--shell-settingsRowGap: 24.0px; --shell-settingsRowGap: 24.0px;
--shell-settingsIcon: 36.0px; --shell-settingsIcon: 36.0px;
--shell-settingsPillHeight: 40.0px; --shell-settingsPillHeight: 40.0px;
--shell-settingsPillStroke: 2.0px;
--shell-settingsPillBorder: #ffffff38;
--shell-settingsPillFocusedBorder: #0b0b1066;
--shell-settingsPillPadding: 16.0px; --shell-settingsPillPadding: 16.0px;
--shell-settingsPillGap: 12.0px; --shell-settingsPillGap: 12.0px;
--shell-settingsChevron: 30.0px; --shell-settingsChevron: 30.0px;
@ -229,14 +226,4 @@
--home-heroWashInset: 80.0px; --home-heroWashInset: 80.0px;
--home-noteTop: 14.0px; --home-noteTop: 14.0px;
--home-noteGap: 16.0px; --home-noteGap: 16.0px;
--home-hairline: 2.0px;
--home-savedHeight: 52.0px;
--home-savedPadding: 20.0px;
--home-savedGap: 10.0px;
--home-savedIcon: 28.0px;
--home-savedRadius: 28.0px;
--home-cardLiftOffset: 18.0px;
--home-cardLiftBlur: 50.0px;
--home-buttonLiftOffset: 12.0px;
--home-buttonLiftBlur: 40.0px;
} }

View file

@ -1,6 +1,6 @@
28f6f7bb878b8388ce04293cd2819bc1cb684da34925c9ddcded78fcddf6d1dd generate_tokens.py 28f6f7bb878b8388ce04293cd2819bc1cb684da34925c9ddcded78fcddf6d1dd generate_tokens.py
73398fc0d3d6b780170fa6d7ef9246f788c800c499981fde092fe790330c8ad1 home-tokens.json 77a489b428b038a874624c9d7d881e69b9f20ca478961743061b76ce31b7bd31 home-tokens.json
408b7c3f4db2028deda97ac0570a57a4da4877917fd0980b2bfa5afe8f8fa790 shell-tokens.json 6d1d19656b40e6a442cb548bb238bb12244adb6b3b717ed9e564af61119559c8 shell-tokens.json
3ff67d4867c2735ed2ad7ffc66bbfb04dad5f0000072daa2f5e98ed1b7059dde src/main/java/org/justinvisser/wolfskin/design/WolfskinTokens.kt b051a021d1fc9bb7d03287891f4a7eb62e4eaee4058d54e13bbff108924c6fa0 src/main/java/org/justinvisser/wolfskin/design/WolfskinTokens.kt
7ded358afa758c8b0dd191d650d06dc0bcfc8dcc2baebba2c07a3e70c435ff5a tokens.css 853f22d6f2572f55a2c8a0cac7e5ba11647d3d70e1b2c0be70d23cf06c7f2e58 tokens.css
5b939f1f3eb90b428008fda75720f6e3a4e844c486668931db3d52bffb987b53 tokens.json 5b939f1f3eb90b428008fda75720f6e3a4e844c486668931db3d52bffb987b53 tokens.json