Compare commits

...

6 commits

Author SHA1 Message Date
1c77f21df3 Merge pull request 'Build TV Requests under Settings' (#32) from fm/tv-slice7-requests into main 2026-11-05 20:06:36 +01:00
924c90a9e0 Align readiness glyphs to inline text baselines
All checks were successful
review/design-claude B1-B5/B7 verified fixed by Sonnet re-review at b8d5d8a1; B8 readiness block-line replication checked by firstmate: 6-file delta, baseline/no-gap code only, st-086 pair viewed, two-line wrap is font-caused and recorded; B6 approved by Justin
2026-11-05 19:53:32 +01:00
b8d5d8a124 Preserve contained rating marks inside dark chips
Some checks failed
review/design-claude B1-B5,B7 fixed; B8 readiness item glyph/gap geometry deviates from locked st-086
2026-11-05 19:10:25 +01:00
d9578ac405 Match Requests sheets to locked design frames 2026-11-05 18:58:06 +01:00
439eea3ff8 Merge library slice and reuse shared image policy
Some checks failed
review/design-claude Changes requested: 7 design-parity findings (separator opacity, line-box rhythm, stage gap, mark chips, avatar, count, thumb fringe)
2026-11-05 17:41:43 +01:00
5b0703aec5 Build household requests under Settings 2026-11-05 17:35:42 +01:00
60 changed files with 1646 additions and 29 deletions

View file

@ -0,0 +1,217 @@
{
"source": "Locked Opus fixture/fixture.json; index.html EXAMPLES() pool order",
"items": [
{
"id": "x1",
"mediaType": "tv",
"tmdbId": 11821,
"title": "The Abbott and Costello Show",
"requesterKey": "justin",
"requesterName": "Justin",
"actorKey": "justin",
"actorName": "Justin",
"stage": "Downloading",
"poster": "tv-the-abbott-and-costello-show-1952-poster.jpg",
"ratings": [
{
"mark": "tmdb",
"score": 7.2,
"percentage": false
}
]
},
{
"id": "x2",
"mediaType": "movie",
"tmdbId": 13483,
"title": "Awake",
"requesterKey": "mindel",
"requesterName": "Mindel",
"actorKey": "justin",
"actorName": "Justin",
"stage": "AlmostReady",
"poster": "movie-awake-2007-poster.jpg",
"ratings": [
{
"mark": "rotten",
"score": 23,
"percentage": true
},
{
"mark": "spilled",
"score": 55,
"percentage": true
},
{
"mark": "imdb",
"score": 6.5,
"percentage": false
}
]
},
{
"id": "x3",
"mediaType": "tv",
"tmdbId": 70785,
"title": "Anne with an E",
"requesterKey": "shared",
"requesterName": "Shared",
"actorKey": "mindel",
"actorName": "Mindel",
"stage": "Searching",
"poster": "tv-anne-with-an-e-2017-poster.jpg",
"ratings": [
{
"mark": "fresh",
"score": 63,
"percentage": true
}
]
},
{
"id": "x4",
"mediaType": "movie",
"tmdbId": 99861,
"title": "Avengers: Age of Ultron",
"requesterKey": "justin",
"requesterName": "Justin",
"actorKey": "justin",
"actorName": "Justin",
"stage": "Waiting",
"poster": "movie-avengers-age-of-ultron-2015-poster.jpg",
"ratings": [
{
"mark": "fresh",
"score": 75,
"percentage": true
},
{
"mark": "upright",
"score": 82,
"percentage": true
},
{
"mark": "imdb",
"score": 7.3,
"percentage": false
}
]
},
{
"id": "x5",
"mediaType": "movie",
"tmdbId": 1033148,
"title": "Die My Love",
"requesterKey": "mindel",
"requesterName": "Mindel",
"actorKey": "mindel",
"actorName": "Mindel",
"stage": "Failed",
"poster": "movie-die-my-love-2025-poster.jpg",
"ratings": [
{
"mark": "fresh",
"score": 74,
"percentage": true
},
{
"mark": "spilled",
"score": 45,
"percentage": true
},
{
"mark": "imdb",
"score": 6,
"percentage": false
}
]
},
{
"id": "x6",
"mediaType": "movie",
"tmdbId": 416153,
"title": "Arctic Dogs",
"requesterKey": "shared",
"requesterName": "Shared",
"actorKey": "justin",
"actorName": "Justin",
"stage": "Ready",
"poster": "movie-arctic-dogs-2019-poster.jpg",
"ratings": [
{
"mark": "rotten",
"score": 13,
"percentage": true
},
{
"mark": "upright",
"score": 60,
"percentage": true
},
{
"mark": "imdb",
"score": 4.9,
"percentage": false
}
]
},
{
"id": "x7",
"mediaType": "movie",
"tmdbId": 76203,
"title": "12 Years a Slave",
"requesterKey": "justin",
"requesterName": "Justin",
"actorKey": "shared",
"actorName": "Shared",
"stage": "Waiting",
"poster": "movie-12-years-a-slave-2013-poster.jpg",
"ratings": [
{
"mark": "fresh",
"score": 95,
"percentage": true
},
{
"mark": "upright",
"score": 90,
"percentage": true
},
{
"mark": "imdb",
"score": 8.1,
"percentage": false
}
]
},
{
"id": "x8",
"mediaType": "movie",
"tmdbId": 14069,
"title": "The Girl Who Leapt Through Time",
"requesterKey": "mindel",
"requesterName": "Mindel",
"actorKey": "justin",
"actorName": "Justin",
"stage": "Waiting",
"poster": "movie-the-girl-who-leapt-through-time-2006-poster.jpg",
"ratings": [
{
"mark": "fresh",
"score": 87,
"percentage": true
},
{
"mark": "upright",
"score": 90,
"percentage": true
},
{
"mark": "imdb",
"score": 7.6,
"percentage": false
}
]
}
]
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

View file

@ -0,0 +1,52 @@
// SPDX-License-Identifier: GPL-2.0-only
package org.justinvisser.wolfskin.tv
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.launch
import kotlinx.coroutines.test.advanceTimeBy
import kotlinx.coroutines.test.runCurrent
import kotlinx.coroutines.test.runTest
import org.junit.Assert.assertEquals
import org.junit.Test
import org.justinvisser.wolfskin.design.ComingInRequest
import org.justinvisser.wolfskin.design.ComingInSnapshot
import org.justinvisser.wolfskin.design.ComingInStage
import org.justinvisser.wolfskin.design.ComingInStatus
import org.justinvisser.wolfskin.design.ShellPerson
import org.justinvisser.wolfskin.design.ShellState
/** The fake clock checks refresh boundaries and cancellation without a server or a real wait. */
@OptIn(ExperimentalCoroutinesApi::class)
class RequestsRefreshTest {
@Test fun foregroundOpenUnsettledSettledAndStoppedTransitions() = runTest {
val state = ShellState(listOf(ShellPerson("justin", "Justin", "J", false)), "justin")
val request = ComingInRequest("one", "movie", 1, "One", "justin", "Justin", "shared", "Shared", ComingInStage.Waiting)
state.requests.update(ComingInSnapshot(status = ComingInStatus.Ready, signedIn = true))
var reads = 0
val active = launch { refreshRequestsWhileStarted(state) { reads++ } }
runCurrent()
assertEquals(1, reads)
advanceTimeBy(30_000); runCurrent()
assertEquals("Closed and settled does not poll", 1, reads)
state.openRequests()
advanceTimeBy(29_999); runCurrent()
assertEquals(1, reads)
advanceTimeBy(1); runCurrent()
assertEquals("An open empty sheet still refreshes at 30 seconds", 2, reads)
state.closeRequests()
state.requests.update(ComingInSnapshot(listOf(request), ComingInStatus.Ready, signedIn = true))
advanceTimeBy(30_000); runCurrent()
assertEquals("An unsettled request refreshes after the sheet closes", 3, reads)
state.requests.update(ComingInSnapshot(listOf(request.copy(stage = ComingInStage.Ready)), ComingInStatus.Ready, signedIn = true))
advanceTimeBy(30_000); runCurrent()
assertEquals("Ready requests stop polling", 3, reads)
active.cancel(); runCurrent()
state.openRequests()
advanceTimeBy(60_000); runCurrent()
assertEquals("A stopped lifecycle cannot poll", 3, reads)
val resumed = launch { refreshRequestsWhileStarted(state) { reads++ } }
runCurrent()
assertEquals("Returning to the foreground refreshes immediately", 4, reads)
resumed.cancel()
}
}

View file

@ -0,0 +1,33 @@
// SPDX-License-Identifier: GPL-2.0-only
package org.justinvisser.wolfskin.tv
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.compose.LocalLifecycleOwner
import androidx.lifecycle.repeatOnLifecycle
import kotlinx.coroutines.currentCoroutineContext
import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive
import org.justinvisser.wolfskin.design.ShellState
import org.justinvisser.wolfskin.design.WolfskinTokens
/** Foreground refreshes once; only an open sheet or an unsettled request keeps polling. */
@Composable
internal fun RequestsRefresh(state: ShellState, refresh: () -> Unit) {
val owner = LocalLifecycleOwner.current
LaunchedEffect(state, owner) {
owner.lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) {
refreshRequestsWhileStarted(state, refresh)
}
}
}
/** Cancellation on leaving STARTED stops polling; reentry performs a fresh foreground read. */
internal suspend fun refreshRequestsWhileStarted(state: ShellState, refresh: () -> Unit) {
refresh()
while (currentCoroutineContext().isActive) {
delay(WolfskinTokens.Behaviour.requestsPollSeconds.toLong() * 1000L)
if (state.requestsVisible || state.requests.snapshot.openCount > 0) refresh()
}
}

View file

@ -0,0 +1,18 @@
// SPDX-License-Identifier: GPL-2.0-only
package org.justinvisser.wolfskin.tv
import android.content.Context
import com.github.damontecres.wholphin.BuildConfig
import org.justinvisser.wolfskin.design.ComingInSnapshot
import org.justinvisser.wolfskin.design.ComingInStatus
import org.justinvisser.wolfskin.design.readComingInFixture
/** Release stays unavailable until Conductarr sign-in supplies an authenticated generated client. */
fun readShellRequests(context: Context): ComingInSnapshot {
if (!BuildConfig.DEBUG) return ComingInSnapshot()
val json = context.assets.open("wolfskin-requests.json").bufferedReader().use { it.readText() }
val items = readComingInFixture(json) { name ->
context.assets.open("wolfskin-requests/$name").use { it.readBytes() }
}
return ComingInSnapshot(items.take(6), ComingInStatus.Ready, signedIn = true)
}

View file

@ -73,6 +73,7 @@ class WolfskinShellViewModel @Inject constructor(
val seeAll: WolfskinSeeAllLists,
) : ViewModel() {
val backdropFlow: StateFlow<BackdropResult> = backdrop.backdropFlow
private val requestsSnapshot by lazy { readShellRequests(context) }
var libraryDestinations by mutableStateOf<Map<String, Destination>>(emptyMap())
private set
var signedInPerson by mutableStateOf("")
@ -98,6 +99,14 @@ class WolfskinShellViewModel @Inject constructor(
fun clearBackdrop() { viewModelScope.launch { backdrop.clearBackdrop() } }
/** The debug fixture is the only Requests supplier until TV sign-in is enabled. */
fun refreshRequests() {
viewModelScope.launch {
val snapshot = withContext(Dispatchers.IO) { requestsSnapshot }
session.state?.requests?.update(snapshot)
}
}
private fun handleAction(action: ShellAction, server: JellyfinServer, user: JellyfinUser) {
when (action) {
is ShellAction.Destination -> if (action.route == "settings") navigation.navigateTo(Destination.Wolfskin("settings")) else navigation.replace(if (action.route == "home") Destination.Home() else Destination.Wolfskin(action.route))
@ -105,6 +114,8 @@ class WolfskinShellViewModel @Inject constructor(
is ShellAction.HandOff -> navigation.navigateTo(handOffDestination(action.group))
is ShellAction.Person -> selectPerson(action.person, server, user)
ShellAction.RetryPeople -> viewModelScope.launch { load(server, user) }
ShellAction.RetryRequests -> refreshRequests()
is ShellAction.RequestTitle -> navigation.navigateTo(Destination.Wolfskin("request-title/${action.request.mediaType}/${action.request.tmdbId}"))
}
}
@ -155,6 +166,7 @@ fun WolfskinApp(
ShellState(listOf(ShellPerson(user.name.orEmpty().lowercase(), user.name.orEmpty(), user.name.orEmpty().take(1), false)), user.name.orEmpty().lowercase()).apply { peopleStatus = PeopleStatus.Loading }
}
val state = viewModel.session.state ?: loading
RequestsRefresh(state, viewModel::refreshRequests)
WolfskinTheme {
CompositionLocalProvider(LocalShellState provides state) {
Box(modifier) {
@ -206,5 +218,9 @@ fun WolfskinApp(
/** The single registered destination branch delegates Wolfskin-only screens back to the shell. */
@Composable
fun WolfskinDestinationContent(route: String, modifier: Modifier = Modifier) {
if (route == "settings") SettingsList(LocalShellState.current, modifier) else ShellPlaceholder(route, modifier)
when {
route == "settings" -> SettingsList(LocalShellState.current, modifier)
route.startsWith("request-title/") -> ShellPlaceholder("Title: ${LocalShellState.current.requests.focused?.title.orEmpty()} (title slice integration pending)", modifier)
else -> ShellPlaceholder(route, modifier)
}
}

View file

@ -59,7 +59,7 @@ cover the Wolfskin nav.
The independent `:wolfskin-design` module owns the shell components, fixed person
colours, focus policy, per-person root-view memory, palette and motion. Its
`tokens.json` is the copied approved TV-spec source, retaining the locked prototype
SHA and provenance. `shell-tokens.json` adds shell and grid measurements from that
SHA and provenance. `shell-tokens.json` adds shell, grid and sheet measurements from that
same prototype. Generate the committed Kotlin, CSS and checksum manifest together:
```sh
@ -83,8 +83,8 @@ padding includes it. The 6 px dash / 4 px gap follows the browser's 3:2 stroke-w
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.
Settings groups hand off to existing upstream pages; the Requests entry explicitly
has no request data yet. **Advanced settings → Wolfskin screens** is the persistent
Settings groups hand off to existing upstream pages; Requests opens the household
Coming-in sheet described below. **Advanced settings → Wolfskin screens** is the persistent
rollout switch: turning it off renders upstream Home and library destinations with
the upstream Backdrop and top scrim, while retaining Wolfskin's nav, picker and
Settings list. Native Up traversal stays in content until the first focusable row,
@ -120,6 +120,68 @@ artifact provider and AAPT2. It checks all string, plural and array configuratio
rejecting user-visible upstream branding while preserving the upstream legal
attribution and project URL. The check does not depend on incremental merge files.
## Requests under Settings
The Requests row and Coming-in sheet share one immutable household snapshot.
Ready requests do not count as open. Rows sort Failed, Downloading/Almost ready,
Checking/Searching, Waiting, Ready. The bounded window shows five rows, or four
with readiness expanded. Right expands Request/Download/Library/Playback; Left
collapses; Up/Down collapse while moving. Opening a title preserves the request
for Back, and closing the sheet restores its opener. Selecting a root or changing
person clears the title-return link. Shared's authenticated actor always reads
“by Shared”, never “by you”.
The debug-only provider loads `app/src/debug/assets/wolfskin-requests.json` and
its eight local posters, projected from the locked prototype fixture. Six stage
rows feed the running debug shell; the Shared and focused-rating records feed
native acceptance states. The identical projection is in design-test resources.
No “sample” or “example stages” pill is drawn. Release builds package none of
these assets and show Requests unavailable until Conductarr sign-in supplies
the generated-client data path. There is no Seerr fallback or handwritten HTTP.
Foreground refreshes once; an open sheet or unsettled requests refresh every
30 seconds while started. The immutable debug projection is loaded once, not
decoded again on every poll.
Thumbnails reuse `LibraryImages` with a 96×144-pixel request, RGB565, no crossfade,
the shared 64 MB memory/128 MB disk cache and four-read limit. Actor separator
dots use the surrounding text colour; the 40% rule applies only to poster captions.
Uncached focused ratings wait 350 ms; moving away cancels the read. Read ratings
show immediately on return; sign-out clears visibility. This follows the frozen
page and spec 6.12, resolving the contradictory no-dwell sentence in spec 7.8.
Deterministic coroutine-clock tests cover cancellation, reentry, cached return,
sign-out and refreshed locale scores; a thin Compose test verifies actual Dutch
rendering and sign-out suppression. The approved nine-key Requests path is JSON
data replayed through the real shell. Home's hero content is not implemented
here: its launch-to-nav shell suffix is the acceptance seam.
Sheet dimensions, padding, glyphs, scales, colours and durations are generated
tokens. Scoped sheet line boxes retain CSS heights and baselines independently
of native leading rounding; shared shell/library typography is unchanged. Platform
Roboto remains required: the host browser used DejaVu Sans, so its width,
wrapping, ellipsis and text-block positions are not a wrapping-parity target.
For example, the 44-pixel “Coming in” ink is 239 pixels wide in the reference
and 194 pixels in native captures. No per-title compensation or font bundle is
used. Native comparisons must report those structural differences explicitly.
Readiness glyphs sit inline on the text baseline with no flex gap, matching the
frozen block-span cascade. Items wrap intrinsically: Roboto fits the expanded
readiness into two lines where the reference uses three; no grouping is forced.
The six rating marks retain the exact locked SVG source in
`wolfskin-design/request-marks/`; native-size PNGs and their hashes are generated
from the JSON dimensions with the locked contain fit (`.rr .rb` outranks `.crow img`).
Inside Coming-in rows they retain the prototype's dark `surface3` chips with 12-pixel rounded corners.
Loaded poster thumbnails have no placeholder fill beneath their clipped edge.
```sh
uv run --with cairosvg==2.9.1 python wolfskin-design/generate_request_marks.py
```
`RequestsCaptureTest` writes `st-081`…`st-086`, `st-088` and `st-111` at 1920×1080,
density 2.0. Title selection currently uses an explicitly labelled
`request-title/...` destination until the independent title screen is integrated;
this slice does not claim to implement title pages, TV sign-in, release or device
deployment.
## Libraries and See all
Movies, Shows and Anime use the shell's retained view and a continuous six-column
@ -134,8 +196,8 @@ its unknown item count.
Caption separators use 40% of the existing muted colour's alpha. The rail follows
the locked prototype CSS: bold 24 px letters, with the focused letter bold 28 px,
overriding the spec table's generic caption style for this component.
Library header initials use the muted colour over the person's fill; navigation,
picker and Settings avatars retain their dark initials.
Library and Settings header initials use the muted colour over the person's fill;
navigation and picker avatars retain their dark initials.
The debug-only `wolfskin-library.json` and `library/assets/` contain the locked
prototype's titles and local poster art. Release libraries read through

View file

@ -59,6 +59,7 @@ dependencies {
implementation(libs.coil.compose)
debugImplementation(libs.androidx.compose.ui.test.manifest)
testImplementation(libs.junit)
testImplementation(libs.kotlinx.coroutines.test)
testImplementation(libs.robolectric)
implementation(libs.androidx.compose.material3)
testImplementation(libs.androidx.compose.ui.test.junit4)

View file

@ -0,0 +1,31 @@
#!/usr/bin/env python3
"""Rasterize locked prototype rating marks at the Coming-in row's native pixel size."""
from pathlib import Path
import hashlib
import json
import cairosvg
ROOT = Path(__file__).resolve().parent
SOURCE = ROOT / "request-marks"
OUTPUT = ROOT / "src/main/res/drawable-nodpi"
def main() -> None:
"""Keep the original SVGs and record hashes alongside generated television-size PNGs."""
OUTPUT.mkdir(parents=True, exist_ok=True)
tokens = json.loads((ROOT / "tokens.json").read_text())
supplement = json.loads((ROOT / "shell-tokens.json").read_text())
height = int(tokens["type"]["label"]["px"])
wordmark_ratio = supplement["requests"]["ratingsWordmarkRatio"]["$value"]
manifest = []
for source in sorted(SOURCE.glob("*.svg")):
target = OUTPUT / f"coming_in_rating_{source.stem}.png"
width = round(height * wordmark_ratio) if source.stem in ("imdb", "tmdb") else height
cairosvg.svg2png(url=str(source), write_to=str(target), output_width=width, output_height=height)
for path in (source, target):
manifest.append(f"{hashlib.sha256(path.read_bytes()).hexdigest()} {path.relative_to(ROOT)}")
(ROOT / "request-marks.sha256").write_text("\n".join(manifest) + "\n")
if __name__ == "__main__":
main()

View file

@ -0,0 +1,12 @@
01aa8a3c3e9db48f0e1c6af25056940986784315bf66f48a8120a7317bf6d75a request-marks/fresh.svg
743b6139dc12019d0940568869065722593f44ce416a7e9c7b8d9ef92942bb3f src/main/res/drawable-nodpi/coming_in_rating_fresh.png
27f93b15a075b015e2448eca7949f7a3da03c37fbf26cecf6df3bdab45fda993 request-marks/imdb.svg
a738afd57519b1b4a38192927b48bd45755ce75e4673c6b12b9b3e0c2e388372 src/main/res/drawable-nodpi/coming_in_rating_imdb.png
510d0f9fb77d72190ebf03208f04215a4e86f71e165488518ac75aa49cf4a79d request-marks/rotten.svg
0aade711ba57b6b201c7395ece97108df928b6076d8120404cdf4155a4242632 src/main/res/drawable-nodpi/coming_in_rating_rotten.png
b8dcf4d27eba2446380980c96d82b1f64aa6859fa2ea1cdab867f849f9fec4fd request-marks/spilled.svg
fa4f50258009cd1d16f0d0f5a3c216dc88a57409b1bccc379512ae914cc9d714 src/main/res/drawable-nodpi/coming_in_rating_spilled.png
d537fb228cf3ded904ef09b136fe3fec72548ebc1fea3fbbd1ad9e36364db38b request-marks/tmdb.svg
8e1cb507f876f324bb5c53bf4e60775712f1fdddbffe94fd249e6e74099e045e src/main/res/drawable-nodpi/coming_in_rating_tmdb.png
2ad511c7a101a7f2f8810434911df8c13cc126fa691272242037237172600bc3 request-marks/upright.svg
6ff8a05501bc1c15b96c0130271c41baaa2ddb74f64916f58b9e7f918931d789 src/main/res/drawable-nodpi/coming_in_rating_upright.png

View file

@ -0,0 +1,20 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg id="svg3390" xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" xmlns="http://www.w3.org/2000/svg" height="141.25" viewBox="0 0 138.75 141.25" width="138.75" version="1.1" xmlns:cc="http://creativecommons.org/ns#" xmlns:dc="http://purl.org/dc/elements/1.1/">
<metadata id="metadata3396">
<rdf:RDF>
<cc:Work rdf:about="">
<dc:format>image/svg+xml</dc:format>
<dc:type rdf:resource="http://purl.org/dc/dcmitype/StillImage"/>
<dc:title/>
</cc:Work>
</rdf:RDF>
</metadata>
<g id="layer1" fill="#f93208">
<path id="path3412" d="m20.154 40.829c-28.149 27.622-13.657 61.011-5.734 71.931 35.254 41.954 92.792 25.339 111.89-5.9071 4.7608-8.2027 22.554-53.467-23.976-78.009z"/>
<path id="path3471" d="m39.613 39.265 4.7778-8.8607 28.406-5.0384 11.119 9.2082z"/>
</g>
<g id="layer2">
<path id="path3437" d="m39.436 8.5696 8.9682-5.2826 6.7569 15.479c3.7925-6.3226 13.79-16.316 24.939-4.6684-4.7281 1.2636-7.5161 3.8553-7.7397 8.4768 15.145-4.1697 31.343 3.2127 33.539 9.0911-10.951-4.314-27.695 10.377-41.771 2.334 0.009 15.045-12.617 16.636-19.902 17.076 2.077-4.996 5.591-9.994 1.474-14.987-7.618 8.171-13.874 10.668-33.17 4.668 4.876-1.679 14.843-11.39 24.448-11.425-6.775-2.467-12.29-2.087-17.814-1.475 2.917-3.961 12.149-15.197 28.625-8.476z" fill="#02902e"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

View file

@ -0,0 +1,3 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" preserveAspectRatio="xMidYMid meet" viewBox="0 0 575 289.83" width="575" height="289.83"><defs><path d="M575 24.91C573.44 12.15 563.97 1.98 551.91 0C499.05 0 76.18 0 23.32 0C10.11 2.17 0 14.16 0 28.61C0 51.84 0 237.64 0 260.86C0 276.86 12.37 289.83 27.64 289.83C79.63 289.83 495.6 289.83 547.59 289.83C561.65 289.83 573.26 278.82 575 264.57C575 216.64 575 48.87 575 24.91Z" id="d1pwhf9wy2"></path><path d="M69.35 58.24L114.98 58.24L114.98 233.89L69.35 233.89L69.35 58.24Z" id="g5jjnq26yS"></path><path d="M201.2 139.15C197.28 112.38 195.1 97.5 194.67 94.53C192.76 80.2 190.94 67.73 189.2 57.09C185.25 57.09 165.54 57.09 130.04 57.09L130.04 232.74L170.01 232.74L170.15 116.76L186.97 232.74L215.44 232.74L231.39 114.18L231.54 232.74L271.38 232.74L271.38 57.09L211.77 57.09L201.2 139.15Z" id="i3Prh1JpXt"></path><path d="M346.71 93.63C347.21 95.87 347.47 100.95 347.47 108.89C347.47 115.7 347.47 170.18 347.47 176.99C347.47 188.68 346.71 195.84 345.2 198.48C343.68 201.12 339.64 202.43 333.09 202.43C333.09 190.9 333.09 98.66 333.09 87.13C338.06 87.13 341.45 87.66 343.25 88.7C345.05 89.75 346.21 91.39 346.71 93.63ZM367.32 230.95C372.75 229.76 377.31 227.66 381.01 224.67C384.7 221.67 387.29 217.52 388.77 212.21C390.26 206.91 391.14 196.38 391.14 180.63C391.14 174.47 391.14 125.12 391.14 118.95C391.14 102.33 390.49 91.19 389.48 85.53C388.46 79.86 385.93 74.71 381.88 70.09C377.82 65.47 371.9 62.15 364.12 60.13C356.33 58.11 343.63 57.09 321.54 57.09C319.27 57.09 307.93 57.09 287.5 57.09L287.5 232.74L342.78 232.74C355.52 232.34 363.7 231.75 367.32 230.95Z" id="a4ov9rRGQm"></path><path d="M464.76 204.7C463.92 206.93 460.24 208.06 457.46 208.06C454.74 208.06 452.93 206.98 452.01 204.81C451.09 202.65 450.64 197.72 450.64 190C450.64 185.36 450.64 148.22 450.64 143.58C450.64 135.58 451.04 130.59 451.85 128.6C452.65 126.63 454.41 125.63 457.13 125.63C459.91 125.63 463.64 126.76 464.6 129.03C465.55 131.3 466.03 136.15 466.03 143.58C466.03 146.58 466.03 161.58 466.03 188.59C465.74 197.84 465.32 203.21 464.76 204.7ZM406.68 231.21L447.76 231.21C449.47 224.5 450.41 220.77 450.6 220.02C454.32 224.52 458.41 227.9 462.9 230.14C467.37 232.39 474.06 233.51 479.24 233.51C486.45 233.51 492.67 231.62 497.92 227.83C503.16 224.05 506.5 219.57 507.92 214.42C509.34 209.26 510.05 201.42 510.05 190.88C510.05 185.95 510.05 146.53 510.05 141.6C510.05 131 509.81 124.08 509.34 120.83C508.87 117.58 507.47 114.27 505.14 110.88C502.81 107.49 499.42 104.86 494.98 102.98C490.54 101.1 485.3 100.16 479.26 100.16C474.01 100.16 467.29 101.21 462.81 103.28C458.34 105.35 454.28 108.49 450.64 112.7C450.64 108.89 450.64 89.85 450.64 55.56L406.68 55.56L406.68 231.21Z" id="fk968BpsX"></path></defs><g><g><g><use xlink:href="#d1pwhf9wy2" opacity="1" fill="#f6c700" fill-opacity="1"></use><g><use xlink:href="#d1pwhf9wy2" opacity="1" fill-opacity="0" stroke="#000000" stroke-width="1" stroke-opacity="0"></use></g></g><g><use xlink:href="#g5jjnq26yS" opacity="1" fill="#000000" fill-opacity="1"></use><g><use xlink:href="#g5jjnq26yS" opacity="1" fill-opacity="0" stroke="#000000" stroke-width="1" stroke-opacity="0"></use></g></g><g><use xlink:href="#i3Prh1JpXt" opacity="1" fill="#000000" fill-opacity="1"></use><g><use xlink:href="#i3Prh1JpXt" opacity="1" fill-opacity="0" stroke="#000000" stroke-width="1" stroke-opacity="0"></use></g></g><g><use xlink:href="#a4ov9rRGQm" opacity="1" fill="#000000" fill-opacity="1"></use><g><use xlink:href="#a4ov9rRGQm" opacity="1" fill-opacity="0" stroke="#000000" stroke-width="1" stroke-opacity="0"></use></g></g><g><use xlink:href="#fk968BpsX" opacity="1" fill="#000000" fill-opacity="1"></use><g><use xlink:href="#fk968BpsX" opacity="1" fill-opacity="0" stroke="#000000" stroke-width="1" stroke-opacity="0"></use></g></g></g></g></svg>

After

Width:  |  Height:  |  Size: 3.9 KiB

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="145" height="140"><path fill="#0fc755" d="M47.4 35.342c-13.607-7.935-12.32-25.203 2.097-31.88 26.124-6.531 29.117 13.78 22.652 30.412-6.542 24.11 18.095 23.662 19.925 10.067 3.605-18.412 19.394-26.695 31.67-16.359 12.598 12.135 7.074 36.581-17.827 34.187-16.03-1.545-19.552 19.585.839 21.183 32.228 1.915 42.49 22.167 31.04 35.865-15.993 15.15-37.691-4.439-45.512-19.505-6.8-9.307-17.321.11-13.423 6.502 12.983 19.465 2.923 31.229-10.906 30.62-13.37-.85-20.96-9.06-13.214-29.15 3.897-12.481-8.595-15.386-16.57-5.45-11.707 19.61-28.865 13.68-33.976 4.19-3.243-7.621-2.921-25.846 24.119-23.696 16.688 4.137 11.776-12.561-.63-13.633-9.245-.443-30.501-7.304-22.86-24.54 7.34-11.056 24.958-11.768 33.348 6.293 3.037 4.232 8.361 11.042 18.037 5.033 3.51-5.197 1.21-13.9-8.809-20.135z"/></svg>

After

Width:  |  Height:  |  Size: 833 B

View file

@ -0,0 +1,46 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg id="svg3880" xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" xmlns="http://www.w3.org/2000/svg" height="108.75" viewBox="0 0 143.75 108.75" width="143.75" version="1.1" xmlns:cc="http://creativecommons.org/ns#" xmlns:dc="http://purl.org/dc/elements/1.1/">
<metadata id="metadata3886">
<rdf:RDF>
<cc:Work rdf:about="">
<dc:format>image/svg+xml</dc:format>
<dc:type rdf:resource="http://purl.org/dc/dcmitype/StillImage"/>
<dc:title/>
</cc:Work>
</rdf:RDF>
</metadata>
<g id="g3998" transform="matrix(.0042962 .96345 -.96345 .0042962 135.21 .16894)"></g>
<g id="layer4">
<path id="path3462" d="m96.641 2.9657c28.149 1.101 27.459 97.814 0.825 97.194l-74.45-9.973c-25.51-7.211-25.922-69.313-0.534-76.178z" fill="#07a23b"/>
<g fill="#fff">
<path id="path3505" d="m85.419 8.8789-63.171 8.9751c-4.2681 3.3648-6.9679 8.2192-7.5687 9.3296l65.017-6.963c1.3226-3.3762 4.3015-9.5395 5.7202-11.342z"/>
<path id="path3551" d="m78.042 28.008-65.329 5.5498c-2.494 4.757-3.169 10.65-3.3147 15.17l66.739-1.5147c0.0074-3.2891 0.55003-13.318 1.9052-19.206z"/>
<path id="path3553" d="m85.595 94.456-63.251-8.403c-4.2975-3.326-7.0398-8.1557-7.6503-9.2607l65.082 6.3737c1.3522 3.3644 4.3852 9.4999 5.8189 11.29z"/>
<path id="path3555" d="m78.051 75.394-65.375-4.957c-2.536-4.734-3.2627-10.621-3.4481-15.14l66.749 0.9101c0.03629 3.2889 0.66694 13.312 2.0737 19.188z"/>
</g>
</g>
<g id="layer5">
<path id="path4031" d="m100.36 10.836c-13.099 0.685-19.878 48.223-11.732 71.195l21.342-4.561c8.39-24.044 1.28-66.986-9.61-66.634z" fill="#03621e"/>
</g>
<g id="layer6" fill="#fdd600">
<path id="path4136" d="m99.087 78.942a6.1255 6.1255 0 0 1 -6.1238 6.1255 6.1255 6.1255 0 0 1 -6.1273 -6.122 6.1255 6.1255 0 0 1 6.1202 -6.1291 6.1255 6.1255 0 0 1 6.1309 6.1184"/>
<path id="path4174" d="m112.96 75.406a5.5952 5.5952 0 0 1 -5.5936 5.5952 5.5952 5.5952 0 0 1 -5.5968 -5.592 5.5952 5.5952 0 0 1 5.5903 -5.5985 5.5952 5.5952 0 0 1 5.6001 5.5887"/>
<path id="path4176" d="m120.39 74.743a4.9323 4.9323 0 0 1 -4.9309 4.9323 4.9323 4.9323 0 0 1 -4.9337 -4.9294 4.9323 4.9323 0 0 1 4.928 -4.9352 4.9323 4.9323 0 0 1 4.9366 4.9266"/>
<path id="path4178" d="m124.1 78.942a3.7391 3.7391 0 0 1 -3.738 3.7391 3.7391 3.7391 0 0 1 -3.7402 -3.7369 3.7391 3.7391 0 0 1 3.7358 -3.7412 3.7391 3.7391 0 0 1 3.7423 3.7347"/>
<path id="path4180" d="m131.08 83.14a5.0207 5.0207 0 0 1 -5.0192 5.0207 5.0207 5.0207 0 0 1 -5.0222 -5.0178 5.0207 5.0207 0 0 1 5.0163 -5.0236 5.0207 5.0207 0 0 1 5.0251 5.0148"/>
<path id="path4182" d="m135.86 91.67a5.5952 5.5952 0 0 1 -5.5936 5.5952 5.5952 5.5952 0 0 1 -5.5968 -5.592 5.5952 5.5952 0 0 1 5.5903 -5.5985 5.5952 5.5952 0 0 1 5.6001 5.5887"/>
<path id="path4184" d="m140.36 97.327a3.9158 3.9158 0 0 1 -3.9147 3.9158 3.9158 3.9158 0 0 1 -3.917 -3.9136 3.9158 3.9158 0 0 1 3.9124 -3.9181 3.9158 3.9158 0 0 1 3.9192 3.9113"/>
<path id="path4186" d="m140.45 99.625a4.181 4.181 0 0 1 -4.1798 4.181 4.181 4.181 0 0 1 -4.1822 -4.1786 4.181 4.181 0 0 1 4.1773 -4.1834 4.181 4.181 0 0 1 4.1846 4.1761"/>
<path id="path4188" d="m134.44 100.55a4.8439 4.8439 0 0 1 -4.8425 4.8439 4.8439 4.8439 0 0 1 -4.8453 -4.8411 4.8439 4.8439 0 0 1 4.8397 -4.8467 4.8439 4.8439 0 0 1 4.8481 4.8383"/>
<path id="path4190" d="m126.84 100.24a4.0042 4.0042 0 0 1 -4.0031 4.0042 4.0042 4.0042 0 0 1 -4.0054 -4.0019 4.0042 4.0042 0 0 1 4.0007 -4.0065 4.0042 4.0042 0 0 1 4.0077 3.9996"/>
<path id="path4192" d="m125.43 97.636a5.1091 5.1091 0 0 1 -5.1076 5.1091 5.1091 5.1091 0 0 1 -5.1106 -5.1061 5.1091 5.1091 0 0 1 5.1046 -5.112 5.1091 5.1091 0 0 1 5.1135 5.1031"/>
<path id="path4194" d="m117.12 98.078a5.1091 5.1091 0 0 1 -5.1076 5.1091 5.1091 5.1091 0 0 1 -5.1106 -5.1061 5.1091 5.1091 0 0 1 5.1046 -5.112 5.1091 5.1091 0 0 1 5.1135 5.1031"/>
<path id="path4196" d="m110.49 97.459a3.6065 3.6065 0 0 1 -3.6054 3.6065 3.6065 3.6065 0 0 1 -3.6075 -3.6044 3.6065 3.6065 0 0 1 3.6033 -3.6086 3.6065 3.6065 0 0 1 3.6096 3.6023"/>
<path id="path4198" d="m105.72 96.929a4.0484 4.0484 0 0 1 -4.0472 4.0484 4.0484 4.0484 0 0 1 -4.0496 -4.0461 4.0484 4.0484 0 0 1 4.0449 -4.0508 4.0484 4.0484 0 0 1 4.052 4.0437"/>
<path id="path4200" d="m94.71 80.271c2.1568-1.7217 5.4319-2.8842 9.5881-3.6062l11.579 0.61872 15.203 13.612-2.0329 9.6343-27.047-2.5633-4.7288 1.4584-11.183-17.899z"/>
</g>
<g id="layer7">
<path id="path4203" d="m85.913 71.627c3.2472 12.036 7.0507 22.57 12.64 28.284l-9.9879-1.591s-5.5685-25.456-4.8614-25.544c0.70711-0.08839 2.2097-1.149 2.2097-1.149z" fill="#09a339"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 4.6 KiB

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 185.04 133.4"><defs><style>.cls-1{fill:url(#linear-gradient);}</style><linearGradient id="linear-gradient" y1="66.7" x2="185.04" y2="66.7" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#90cea1"/><stop offset="0.56" stop-color="#3cbec9"/><stop offset="1" stop-color="#00b3e5"/></linearGradient></defs><title>Asset 4</title><g id="Layer_2" data-name="Layer 2"><g id="Layer_1-2" data-name="Layer 1"><path class="cls-1" d="M51.06,66.7h0A17.67,17.67,0,0,1,68.73,49h-.1A17.67,17.67,0,0,1,86.3,66.7h0A17.67,17.67,0,0,1,68.63,84.37h.1A17.67,17.67,0,0,1,51.06,66.7Zm82.67-31.33h32.9A17.67,17.67,0,0,0,184.3,17.7h0A17.67,17.67,0,0,0,166.63,0h-32.9A17.67,17.67,0,0,0,116.06,17.7h0A17.67,17.67,0,0,0,133.73,35.37Zm-113,98h63.9A17.67,17.67,0,0,0,102.3,115.7h0A17.67,17.67,0,0,0,84.63,98H20.73A17.67,17.67,0,0,0,3.06,115.7h0A17.67,17.67,0,0,0,20.73,133.37Zm83.92-49h6.25L125.5,49h-8.35l-8.9,23.2h-.1L99.4,49H90.5Zm32.45,0h7.8V49h-7.8Zm22.2,0h24.95V77.2H167.1V70h15.35V62.8H167.1V56.2h16.25V49h-24ZM10.1,35.4h7.8V6.9H28V0H0V6.9H10.1ZM39,35.4h7.8V20.1H61.9V35.4h7.8V0H61.9V13.2H46.75V0H39Zm41.25,0h25V28.2H88V21h15.35V13.8H88V7.2h16.25V0h-24Zm-79,49H9V57.25h.1l9,27.15H24l9.3-27.15h.1V84.4h7.8V49H29.45l-8.2,23.1h-.1L13,49H1.2Zm112.09,49H126a24.59,24.59,0,0,0,7.56-1.15,19.52,19.52,0,0,0,6.35-3.37,16.37,16.37,0,0,0,4.37-5.5A16.91,16.91,0,0,0,146,115.8a18.5,18.5,0,0,0-1.68-8.25,15.1,15.1,0,0,0-4.52-5.53A18.55,18.55,0,0,0,133.07,99,33.54,33.54,0,0,0,125,98H113.29Zm7.81-28.2h4.6a17.43,17.43,0,0,1,4.67.62,11.68,11.68,0,0,1,3.88,1.88,9,9,0,0,1,2.62,3.18,9.87,9.87,0,0,1,1,4.52,11.92,11.92,0,0,1-1,5.08,8.69,8.69,0,0,1-2.67,3.34,10.87,10.87,0,0,1-4,1.83,21.57,21.57,0,0,1-5,.55H121.1Zm36.14,28.2h14.5a23.11,23.11,0,0,0,4.73-.5,13.38,13.38,0,0,0,4.27-1.65,9.42,9.42,0,0,0,3.1-3,8.52,8.52,0,0,0,1.2-4.68,9.16,9.16,0,0,0-.55-3.2,7.79,7.79,0,0,0-1.57-2.62,8.38,8.38,0,0,0-2.45-1.85,10,10,0,0,0-3.18-1v-.1a9.28,9.28,0,0,0,4.43-2.82,7.42,7.42,0,0,0,1.67-5,8.34,8.34,0,0,0-1.15-4.65,7.88,7.88,0,0,0-3-2.73,12.9,12.9,0,0,0-4.17-1.3,34.42,34.42,0,0,0-4.63-.32h-13.2Zm7.8-28.8h5.3a10.79,10.79,0,0,1,1.85.17,5.77,5.77,0,0,1,1.7.58,3.33,3.33,0,0,1,1.23,1.13,3.22,3.22,0,0,1,.47,1.82,3.63,3.63,0,0,1-.42,1.8,3.34,3.34,0,0,1-1.13,1.2,4.78,4.78,0,0,1-1.57.65,8.16,8.16,0,0,1-1.78.2H165Zm0,14.15h5.9a15.12,15.12,0,0,1,2.05.15,7.83,7.83,0,0,1,2,.55,4,4,0,0,1,1.58,1.17,3.13,3.13,0,0,1,.62,2,3.71,3.71,0,0,1-.47,1.95,4,4,0,0,1-1.23,1.3,4.78,4.78,0,0,1-1.67.7,8.91,8.91,0,0,1-1.83.2h-7Z"/></g></g></svg>

After

Width:  |  Height:  |  Size: 2.5 KiB

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="106.25" height="140"><path fill="#fa3106" d="M2.727 39.537c-.471-21.981 100.88-25.089 100.88-.42L92.91 117.56c-7.605 26.86-72.064 27.007-79.07.21z"/><g fill="#fff"><path d="M8.809 51.911l9.018 66.639c3.472 4.515 8.498 7.384 9.648 8.022l-6.921-68.576c-3.498-1.41-9.881-4.579-11.745-6.083zM28.629 59.776l5.453 68.898c4.926 2.652 11.04 3.391 15.73 3.566l-1.258-70.366c-3.414-.024-13.82-.642-19.925-2.098zM97.632 52.121l-9.019 66.643c-3.472 4.515-8.498 7.384-9.647 8.022l6.92-68.583c3.5-1.41 9.882-4.579 11.746-6.082zM77.812 59.986l-5.453 68.898c-4.926 2.652-11.04 3.391-15.73 3.566l1.258-70.366c3.414-.024 13.82-.642 19.925-2.098z"/></g><g fill="#ffd600"><circle cx="13.213" cy="31.252" r="6.816"/><circle cx="22.022" cy="27.687" r="6.607"/><circle cx="30.359" cy="19.769" r="5.925"/><circle cx="34.973" cy="15.155" r="6.03"/><circle cx="45.093" cy="17.095" r="4.929"/><circle cx="51.123" cy="9.597" r="6.24"/><circle cx="61.19" cy="9.387" r="6.554"/><circle cx="67.954" cy="13.635" r="4.929"/><circle cx="76.081" cy="17.672" r="5.925"/><circle cx="78.913" cy="22.706" r="4.352"/><circle cx="83.475" cy="26.324" r="5.243"/><circle cx="88.194" cy="34.398" r="5.768"/><path d="M87.355 35.447c5.79 2.799 1.352-2.213 10.696 2.097-9.574 15.338-74.774 16.892-90.291.525l-.21-3.985L38.59 16.99l22.863-6.606 15.52 9.962z"/></g></svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

View file

@ -1,4 +1,48 @@
{
"requests": {
"sheetPaddingY": { "$type": "dimension", "px": 80, "dp": 40, "source": "index.html:227" },
"sheetPaddingX": { "$type": "dimension", "px": 64, "dp": 32, "source": "index.html:227" },
"sheetGap": { "$type": "dimension", "px": 18, "dp": 9, "source": "index.html:227" },
"sheetTitleLineHeight": { "$type": "dimension", "px": 56, "dp": 28, "source": "index.html:228; locked st-083/st-086: content begins at 154px after 80px padding and 18px gap" },
"sheetTitleBaseline": { "$type": "dimension", "px": 44, "dp": 22, "source": "locked st-083: modal ink-bottom 123px, native baseline boundary 124px minus 80px padding" },
"titleLineHeight": { "$type": "dimension", "px": 39, "dp": 19.5, "source": "index.html:250; 30px font with 1.3 line box" },
"titleBaseline": { "$type": "dimension", "px": 31, "dp": 15.5, "source": "locked st-082/st-086: ink-bottom 45px, 15px text-block inset, baseline boundary 1px below ink" },
"factsLineHeight": { "$type": "dimension", "px": 31.2, "dp": 15.6, "source": "index.html:251; 24px font with 1.3 line box" },
"factsBaseline": { "$type": "dimension", "px": 24, "dp": 12, "source": "locked st-082/st-086: ink-bottom 77px, 15px block inset, 39px title box and 1px baseline boundary" },
"stageLineHeight": { "$type": "dimension", "px": 39.6, "dp": 19.8, "source": "index.html:251-252; inline-flex stage within the 28px/1.3 parent strut" },
"stageLabelLineHeight": { "$type": "dimension", "px": 33.8, "dp": 16.9, "source": "index.html:220; 26px font with 1.3 leading" },
"stageBaseline": { "$type": "dimension", "px": 24, "dp": 12, "source": "locked st-082/st-086: ink-bottom 112px, 15px block inset, 39px title, 31.2px facts, 4px margin and 1px baseline boundary" },
"emptyMargin": { "$type": "dimension", "px": 20, "dp": 10, "source": "index.html:1905" },
"rowPaddingY": { "$type": "dimension", "px": 14, "dp": 7, "source": "index.html:247" },
"rowPaddingX": { "$type": "dimension", "px": 18, "dp": 9, "source": "index.html:247" },
"rowGap": { "$type": "dimension", "px": 24, "dp": 12, "source": "index.html:247" },
"listGap": { "$type": "dimension", "px": 6, "dp": 3, "source": "index.html:255" },
"stageMargin": { "$type": "dimension", "px": 4, "dp": 2, "source": "index.html:252" },
"stageGlyph": { "$type": "dimension", "px": 28, "dp": 14, "source": "index.html:221" },
"stageGap": { "$type": "dimension", "px": 10, "dp": 5, "source": "index.html:220" },
"readinessGapX": { "$type": "dimension", "px": 22, "dp": 11, "source": "index.html:312" },
"readinessGapY": { "$type": "dimension", "px": 10, "dp": 5, "source": "index.html:312" },
"readinessMargin": { "$type": "dimension", "px": 8, "dp": 4, "source": "index.html:312" },
"readinessGlyph": { "$type": "dimension", "px": 26, "dp": 13, "source": "index.html:314" },
"chevron": { "$type": "dimension", "px": 30, "dp": 15, "source": "index.html:317" },
"chevronAlpha": { "$type": "number", "$value": 0.6, "source": "index.html:317" },
"receiptPaddingY": { "$type": "dimension", "px": 28, "dp": 14, "source": "index.html:243" },
"receiptPaddingX": { "$type": "dimension", "px": 30, "dp": 15, "source": "index.html:243" },
"receiptGap": { "$type": "dimension", "px": 10, "dp": 5, "source": "index.html:243" },
"receiptGlyph": { "$type": "dimension", "px": 36, "dp": 18, "source": "index.html:245" },
"buttonMargin": { "$type": "dimension", "px": 12, "dp": 6, "source": "index.html:241" },
"skeletonTitleWidth": { "$type": "dimension", "px": 360, "dp": 180, "source": "index.html:1902" },
"skeletonTitleHeight": { "$type": "dimension", "px": 34, "dp": 17, "source": "index.html:1902" },
"skeletonSubtitleWidth": { "$type": "dimension", "px": 240, "dp": 120, "source": "index.html:1902" },
"skeletonSubtitleHeight": { "$type": "dimension", "px": 26, "dp": 13, "source": "index.html:1902" },
"skeletonGap": { "$type": "dimension", "px": 12, "dp": 6, "source": "index.html:1902" },
"ratingsGap": { "$type": "dimension", "px": 20, "dp": 10, "source": "index.html:156" },
"ratingsEntryGap": { "$type": "dimension", "px": 8, "dp": 4, "source": "index.html:157" },
"shadowBlur": { "$type": "dimension", "px": 80, "dp": 40, "source": "index.html:227" },
"shadowOffset": { "$type": "dimension", "px": -30, "dp": -15, "source": "index.html:227" },
"shadowColor": { "$type": "color", "$value": "#00000080", "source": "index.html:227" },
"ratingsWordmarkRatio": { "$type": "number", "$value": 1.8, "source": "index.html:160" }
},
"shell": {
"navLabelFade": {
"$type": "duration",

View file

@ -0,0 +1,23 @@
// SPDX-License-Identifier: GPL-2.0-only
package org.justinvisser.wolfskin.design
import org.json.JSONObject
/** Debug assets and native screenshots consume the same projection of the frozen prototype. */
fun readComingInFixture(json: String, poster: (String) -> Any?): List<ComingInRequest> {
val items = JSONObject(json).getJSONArray("items")
return List(items.length()) { index ->
val item = items.getJSONObject(index)
val scores = item.getJSONArray("ratings")
ComingInRequest(
id = item.getString("id"), mediaType = item.getString("mediaType"), tmdbId = item.getLong("tmdbId"),
title = item.getString("title"), requesterKey = item.getString("requesterKey"), requesterName = item.getString("requesterName"),
actorKey = item.getString("actorKey"), actorName = item.getString("actorName"), stage = ComingInStage.valueOf(item.getString("stage")),
poster = poster(item.getString("poster")),
ratings = List(scores.length()) { scoreIndex ->
val score = scores.getJSONObject(scoreIndex)
ComingInRating(score.getString("mark"), score.getDouble("score"), score.getBoolean("percentage"))
},
)
}
}

View file

@ -0,0 +1,16 @@
// SPDX-License-Identifier: GPL-2.0-only
package org.justinvisser.wolfskin.design
import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.FirstBaseline
import androidx.compose.ui.layout.layout
import androidx.compose.ui.unit.Dp
/** A CSS line box has its own height and baseline, independent of native leading rounding. */
internal fun Modifier.comingInLineBox(height: Dp, baseline: Dp): Modifier = layout { measurable, constraints ->
val text = measurable.measure(constraints.copy(minHeight = 0))
val baselineOffset = baseline.roundToPx() - text[FirstBaseline]
layout(text.width, height.roundToPx()) {
text.placeRelative(0, baselineOffset)
}
}

View file

@ -0,0 +1,51 @@
// SPDX-License-Identifier: GPL-2.0-only
package org.justinvisser.wolfskin.design
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.width
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.painterResource
import androidx.compose.foundation.background
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.ui.draw.clip
import androidx.compose.ui.layout.ContentScale
import androidx.tv.material3.Text
import java.util.Locale
import org.justinvisser.wolfskin.design.WolfskinTokens.Requests as T
/** Percentages remain whole; ten-point scores use the television's locale. */
fun formatComingInRating(rating: ComingInRating, locale: Locale = Locale.getDefault()): String =
if (rating.percentage) "${rating.score.toInt()}%" else String.format(locale, "%.1f", rating.score)
/** This row always displays the full supplier set rather than the card plate's shortened prefix. */
@Composable
fun ComingInRatings(ratings: List<ComingInRating>, color: Color) {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(T.ratingsGap)) {
ratings.forEach { rating ->
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(T.ratingsEntryGap)) {
val mark = when (rating.mark) {
"fresh" -> R.drawable.coming_in_rating_fresh
"rotten" -> R.drawable.coming_in_rating_rotten
"upright" -> R.drawable.coming_in_rating_upright
"spilled" -> R.drawable.coming_in_rating_spilled
"imdb" -> R.drawable.coming_in_rating_imdb
else -> R.drawable.coming_in_rating_tmdb
}
val wide = rating.mark == "imdb" || rating.mark == "tmdb"
Image(painterResource(mark), contentDescription = rating.mark,
modifier = Modifier.height(T.readinessGlyph).width(T.readinessGlyph * if (wide) T.ratingsWordmarkRatio else 1f)
.clip(ratingMarkShape).background(WolfskinTokens.Color.surface3),
contentScale = ContentScale.Fit)
Text(formatComingInRating(rating), style = ComingInTypography.rating, color = color)
}
}
}
}
private val ratingMarkShape = RoundedCornerShape(WolfskinTokens.Radius.thumb)

View file

@ -0,0 +1,170 @@
// SPDX-License-Identifier: GPL-2.0-only
package org.justinvisser.wolfskin.design
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.clipToBounds
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.draw.drawWithCache
import androidx.compose.ui.graphics.drawscope.drawIntoCanvas
import androidx.compose.ui.graphics.nativeCanvas
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.input.pointer.PointerEventType
import androidx.compose.ui.input.pointer.PointerEventPass
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.text.style.TextOverflow
import androidx.tv.material3.Text
import coil3.compose.AsyncImage
import coil3.compose.AsyncImagePainter
import androidx.compose.runtime.mutableStateOf
import org.justinvisser.wolfskin.design.WolfskinTokens.Color as C
import org.justinvisser.wolfskin.design.WolfskinTokens.Layout as L
import org.justinvisser.wolfskin.design.WolfskinTokens.Requests as T
/** The overlay intercepts pointer input as well as the shell's virtual D-pad focus. */
@Composable
fun ComingInSheet(shell: ShellState, modifier: Modifier = Modifier) {
val state = shell.requests
val data = state.snapshot
LaunchedEffect(state.focusedId, data.signedIn, data.status) {
state.awaitFocusedRatings()
}
Box(modifier.fillMaxSize().background(C.sheetScrim).clickable { shell.closeRequests() }.testTag("coming-in-scrim"))
Column(modifier.fillMaxHeight().width(L.sheetWidth)
.offset(x = WolfskinTokens.Shell.stageWidth - L.sheetWidth)
.drawWithCache {
val panel = android.graphics.Paint(android.graphics.Paint.ANTI_ALIAS_FLAG).apply {
color = C.surface.toArgb()
setShadowLayer(T.shadowBlur.toPx(), T.shadowOffset.toPx(), 0f, T.shadowColor.toArgb())
}
onDrawBehind {
drawIntoCanvas { it.nativeCanvas.drawRect(0f, 0f, size.width, size.height, panel) }
}
}.pointerInput(Unit) {
awaitPointerEventScope {
while (true) awaitPointerEvent(PointerEventPass.Final).changes.forEach { it.consume() }
}
}
.padding(horizontal = T.sheetPaddingX, vertical = T.sheetPaddingY)
.testTag("coming-in"), verticalArrangement = Arrangement.spacedBy(T.sheetGap)) {
Text("Coming in", Modifier.comingInLineBox(T.sheetTitleLineHeight, T.sheetTitleBaseline), style = ComingInTypography.title, color = C.fg)
when {
data.status == ComingInStatus.Loading && data.items.isEmpty() -> {
LazyColumn(verticalArrangement = Arrangement.spacedBy(T.listGap)) {
items(3, key = { "request-skeleton-$it" }, contentType = { "skeleton" }) { ComingInSkeleton() }
}
SheetButton("Close") { shell.closeRequests() }
}
data.status == ComingInStatus.Unavailable && data.items.isEmpty() -> {
Column(Modifier.fillMaxWidth().background(C.listRowRest, RoundedCornerShape(WolfskinTokens.Radius.panel))
.padding(horizontal = T.receiptPaddingX, vertical = T.receiptPaddingY),
verticalArrangement = Arrangement.spacedBy(T.receiptGap)) {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(T.rowPaddingY)) {
Glyph("alert", C.warn, Modifier.size(T.receiptGlyph))
Text("Requests unavailable", style = ComingInTypography.stateTitle, color = C.warn)
}
Text("Conductarr didn’t answer.", style = ComingInTypography.body, color = C.muted)
}
SheetButton("Try again", primary = true) { shell.retryRequests() }
}
data.items.isEmpty() -> Text("Nothing coming in.", Modifier.padding(top = T.emptyMargin), style = ComingInTypography.body, color = C.muted)
else -> {
if (data.partial) Text("Some requests may be missing: Conductarr answered only in part.", style = ComingInTypography.caption, color = C.warn)
val start = state.windowStart
val visibleRows = remember(data, start, state.windowSize) {
data.ordered.subList(start, (start + state.windowSize).coerceAtMost(data.ordered.size))
}
LazyColumn(Modifier.clipToBounds(), verticalArrangement = Arrangement.spacedBy(T.listGap)) {
itemsIndexed(visibleRows,
key = { _, item -> item.id }, contentType = { _, _ -> "request" }) { _, item ->
ComingInRow(item, shell.person, item.id == state.focusedId, state.expanded && item.id == state.focusedId,
state.ratingsVisible && data.signedIn && item.id == state.focusedId,
onFocus = { state.focus(item.id) }, onClick = { state.focus(item.id); shell.activateRequest() })
}
}
}
}
}
}
@Composable
private fun SheetButton(label: String, primary: Boolean = false, onClick: () -> Unit) {
Box(Modifier.padding(top = T.buttonMargin)) { PickerButton(label, true, primary, onClick) }
}
@Composable
private fun ComingInSkeleton() {
Row(Modifier.fillMaxWidth().padding(horizontal = T.rowPaddingX, vertical = T.rowPaddingY),
verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(T.rowGap)) {
Box(Modifier.size(L.thumbWidth, L.thumbHeight).background(C.surface3, RoundedCornerShape(WolfskinTokens.Radius.thumb)))
Column(verticalArrangement = Arrangement.spacedBy(T.skeletonGap)) {
Box(Modifier.size(T.skeletonTitleWidth, T.skeletonTitleHeight).background(C.surface, RoundedCornerShape(WolfskinTokens.Shell.skeletonRadius)))
Box(Modifier.size(T.skeletonSubtitleWidth, T.skeletonSubtitleHeight).background(C.surface, RoundedCornerShape(WolfskinTokens.Radius.card)))
}
}
}
/** Thumbnail slots do not resize when the text or the readiness block grows. */
@OptIn(androidx.compose.ui.ExperimentalComposeUiApi::class, ExperimentalLayoutApi::class)
@Composable
fun ComingInRow(item: ComingInRequest, viewer: ShellPerson, focused: Boolean, expanded: Boolean,
showRatings: Boolean, onFocus: () -> Unit, onClick: () -> Unit) {
val foreground = if (focused) C.onFocus else C.fg
val secondary = if (focused) C.onFocus else C.muted
val context = LocalContext.current
val request = remember(item.poster, context) { LibraryImages.request(context, item.poster, L.thumbWidth, L.thumbHeight) }
val posterReady = remember(request) { mutableStateOf(false) }
Row(Modifier.fillMaxWidth().focusScale(focused, WolfskinTokens.Focus.scaleRow)
.background(if (focused) C.fg else Color.Transparent, RoundedCornerShape(WolfskinTokens.Radius.panel))
.pointerInput(item.id) {
awaitPointerEventScope {
while (true) if (awaitPointerEvent().type == PointerEventType.Enter) onFocus()
}
}.clickable(onClick = onClick)
.padding(horizontal = T.rowPaddingX, vertical = T.rowPaddingY).testTag("coming-row-${item.id}"),
verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(T.rowGap)) {
AsyncImage(model = request, imageLoader = LibraryImages.loader(context), contentDescription = null,
modifier = Modifier.size(L.thumbWidth, L.thumbHeight).clip(RoundedCornerShape(WolfskinTokens.Radius.thumb))
.background(if (posterReady.value) Color.Transparent else C.surface3),
onState = { posterReady.value = it is AsyncImagePainter.State.Success },
contentScale = androidx.compose.ui.layout.ContentScale.Crop)
Column(Modifier.weight(1f)) {
Text(item.title, Modifier.comingInLineBox(T.titleLineHeight, T.titleBaseline),
style = ComingInTypography.listTitle, color = foreground, maxLines = 1, overflow = TextOverflow.Ellipsis)
Text("for ${item.recipient(viewer)} · by ${item.actor(viewer)}", Modifier.comingInLineBox(T.factsLineHeight, T.factsBaseline),
style = ComingInTypography.caption, color = secondary)
FlowRow(Modifier.padding(top = T.stageMargin).heightIn(min = T.stageLineHeight), horizontalArrangement = Arrangement.spacedBy(T.readinessGlyph)) {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(T.stageGap)) {
Glyph(item.stage.glyph, secondary, Modifier.size(T.stageGlyph))
Text(item.stage.label, Modifier.comingInLineBox(T.stageLabelLineHeight, T.stageBaseline), style = ComingInTypography.label, color = secondary)
}
if (showRatings) ComingInRatings(item.ratings, foreground)
}
if (expanded) FlowRow(Modifier.padding(top = T.readinessMargin),
horizontalArrangement = Arrangement.spacedBy(T.readinessGapX), verticalArrangement = Arrangement.spacedBy(T.readinessGapY)) {
item.stage.readiness.forEachIndexed { index, word ->
Row {
val glyph = when (word) { "Done" -> "check"; "Failed" -> "alert"; "Not yet", "Unknown" -> null; else -> "clock" }
if (glyph != null) Glyph(glyph, secondary, Modifier.size(T.readinessGlyph).alignBy { it.measuredHeight })
Text("${READINESS_NAMES[index]} · $word", Modifier.alignByBaseline(), style = ComingInTypography.readiness, color = secondary)
}
}
}
}
if (focused && !expanded) Glyph("right", foreground.copy(alpha = T.chevronAlpha), Modifier.size(T.chevron))
}
}
private val READINESS_NAMES = listOf("Request", "Download", "Library", "Playback")

View file

@ -0,0 +1,110 @@
// SPDX-License-Identifier: GPL-2.0-only
package org.justinvisser.wolfskin.design
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import kotlinx.coroutines.delay
/** Household acquisition stages, ordered independently from their display wording. */
enum class ComingInStage(val label: String, val rank: Int, val glyph: String, val readiness: List<String>) {
Failed("Failed", 0, "alert", listOf("Done", "Failed", "Not yet", "Not yet")),
Downloading("Downloading", 1, "download", listOf("Done", "Downloading", "Not yet", "Not yet")),
AlmostReady("Almost ready", 1, "download", listOf("Done", "Done", "Being added", "Not yet")),
Checking("Checking", 2, "clock", listOf("Checking", "Unknown", "Not yet", "Not yet")),
Searching("Searching", 2, "clock", listOf("Done", "Searching", "Not yet", "Not yet")),
Waiting("Waiting", 3, "clock", listOf("Waiting", "Not yet", "Not yet", "Not yet")),
Ready("Ready", 4, "check", listOf("Done", "Done", "Done", "Not checked yet")),
}
/** Ratings have already passed the supplier's observed-score and vote checks. */
data class ComingInRating(val mark: String, val score: Double, val percentage: Boolean = false)
/** Requests carry authenticated actor evidence, never the current viewer as a substitute. */
data class ComingInRequest(
val id: String,
val mediaType: String,
val tmdbId: Long,
val title: String,
val requesterKey: String,
val requesterName: String,
val actorKey: String?,
val actorName: String?,
val stage: ComingInStage,
val poster: Any? = null,
val ratings: List<ComingInRating> = emptyList(),
) {
val ratingKey = "$mediaType:$tmdbId"
fun recipient(viewer: ShellPerson): String = if (requesterKey == viewer.key) "you" else requesterName
fun actor(viewer: ShellPerson): String = when {
actorKey == "shared" -> "Shared"
actorKey != null && actorKey == viewer.key -> "you"
else -> actorName ?: "Unknown"
}
}
enum class ComingInStatus { Ready, Loading, Unavailable }
/** The provider retains saved rows when refresh fails; an unavailable empty result is not empty success. */
data class ComingInSnapshot(
val items: List<ComingInRequest> = emptyList(),
val status: ComingInStatus = ComingInStatus.Unavailable,
val partial: Boolean = false,
val signedIn: Boolean = false,
) {
val ordered = items.sortedBy { it.stage.rank }
val openCount: Int get() = items.count { it.stage != ComingInStage.Ready }
}
/** A bounded window and stable request id preserve focus across refreshes and title round trips. */
class ComingInState {
var snapshot by mutableStateOf(ComingInSnapshot())
private set
var focusedId by mutableStateOf<String?>(null)
private set
var expanded by mutableStateOf(false)
private set
var ratingsVisible by mutableStateOf(false)
private set
private val cachedRatings = mutableSetOf<String>()
val index: Int get() = snapshot.ordered.indexOfFirst { it.id == focusedId }.coerceAtLeast(0)
val focused: ComingInRequest? get() = snapshot.ordered.getOrNull(index)
val windowSize: Int get() = if (expanded) 4 else 5
val windowStart: Int get() = (index - 2).coerceIn(0, (snapshot.ordered.size - windowSize).coerceAtLeast(0))
fun update(value: ComingInSnapshot) {
snapshot = value
if (!value.signedIn) cachedRatings.clear()
if (value.ordered.none { it.id == focusedId }) focus(value.ordered.firstOrNull()?.id)
ratingsVisible = value.signedIn && focused?.ratingKey in cachedRatings
}
fun focus(id: String?) {
focusedId = id
expanded = false
ratingsVisible = snapshot.signedIn && focused?.ratingKey in cachedRatings
}
/** Coroutine cancellation and the captured request id prevent stale focus reads from painting. */
suspend fun awaitFocusedRatings() {
val item = focused ?: return
if (!snapshot.signedIn || snapshot.status != ComingInStatus.Ready || item.ratings.isEmpty() || ratingsVisible) return
delay(WolfskinTokens.Motion.ratingDwell.toLong())
if (focusedId != item.id || !snapshot.signedIn || snapshot.status != ComingInStatus.Ready) return
cachedRatings.add(item.ratingKey)
ratingsVisible = true
}
/** Return true only when a row requests a title; every direction remains trapped in the sheet. */
fun key(key: ShellKey): Boolean {
when (key) {
ShellKey.Up -> focus(snapshot.ordered.getOrNull((index - 1).coerceAtLeast(0))?.id)
ShellKey.Down -> focus(snapshot.ordered.getOrNull((index + 1).coerceAtMost(snapshot.ordered.lastIndex))?.id)
ShellKey.Right -> if (focused != null) expanded = true
ShellKey.Left -> expanded = false
ShellKey.Ok -> return focused != null
else -> Unit
}
return false
}
}

View file

@ -0,0 +1,18 @@
// SPDX-License-Identifier: GPL-2.0-only
package org.justinvisser.wolfskin.design
import androidx.compose.ui.text.style.LineHeightStyle
import androidx.compose.ui.text.font.FontWeight
/** CSS line boxes retain their leading even on single lines; trimming changes sheet geometry. */
internal object ComingInTypography {
private val lineBox = LineHeightStyle(LineHeightStyle.Alignment.Center, LineHeightStyle.Trim.None)
val title = WolfskinTokens.Type.sheetTitle.copy(lineHeightStyle = lineBox)
val listTitle = WolfskinTokens.Type.listTitle.copy(lineHeightStyle = lineBox)
val body = WolfskinTokens.Type.body.copy(lineHeightStyle = lineBox)
val label = WolfskinTokens.Type.label.copy(lineHeightStyle = lineBox)
val caption = WolfskinTokens.Type.caption.copy(lineHeightStyle = lineBox)
val stateTitle = WolfskinTokens.Type.stateTitle.copy(lineHeightStyle = lineBox, fontWeight = FontWeight.Bold)
val readiness = caption.copy(fontWeight = FontWeight.Bold)
val rating = WolfskinTokens.Type.label.copy(fontFeatureSettings = "tnum")
}

View file

@ -20,6 +20,9 @@ fun Glyph(name: String, color: Color, modifier: Modifier = Modifier) {
"seerr" -> R.drawable.wolfskin_sparkle
"advanced" -> R.drawable.wolfskin_sort
"alert" -> R.drawable.wolfskin_alert
"clock" -> R.drawable.wolfskin_clock
"check" -> R.drawable.wolfskin_check
"download" -> R.drawable.wolfskin_download
"film" -> R.drawable.wolfskin_film
"eye" -> R.drawable.wolfskin_eye
"down" -> R.drawable.wolfskin_down

View file

@ -1,6 +1,7 @@
package org.justinvisser.wolfskin.design
import android.content.Context
import androidx.compose.ui.unit.Dp
import coil3.ImageLoader
import coil3.SingletonImageLoader
import coil3.disk.DiskCache
@ -26,7 +27,8 @@ object LibraryImages {
.components { add(Interceptor { chain -> reads.withPermit { chain.proceed() } }) }
.crossfade(false).build().also { loader = it }
fun request(context: Context, poster: String): ImageRequest = ImageRequest.Builder(context).data(poster)
.size((WolfskinTokens.Card.posterW.value * 2).toInt(), (WolfskinTokens.Card.posterH.value * 2).toInt())
fun request(context: Context, poster: Any?, width: Dp = WolfskinTokens.Card.posterW, height: Dp = WolfskinTokens.Card.posterH): ImageRequest =
ImageRequest.Builder(context).data(poster)
.size((width.value * 2).toInt(), (height.value * 2).toInt())
.allowRgb565(true).crossfade(false).build()
}

View file

@ -52,7 +52,7 @@ val SETTINGS_GROUPS = listOf(
SettingsGroup("advanced", "Advanced settings", "Ratings display · Backdrop style · Playback backend (ExoPlayer, MPV) · Max resolution and bitrate · Skip segments · Logs and image cache"),
)
/** Requests is intentionally a no-data Wolfskin entry until the Requests slice. */
/** The household request count shares the sheet's snapshot and excludes Ready. */
@Composable
fun SettingsList(state: ShellState, modifier: Modifier = Modifier, openRequests: Int? = null) {
val list = state.view.listState
@ -70,14 +70,14 @@ fun SettingsList(state: ShellState, modifier: Modifier = Modifier, openRequests:
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)
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)
}
}
itemsIndexed(SETTINGS_GROUPS, key = { _, group -> group.key }) { index, group ->
Box(Modifier.height(L.row_srow)) {
SettingsRow(group, !state.navFocused && index == state.settingsRow,
SettingsRow(group, !state.navFocused && !state.requestsVisible && index == state.settingsRow,
if (group.key == "requests") openRequests else null,
Modifier.offset(L.safeSide, S.settingsRowTop)) {
state.navFocused = false; state.settingsRow = index; state.key(ShellKey.Ok)

View file

@ -16,6 +16,8 @@ sealed interface ShellAction {
data class HandOff(val group: String) : ShellAction
data object Pop : ShellAction
data object RetryPeople : ShellAction
data object RetryRequests : ShellAction
data class RequestTitle(val request: ComingInRequest) : ShellAction
}
/** D-pad inputs are shared by Android events and deterministic JVM replays. */
@ -55,6 +57,8 @@ class ShellState(
var peopleStatus by mutableStateOf(PeopleStatus.Ready)
var recoveryIndex by mutableStateOf(0)
var requestsVisible by mutableStateOf(false)
val requests = ComingInState()
private var requestReturnRoute: String? = null
var handOff by mutableStateOf<String?>(null)
var settingsRow by mutableStateOf(0)
private var rootRoute by mutableStateOf("home")
@ -71,6 +75,10 @@ class ShellState(
route = destination
if (destination in tabs) rootRoute = destination
handOff = null
if (requestReturnRoute == destination) {
requestsVisible = true
requestReturnRoute = null
}
}
/** Pointer navigation uses the same transitions as D-pad selection. */
@ -94,7 +102,12 @@ class ShellState(
armedCard = null
}
if (requestsVisible) {
if (key == ShellKey.Back || key == ShellKey.Ok) requestsVisible = false
when {
key == ShellKey.Back -> closeRequests()
key == ShellKey.Ok && requests.snapshot.status == ComingInStatus.Unavailable && requests.snapshot.items.isEmpty() -> retryRequests()
key == ShellKey.Ok && requests.snapshot.status == ComingInStatus.Loading && requests.snapshot.items.isEmpty() -> closeRequests()
requests.key(key) -> activateRequest()
}
return true
}
if (pickerVisible) pickerKey(key) else if (key == ShellKey.Back) back() else if (navFocused) navKey(key) else contentKey(key)
@ -143,6 +156,8 @@ class ShellState(
navFocused = false
navIndex = 0
rootRoute = "home"
requestReturnRoute = null
requestsVisible = false
views[person.key to route] = RememberedView()
onAction(ShellAction.Person(selected))
}
@ -181,6 +196,7 @@ class ShellState(
gearFocused = false
} else {
val target = tabs[navIndex]
requestReturnRoute = null
if (target == route) views[person.key to target] = RememberedView()
route = target
rootRoute = target
@ -189,7 +205,7 @@ class ShellState(
}
} else if (route == "settings") {
val group = SETTINGS_GROUPS[settingsRow].key
if (group == "requests") requestsVisible = true else {
if (group == "requests") openRequests() else {
handOff = group
onAction(ShellAction.HandOff(group))
}
@ -197,8 +213,34 @@ class ShellState(
else contentFocus?.key(ShellKey.Ok)
}
/** Requested buttons may name an existing request without changing its authenticated actor. */
fun openRequests(requestId: String? = null) {
requests.focus(requestId ?: requests.snapshot.ordered.firstOrNull()?.id)
requestsVisible = true
navFocused = false
}
fun closeRequests() { requestsVisible = false }
fun retryRequests() { onAction(ShellAction.RetryRequests) }
fun activateRequest() {
val request = requests.focused ?: return
requestReturnRoute = route
requestsVisible = false
onAction(ShellAction.RequestTitle(request))
}
/** Back never discards a root view, including the card below a focused nav. */
fun back() {
if (requestsVisible) { closeRequests(); return }
val returnRoute = requestReturnRoute
if (returnRoute != null && route != returnRoute) {
route = returnRoute
requestsVisible = true
requestReturnRoute = null
onAction(ShellAction.Pop)
return
}
if (!pickerVisible && !navFocused && contentFocus?.key(ShellKey.Back) == true) return
if (route == "settings") {
route = pickerReturnRoute

View file

@ -82,20 +82,9 @@ fun ShellSurface(
if (state.pickerVisible) {
WhosWatching(state)
} else {
if (state.route == "settings") SettingsList(state, openRequests = settingsOpenRequests) else content(Modifier.fillMaxSize().focusRequester(contentFocus).focusRestorer().focusGroup())
if (state.route == "settings") SettingsList(state, openRequests = settingsOpenRequests ?: state.requests.snapshot.openCount) 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) {
Box(Modifier.fillMaxSize().background(C.sheetScrim)) {
Column(Modifier.offset(x = WolfskinTokens.Shell.stageWidth - L.sheetWidth)
.width(L.sheetWidth).fillMaxSize().background(C.surface).padding(L.safeSide),
verticalArrangement = Arrangement.spacedBy(L.buttonGap)) {
Text("Requests", color = C.fg, style = WolfskinTokens.Type.sheetTitle)
Text("Wolfskin screen", color = C.accentText, style = WolfskinTokens.Type.label)
Text("No request data yet. Conductarr sign-in is not available on this TV.", color = C.muted, style = WolfskinTokens.Type.body)
PickerButton("Close", true, true) { state.requestsVisible = false }
}
}
}
if (state.requestsVisible) ComingInSheet(state)
}
}
}

View file

@ -229,6 +229,50 @@ object WolfskinTokens {
const val requestsPollSeconds = 30.0f
const val comingInVisibleRows = 5.0f
}
object Requests {
val sheetPaddingY = 40.0.dp
val sheetPaddingX = 32.0.dp
val sheetGap = 9.0.dp
val sheetTitleLineHeight = 28.0.dp
val sheetTitleBaseline = 22.0.dp
val titleLineHeight = 19.5.dp
val titleBaseline = 15.5.dp
val factsLineHeight = 15.6.dp
val factsBaseline = 12.0.dp
val stageLineHeight = 19.8.dp
val stageLabelLineHeight = 16.9.dp
val stageBaseline = 12.0.dp
val emptyMargin = 10.0.dp
val rowPaddingY = 7.0.dp
val rowPaddingX = 9.0.dp
val rowGap = 12.0.dp
val listGap = 3.0.dp
val stageMargin = 2.0.dp
val stageGlyph = 14.0.dp
val stageGap = 5.0.dp
val readinessGapX = 11.0.dp
val readinessGapY = 5.0.dp
val readinessMargin = 4.0.dp
val readinessGlyph = 13.0.dp
val chevron = 15.0.dp
const val chevronAlpha = 0.6f
val receiptPaddingY = 14.0.dp
val receiptPaddingX = 15.0.dp
val receiptGap = 5.0.dp
val receiptGlyph = 18.0.dp
val buttonMargin = 6.0.dp
val skeletonTitleWidth = 180.0.dp
val skeletonTitleHeight = 17.0.dp
val skeletonSubtitleWidth = 120.0.dp
val skeletonSubtitleHeight = 13.0.dp
val skeletonGap = 6.0.dp
val ratingsGap = 10.0.dp
val ratingsEntryGap = 4.0.dp
val shadowBlur = 40.0.dp
val shadowOffset = -15.0.dp
val shadowColor = ComposeColor(0x80000000)
const val ratingsWordmarkRatio = 1.8f
}
object Shell {
const val navLabelFade = 200
val zero = 0.0.dp

Binary file not shown.

After

Width:  |  Height:  |  Size: 804 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 813 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 964 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1,023 B

View file

@ -0,0 +1,3 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24">
<path android:pathData="M5,12.5l4.5,4.5L19,7.5" android:fillColor="#00000000" android:strokeColor="#FFFFFFFF" android:strokeWidth="2.8" android:strokeLineCap="round" android:strokeLineJoin="round"/>
</vector>

View file

@ -0,0 +1,3 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24">
<path android:pathData="M20.5,12a8.5,8.5 0,1 1,-17,0a8.5,8.5 0,1 1,17,0M12,7.5V12l3,2" android:fillColor="#00000000" android:strokeColor="#FFFFFFFF" android:strokeWidth="2.2" android:strokeLineCap="round"/>
</vector>

View file

@ -0,0 +1,3 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24">
<path android:pathData="M12,4v11m-5,-5l5,5l5,-5M5,20h14" android:fillColor="#00000000" android:strokeColor="#FFFFFFFF" android:strokeWidth="2.4" android:strokeLineCap="round" android:strokeLineJoin="round"/>
</vector>

View file

@ -0,0 +1,84 @@
// SPDX-License-Identifier: GPL-2.0-only
package org.justinvisser.wolfskin.design
import java.util.Locale
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.launch
import kotlinx.coroutines.test.advanceTimeBy
import kotlinx.coroutines.test.runCurrent
import kotlinx.coroutines.test.runTest
import org.junit.Assert.*
import org.junit.Test
/** The fake coroutine clock proves the production dwell/cache decision without a Compose scheduler. */
@OptIn(ExperimentalCoroutinesApi::class)
class ComingInRatingsGateTest {
private val first = ComingInRequest("first", "movie", 1, "First", "justin", "Justin", "justin", "Justin", ComingInStage.Waiting,
ratings = listOf(ComingInRating("imdb", 6.0)))
private val second = first.copy(id = "second", tmdbId = 2, title = "Second", ratings = listOf(ComingInRating("tmdb", 7.2)))
@Test fun uncachedDwellCancelsOnLeaveAndReentryStartsAFreshRead() = runTest {
val state = ComingInState()
state.update(ComingInSnapshot(listOf(first, second), ComingInStatus.Ready, signedIn = true))
val abandoned = launch { state.awaitFocusedRatings() }
runCurrent()
advanceTimeBy(100)
assertFalse(state.ratingsVisible)
abandoned.cancel()
state.focus(second.id)
val next = launch { state.awaitFocusedRatings() }
runCurrent()
advanceTimeBy(349)
runCurrent()
assertFalse(state.ratingsVisible)
advanceTimeBy(1)
runCurrent()
assertTrue(state.ratingsVisible)
next.join()
state.focus(first.id)
assertFalse(state.ratingsVisible)
launch { state.awaitFocusedRatings() }
runCurrent()
advanceTimeBy(350)
runCurrent()
assertTrue("Returning to the abandoned uncached title must complete a new dwell", state.ratingsVisible)
state.focus(second.id)
assertTrue("A previously read title must be visible on the focus transition itself", state.ratingsVisible)
}
@Test fun signOutClearsCachedVisibilityAndSignInUsesFreshRatingAndLocale() = runTest {
val state = ComingInState()
val snapshot = ComingInSnapshot(listOf(first), ComingInStatus.Ready, signedIn = true)
state.update(snapshot)
launch { state.awaitFocusedRatings() }
runCurrent(); advanceTimeBy(350); runCurrent()
assertTrue(state.ratingsVisible)
state.update(snapshot.copy(signedIn = false))
assertFalse(state.ratingsVisible)
val refreshed = first.copy(ratings = listOf(ComingInRating("imdb", 7.6)))
state.update(snapshot.copy(items = listOf(refreshed)))
assertFalse("Sign-out invalidates the previous session's rating gate", state.ratingsVisible)
launch { state.awaitFocusedRatings() }
runCurrent(); advanceTimeBy(350); runCurrent()
assertTrue(state.ratingsVisible)
val rating = state.focused!!.ratings.single()
assertEquals("7,6", formatComingInRating(rating, Locale.forLanguageTag("nl-NL")))
assertEquals("7.6", formatComingInRating(rating, Locale.ENGLISH))
assertEquals("87%", formatComingInRating(ComingInRating("fresh", 87.9, true), Locale.forLanguageTag("nl-NL")))
}
@Test fun savedOfflineRowsOnlyShowPreviouslyReadRatingsAcrossHouseholdRequests() = runTest {
val state = ComingInState()
val sharedCopy = first.copy(id = "other-request", requesterKey = "shared", requesterName = "Shared")
val online = ComingInSnapshot(listOf(first, second, sharedCopy), ComingInStatus.Ready, signedIn = true)
state.update(online)
launch { state.awaitFocusedRatings() }
runCurrent(); advanceTimeBy(350); runCurrent()
state.update(online.copy(status = ComingInStatus.Unavailable))
state.focus(second.id)
state.awaitFocusedRatings()
assertFalse("An unread title cannot acquire ratings while offline", state.ratingsVisible)
state.focus(sharedCopy.id)
assertTrue("The title cache is shared across request rows for the same media type and TMDB id", state.ratingsVisible)
}
}

View file

@ -0,0 +1,70 @@
// SPDX-License-Identifier: GPL-2.0-only
package org.justinvisser.wolfskin.design
import android.app.Application
import android.view.View
import android.view.WindowManager
import androidx.activity.ComponentActivity
import androidx.compose.foundation.layout.requiredSize
import androidx.compose.ui.Modifier
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.test.onRoot
import com.github.takahirom.roborazzi.captureRoboImage
import java.io.File
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
import org.robolectric.annotation.GraphicsMode
/** Every approved Requests state uses the actual shell overlay at native television density. */
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi")
@GraphicsMode(GraphicsMode.Mode.NATIVE)
class RequestsCaptureTest {
@get:Rule val rule = createAndroidComposeRule<ComponentActivity>()
@Before fun fullscreen() {
rule.activity.window.setFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN, WindowManager.LayoutParams.FLAG_FULLSCREEN)
@Suppress("DEPRECATION")
rule.activity.window.decorView.systemUiVisibility = View.SYSTEM_UI_FLAG_FULLSCREEN or View.SYSTEM_UI_FLAG_HIDE_NAVIGATION or View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY
}
private fun capture(name: String, snapshot: ComingInSnapshot, person: String = "justin", expanded: Boolean = false) {
val state = ShellState(fixturePeople(), person)
state.pickerVisible = false
state.enterDestination("settings")
state.requests.update(snapshot)
state.openRequests()
if (expanded) { state.requests.key(ShellKey.Down); state.requests.key(ShellKey.Right) }
rule.setContent {
WolfskinTheme { ShellSurface(state, Modifier.requiredSize(WolfskinTokens.Shell.stageWidth, WolfskinTokens.Shell.stageHeight)) }
}
rule.mainClock.advanceTimeBy(1000)
rule.waitForIdle()
val directory = File(System.getProperty("wolfskin.captureDirectory"))
directory.mkdirs()
rule.onRoot().captureRoboImage(File(directory, "$name.png").absolutePath)
}
@Test fun st081_empty() = capture("st-081", ComingInSnapshot(status = ComingInStatus.Ready, signedIn = true))
@Test fun st082_stages() = capture("st-082", requestsFixture())
@Test fun st083_unavailable() = capture("st-083", ComingInSnapshot())
@Test fun st084_loading() = capture("st-084", ComingInSnapshot(status = ComingInStatus.Loading))
@Test fun st085_partial() = capture("st-085", requestsFixture().copy(items = requestsFixture().items.take(3), partial = true))
@Test fun st086_readiness() = capture("st-086", requestsFixture(), expanded = true)
@Test fun st088_sharedActor() = capture("st-088", requestsFixture(shared = true), person = "shared")
@Test fun st111_focusedRatings() = capture("st-111", requestsFixture(ratings = true))
}
/** Shared actor and stage fixtures are projections, never network calls or synthetic UI redraws. */
fun requestsFixture(shared: Boolean = false, ratings: Boolean = false): ComingInSnapshot {
val source = RequestsCaptureTest::class.java.getResourceAsStream("/wolfskin-requests.json")!!.bufferedReader().use { it.readText() }
val items = readComingInFixture(source) { name ->
RequestsCaptureTest::class.java.getResourceAsStream("/wolfskin-requests/$name")!!.use { it.readBytes() }
}
val selected = when { shared -> items.filter { it.id == "x7" }; ratings -> items.filter { it.id == "x8" }; else -> items.take(6) }
return ComingInSnapshot(selected, ComingInStatus.Ready, signedIn = true)
}

View file

@ -0,0 +1,150 @@
// SPDX-License-Identifier: GPL-2.0-only
package org.justinvisser.wolfskin.design
import android.app.Application
import androidx.compose.ui.input.key.Key
import androidx.compose.ui.test.*
import androidx.compose.ui.test.junit4.createComposeRule
import java.util.Locale
import org.json.JSONArray
import org.junit.Assert.*
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
/** Key replays exercise real shell handling, including trapped overlays and title-return memory. */
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi")
class RequestsKeysTest {
@get:Rule val rule = createComposeRule()
private fun mount(state: ShellState) {
rule.setContent { WolfskinTheme { ShellSurface(state) } }
rule.mainClock.advanceTimeByFrame()
}
private fun send(first: Key, second: Key? = null, third: Key? = null, fourth: Key? = null) {
listOfNotNull(first, second, third, fourth).forEach { key ->
rule.onNodeWithTag("wolfskin-shell").performKeyInput { pressKey(key) }
rule.mainClock.advanceTimeByFrame()
}
}
private fun sheet(snapshot: ComingInSnapshot = requestsFixture(), actions: (ShellAction) -> Unit = {}): ShellState =
ShellState(fixturePeople(), "justin", actions).apply {
pickerVisible = false
enterDestination("settings")
requests.update(snapshot)
openRequests()
}
@Test fun approvedNinePressPathUsesRealShellFocus() {
val paths = RequestsKeysTest::class.java.getResourceAsStream("/requests-press-paths.json")!!.bufferedReader().use { JSONArray(it.readText()) }
val keys = mapOf("OK" to Key.DirectionCenter, "UP" to Key.DirectionUp, "LEFT" to Key.DirectionLeft,
"RIGHT" to Key.DirectionRight, "DOWN" to Key.DirectionDown)
val state = ShellState(fixturePeople(), "justin")
state.requests.update(requestsFixture())
mount(state)
val path = paths.getJSONObject(0)
val presses = path.getJSONArray("keys")
assertEquals(9, path.getInt("presses"))
assertEquals(path.getInt("presses"), presses.length())
for (index in 0 until presses.length()) send(keys.getValue(presses.getString(index)))
rule.onNodeWithTag("coming-in").assertExists()
rule.onNodeWithText("Requests · 5 open").assertExists()
assertEquals("settings", state.route)
assertEquals("x5", state.requests.focusedId)
assertFalse(state.navFocused)
}
@Test fun readinessWindowAndTitleRoundTripPreserveRequest() {
lateinit var state: ShellState
state = sheet { action -> if (action is ShellAction.RequestTitle) state.enterDestination("request-title/${action.request.tmdbId}") }
mount(state)
send(Key.DirectionDown, Key.DirectionRight)
rule.onNodeWithText("Download · Downloading").assertExists()
assertEquals(4, state.requests.windowSize)
assertEquals("x1", state.requests.focusedId)
send(Key.DirectionLeft)
rule.onNodeWithText("Download · Downloading").assertDoesNotExist()
send(Key.DirectionDown, Key.DirectionDown, Key.DirectionDown, Key.DirectionDown)
assertEquals("x6", state.requests.focusedId)
assertEquals(1, state.requests.windowStart)
send(Key.DirectionDown)
assertEquals("x6", state.requests.focusedId)
send(Key.DirectionCenter)
assertFalse(state.requestsVisible)
assertTrue(state.route.startsWith("request-title/"))
send(Key.Back)
rule.onNodeWithTag("coming-row-x6").assertExists()
assertEquals("x6", state.requests.focusedId)
assertTrue(state.requestsVisible)
send(Key.Back)
assertFalse(state.requestsVisible)
assertEquals("settings", state.route)
assertEquals(0, state.settingsRow)
}
@Test fun emptyAndLoadingTrapDirectionsWhileUnavailableRetries() {
var retries = 0
val state = sheet(ComingInSnapshot(status = ComingInStatus.Loading)) { if (it == ShellAction.RetryRequests) retries++ }
mount(state)
send(Key.DirectionUp, Key.DirectionLeft)
assertFalse(state.navFocused)
assertTrue(state.requestsVisible)
send(Key.DirectionCenter)
assertFalse(state.requestsVisible)
rule.runOnIdle { state.requests.update(ComingInSnapshot()); state.openRequests() }
send(Key.DirectionCenter)
assertEquals(1, retries)
assertTrue(state.requestsVisible)
rule.runOnIdle { state.requests.update(ComingInSnapshot(status = ComingInStatus.Ready)) }
send(Key.DirectionDown, Key.DirectionCenter)
rule.onNodeWithText("Nothing coming in.").assertExists()
assertTrue(state.requestsVisible)
}
@Test fun cachedRatingsRenderInTheTelevisionLocale() {
val original = Locale.getDefault()
try {
Locale.setDefault(Locale.forLanguageTag("nl-NL"))
val state = sheet(requestsFixture(ratings = true))
kotlinx.coroutines.test.runTest { state.requests.awaitFocusedRatings() }
mount(state)
rule.onNodeWithText("7,6").assertExists()
rule.onNodeWithText("87%").assertExists()
rule.runOnIdle { state.requests.update(state.requests.snapshot.copy(signedIn = false)) }
rule.onNodeWithText("7,6").assertDoesNotExist()
rule.onNodeWithText("87%").assertDoesNotExist()
} finally { Locale.setDefault(original) }
}
@Test fun selectingARootAfterARequestTitleDiscardsTheSheetReturnLink() {
lateinit var state: ShellState
state = sheet { action -> if (action is ShellAction.RequestTitle) state.enterDestination("request-title/${action.request.tmdbId}") }
mount(state)
send(Key.DirectionCenter, Key.DirectionUp, Key.DirectionCenter)
assertEquals("home", state.route)
send(Key.Back)
assertFalse("Root Back must focus navigation, not reopen the abandoned sheet", state.requestsVisible)
assertTrue(state.navFocused)
}
@Test fun refreshKeepsStableFocusAndSharedActorNeverBecomesYou() {
val snapshot = requestsFixture()
val state = sheet(snapshot)
mount(state)
send(Key.DirectionDown)
rule.runOnIdle { state.requests.update(snapshot.copy(items = snapshot.items.reversed(), partial = true)) }
assertEquals("x1", state.requests.focusedId)
rule.onNodeWithText("Some requests may be missing: Conductarr answered only in part.").assertExists()
val shared = requestsFixture(shared = true).items.single()
val viewer = fixturePeople().single { it.key == "shared" }
assertEquals("Shared", shared.actor(viewer))
assertEquals("Justin", shared.recipient(viewer))
assertEquals(5, snapshot.openCount)
assertEquals(listOf(0, 1, 1, 2, 3, 4), snapshot.ordered.map { it.stage.rank })
}
}

View file

@ -0,0 +1,21 @@
[
{
"goal": "Round 4 \u00b7 Home hero \u2192 Requests (Coming in) in Settings",
"keys": [
"OK",
"UP",
"LEFT",
"OK",
"RIGHT",
"RIGHT",
"DOWN",
"OK",
"OK"
],
"presses": 9,
"note": "8 from the hero",
"start": "app launch: Who's watching with Justin focused (index.html:357)",
"replayed": "PASS",
"source": "index.html:2152-2174 PRESSES, replayed 2026-11-05"
}
]

View file

@ -0,0 +1,217 @@
{
"source": "Locked Opus fixture/fixture.json; index.html EXAMPLES() pool order",
"items": [
{
"id": "x1",
"mediaType": "tv",
"tmdbId": 11821,
"title": "The Abbott and Costello Show",
"requesterKey": "justin",
"requesterName": "Justin",
"actorKey": "justin",
"actorName": "Justin",
"stage": "Downloading",
"poster": "tv-the-abbott-and-costello-show-1952-poster.jpg",
"ratings": [
{
"mark": "tmdb",
"score": 7.2,
"percentage": false
}
]
},
{
"id": "x2",
"mediaType": "movie",
"tmdbId": 13483,
"title": "Awake",
"requesterKey": "mindel",
"requesterName": "Mindel",
"actorKey": "justin",
"actorName": "Justin",
"stage": "AlmostReady",
"poster": "movie-awake-2007-poster.jpg",
"ratings": [
{
"mark": "rotten",
"score": 23,
"percentage": true
},
{
"mark": "spilled",
"score": 55,
"percentage": true
},
{
"mark": "imdb",
"score": 6.5,
"percentage": false
}
]
},
{
"id": "x3",
"mediaType": "tv",
"tmdbId": 70785,
"title": "Anne with an E",
"requesterKey": "shared",
"requesterName": "Shared",
"actorKey": "mindel",
"actorName": "Mindel",
"stage": "Searching",
"poster": "tv-anne-with-an-e-2017-poster.jpg",
"ratings": [
{
"mark": "fresh",
"score": 63,
"percentage": true
}
]
},
{
"id": "x4",
"mediaType": "movie",
"tmdbId": 99861,
"title": "Avengers: Age of Ultron",
"requesterKey": "justin",
"requesterName": "Justin",
"actorKey": "justin",
"actorName": "Justin",
"stage": "Waiting",
"poster": "movie-avengers-age-of-ultron-2015-poster.jpg",
"ratings": [
{
"mark": "fresh",
"score": 75,
"percentage": true
},
{
"mark": "upright",
"score": 82,
"percentage": true
},
{
"mark": "imdb",
"score": 7.3,
"percentage": false
}
]
},
{
"id": "x5",
"mediaType": "movie",
"tmdbId": 1033148,
"title": "Die My Love",
"requesterKey": "mindel",
"requesterName": "Mindel",
"actorKey": "mindel",
"actorName": "Mindel",
"stage": "Failed",
"poster": "movie-die-my-love-2025-poster.jpg",
"ratings": [
{
"mark": "fresh",
"score": 74,
"percentage": true
},
{
"mark": "spilled",
"score": 45,
"percentage": true
},
{
"mark": "imdb",
"score": 6,
"percentage": false
}
]
},
{
"id": "x6",
"mediaType": "movie",
"tmdbId": 416153,
"title": "Arctic Dogs",
"requesterKey": "shared",
"requesterName": "Shared",
"actorKey": "justin",
"actorName": "Justin",
"stage": "Ready",
"poster": "movie-arctic-dogs-2019-poster.jpg",
"ratings": [
{
"mark": "rotten",
"score": 13,
"percentage": true
},
{
"mark": "upright",
"score": 60,
"percentage": true
},
{
"mark": "imdb",
"score": 4.9,
"percentage": false
}
]
},
{
"id": "x7",
"mediaType": "movie",
"tmdbId": 76203,
"title": "12 Years a Slave",
"requesterKey": "justin",
"requesterName": "Justin",
"actorKey": "shared",
"actorName": "Shared",
"stage": "Waiting",
"poster": "movie-12-years-a-slave-2013-poster.jpg",
"ratings": [
{
"mark": "fresh",
"score": 95,
"percentage": true
},
{
"mark": "upright",
"score": 90,
"percentage": true
},
{
"mark": "imdb",
"score": 8.1,
"percentage": false
}
]
},
{
"id": "x8",
"mediaType": "movie",
"tmdbId": 14069,
"title": "The Girl Who Leapt Through Time",
"requesterKey": "mindel",
"requesterName": "Mindel",
"actorKey": "justin",
"actorName": "Justin",
"stage": "Waiting",
"poster": "movie-the-girl-who-leapt-through-time-2006-poster.jpg",
"ratings": [
{
"mark": "fresh",
"score": 87,
"percentage": true
},
{
"mark": "upright",
"score": 90,
"percentage": true
},
{
"mark": "imdb",
"score": 7.6,
"percentage": false
}
]
}
]
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

View file

@ -127,6 +127,46 @@
--motion-scroll: 180ms;
--motion-ratingDwell: 350ms;
--motion-toast: 3200ms;
--requests-sheetPaddingY: 80.0px;
--requests-sheetPaddingX: 64.0px;
--requests-sheetGap: 18.0px;
--requests-sheetTitleLineHeight: 56.0px;
--requests-sheetTitleBaseline: 44.0px;
--requests-titleLineHeight: 39.0px;
--requests-titleBaseline: 31.0px;
--requests-factsLineHeight: 31.2px;
--requests-factsBaseline: 24.0px;
--requests-stageLineHeight: 39.6px;
--requests-stageLabelLineHeight: 33.8px;
--requests-stageBaseline: 24.0px;
--requests-emptyMargin: 20.0px;
--requests-rowPaddingY: 14.0px;
--requests-rowPaddingX: 18.0px;
--requests-rowGap: 24.0px;
--requests-listGap: 6.0px;
--requests-stageMargin: 4.0px;
--requests-stageGlyph: 28.0px;
--requests-stageGap: 10.0px;
--requests-readinessGapX: 22.0px;
--requests-readinessGapY: 10.0px;
--requests-readinessMargin: 8.0px;
--requests-readinessGlyph: 26.0px;
--requests-chevron: 30.0px;
--requests-receiptPaddingY: 28.0px;
--requests-receiptPaddingX: 30.0px;
--requests-receiptGap: 10.0px;
--requests-receiptGlyph: 36.0px;
--requests-buttonMargin: 12.0px;
--requests-skeletonTitleWidth: 360.0px;
--requests-skeletonTitleHeight: 34.0px;
--requests-skeletonSubtitleWidth: 240.0px;
--requests-skeletonSubtitleHeight: 26.0px;
--requests-skeletonGap: 12.0px;
--requests-ratingsGap: 20.0px;
--requests-ratingsEntryGap: 8.0px;
--requests-shadowBlur: 80.0px;
--requests-shadowOffset: -30.0px;
--requests-shadowColor: #00000080;
--shell-navLabelFade: 200ms;
--shell-zero: 0.0px;
--shell-stageWidth: 1920.0px;

View file

@ -1,5 +1,5 @@
1438f7ca4a2b6f951c9e643ab0afacbf2f7fc98e57905295d518c6225e054de4 generate_tokens.py
1052c79be79e0fb85e64ba5b48a77e7e9dddd114c656e5572f39c0a85e09b9f5 shell-tokens.json
f380580c85d518e70f76991632b1c0be394f2d237c07097b2eec362950a14d20 src/main/java/org/justinvisser/wolfskin/design/WolfskinTokens.kt
900a8d995b74083cdf911fc4055c1c90827384fb1b0f5799c8823a8d0f0e48b2 tokens.css
f61da327af6f6a7fb40c65e2d187957506b72a5b03a760f0f7fba5eca6067818 shell-tokens.json
61ee54eff31304bc352fdf4a295825156566c8e97cba9e5441e9c98d644c9986 src/main/java/org/justinvisser/wolfskin/design/WolfskinTokens.kt
9e4b8a9a21c5275b140c5c4a512f240c901006793b434ac532975be0e5a004ee tokens.css
5b939f1f3eb90b428008fda75720f6e3a4e844c486668931db3d52bffb987b53 tokens.json