Measure native Discover typography and focus boundaries

This commit is contained in:
justinzeus 2026-11-05 17:04:45 +01:00
parent 6f430439a6
commit 90e956b157
7 changed files with 87 additions and 11 deletions

View file

@ -59,8 +59,8 @@ cover the Wolfskin nav.
The independent `:wolfskin-design` module owns the shell components, fixed person The independent `:wolfskin-design` module owns the shell components, fixed person
colours, focus policy, per-person root-view memory, palette and motion. Its 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 `tokens.json` is the copied approved TV-spec source, retaining the locked prototype
SHA and provenance. `shell-tokens.json` adds shell measurements from that same SHA and provenance. `shell-tokens.json` and `discover-tokens.json` add measurements
prototype. Generate the committed Kotlin, CSS and checksum manifest together: from that same prototype. Generate the committed Kotlin, CSS and checksum manifest together:
```sh ```sh
python3 wolfskin-design/generate_tokens.py python3 wolfskin-design/generate_tokens.py
@ -96,6 +96,26 @@ prototype fixture in `app/src/debug/assets/wolfskin-shell.json`; release resourc
do not package it. Saved Jellyfin identities use existing upstream services. The do not package it. Saved Jellyfin identities use existing upstream services. The
shell makes no Conductarr calls and does not add Conductarr sign-in. shell makes no Conductarr calls and does not add Conductarr sign-in.
Discover adds the picks hero, type chips, catalogue shelves, service pages, continuous
See all grids, Hidden picks, request chooser and receipts. Its `discover-title/...`
destination is an explicit title-family integration stub exposing only Discover's
request and pick actions. Unheld titles have no trailer control until the pinned
Conductarr title contract supplies a trailer.
The debug-only fixture and local art live in
`app/src/debug/assets/wolfskin-discover/`. Release builds remain on the explicit
Conductarr sign-in state. The fixture host cannot construct a network client:
debug request outcomes and dismissals stay in memory. The real mutation adapter
accepts an injected `ConductarrClient` and uses only its typed routes; it is not
wired to a live server. Unknown request outcomes use a title-requests lookup rather
than blindly submitting again. Whole-series requests require every season.
Discover's native capture suite covers its 28 named acceptance states and emits
text-layout metrics beside the PNGs. Data-driven Android key replays cover opening
Discover in 4 presses, requesting for yourself in 7, requesting for Mindel in 9 and
Not interested in 8. Additional regressions exercise held-OK safety, uncertain
receipts, unknown seasons, Dutch decimal formatting and short-row/tail clamping.
Native Roborazzi captures use Robolectric SDK 34 and Native Roborazzi captures use Robolectric SDK 34 and
`w960dp-h540dp-xhdpi` (1920×1080 at density 2.0). `make test` writes capture PNGs and `w960dp-h540dp-xhdpi` (1920×1080 at density 2.0). `make test` writes capture PNGs and
the sampled Compose nav-spring curve to `build/wolfskin-captures/`. Key replays the sampled Compose nav-spring curve to `build/wolfskin-captures/`. Key replays

View file

@ -286,6 +286,24 @@
"fontWeight": 800 "fontWeight": 800
}, },
"source": "locked index.html:149" "source": "locked index.html:149"
},
"sheetRadioRing": {
"$type": "dimension",
"px": 3,
"dp": 1.5,
"source": "locked index.html:234"
},
"skeletonNameWidth": {
"$type": "dimension",
"px": 260,
"dp": 130,
"source": "locked index.html:1851"
},
"skeletonNameHeight": {
"$type": "dimension",
"px": 34,
"dp": 17,
"source": "locked index.html:1851"
} }
} }
} }

View file

@ -73,7 +73,13 @@ private fun RequestSheet(state: DiscoverState, sheet: RequestSheetState) {
RequestPhase.Choose, RequestPhase.Sending -> RequestChoices(state, sheet) RequestPhase.Choose, RequestPhase.Sending -> RequestChoices(state, sheet)
RequestPhase.LoadingRecipients -> { RequestPhase.LoadingRecipients -> {
Text("For", color = C.dim, style = WolfskinTokens.Type.label) Text("For", color = C.dim, style = WolfskinTokens.Type.label)
repeat(3) { Box(Modifier.fillMaxWidth().height(L.sheetRow).background(C.listRowRest, RoundedCornerShape(WolfskinTokens.Radius.listRow))) } repeat(3) {
Row(Modifier.fillMaxWidth().height(L.sheetRow).background(C.listRowRest, RoundedCornerShape(WolfskinTokens.Radius.listRow)).padding(horizontal = D.sheetRowPadding),
horizontalArrangement = Arrangement.spacedBy(D.sheetPersonGap), verticalAlignment = Alignment.CenterVertically) {
Box(Modifier.size(D.sheetAvatar).background(C.surface2, CircleShape))
Box(Modifier.width(D.skeletonNameWidth).height(D.skeletonNameHeight).background(C.surface2, RoundedCornerShape(WolfskinTokens.Shell.skeletonRadius)))
}
}
DiscoverButton("Close", true) { state.overlay = null } DiscoverButton("Close", true) { state.overlay = null }
} }
else -> RequestOutcome(state, sheet) else -> RequestOutcome(state, sheet)
@ -89,13 +95,13 @@ private fun RequestChoices(state: DiscoverState, sheet: RequestSheetState) {
Row(Modifier.fillMaxWidth().height(L.sheetRow).alpha(if (disabled) .5f else 1f) Row(Modifier.fillMaxWidth().height(L.sheetRow).alpha(if (disabled) .5f else 1f)
.focusScale(focused, WolfskinTokens.Focus.scaleRow) .focusScale(focused, WolfskinTokens.Focus.scaleRow)
.background(if (focused) C.fg else C.listRowRest, RoundedCornerShape(WolfskinTokens.Radius.listRow)) .background(if (focused) C.fg else C.listRowRest, RoundedCornerShape(WolfskinTokens.Radius.listRow))
.clickable(enabled = !disabled) { sheet.selected = index; sheet.focus = sheet.buttonIndex } .clickable(enabled = !disabled && sheet.phase == RequestPhase.Choose) { sheet.selected = index; sheet.focus = sheet.buttonIndex }
.padding(horizontal = D.sheetRowPadding), horizontalArrangement = Arrangement.spacedBy(D.sheetPersonGap), verticalAlignment = Alignment.CenterVertically) { .padding(horizontal = D.sheetRowPadding), horizontalArrangement = Arrangement.spacedBy(D.sheetPersonGap), verticalAlignment = Alignment.CenterVertically) {
Avatar(person, D.sheetAvatar, WolfskinTokens.Type.avatarLetterSheetRow) Avatar(person, D.sheetAvatar, WolfskinTokens.Type.avatarLetterSheetRow)
Text(person.name + if (person.key == sheet.viewer.key) " (you)" else "", Modifier.weight(1f), color = if (focused) C.onFocus else C.fg, style = WolfskinTokens.Type.listTitle) Text(person.name + if (person.key == sheet.viewer.key) " (you)" else "", Modifier.weight(1f), color = if (focused) C.onFocus else C.fg, style = WolfskinTokens.Type.listTitle)
if (disabled) Text("In library", color = if (focused) C.onFocus else C.muted, style = WolfskinTokens.Type.caption) if (disabled) Text("In library", color = if (focused) C.onFocus else C.muted, style = WolfskinTokens.Type.caption)
else Box(Modifier.size(D.sheetRadio).background(if (sheet.selected == index) C.accent else C.listRowRest.copy(alpha = 0f), CircleShape) else Box(Modifier.size(D.sheetRadio).background(if (sheet.selected == index) C.accent else C.listRowRest.copy(alpha = 0f), CircleShape)
.border(if (sheet.selected == index) WolfskinTokens.Shell.zero else WolfskinTokens.Focus.ringCardRing, if (focused) C.onFocus.copy(alpha = .5f) else C.fg.copy(alpha = .35f), CircleShape), contentAlignment = Alignment.Center) { .border(if (sheet.selected != index || focused) D.sheetRadioRing else WolfskinTokens.Shell.zero, if (focused) C.onFocus.copy(alpha = .5f) else C.fg.copy(alpha = .35f), CircleShape), contentAlignment = Alignment.Center) {
if (sheet.selected == index) Box(Modifier.size(D.sheetRadioDot).background(C.onFocus, CircleShape)) if (sheet.selected == index) Box(Modifier.size(D.sheetRadioDot).background(C.onFocus, CircleShape))
} }
} }
@ -103,7 +109,7 @@ private fun RequestChoices(state: DiscoverState, sheet: RequestSheetState) {
if (sheet.blocked) Text("Season details are unknown right now, so this series can’t be requested yet. Nothing was sent.", color = C.warn, style = WolfskinTokens.Type.caption) if (sheet.blocked) Text("Season details are unknown right now, so this series can’t be requested yet. Nothing was sent.", color = C.warn, style = WolfskinTokens.Type.caption)
else if (sheet.title.type == "tv" && !sheet.held) Text("Whole series: every listed season. Other seasons may download too.", color = C.dim, style = WolfskinTokens.Type.caption) else if (sheet.title.type == "tv" && !sheet.held) Text("Whole series: every listed season. Other seasons may download too.", color = C.dim, style = WolfskinTokens.Type.caption)
Spacer(Modifier.height(D.sheetButtonTop)) Spacer(Modifier.height(D.sheetButtonTop))
DiscoverButton(if (sheet.blocked) "Close" else sheet.buttonLabel, sheet.focus == sheet.buttonIndex) { sheet.focus = sheet.buttonIndex; state.key(ShellKey.Ok, true) } DiscoverButton(if (sheet.blocked) "Close" else sheet.buttonLabel, sheet.focus == sheet.buttonIndex, glyph = if (sheet.blocked) null else "plus") { sheet.focus = sheet.buttonIndex; state.key(ShellKey.Ok, true) }
} }
@Composable @Composable

View file

@ -333,5 +333,8 @@ object WolfskinTokens {
val entryPaddingX = 11.0.dp val entryPaddingX = 11.0.dp
val entryPaddingY = 10.0.dp val entryPaddingY = 10.0.dp
val entryText = TextStyle(fontSize = 15.0.sp, fontWeight = FontWeight(800), lineHeight = 19.5.sp, letterSpacing = -0.01.em) val entryText = TextStyle(fontSize = 15.0.sp, fontWeight = FontWeight(800), lineHeight = 19.5.sp, letterSpacing = -0.01.em)
val sheetRadioRing = 1.5.dp
val skeletonNameWidth = 130.0.dp
val skeletonNameHeight = 17.0.dp
} }
} }

View file

@ -14,6 +14,11 @@ import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.graphics.painter.BitmapPainter import androidx.compose.ui.graphics.painter.BitmapPainter
import androidx.compose.ui.test.junit4.createAndroidComposeRule import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.test.onRoot import androidx.compose.ui.test.onRoot
import androidx.compose.ui.test.onAllNodesWithText
import androidx.compose.ui.semantics.SemanticsActions
import androidx.compose.ui.text.TextLayoutResult
import org.json.JSONArray
import org.json.JSONObject
import com.github.takahirom.roborazzi.captureRoboImage import com.github.takahirom.roborazzi.captureRoboImage
import java.io.File import java.io.File
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
@ -41,7 +46,8 @@ class DiscoverCaptureTest {
} }
private fun capture(name: String, person: String = "justin", snapshotTransform: (DiscoverSnapshot) -> DiscoverSnapshot = { it }, configure: (DiscoverState) -> Unit = {}) { private fun capture(name: String, person: String = "justin", snapshotTransform: (DiscoverSnapshot) -> DiscoverSnapshot = { it }, configure: (DiscoverState) -> Unit = {}) {
val shell = ShellState(fixturePeople(), person).apply { pickerVisible = false; enterDestination("discover") } val titleState = name.substringAfter("st-").toInt() in setOf(58, 59, 71, 72, 73, 74, 75, 76, 77, 78, 79, 80, 87, 110)
val shell = ShellState(fixturePeople(), person).apply { pickerVisible = false; enterDestination(if (titleState) "home" else "discover") }
val snapshot = snapshotTransform(fixture.snapshot(person)) val snapshot = snapshotTransform(fixture.snapshot(person))
val state = DiscoverState(shell, snapshot, RecordingDiscoverOperations(snapshot, person), CoroutineScope(Dispatchers.Main.immediate), val state = DiscoverState(shell, snapshot, RecordingDiscoverOperations(snapshot, person), CoroutineScope(Dispatchers.Main.immediate),
onPush = shell::enterDestination, onPop = { shell.enterDestination("discover") }, serviceLanes = fixture::service) onPush = shell::enterDestination, onPop = { shell.enterDestination("discover") }, serviceLanes = fixture::service)
@ -56,8 +62,28 @@ class DiscoverCaptureTest {
} }
rule.mainClock.advanceTimeBy(1000) rule.mainClock.advanceTimeBy(1000)
rule.waitForIdle() rule.waitForIdle()
val directory = File(System.getProperty("wolfskin.captureDirectory")).apply { mkdirs() } val directory = File(requireNotNull(System.getProperty("wolfskin.captureDirectory"))).apply { mkdirs() }
rule.onRoot().captureRoboImage(File(directory, "$name.png").absolutePath) rule.onRoot().captureRoboImage(File(directory, "$name.png").absolutePath)
recordTypography(directory, name, state)
}
private fun recordTypography(directory: File, name: String, state: DiscoverState) {
val rows = JSONArray()
val labels = listOfNotNull(state.hero?.title, "Request", "For", "Justin (you)", "Hidden picks", "Netflix", "Popular movies")
labels.forEach { text ->
rule.onAllNodesWithText(text, useUnmergedTree = true).fetchSemanticsNodes().forEach { node ->
val layouts = mutableListOf<TextLayoutResult>()
node.config[SemanticsActions.GetTextLayoutResult].action?.invoke(layouts)
layouts.forEach { layout ->
rows.put(JSONObject().put("text", text).put("lines", layout.lineCount)
.put("fontSizeSp", layout.layoutInput.style.fontSize.value).put("fontWeight", layout.layoutInput.style.fontWeight?.weight)
.put("lineHeightSp", layout.layoutInput.style.lineHeight.value).put("trim", layout.layoutInput.style.lineHeightStyle?.trim.toString())
.put("density", layout.layoutInput.density.density).put("fontScale", layout.layoutInput.density.fontScale)
.put("left", node.boundsInRoot.left).put("top", node.boundsInRoot.top).put("width", node.boundsInRoot.width).put("height", node.boundsInRoot.height))
}
}
}
File(directory, "$name-type-metrics.json").writeText(rows.toString(2))
} }
@Test fun st029_discover() = capture("st-029") @Test fun st029_discover() = capture("st-029")

View file

@ -222,4 +222,7 @@
--discover-entryPaddingX: 22.0px; --discover-entryPaddingX: 22.0px;
--discover-entryPaddingY: 20.0px; --discover-entryPaddingY: 20.0px;
--discover-entryText: 30.0px; --discover-entryText: 30.0px;
--discover-sheetRadioRing: 3.0px;
--discover-skeletonNameWidth: 260.0px;
--discover-skeletonNameHeight: 34.0px;
} }

View file

@ -1,6 +1,6 @@
145606da685f1d54ce0225a880220676dac34470eba45b3eda1a92000bb4937d discover-tokens.json b88b3ae2be6865a53076cbdbca8362a94f2cc40561f20ef9864394eb10c097dc discover-tokens.json
fcff646d59fb39e859c34c05037e05a7fb7583de2fe07315ec0176cf4d597980 generate_tokens.py fcff646d59fb39e859c34c05037e05a7fb7583de2fe07315ec0176cf4d597980 generate_tokens.py
408b7c3f4db2028deda97ac0570a57a4da4877917fd0980b2bfa5afe8f8fa790 shell-tokens.json 408b7c3f4db2028deda97ac0570a57a4da4877917fd0980b2bfa5afe8f8fa790 shell-tokens.json
23924778eb5075fe2c5135f41fc43559642826848048eec91197c975c9666caa src/main/java/org/justinvisser/wolfskin/design/WolfskinTokens.kt 3240addb082c7b681b675e9008618a7bfe4c2fde5e7970d702c2ac759eac9ffe src/main/java/org/justinvisser/wolfskin/design/WolfskinTokens.kt
feecf6210212cdd1e043dec359a957fc9dc50c05379e34bef6e5acf1e0f73c37 tokens.css a639780189f0426d456f23dc0ba18c45581702b8d12a3f59430d71b912e4315d tokens.css
5b939f1f3eb90b428008fda75720f6e3a4e844c486668931db3d52bffb987b53 tokens.json 5b939f1f3eb90b428008fda75720f6e3a4e844c486668931db3d52bffb987b53 tokens.json