diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/TitleCaptureTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/TitleCaptureTest.kt index 90d6fcc1..55f9d747 100644 --- a/app/src/test/java/org/justinvisser/wolfskin/tv/TitleCaptureTest.kt +++ b/app/src/test/java/org/justinvisser/wolfskin/tv/TitleCaptureTest.kt @@ -87,7 +87,7 @@ class TitleCaptureTest { data.cast.forEach { person -> person.portrait?.let { add(Triple(it, 200, 200)) } } data.related.forEach { item -> item.poster?.let { add(Triple(it, 256, 384)) } } data.episodes.forEach { item -> - (if (item.isPosterBackdrop) item.poster else item.backdrop)?.let { add(Triple(it, 416, 234)) } + (if (item.isPosterBackdrop) item.poster else item.backdrop)?.let { add(Triple(it, (WolfskinTokens.Card.tileW.value * 2).toInt(), (WolfskinTokens.Card.tileH.value * 2).toInt())) } } } return requests.distinctBy { it.first }.associate { (path, width, height) -> @@ -215,4 +215,5 @@ class TitleCaptureTest { @Test fun st107_seriesEpisodeRatings() = capture(107, "Andor") @Test fun st108_resumingEpisodeRatings() = capture(108, "episode-sex-and-the-country-2001-s4e9", "mindel") @Test fun st109_noRatings() = capture(109, "The Amazing Spider-Man") + @Test fun spacingCastFirstFocused() = capture(114) { state, _ -> state.focus("cast", 0) } } diff --git a/docker/README.md b/docker/README.md index 1996dd3d..e156f2cf 100644 --- a/docker/README.md +++ b/docker/README.md @@ -310,9 +310,10 @@ and device deployment remain separate. ## Libraries and See all Movies, Shows and Anime use the shell's retained view and a continuous six-column -lazy grid. Up from its first row reaches Sort/Filter, then the nav; Back preserves -the card and scroll. Sort cycles A–Z, Recently added and Rating. The letter rail -exists only at 200 titles with A–Z selected. Card OK activates on key-up through +lazy grid, reduced to five columns while the letter rail is visible so scaled +posters cannot overlap it. Up from the first row reaches Sort/Filter, then the nav; +Back preserves the card and scroll. Sort cycles A–Z, Recently added and Rating. +The letter rail exists only at 200 titles with A–Z selected. Card OK activates on key-up through the same shell hold guard, without a long-press menu. Down clamps a short final title row before entering the separate Try again / Show more row; an empty slot cannot activate recovery. An unavailable library hides @@ -347,6 +348,29 @@ music keep their upstream destinations. failure/retry, stale copies, and person/server isolation. Library captures are written to `build/wolfskin-captures/library/`; Discover's independent captures use `discover/`. +### Roomy card spacing + +The generated design tokens use 40 px card gaps (44 px for cast), 84 px shelf +heading bands, 68 px shelf bottom gaps and 524 px grid rows at 1920×1080. +Posters remain 256×384 px; tiles are 400×225 px and wide cards 544×306 px, +preserving six/four/three shelf slots. Library and See all toolbars occupy 136 px: +chips stay at y 252–320 and the first poster row starts at y 372 (52 px clear). +Discover and season-chip rows retain their 104 px height. + +Cards and cast circles draw a single 4 px accent stroke centred on the art edge, +above the content, with no background gap and the existing card lift. The stroke +and art scale together at 1.08; buttons, avatars, chips and picker focus are +unchanged. Title shelves reserve vertical drawing room for the scaled ring. +Before scaling the stroke, ring-to-neighbour clearances round to 28/22/16 px +for poster/tile/wide cards; including stroke scale they are 27.6/21.8/16.1 px. + +`LibraryCaptureTest.spacingShowsFirstFocused`, +`HomeCaptureTest.spacingNextUpFirstFocused` and +`TitleCaptureTest.spacingCastFirstFocused` render the amended reference states +at native resolution. Their files are `library/spacing-shows.png`, +`home/spacing-next-up.png` and `st-114.png` under `build/wolfskin-captures/`. +Platform Roboto metrics and fixture ordering are not browser pixel parity. + ## Title pages and people `TitleSurface` owns the film/series/episode header, season chips, episode tiles, diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/CatalogueRecoveryCard.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/CatalogueRecoveryCard.kt index db0a22f6..b2bb7ca4 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/CatalogueRecoveryCard.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/CatalogueRecoveryCard.kt @@ -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, FocusLift.Card) + .focusRing(focused, WolfskinTokens.Radius.card, WolfskinTokens.Focus.ringCardGap, WolfskinTokens.Focus.ringCardRing, C.accent, FocusLift.Card, straddle = true) .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)) diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/DiscoverShelf.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/DiscoverShelf.kt index 4492a69c..7af7ec22 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/DiscoverShelf.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/DiscoverShelf.kt @@ -70,7 +70,7 @@ fun DiscoverShelf(state: DiscoverState, lane: DiscoverLane, row: Int) { 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, FocusLift.Card) + .focusRing(active && index == column, WolfskinTokens.Radius.card, WolfskinTokens.Focus.ringCardGap, WolfskinTokens.Focus.ringCardRing, C.accent, FocusLift.Card, straddle = true) .background(Brush.linearGradient(listOf(Color.hsl(service.hue, .30f, .26f), Color.hsl(service.hue, .30f, .12f))), 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) { diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/Focus.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/Focus.kt index 147409ea..a4a69580 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/Focus.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/Focus.kt @@ -16,11 +16,13 @@ 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.draw.drawWithCache import androidx.compose.ui.geometry.CornerRadius import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Size import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.graphics.drawscope.Stroke import androidx.compose.ui.unit.Dp /** Only transforms animate: focus never changes measured geometry. */ @@ -37,9 +39,9 @@ fun Modifier.focusScale(focused: Boolean, scale: Float): Modifier { /** 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. */ +/** Card rings straddle the art edge above content; control rings retain their outside gap. */ @Composable -fun Modifier.focusRing(focused: Boolean, radius: Dp, gap: Dp, width: Dp, color: Color, lift: FocusLift = FocusLift.None): Modifier { +fun Modifier.focusRing(focused: Boolean, radius: Dp, gap: Dp, width: Dp, color: Color, lift: FocusLift = FocusLift.None, straddle: Boolean = false): Modifier { val ringAlpha by animateFloatAsState(if (focused) 1f else 0f, tween(WolfskinTokens.Motion.fast, easing = CubicBezierEasing(.25f, .1f, .25f, 1f)), label = "focus-ring") val shadow = if (lift == FocusLift.None) this else { @@ -54,6 +56,17 @@ fun Modifier.focusRing(focused: Boolean, radius: Dp, gap: Dp, width: Dp, color: alpha = ringAlpha * if (lift == FocusLift.Card) WolfskinTokens.Lift.cardOpacity else WolfskinTokens.Lift.buttonOpacity } } + if (straddle) return shadow.drawWithCache { + val stroke = Stroke(width.toPx()) + val corner = CornerRadius(radius.toPx()) + onDrawWithContent { + drawContent() + if (ringAlpha > 0f) { + drawRoundRect(color.copy(alpha = color.alpha * ringAlpha), + cornerRadius = corner, style = stroke) + } + } + } return shadow.drawBehind { if (ringAlpha > 0f) { val outer = (gap + width).toPx() diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/LibraryGridState.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/LibraryGridState.kt index bd36f97f..2b6bcc1a 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/LibraryGridState.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/LibraryGridState.kt @@ -91,7 +91,7 @@ class LibraryGridState( var inRail by mutableStateOf(false) var railLetter by mutableStateOf('#') private var railReturn = 0 - val columns = WolfskinTokens.Behaviour.gridColumns.toInt() + val columns: Int get() = WolfskinTokens.Behaviour.gridColumns.toInt() - if (railVisible) 1 else 0 val mixed: Boolean get() = catalogue && source.any { it.type == "movie" } && source.any { it.type == "tv" } val hasToolbar: Boolean get() = !catalogue || mixed val items: List by derivedStateOf { diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/LibraryScreen.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/LibraryScreen.kt index 07082c4f..93fb47a0 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/LibraryScreen.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/LibraryScreen.kt @@ -94,7 +94,7 @@ fun LibraryScreen( if (grid.toolbar || row == 0 && active) { animateLibraryScroll(view, headerCount, 0f, density) } else if (row > 0) { - val target = with(density) { (L.row_lhead + (if (headerCount == 2) L.row_chips else WolfskinTokens.Shell.zero) + rowHeight * row - L.pinnedLine).toPx() } + val target = with(density) { (L.row_lhead + (if (headerCount == 2) L.row_chipsGrid else WolfskinTokens.Shell.zero) + rowHeight * row - L.pinnedLine).toPx() } animateLibraryScroll(view, headerCount, target, density) } } @@ -186,7 +186,7 @@ fun LibraryHeader(person: ShellPerson, title: String, subtitle: String = "", sav @Composable private fun LibraryToolbar(grid: LibraryGridState, active: Boolean) { - Row(Modifier.fillMaxWidth().height(L.row_chips).padding(start = L.safeSide, top = G.chipTop), horizontalArrangement = Arrangement.spacedBy(G.chipGap)) { + Row(Modifier.fillMaxWidth().height(L.row_chipsGrid).padding(start = L.safeSide, top = G.chipTop), horizontalArrangement = Arrangement.spacedBy(G.chipGap)) { val labels = if (grid.catalogue) listOf("All", "Movies", "Series") else listOf("Sort ${listOf("A–Z", "Recently added", "Rating")[grid.sort]}", if (grid.unwatched) "Unwatched" else "All") labels.forEachIndexed { index, label -> val focused = active && grid.toolbar && grid.tool == index @@ -266,7 +266,7 @@ private suspend fun animateLibraryScroll(view: RememberedView, headerCount: Int, val current = with(density) { (if (first == 0) WolfskinTokens.Shell.zero else L.row_lhead + if (first < headerCount) WolfskinTokens.Shell.zero else - (if (headerCount == 2) L.row_chips else WolfskinTokens.Shell.zero) + rowHeight * (first - headerCount)).toPx() + (if (headerCount == 2) L.row_chipsGrid else WolfskinTokens.Shell.zero) + rowHeight * (first - headerCount)).toPx() } + view.listState.firstVisibleItemScrollOffset animateFrameScroll(view.listState, target - current) } diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleCard.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleCard.kt index 81779ed3..fa51ac7c 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleCard.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleCard.kt @@ -17,7 +17,7 @@ fun TitleCard(focused: Boolean, modifier: Modifier = Modifier, width: Dp = Wolfs height: Dp = WolfskinTokens.Card.posterH, content: @Composable BoxScope.() -> Unit) { val shape = RoundedCornerShape(WolfskinTokens.Radius.card) Box(modifier.size(width, height).focusScale(focused, WolfskinTokens.Focus.scaleCard) - .focusRing(focused, WolfskinTokens.Radius.card, WolfskinTokens.Focus.ringCardGap, WolfskinTokens.Focus.ringCardRing, WolfskinTokens.Color.accent, FocusLift.Card)) { + .focusRing(focused, WolfskinTokens.Radius.card, WolfskinTokens.Focus.ringCardGap, WolfskinTokens.Focus.ringCardRing, WolfskinTokens.Color.accent, FocusLift.Card, straddle = true)) { Box(Modifier.fillMaxSize().clip(shape).background(WolfskinTokens.Color.surface2), content = content) } } diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleShelf.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleShelf.kt index dcfaadc7..0e3e8315 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleShelf.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleShelf.kt @@ -51,9 +51,10 @@ fun TitleCast(state: TitleState, active: Boolean) { val focusOwner = LocalTitleFocusOwner.current val pitch = with(LocalDensity.current) { WolfskinTokens.Card.personPitch.toPx() } LaunchedEffect(state.column, active) { if (active) track.keepColumnInWindow(state.column, WolfskinTokens.Card.personSlots, pitch) } - Column(Modifier.height(D.castHeight)) { + Box(Modifier.height(D.castHeight)) { Text("Cast", color = C.fg, style = WolfskinTokens.Type.rowHeading, modifier = Modifier.padding(start = L.safeSide).height(L.rowHeadingBand)) - LazyRow(state = track, contentPadding = PaddingValues(horizontal = L.safeSide), horizontalArrangement = Arrangement.spacedBy(WolfskinTokens.Card.personGap)) { + LazyRow(state = track, modifier = Modifier.fillMaxWidth().offset(y = L.rowHeadingBand - L.rowGapAfter).height(WolfskinTokens.Card.personH + L.rowGapAfter * 2), + contentPadding = PaddingValues(horizontal = L.safeSide, vertical = L.rowGapAfter), horizontalArrangement = Arrangement.spacedBy(WolfskinTokens.Card.personGap)) { if (state.data.castStatus == TitleReadStatus.Loading) { items(WolfskinTokens.Card.personSlots, key = { "cast-skeleton-$it" }, contentType = { "skeleton" }) { Box(Modifier.size(L.castCircle).background(C.surface, CircleShape)) @@ -63,7 +64,7 @@ fun TitleCast(state: TitleState, active: Boolean) { Box(Modifier.size(WolfskinTokens.Card.personW, WolfskinTokens.Card.personH).zIndex(if (focused) 1f else 0f) .onGloballyPositioned { state.recordCenter("cast", index, it.boundsInRoot().center.x) } .titlePointerFocus { state.focus("cast", index) }.focusScale(focused, WolfskinTokens.Focus.scaleCard).clickable { focusOwner?.invoke(); state.click("cast", index) }.testTag("title-cast-${person.id}")) { - PersonPortrait(person, Modifier.size(L.castCircle).focusRing(focused, L.castCircle / 2, WolfskinTokens.Focus.ringCardGap, WolfskinTokens.Focus.ringCardRing, C.accent).clip(CircleShape)) + PersonPortrait(person, Modifier.size(L.castCircle).focusRing(focused, L.castCircle / 2, WolfskinTokens.Focus.ringCardGap, WolfskinTokens.Focus.ringCardRing, C.accent, FocusLift.Card, straddle = true).clip(CircleShape)) Column(Modifier.offset(x = -D.castNameSide, y = D.castNameTop).wrapContentWidth(Alignment.Start, unbounded = true).width(L.castCircle + D.castNameSide * 2), horizontalAlignment = Alignment.CenterHorizontally) { Text(person.name, color = C.fg, style = TitleTextStyles.castName, modifier = Modifier.exactLineBox(TitleTextStyles.castName), maxLines = 1, overflow = TextOverflow.Ellipsis, textAlign = TextAlign.Center) Text(person.character, color = C.muted, style = TitleTextStyles.caption, modifier = Modifier.exactLineBox(TitleTextStyles.caption), maxLines = 1, overflow = TextOverflow.Ellipsis, textAlign = TextAlign.Center) @@ -85,9 +86,11 @@ fun TitleMediaShelf(state: TitleState, episodes: Boolean, active: Boolean) { val visible = if (episodes) WolfskinTokens.Card.tileSlots else WolfskinTokens.Card.posterSlots val pitch = with(LocalDensity.current) { (if (episodes) WolfskinTokens.Card.tilePitch else WolfskinTokens.Card.posterPitch).toPx() } LaunchedEffect(state.column, active) { if (active) track.keepColumnInWindow(state.column, visible, pitch) } - Column(Modifier.height(L.rowHeadingBand + height + L.rowGapAfter + if (episodes) WolfskinTokens.Shell.zero else WolfskinTokens.Card.posterCaptionBand)) { + Box(Modifier.height(L.rowHeadingBand + height + L.rowGapAfter + if (episodes) WolfskinTokens.Shell.zero else WolfskinTokens.Card.posterCaptionBand)) { Text(if (episodes) "Season ${state.season}" else "More like this", color = C.fg, style = WolfskinTokens.Type.rowHeading, modifier = Modifier.padding(start = L.safeSide).height(L.rowHeadingBand)) - LazyRow(state = track, contentPadding = PaddingValues(horizontal = L.safeSide), horizontalArrangement = Arrangement.spacedBy(WolfskinTokens.Card.posterGap)) { + LazyRow(state = track, modifier = Modifier.fillMaxWidth().offset(y = L.rowHeadingBand - L.rowGapAfter) + .height(height + (if (episodes) WolfskinTokens.Shell.zero else WolfskinTokens.Card.posterCaptionBand) + L.rowGapAfter * 2), + contentPadding = PaddingValues(horizontal = L.safeSide, vertical = L.rowGapAfter), horizontalArrangement = Arrangement.spacedBy(WolfskinTokens.Card.posterGap)) { if (episodes && state.data.episodesStatus == TitleReadStatus.Loading) { items(visible, key = { "episode-skeleton-$it" }, contentType = { "skeleton" }) { Box(Modifier.size(width, height).background(C.surface, RoundedCornerShape(WolfskinTokens.Radius.card))) @@ -112,7 +115,7 @@ fun TitlePoster(item: TitleItem, focused: Boolean, modifier: Modifier = Modifier LibraryPoster(item, focused, focused && ratingsVisible, catalogue = catalogue, modifier = Modifier.titlePointerFocus(onFocus).clickable { focusOwner?.invoke(); onClick() }, offlineArt = offlineArt) } else Box(Modifier.size(WolfskinTokens.Card.tileW, WolfskinTokens.Card.tileH).titlePointerFocus(onFocus).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, straddle = true) .clip(RoundedCornerShape(WolfskinTokens.Radius.card)).background(C.surface2).clickable { focusOwner?.invoke(); onClick() }) { TitleTileArtwork(item) Column(Modifier.align(Alignment.BottomStart).fillMaxWidth().background(Brush.verticalGradient(listOf(C.bg.copy(alpha = 0f), C.bg.copy(alpha = D.cardOverlayOpacity)))) diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/WolfskinTokens.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/WolfskinTokens.kt index 9cf06f03..58f5a3df 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/WolfskinTokens.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/WolfskinTokens.kt @@ -112,9 +112,9 @@ object WolfskinTokens { val navSolidAfter = 20.0.dp val pinnedLine = 84.0.dp val windowAhead = 350.0.dp - val rowHeadingBand = 30.0.dp - val rowGapAfter = 22.0.dp - val gridRowGap = 14.0.dp + val rowHeadingBand = 42.0.dp + val rowGapAfter = 34.0.dp + val gridRowGap = 22.0.dp val row_hero = 410.0.dp val row_phero = 370.0.dp val row_section = 75.0.dp @@ -146,7 +146,7 @@ object WolfskinTokens { val settingsRowWidth = 650.0.dp val settingsRowHeight = 52.0.dp val stateWidth = 550.0.dp - val captionOffset = 9.0.dp + val captionOffset = 19.0.dp val badge = 24.0.dp val badgeInset = 7.0.dp val progressBar = 4.0.dp @@ -159,36 +159,37 @@ object WolfskinTokens { val personPortrait = 120.0.dp val thumbWidth = 48.0.dp val thumbHeight = 72.0.dp + val row_chipsGrid = 68.0.dp } object Card { val posterW = 128.0.dp val posterH = 192.0.dp - val posterGap = 12.0.dp - val posterPitch = 140.0.dp - val posterCaptionBand = 38.0.dp + val posterGap = 20.0.dp + val posterPitch = 148.0.dp + val posterCaptionBand = 48.0.dp const val posterSlots = 6 - val tileW = 208.0.dp - val tileH = 117.0.dp - val tileGap = 12.0.dp + val tileW = 200.0.dp + val tileH = 112.5.dp + val tileGap = 20.0.dp val tilePitch = 220.0.dp val tileCaptionBand = 0.0.dp const val tileSlots = 4 - val wideW = 280.0.dp - val wideH = 157.5.dp - val wideGap = 12.0.dp + val wideW = 272.0.dp + val wideH = 153.0.dp + val wideGap = 20.0.dp val widePitch = 292.0.dp val wideCaptionBand = 0.0.dp const val wideSlots = 3 val entryW = 128.0.dp val entryH = 72.0.dp - val entryGap = 12.0.dp - val entryPitch = 140.0.dp + val entryGap = 20.0.dp + val entryPitch = 148.0.dp val entryCaptionBand = 0.0.dp const val entrySlots = 6 val personW = 100.0.dp - val personH = 145.0.dp - val personGap = 14.0.dp - val personPitch = 114.0.dp + val personH = 155.0.dp + val personGap = 22.0.dp + val personPitch = 122.0.dp val personCaptionBand = 0.0.dp const val personSlots = 7 } @@ -196,7 +197,7 @@ object WolfskinTokens { const val scaleCard = 1.08f const val scaleControl = 1.05f const val scaleRow = 1.02f - val ringCardGap = 2.0.dp + val ringCardGap = 0.0.dp val ringCardRing = 2.0.dp val ringButtonGap = 2.0.dp val ringButtonRing = 2.0.dp @@ -429,8 +430,8 @@ object WolfskinTokens { val progressBottom = 15.0.dp val progressGap = 9.0.dp val progressLift = -7.0.dp - val castHeight = 197.0.dp - val castNameTop = 108.0.dp + val castHeight = 231.0.dp + val castNameTop = 118.0.dp val castNameSide = 6.0.dp val personTop = 85.0.dp val personGap = 24.0.dp @@ -456,7 +457,7 @@ object WolfskinTokens { val infoRowGap = 3.0.dp val infoColumnGap = 10.0.dp val infoKey = TextStyle(fontSize = 12.0.sp, fontWeight = FontWeight.Bold, lineHeight = 15.600000000000001.sp, letterSpacing = 0.0.em) - val gridHeight = 244.0.dp + val gridHeight = 262.0.dp val minimumEndPadding = 40.0.dp val ratingGap = 10.0.dp val ratingMarkGap = 4.0.dp diff --git a/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/HomeCaptureTest.kt b/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/HomeCaptureTest.kt index 04a5e866..647bf533 100644 --- a/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/HomeCaptureTest.kt +++ b/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/HomeCaptureTest.kt @@ -115,5 +115,6 @@ class HomeCaptureTest { @Test fun st101_unratedEpisode() = capture("st-101", person = "mindel", row = "nu", column = 2) @Test fun st102_filmTile() = capture("st-102", person = "mindel", row = "new", column = 4) @Test fun st103_heroRatings() = capture("st-103") + @Test fun spacingNextUpFirstFocused() = capture("spacing-next-up", row = "nu") @Test fun xHomeDiscoverStart() = capture("x-home-discover-start", row = "pk-m") } diff --git a/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/LibraryCaptureTest.kt b/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/LibraryCaptureTest.kt index af8387f9..36fb48d3 100644 --- a/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/LibraryCaptureTest.kt +++ b/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/LibraryCaptureTest.kt @@ -82,6 +82,14 @@ class LibraryCaptureTest { @Test fun st112_cachedWhileDown() = capture("st-112", reachable = false, cached = true) @Test fun st113_uncachedWhileDown() = capture("st-113", reachable = false) @Test fun unsignedRatingsHidden() = capture("library-unsigned", ratings = false) + @Test fun spacingShowsFirstFocused() = capture("spacing-shows", "mindel", "shows") + @Test fun spacingLetterRailClearsFocusedFifthColumn() = capture("spacing-letter-rail") { grid -> + val original = grid.source + grid.source = List(200) { index -> + original[index % original.size].copy(id = "rail-$index", title = "${('A'.code + index / 10).toChar()} Title $index") + } + grid.index = 4 + } } fun libraryFixture(): LibraryFixture = LibraryFixture(LibraryCaptureTest::class.java.getResourceAsStream("/wolfskin-library.json")!!.bufferedReader().use { it.readText() }, "/library/") diff --git a/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/LibraryKeysTest.kt b/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/LibraryKeysTest.kt index c4130d39..5eae18b5 100644 --- a/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/LibraryKeysTest.kt +++ b/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/LibraryKeysTest.kt @@ -112,18 +112,25 @@ class LibraryKeysTest { val titles = (0 until 200).map { index -> LibraryTitle("$index", "${('A'.code + index / 10).toChar()}Title $index", "movie") } val grid = LibraryGridState("Movies", titles) assertTrue(grid.railVisible) - grid.index = 5 + grid.index = 4 grid.key(ShellKey.Right) assertTrue(grid.inRail) grid.key(ShellKey.Down) assertEquals('B', grid.railLetter) grid.key(ShellKey.Back) - assertEquals(5, grid.index) + assertEquals(4, grid.index) assertFalse(grid.inRail) + grid.key(ShellKey.Down) + assertEquals(9, grid.index) + grid.key(ShellKey.Up) + assertEquals(4, grid.index) grid.key(ShellKey.Right); grid.key(ShellKey.Down); grid.key(ShellKey.Ok) assertEquals('B', grid.focusedTitle!!.letter) grid.source = titles.take(199) assertFalse(grid.railVisible) + grid.index = 4 + grid.key(ShellKey.Right) + assertEquals(5, grid.index) grid.source = titles; grid.sort = 1 assertFalse(grid.railVisible) } diff --git a/wolfskin-design/src/test/resources/library-screen.json b/wolfskin-design/src/test/resources/library-screen.json index 0d1c8c2a..07f33c34 100644 --- a/wolfskin-design/src/test/resources/library-screen.json +++ b/wolfskin-design/src/test/resources/library-screen.json @@ -1,6 +1,6 @@ { "id": "library", - "title": "Libraries: Movies, Shows, Anime (one continuous 6-column grid)", + "title": "Libraries: Movies, Shows, Anime (continuous grid; five columns with letter rail)", "specSection": "6.4", "source": "index.html:1403-1426, 1563-1611", "layout": [ @@ -22,7 +22,7 @@ "token": null, "title": "", "yPx": 236, - "heightPx": 104, + "heightPx": 136, "cards": null, "focusable": true, "endsInSeeAll": false, @@ -33,8 +33,8 @@ "type": "grid", "token": "poster", "title": "", - "yPx": 340, - "heightPx": 488, + "yPx": 372, + "heightPx": 524, "cards": 6, "focusable": true, "endsInSeeAll": false, @@ -45,8 +45,8 @@ "type": "grid", "token": "poster", "title": "", - "yPx": 828, - "heightPx": 488, + "yPx": 896, + "heightPx": 524, "cards": 6, "focusable": true, "endsInSeeAll": false, @@ -57,8 +57,8 @@ "type": "grid", "token": "poster", "title": "", - "yPx": 1316, - "heightPx": 488, + "yPx": 1420, + "heightPx": 524, "cards": 6, "focusable": true, "endsInSeeAll": false, @@ -69,8 +69,8 @@ "type": "grid", "token": "poster", "title": "", - "yPx": 1804, - "heightPx": 488, + "yPx": 1944, + "heightPx": 524, "cards": 6, "focusable": true, "endsInSeeAll": false, @@ -81,8 +81,8 @@ "type": "grid", "token": "poster", "title": "", - "yPx": 2292, - "heightPx": 488, + "yPx": 2468, + "heightPx": 524, "cards": 6, "focusable": true, "endsInSeeAll": false, @@ -93,8 +93,8 @@ "type": "grid", "token": "poster", "title": "", - "yPx": 2780, - "heightPx": 488, + "yPx": 2992, + "heightPx": 524, "cards": 6, "focusable": true, "endsInSeeAll": false, @@ -105,8 +105,8 @@ "type": "grid", "token": "poster", "title": "", - "yPx": 3268, - "heightPx": 488, + "yPx": 3516, + "heightPx": 524, "cards": 6, "focusable": true, "endsInSeeAll": false, @@ -117,8 +117,8 @@ "type": "grid", "token": "poster", "title": "", - "yPx": 3756, - "heightPx": 488, + "yPx": 4040, + "heightPx": 524, "cards": 6, "focusable": true, "endsInSeeAll": false, @@ -129,8 +129,8 @@ "type": "grid", "token": "poster", "title": "", - "yPx": 4244, - "heightPx": 488, + "yPx": 4564, + "heightPx": 524, "cards": 6, "focusable": true, "endsInSeeAll": false, @@ -141,8 +141,8 @@ "type": "grid", "token": "poster", "title": "", - "yPx": 4732, - "heightPx": 488, + "yPx": 5088, + "heightPx": 524, "cards": 6, "focusable": true, "endsInSeeAll": false, @@ -153,8 +153,8 @@ "type": "grid", "token": "poster", "title": "", - "yPx": 5220, - "heightPx": 488, + "yPx": 5612, + "heightPx": 524, "cards": 6, "focusable": true, "endsInSeeAll": false, @@ -165,8 +165,8 @@ "type": "grid", "token": "poster", "title": "", - "yPx": 5708, - "heightPx": 488, + "yPx": 6136, + "heightPx": 524, "cards": 6, "focusable": true, "endsInSeeAll": false, @@ -177,8 +177,8 @@ "type": "grid", "token": "poster", "title": "", - "yPx": 6196, - "heightPx": 488, + "yPx": 6660, + "heightPx": 524, "cards": 6, "focusable": true, "endsInSeeAll": false, @@ -189,8 +189,8 @@ "type": "grid", "token": "poster", "title": "", - "yPx": 6684, - "heightPx": 488, + "yPx": 7184, + "heightPx": 524, "cards": 6, "focusable": true, "endsInSeeAll": false, diff --git a/wolfskin-design/title-tokens.json b/wolfskin-design/title-tokens.json index 9f7ad2e6..5138e020 100644 --- a/wolfskin-design/title-tokens.json +++ b/wolfskin-design/title-tokens.json @@ -14,8 +14,8 @@ "progressBottom": {"dp": 15, "px": 30, "source": "index.html:217"}, "progressGap": {"dp": 9, "px": 18, "source": "index.html:217"}, "progressLift": {"dp": -7, "px": -14, "source": "index.html:217"}, - "castHeight": {"dp": 197, "px": 394, "source": "index.html:1018"}, - "castNameTop": {"dp": 108, "px": 216, "source": "index.html:289"}, + "castHeight": {"dp": 231, "px": 462, "source": "index.html:1018"}, + "castNameTop": {"dp": 118, "px": 236, "source": "index.html:289"}, "castNameSide": {"dp": 6, "px": 12, "source": "index.html:289"}, "personTop": {"dp": 85, "px": 170, "source": "index.html:293"}, "personGap": {"dp": 24, "px": 48, "source": "index.html:293"}, @@ -41,7 +41,7 @@ "infoRowGap": {"dp": 3, "px": 6, "source": "index.html:318"}, "infoColumnGap": {"dp": 10, "px": 20, "source": "index.html:318"}, "infoKey": {"$type": "typography", "sp": 12, "px": 24, "weight": 650, "$value": {"fontWeight": 650}, "source": "index.html:319"}, - "gridHeight": {"dp": 244, "px": 488, "source": "index.html:1019"}, + "gridHeight": {"dp": 262, "px": 524, "source": "index.html:1019"}, "minimumEndPadding": {"dp": 40, "px": 80, "source": "index.html:1042"}, "ratingGap": {"dp": 10, "px": 20, "source": "index.html:156"}, "ratingMarkGap": {"dp": 4, "px": 8, "source": "index.html:157"}, diff --git a/wolfskin-design/tokens.css b/wolfskin-design/tokens.css index 4d3f3488..9c7f17f1 100644 --- a/wolfskin-design/tokens.css +++ b/wolfskin-design/tokens.css @@ -81,9 +81,9 @@ --layout-navSolidAfter: 40.0px; --layout-pinnedLine: 168.0px; --layout-windowAhead: 700.0px; - --layout-rowHeadingBand: 60.0px; - --layout-rowGapAfter: 44.0px; - --layout-gridRowGap: 28.0px; + --layout-rowHeadingBand: 84.0px; + --layout-rowGapAfter: 68.0px; + --layout-gridRowGap: 44.0px; --layout-row_hero: 820.0px; --layout-row_phero: 740.0px; --layout-row_section: 150.0px; @@ -111,7 +111,7 @@ --layout-sheetWidth: 800.0px; --layout-sheetRow: 100.0px; --layout-stateWidth: 1100.0px; - --layout-captionOffset: 18.0px; + --layout-captionOffset: 38.0px; --layout-badge: 48.0px; --layout-badgeInset: 14.0px; --layout-progressBar: 8.0px; @@ -122,6 +122,7 @@ --layout-navAvatar: 64.0px; --layout-castCircle: 200.0px; --layout-personPortrait: 240.0px; + --layout-row_chipsGrid: 136.0px; --motion-fast: 140ms; --motion-base: 160ms; --motion-scroll: 180ms; @@ -273,8 +274,8 @@ --title-progressBottom: 30.0px; --title-progressGap: 18.0px; --title-progressLift: -14.0px; - --title-castHeight: 394.0px; - --title-castNameTop: 216.0px; + --title-castHeight: 462.0px; + --title-castNameTop: 236.0px; --title-castNameSide: 12.0px; --title-personTop: 170.0px; --title-personGap: 48.0px; @@ -300,7 +301,7 @@ --title-infoRowGap: 6.0px; --title-infoColumnGap: 20.0px; --title-infoKey: 24.0px; - --title-gridHeight: 488.0px; + --title-gridHeight: 524.0px; --title-minimumEndPadding: 80.0px; --title-ratingGap: 20.0px; --title-ratingMarkGap: 8.0px; diff --git a/wolfskin-design/tokens.json b/wolfskin-design/tokens.json index 95fa62f2..30bdacb8 100644 --- a/wolfskin-design/tokens.json +++ b/wolfskin-design/tokens.json @@ -1,5 +1,5 @@ { - "$description": "Wolfskin TV design tokens, extracted by script from the locked prototype (data/tv-v2/opus-final-2026-11-05/index.html, sha256 ee8844fc...). Values are CSS px on the 1920x1080 stage; the box renders 1920x1080 at 320 dpi = density 2.0, so dp/sp = px / 2. Format follows the W3C design-tokens draft ($type/$value); extra keys: px, dp|sp, source (page file:line), usedBy, page (the page value where q1 consolidated it).", + "$description": "Wolfskin TV design tokens, extracted by script from the locked prototype (originally data/tv-v2/opus-final-2026-11-05/index.html, sha256 ee8844fc...; now amended in data/tv-v2/spacing-2026-11-06/index.html, sha256 fcc5d879...). Values are CSS px on the 1920x1080 stage; the box renders 1920x1080 at 320 dpi = density 2.0, so dp/sp = px / 2. Format follows the W3C design-tokens draft ($type/$value); extra keys: px, dp|sp, source (page file:line), usedBy, page (the page value where q1 consolidated it). Amended 2026-11-06 (spec revision 3, Level 2 spacing + card ring; data/tv-card-spacing/answers.md): card, layout.row*/gridRowGap and focus.ringCard changed; every other token is as extracted. The prototype source line numbers in `source` fields predate the amendment and drift by a few lines after index.html:8.", "meta": { "density": 2.0, "stagePx": [ @@ -10,8 +10,8 @@ 960, 540 ], - "source": "data/tv-v2/opus-final-2026-11-05/index.html", - "sourceSha256": "ee8844fc6d0237a3727de0d9454db8e5cf3bb14083fb7877d7bb0f4233c6e23d", + "source": "data/tv-v2/spacing-2026-11-06/index.html", + "sourceSha256": "fcc5d879c34da868b817f8d9d306193743d3933db3096da605bda659d8e633b2", "generator": "data/tv-spec-lane/scratch/extract.py + build_tokens.py" }, "color": { @@ -773,27 +773,27 @@ }, "rowHeadingBand": { "$type": "dimension", - "$value": "30dp", - "px": 60, - "dp": 30.0, + "$value": "42dp", + "px": 84, + "dp": 42.0, "source": "index.html:1018", - "usedBy": "shelf row: heading band (44 px heading at top 0) before the cards start at y + 60" + "usedBy": "shelf row: heading band (44 px heading at top 0, then 40 px) before the cards start at y + 84 (HEAD_OFF)" }, "rowGapAfter": { + "$type": "dimension", + "$value": "34dp", + "px": 68, + "dp": 34.0, + "source": "index.html:1018", + "usedBy": "shelf row: space after cards (+ caption band) before the next row; shelf row height = 84 + card h + caption + 68" + }, + "gridRowGap": { "$type": "dimension", "$value": "22dp", "px": 44, "dp": 22.0, - "source": "index.html:1018", - "usedBy": "shelf row: space after cards (+ caption band) before the next row; shelf row height = 60 + card h + caption + 44" - }, - "gridRowGap": { - "$type": "dimension", - "$value": "14dp", - "px": 28, - "dp": 14.0, "source": "index.html:1019", - "usedBy": "grid row height = 384 + 76 + 28 = 488 (library, See all, person)" + "usedBy": "grid row height = 384 + 96 + 44 = 524 (library, See all, person)" }, "row_hero": { "$type": "dimension", @@ -825,7 +825,7 @@ "px": 104, "dp": 52.0, "source": "index.html:1021", - "usedBy": "chip row (Discover filters, library toolbar, See all/service type chips, seasons)" + "usedBy": "chip row above shelves (Discover filters, service type chips, seasons); 104 px" }, "row_note": { "$type": "dimension", @@ -1041,10 +1041,10 @@ }, "captionOffset": { "$type": "dimension", - "px": 18, - "dp": 9, + "px": 38, + "dp": 19, "source": "index.html:1242", - "usedBy": "caption under a focused poster starts 18 px below the card bottom (capHtml top = headH + h + 18); width 640" + "usedBy": "caption under a focused poster starts 38 px below the card bottom (capHtml top = headH + h + CAP_OFF; was 18, amended 2026-11-06); width 640" }, "plateInset": { "$type": "dimension", @@ -1171,6 +1171,14 @@ }, "source": "index.html:204", "usedBy": "letter rail (only for destinations with >= 200 titles)" + }, + "row_chipsGrid": { + "$type": "dimension", + "$value": "68dp", + "px": 136, + "dp": 68.0, + "source": "index.html:rowH", + "usedBy": "chip row directly above a grid (library toolbar, See all type chips): 136 px, so 52 px from the chips to the first card row (was 104 / 20 px)" } }, "card": { @@ -1179,16 +1187,16 @@ "px": { "w": 256, "h": 384, - "gap": 24, - "pitch": 280, - "captionBand": 76 + "gap": 40, + "pitch": 296, + "captionBand": 96 }, "dp": { "w": 128.0, "h": 192.0, - "gap": 12.0, - "pitch": 140.0, - "captionBand": 38.0 + "gap": 20.0, + "pitch": 148.0, + "captionBand": 48.0 }, "ratio": "2:3", "fullSlotsPerRow": 6, @@ -1198,16 +1206,16 @@ "tile": { "$type": "card", "px": { - "w": 416, - "h": 234, - "gap": 24, + "w": 400, + "h": 225, + "gap": 40, "pitch": 440, "captionBand": 0 }, "dp": { - "w": 208.0, - "h": 117.0, - "gap": 12.0, + "w": 200.0, + "h": 112.5, + "gap": 20.0, "pitch": 220.0, "captionBand": 0.0 }, @@ -1219,16 +1227,16 @@ "wide": { "$type": "card", "px": { - "w": 560, - "h": 315, - "gap": 24, + "w": 544, + "h": 306, + "gap": 40, "pitch": 584, "captionBand": 0 }, "dp": { - "w": 280.0, - "h": 157.5, - "gap": 12.0, + "w": 272.0, + "h": 153.0, + "gap": 20.0, "pitch": 292.0, "captionBand": 0.0 }, @@ -1242,15 +1250,15 @@ "px": { "w": 256, "h": 144, - "gap": 24, - "pitch": 280, + "gap": 40, + "pitch": 296, "captionBand": 0 }, "dp": { "w": 128.0, "h": 72.0, - "gap": 12.0, - "pitch": 140.0, + "gap": 20.0, + "pitch": 148.0, "captionBand": 0.0 }, "ratio": "16:9", @@ -1262,22 +1270,22 @@ "$type": "card", "px": { "w": 200, - "h": 290, - "gap": 28, - "pitch": 228, + "h": 310, + "gap": 44, + "pitch": 244, "captionBand": 0 }, "dp": { "w": 100.0, - "h": 145.0, - "gap": 14.0, - "pitch": 114.0, + "h": 155.0, + "gap": 22.0, + "pitch": 122.0, "captionBand": 0.0 }, "ratio": "1:1 circle", "fullSlotsPerRow": 7, "source": "index.html:660", - "usedBy": "Cast row: 200 px circle + name + character, 290 px tall" + "usedBy": "Cast row: 200 px circle + name + character, 310 tall (name starts 236 below the top)" } }, "focus": { @@ -1318,16 +1326,17 @@ }, "ringCard": { "$type": "shadow", - "$value": "0 0 0 4px var(--bg),0 0 0 8px var(--accent),0 18px 50px rgb(0 0 0 / .65)", - "gapPx": 4, + "$value": "0 0 0 2px var(--accent), inset 0 0 0 2px var(--accent) (drawn above the art), 0 18px 50px rgb(0 0 0 / .65)", + "gapPx": 0, "ringPx": 4, - "gapDp": 2, + "gapDp": 0, "ringDp": 2, - "gapColor": "color.bg", "ringColor": "color.accent", "liftShadow": "0 18px 50px rgb(0 0 0 / .65)", "source": "index.html:134", - "usedBy": "focused card: a 4 px bg-coloured gap then a 4 px lilac ring OUTSIDE the art (never covers it), plus a lift shadow; drawn on the circle for cast cards" + "usedBy": "focused card (poster, tile, wide, entry) and cast circle: one 4 px lilac ring centred on the art edge, 2 px outside and 2 px over the art, no gap, plus a lift shadow (Justin 2026-11-06: \"2px inset, 4px ring\")", + "outsidePx": 2, + "insidePx": 2 }, "ringButton": { "$type": "shadow", @@ -1589,4 +1598,4 @@ "usedBy": "Coming-in sheet shows 5 rows (4 while one is expanded), window follows focus" } } -} \ No newline at end of file +} diff --git a/wolfskin-design/tokens.sha256 b/wolfskin-design/tokens.sha256 index ba5f6201..dfc777fb 100644 --- a/wolfskin-design/tokens.sha256 +++ b/wolfskin-design/tokens.sha256 @@ -2,7 +2,7 @@ 85109c394255f6120a621fbefe8e58df82832974ccd6a6a975a61567f641ea2a generate_tokens.py 25985087986a46f00e2819917d0421ff2b437ec21c54f6ce6412b84c0eb05302 home-tokens.json be3af0ddcb0d67abef0805d19d7a33cdb55428bcace656b50f0a38935f212028 shell-tokens.json -1c805ae8eacbca1c63fbf91e19d7f9fea6c27b54e709e91f6bd46720d5048a41 src/main/java/org/justinvisser/wolfskin/design/WolfskinTokens.kt -7ed093cc63b0fd6f47fe951da5df26ee7258865b8d0a51345a408ab64f037de0 title-tokens.json -3b14b60264e1f97c02be3e57d0c0bd9477e982044ee944c8c8a5b71e939fa8fb tokens.css -5b939f1f3eb90b428008fda75720f6e3a4e844c486668931db3d52bffb987b53 tokens.json +4f19801799936db4c126cd7442942ff8552774f141af0de42eb5b3c1ecac92fc src/main/java/org/justinvisser/wolfskin/design/WolfskinTokens.kt +8ae28b149e38a00952c07a6c27bb5df667101acf09b7b571b658774f453b2063 title-tokens.json +b482e5c532b50cffae9923b86efd03d40f1a56300e1c3c6d969b965f41ea1670 tokens.css +6764fa02146c3f37cc60e298f7b13c5d2186fb2be575a39e45c7b207da5429af tokens.json