Build the approved television Updates screen

This commit is contained in:
justinzeus 2026-11-07 16:27:55 +01:00
parent 7a0cbdf909
commit 19df50c0f1
35 changed files with 1069 additions and 69 deletions

View file

@ -121,9 +121,10 @@ def main() -> None:
tokens.update(json.loads((ROOT / "title-tokens.json").read_text()))
tokens.update(json.loads((ROOT / "home-tokens.json").read_text()))
tokens.update(json.loads((ROOT / "discover-tokens.json").read_text()))
tokens.update(json.loads((ROOT / "update-tokens.json").read_text()))
kotlin, css = render_tokens(tokens)
outputs = {str(KOTLIN): kotlin, "tokens.css": css}
inputs = ["tokens.json", "shell-tokens.json", "title-tokens.json", "home-tokens.json", "discover-tokens.json", "generate_tokens.py"]
inputs = ["tokens.json", "shell-tokens.json", "title-tokens.json", "home-tokens.json", "discover-tokens.json", "update-tokens.json", "generate_tokens.py"]
checksums = {name: hashlib.sha256((ROOT / name).read_bytes()).hexdigest() for name in inputs}
checksums.update({name: hashlib.sha256(content.encode()).hexdigest() for name, content in outputs.items()})
outputs["tokens.sha256"] = "".join(f"{checksum} {name}\n" for name, checksum in sorted(checksums.items()))

View file

@ -40,7 +40,7 @@ private class CssColumnHeight : ParentDataModifier {
/** Fractional line heights accumulate before rounding, rather than adding a rounded pixel per row. */
@Composable
internal fun CssColumn(modifier: Modifier = Modifier, gap: Dp = WolfskinTokens.Shell.zero, content: @Composable () -> Unit) {
fun CssColumn(modifier: Modifier = Modifier, gap: Dp = WolfskinTokens.Shell.zero, content: @Composable () -> Unit) {
val exact = remember { CssColumnHeight() }
Layout(content, modifier.then(exact)) { children, constraints ->
val measured = Array(children.size) { children[it].measure(constraints.copy(minWidth = 0, minHeight = 0)) }

View file

@ -23,10 +23,10 @@ fun personColor(key: String): Color = when (key.lowercase()) {
/** The same mark anchors the picker and the persistent navigation. */
@Composable
fun Wordmark(modifier: Modifier = Modifier) {
fun Wordmark(modifier: Modifier = Modifier, prefix: String = "Wolf", textStyle: TextStyle = WolfskinTokens.Type.wordmark) {
Row(modifier) {
Text("Wolf", color = WolfskinTokens.Color.fg, style = WolfskinTokens.Type.wordmark)
Text("skin", color = WolfskinTokens.Color.accent, style = WolfskinTokens.Type.wordmark)
Text(prefix, color = WolfskinTokens.Color.fg, style = textStyle)
Text("skin", color = WolfskinTokens.Color.accent, style = textStyle)
}
}

View file

@ -61,6 +61,7 @@ fun NavBar(state: ShellState, scrolled: Boolean, modifier: Modifier = Modifier)
val reduced = reducedMotion()
val labelFade = tween<Color>(motionDuration(S.navLabelFade, reduced), easing = CubicBezierEasing(0f, 0f, .58f, 1f))
val index = if (state.navFocused) state.navIndex else state.currentTab
val hasSelectedTab = state.route != "settings" && state.route != "updates"
val box = boxes[index]
val avatarFocused = state.navFocused && index == state.tabs.size
val pad = if (avatarFocused) L.navBlobAvatarPad else S.zero
@ -79,14 +80,15 @@ fun NavBar(state: ShellState, scrolled: Boolean, modifier: Modifier = Modifier)
Box(modifier.fillMaxWidth().height(L.navBand)) {
Canvas(Modifier.matchParentSize()) {
if (scrolled) drawRect(C.bg) else drawRect(band)
drawRoundRect(color, Offset(x * density, y * density), Size(width * density, height * density), CornerRadius(height * density / 2))
if (state.navFocused || hasSelectedTab) drawRoundRect(color, Offset(x * density, y * density), Size(width * density, height * density), CornerRadius(height * density / 2))
}
Wordmark(Modifier.offset(L.safeSide, S.wordmarkTop))
Wordmark(Modifier.offset(L.safeSide, S.wordmarkTop), if (hasSelectedTab) "Wolf" else "wolf",
if (hasSelectedTab) WolfskinTokens.Type.wordmark else UpdateTypography.wordmark)
state.tabs.forEachIndexed { itemIndex, tab ->
val item = boxes[itemIndex]
val focused = state.navFocused && state.navIndex == itemIndex
val labelColor by animateColorAsState(
if (focused) C.onFocus else if (itemIndex == state.currentTab) C.fg else C.muted,
if (focused) C.onFocus else if (hasSelectedTab && itemIndex == state.currentTab) C.fg else C.muted,
animationSpec = labelFade,
label = "nav-label-$tab",
)
@ -95,8 +97,8 @@ fun NavBar(state: ShellState, scrolled: Boolean, modifier: Modifier = Modifier)
.clickable {
state.focusNav(itemIndex); state.key(ShellKey.Ok)
}, contentAlignment = Alignment.Center) {
Text(tab.replaceFirstChar { it.uppercase() }, style = WolfskinTokens.Type.nav, color = labelColor)
if (itemIndex == state.currentTab && !focused) {
Text(tab.replaceFirstChar { it.uppercase() }, style = if (hasSelectedTab) WolfskinTokens.Type.nav else UpdateTypography.nav, color = labelColor)
if (hasSelectedTab && itemIndex == state.currentTab && !focused) {
Box(Modifier.align(Alignment.BottomCenter).offset(y = -L.navUnderline).size(item.width - L.navItemPaddingX * 2, L.navUnderline).background(C.accent))
}
}
@ -111,7 +113,7 @@ fun NavBar(state: ShellState, scrolled: Boolean, modifier: Modifier = Modifier)
}
}
val avatar = boxes.last()
Avatar(state.person, L.navAvatar, WolfskinTokens.Type.avatarLetterNav,
Avatar(state.person, L.navAvatar, if (hasSelectedTab) WolfskinTokens.Type.avatarLetterNav else UpdateTypography.avatarLetterNav,
Modifier.offset(avatar.left, avatar.top)
.focusRing(avatarFocused, L.navAvatar / 2, WolfskinTokens.Focus.ringAvatarRing)
.focusProperties { canFocus = false }

View file

@ -11,6 +11,7 @@ import androidx.compose.foundation.layout.PaddingValues
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
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
@ -36,50 +37,52 @@ import androidx.tv.material3.Text
import org.justinvisser.wolfskin.design.WolfskinTokens.Color as C
import org.justinvisser.wolfskin.design.WolfskinTokens.Layout as L
import org.justinvisser.wolfskin.design.WolfskinTokens.Shell as S
import org.justinvisser.wolfskin.design.UpdateTypography as T
/** Settings routes identify real upstream destinations rather than stand-in panels. */
data class SettingsGroup(val key: String, val title: String, val description: String, val wolfskin: Boolean = false)
val SETTINGS_GROUPS = listOf(
SettingsGroup("requests", "Requests", "Coming in: what the household asked for, and how far each is"),
SettingsGroup("interface", "Interface", "Sign in automatically · Remember selected tabs · Theme music · Subtitle style · Application theme · Screensaver"),
SettingsGroup("playback", "Playback", "Skip forward · Skip back · Skip back when resuming"),
SettingsGroup("nextup", "Next up", "Show next up timing · Auto play next up · Delay · Passout protection"),
SettingsGroup("profile", "User profile settings", "Profile protection · Customize home page (Wolfskin presets: Default, Compact, Series thumbs, Episode thumbnails) · Navigation drawer items · Language", true),
SettingsGroup("about", "About", "Installed version · Update (Wolfskin releases on Forgejo) · Wolfskin about", true),
SettingsGroup("seerr", "Seerr integration", "Add, change or remove the Seerr server this TV asks for Discover and ratings", true),
SettingsGroup("advanced", "Advanced settings", "Ratings display · Backdrop style · Playback backend (ExoPlayer, MPV) · Max resolution and bitrate · Skip segments · Logs and image cache"),
SettingsGroup("requests", "Requests", "Coming in: what the household asked for"),
SettingsGroup("updates", "Updates", "Check and install", true),
SettingsGroup("interface", "Interface", "Sign-in · Theme music · Subtitles"),
SettingsGroup("playback", "Playback", "Skip forward · Skip back · Resume"),
SettingsGroup("nextup", "Next up", "Auto-play · Delay · Passout protection"),
SettingsGroup("profile", "User profile settings", "Protection · Home · Language"),
SettingsGroup("about", "About", "Installed version · Wolfskin about"),
SettingsGroup("seerr", "Seerr integration", "Manage the Seerr server"),
SettingsGroup("advanced", "Advanced settings", "Playback backend · Logs · Image cache"),
SettingsGroup("classic", "Classic settings (trial)", "Original settings, including About → Update"),
)
/** The count describes returned request history, not an inferred queue of active downloads. */
@Composable
fun SettingsList(state: ShellState, modifier: Modifier = Modifier, requestCount: Int? = null) {
fun SettingsList(state: ShellState, modifier: Modifier = Modifier, requestCount: Int? = null, installedVersion: String = "") {
val list = state.view.listState
val reduced = reducedMotion()
val density = LocalDensity.current
LaunchedEffect(state.settingsRow, state.navFocused) {
if (state.navFocused) return@LaunchedEffect
val y = L.row_lhead + L.row_srow * state.settingsRow
val target = if (y >= L.windowAhead) y - L.pinnedLine else S.zero
val current = if (list.firstVisibleItemIndex == 0) S.zero else L.row_lhead + L.row_srow * (list.firstVisibleItemIndex - 1)
val distance = with(density) { (target - current).toPx() } - list.firstVisibleItemScrollOffset
list.animateScrollBy(distance, tween(if (NetworkArtTransition.reduced) 0 else WolfskinTokens.Motion.scroll, easing = WolfskinTokens.Motion.easeOut))
val target = (state.settingsRow - WolfskinTokens.Update.settingsVisibleRows.toInt() + 1).coerceAtLeast(0)
val distance = with(density) { (L.row_srow * (target - list.firstVisibleItemIndex)).toPx() } - list.firstVisibleItemScrollOffset
list.animateScrollBy(distance, tween(if (reduced) 0 else WolfskinTokens.Motion.scroll, easing = WolfskinTokens.Motion.easeOut))
}
LazyColumn(modifier.fillMaxSize(), state = list,
contentPadding = PaddingValues(bottom = S.stageHeight - L.pinnedLine - L.row_srow)) {
item(key = "settings-header") {
Row(Modifier.height(L.row_lhead).padding(start = L.safeSide, top = S.settingsHeaderTop),
verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(S.settingsHeaderGap)) {
Avatar(state.person, S.settingsHeaderAvatar, WolfskinTokens.Type.avatarLetterLibHeader, letterColor = C.muted)
Text("Settings", Modifier.alignByBaseline(), style = WolfskinTokens.Type.screenTitle, color = C.fg)
Text("Requests and Wholphin settings on this TV · ${state.person.name}", Modifier.alignByBaseline(), style = WolfskinTokens.Type.nav.copy(fontWeight = FontWeight.Normal), color = C.muted)
}
Box(modifier.fillMaxSize()) {
Row(Modifier.offset(L.safeSide, WolfskinTokens.Update.headerTop).height(WolfskinTokens.Update.headerHeight),
verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(S.settingsHeaderGap)) {
Avatar(state.person, S.settingsHeaderAvatar, T.avatarLetterLibHeader, letterColor = C.muted)
Text("Settings", Modifier.alignByBaseline().exactLineBox(T.screenTitle), style = T.screenTitle, color = C.fg)
Text("This TV · ${state.person.name}", Modifier.alignByBaseline(), style = T.nav.copy(fontWeight = FontWeight.Normal), color = C.muted)
}
itemsIndexed(SETTINGS_GROUPS, key = { _, group -> group.key }) { index, group ->
Box(Modifier.height(L.row_srow)) {
SettingsRow(group, !state.navFocused && !state.requestsVisible && index == state.settingsRow,
if (group.key == "requests") requestCount else null,
Modifier.offset(L.safeSide, S.settingsRowTop)) {
state.navFocused = false; state.settingsRow = index; state.key(ShellKey.Ok)
LazyColumn(Modifier.fillMaxWidth().offset(y = WolfskinTokens.Update.rowsTop).height(WolfskinTokens.Update.rowsViewport + S.settingsRowGap),
state = list, contentPadding = PaddingValues(bottom = S.settingsRowGap)) {
itemsIndexed(SETTINGS_GROUPS, key = { _, group -> group.key }) { index, group ->
Box(Modifier.height(L.row_srow)) {
val display = if (group.key == "updates") group.copy(description = "$installedVersion · Check and install") else group
SettingsRow(display, !state.navFocused && !state.requestsVisible && index == state.settingsRow,
if (group.key == "requests") requestCount else null, Modifier.offset(L.safeSide)) {
state.navFocused = false; state.settingsRow = index; state.key(ShellKey.Ok)
}
}
}
}
@ -94,14 +97,13 @@ fun SettingsRow(group: SettingsGroup, focused: Boolean, requestCount: Int?, modi
.background(if (focused) C.fg else C.listRowRest, RoundedCornerShape(WolfskinTokens.Radius.listRow))
.clickable(onClick = onClick).padding(horizontal = S.settingsRowPadding),
verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(S.settingsRowGap)) {
Glyph(group.key, if (focused) C.onFocus else C.accentText, Modifier.size(S.settingsIcon))
Glyph(if (group.key == "updates") "updates" else "settings-list", if (focused) C.onFocus else C.accentText, Modifier.size(S.settingsIcon))
Column(Modifier.weight(1f)) {
val count = requestCount?.let { " · $it ${if (it == 1) "request" else "requests"}" } ?: ""
Text(group.title + count, Modifier.exactLineBox(WolfskinTokens.Type.listTitle), color = foreground, style = WolfskinTokens.Type.listTitle, maxLines = 1)
Text(group.description, Modifier.exactLineBox(WolfskinTokens.Type.caption), color = if (focused) C.onFocus else C.muted, style = WolfskinTokens.Type.caption, maxLines = 1, overflow = TextOverflow.Ellipsis)
Text(group.title + count, Modifier.exactLineBox(T.listTitle), color = foreground, style = T.listTitle, maxLines = 1)
Text(group.description, Modifier.exactLineBox(T.caption), color = if (focused) C.onFocus else C.muted, style = T.caption, maxLines = 1, overflow = TextOverflow.Ellipsis)
}
if (group.wolfskin) SettingsPill("Wolfskin", focused)
SettingsPill(if (group.key == "requests") "Wolfskin screen" else "upstream screen", focused)
SettingsPill(if (group.key == "requests" || group.key == "updates") "Wolfskin screen" else "upstream screen", focused)
Glyph("right", if (focused) C.onFocus else C.accentText, Modifier.size(S.settingsChevron))
}
}
@ -122,6 +124,6 @@ private fun SettingsPill(label: String, focused: Boolean) {
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 = T.caption.copy(fontWeight = T.label.fontWeight))
}
}

View file

@ -260,9 +260,13 @@ class ShellState(
}
} else if (route == "settings") {
val group = SETTINGS_GROUPS[settingsRow].key
if (group == "requests") openRequests() else {
handOff = group
onAction(ShellAction.HandOff(group))
when (group) {
"requests" -> openRequests()
"updates" -> onAction(ShellAction.Destination("updates"))
else -> {
handOff = group
onAction(ShellAction.HandOff(group))
}
}
}
else contentFocus?.key(ShellKey.Ok)
@ -305,6 +309,7 @@ class ShellState(
gearFocused = true
pickerIndex = people.lastIndex
} else if (route !in tabs) {
if (route == "updates") navFocused = false
onAction(ShellAction.Pop)
} else if (!navFocused) {
navFocused = true

View file

@ -56,6 +56,7 @@ fun ShellSurface(
modifier: Modifier = Modifier,
handlesContentKeys: Boolean = true,
settingsRequestCount: Int? = null,
settingsInstalledVersion: String = "",
overlay: @Composable () -> Unit = {},
content: @Composable (Modifier) -> Unit = { ShellPlaceholder(state.route) },
) {
@ -91,7 +92,7 @@ fun ShellSurface(
if (state.pickerVisible) {
WhosWatching(state)
} else {
if (state.route == "settings") SettingsList(state, requestCount = settingsRequestCount ?: state.requests.snapshot.requestCount) else content(Modifier.fillMaxSize().focusRequester(contentFocus).focusRestorer().focusGroup())
if (state.route == "settings") SettingsList(state, requestCount = settingsRequestCount ?: state.requests.snapshot.requestCount, installedVersion = settingsInstalledVersion) else content(Modifier.fillMaxSize().focusRequester(contentFocus).focusRestorer().focusGroup())
NavBar(state, state.view.listState.firstVisibleItemIndex > 0 || state.view.listState.firstVisibleItemScrollOffset > L.navSolidAfter.value * 2)
if (state.requestsVisible) ComingInSheet(state)
overlay()

View file

@ -0,0 +1,23 @@
// SPDX-License-Identifier: GPL-2.0-only
package org.justinvisser.wolfskin.design
import androidx.compose.ui.text.font.Font
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
/** The approved Updates frames resolve system-ui to DejaVu Sans; other screens keep their fonts. */
object UpdateTypography {
val family = FontFamily(Font(R.font.update_dejavu_sans), Font(R.font.update_dejavu_sans_bold, FontWeight.Bold))
val screenTitle = WolfskinTokens.Type.screenTitle.copy(fontFamily = family)
val stateTitle = WolfskinTokens.Type.stateTitle.copy(fontFamily = family)
val heading = WolfskinTokens.Update.heading.copy(fontFamily = family)
val nav = WolfskinTokens.Type.nav.copy(fontFamily = family)
val wordmark = WolfskinTokens.Type.wordmark.copy(fontFamily = family)
val listTitle = WolfskinTokens.Type.listTitle.copy(fontFamily = family)
val body = WolfskinTokens.Type.body.copy(fontFamily = family)
val button = WolfskinTokens.Type.button.copy(fontFamily = family)
val label = WolfskinTokens.Type.label.copy(fontFamily = family)
val caption = WolfskinTokens.Type.caption.copy(fontFamily = family)
val avatarLetterNav = WolfskinTokens.Type.avatarLetterNav.copy(fontFamily = family)
val avatarLetterLibHeader = WolfskinTokens.Type.avatarLetterLibHeader.copy(fontFamily = family)
}

View file

@ -209,6 +209,9 @@ object WolfskinTokens {
const val fillInverse = "background color.fg, text color.onFocus"
}
object Motion {
const val updateScan = 1800
const val updateSheen = 1600
val linear = CubicBezierEasing(0.0f, 0.0f, 1.0f, 1.0f)
const val skeletonCycle = 1400
const val skeletonLowAlpha = 0.55f
const val focusMove = 180
@ -698,4 +701,38 @@ object WolfskinTokens {
const val ambientSaturation = 1.2f
const val ambientBrightness = 0.55f
}
object Update {
val heading = TextStyle(fontSize = 18.0.sp, fontWeight = FontWeight(800), lineHeight = 20.160000000000004.sp, letterSpacing = -0.02.em)
const val settingsVisibleRows = 6.0f
val headerTop = 75.0.dp
val headerHeight = 42.0.dp
val rowsTop = 139.0.dp
val rowsViewport = 362.0.dp
val contentTop = 149.0.dp
val width = 650.0.dp
val padding = 20.0.dp
val border = 1.0.dp
val radius = 11.0.dp
val titleBottom = 8.0.dp
val paragraphBottom = 7.0.dp
val actionsTop = 18.0.dp
val actionGap = 12.0.dp
val progressTop = 15.0.dp
val progressBottom = 11.0.dp
val progressHeight = 6.0.dp
val progressRadius = 4.0.dp
val autoTop = 431.0.dp
val toggleWidth = 40.0.dp
val toggleHeight = 21.0.dp
val toggleThumb = 15.0.dp
val toggleInset = 3.0.dp
val notesHeight = 256.0.dp
val notesTitleBottom = 10.0.dp
val notesGap = 9.0.dp
val notesScrollStep = 40.0.dp
val backTop = 92.0.dp
const val scanFraction = 0.28f
const val scanEnd = 4.6f
const val sheenAlpha = 0.35f
}
}

View file

@ -0,0 +1,3 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="32dp" android:height="32dp" android:viewportWidth="32" android:viewportHeight="32">
<path android:pathData="M5,8 H27 M5,16 H27 M5,24 H27" android:strokeColor="#FFFFFFFF" android:strokeWidth="2.3" android:fillColor="@android:color/transparent" />
</vector>

View file

@ -0,0 +1,3 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="32dp" android:height="32dp" android:viewportWidth="32" android:viewportHeight="32">
<path android:pathData="M26,14 A11,11 0,1 0,25 23 M26,5 V14 H17" android:strokeColor="#FFFFFFFF" android:strokeWidth="2.3" android:fillColor="@android:color/transparent" />
</vector>

View file

@ -0,0 +1,47 @@
DejaVu Sans and DejaVu Sans Bold
https://dejavu-fonts.github.io/
Copyright (c) 2003 by Bitstream, Inc. All Rights Reserved.
Bitstream Vera is a trademark of Bitstream, Inc.
DejaVu changes are in public domain.
Permission is hereby granted, free of charge, to any person obtaining a copy
of the fonts accompanying this license ("Fonts") and associated
documentation files (the "Font Software"), to reproduce and distribute the
Font Software, including without limitation the rights to use, copy, merge,
publish, distribute, and/or sell copies of the Font Software, and to permit
persons to whom the Font Software is furnished to do so, subject to the
following conditions:
The above copyright and trademark notices and this permission notice shall
be included in all copies of one or more of the Font Software typefaces.
The Font Software may be modified, altered, or added to, and in particular
the designs of glyphs or characters in the Fonts may be modified and
additional glyphs or characters may be added to the Fonts, only if the fonts
are renamed to names not containing either the words "Bitstream" or the word
"Vera".
This License becomes null and void to the extent applicable to Fonts or Font
Software that has been modified and is distributed under the "Bitstream
Vera" names.
The Font Software may be sold as part of a larger software package but no
copy of one or more of the Font Software typefaces may be sold by itself.
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT OF COPYRIGHT, PATENT,
TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL BITSTREAM OR THE GNOME
FOUNDATION BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, INCLUDING
ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL DAMAGES,
WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF
THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM OTHER DEALINGS IN THE
FONT SOFTWARE.
Except as contained in this notice, the names of Gnome, the Gnome
Foundation, and Bitstream Inc., shall not be used in advertising or
otherwise to promote the sale, use or other dealings in this Font Software
without prior written authorization from the Gnome Foundation or Bitstream
Inc., respectively. For further information, contact: fonts at gnome dot
org.

View file

@ -54,10 +54,10 @@ class ShellKeysTest {
assertEquals(state.tabs.size, state.navIndex)
}
@Test fun playbackHandOffInElevenPresses() {
replay(settings + listOf(Key.DirectionDown, Key.DirectionDown, Key.DirectionCenter), 11)
@Test fun playbackHandOffInTwelvePresses() {
replay(settings + listOf(Key.DirectionDown, Key.DirectionDown, Key.DirectionDown, Key.DirectionCenter), 12)
assertEquals("playback", state.handOff)
assertEquals(2, state.settingsRow)
assertEquals(3, state.settingsRow)
}
@Test fun onlyLastPersonReachesGearAndUpReturnsThere() {

View file

@ -123,6 +123,9 @@
--layout-castCircle: 200.0px;
--layout-personPortrait: 240.0px;
--layout-row_chipsGrid: 136.0px;
--motion-updateScan: 1800ms;
--motion-updateSheen: 1600ms;
--motion-linear: cubic-bezier(0, 0, 1, 1);
--motion-skeletonCycle: 1400ms;
--motion-focusMove: 180ms;
--motion-captionDelay: 60ms;
@ -513,4 +516,32 @@
--discover-stageGap: 10.0px;
--discover-ambientInset: 80.0px;
--discover-ambientBlur: 60.0px;
--update-heading: 36.0px;
--update-headerTop: 150.0px;
--update-headerHeight: 84.0px;
--update-rowsTop: 278.0px;
--update-rowsViewport: 724.0px;
--update-contentTop: 298.0px;
--update-width: 1300.0px;
--update-padding: 40.0px;
--update-border: 2.0px;
--update-radius: 22.0px;
--update-titleBottom: 16.0px;
--update-paragraphBottom: 14.0px;
--update-actionsTop: 36.0px;
--update-actionGap: 24.0px;
--update-progressTop: 30.0px;
--update-progressBottom: 22.0px;
--update-progressHeight: 12.0px;
--update-progressRadius: 8.0px;
--update-autoTop: 862.0px;
--update-toggleWidth: 80.0px;
--update-toggleHeight: 42.0px;
--update-toggleThumb: 30.0px;
--update-toggleInset: 6.0px;
--update-notesHeight: 512.0px;
--update-notesTitleBottom: 20.0px;
--update-notesGap: 18.0px;
--update-notesScrollStep: 80.0px;
--update-backTop: 184.0px;
}

View file

@ -1390,6 +1390,9 @@
}
},
"motion": {
"updateScan": { "$type": "duration", "$value": "1800ms", "source": "approved Updates progress busy scan" },
"updateSheen": { "$type": "duration", "$value": "1600ms", "source": "approved Updates download highlight" },
"linear": { "$type": "cubicBezier", "$value": [0, 0, 1, 1] },
"skeletonCycle": { "$type": "duration", "$value": "1400ms" },
"skeletonLowAlpha": { "$type": "number", "$value": 0.55 },
"focusMove": { "$type": "duration", "$value": "180ms" },

View file

@ -1,8 +1,9 @@
b3f096b692c395534177cc82ac68f5b801870bc7e28f0dd5cd7c17e2103920a3 discover-tokens.json
c29dc64f3c3ca900c747b3db8b270e25908948bf5f76388d2c627a612fc7c99d generate_tokens.py
993d933950e17fb72d0902ffc440e1e4ed4dc088c96a8d12974431c77dc31634 generate_tokens.py
25985087986a46f00e2819917d0421ff2b437ec21c54f6ce6412b84c0eb05302 home-tokens.json
be3af0ddcb0d67abef0805d19d7a33cdb55428bcace656b50f0a38935f212028 shell-tokens.json
0446c2e85bbda72adde76329958931216fe81c43cebd47bbdb885f5dd59d87f8 src/main/java/org/justinvisser/wolfskin/design/WolfskinTokens.kt
18c08c6f4d77ee0690e63ec2e1ce14854eced539746c41c948942460b37a38b2 src/main/java/org/justinvisser/wolfskin/design/WolfskinTokens.kt
e0a2f5df2f249bce3f6e13f4a0320c7b18b85777eaf04a27a428d6683a6aad28 title-tokens.json
faa4e0c5398d35ecab9b1088815b79c8de26a44d403c4cafae59c7c341992da7 tokens.css
f4424475291a745400a5daa071cb846838fb5e9ce889022288bcc6a7be758fe0 tokens.json
eed527dac5e09b209619cd67990556a5f9223d05e7e2516f300cb4760a198ca7 tokens.css
2e5e0476341adec1ec05331af38bc67cd02ba013a0988aea40a70eff21513264 tokens.json
824cb58d1507bc05212323ecfa66664f8b2bde23438940eed8717266161cac55 update-tokens.json

View file

@ -0,0 +1,161 @@
{
"update": {
"heading": {
"$type": "typography",
"$value": {"fontWeight": 800},
"px": 36,
"sp": 18,
"weight": 800,
"lineHeight": 1.12,
"letterSpacingEm": -0.02
},
"settingsVisibleRows": {"$type": "number", "$value": 6},
"headerTop": {
"$type": "dimension",
"px": 150,
"dp": 75.0
},
"headerHeight": {
"$type": "dimension",
"px": 84,
"dp": 42.0
},
"rowsTop": {
"$type": "dimension",
"px": 278,
"dp": 139.0
},
"rowsViewport": {
"$type": "dimension",
"px": 724,
"dp": 362.0
},
"contentTop": {
"$type": "dimension",
"px": 298,
"dp": 149.0
},
"width": {
"$type": "dimension",
"px": 1300,
"dp": 650.0
},
"padding": {
"$type": "dimension",
"px": 40,
"dp": 20.0
},
"border": {
"$type": "dimension",
"px": 2,
"dp": 1.0
},
"radius": {
"$type": "dimension",
"px": 22,
"dp": 11.0
},
"titleBottom": {
"$type": "dimension",
"px": 16,
"dp": 8.0
},
"paragraphBottom": {
"$type": "dimension",
"px": 14,
"dp": 7.0
},
"actionsTop": {
"$type": "dimension",
"px": 36,
"dp": 18.0
},
"actionGap": {
"$type": "dimension",
"px": 24,
"dp": 12.0
},
"progressTop": {
"$type": "dimension",
"px": 30,
"dp": 15.0
},
"progressBottom": {
"$type": "dimension",
"px": 22,
"dp": 11.0
},
"progressHeight": {
"$type": "dimension",
"px": 12,
"dp": 6.0
},
"progressRadius": {
"$type": "dimension",
"px": 8,
"dp": 4.0
},
"autoTop": {
"$type": "dimension",
"px": 862,
"dp": 431.0
},
"toggleWidth": {
"$type": "dimension",
"px": 80,
"dp": 40.0
},
"toggleHeight": {
"$type": "dimension",
"px": 42,
"dp": 21.0
},
"toggleThumb": {
"$type": "dimension",
"px": 30,
"dp": 15.0
},
"toggleInset": {
"$type": "dimension",
"px": 6,
"dp": 3.0
},
"notesHeight": {
"$type": "dimension",
"px": 512,
"dp": 256.0
},
"notesTitleBottom": {
"$type": "dimension",
"px": 20,
"dp": 10.0
},
"notesGap": {
"$type": "dimension",
"px": 18,
"dp": 9.0
},
"notesScrollStep": {
"$type": "dimension",
"px": 80,
"dp": 40.0
},
"backTop": {
"$type": "dimension",
"px": 184,
"dp": 92.0
},
"scanFraction": {
"$type": "number",
"$value": 0.28
},
"scanEnd": {
"$type": "number",
"$value": 4.6
},
"sheenAlpha": {
"$type": "number",
"$value": 0.35
}
}
}