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

@ -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