Match Requests sheets to locked design frames

This commit is contained in:
justinzeus 2026-11-05 18:58:06 +01:00
parent 439eea3ff8
commit d9578ac405
17 changed files with 98 additions and 34 deletions

View file

@ -142,8 +142,8 @@ 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 separators
reuse the shared caption-opacity token rather than defining another convention.
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
@ -155,7 +155,8 @@ 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. Single-line sheet text retains CSS-style line-box leading. Platform
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
@ -164,10 +165,12 @@ used. Native comparisons must report those structural differences explicitly.
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, avoiding differences in older upstream conversions:
from the JSON dimensions with the locked cover crop. 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
uv run --with cairosvg==2.9.1 --with pillow==13.0.0 python wolfskin-design/generate_request_marks.py
```
`RequestsCaptureTest` writes `st-081`…`st-086`, `st-088` and `st-111` at 1920×1080,
@ -190,8 +193,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

@ -1,9 +1,11 @@
#!/usr/bin/env python3
"""Rasterize locked prototype rating marks at the Coming-in row's native pixel size."""
from pathlib import Path
from io import BytesIO
import hashlib
import json
import cairosvg
from PIL import Image, ImageOps
ROOT = Path(__file__).resolve().parent
SOURCE = ROOT / "request-marks"
@ -21,7 +23,9 @@ def main() -> None:
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)
source_pixels = cairosvg.svg2png(url=str(source), output_height=height * 4)
with Image.open(BytesIO(source_pixels)) as image:
ImageOps.fit(image, (width, height), method=Image.Resampling.LANCZOS).save(target)
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")

View file

@ -1,12 +1,12 @@
01aa8a3c3e9db48f0e1c6af25056940986784315bf66f48a8120a7317bf6d75a request-marks/fresh.svg
743b6139dc12019d0940568869065722593f44ce416a7e9c7b8d9ef92942bb3f src/main/res/drawable-nodpi/coming_in_rating_fresh.png
e61333be2c5a028adcc1052a24add31ed34001af8cfdf1d180358faffafc35f4 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
72c167c7d0ed23f998e47010be0bbf180790431bd0add0b525f8a5928f44dfd5 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
e3e0a434eaaf5c9390fec821807e34ad6bf656a05520039219dbaaea68d03924 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
3d509c58f80ef773f7d909bf57953d835cfddb0565896c79bf25698146e90099 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
a1d49d5dba29e25738e06dd6d97a32f4d357dc0cf66e1acca00f30287883dd32 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
3d9d745d878a5f6976a31f23c58be7d0ea037353e4a2c6d037861469a716a486 src/main/res/drawable-nodpi/coming_in_rating_upright.png

View file

@ -3,14 +3,24 @@
"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:152" },
"stageGap": { "$type": "dimension", "px": 8, "dp": 4, "source": "index.html:152" },
"stageGlyph": { "$type": "dimension", "px": 28, "dp": 14, "source": "index.html:221" },
"stageGap": { "$type": "dimension", "px": 10, "dp": 5, "source": "index.html:220" },
"readinessItemGap": { "$type": "dimension", "px": 8, "dp": 4, "source": "index.html:313" },
"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" },

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

@ -11,6 +11,10 @@ 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
@ -35,9 +39,13 @@ fun ComingInRatings(ratings: List<ComingInRating>, color: Color) {
}
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))
modifier = Modifier.height(T.readinessGlyph).width(T.readinessGlyph * if (wide) T.ratingsWordmarkRatio else 1f)
.clip(ratingMarkShape).background(WolfskinTokens.Color.surface3),
contentScale = ContentScale.Crop)
Text(formatComingInRating(rating), style = ComingInTypography.rating, color = color)
}
}
}
}
private val ratingMarkShape = RoundedCornerShape(WolfskinTokens.Radius.thumb)

View file

@ -27,6 +27,8 @@ 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
@ -57,7 +59,7 @@ fun ComingInSheet(shell: ShellState, modifier: Modifier = Modifier) {
}
.padding(horizontal = T.sheetPaddingX, vertical = T.sheetPaddingY)
.testTag("coming-in"), verticalArrangement = Arrangement.spacedBy(T.sheetGap)) {
Text("Coming in", style = ComingInTypography.title, color = C.fg)
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)) {
@ -123,6 +125,7 @@ fun ComingInRow(item: ComingInRequest, viewer: ShellPerson, focused: Boolean, ex
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) {
@ -133,26 +136,26 @@ fun ComingInRow(item: ComingInRequest, viewer: ShellPerson, focused: Boolean, ex
.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(C.surface3),
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, style = ComingInTypography.listTitle, color = foreground, maxLines = 1, overflow = TextOverflow.Ellipsis)
Row {
Text("for ${item.recipient(viewer)}", style = ComingInTypography.caption, color = secondary)
Text(" · ", style = ComingInTypography.caption, color = secondary.copy(alpha = secondary.alpha * WolfskinTokens.Library.captionSeparatorOpacity))
Text("by ${item.actor(viewer)}", style = ComingInTypography.caption, color = secondary)
}
FlowRow(Modifier.padding(top = T.stageMargin), horizontalArrangement = Arrangement.spacedBy(T.readinessGlyph)) {
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, style = ComingInTypography.label, color = secondary)
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(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(T.stageGap)) {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(T.readinessItemGap)) {
val glyph = when (word) { "Done" -> "check"; "Failed" -> "alert"; "Not yet", "Unknown" -> null; else -> "clock" }
if (glyph != null) Glyph(glyph, secondary, Modifier.size(T.readinessGlyph))
Text("${READINESS_NAMES[index]} · $word", style = ComingInTypography.readiness, color = secondary)

View file

@ -70,7 +70,7 @@ 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)
}

View file

@ -233,6 +233,15 @@ object WolfskinTokens {
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
@ -240,7 +249,8 @@ object WolfskinTokens {
val listGap = 3.0.dp
val stageMargin = 2.0.dp
val stageGlyph = 14.0.dp
val stageGap = 4.0.dp
val stageGap = 5.0.dp
val readinessItemGap = 4.0.dp
val readinessGapX = 11.0.dp
val readinessGapY = 5.0.dp
val readinessMargin = 4.0.dp

Binary file not shown.

Before

Width:  |  Height:  |  Size: 804 B

After

Width:  |  Height:  |  Size: 1.1 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 813 B

After

Width:  |  Height:  |  Size: 1.3 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 964 B

After

Width:  |  Height:  |  Size: 1.6 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1 KiB

After

Width:  |  Height:  |  Size: 1.6 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 KiB

After

Width:  |  Height:  |  Size: 2.8 KiB

Before After
Before After

Binary file not shown.

Before

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

After

Width:  |  Height:  |  Size: 1.4 KiB

Before After
Before After

View file

@ -130,6 +130,15 @@
--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;
@ -137,7 +146,8 @@
--requests-listGap: 6.0px;
--requests-stageMargin: 4.0px;
--requests-stageGlyph: 28.0px;
--requests-stageGap: 8.0px;
--requests-stageGap: 10.0px;
--requests-readinessItemGap: 8.0px;
--requests-readinessGapX: 22.0px;
--requests-readinessGapY: 10.0px;
--requests-readinessMargin: 8.0px;

View file

@ -1,5 +1,5 @@
1438f7ca4a2b6f951c9e643ab0afacbf2f7fc98e57905295d518c6225e054de4 generate_tokens.py
dafb97c1d26ce435dc4b28b57cc02475a13901191824568c39e88e0474acf86f shell-tokens.json
673d4ece482b480c88acb085438f1e03d979bfa9c85a960c69a5543987e42c9f src/main/java/org/justinvisser/wolfskin/design/WolfskinTokens.kt
16318b38182d7248402c4c448ead5444644eb59a7fd5b63c4e845ffee0085b4a tokens.css
61568cdb87c1b64984033e6d66b06c4aee1870580fb5640ed4a1a97100451c0a shell-tokens.json
cddf8798e731206e1adf1e66384ffcb1b679b27e2ee9f33fd128a04850fd829f src/main/java/org/justinvisser/wolfskin/design/WolfskinTokens.kt
60ac4a0fff7d7965a9c4fa25d71a10cb304c3e636031ccf036a33459571e04fe tokens.css
5b939f1f3eb90b428008fda75720f6e3a4e844c486668931db3d52bffb987b53 tokens.json