diff --git a/data/tv-spec/spec.md b/data/tv-spec/spec.md index 913f31f5..3282a8d8 100644 --- a/data/tv-spec/spec.md +++ b/data/tv-spec/spec.md @@ -1,6 +1,6 @@ # Wolfskin TV specification — revision 6 -2026-11-07. Revision 6 adds the approved direction-A motion and loading states from [the motion reference](https://pages.justinvisser.org/wolfskin/tv-motion-speed.html), frozen with SHA-256 checksums at `data/tv-v2/motion-2026-11-06/`, and the subsequently approved **option B** [card metadata frames](https://pages.justinvisser.org/wolfskin/tv-card-meta-frames.html). Revision-5 feedback below still applies except where the card amendment explicitly supersedes it. Unchanged navigation, typography and colours remain governed by the [written specification](https://pages.justinvisser.org/wolfskin/tv-spec). The revision-5 prototype delta remains `data/tv-v2/feedback-2026-11-06/`. +2026-11-06. Revision 6 adds the approved direction-A motion and loading states from [the motion reference](https://pages.justinvisser.org/wolfskin/tv-motion-speed.html), frozen with SHA-256 checksums at `data/tv-v2/motion-2026-11-06/`. Revision-5 feedback below still applies. Unchanged static geometry, navigation, typography and colours remain governed by the approved frames and the [written specification](https://pages.justinvisser.org/wolfskin/tv-spec). The revision-5 prototype delta remains `data/tv-v2/feedback-2026-11-06/`. ## Who’s watching @@ -83,29 +83,6 @@ Discover key traversal skips pending lanes in both directions. If every remainin Native **1920×1080** before/after frames and the frame-budget inventory are published at [the motion build proof](https://pages.justinvisser.org/wolfskin/tv-motion-build/). Before frames use the pre-change surface sources in this same isolated worktree; after frames use the shipped Compose surfaces. No device access, installation or optimized APK is part of this lane. On-box acceptance remains: no series layout change after first content, Home scrolling no worse than **39.6% janky / p90 65 ms**. Native JVM rendering does not measure that GPU/frame-timing target. -## Card metadata and bottom state line — revision 6, option B - -Film cards show year, duration and a **24 px watched check**. Episodes show their own duration and check, without a year; series show year and check, never an aggregate runtime. Resuming and unwatched cards have no check. Compact metadata omits media-type words, season counts, character roles, ownership and remaining-time prose. Duration rounds total minutes before formatting hours/minutes; narrow layouts tighten metadata gaps from **12 → 8 → 4 px**, never shrink text or ellipsise duration. - -Movies, Shows, Anime, Home See all and person filmography always show their card-width title/subtitle. Titles are one-line ellipses: **28 px / 32 px line**, medium muted at rest, bold foreground when focused. Metadata is **24 px / 26 px line**. The **96 px** poster caption band starts its text **38 px** below unscaled art. Home poster captions remain focus-only; Home landscape titles stay on the art with focus-only metadata below it. Landscape shelves reserve **64 px** below art. Loading placeholders reserve identical **blank** caption bands: only art-sized blocks are painted, with no title/subtitle pills. Each shelf still shares the motion lane's single pulse layer. - -| Geometry at 1920×1080 | Pixels | -|---|---:| -| Library row pitch | 524 | -| Home poster shelf pitch | 632 | -| Home wide / tile shelf pitch | 522 / 441 | -| Initial library art row tops | 372 / 896 | -| Pinned library art row tops / second-row bottom | 168 / 692 / 1076 | -| Focus ring to caption / caption to next focused row ring | 20.48 / 26.48 | - -One bottom state line replaces the independent resume bar. Unwatched and Discover have no state line; watched has a full-width **3 px accent** line; resume has proportional **6 px accent** fill on a full-width `color.progressTrack` track. Left/right are zero, with **2 px bottom backing** and **2 px opaque separator** above the fill, clipped by the art's **16 px** radius. Focus scales the art/line by **1.08**, with its **4 px** straddle ring drawn independently above them. - -Completed Home progress (`resumeSeconds / runtimeSeconds >= 1`) follows the same state precedence as library/title cards: it is no longer resuming, so a played title uses the 3 px watched line and an unplayed title has no state line. - -Justin's additional correction is binding even where a reference frame leaks: **the lowest card row must contain card/edge/fill colour, never art**. Clip artwork out of the backing band before painting it; merely overdrawing after a rounded clip can retain poster colours in antialiased corners. The backing is opaque `color.bg` (**#07070a**). Precompose and quantise the track over that backing once to preserve the approved **#3d3d3f** browser pixels instead of native alpha-rounding differences; fill is **#cc9fff**. The approved Annie Hall and Locke treatment frames themselves expose artwork below their inset state line; the native build deliberately makes that row solid. - -Native same-fixture comparisons and the exhaustive real-art bottom-row proof are published in [the card metadata build gallery](https://pages.justinvisser.org/wolfskin/tv-card-meta-build/). `CardBottomPixelTest` checks white Annie Hall, dark Locke, colourful Apocalypse Now and Avatar Aang at rest and individually focused; every pixel across each full bottom row must equal an art-free rendering of the same clipped backing/ring, while straight-edge and fill/track pixels additionally match exact state colours. It also checks Home/library parity when progress reaches or exceeds completion. `CardGeometryTest` measures narrow fitting, the 24 px check, grid/shelf pitches, pinned rows and ring clearances to 0.1 px, and asserts every pixel in reserved loading caption bands remains background-coloured. `CardCaptionMotionTest` compares native glyph pixels after changing a focused title and moving across a focused Home shelf. Reference comparisons retain documented renderer/fixture artifacts; unapproved visible design changes remain merge blockers. - ## Sources and verification Authoritative changed values live in `wolfskin-design/tokens.json`, `title-tokens.json` and `discover-tokens.json`; regenerate Kotlin, CSS and the drift manifest with `python3 wolfskin-design/generate_tokens.py`. diff --git a/docker/README.md b/docker/README.md index ec71b8e7..efe8d3c6 100644 --- a/docker/README.md +++ b/docker/README.md @@ -520,9 +520,9 @@ Down clamps a short final title row before entering the separate Try again / Sho more row; an empty slot cannot activate recovery. An unavailable library hides its unknown item count. -Library titles and compact subtitles remain visible at rest, with one-line, -card-width ellipsis rather than a 640 px focused caption. The letter rail uses -bold 24 px letters, with the focused letter bold 28 px. +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 and Settings header initials use the muted colour over the person's fill; navigation and picker avatars retain their dark initials. @@ -566,35 +566,12 @@ Title shelves reserve vertical drawing room for the scaled ring. Before scaling the stroke, ring-to-neighbour clearances round to 28/22/16 px for poster/tile/wide cards; including stroke scale they are 27.6/21.8/16.1 px. -Cards use the approved option-B bottom state line instead of a separate inset -resume bar: watched is 3 px accent; resume is 6 px proportional accent over -`color.progressTrack`; unwatched and Discover have no line. Opaque background -backs the entire strip, including its bottom 2 px and the 2 px separator above -the fill. Artwork is clipped out of the backing band before painting it so -rounded-corner antialiasing cannot leak poster pixels into the lowest row. -The separately drawn focus ring stays above the line. - -Film subtitles are year + duration + a 24 px watched check, episode subtitles -are their own duration + check, and series subtitles are year + check with no -single runtime. Resuming cards have no check. The poster caption band remains -96 px; Home poster captions are focus-only. Home landscapes retain titles on -the art and reserve 64 px for focused metadata below it, making wide/tile shelf -pitches 522/441 px; poster shelves remain 632 px and grids remain 524 px. -Loading rows reserve the same blank caption bands and share one pulse layer; -only art-sized blocks are painted, with no title/subtitle placeholder pills. - -`CardBottomPixelTest` samples full native bottom rows on four real posters at -rest and individually focused, including white Annie Hall and dark Locke. -`CardGeometryTest` measures 210/327 px caption fitting, card-width titles, -24 px checks, row pitches, pinned rows and ring clearances to 0.1 px, and checks -that every reserved loading-caption pixel remains background-coloured. -`CardCaptionMotionTest` checks visible native glyph pixels when a still-focused -caption changes title directly or while moving across a Home shelf. -The [native option-B proof gallery](https://pages.justinvisser.org/wolfskin/tv-card-meta-build/) -contains the same illustrative watch-state data as the approved frames and -records renderer/fixture artifacts; unapproved design changes block merge. -`LibrarySpacingRegressionTest` retains the deep focused index across the -199↔200-title letter-rail boundary. +Poster progress uses the independent 18 px title-card inset, not caption spacing: +the track remains 220 px wide on a 256 px poster. Library focus-follow restarts +when the effective column count, focused row or header geometry changes, retaining +deep focus across live snapshots that add or remove the letter rail. +`LibrarySpacingRegressionTest` renders both progress geometry and the unchanged +focused index across the 199↔200-title boundary. `LibraryCaptureTest.spacingShowsFirstFocused`, `HomeCaptureTest.spacingNextUpFirstFocused` and diff --git a/wolfskin-design/home-tokens.json b/wolfskin-design/home-tokens.json index 7cceeaa7..a574a31d 100644 --- a/wolfskin-design/home-tokens.json +++ b/wolfskin-design/home-tokens.json @@ -114,6 +114,12 @@ "dp": 18.0, "source": "locked index.html:94-165,180-198,1014-1029,1276-1289" }, + "captionWidth": { + "$type": "dimension", + "px": 640, + "dp": 320.0, + "source": "locked index.html:94-165,180-198,1014-1029,1276-1289" + }, "plateTop": { "$type": "dimension", "px": 14, @@ -234,6 +240,18 @@ "dp": 30.0, "source": "locked index.html:94-165,180-198,1014-1029,1276-1289" }, + "progressInset": { + "$type": "dimension", + "px": 18, + "dp": 9.0, + "source": "locked index.html:94-165,180-198,1014-1029,1276-1289" + }, + "progressBottom": { + "$type": "dimension", + "px": 10, + "dp": 5.0, + "source": "locked index.html:94-165,180-198,1014-1029,1276-1289" + }, "endTop": { "$type": "dimension", "px": 40, @@ -298,6 +316,7 @@ "savedPadding": { "$type": "dimension", "px": 20, "dp": 10.0, "source": "locked index.html:87" }, "savedGap": { "$type": "dimension", "px": 10, "dp": 5.0, "source": "locked index.html:87" }, "savedIcon": { "$type": "dimension", "px": 28, "dp": 14.0, "source": "locked index.html:88" }, - "savedRadius": { "$type": "dimension", "px": 28, "dp": 14.0, "source": "locked index.html:87" } + "savedRadius": { "$type": "dimension", "px": 28, "dp": 14.0, "source": "locked index.html:87" }, + "posterProgressBottom": { "$type": "dimension", "px": 12, "dp": 6.0, "source": "locked index.html:138-140" } } } diff --git a/wolfskin-design/shell-tokens.json b/wolfskin-design/shell-tokens.json index 3c29b7ae..e66ac6ec 100644 --- a/wolfskin-design/shell-tokens.json +++ b/wolfskin-design/shell-tokens.json @@ -367,6 +367,9 @@ "chipGap": {"$type":"dimension","px":16,"dp":8,"source":"index.html:184-185"}, "chipTop": {"$type":"dimension","px":16,"dp":8,"source":"index.html:184"}, "chipIcon": {"$type":"dimension","px":26,"dp":13,"source":"index.html:185"}, + "captionWidth": {"$type":"dimension","px":640,"dp":320,"source":"index.html:151"}, + "captionSeparatorOpacity": {"$type":"number","$value":0.4,"source":"index.html:1207; spec 4.4"}, + "captionFactsGap": {"$type":"dimension","px":18,"dp":9,"source":"index.html:154"}, "badgeIcon": {"$type":"dimension","px":28,"dp":14,"source":"index.html:142"}, "plateTop": {"$type":"dimension","px":14,"dp":7,"source":"index.html:162"}, "plateSide": {"$type":"dimension","px":16,"dp":8,"source":"index.html:162"}, diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/Caption.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/Caption.kt index 065e26e4..3b2b2f8e 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/Caption.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/Caption.kt @@ -10,11 +10,18 @@ import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.text.TextStyle import androidx.tv.material3.Text +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.ui.draw.clipToBounds +import androidx.compose.ui.layout.Layout +import androidx.compose.ui.unit.constrainHeight /** Captions rise inside the reserved band without changing measured geometry. */ @Composable -fun Caption(title: String, facts: String, visible: Boolean, modifier: Modifier = Modifier) { +fun Caption(title: String, facts: String, visible: Boolean, modifier: Modifier = Modifier, accentLastFact: Boolean = false, accentFirstFact: Boolean = false, + titleStyle: TextStyle = WolfskinTokens.Type.button, factsStyle: TextStyle = WolfskinTokens.Type.caption, + titleOverflow: TextOverflow = TextOverflow.Ellipsis) { val alpha = remember(title, facts) { Animatable(0f) } val entryProgress = remember(title, facts) { mutableFloatStateOf(0f) } val reduced = reducedMotion() @@ -25,10 +32,47 @@ fun Caption(title: String, facts: String, visible: Boolean, modifier: Modifier = if (visible) entryProgress.floatValue = value } } - val titleStyle = WolfskinTokens.Type.button - val factsStyle = WolfskinTokens.Type.caption Column(modifier.graphicsLayer { this.alpha = alpha.value; translationY = if (reduced) 0f else rise * (1f - entryProgress.floatValue) }) { - Text(title, color = WolfskinTokens.Color.fg, style = titleStyle, modifier = Modifier.exactLineBox(titleStyle), maxLines = 1, overflow = TextOverflow.Ellipsis) - Text(facts, color = WolfskinTokens.Color.muted, style = factsStyle, modifier = Modifier.exactLineBox(factsStyle), maxLines = 1, overflow = TextOverflow.Ellipsis) + Text(title, color = WolfskinTokens.Color.fg, style = titleStyle, modifier = Modifier.exactLineBox(titleStyle), maxLines = 1, overflow = titleOverflow) + CaptionFacts(facts, accentFirstFact, accentLastFact, factsStyle) + } +} + +/** CSS flex facts: only coloured role/ownership spans shrink; ordinary facts and dots keep their width. */ +@Composable +private fun CaptionFacts(facts: String, accentFirst: Boolean, accentLast: Boolean, style: TextStyle) { + val parts = remember(facts) { facts.split(" · ") } + val muted = WolfskinTokens.Color.muted + val colour = WolfskinTokens.Color.accentText + Layout( + content = { + parts.forEachIndexed { index, part -> + val accented = (index == 0 && accentFirst) || (index == parts.lastIndex && accentLast) + Text(part, color = if (accented) colour else muted, style = style, modifier = Modifier.exactLineBox(style), maxLines = 1, overflow = TextOverflow.Clip) + if (index != parts.lastIndex) Text("·", color = muted.copy(alpha = muted.alpha * WolfskinTokens.Library.captionSeparatorOpacity), style = style, modifier = Modifier.exactLineBox(style)) + } + }, + modifier = Modifier.fillMaxWidth().clipToBounds(), + ) { children, constraints -> + val gap = WolfskinTokens.Library.captionFactsGap.roundToPx() + val basis = IntArray(children.size) + var fixed = gap * (children.size - 1).coerceAtLeast(0) + var flexible = 0 + fun shrinks(index: Int) = (index == 0 && accentFirst) || (index == children.lastIndex && accentLast) + children.forEachIndexed { index, child -> + val width = child.maxIntrinsicWidth(constraints.maxHeight) + basis[index] = width + if (shrinks(index)) flexible += width else fixed += width + } + val available = (constraints.maxWidth - fixed).coerceAtLeast(0) + val ratio = if (flexible > available) available.toFloat() / flexible else 1f + val measured = children.mapIndexed { index, child -> + val width = if (shrinks(index)) (basis[index] * ratio).toInt() else basis[index] + child.measure(constraints.copy(minWidth = 0, maxWidth = width, minHeight = 0)) + } + layout(constraints.maxWidth, constraints.constrainHeight(measured.maxOfOrNull { it.height } ?: 0)) { + var x = 0 + measured.forEach { child -> child.placeRelative(x, 0); x += child.width + gap } + } } } diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/CardCaption.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/CardCaption.kt deleted file mode 100644 index 9d6f28a8..00000000 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/CardCaption.kt +++ /dev/null @@ -1,81 +0,0 @@ -package org.justinvisser.wolfskin.design - -import androidx.compose.animation.core.Animatable -import androidx.compose.animation.core.tween -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.size -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.remember -import androidx.compose.runtime.mutableFloatStateOf -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clipToBounds -import androidx.compose.ui.graphics.graphicsLayer -import androidx.compose.ui.layout.Layout -import androidx.compose.ui.platform.LocalDensity -import androidx.compose.ui.text.style.TextOverflow -import androidx.compose.ui.unit.Constraints -import androidx.compose.ui.res.painterResource -import androidx.tv.material3.Icon -import androidx.tv.material3.Text -import org.justinvisser.wolfskin.design.WolfskinTokens.CardMetadata as M -import org.justinvisser.wolfskin.design.WolfskinTokens.Color as C - -/** Card-width titles never displace runtime or watched metadata into a neighbouring slot. */ -@Composable -fun CardCaption(title: String, year: Int?, duration: String?, watched: Boolean, focused: Boolean, - modifier: Modifier = Modifier, focusOnly: Boolean = false, showTitle: Boolean = true) { - val transition = if (focusOnly) { - val alpha = remember(title) { Animatable(0f) } - val entryProgress = remember(title) { mutableFloatStateOf(0f) } - val reduced = reducedMotion() - val rise = with(LocalDensity.current) { WolfskinTokens.Motion.captionRise.toPx() } - LaunchedEffect(alpha, focused, reduced) { - if (focused && !reduced) kotlinx.coroutines.delay(WolfskinTokens.Motion.captionDelay.toLong()) - alpha.animateTo(if (focused) 1f else 0f, tween(motionDuration(if (focused) WolfskinTokens.Motion.focusMove else WolfskinTokens.Motion.captionOut, reduced), easing = WolfskinTokens.Motion.easeOut)) { - if (focused) entryProgress.floatValue = value - } - } - Modifier.graphicsLayer { - this.alpha = alpha.value - translationY = if (reduced) 0f else rise * (1f - entryProgress.floatValue) - } - } else Modifier - Column(modifier.then(transition)) { - if (showTitle) { - val style = if (focused || focusOnly) M.titleFocus else M.titleRest - Text(title, color = if (focused || focusOnly) C.fg else C.muted, style = style, - modifier = Modifier.fillMaxWidth().exactLineBox(style), maxLines = 1, overflow = TextOverflow.Ellipsis) - } - CardMetadata(year, duration, watched) - } -} - -/** Measured gaps tighten before any fixed-size year, runtime or check can be clipped. */ -@Composable -fun CardMetadata(year: Int?, duration: String?, watched: Boolean, modifier: Modifier = Modifier) { - Layout(content = { - if (year != null) Text(year.toString(), color = C.muted, style = M.subtitle, modifier = Modifier.exactLineBox(M.subtitle), maxLines = 1) - if (duration != null) Text(duration, color = C.muted, style = M.subtitle, modifier = Modifier.exactLineBox(M.subtitle), maxLines = 1) - if (watched) Icon(painterResource(R.drawable.card_watched_check), "Watched", Modifier.size(M.check), tint = C.muted) - }, modifier = modifier.fillMaxWidth().clipToBounds()) { children, constraints -> - val sizes = IntArray(children.size) { children[it].maxIntrinsicWidth(constraints.maxHeight) } - val gaps = (children.size - 1).coerceAtLeast(0) - val textWidth = sizes.sum() - val gap = when { - textWidth + gaps * M.gap.roundToPx() <= constraints.maxWidth -> M.gap.roundToPx() - textWidth + gaps * M.compactGap.roundToPx() <= constraints.maxWidth -> M.compactGap.roundToPx() - else -> M.minimumGap.roundToPx() - } - val measured = Array(children.size) { children[it].measure(Constraints.fixedWidth(sizes[it])) } - val height = with(this) { M.subtitle.lineHeight.toPx().toInt() } - layout(constraints.maxWidth, height) { - var left = 0 - measured.forEach { child -> - child.placeRelative(left, (height - child.height) / 2) - left += child.width + gap - } - } - } -} diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/DiscoverShelf.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/DiscoverShelf.kt index ed527a73..2551560a 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/DiscoverShelf.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/DiscoverShelf.kt @@ -55,7 +55,7 @@ fun DiscoverShelf(state: DiscoverState, lane: DiscoverLane, row: Int) { LazyRow(state = cards, userScrollEnabled = false, contentPadding = PaddingValues(start = L.safeSide, end = WolfskinTokens.Shell.stageWidth - L.safeSide - (K.posterW * slots + K.posterGap * (slots - 1)), top = K.posterGap, bottom = K.posterGap), horizontalArrangement = Arrangement.spacedBy(K.posterGap), - modifier = Modifier.offset(y = D.shelfHeadingBand - K.posterGap).height(height + (if (services) WolfskinTokens.Shell.zero else K.posterCaptionBand) + K.posterGap * 2).skeletonPulse(lane.pending).rowReveal(lane.id, !lane.pending)) { + modifier = Modifier.offset(y = D.shelfHeadingBand - K.posterGap).height(height + K.posterGap * 2).skeletonPulse(lane.pending).rowReveal(lane.id, !lane.pending)) { if (services) itemsIndexed(state.snapshot.services, key = { _, service -> "service:${service.id}" }, contentType = { _, _ -> "entry" }) { index, service -> Box(Modifier.size(K.entryW, K.entryH).focusScale(active && index == column, WolfskinTokens.Focus.scaleCard) .focusRing(active && index == column, WolfskinTokens.Radius.card, WolfskinTokens.Focus.ringCardRing, FocusLift.Card) @@ -66,7 +66,7 @@ fun DiscoverShelf(state: DiscoverState, lane: DiscoverLane, row: Int) { } } else if (lane.pending) { items(slots, key = { "pending:$it" }) { - PosterCaptionSkeleton() + Box(Modifier.size(K.posterW, K.posterH).background(C.surface2, RoundedCornerShape(WolfskinTokens.Radius.card))) } } else { itemsIndexed(lane.titles, key = { _, item -> item.key }, contentType = { _, _ -> "poster" }) { index, item -> @@ -80,16 +80,15 @@ fun DiscoverShelf(state: DiscoverState, lane: DiscoverLane, row: Int) { } } } - if (!services && !lane.pending) { + if (!services && active && !lane.pending) { val item = lane.titles.getOrNull(column) - val captionModifier = Modifier.offset(L.safeSide + K.posterPitch * (column - first), D.shelfHeadingBand + K.posterH + L.captionOffset).width(K.posterW) - if (item != null) { - val card = item.libraryCard(state) - CardCaption(item.title, card.year, card.duration, false, active, captionModifier, focusOnly = true) - } else Caption( - if (lane.tail == DiscoverTail.SeeAll) "See all ${lane.title}" else if (lane.tail == DiscoverTail.Retry) "Try again" else "Show more", - if (lane.tail == DiscoverTail.SeeAll) "${lane.total} titles" else if (lane.tail == DiscoverTail.Retry) "${lane.title} stopped loading" else "The last read only repeated titles", - active, captionModifier) + Caption( + item?.title ?: if (lane.tail == DiscoverTail.SeeAll) "See all ${lane.title}" else if (lane.tail == DiscoverTail.Retry) "Try again" else "Show more", + if (item != null) buildDiscoverCaptionFacts(item, state, lane.hidden) else if (lane.tail == DiscoverTail.SeeAll) "${lane.total} titles" else if (lane.tail == DiscoverTail.Retry) "${lane.title} stopped loading" else "The last read only repeated titles", + true, Modifier.offset(L.safeSide + K.posterPitch * (column - first), D.shelfHeadingBand + K.posterH + L.captionOffset).width(WolfskinTokens.Library.captionWidth), + accentFirstFact = lane.hidden, + accentLastFact = item != null && (item.heldBy.isNotEmpty() || state.requests[item.key].orEmpty().isNotEmpty()), + ) } } } @@ -109,3 +108,7 @@ internal fun DiscoverTailCard(lane: DiscoverLane, focused: Boolean, onClick: () else CatalogueRecoveryCard(lane.tail == DiscoverTail.Retry, focused, onClick) } +internal fun buildDiscoverCaptionFacts(title: DiscoverTitle, state: DiscoverState, hidden: Boolean): String { + val card = title.libraryCard(state) + return (if (hidden) "Hidden from your picks · " else "") + card.facts(true) +} diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/HomeModel.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/HomeModel.kt index 8bf7d6e2..81fbd5eb 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/HomeModel.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/HomeModel.kt @@ -38,6 +38,7 @@ data class HomeTitle( } val remaining: String get() = "${(((runtimeSeconds ?: 0) - resumeSeconds) / 60).roundToInt().coerceAtLeast(1)} min left" val subtitle: String get() = if (isEpisode) "$episodeLabel · $title" else "${if (type == "movie") "Movie" else "Series"} · ${year ?: ""}" + val facts: String get() = listOfNotNull(year?.toString(), if (type == "movie") runtimeSeconds?.let { runtime } ?: "Movie" else seasons?.let { "$it ${if (it == 1) "season" else "seasons"}" } ?: "Series").joinToString(" · ") } /** Only observed, range-checked scores reach this model; formatting follows the TV locale. */ diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/HomeScreen.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/HomeScreen.kt index 11725f4a..542e0704 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/HomeScreen.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/HomeScreen.kt @@ -119,7 +119,7 @@ fun HomeScreen( private fun rowHeight(row: HomeRow) = when (row.kind) { HomeRowKind.Hero -> L.row_hero - HomeRowKind.Shelf -> L.rowHeadingBand + row.cardHeight + (if (row.cardKind == HomeCardKind.Poster) WolfskinTokens.Card.posterCaptionBand else WolfskinTokens.CardMetadata.landscapeBand) + L.rowGapAfter + HomeRowKind.Shelf -> L.rowHeadingBand + row.cardHeight + (if (row.cardKind == HomeCardKind.Poster) WolfskinTokens.Card.posterCaptionBand else WolfskinTokens.Shell.zero) + L.rowGapAfter HomeRowKind.Section -> L.row_section HomeRowKind.State -> L.row_state HomeRowKind.Note -> L.row_note diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/HomeShelf.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/HomeShelf.kt index d1827758..6bf8dea4 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/HomeShelf.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/HomeShelf.kt @@ -8,6 +8,7 @@ import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.SideEffect @@ -19,6 +20,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Brush import androidx.compose.ui.platform.testTag +import androidx.compose.ui.draw.clip import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.Dp import androidx.compose.ui.zIndex @@ -34,7 +36,7 @@ import org.justinvisser.wolfskin.design.HomeType as T fun HomeShelf(row: HomeRow, memory: HomeViewMemory, focused: Boolean, column: Int, ratings: List, onCard: (Int) -> Unit, onHover: (Int) -> Unit) { val width = row.cardWidth val height = row.cardHeight - val caption = if (row.cardKind == HomeCardKind.Poster) K.posterCaptionBand else WolfskinTokens.CardMetadata.landscapeBand + val caption = if (row.cardKind == HomeCardKind.Poster) K.posterCaptionBand else WolfskinTokens.Shell.zero val first = row.adjustedFirst(column, memory.firstCards[row.id] ?: 0) SideEffect { if (memory.firstCards[row.id] != first) memory.firstCards[row.id] = first } val track = remember(memory, row.id) { memory.tracks.getOrPut(row.id) { LazyListState(first) } } @@ -48,36 +50,39 @@ fun HomeShelf(row: HomeRow, memory: HomeViewMemory, focused: Boolean, column: In Box(Modifier.fillMaxWidth().height(L.rowHeadingBand + height + caption + L.rowGapAfter)) { if (!row.skeleton) Text(row.title, color = C.fg, style = T.rowHeading, maxLines = 1, modifier = Modifier.offset(x = L.safeSide)) val cards = if (row.skeleton) List(if (row.cardKind == HomeCardKind.Poster) 8 else 5) { null } else row.cards + if (row.hasSeeAll) listOf(null) else emptyList() - LazyRow(state = track, modifier = Modifier.fillMaxWidth().offset(y = L.rowHeadingBand - L.rowGapAfter).height(height + caption + L.rowGapAfter * 2).skeletonPulse(row.skeleton).rowReveal(row.id, !row.skeleton), contentPadding = PaddingValues(horizontal = L.safeSide, vertical = L.rowGapAfter), horizontalArrangement = Arrangement.spacedBy(K.posterGap), userScrollEnabled = !row.skeleton) { + LazyRow(state = track, modifier = Modifier.fillMaxWidth().offset(y = L.rowHeadingBand - L.rowGapAfter).height(height + L.rowGapAfter * 2).skeletonPulse(row.skeleton).rowReveal(row.id, !row.skeleton), contentPadding = PaddingValues(horizontal = L.safeSide, vertical = L.rowGapAfter), horizontalArrangement = Arrangement.spacedBy(K.posterGap), userScrollEnabled = !row.skeleton) { itemsIndexed(cards, key = { index, title -> title?.id ?: "${row.id}:tail:$index" }, contentType = { _, title -> if (row.skeleton) "skeleton" else if (title == null) "seeAll" else row.cardKind.name.lowercase() }) { index, title -> val selected = !row.skeleton && focused && column == index val cardModifier = Modifier.zIndex(if (selected) 2f else 0f).testTag("home-card:${row.id}:$index") .then(if (!row.skeleton) Modifier.focusOnPointerEnter { onHover(index) } else Modifier) - if (row.skeleton) { - PosterCaptionSkeleton(width, height, caption) - } else if (title == null) { + if (title == null && !row.skeleton) { LibrarySeeAllCard(row.total, selected, { onCard(index) }, cardModifier, width, height, T.listTitle, T.label) - } else { - val progress = if (row.catalogue || title.runtimeSeconds == null || title.runtimeSeconds <= 0 || title.resumeSeconds <= 0) 0f else (title.resumeSeconds / title.runtimeSeconds).toFloat() - TitleCard(selected, cardModifier.clickable { onCard(index) }, width, height, - watched = !row.catalogue && title.played, progress = if (progress >= 1f) 0f else progress) { + } else TitleCard(selected, cardModifier.then(if (!row.skeleton) Modifier.clickable { onCard(index) } else Modifier), width, height) { + if (title == null) Box(Modifier.fillMaxSize().background(C.surface2)) else { TitleArtwork(title.title, title.type, title.poster, width, height, Modifier.fillMaxSize(), backdrop = title.backdrop, posterOnly = title.posterOnly, landscape = row.cardKind != HomeCardKind.Poster) if (row.cardKind != HomeCardKind.Poster) TileOverlay(title) if (row.catalogue && title.held) Box(Modifier.align(Alignment.BottomEnd).padding(L.badgeInset).size(L.badge).background(C.ok, CircleShape), contentAlignment = Alignment.Center) { Text("✓", color = C.onFocus, style = T.button) } + if (title.resumeSeconds > 0 && title.runtimeSeconds != null) Box(Modifier.align(Alignment.BottomCenter).padding(horizontal = H.progressInset).padding(bottom = if (row.cardKind == HomeCardKind.Poster) H.posterProgressBottom else H.progressBottom).fillMaxWidth().height(L.progressBar).clip(RoundedCornerShape(WolfskinTokens.Radius.progress)).background(C.progressTrack)) { + Box(Modifier.fillMaxHeight().fillMaxWidth((title.resumeSeconds / title.runtimeSeconds).toFloat().coerceIn(0f, 1f)).background(C.accent)) + } HomeRatingPlate(if (selected) ratings else emptyList(), selected, Modifier.fillMaxWidth()) } } } } - if (!row.skeleton) { + if (row.cardKind == HomeCardKind.Poster && focused && !row.skeleton) { val title = row.cards.getOrNull(column) - val captionModifier = Modifier.offset(L.safeSide + (width + K.posterGap) * (column - first), L.rowHeadingBand + height + L.captionOffset).width(width) - if (title == null) { - if (row.cardKind == HomeCardKind.Poster) Caption("See all ${row.title}", "${row.total} titles", focused, captionModifier) - } else CardCaption(title.displayTitle, if (title.isEpisode) null else title.year, - title.takeIf { it.type == "movie" || it.isEpisode }?.runtimeSeconds?.takeIf { it > 0 }?.let { title.runtime }, - !row.catalogue && title.played && title.resumeSeconds <= 0, focused, captionModifier, - focusOnly = true, showTitle = row.cardKind == HomeCardKind.Poster) + val availability = when { + title == null -> null + row.catalogue -> if (title.held) "In your library" else title.otherOwner?.let { "In $it’s library" } + title.played -> "Watched" + title.resumeSeconds > 0 -> title.remaining + else -> null + } + val facts = remember(title, row.total, availability) { + (title?.facts ?: "${row.total} titles").replace(" · ", " · ") + availability?.let { " · $it" }.orEmpty() + } + Caption(title?.title ?: "See all ${row.title}", facts, true, Modifier.offset(L.safeSide + (width + K.posterGap) * (column - first), L.rowHeadingBand + height + L.captionOffset).width(H.captionWidth), accentLastFact = availability != null, titleStyle = T.button, factsStyle = T.caption, titleOverflow = TextOverflow.Ellipsis) } } } @@ -85,11 +90,9 @@ fun HomeShelf(row: HomeRow, memory: HomeViewMemory, focused: Boolean, column: In @Composable private fun TileOverlay(title: HomeTitle) { Box(Modifier.fillMaxSize(), contentAlignment = Alignment.BottomStart) { - val metadata = WolfskinTokens.CardMetadata - Column(Modifier.padding(bottom = metadata.bottom + metadata.separator + metadata.resumeLine).fillMaxWidth() - .background(Brush.verticalGradient(listOf(C.bg.copy(alpha = 0f), C.bg.copy(alpha = .92f)))) - .padding(start = H.cardPadding, end = H.cardPadding, top = H.cardOverlayTop, bottom = H.cardOverlayBottom)) { - Text(title.displayTitle, modifier = Modifier.exactLineBox(metadata.titleFocus), color = C.fg, style = metadata.titleFocus, maxLines = 1, overflow = TextOverflow.Ellipsis) + Column(Modifier.fillMaxWidth().background(Brush.verticalGradient(listOf(C.bg.copy(alpha = 0f), C.bg.copy(alpha = .92f)))).padding(start = H.cardPadding, end = H.cardPadding, top = H.cardOverlayTop, bottom = H.cardOverlayBottom)) { + Text(title.displayTitle, modifier = Modifier.exactLineBox(T.button), color = C.fg, style = T.button, maxLines = 1, overflow = TextOverflow.Ellipsis) + Text(title.subtitle, modifier = Modifier.exactLineBox(T.caption), color = C.muted, style = T.caption, maxLines = 1, overflow = TextOverflow.Ellipsis) } } } diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/LibraryGridState.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/LibraryGridState.kt index d4f09754..2b6bcc1a 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/LibraryGridState.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/LibraryGridState.kt @@ -18,7 +18,6 @@ interface PosterContent { val poster: String? val ratings: List val progress: Float - val posterWatched: Boolean val badge: String? } @@ -41,15 +40,18 @@ data class LibraryTitle( ) : PosterContent { override val posterTitle: String get() = title override val posterIsMovie: Boolean get() = type == "movie" - override val posterWatched: Boolean get() = played - val duration: String? get() = if (type == "movie" || type == "episode") runtimeSeconds?.takeIf { it > 0 }?.let { - val minutes = (it + 30) / 60 - if (minutes >= 60) "${minutes / 60}h ${minutes % 60}m" else "${minutes}m" - } else null val sortName: String = title.replace(leadingArticle, "").lowercase(Locale.ROOT) val catalogueKey: String = "$type:${tmdbId ?: id}" val letter: Char = sortName.firstOrNull()?.uppercaseChar()?.takeIf { it in 'A'..'Z' } ?: '#' val leadScore: Double get() = ratings.firstOrNull()?.let { if (it.source.startsWith("rt")) it.score else it.score * 10 } ?: -1.0 + + fun facts(catalogue: Boolean): String = buildList { + year?.let { add(it.toString()) } + add(if (type == "movie") runtimeSeconds?.let { val minutes = (it + 30) / 60; if (minutes >= 60) "${minutes / 60}h ${minutes % 60}m" else "${minutes}m" } ?: "Movie" else seasons?.let { "$it season${if (it == 1) "" else "s"}" } ?: "Series") + if (catalogue) { if (availability.isNotEmpty()) add(availability) } + else if (played) add("Watched") + else if (progress > 0f && runtimeSeconds != null) add("${((runtimeSeconds * (1 - progress) + 30) / 60).toInt().coerceAtLeast(1)} min left") + }.joinToString(" · ") } /** Ordered rating entries are formatted after the active TV locale is known. */ diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/LibraryPoster.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/LibraryPoster.kt index e3df4802..41e1e053 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/LibraryPoster.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/LibraryPoster.kt @@ -25,12 +25,9 @@ fun LibraryPoster( catalogue: Boolean, modifier: Modifier = Modifier, offlineArt: ((String) -> ImageBitmap?)? = null, - showWatchState: Boolean = !catalogue, ) { val poster = title.poster - val progress = if (showWatchState) title.progress else 0f - TitleCard(focused, modifier.testTag("poster-${title.id}"), watched = showWatchState && title.posterWatched, - progress = if (progress >= 1f) 0f else progress) { + TitleCard(focused, modifier.testTag("poster-${title.id}")) { val bitmap = remember(poster, offlineArt) { poster?.let { offlineArt?.invoke(it) } } if (bitmap != null) Image(bitmap, title.posterTitle, Modifier.fillMaxSize(), contentScale = ContentScale.Crop) else if (poster != null && offlineArt == null) { @@ -42,6 +39,13 @@ fun LibraryPoster( onSuccess = { ready = true }, onError = { ready = false }) } else MediaArtworkFallback(title.posterTitle, title.posterIsMovie) if (title.ratings.isNotEmpty()) LibraryRatingPlate(title.ratings, visible = ratingsVisible) + val progress = title.progress + if (progress > 0f && progress < 1f) { + Box(Modifier.align(Alignment.BottomCenter).padding(horizontal = WolfskinTokens.Title.cardProgressInset, vertical = WolfskinTokens.Layout.navItemGap) + .fillMaxWidth().height(WolfskinTokens.Layout.progressBar).testTag("poster-progress-${title.id}").background(C.progressTrack, CircleShape)) { + Box(Modifier.fillMaxHeight().fillMaxWidth(progress).background(C.accent, CircleShape)) + } + } if (catalogue && title.badge != null) { val held = title.badge == "held" val color = if (title.badge == "failed") C.fail else C.accent diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/LibraryScreen.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/LibraryScreen.kt index 93c55c85..393620c3 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/LibraryScreen.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/LibraryScreen.kt @@ -121,7 +121,7 @@ fun LibraryScreen( LibraryStatus.Loading -> items(3, key = { "skeleton-$it" }, contentType = { "grid" }) { Box(Modifier.fillMaxWidth().height(rowHeight)) { Row(Modifier.padding(start = L.safeSide).skeletonPulse(), horizontalArrangement = Arrangement.spacedBy(K.posterGap)) { - repeat(grid.columns) { PosterCaptionSkeleton() } + repeat(grid.columns) { Box(Modifier.size(K.posterW, K.posterH).background(C.surface2, RoundedCornerShape(WolfskinTokens.Radius.card))) } } } } @@ -139,7 +139,6 @@ fun LibraryScreen( val title = items.getOrNull(index) ?: break key(if (grid.catalogue) title.catalogueKey else title.id) { val isFocused = active && !grid.toolbar && !grid.inRail && grid.index == index - Box(Modifier.width(K.posterW)) { LibraryPoster(title, isFocused, plateKey == title.id && isFocused, grid.catalogue, Modifier.zIndex(if (isFocused) 1f else 0f).pointerInput(index, grid) { awaitPointerEventScope { @@ -151,13 +150,15 @@ fun LibraryScreen( } } }.clickable { grid.index = index; grid.toolbar = false; shell.navFocused = false; grid.onTitle(title) }, offlineArt) - CardCaption(title.title, if (title.type == "episode") null else title.year, title.duration, - !grid.catalogue && title.played && !(title.progress > 0f && title.progress < 1f), isFocused, - Modifier.offset(y = K.posterH + L.captionOffset).width(K.posterW)) - } } } } + if (active && !grid.toolbar && !grid.inRail && grid.index / grid.columns == row) { + val title = items.getOrNull(grid.index) + if (title != null) Caption(title.title, title.facts(grid.catalogue), true, + Modifier.offset(L.safeSide + K.posterPitch * (grid.index % grid.columns), K.posterH + L.captionOffset).width(G.captionWidth), + accentLastFact = if (grid.catalogue) title.availability.isNotEmpty() else title.played || title.progress > 0f) + } } } if (grid.tail != GridTail.None) item(key = "tail", contentType = "grid") { @@ -239,7 +240,7 @@ private fun LibraryTail(grid: LibraryGridState, focused: Boolean) { Box(Modifier.fillMaxWidth().height(K.posterH + K.posterCaptionBand + L.gridRowGap)) { CatalogueRecoveryCard(retry, focused, { grid.index = grid.tailIndex; grid.key(ShellKey.Ok) }, Modifier.offset(L.safeSide)) Caption(if (retry) "Try again" else "Show more", if (retry) "${grid.title} stopped loading" else "The last read only repeated titles", focused, - Modifier.offset(L.safeSide, K.posterH + L.captionOffset).width(K.posterW)) + Modifier.offset(L.safeSide, K.posterH + L.captionOffset).width(G.captionWidth)) } } diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/LoadingSurface.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/LoadingSurface.kt index e7607e04..93aa9887 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/LoadingSurface.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/LoadingSurface.kt @@ -46,7 +46,7 @@ private fun PosterSkeletonRow(heading: androidx.compose.ui.unit.Dp) { Column { Spacer(Modifier.height(heading)) Row(Modifier.padding(start = L.safeSide).skeletonPulse(), horizontalArrangement = Arrangement.spacedBy(K.posterGap)) { - repeat(K.posterSlots) { PosterCaptionSkeleton() } + repeat(K.posterSlots) { SkeletonBlock(K.posterW, K.posterH) } } } } @@ -55,13 +55,3 @@ private fun PosterSkeletonRow(heading: androidx.compose.ui.unit.Dp) { private fun SkeletonBlock(width: androidx.compose.ui.unit.Dp, height: androidx.compose.ui.unit.Dp) { Box(Modifier.size(width, height).background(C.surface2, RoundedCornerShape(WolfskinTokens.Radius.card))) } - -/** Loading reserves the ready caption band without painting unapproved text placeholders. */ -@Composable -internal fun PosterCaptionSkeleton(width: androidx.compose.ui.unit.Dp = K.posterW, - height: androidx.compose.ui.unit.Dp = K.posterH, band: androidx.compose.ui.unit.Dp = K.posterCaptionBand) { - Column(Modifier.width(width)) { - SkeletonBlock(width, height) - Spacer(Modifier.height(band)) - } -} 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 7da2fba8..ea95f24b 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 @@ -148,7 +148,7 @@ private fun PersonPage(state: PersonState, shell: ShellState, modifier: Modifier Row(Modifier.height(D.gridHeight).padding(start = L.safeSide), horizontalArrangement = Arrangement.spacedBy(WolfskinTokens.Card.posterGap)) { row.forEachIndexed { column, item -> val index = rowIndex * columns + column - TitlePoster(item, index == state.index && !shell.navFocused, Modifier.width(WolfskinTokens.Card.posterW), alwaysShowCaption = true, onFocus = { state.index = index }) { state.click(index) } + TitlePoster(item, index == state.index && !shell.navFocused, Modifier.width(WolfskinTokens.Card.posterW), catalogue = true, onFocus = { state.index = index }) { state.click(index) } } } } diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleCard.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleCard.kt index ea760054..fd581f12 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleCard.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleCard.kt @@ -9,51 +9,15 @@ import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip -import androidx.compose.ui.draw.drawWithCache -import androidx.compose.ui.geometry.Offset -import androidx.compose.ui.geometry.Size -import androidx.compose.ui.graphics.drawscope.clipRect -import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.Dp -// Precompose before packing into Color: packing first loses the browser's truncated channel fractions. -private val opaqueProgressTrack = WolfskinTokens.Color.progressTrack.let { track -> - val backing = WolfskinTokens.Color.bg - val inverse = 1f - track.alpha - Color( - ((track.red * track.alpha + backing.red * inverse) * 255).toInt(), - ((track.green * track.alpha + backing.green * inverse) * 255).toInt(), - ((track.blue * track.alpha + backing.blue * inverse) * 255).toInt(), - ) -} - /** Shelf, grid and continuation cards share one fixed slot and draw-only focus surface. */ @Composable fun TitleCard(focused: Boolean, modifier: Modifier = Modifier, width: Dp = WolfskinTokens.Card.posterW, - height: Dp = WolfskinTokens.Card.posterH, watched: Boolean = false, progress: Float = 0f, - content: @Composable BoxScope.() -> Unit) { + height: Dp = WolfskinTokens.Card.posterH, content: @Composable BoxScope.() -> Unit) { val shape = RoundedCornerShape(WolfskinTokens.Radius.card) Box(modifier.size(width, height).focusScale(focused, WolfskinTokens.Focus.scaleCard) .focusRing(focused, WolfskinTokens.Radius.card, WolfskinTokens.Focus.ringCardRing, FocusLift.Card)) { - Box(Modifier.fillMaxSize().clip(shape).background(WolfskinTokens.Color.surface2) - .drawWithCache { - val edge = WolfskinTokens.CardMetadata.bottom.toPx() - val fill = when { - progress > 0f -> WolfskinTokens.CardMetadata.resumeLine.toPx() - watched -> WolfskinTokens.CardMetadata.watchedLine.toPx() - else -> 0f - } - val backing = edge + if (fill > 0f) fill + WolfskinTokens.CardMetadata.separator.toPx() else 0f - onDrawWithContent { - clipRect(bottom = size.height - backing) { this@onDrawWithContent.drawContent() } - drawRect(WolfskinTokens.Color.bg, Offset(0f, size.height - backing), Size(size.width, backing)) - if (fill > 0f) { - val top = Offset(0f, size.height - edge - fill) - if (progress > 0f) drawRect(opaqueProgressTrack, top, Size(size.width, fill)) - drawRect(WolfskinTokens.Color.accent, top, - Size(size.width * if (progress > 0f) progress.coerceIn(0f, 1f) else 1f, fill)) - } - } - }, content = content) + Box(Modifier.fillMaxSize().clip(shape).background(WolfskinTokens.Color.surface2), content = content) } } diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleModel.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleModel.kt index 1fb71dd9..4630ce75 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleModel.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleModel.kt @@ -30,7 +30,6 @@ data class TitleItem( ) : PosterContent { override val posterTitle: String get() = name override val posterIsMovie: Boolean get() = kind == "movie" - override val posterWatched: Boolean get() = watched override val progress: Float get() = if (runtimeSeconds > 0) resumeSeconds.toFloat() / runtimeSeconds else 0f override val badge: String? get() = if (held) "held" else null val episodeLabel: String get() = "S$season E$episode" diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleShelf.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleShelf.kt index 429a38ce..fdb719df 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleShelf.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleShelf.kt @@ -4,6 +4,7 @@ import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut import androidx.compose.animation.core.tween import androidx.compose.ui.platform.LocalDensity +import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement @@ -106,36 +107,53 @@ fun TitleMediaShelf(state: TitleState, episodes: Boolean, active: Boolean) { } } -/** Shelf captions are focus-only; filmography keeps card-width metadata visible at rest. */ +/** Posters draw art at rest, with facts only in the reserved focused-caption band. */ @Composable -fun TitlePoster(item: TitleItem, focused: Boolean, modifier: Modifier = Modifier, tile: Boolean = false, ratingsVisible: Boolean = true, alwaysShowCaption: Boolean = false, onFocus: (() -> Unit)? = null, onClick: () -> Unit) { +fun TitlePoster(item: TitleItem, focused: Boolean, modifier: Modifier = Modifier, tile: Boolean = false, ratingsVisible: Boolean = true, catalogue: Boolean = false, onFocus: (() -> Unit)? = null, onClick: () -> Unit) { val focusOwner = LocalTitleFocusOwner.current Column(modifier.zIndex(if (focused) 1f else 0f)) { if (!tile) { val bitmaps = LocalTitleBitmaps.current val offlineArt = androidx.compose.runtime.remember(bitmaps) { if (bitmaps.isEmpty()) null else { path: String -> bitmaps[path] } } - LibraryPoster(item, focused, focused && ratingsVisible, catalogue = alwaysShowCaption, showWatchState = true, + LibraryPoster(item, focused, focused && ratingsVisible, catalogue = catalogue, modifier = Modifier.titlePointerFocus(onFocus).clickable { focusOwner?.invoke(); onClick() }, offlineArt = offlineArt) - } else TitleCard(focused, Modifier.titlePointerFocus(onFocus).clickable { focusOwner?.invoke(); onClick() }, - WolfskinTokens.Card.tileW, D.episodeCardHeight, watched = item.watched, - progress = item.progress.takeIf { it < 1f } ?: 0f) { + } else Box(Modifier.size(WolfskinTokens.Card.tileW, D.episodeCardHeight).titlePointerFocus(onFocus).focusScale(focused, WolfskinTokens.Focus.scaleCard) + .focusRing(focused, WolfskinTokens.Radius.card, WolfskinTokens.Focus.ringCardRing, FocusLift.Card) + .clip(RoundedCornerShape(WolfskinTokens.Radius.card)).background(C.surface2).clickable { focusOwner?.invoke(); onClick() }) { TitleTileArtwork(item) Column(Modifier.align(Alignment.BottomStart).fillMaxWidth().background(Brush.verticalGradient(listOf(C.bg.copy(alpha = 0f), C.bg.copy(alpha = D.cardOverlayOpacity)))) .padding(start = D.tileOverlaySide, end = D.tileOverlaySide, top = D.tileOverlayTop, bottom = D.tileOverlayBottom)) { Text(item.seriesName ?: item.name, color = C.fg, style = WolfskinTokens.Type.button, maxLines = 1, overflow = TextOverflow.Ellipsis) Text("${item.episodeLabel} · ${item.name}", color = C.muted, style = WolfskinTokens.Type.caption, maxLines = 1, overflow = TextOverflow.Ellipsis) if (focused && item.overview.isNotBlank()) Text(item.overview, color = C.muted, style = WolfskinTokens.Type.caption, maxLines = 2, overflow = TextOverflow.Ellipsis) - if (focused) CardMetadata(null, if (item.runtimeSeconds > 0) item.runtime else null, item.watched && item.resumeSeconds <= 0) + } + val progress = item.progress + if (progress > 0f && progress < 1f) { + Box(Modifier.align(Alignment.BottomCenter).padding(start = D.cardProgressInset, end = D.cardProgressInset, bottom = D.tileProgressBottom) + .fillMaxWidth().height(L.progressBar).clip(RoundedCornerShape(WolfskinTokens.Radius.progress)).background(C.progressTrack)) { + Box(Modifier.fillMaxWidth(progress).fillMaxHeight().background(C.accent)) + } } androidx.compose.animation.AnimatedVisibility(focused && ratingsVisible, enter = fadeIn(tween(motionDuration(WolfskinTokens.Motion.base, reducedMotion()))), exit = fadeOut(tween(motionDuration(WolfskinTokens.Motion.base, reducedMotion())))) { LibraryRatingPlate(item.ratings, Modifier.fillMaxWidth()) } } if (!tile) { - Box(Modifier.height(WolfskinTokens.Card.posterCaptionBand).width(WolfskinTokens.Card.posterW)) { - CardCaption(item.name, item.year, if (item.kind == "movie" && item.runtimeSeconds > 0) item.runtime else null, - item.watched && item.resumeSeconds <= 0, focused, - Modifier.offset(y = L.captionOffset).width(WolfskinTokens.Card.posterW), focusOnly = !alwaysShowCaption) + val availability = androidx.compose.runtime.remember(item, catalogue) { + if (catalogue) { + if (item.held) "In your library" else item.otherOwners.takeIf { it.isNotEmpty() }?.let { "In ${it.joinToString(" and ")}’s library" } + } else if (item.watched) "Watched" + else if (item.resumeSeconds > 0) "${((item.runtimeSeconds - item.resumeSeconds + 30) / 60).coerceAtLeast(1)} min left" + else null + } + val facts = androidx.compose.runtime.remember(item, availability) { + listOfNotNull(item.character?.takeIf { it.isNotBlank() }?.let { "as $it" }, item.year?.toString(), + if (item.kind == "movie") { if (item.runtimeSeconds > 0) item.runtime else "Movie" } else if (item.seasons > 0) "${item.seasons} season${if (item.seasons == 1) "" else "s"}" else "Series", + availability).joinToString(" · ") + } + Box(Modifier.height(WolfskinTokens.Card.posterCaptionBand)) { + Caption(item.name, facts, focused, Modifier.offset(y = L.captionOffset).wrapContentWidth(Alignment.Start, unbounded = true).width(D.captionWidth), + accentLastFact = availability != null, accentFirstFact = !item.character.isNullOrBlank()) } } } 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 6c014755..35ac83e3 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 @@ -11,20 +11,6 @@ import androidx.compose.ui.unit.sp /** The locked TV design, converted at the source density. */ object WolfskinTokens { - object CardMetadata { - val titleRest = TextStyle(fontSize = 14.0.sp, fontWeight = FontWeight(500), lineHeight = 16.0.sp, letterSpacing = 0.0.em) - val titleFocus = TextStyle(fontSize = 14.0.sp, fontWeight = FontWeight.Bold, lineHeight = 16.0.sp, letterSpacing = 0.0.em) - val subtitle = TextStyle(fontSize = 12.0.sp, fontWeight = FontWeight(400), lineHeight = 13.0.sp, letterSpacing = 0.0.em) - val check = 12.0.dp - val gap = 6.0.dp - val compactGap = 4.0.dp - val minimumGap = 2.0.dp - val bottom = 1.0.dp - val separator = 1.0.dp - val watchedLine = 1.5.dp - val resumeLine = 3.0.dp - val landscapeBand = 32.0.dp - } object Color { val bg = ComposeColor(0xFF07070A) val surface = ComposeColor(0xFF13131A) @@ -187,11 +173,13 @@ object WolfskinTokens { val tileH = 112.5.dp val tileGap = 20.0.dp val tilePitch = 220.0.dp + val tileCaptionBand = 0.0.dp const val tileSlots = 4 val wideW = 272.0.dp val wideH = 153.0.dp val wideGap = 20.0.dp val widePitch = 292.0.dp + val wideCaptionBand = 0.0.dp const val wideSlots = 3 val entryW = 128.0.dp val entryH = 72.0.dp @@ -391,6 +379,9 @@ object WolfskinTokens { val chipGap = 8.0.dp val chipTop = 8.0.dp val chipIcon = 13.0.dp + val captionWidth = 320.0.dp + const val captionSeparatorOpacity = 0.4f + val captionFactsGap = 9.0.dp val badgeIcon = 14.0.dp val plateTop = 7.0.dp val plateSide = 8.0.dp @@ -474,6 +465,7 @@ object WolfskinTokens { val personGap = 24.0.dp val personNameBottom = 5.0.dp val personNoteBottom = 3.0.dp + val captionWidth = 320.0.dp val sheetPaddingTop = 40.0.dp val sheetPaddingSide = 32.0.dp val sheetGap = 9.0.dp @@ -560,6 +552,8 @@ object WolfskinTokens { val tileOverlaySide = 11.0.dp val tileOverlayBottom = 9.0.dp const val cardOverlayOpacity = 0.92f + val cardProgressInset = 9.0.dp + val tileProgressBottom = 5.0.dp val ambientInset = 40.0.dp const val ambientScale = 1.1f val posterShadowRadius = 40.0.dp @@ -597,6 +591,7 @@ object WolfskinTokens { val statePaddingY = 17.0.dp val stateGap = 4.0.dp val stateIcon = 18.0.dp + val captionWidth = 320.0.dp val plateTop = 7.0.dp val plateSide = 8.0.dp val plateFade = 23.0.dp @@ -617,6 +612,8 @@ object WolfskinTokens { val heroSheetTop = 75.0.dp val cardBlur = 13.0.dp val heroBlur = 30.0.dp + val progressInset = 9.0.dp + val progressBottom = 5.0.dp val endTop = 20.0.dp val frameBottom = 40.0.dp val toastPadding = 14.0.dp @@ -637,6 +634,7 @@ object WolfskinTokens { val savedGap = 5.0.dp val savedIcon = 14.0.dp val savedRadius = 14.0.dp + val posterProgressBottom = 6.0.dp } object Discover { val shelfHeadingBand = 52.0.dp diff --git a/wolfskin-design/src/main/res/drawable/card_watched_check.xml b/wolfskin-design/src/main/res/drawable/card_watched_check.xml deleted file mode 100644 index 1dd41eb0..00000000 --- a/wolfskin-design/src/main/res/drawable/card_watched_check.xml +++ /dev/null @@ -1,4 +0,0 @@ - - - - diff --git a/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/CardBottomPixelTest.kt b/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/CardBottomPixelTest.kt deleted file mode 100644 index 5bbc98ce..00000000 --- a/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/CardBottomPixelTest.kt +++ /dev/null @@ -1,135 +0,0 @@ -package org.justinvisser.wolfskin.design - -import android.app.Application -import android.graphics.BitmapFactory -import android.view.View -import android.view.WindowManager -import androidx.activity.ComponentActivity -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.* -import androidx.compose.runtime.mutableStateOf -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.toArgb -import androidx.compose.ui.test.junit4.createAndroidComposeRule -import androidx.compose.ui.test.onRoot -import androidx.compose.ui.test.onNodeWithTag -import androidx.compose.ui.unit.dp -import com.github.takahirom.roborazzi.captureRoboImage -import java.io.File -import org.junit.Assert.assertEquals -import org.junit.Rule -import org.junit.Test -import org.junit.runner.RunWith -import org.robolectric.RobolectricTestRunner -import org.robolectric.annotation.Config -import org.robolectric.annotation.GraphicsMode - -/** Real white, dark and colourful art cannot leak through the card's opaque bottom edge. */ -@RunWith(RobolectricTestRunner::class) -@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi") -@GraphicsMode(GraphicsMode.Mode.NATIVE) -class CardBottomPixelTest { - @get:Rule val rule = createAndroidComposeRule() - - @Test fun realPosterBottomRowsStayOpaqueAtRestAndFocused() { - rule.activity.window.setFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN, WindowManager.LayoutParams.FLAG_FULLSCREEN) - @Suppress("DEPRECATION") - rule.activity.window.decorView.systemUiVisibility = View.SYSTEM_UI_FLAG_FULLSCREEN or View.SYSTEM_UI_FLAG_HIDE_NAVIGATION or View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY - val focused = mutableStateOf(-1) - val showArt = mutableStateOf(true) - val state = mutableStateOf("unwatched") - val names = listOf("annie-hall-1977", "locke-2014", "apocalypse-now-1979", "avatar-aang-the-last-airbender-2026") - rule.setContent { WolfskinTheme { - Box(Modifier.requiredSize(960.dp, 540.dp).background(WolfskinTokens.Color.bg)) { - names.forEachIndexed { index, name -> - LibraryPoster(LibraryTitle(name, name, "movie", poster = if (showArt.value) "/library/assets/movie-$name-poster.jpg" else null, - played = state.value == "watched", progress = if (state.value == "resume") .43f else 0f), - focused.value == index, false, false, Modifier.offset((48 + 148 * index).dp, 130.dp), ::fixtureArt) - } - } - } } - val directory = File(System.getProperty("wolfskin.captureDirectory"), "card-pixels").apply { mkdirs() } - for (watchState in listOf("unwatched", "watched", "resume")) for (selection in -1..3) { - rule.runOnIdle { state.value = watchState; focused.value = selection; showArt.value = false } - rule.mainClock.advanceTimeBy(1000) - rule.waitForIdle() - val referenceFile = File(directory, "reference.png") - rule.onRoot().captureRoboImage(referenceFile.absolutePath) - val reference = BitmapFactory.decodeFile(referenceFile.absolutePath) - rule.runOnIdle { showArt.value = true } - rule.waitForIdle() - val file = File(directory, "$watchState-focus-$selection.png") - rule.onRoot().captureRoboImage(file.absolutePath) - val bitmap = BitmapFactory.decodeFile(file.absolutePath) - names.forEachIndexed { index, name -> - val focus = selection == index - val left = 96 + 296 * index - val bottom = if (focus) 659 else 643 - val edgeLeft = if (focus) left - 10 else left - val edgeRight = if (focus) left + 266 else left + 256 - // The reference supplies only rounded-corner/ring coverage, never poster colours. - for (x in edgeLeft until edgeRight) assertEquals("$name $watchState focus=$focus full bottom row ($x,$bottom)", - reference.getPixel(x, bottom), bitmap.getPixel(x, bottom)) - val expected = if (focus) WolfskinTokens.Color.accent.toArgb() else WolfskinTokens.Color.bg.toArgb() - for (x in left + 32 until left + 224) assertEquals("$name $watchState focus=$focus straight edge ($x,$bottom)", - expected, bitmap.getPixel(x, bottom)) - if (watchState != "unwatched") { - val fillY = if (focus) 655 else 640 - assertEquals("$name $watchState fill", WolfskinTokens.Color.accent.toArgb(), bitmap.getPixel(left + 32, fillY)) - val right = if (watchState == "resume") androidx.compose.ui.graphics.Color(0xFF3D3D3F) else WolfskinTokens.Color.accent - assertEquals("$name $watchState track", right.toArgb(), bitmap.getPixel(left + 224, fillY)) - } - } - reference.recycle() - bitmap.recycle() - } - } - - @Test fun completedHomeProgressUsesTheSameWatchedStripAsLibraryCards() { - rule.activity.window.setFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN, WindowManager.LayoutParams.FLAG_FULLSCREEN) - @Suppress("DEPRECATION") - rule.activity.window.decorView.systemUiVisibility = View.SYSTEM_UI_FLAG_FULLSCREEN or View.SYSTEM_UI_FLAG_HIDE_NAVIGATION or View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY - val path = "/library/assets/movie-annie-hall-1977-poster.jpg" - val fraction = mutableStateOf(.5f) - val art = mapOf(path to fixtureArt(path)!!) - val memory = HomeViewMemory() - rule.setContent { WolfskinTheme { - androidx.compose.runtime.CompositionLocalProvider(LocalTitleBitmaps provides art) { - Box(Modifier.requiredSize(960.dp, 540.dp).background(WolfskinTokens.Color.bg)) { - val title = HomeTitle("home", "Home film", "movie", runtimeSeconds = 100, - poster = path, played = true, resumeSeconds = fraction.value * 100.0) - HomeShelf(HomeRow("progress", HomeRowKind.Shelf, "Progress", listOf(title)), - memory, false, 0, emptyList(), {}, {}) - LibraryPoster(LibraryTitle("reference", "Reference", "movie", poster = path, - played = true, progress = fraction.value), false, false, false, - Modifier.offset(240.dp, 42.dp), ::fixtureArt) - } - } - } } - val directory = File(System.getProperty("wolfskin.captureDirectory"), "card-pixels").apply { mkdirs() } - for (progress in listOf(.5f, 1f, 1.5f)) { - rule.runOnIdle { fraction.value = progress } - rule.mainClock.advanceTimeBy(1000) - rule.waitForIdle() - val home = rule.onNodeWithTag("home-card:progress:0").fetchSemanticsNode().boundsInRoot - val reference = rule.onNodeWithTag("poster-reference").fetchSemanticsNode().boundsInRoot - assertEquals(reference.width, home.width, .1f) - assertEquals(reference.bottom, home.bottom, .1f) - val file = File(directory, "home-progress-${(progress * 100).toInt()}.png") - rule.onRoot().captureRoboImage(file.absolutePath) - val bitmap = BitmapFactory.decodeFile(file.absolutePath) - try { - val bottom = home.bottom.toInt() - val band = if (progress >= 1f) 7 else 10 - for (y in bottom - band until bottom) for (x in 0 until home.width.toInt()) { - assertEquals("Home/library strip progress=$progress ($x,$y)", - bitmap.getPixel(reference.left.toInt() + x, y), bitmap.getPixel(home.left.toInt() + x, y)) - } - val separator = if (progress >= 1f) WolfskinTokens.Color.bg else WolfskinTokens.Color.accent - assertEquals("Completion changes the resume strip to the 3px watched strip", - separator.toArgb(), bitmap.getPixel(home.left.toInt() + 32, bottom - 7)) - assertEquals(WolfskinTokens.Color.accent.toArgb(), bitmap.getPixel(home.left.toInt() + 32, bottom - 4)) - } finally { bitmap.recycle() } - } - } -} diff --git a/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/CardCaptionMotionTest.kt b/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/CardCaptionMotionTest.kt deleted file mode 100644 index ee5b9d57..00000000 --- a/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/CardCaptionMotionTest.kt +++ /dev/null @@ -1,121 +0,0 @@ -package org.justinvisser.wolfskin.design - -import android.app.Application -import android.graphics.BitmapFactory -import android.view.View -import android.view.WindowManager -import androidx.activity.ComponentActivity -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.* -import androidx.compose.runtime.CompositionLocalProvider -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.snapshots.Snapshot -import androidx.compose.ui.Modifier -import androidx.compose.ui.geometry.Rect -import androidx.compose.ui.graphics.toArgb -import androidx.compose.ui.platform.testTag -import androidx.compose.ui.test.* -import androidx.compose.ui.test.junit4.createAndroidComposeRule -import androidx.compose.ui.unit.dp -import com.github.takahirom.roborazzi.captureRoboImage -import java.io.File -import org.junit.Assert.assertEquals -import org.junit.Assert.assertTrue -import org.junit.Before -import org.junit.Rule -import org.junit.Test -import org.junit.runner.RunWith -import org.robolectric.RobolectricTestRunner -import org.robolectric.annotation.Config -import org.robolectric.annotation.GraphicsMode - -/** Changing a shelf's selected identity must restart its focus-only caption reveal. */ -@RunWith(RobolectricTestRunner::class) -@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi") -@GraphicsMode(GraphicsMode.Mode.NATIVE) -class CardCaptionMotionTest { - @get:Rule val rule = createAndroidComposeRule() - - @Before fun fullscreen() { - rule.activity.window.setFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN, WindowManager.LayoutParams.FLAG_FULLSCREEN) - @Suppress("DEPRECATION") - rule.activity.window.decorView.systemUiVisibility = View.SYSTEM_UI_FLAG_FULLSCREEN or View.SYSTEM_UI_FLAG_HIDE_NAVIGATION or View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY - rule.mainClock.autoAdvance = false - } - - @Test fun changingTitleWhileStillFocusedRestoresOpaqueCaptionPixels() { - val title = mutableStateOf("Initial caption") - rule.setContent { WolfskinTheme { - Box(Modifier.requiredSize(960.dp, 540.dp).background(WolfskinTokens.Color.bg)) { - CardCaption(title.value, 2007, "2h 56m", true, true, - Modifier.offset(40.dp, 80.dp).width(128.dp).testTag("animated"), focusOnly = true) - CardCaption(title.value, 2007, "2h 56m", true, true, - Modifier.offset(40.dp, 180.dp).width(128.dp).testTag("reference")) - } - } } - for ((index, selected) in listOf("Initial caption", "Second caption", "Third caption").withIndex()) { - update { title.value = selected } - rule.mainClock.advanceTimeBy(400) - assertCaptionPixels("component-$index", - rule.onNodeWithTag("animated").fetchSemanticsNode().boundsInRoot, - rule.onNodeWithTag("reference").fetchSemanticsNode().boundsInRoot) - } - } - - @Test fun movingAcrossAFocusedHomeShelfKeepsEachSelectedCaptionVisible() { - val path = "/library/assets/movie-annie-hall-1977-poster.jpg" - val cards = listOf(HomeTitle("first", "First shelf caption", "movie", 2007, 10560, poster = path), - HomeTitle("second", "Second shelf caption", "movie", 2008, 11520, poster = path)) - val row = HomeRow("captions", HomeRowKind.Shelf, "Shelf", cards) - val column = mutableStateOf(0) - val memory = HomeViewMemory() - val art = mapOf(path to fixtureArt(path)!!) - rule.setContent { WolfskinTheme { - CompositionLocalProvider(LocalTitleBitmaps provides art) { - Box(Modifier.requiredSize(960.dp, 540.dp).background(WolfskinTokens.Color.bg)) { - Box(Modifier.testTag("home-shelf")) { - HomeShelf(row, memory, true, column.value, emptyList(), {}, {}) - } - val selected = cards[column.value] - CardCaption(selected.title, selected.year, selected.runtime, false, true, - Modifier.offset(48.dp, 400.dp).width(128.dp).testTag("reference")) - } - } - } } - for ((step, selected) in listOf(0, 1, 0).withIndex()) { - update { column.value = selected } - rule.mainClock.advanceTimeBy(400) - val title = cards[selected].title - val actual = rule.onNode(hasText(title) and hasAnyAncestor(hasTestTag("home-shelf")), useUnmergedTree = true).fetchSemanticsNode().boundsInRoot - val reference = rule.onNode(hasText(title) and hasAnyAncestor(hasTestTag("reference")), useUnmergedTree = true).fetchSemanticsNode().boundsInRoot - assertCaptionPixels("home-$step", actual, reference) - } - } - - private fun update(block: () -> Unit) { - rule.runOnIdle { block(); Snapshot.sendApplyNotifications() } - rule.mainClock.advanceTimeByFrame() - } - - private fun assertCaptionPixels(name: String, actual: Rect, reference: Rect) { - assertEquals(reference.width, actual.width, .1f) - assertEquals(reference.height, actual.height, .1f) - rule.waitForIdle() - val directory = File(System.getProperty("wolfskin.captureDirectory"), "caption-followup").apply { mkdirs() } - val file = File(directory, "$name.png") - rule.onRoot().captureRoboImage(file.absolutePath) - val bitmap = BitmapFactory.decodeFile(file.absolutePath) - try { - val foreground = WolfskinTokens.Color.fg.toArgb() - var compared = 0 - for (y in 0 until reference.height.toInt()) for (x in 0 until reference.width.toInt()) { - if (bitmap.getPixel(reference.left.toInt() + x, reference.top.toInt() + y) == foreground) { - assertEquals("$name opaque title pixel ($x,$y)", foreground, - bitmap.getPixel(actual.left.toInt() + x, actual.top.toInt() + y)) - compared++ - } - } - assertTrue("The reference must contain opaque title glyphs", compared >= 24) - } finally { bitmap.recycle() } - } -} diff --git a/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/CardGeometryTest.kt b/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/CardGeometryTest.kt deleted file mode 100644 index be217089..00000000 --- a/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/CardGeometryTest.kt +++ /dev/null @@ -1,169 +0,0 @@ -package org.justinvisser.wolfskin.design - -import android.app.Application -import android.graphics.BitmapFactory -import androidx.compose.foundation.background -import androidx.compose.ui.graphics.toArgb -import com.github.takahirom.roborazzi.captureRoboImage -import java.io.File -import androidx.compose.foundation.layout.* -import androidx.compose.runtime.mutableStateOf -import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.testTag -import androidx.compose.ui.semantics.SemanticsActions -import androidx.compose.ui.test.* -import androidx.compose.ui.test.junit4.createComposeRule -import androidx.compose.ui.text.TextLayoutResult -import androidx.compose.ui.unit.dp -import org.junit.Assert.* -import org.junit.Rule -import org.junit.Test -import org.junit.runner.RunWith -import org.robolectric.RobolectricTestRunner -import org.robolectric.annotation.Config - -/** Caption fitting and row pitches are measured from the same surfaces used by the TV. */ -@RunWith(RobolectricTestRunner::class) -@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi") -class CardGeometryTest { - @get:Rule val rule = createComposeRule() - - @Test fun narrowCaptionsEllipsiseOnlyTheTitleAndKeepRuntimeAndCheck() { - val width = mutableStateOf(105.dp) - rule.setContent { WolfskinTheme { - CardCaption("Steins;Gate: The Movie - Load Region of Déjà Vu", 2007, "999h 59m", true, true, - Modifier.width(width.value).testTag("caption")) - } } - for (pixels in listOf(210, 327)) { - rule.runOnIdle { width.value = (pixels / 2f).dp } - rule.waitForIdle() - val layouts = rule.onAllNodes(SemanticsMatcher.keyIsDefined(SemanticsActions.GetTextLayoutResult), useUnmergedTree = true).fetchSemanticsNodes().map { node -> - val results = mutableListOf() - node.config[SemanticsActions.GetTextLayoutResult].action!!.invoke(results) - results.single() - } - val title = layouts.single { it.layoutInput.text.text.startsWith("Steins;") } - assertEquals(1, title.lineCount) - assertTrue("Only the long title ellipsises at $pixels", title.isLineEllipsized(0)) - for (facts in layouts.filter { it !== title }) { - assertFalse("Metadata must not overflow at $pixels", facts.hasVisualOverflow) - assertFalse(facts.isLineEllipsized(0)) - } - val caption = rule.onNodeWithTag("caption").fetchSemanticsNode().boundsInRoot - assertEquals(58f, caption.height, .1f) - assertEquals(pixels.toFloat(), caption.width, .1f) - val check = rule.onNodeWithContentDescription("Watched").fetchSemanticsNode().boundsInRoot - assertEquals(24f, check.width, .1f) - assertEquals(24f, check.height, .1f) - assertTrue(check.right <= caption.right) - } - } - - @Test fun unfocusedLibraryTitlesKeepCardWidthAndGridPitch() { - val titles = List(12) { LibraryTitle("$it", "A long title occupying only one card column ${it.toString().padStart(2, '0')}", "movie", 2007, 10560, poster = "/library/assets/movie-annie-hall-1977-poster.jpg") } - val shell = ShellState(fixturePeople(), "shared").apply { pickerVisible = false; enterDestination("movies") } - val grid = LibraryGridState("Movies", titles) - rule.setContent { WolfskinTheme { ShellSurface(shell) { LibraryScreen(shell, grid, it, offlineArt = ::fixtureArt) } } } - rule.mainClock.advanceTimeBy(1000) - rule.waitForIdle() - val first = rule.onNodeWithTag("poster-0").fetchSemanticsNode().boundsInRoot - val second = rule.onNodeWithTag("poster-6").fetchSemanticsNode().boundsInRoot - // Focus scales around the unchanged slot centre. - assertEquals(372f, first.top + (first.height - 384f) / 2, .1f) - assertEquals(524f, second.top - (first.top + (first.height - 384f) / 2), .1f) - for (index in 0..5) { - val caption = rule.onNodeWithText(titles[index].title, useUnmergedTree = true).fetchSemanticsNode() - assertEquals(256f, caption.boundsInRoot.width, .1f) - assertEquals(794f, caption.boundsInRoot.top, .1f) - } - val focusedCaption = rule.onNodeWithText(titles[0].title, useUnmergedTree = true).fetchSemanticsNode().boundsInRoot - assertEquals(20.48f, focusedCaption.top - first.bottom - first.height * .04f - 2f * 1.08f, .1f) - assertEquals(26.48f, second.top - first.height * .04f - 2f * 1.08f - (focusedCaption.top + 58f), .1f) - rule.runOnIdle { kotlinx.coroutines.runBlocking { shell.view.listState.scrollToItem(0, 204) } } - rule.waitForIdle() - val pinnedFirst = rule.onNodeWithTag("poster-0").fetchSemanticsNode().boundsInRoot - val pinnedSecond = rule.onNodeWithTag("poster-6").fetchSemanticsNode().boundsInRoot - assertEquals(168f, pinnedFirst.top, .1f) - assertEquals(692f, pinnedSecond.top, .1f) - assertEquals(1076f, pinnedSecond.bottom, .1f) - } - - @Test fun homePosterWideAndTileShelvesReserveTheirCaptionBands() { - val kind = mutableStateOf(HomeCardKind.Poster) - val loading = mutableStateOf(false) - val memory = HomeViewMemory() - rule.setContent { WolfskinTheme { - Column { - Box(Modifier.testTag("shelf")) { - HomeShelf(HomeRow("fixture", HomeRowKind.Shelf, "Fixture", listOf(HomeTitle("one", "One", "movie", 2007, 10560)), - kind.value, skeleton = loading.value), memory, false, 0, emptyList(), {}, {}) - } - Box(Modifier.size(1.dp).testTag("next")) - } - } } - for ((cardKind, expected) in listOf(HomeCardKind.Poster to 632f, HomeCardKind.Wide to 522f, HomeCardKind.Tile to 441f)) { - for (pending in listOf(false, true)) { - rule.runOnIdle { kind.value = cardKind; loading.value = pending } - rule.waitForIdle() - assertEquals("$cardKind loading=$pending", expected, rule.onNodeWithTag("shelf").fetchSemanticsNode().boundsInRoot.height, .1f) - assertEquals(expected, rule.onNodeWithTag("next").fetchSemanticsNode().boundsInRoot.top, .1f) - } - } - } - - @Test fun filmographyKeepsUnfocusedMetadataAndDistinguishesSeriesFromFilmRuntime() { - val path = "/library/assets/movie-annie-hall-1977-poster.jpg" - val item = mutableStateOf(TitleItem("credit", "A filmography credit", "movie", 2007, 10560, - poster = path, held = true, watched = true)) - val art = mapOf(path to fixtureArt(path)!!) - rule.setContent { WolfskinTheme { - androidx.compose.runtime.CompositionLocalProvider(LocalTitleBitmaps provides art) { - TitlePoster(item.value, false, alwaysShowCaption = true) {} - } - } } - rule.onNodeWithText("A filmography credit").assertIsDisplayed() - rule.onNodeWithText("2007").assertIsDisplayed() - rule.onNodeWithText("2h 56m").assertIsDisplayed() - rule.onNodeWithContentDescription("Watched").assertIsDisplayed() - rule.runOnIdle { item.value = item.value.copy(kind = "tv", year = 2016, runtimeSeconds = 123456) } - rule.onNodeWithText("2016").assertIsDisplayed() - rule.onNodeWithText("2h 56m").assertDoesNotExist() - rule.onNodeWithContentDescription("Watched").assertIsDisplayed() - rule.runOnIdle { item.value = item.value.copy(resumeSeconds = 10) } - rule.onNodeWithContentDescription("Watched").assertDoesNotExist() - rule.onNodeWithText("A filmography credit").assertIsDisplayed() - } - - @Test fun loadingCaptionBandsReserveSpaceWithoutPaintingBars() { - val slots = listOf("poster" to Triple(128.dp, 192.dp, 48.dp), - "wide" to Triple(272.dp, 153.dp, 32.dp), "tile" to Triple(200.dp, 112.5.dp, 32.dp)) - rule.setContent { WolfskinTheme { - Box(Modifier.requiredSize(960.dp, 540.dp).background(WolfskinTokens.Color.bg)) { - Row(Modifier.offset(48.dp, 42.dp), horizontalArrangement = Arrangement.spacedBy(20.dp)) { - slots.forEach { (name, geometry) -> - Box(Modifier.testTag("loading-$name")) { - PosterCaptionSkeleton(geometry.first, geometry.second, geometry.third) - } - } - } - } - } } - rule.waitForIdle() - val directory = File(System.getProperty("wolfskin.captureDirectory"), "caption-followup").apply { mkdirs() } - val file = File(directory, "loading-bands.png") - rule.onRoot().captureRoboImage(file.absolutePath) - val bitmap = BitmapFactory.decodeFile(file.absolutePath) - try { - for ((name, geometry) in slots) { - val bounds = rule.onNodeWithTag("loading-$name").fetchSemanticsNode().boundsInRoot - val band = geometry.third.value * 2 - assertEquals((geometry.second.value + geometry.third.value) * 2, bounds.height, .1f) - for (y in (bounds.bottom - band).toInt() until bounds.bottom.toInt()) { - for (x in bounds.left.toInt() until bounds.right.toInt()) { - assertEquals("$name reserved caption band ($x,$y)", WolfskinTokens.Color.bg.toArgb(), bitmap.getPixel(x, y)) - } - } - } - } finally { bitmap.recycle() } - } -} diff --git a/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/LibrarySpacingRegressionTest.kt b/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/LibrarySpacingRegressionTest.kt index a3d8a958..79e89277 100644 --- a/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/LibrarySpacingRegressionTest.kt +++ b/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/LibrarySpacingRegressionTest.kt @@ -11,12 +11,23 @@ import org.junit.runner.RunWith import org.robolectric.RobolectricTestRunner import org.robolectric.annotation.Config -/** Live letter-rail changes preserve deep selection and poster geometry. */ +/** Caption spacing and live rail changes must preserve poster geometry and retained focus. */ @RunWith(RobolectricTestRunner::class) @Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi") class LibrarySpacingRegressionTest { @get:Rule val rule = createComposeRule() + @Test fun partiallyWatchedPosterKeepsItsOriginalProgressInsets() { + val title = LibraryTitle("partial", "Partially watched", "movie", progress = .5f) + rule.setContent { WolfskinTheme { LibraryPoster(title, false, false, false) } } + val poster = rule.onNodeWithTag("poster-partial").fetchSemanticsNode().boundsInRoot + val track = rule.onNodeWithTag("poster-progress-partial").fetchSemanticsNode().boundsInRoot + assertEquals(256f, poster.width, .1f) + assertEquals(220f, track.width, .1f) + assertEquals(18f, track.left - poster.left, .1f) + assertEquals(18f, poster.right - track.right, .1f) + } + @Test fun crossingRailThresholdKeepsTheSameDeepFocusedPosterVisible() { val titles = List(200) { LibraryTitle("$it", "Title ${it.toString().padStart(3, '0')}", "tv") } val shell = ShellState(fixturePeople(), "justin").apply { pickerVisible = false; enterDestination("shows") } diff --git a/wolfskin-design/title-tokens.json b/wolfskin-design/title-tokens.json index 309913d4..156054d0 100644 --- a/wolfskin-design/title-tokens.json +++ b/wolfskin-design/title-tokens.json @@ -23,6 +23,7 @@ "personGap": {"dp": 24, "px": 48, "source": "index.html:293"}, "personNameBottom": {"dp": 5, "px": 10, "source": "index.html:296"}, "personNoteBottom": {"dp": 3, "px": 6, "source": "index.html:297"}, + "captionWidth": {"dp": 320, "px": 640, "source": "index.html:1242"}, "sheetPaddingTop": {"dp": 40, "px": 80, "source": "index.html:227"}, "sheetPaddingSide": {"dp": 32, "px": 64, "source": "index.html:227"}, "sheetGap": {"dp": 9, "px": 18, "source": "index.html:227"}, @@ -109,6 +110,8 @@ "tileOverlaySide": {"dp": 11, "px": 22, "source": "index.html:135"}, "tileOverlayBottom": {"dp": 9, "px": 18, "source": "index.html:135"}, "cardOverlayOpacity": {"$value": 0.92, "source": "index.html:135"}, + "cardProgressInset": {"dp": 9, "px": 18, "source": "index.html:138"}, + "tileProgressBottom": {"dp": 5, "px": 10, "source": "index.html:140"}, "ambientInset": {"dp": 40, "px": 80, "source": "index.html:96"}, "ambientScale": {"$value": 1.1, "source": "index.html:96"}, "posterShadowRadius": {"dp": 40, "px": 80, "source": "index.html:97"}, diff --git a/wolfskin-design/tokens.css b/wolfskin-design/tokens.css index cb81f2a5..bd65a73d 100644 --- a/wolfskin-design/tokens.css +++ b/wolfskin-design/tokens.css @@ -1,17 +1,5 @@ /* Generated from the locked TV token sources. */ :root { - --cardMetadata-titleRest: 28.0px; - --cardMetadata-titleFocus: 28.0px; - --cardMetadata-subtitle: 24.0px; - --cardMetadata-check: 24.0px; - --cardMetadata-gap: 12.0px; - --cardMetadata-compactGap: 8.0px; - --cardMetadata-minimumGap: 4.0px; - --cardMetadata-bottom: 2.0px; - --cardMetadata-separator: 2.0px; - --cardMetadata-watchedLine: 3.0px; - --cardMetadata-resumeLine: 6.0px; - --cardMetadata-landscapeBand: 64.0px; --color-bg: #07070a; --color-surface: #13131a; --color-surface2: #1b1b25; @@ -254,6 +242,8 @@ --library-chipGap: 16.0px; --library-chipTop: 16.0px; --library-chipIcon: 26.0px; + --library-captionWidth: 640.0px; + --library-captionFactsGap: 18.0px; --library-badgeIcon: 28.0px; --library-plateTop: 14.0px; --library-plateSide: 16.0px; @@ -313,6 +303,7 @@ --title-personGap: 48.0px; --title-personNameBottom: 10.0px; --title-personNoteBottom: 6.0px; + --title-captionWidth: 640.0px; --title-sheetPaddingTop: 80.0px; --title-sheetPaddingSide: 64.0px; --title-sheetGap: 18.0px; @@ -393,6 +384,8 @@ --title-tileOverlayTop: 54.0px; --title-tileOverlaySide: 22.0px; --title-tileOverlayBottom: 18.0px; + --title-cardProgressInset: 18.0px; + --title-tileProgressBottom: 10.0px; --title-ambientInset: 80.0px; --title-posterShadowRadius: 80.0px; --title-posterShadowY: 30.0px; @@ -422,6 +415,7 @@ --home-statePaddingY: 34.0px; --home-stateGap: 8.0px; --home-stateIcon: 36.0px; + --home-captionWidth: 640.0px; --home-plateTop: 14.0px; --home-plateSide: 16.0px; --home-plateFade: 46.0px; @@ -442,6 +436,8 @@ --home-heroSheetTop: 150.0px; --home-cardBlur: 26.0px; --home-heroBlur: 60.0px; + --home-progressInset: 18.0px; + --home-progressBottom: 10.0px; --home-endTop: 40.0px; --home-frameBottom: 80.0px; --home-toastPadding: 28.0px; @@ -461,6 +457,7 @@ --home-savedGap: 10.0px; --home-savedIcon: 28.0px; --home-savedRadius: 28.0px; + --home-posterProgressBottom: 12.0px; --discover-shelfHeadingBand: 104.0px; --discover-sheetPaddingX: 64.0px; --discover-sheetPaddingY: 80.0px; diff --git a/wolfskin-design/tokens.json b/wolfskin-design/tokens.json index d7db186e..28161fbc 100644 --- a/wolfskin-design/tokens.json +++ b/wolfskin-design/tokens.json @@ -1,5 +1,5 @@ { - "$description": "Wolfskin TV design tokens, spec revision 6: approved loading/motion and option-B card metadata/state lines. CSS px on a 1920x1080 stage at density 2.0; dp/sp = px / 2.", + "$description": "Wolfskin TV design tokens, spec revision 5: approved focus and Level 2 spacing, taller episode cards, roomier Discover headings and panel-resolution hero artwork. CSS px on a 1920x1080 stage at density 2.0; dp/sp = px / 2.", "meta": { "density": 2.0, "stagePx": [ @@ -15,20 +15,6 @@ "generator": "data/tv-spec-lane/scratch/extract.py + build_tokens.py", "feedbackDelta": "data/tv-v2/feedback-2026-11-06/delta.json" }, - "cardMetadata": { - "titleRest": {"$type":"typography","px":28,"sp":14,"lineHeight":1.1428571428571428,"weight":500,"$value":{"fontWeight":500}}, - "titleFocus": {"$type":"typography","px":28,"sp":14,"lineHeight":1.1428571428571428,"weight":700,"$value":{"fontWeight":700}}, - "subtitle": {"$type":"typography","px":24,"sp":12,"lineHeight":1.0833333333333333,"weight":400,"$value":{"fontWeight":400}}, - "check": {"$type":"dimension","px":24,"dp":12}, - "gap": {"$type":"dimension","px":12,"dp":6}, - "compactGap": {"$type":"dimension","px":8,"dp":4}, - "minimumGap": {"$type":"dimension","px":4,"dp":2}, - "bottom": {"$type":"dimension","px":2,"dp":1}, - "separator": {"$type":"dimension","px":2,"dp":1}, - "watchedLine": {"$type":"dimension","px":3,"dp":1.5}, - "resumeLine": {"$type":"dimension","px":6,"dp":3}, - "landscapeBand": {"$type":"dimension","px":64,"dp":32} - }, "color": { "bg": { "$type": "color", @@ -1059,7 +1045,7 @@ "px": 38, "dp": 19, "source": "index.html:1242", - "usedBy": "card-width captions start 38 px below unscaled art; library titles and subtitles remain visible, Home poster captions are focus-only" + "usedBy": "caption under a focused poster starts 38 px below the card bottom (capHtml top = headH + h + CAP_OFF; was 18, amended 2026-11-06); width 640" }, "plateInset": { "$type": "dimension", @@ -1216,7 +1202,7 @@ "ratio": "2:3", "fullSlotsPerRow": 6, "source": "index.html:656", - "usedBy": "every poster shelf and six-column library, See all and filmography grid; library captions always visible, shelf captions focus-only" + "usedBy": "every title shelf, library grid, See all grid, More like this, person filmography; art only at rest" }, "tile": { "$type": "card", @@ -1224,13 +1210,15 @@ "w": 400, "h": 225, "gap": 40, - "pitch": 440 + "pitch": 440, + "captionBand": 0 }, "dp": { "w": 200.0, "h": 112.5, "gap": 20.0, - "pitch": 220.0 + "pitch": 220.0, + "captionBand": 0.0 }, "ratio": "16:9", "fullSlotsPerRow": 4, @@ -1243,13 +1231,15 @@ "w": 544, "h": 306, "gap": 40, - "pitch": 584 + "pitch": 584, + "captionBand": 0 }, "dp": { "w": 272.0, "h": 153.0, "gap": 20.0, - "pitch": 292.0 + "pitch": 292.0, + "captionBand": 0.0 }, "ratio": "16:9", "fullSlotsPerRow": 3, diff --git a/wolfskin-design/tokens.sha256 b/wolfskin-design/tokens.sha256 index a45b7f17..8d5b523f 100644 --- a/wolfskin-design/tokens.sha256 +++ b/wolfskin-design/tokens.sha256 @@ -1,8 +1,8 @@ b3f096b692c395534177cc82ac68f5b801870bc7e28f0dd5cd7c17e2103920a3 discover-tokens.json c29dc64f3c3ca900c747b3db8b270e25908948bf5f76388d2c627a612fc7c99d generate_tokens.py -eef4e9f8c91306362540eda617c9efad4e19ed688f205f6f486625a5918d8105 home-tokens.json -192beb887fc34005db99c87ffbcbaf4a057ee2728475fc96224cf89445019b51 shell-tokens.json -3e2ad9a57c15c2ed4713abf383807bb567b27b38eddaaa36c00025e078864e12 src/main/java/org/justinvisser/wolfskin/design/WolfskinTokens.kt -062597ea50dff9e6987485f9a83d412918dba3e10109524c26d3c2332a2f5b73 title-tokens.json -d7d6b17f8258083c879365f01e06729aa0fa12b69b7cd81e1bceabbbe6ab2a7d tokens.css -ba0da02b03047fbc2f29d538d980e87aa237f8ae36545aa4234600243fe14929 tokens.json +25985087986a46f00e2819917d0421ff2b437ec21c54f6ce6412b84c0eb05302 home-tokens.json +be3af0ddcb0d67abef0805d19d7a33cdb55428bcace656b50f0a38935f212028 shell-tokens.json +0446c2e85bbda72adde76329958931216fe81c43cebd47bbdb885f5dd59d87f8 src/main/java/org/justinvisser/wolfskin/design/WolfskinTokens.kt +e0a2f5df2f249bce3f6e13f4a0320c7b18b85777eaf04a27a428d6683a6aad28 title-tokens.json +faa4e0c5398d35ecab9b1088815b79c8de26a44d403c4cafae59c7c341992da7 tokens.css +f4424475291a745400a5daa071cb846838fb5e9ce889022288bcc6a7be758fe0 tokens.json