diff --git a/docker/README.md b/docker/README.md index 0ccf48aa..a6e994e1 100644 --- a/docker/README.md +++ b/docker/README.md @@ -59,8 +59,8 @@ 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 measurements from that same -prototype. Generate the committed Kotlin, CSS and checksum manifest together: +SHA and provenance. `shell-tokens.json` and `discover-tokens.json` add measurements +from that same prototype. Generate the committed Kotlin, CSS and checksum manifest together: ```sh 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 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 `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 diff --git a/wolfskin-design/discover-tokens.json b/wolfskin-design/discover-tokens.json index a1a49947..0b711725 100644 --- a/wolfskin-design/discover-tokens.json +++ b/wolfskin-design/discover-tokens.json @@ -286,6 +286,24 @@ "fontWeight": 800 }, "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" } } } diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/DiscoverOverlay.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/DiscoverOverlay.kt index 323bb51f..54ea6d85 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/DiscoverOverlay.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/DiscoverOverlay.kt @@ -73,7 +73,13 @@ private fun RequestSheet(state: DiscoverState, sheet: RequestSheetState) { RequestPhase.Choose, RequestPhase.Sending -> RequestChoices(state, sheet) RequestPhase.LoadingRecipients -> { 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 } } 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) .focusScale(focused, WolfskinTokens.Focus.scaleRow) .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) { 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) 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) - .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)) } } @@ -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) 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)) - 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 diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/WolfskinTokens.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/WolfskinTokens.kt index 5cb56f61..4150bc1c 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/WolfskinTokens.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/WolfskinTokens.kt @@ -333,5 +333,8 @@ object WolfskinTokens { val entryPaddingX = 11.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 sheetRadioRing = 1.5.dp + val skeletonNameWidth = 130.0.dp + val skeletonNameHeight = 17.0.dp } } diff --git a/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/DiscoverCaptureTest.kt b/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/DiscoverCaptureTest.kt index 35faea93..eda07393 100644 --- a/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/DiscoverCaptureTest.kt +++ b/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/DiscoverCaptureTest.kt @@ -14,6 +14,11 @@ import androidx.compose.ui.graphics.asImageBitmap import androidx.compose.ui.graphics.painter.BitmapPainter import androidx.compose.ui.test.junit4.createAndroidComposeRule 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 java.io.File 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 = {}) { - 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 state = DiscoverState(shell, snapshot, RecordingDiscoverOperations(snapshot, person), CoroutineScope(Dispatchers.Main.immediate), onPush = shell::enterDestination, onPop = { shell.enterDestination("discover") }, serviceLanes = fixture::service) @@ -56,8 +62,28 @@ class DiscoverCaptureTest { } rule.mainClock.advanceTimeBy(1000) 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) + 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() + 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") diff --git a/wolfskin-design/tokens.css b/wolfskin-design/tokens.css index f39f4a0a..1542a58b 100644 --- a/wolfskin-design/tokens.css +++ b/wolfskin-design/tokens.css @@ -222,4 +222,7 @@ --discover-entryPaddingX: 22.0px; --discover-entryPaddingY: 20.0px; --discover-entryText: 30.0px; + --discover-sheetRadioRing: 3.0px; + --discover-skeletonNameWidth: 260.0px; + --discover-skeletonNameHeight: 34.0px; } diff --git a/wolfskin-design/tokens.sha256 b/wolfskin-design/tokens.sha256 index 47a28428..674e6f8f 100644 --- a/wolfskin-design/tokens.sha256 +++ b/wolfskin-design/tokens.sha256 @@ -1,6 +1,6 @@ -145606da685f1d54ce0225a880220676dac34470eba45b3eda1a92000bb4937d discover-tokens.json +b88b3ae2be6865a53076cbdbca8362a94f2cc40561f20ef9864394eb10c097dc discover-tokens.json fcff646d59fb39e859c34c05037e05a7fb7583de2fe07315ec0176cf4d597980 generate_tokens.py 408b7c3f4db2028deda97ac0570a57a4da4877917fd0980b2bfa5afe8f8fa790 shell-tokens.json -23924778eb5075fe2c5135f41fc43559642826848048eec91197c975c9666caa src/main/java/org/justinvisser/wolfskin/design/WolfskinTokens.kt -feecf6210212cdd1e043dec359a957fc9dc50c05379e34bef6e5acf1e0f73c37 tokens.css +3240addb082c7b681b675e9008618a7bfe4c2fde5e7970d702c2ac759eac9ffe src/main/java/org/justinvisser/wolfskin/design/WolfskinTokens.kt +a639780189f0426d456f23dc0ba18c45581702b8d12a3f59430d71b912e4315d tokens.css 5b939f1f3eb90b428008fda75720f6e3a4e844c486668931db3d52bffb987b53 tokens.json