From 43ae71615749c8d73e9e3acd3573e7fc23095514 Mon Sep 17 00:00:00 2001 From: justinzeus Date: Thu, 5 Nov 2026 20:26:43 +0100 Subject: [PATCH] Preserve native title line boxes and async focus --- .../wolfskin/tv/TitleAsyncFocusTest.kt | 88 +++++++++++++++++++ .../wolfskin/tv/TitleCaptureTest.kt | 26 ++++++ .../justinvisser/wolfskin/tv/TitleKeysTest.kt | 12 +++ docker/README.md | 27 +++--- .../justinvisser/wolfskin/design/NavBar.kt | 2 +- .../wolfskin/design/PersonPage.kt | 8 +- .../wolfskin/design/TitleOptionsSheet.kt | 30 +++---- .../justinvisser/wolfskin/design/TitlePage.kt | 5 +- .../wolfskin/design/TitleTextStyles.kt | 21 +++++ 9 files changed, 187 insertions(+), 32 deletions(-) create mode 100644 app/src/test/java/org/justinvisser/wolfskin/tv/TitleAsyncFocusTest.kt create mode 100644 wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleTextStyles.kt diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/TitleAsyncFocusTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/TitleAsyncFocusTest.kt new file mode 100644 index 00000000..4909bb95 --- /dev/null +++ b/app/src/test/java/org/justinvisser/wolfskin/tv/TitleAsyncFocusTest.kt @@ -0,0 +1,88 @@ +package org.justinvisser.wolfskin.tv + +import android.app.Application +import androidx.activity.ComponentActivity +import androidx.compose.ui.input.key.Key +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.onRoot +import androidx.compose.ui.test.performKeyInput +import androidx.compose.ui.test.pressKey +import androidx.lifecycle.viewModelScope +import com.github.damontecres.wholphin.preferences.AppPreferences +import com.github.damontecres.wholphin.preferences.UserPreferences +import com.github.damontecres.wholphin.services.NavigationManager +import io.mockk.coEvery +import io.mockk.mockk +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.cancel +import org.junit.After +import org.junit.Assert.* +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.justinvisser.wolfskin.design.ShellPerson +import org.justinvisser.wolfskin.design.ShellState +import org.justinvisser.wolfskin.design.TitleItem +import org.justinvisser.wolfskin.design.TitlePageData +import org.justinvisser.wolfskin.design.TitleReadStatus +import org.justinvisser.wolfskin.design.TitleSheet +import org.justinvisser.wolfskin.design.TitleTrack +import org.justinvisser.wolfskin.design.TitleVersion +import org.justinvisser.wolfskin.design.WolfskinTheme +import org.justinvisser.wolfskin.tv.read.TitleReadFacade +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import java.util.UUID + +/** A delayed service reply exercises the production destination, view-model and Android focus transfer. */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi") +class TitleAsyncFocusTest { + @get:Rule val rule = createAndroidComposeRule() + private lateinit var viewModel: WolfskinTitleViewModel + + @After fun dispose() { + if (::viewModel.isInitialized) viewModel.viewModelScope.cancel() + } + + @Test fun loadingToReadyRestoresRealKeyFocusAndSheetReturn() { + val id = UUID.fromString("00000000-0000-0000-0000-000000000012") + val ready = CompletableDeferred() + val facade = mockk() + coEvery { facade.readSaved(id, any()) } returns null + coEvery { facade.read(id, any(), any()) } coAnswers { ready.await() } + viewModel = WolfskinTitleViewModel(rule.activity, facade, mockk(), mockk(relaxed = true)) + val person = ShellPerson("justin", "Justin", "J", false) + val route = titleRoute(id.toString()) + val shell = ShellState(listOf(person), person.key).apply { + pickerVisible = false + pickerLaunch = false + enterDestination(route) + } + val preferences = UserPreferences(AppPreferences.getDefaultInstance(), null) + rule.setContent { WolfskinTheme { TitleDestination(route, shell, preferences, viewModel = viewModel) } } + rule.waitUntil { viewModel.title?.data?.status == TitleReadStatus.Loading } + rule.waitForIdle() + assertTrue(shell.navFocused) + press(Key.DirectionDown) + assertTrue("The initial skeleton cannot receive nav Down", shell.navFocused) + + val source = TitleVersion("first", "Original", "1080p", "", "", "MKV", listOf(TitleTrack(3, "English", true)), emptyList()) + rule.runOnIdle { + ready.complete(TitlePageData(TitleItem(id.toString(), "A held film", "movie", held = true), versions = listOf(source, source.copy(id = "second", name = "Alternate")))) + } + rule.waitUntil { viewModel.title?.data?.status == TitleReadStatus.Ready && !shell.navFocused } + press(Key.DirectionRight) + val state = requireNotNull(viewModel.title) + assertEquals("version", state.options[state.column].id) + press(Key.DirectionCenter) + assertEquals(TitleSheet.Version, state.sheet) + rule.runOnIdle { rule.activity.onBackPressedDispatcher.onBackPressed() } + assertNull(state.sheet) + press(Key.DirectionRight) + assertEquals("tracks", state.options[state.column].id) + assertFalse(shell.navFocused) + } + + private fun press(key: Key) { rule.onRoot().performKeyInput { pressKey(key) } } +} diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/TitleCaptureTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/TitleCaptureTest.kt index 0a8ef616..5db527f4 100644 --- a/app/src/test/java/org/justinvisser/wolfskin/tv/TitleCaptureTest.kt +++ b/app/src/test/java/org/justinvisser/wolfskin/tv/TitleCaptureTest.kt @@ -17,6 +17,8 @@ import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.performSemanticsAction import androidx.compose.ui.semantics.SemanticsActions import androidx.compose.ui.text.TextLayoutResult +import androidx.compose.ui.test.SemanticsMatcher +import androidx.compose.ui.graphics.toArgb import com.github.takahirom.roborazzi.captureRoboImage import org.json.JSONObject import org.junit.Before @@ -115,6 +117,30 @@ class TitleCaptureTest { .put("boundsPx", org.json.JSONArray(listOf(heading.boundsInRoot.left, heading.boundsInRoot.top, heading.boundsInRoot.right, heading.boundsInRoot.bottom))) File(directory, "st-${number.toString().padStart(3, '0')}-layout.json").writeText(metrics.toString(2)) } + recordTextMetrics(directory, number) + } + + private fun recordTextMetrics(directory: File, number: Int) { + val elements = rule.onAllNodes(SemanticsMatcher.keyIsDefined(SemanticsActions.GetTextLayoutResult), useUnmergedTree = true) + val records = org.json.JSONArray() + elements.fetchSemanticsNodes().forEachIndexed { index, node -> + if (node.boundsInRoot.width > 0 && node.boundsInRoot.height > 0) { + val layouts = mutableListOf() + elements[index].performSemanticsAction(SemanticsActions.GetTextLayoutResult) { action -> action(layouts) } + val layout = layouts.single() + records.put(JSONObject().put("text", layout.layoutInput.text.text) + .put("fontSizeSp", layout.layoutInput.style.fontSize.value) + .put("weight", layout.layoutInput.style.fontWeight?.weight) + .put("colourArgb", Integer.toUnsignedString(layout.layoutInput.style.color.toArgb(), 16)) + .put("lineHeightSp", layout.layoutInput.style.lineHeight.value.takeIf { it.isFinite() } ?: JSONObject.NULL) + .put("lineCount", layout.lineCount) + .put("boundsPx", org.json.JSONArray(listOf(node.boundsInRoot.left, node.boundsInRoot.top, node.boundsInRoot.right, node.boundsInRoot.bottom))) + .put("spans", org.json.JSONArray(layout.layoutInput.text.spanStyles.map { span -> + JSONObject().put("start", span.start).put("end", span.end).put("weight", span.item.fontWeight?.weight) + }))) + } + } + File(directory, "st-${number.toString().padStart(3, '0')}-text.json").writeText(records.toString(2)) } private fun option(state: TitleState, id: String) { state.focus("header", state.options.indexOfFirst { it.id == id }) } diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/TitleKeysTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/TitleKeysTest.kt index 3da70809..178eeded 100644 --- a/app/src/test/java/org/justinvisser/wolfskin/tv/TitleKeysTest.kt +++ b/app/src/test/java/org/justinvisser/wolfskin/tv/TitleKeysTest.kt @@ -33,6 +33,7 @@ import org.justinvisser.wolfskin.design.TitleSurface import org.justinvisser.wolfskin.design.WolfskinTheme import org.robolectric.RobolectricTestRunner import org.robolectric.annotation.Config +import org.robolectric.annotation.GraphicsMode /** Approved title-entry suffixes replay Android keys through the rendered title and shared shell. */ @RunWith(RobolectricTestRunner::class) @@ -289,4 +290,15 @@ class TitleKeysTest { val genre = rule.onNodeWithText("Drama", useUnmergedTree = true).fetchSemanticsNode().boundsInRoot assertEquals(WolfskinTokens.Title.factsGap.value * 2, genre.left - year.right, 1f) } + + @GraphicsMode(GraphicsMode.Mode.NATIVE) + @Test fun mediaInfoRowsPreserveTheirDeclaredLineBoxes() { + render() + rule.runOnIdle { state.focus("header", state.options.indexOfFirst { it.id == "more" }) } + press(Key.DirectionCenter) + val container = rule.onNodeWithText("Container", useUnmergedTree = true).fetchSemanticsNode().boundsInRoot + val size = rule.onNodeWithText("Size", useUnmergedTree = true).fetchSemanticsNode().boundsInRoot + val expectedPitch = (WolfskinTokens.Title.infoKey.lineHeight.value + WolfskinTokens.Title.infoRowGap.value) * 2 + assertEquals("Stacked facts retain the CSS line-box rhythm", expectedPitch, size.top - container.top, 1f) + } } diff --git a/docker/README.md b/docker/README.md index be05469c..c4cb03fa 100644 --- a/docker/README.md +++ b/docker/README.md @@ -200,16 +200,23 @@ button until Conductarr's trailer field exists. Conductarr sign-in and request submission/receipt workflows belong to their separate slices; title pages do not pretend those operations succeeded or fall back to Seerr. -`TitleCaptureTest` writes the 34 approved title/person/options frames and native -heading layout measurements at 1920×1080, density 2.0. The approved typeface ruling -keeps platform Roboto rather than bundling the browser's DejaVu Sans: there is no -per-title compensation and no wrapping-parity claim. `TitleKeysTest` replays -real Android key events from title entry, including modal Back, held OK, nearest -row entry, remembered columns, pending seasons and mouse-wheel re-entry. -`title-press-paths.json` also retains the complete launch paths and their entry -boundaries; their Home prefixes require the Home surface before end-to-end replay. -SDK cache, exact-episode, track-index, identity, locale-layout and trailer-handler -tests run entirely offline with in-process service replies and local art. +`TitleCaptureTest` writes the 34 approved title/person/options frames plus native +text weights, colours, line boxes and bounds at 1920×1080, density 2.0. The approved +typeface ruling keeps platform Roboto, not the browser's DejaVu Sans: no per-title +compensation and no wrapping-parity claim. Untrimmed CSS line boxes are scoped to +title/person content and sheets; shared nav/library typography is unchanged. +`TitleKeysTest` replays real Android keys from title entry, including modal Back, +held OK, nearest-row entry, remembered columns, pending seasons, wheel re-entry +and native media-info line-box pitch. `TitleAsyncFocusTest` delays the service reply +through the production destination/view-model, then exercises loaded options and +hardware sheet Back through real focus handling. + +The approved current gate uses exact title-entry suffixes, not padded launch +paths. `title-press-paths.json` retains the full sequences and entry boundaries. +The full 4/8/15/11 launch replays will be added after the native Home slice merges; +the corresponding title-entry paths are 1/5/6/2. SDK cache, exact-episode, +track-index, identity, locale-layout and trailer-handler tests run offline with +in-process service replies and local art. ## Toolchain and disk use diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/NavBar.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/NavBar.kt index 9521ce1b..2d4a0dd9 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/NavBar.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/NavBar.kt @@ -104,7 +104,7 @@ fun NavBar(state: ShellState, scrolled: Boolean, modifier: Modifier = Modifier, Row(Modifier.align(Alignment.TopEnd) .offset(x = -(L.safeSide + L.navAvatar + L.navItemGap), y = S.navItemTop + (L.navAvatar - V.height) / 2) .height(V.height).border(V.border, C.warn.copy(alpha = V.borderOpacity), RoundedCornerShape(WolfskinTokens.Radius.button)) - .padding(horizontal = V.side), horizontalArrangement = Arrangement.spacedBy(V.gap), verticalAlignment = Alignment.CenterVertically) { + .padding(horizontal = V.side + V.border), horizontalArrangement = Arrangement.spacedBy(V.gap), verticalAlignment = Alignment.CenterVertically) { Glyph("wifi", C.warn, Modifier.size(V.icon)) Text("Saved $savedAt", color = C.warn, style = WolfskinTokens.Type.caption) } diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/PersonPage.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/PersonPage.kt index cade1bcd..37923f98 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/PersonPage.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/PersonPage.kt @@ -131,15 +131,15 @@ private fun PersonPage(state: PersonState, shell: ShellState, modifier: Modifier Row(Modifier.offset(L.safeSide, D.personTop), horizontalArrangement = Arrangement.spacedBy(D.personGap), verticalAlignment = Alignment.CenterVertically) { PersonPortrait(state.person, Modifier.size(L.personPortrait).clip(CircleShape), true) Column(Modifier.width(L.contentWidth - L.personPortrait - D.personGap)) { - Text(state.person.name, color = C.fg, style = WolfskinTokens.Type.screenTitle, modifier = Modifier.padding(bottom = D.personNameBottom)) - Text("Acting · ${state.credits.size} credit${if (state.credits.size == 1) "" else "s"} among this household’s titles", color = C.muted, style = WolfskinTokens.Type.body) - if (state.person.biography.isNotBlank()) Text(state.person.biography, color = C.dim, style = WolfskinTokens.Type.caption, maxLines = 3) + Text(state.person.name, color = C.fg, style = TitleTextStyles.screenTitle, modifier = Modifier.padding(bottom = D.personNameBottom)) + Text("Acting · ${state.credits.size} credit${if (state.credits.size == 1) "" else "s"} among this household’s titles", color = C.muted, style = TitleTextStyles.body) + if (state.person.biography.isNotBlank()) Text(state.person.biography, color = C.dim, style = TitleTextStyles.caption, maxLines = 3) } } } } item(key = "filmography", contentType = "header") { - Text("Filmography", color = C.fg, style = WolfskinTokens.Type.rowHeading, modifier = Modifier.height(L.row_gh).padding(start = L.safeSide, top = D.gridHeadingTop)) + Text("Filmography", color = C.fg, style = TitleTextStyles.rowHeading, modifier = Modifier.height(L.row_gh).padding(start = L.safeSide, top = D.gridHeadingTop)) } itemsIndexed(state.credits.chunked(columns), key = { _, row -> row.first().id }, contentType = { _, _ -> "grid" }) { rowIndex, row -> Row(Modifier.height(D.gridHeight).padding(horizontal = L.safeSide), horizontalArrangement = Arrangement.spacedBy(WolfskinTokens.Card.posterGap)) { diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleOptionsSheet.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleOptionsSheet.kt index 0c3d6bd5..711080da 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleOptionsSheet.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleOptionsSheet.kt @@ -70,24 +70,24 @@ fun TitleOptionsSheet(state: TitleState, shell: ShellState) { @Composable private fun VersionChoices(state: TitleState) { - Text("Version", color = C.fg, style = WolfskinTokens.Type.sheetTitle) + Text("Version", color = C.fg, style = TitleTextStyles.sheetTitle) SheetSubtitle("${state.target?.name ?: state.data.item.name} · ${state.data.versions.size} files") state.data.versions.forEachIndexed { index, version -> PlaybackChoice(version.name, listOf(version.video, version.bitrate, version.size).filter { it.isNotBlank() }.joinToString(" · "), version == state.version, state.sheetIndex == index, { state.sheetIndex = index }) { state.clickSheet(index) } } - Text("Remembered for ${state.person.name} on this title.", color = C.dim, style = WolfskinTokens.Type.caption) + Text("Remembered for ${state.person.name} on this title.", color = C.dim, style = TitleTextStyles.caption) } @Composable private fun TrackChoices(state: TitleState) { val version = state.version ?: return - Text("Audio and subtitles", color = C.fg, style = WolfskinTokens.Type.sheetTitle) + Text("Audio and subtitles", color = C.fg, style = TitleTextStyles.sheetTitle) SheetSubtitle("${state.target?.let { if (it.kind == "episode") "${it.seriesName} · ${it.episodeLabel}" else it.name } ?: state.data.item.name} · ${version.name}") - Text("Audio", color = C.dim, style = WolfskinTokens.Type.label, modifier = Modifier.padding(top = D.sheetLabelTop)) + Text("Audio", color = C.dim, style = TitleTextStyles.label, modifier = Modifier.padding(top = D.sheetLabelTop)) version.audio.forEachIndexed { index, track -> PlaybackChoice(track.label, null, track.index == state.audio?.index, state.sheetIndex == index, { state.sheetIndex = index }) { state.clickSheet(index) } } - Text("Subtitles", color = C.dim, style = WolfskinTokens.Type.label, modifier = Modifier.padding(top = D.sheetLabelTop)) + Text("Subtitles", color = C.dim, style = TitleTextStyles.label, modifier = Modifier.padding(top = D.sheetLabelTop)) val start = version.audio.size PlaybackChoice("Off", null, state.subtitleIndex == -1, state.sheetIndex == start, { state.sheetIndex = start }) { state.clickSheet(start) } version.subtitles.forEachIndexed { index, track -> @@ -107,8 +107,8 @@ private fun PlaybackChoice(label: String, why: String?, selected: Boolean, focus .clickable(onClick = onClick).then(sizing).padding(horizontal = D.sheetRowSide), horizontalArrangement = Arrangement.spacedBy(D.personGap), verticalAlignment = Alignment.CenterVertically) { Column(Modifier.weight(1f)) { - Text(label, color = foreground, style = WolfskinTokens.Type.listTitle) - if (why != null) Text(why, color = foreground.copy(alpha = .8f), style = WolfskinTokens.Type.caption) + Text(label, color = foreground, style = TitleTextStyles.listTitle) + if (why != null) Text(why, color = foreground.copy(alpha = .8f), style = TitleTextStyles.caption) } TitleRadio(selected, focused) } @@ -117,7 +117,7 @@ private fun PlaybackChoice(label: String, why: String?, selected: Boolean, focus @Composable private fun MediaInformation(state: TitleState) { val version = state.version ?: return - Text("Media info", color = C.fg, style = WolfskinTokens.Type.sheetTitle) + Text("Media info", color = C.fg, style = TitleTextStyles.sheetTitle) SheetSubtitle(state.target?.let { if (it.kind == "episode") "${it.seriesName} · ${it.episodeLabel}" else it.name } ?: state.data.item.name) MediaInfoSection("General", listOf("Container" to version.container, "Size" to version.size.ifEmpty { "not known" }, "Bitrate" to version.bitrate, "Runtime" to (state.target?.runtime ?: "not known"))) MediaInfoSection("Video", listOf("Stream" to version.video)) @@ -128,11 +128,11 @@ private fun MediaInformation(state: TitleState) { @Composable private fun MediaInfoSection(label: String, values: List>) { - Text(label, color = C.dim, style = WolfskinTokens.Type.label, modifier = Modifier.padding(top = D.sheetLabelTop)) + Text(label, color = C.dim, style = TitleTextStyles.label, modifier = Modifier.padding(top = D.sheetLabelTop)) Column(verticalArrangement = Arrangement.spacedBy(D.infoRowGap)) { values.forEach { (name, value) -> Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(D.infoColumnGap)) { - Text(name, color = C.dim, style = D.infoKey, modifier = Modifier.width(D.infoLabelWidth)) - Text(value, color = C.fg, style = WolfskinTokens.Type.caption) + Text(name, color = C.dim, style = TitleTextStyles.infoKey, modifier = Modifier.width(D.infoLabelWidth)) + Text(value, color = C.fg, style = TitleTextStyles.caption) } } } } @@ -140,7 +140,7 @@ private fun MediaInfoSection(label: String, values: List>) @Composable private fun WatchedConfirmation(state: TitleState) { val verb = if (state.watched) "unwatched" else "watched" - Text("Mark the whole series as $verb?", color = C.fg, style = WolfskinTokens.Type.sheetTitle) + Text("Mark the whole series as $verb?", color = C.fg, style = TitleTextStyles.sheetTitle) SheetSubtitle("Every episode of ${state.data.item.name} changes for ${state.person.name}.") Row(horizontalArrangement = Arrangement.spacedBy(L.buttonGap)) { TitleControl(TitleOption("confirm", "Mark as $verb", "", primary = true, icon = false), state.sheetIndex == 0, onFocus = { state.sheetIndex = 0 }) { state.clickSheet(0) } @@ -150,7 +150,7 @@ private fun WatchedConfirmation(state: TitleState) { @Composable private fun SheetSubtitle(text: String) { - Text(text, color = C.muted, style = WolfskinTokens.Type.body, modifier = Modifier.layout { measurable, constraints -> + Text(text, color = C.muted, style = TitleTextStyles.body, modifier = Modifier.layout { measurable, constraints -> val child = measurable.measure(constraints) val lift = D.sheetSubLift.roundToPx() layout(child.width, (child.height + lift).coerceAtLeast(0)) { child.placeRelative(0, lift) } @@ -163,9 +163,9 @@ private fun HiddenReceipt(state: TitleState) { verticalArrangement = Arrangement.spacedBy(D.receiptGap)) { Row(horizontalArrangement = Arrangement.spacedBy(D.iconGap), verticalAlignment = Alignment.CenterVertically) { Glyph("check", C.fg, Modifier.size(D.receiptIcon)) - Text("Hidden from your picks", color = C.fg, style = D.receiptTitle) + Text("Hidden from your picks", color = C.fg, style = TitleTextStyles.receiptTitle) } - Text("${state.data.item.name} leaves every row of ${state.person.name}’s picks. It waits under Hidden picks at the end of Discover.", color = C.muted, style = WolfskinTokens.Type.body) + Text("${state.data.item.name} leaves every row of ${state.person.name}’s picks. It waits under Hidden picks at the end of Discover.", color = C.muted, style = TitleTextStyles.body) } Row(horizontalArrangement = Arrangement.spacedBy(L.buttonGap)) { TitleControl(TitleOption("undo", "Undo", "", primary = true, icon = false), state.sheetIndex == 0, onFocus = { state.sheetIndex = 0 }) { state.clickSheet(0) } diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitlePage.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitlePage.kt index db850792..e725f6db 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitlePage.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitlePage.kt @@ -201,12 +201,13 @@ private fun TitleReadError(state: TitleState) { Column(Modifier.offset(L.safeSide, D.errorTop).width(L.stateWidth) .background(C.surface, RoundedCornerShape(WolfskinTokens.Radius.panel)) .border(WolfskinTokens.SavedIndicator.border, C.border, RoundedCornerShape(WolfskinTokens.Radius.panel)) + .padding(WolfskinTokens.SavedIndicator.border) .padding(horizontal = D.errorSide, vertical = D.errorVertical)) { Row(Modifier.padding(bottom = D.errorHeadingBottom), horizontalArrangement = Arrangement.spacedBy(D.iconGap), verticalAlignment = Alignment.CenterVertically) { Glyph("alert", C.warn, Modifier.size(D.receiptIcon)) - Text(if (state.data.item.kind == "person") "This person can’t be read" else "This title can’t be read", color = C.fg, style = D.errorTitle) + Text(if (state.data.item.kind == "person") "This person can’t be read" else "This title can’t be read", color = C.fg, style = TitleTextStyles.errorTitle) } - Text("${if (state.data.connection == TitleConnection.SignedOut) "Jellyfin" else "Conductarr"} didn’t answer, and this TV has no saved copy of the page yet.", color = C.muted, style = WolfskinTokens.Type.body, modifier = Modifier.padding(bottom = D.errorBodyBottom)) + Text("${if (state.data.connection == TitleConnection.SignedOut) "Jellyfin" else "Conductarr"} didn’t answer, and this TV has no saved copy of the page yet.", color = C.muted, style = TitleTextStyles.body, modifier = Modifier.padding(bottom = D.errorBodyBottom)) Row(horizontalArrangement = Arrangement.spacedBy(L.buttonGap)) { state.options.forEachIndexed { index, option -> TitleControl(option, state.column == index, onFocus = { state.focus("header", index) }, overArtwork = false) { state.click("header", index) } } } } } diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleTextStyles.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleTextStyles.kt new file mode 100644 index 00000000..ffb534fe --- /dev/null +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleTextStyles.kt @@ -0,0 +1,21 @@ +package org.justinvisser.wolfskin.design + +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.style.LineHeightStyle + +/** Explicit styles retain CSS leading without changing shared nav or library typography. */ +internal object TitleTextStyles { + private val lineBoxes = LineHeightStyle(LineHeightStyle.Alignment.Center, LineHeightStyle.Trim.None) + private fun TextStyle.withLineBoxes() = copy(lineHeightStyle = lineBoxes) + + val body = WolfskinTokens.Type.body.withLineBoxes() + val caption = WolfskinTokens.Type.caption.withLineBoxes() + val label = WolfskinTokens.Type.label.withLineBoxes() + val sheetTitle = WolfskinTokens.Type.sheetTitle.withLineBoxes() + val listTitle = WolfskinTokens.Type.listTitle.withLineBoxes() + val screenTitle = WolfskinTokens.Type.screenTitle.withLineBoxes() + val rowHeading = WolfskinTokens.Type.rowHeading.withLineBoxes() + val errorTitle = WolfskinTokens.Title.errorTitle.withLineBoxes() + val receiptTitle = WolfskinTokens.Title.receiptTitle.withLineBoxes() + val infoKey = WolfskinTokens.Title.infoKey.withLineBoxes() +}