diff --git a/docker/README.md b/docker/README.md index a40c5a4c..07e1b750 100644 --- a/docker/README.md +++ b/docker/README.md @@ -384,7 +384,7 @@ Discover and season-chip rows retain their 104 px height. Cards and cast circles draw a single 4 px accent stroke centred on the art edge, above the content, with no background gap. Card lift parameters are unchanged; cast portraits and episode tiles now receive that lift too. The stroke and art -scale together at 1.08; buttons, avatars, chips and picker focus are unchanged. +scale together at 1.08. 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. @@ -405,6 +405,32 @@ These captures are recordings, not automated reference-pixel assertions; pair measurements are a separate check. Platform Roboto metrics and fixture ordering are not browser pixel parity. +### Shared focus rings + +The same `focusRing` stroke now covers pill/icon buttons (hero, title, state, +recovery and sheet actions), the nav avatar, picker people and gear. It is always +accent, 4 px / 2 dp wide, centred on the surface edge with no background gap. +The former outside-gap renderer is removed. The stroke draws after opaque +content, so neither half can be covered by the background or portrait. + +Control tokens are `Focus.ringControlGap = 0` and `ringControlRing = 2.dp`; +avatar and picker tokens use the same geometry. Existing scales remain 1.05 for +buttons and 1.08 for picker people/gear; the nav avatar remains unscaled. Card +and button lifts remain; avatars, people and gear gain no shadow. Existing +clearance accommodates the smaller outside reach without layout movement. + +Filled-only chips, nav pills, Settings/sheet/Coming-in rows and the A–Z rail +are unchanged. Selected-only chip/icon/radio borders and decorative borders +are not focus rings. These distinctions are explicit in the inventory and +reference states rather than silently adding rings to filled controls. + +`FocusRingTest.ringStraddlesOpaqueSurfaceAndDisappearsWithoutFocus` checks the +two outside and two inside pixels against opaque content for pill, circle and +card radii, including clearing the stroke on blur. The focused hero Details, +nav avatar, picker, Discover chip, Library toolbar chip and title Request +captures exercise the actual screens at 1920×1080. Browser and native glyph +metrics differ; their ring offsets are measured relative to each surface edge. + ## Title pages and people `TitleSurface` owns the film/series/episode header, season chips, episode tiles, diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/CatalogueRecoveryCard.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/CatalogueRecoveryCard.kt index b2bb7ca4..4c7e5f8d 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/CatalogueRecoveryCard.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/CatalogueRecoveryCard.kt @@ -23,7 +23,7 @@ fun CatalogueRecoveryCard(retry: Boolean, focused: Boolean, onClick: () -> Unit, val shape = RoundedCornerShape(WolfskinTokens.Radius.card) Column(modifier.size(WolfskinTokens.Card.posterW, WolfskinTokens.Card.posterH) .focusScale(focused, WolfskinTokens.Focus.scaleCard) - .focusRing(focused, WolfskinTokens.Radius.card, WolfskinTokens.Focus.ringCardGap, WolfskinTokens.Focus.ringCardRing, C.accent, FocusLift.Card, straddle = true) + .focusRing(focused, WolfskinTokens.Radius.card, WolfskinTokens.Focus.ringCardRing, FocusLift.Card) .background(C.surface, shape).border(G.borderWidth, C.border, shape).clickable(onClick = onClick).padding(G.tailPadding), verticalArrangement = Arrangement.spacedBy(G.tailGap, Alignment.CenterVertically), horizontalAlignment = Alignment.CenterHorizontally) { Glyph(if (retry) "alert" else "down", if (retry) C.warn else C.accentText, Modifier.size(G.tailIcon)) diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/DiscoverScreen.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/DiscoverScreen.kt index 0d836c4f..bd6c40ef 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/DiscoverScreen.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/DiscoverScreen.kt @@ -197,7 +197,7 @@ private fun DiscoverStatePanel(state: DiscoverState) { @Composable fun DiscoverButton(label: String, focused: Boolean, glyph: String? = null, primary: Boolean = true, onClick: () -> Unit) { Row(Modifier.height(L.buttonHeight).focusScale(focused, WolfskinTokens.Focus.scaleControl) - .focusRing(focused, WolfskinTokens.Radius.button, WolfskinTokens.Focus.ringButtonGap, WolfskinTokens.Focus.ringButtonRing, C.accent, FocusLift.Button) + .focusRing(focused, WolfskinTokens.Radius.button, WolfskinTokens.Focus.ringControlRing, FocusLift.Button) .background(if (primary) C.fg else if (focused) C.buttonSecondaryFocused else C.buttonSecondary, RoundedCornerShape(WolfskinTokens.Radius.button)) .clickable(onClick = onClick).padding(horizontal = L.buttonPaddingX), horizontalArrangement = Arrangement.spacedBy(L.badgeInset), verticalAlignment = Alignment.CenterVertically) { if (glyph != null) Glyph(glyph, if (primary) C.onFocus else C.fg, Modifier.size(L.buttonIcon)) 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 7af7ec22..92e8317e 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 @@ -70,7 +70,7 @@ fun DiscoverShelf(state: DiscoverState, lane: DiscoverLane, row: Int) { modifier = Modifier.offset(y = L.rowHeadingBand - K.posterGap).height(height + K.posterGap * 2)) { 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.ringCardGap, WolfskinTokens.Focus.ringCardRing, C.accent, FocusLift.Card, straddle = true) + .focusRing(active && index == column, WolfskinTokens.Radius.card, WolfskinTokens.Focus.ringCardRing, FocusLift.Card) .background(Brush.linearGradient(listOf(Color.hsl(service.hue, .30f, .26f), Color.hsl(service.hue, .30f, .12f))), RoundedCornerShape(WolfskinTokens.Radius.card)) .border(WolfskinTokens.Library.borderWidth, C.border.copy(alpha = D.entryBorderOpacity), RoundedCornerShape(WolfskinTokens.Radius.card)) .clickable { state.focus(row, index); state.openService(service) }, contentAlignment = Alignment.BottomStart) { diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/Focus.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/Focus.kt index a4a69580..39e23ea1 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/Focus.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/Focus.kt @@ -15,11 +15,9 @@ import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.ui.draw.dropShadow import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.drawBehind import androidx.compose.ui.draw.drawWithCache import androidx.compose.ui.geometry.CornerRadius import androidx.compose.ui.geometry.Offset -import androidx.compose.ui.geometry.Size import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.graphics.drawscope.Stroke @@ -36,12 +34,12 @@ fun Modifier.focusScale(focused: Boolean, scale: Float): Modifier { return graphicsLayer { scaleX = animated; scaleY = animated } } -/** Content lifts are distinct from the white person/gear rings. */ +/** Lift shadows remain specific to cards and buttons. */ enum class FocusLift { None, Card, Button } -/** Card rings straddle the art edge above content; control rings retain their outside gap. */ +/** Every focus ring straddles the surface edge above content, without a background gap. */ @Composable -fun Modifier.focusRing(focused: Boolean, radius: Dp, gap: Dp, width: Dp, color: Color, lift: FocusLift = FocusLift.None, straddle: Boolean = false): Modifier { +fun Modifier.focusRing(focused: Boolean, radius: Dp, width: Dp, lift: FocusLift = FocusLift.None): Modifier { val ringAlpha by animateFloatAsState(if (focused) 1f else 0f, tween(WolfskinTokens.Motion.fast, easing = CubicBezierEasing(.25f, .1f, .25f, 1f)), label = "focus-ring") val shadow = if (lift == FocusLift.None) this else { @@ -56,25 +54,17 @@ fun Modifier.focusRing(focused: Boolean, radius: Dp, gap: Dp, width: Dp, color: alpha = ringAlpha * if (lift == FocusLift.Card) WolfskinTokens.Lift.cardOpacity else WolfskinTokens.Lift.buttonOpacity } } - if (straddle) return shadow.drawWithCache { + return shadow.drawWithCache { val stroke = Stroke(width.toPx()) val corner = CornerRadius(radius.toPx()) onDrawWithContent { drawContent() if (ringAlpha > 0f) { - drawRoundRect(color.copy(alpha = color.alpha * ringAlpha), + drawRoundRect(WolfskinTokens.Color.accent.copy(alpha = ringAlpha), cornerRadius = corner, style = stroke) } } } - return shadow.drawBehind { - if (ringAlpha > 0f) { - val outer = (gap + width).toPx() - val inner = gap.toPx() - drawRoundRect(color.copy(alpha = color.alpha * ringAlpha), Offset(-outer, -outer), Size(size.width + 2 * outer, size.height + 2 * outer), CornerRadius(radius.toPx() + outer)) - drawRoundRect(WolfskinTokens.Color.bg, Offset(-inner, -inner), Size(size.width + 2 * inner, size.height + 2 * inner), CornerRadius(radius.toPx() + inner)) - } - } } /** Pointing changes the same logical focus as the D-pad without activating an action. */ diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/NavBar.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/NavBar.kt index 1fedb8c7..f88d4458 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/NavBar.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/NavBar.kt @@ -111,7 +111,7 @@ fun NavBar(state: ShellState, scrolled: Boolean, modifier: Modifier = Modifier) val avatar = boxes.last() Avatar(state.person, L.navAvatar, WolfskinTokens.Type.avatarLetterNav, Modifier.offset(avatar.left, avatar.top) - .focusRing(avatarFocused, L.navAvatar / 2, WolfskinTokens.Focus.ringAvatarGap, WolfskinTokens.Focus.ringAvatarRing, C.fg) + .focusRing(avatarFocused, L.navAvatar / 2, WolfskinTokens.Focus.ringAvatarRing) .focusProperties { canFocus = false } .clickable { state.focusNav(state.tabs.size); state.key(ShellKey.Ok) }) } 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 fa51ac7c..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 @@ -17,7 +17,7 @@ fun TitleCard(focused: Boolean, modifier: Modifier = Modifier, width: Dp = Wolfs 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.ringCardGap, WolfskinTokens.Focus.ringCardRing, WolfskinTokens.Color.accent, FocusLift.Card, straddle = true)) { + .focusRing(focused, WolfskinTokens.Radius.card, WolfskinTokens.Focus.ringCardRing, FocusLift.Card)) { Box(Modifier.fillMaxSize().clip(shape).background(WolfskinTokens.Color.surface2), content = content) } } diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleControl.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleControl.kt index 4346eae2..ee5054c5 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleControl.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/TitleControl.kt @@ -37,7 +37,7 @@ fun TitleControl(option: TitleOption, focused: Boolean, modifier: Modifier = Mod val dimensions = if (option.icon) Modifier.size(L.iconButton) else Modifier.height(L.buttonHeight).padding(horizontal = L.buttonPaddingX) Row(modifier.testTag("title-option-${option.id}").titlePointerFocus(onFocus).focusScale(focused, WolfskinTokens.Focus.scaleControl) .semantics { if (option.icon) contentDescription = option.label; role = Role.Button; selected = option.on } - .focusRing(focused, WolfskinTokens.Radius.button, WolfskinTokens.Focus.ringButtonGap, WolfskinTokens.Focus.ringButtonRing, C.accent, FocusLift.Button) + .focusRing(focused, WolfskinTokens.Radius.button, WolfskinTokens.Focus.ringControlRing, FocusLift.Button) .background(background, shape) .then(if (option.on && !focused) Modifier.border(D.radioBorder, C.accent, shape) else Modifier) .clickable { focusOwner?.invoke(); onClick() }.then(dimensions), 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 0e3e8315..15a74880 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 @@ -64,7 +64,7 @@ fun TitleCast(state: TitleState, active: Boolean) { Box(Modifier.size(WolfskinTokens.Card.personW, WolfskinTokens.Card.personH).zIndex(if (focused) 1f else 0f) .onGloballyPositioned { state.recordCenter("cast", index, it.boundsInRoot().center.x) } .titlePointerFocus { state.focus("cast", index) }.focusScale(focused, WolfskinTokens.Focus.scaleCard).clickable { focusOwner?.invoke(); state.click("cast", index) }.testTag("title-cast-${person.id}")) { - PersonPortrait(person, Modifier.size(L.castCircle).focusRing(focused, L.castCircle / 2, WolfskinTokens.Focus.ringCardGap, WolfskinTokens.Focus.ringCardRing, C.accent, FocusLift.Card, straddle = true).clip(CircleShape)) + PersonPortrait(person, Modifier.size(L.castCircle).focusRing(focused, L.castCircle / 2, WolfskinTokens.Focus.ringCardRing, FocusLift.Card).clip(CircleShape)) Column(Modifier.offset(x = -D.castNameSide, y = D.castNameTop).wrapContentWidth(Alignment.Start, unbounded = true).width(L.castCircle + D.castNameSide * 2), horizontalAlignment = Alignment.CenterHorizontally) { Text(person.name, color = C.fg, style = TitleTextStyles.castName, modifier = Modifier.exactLineBox(TitleTextStyles.castName), maxLines = 1, overflow = TextOverflow.Ellipsis, textAlign = TextAlign.Center) Text(person.character, color = C.muted, style = TitleTextStyles.caption, modifier = Modifier.exactLineBox(TitleTextStyles.caption), maxLines = 1, overflow = TextOverflow.Ellipsis, textAlign = TextAlign.Center) @@ -115,7 +115,7 @@ fun TitlePoster(item: TitleItem, focused: Boolean, modifier: Modifier = Modifier LibraryPoster(item, focused, focused && ratingsVisible, catalogue = catalogue, modifier = Modifier.titlePointerFocus(onFocus).clickable { focusOwner?.invoke(); onClick() }, offlineArt = offlineArt) } else Box(Modifier.size(WolfskinTokens.Card.tileW, WolfskinTokens.Card.tileH).titlePointerFocus(onFocus).focusScale(focused, WolfskinTokens.Focus.scaleCard) - .focusRing(focused, WolfskinTokens.Radius.card, WolfskinTokens.Focus.ringCardGap, WolfskinTokens.Focus.ringCardRing, C.accent, FocusLift.Card, straddle = true) + .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)))) diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/WhosWatching.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/WhosWatching.kt index 1911e346..48d6c5e6 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/WhosWatching.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/WhosWatching.kt @@ -46,7 +46,7 @@ private fun PeoplePicker(state: ShellState) { state.focusPerson(index); state.key(ShellKey.Ok) }, horizontalAlignment = Alignment.CenterHorizontally) { Avatar(person, L.pickerAvatar, WolfskinTokens.Type.avatarLetterPicker, - Modifier.focusRing(focused, L.pickerAvatar / 2, WolfskinTokens.Focus.ringPickerGap, WolfskinTokens.Focus.ringPickerRing, C.fg)) + Modifier.focusRing(focused, L.pickerAvatar / 2, WolfskinTokens.Focus.ringPickerRing)) Spacer(Modifier.height(S.pickerNameGap)) Text(person.name, color = if (focused) C.fg else C.muted, style = WolfskinTokens.Type.pickerName) } @@ -55,7 +55,7 @@ private fun PeoplePicker(state: ShellState) { val total = L.pickerAvatar * state.people.size + L.pickerGap * (state.people.size - 1) val left = (S.stageWidth - total) / 2 + (L.pickerAvatar + L.pickerGap) * state.people.lastIndex + (L.pickerAvatar - L.gear) / 2 Box(Modifier.offset(left, S.pickerGearTop).size(L.gear).focusScale(state.gearFocused, WolfskinTokens.Focus.scaleCard) - .focusRing(state.gearFocused, L.gear / 2, WolfskinTokens.Focus.ringAvatarGap, WolfskinTokens.Focus.ringAvatarRing, C.fg) + .focusRing(state.gearFocused, L.gear / 2, WolfskinTokens.Focus.ringAvatarRing) .clickable { state.gearFocused = true; state.key(ShellKey.Ok) }, contentAlignment = Alignment.Center) { Glyph("gear", if (state.gearFocused) C.fg else C.dim.copy(alpha = C.dim.alpha * S.gearOpacity), Modifier.size(L.buttonIcon)) if (state.gearFocused) { @@ -106,7 +106,7 @@ fun PickerButton(label: String, focused: Boolean, primary: Boolean, icon: String textStyle: androidx.compose.ui.text.TextStyle = WolfskinTokens.Type.button, onClick: () -> Unit) { val background = if (primary) C.fg else if (focused) C.buttonSecondaryFocused else C.buttonSecondary Box(Modifier.height(L.buttonHeight).focusScale(focused, WolfskinTokens.Focus.scaleControl) - .focusRing(focused, WolfskinTokens.Radius.button, WolfskinTokens.Focus.ringButtonGap, WolfskinTokens.Focus.ringButtonRing, C.accent, FocusLift.Button) + .focusRing(focused, WolfskinTokens.Radius.button, WolfskinTokens.Focus.ringControlRing, FocusLift.Button) .then(if (onFocus != null) Modifier.focusOnPointerEnter(onFocus) else Modifier) .background(background, RoundedCornerShape(WolfskinTokens.Radius.button)).clickable(onClick = onClick) .padding(horizontal = L.buttonPaddingX), contentAlignment = Alignment.Center) { 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 58f5a3df..4a9ec01c 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 @@ -199,12 +199,12 @@ object WolfskinTokens { const val scaleRow = 1.02f val ringCardGap = 0.0.dp val ringCardRing = 2.0.dp - val ringButtonGap = 2.0.dp - val ringButtonRing = 2.0.dp - val ringAvatarGap = 2.0.dp + val ringControlGap = 0.0.dp + val ringControlRing = 2.0.dp + val ringAvatarGap = 0.0.dp val ringAvatarRing = 2.0.dp - val ringPickerGap = 3.0.dp - val ringPickerRing = 3.0.dp + val ringPickerGap = 0.0.dp + val ringPickerRing = 2.0.dp const val fillInverse = "background color.fg, text color.onFocus" } object Motion { diff --git a/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/DiscoverCaptureTest.kt b/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/DiscoverCaptureTest.kt index a913391e..68ee58f5 100644 --- a/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/DiscoverCaptureTest.kt +++ b/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/DiscoverCaptureTest.kt @@ -90,6 +90,7 @@ class DiscoverCaptureTest { } @Test fun st029_discover() = capture("st-029") + @Test fun focusFilterChip() = capture("st-120") { it.focus(1, 0) } @Test fun st030_picksCollection() = capture("st-030") { state -> state.openCollection(state.lanes.first { it.id == "pk-movie" }) } @Test fun st031_hidden() = capture("st-031") { hidden(it) } @Test fun st032_restore() { diff --git a/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/FocusRingTest.kt b/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/FocusRingTest.kt new file mode 100644 index 00000000..9a2b6ae5 --- /dev/null +++ b/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/FocusRingTest.kt @@ -0,0 +1,84 @@ +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.Box +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.requiredSize +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +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.unit.dp +import com.github.takahirom.roborazzi.captureRoboImage +import java.io.File +import org.junit.Assert.assertTrue +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 + +/** Opaque content must not cover either half of the shared accent focus stroke. */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34], application = Application::class, qualifiers = "w960dp-h540dp-xhdpi") +@GraphicsMode(GraphicsMode.Mode.NATIVE) +class FocusRingTest { + @get:Rule val rule = createAndroidComposeRule() + + @Test fun ringStraddlesOpaqueSurfaceAndDisappearsWithoutFocus() { + 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(true) + val radius = mutableStateOf(WolfskinTokens.Radius.button) + rule.setContent { + Box(Modifier.requiredSize(120.dp).background(WolfskinTokens.Color.bg)) { + Box(Modifier.offset(40.dp, 40.dp).size(60.dp) + .focusRing(focused.value, radius.value, WolfskinTokens.Focus.ringControlRing) + .background(WolfskinTokens.Color.surface, RoundedCornerShape(radius.value))) + } + } + val file = File.createTempFile("focus-ring-", ".png") + try { + for (corner in listOf(WolfskinTokens.Radius.button, 30.dp, WolfskinTokens.Radius.card)) { + rule.runOnIdle { radius.value = corner } + rule.mainClock.advanceTimeBy(1000) + rule.waitForIdle() + rule.onRoot().captureRoboImage(file.absolutePath) + val bitmap = BitmapFactory.decodeFile(file.absolutePath) + for ((x, expected) in listOf(77 to WolfskinTokens.Color.bg, 78 to WolfskinTokens.Color.accent, + 79 to WolfskinTokens.Color.accent, 80 to WolfskinTokens.Color.accent, + 81 to WolfskinTokens.Color.accent, 82 to WolfskinTokens.Color.surface)) { + assertPixel("Left edge x=$x, radius=$corner", expected.toArgb(), bitmap.getPixel(x, 140)) + assertPixel("Top edge y=$x, radius=$corner", expected.toArgb(), bitmap.getPixel(140, x)) + } + bitmap.recycle() + } + rule.runOnIdle { focused.value = false } + rule.mainClock.advanceTimeBy(1000) + rule.waitForIdle() + rule.onRoot().captureRoboImage(file.absolutePath) + val bitmap = BitmapFactory.decodeFile(file.absolutePath) + assertEquals(WolfskinTokens.Color.bg.toArgb(), bitmap.getPixel(79, 140)) + assertEquals(WolfskinTokens.Color.surface.toArgb(), bitmap.getPixel(80, 140)) + bitmap.recycle() + } finally { file.delete() } + } + private fun assertPixel(label: String, expected: Int, actual: Int) { + // Circular edge coverage can differ by a few channel values at pixel centres. + for (shift in listOf(0, 8, 16, 24)) { + val difference = kotlin.math.abs((expected ushr shift and 255) - (actual ushr shift and 255)) + assertTrue("$label: channel $shift differs by $difference", difference <= 8) + } + } +} \ No newline at end of file diff --git a/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/HomeCaptureTest.kt b/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/HomeCaptureTest.kt index 647bf533..323b52ff 100644 --- a/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/HomeCaptureTest.kt +++ b/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/HomeCaptureTest.kt @@ -116,5 +116,6 @@ class HomeCaptureTest { @Test fun st102_filmTile() = capture("st-102", person = "mindel", row = "new", column = 4) @Test fun st103_heroRatings() = capture("st-103") @Test fun spacingNextUpFirstFocused() = capture("spacing-next-up", row = "nu") + @Test fun focusHeroDetails() = capture("focus-hero-details", column = 1) @Test fun xHomeDiscoverStart() = capture("x-home-discover-start", row = "pk-m") } diff --git a/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/LibraryCaptureTest.kt b/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/LibraryCaptureTest.kt index 36fb48d3..8dd2e7f4 100644 --- a/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/LibraryCaptureTest.kt +++ b/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/LibraryCaptureTest.kt @@ -83,6 +83,7 @@ class LibraryCaptureTest { @Test fun st113_uncachedWhileDown() = capture("st-113", reachable = false) @Test fun unsignedRatingsHidden() = capture("library-unsigned", ratings = false) @Test fun spacingShowsFirstFocused() = capture("spacing-shows", "mindel", "shows") + @Test fun focusToolbarChip() = capture("focus-toolbar-chip") { it.toolbar = true; it.tool = 0 } @Test fun spacingLetterRailClearsFocusedFifthColumn() = capture("spacing-letter-rail") { grid -> val original = grid.source grid.source = List(200) { index -> diff --git a/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/ShellCaptureTest.kt b/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/ShellCaptureTest.kt index 62d40759..3774962c 100644 --- a/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/ShellCaptureTest.kt +++ b/wolfskin-design/src/test/java/org/justinvisser/wolfskin/design/ShellCaptureTest.kt @@ -54,6 +54,7 @@ class ShellCaptureTest { } @Test fun st001_launch() = capture("st-001") + @Test fun focusNavAvatar() = capture("focus-nav-avatar") { it.pickerVisible = false; it.focusNav(it.tabs.size) } @Test fun st002_gear() { capture("st-002") { it.pickerIndex = 2; it.gearFocused = true } val layouts = mutableListOf() diff --git a/wolfskin-design/tokens.json b/wolfskin-design/tokens.json index 30bdacb8..f8121dc4 100644 --- a/wolfskin-design/tokens.json +++ b/wolfskin-design/tokens.json @@ -1,5 +1,5 @@ { - "$description": "Wolfskin TV design tokens, extracted by script from the locked prototype (originally data/tv-v2/opus-final-2026-11-05/index.html, sha256 ee8844fc...; now amended in data/tv-v2/spacing-2026-11-06/index.html, sha256 fcc5d879...). Values are CSS px on the 1920x1080 stage; the box renders 1920x1080 at 320 dpi = density 2.0, so dp/sp = px / 2. Format follows the W3C design-tokens draft ($type/$value); extra keys: px, dp|sp, source (page file:line), usedBy, page (the page value where q1 consolidated it). Amended 2026-11-06 (spec revision 3, Level 2 spacing + card ring; data/tv-card-spacing/answers.md): card, layout.row*/gridRowGap and focus.ringCard changed; every other token is as extracted. The prototype source line numbers in `source` fields predate the amendment and drift by a few lines after index.html:8.", + "$description": "Wolfskin TV design tokens, spec revision 4: approved Level 2 card spacing and one accent straddle focus ring for cards, controls, avatars and picker people. Source: data/tv-v2/focus-2026-11-06/index.html. CSS px on a 1920x1080 stage at density 2.0; dp/sp = px / 2. Earlier prototype source coordinates remain provenance; revision 4 focus geometry is recorded explicitly in each focus ring token.", "meta": { "density": 2.0, "stagePx": [ @@ -10,8 +10,8 @@ 960, 540 ], - "source": "data/tv-v2/spacing-2026-11-06/index.html", - "sourceSha256": "fcc5d879c34da868b817f8d9d306193743d3933db3096da605bda659d8e633b2", + "source": "data/tv-v2/focus-2026-11-06/index.html", + "sourceSha256": "1bb7f4097a5303a58e45ec5e61cf867fa6cfdcbfe12fc7a196f00acacae5e48d", "generator": "data/tv-spec-lane/scratch/extract.py + build_tokens.py" }, "color": { @@ -20,7 +20,7 @@ "$value": "#07070a", "css": "#07070a", "source": "index.html:10", - "usedBy": "app background; stage; focus-ring gap colour; hero/detail scrim end; nav band when solid" + "usedBy": "app background; stage; hero/detail scrim end; nav band when solid" }, "surface": { "$type": "color", @@ -1338,33 +1338,48 @@ "outsidePx": 2, "insidePx": 2 }, - "ringButton": { + "ringControl": { "$type": "shadow", - "$value": "0 0 0 4px var(--bg),0 0 0 8px var(--accent),0 12px 40px rgb(0 0 0 / .5)", - "gapPx": 4, + "$value": "0 0 0 2px var(--accent),inset 0 0 0 2px var(--accent),0 12px 40px rgb(0 0 0 / .5)", + "gapPx": 0, "ringPx": 4, + "gapDp": 0, + "ringDp": 2, + "outsidePx": 2, + "insidePx": 2, + "alignment": "straddle", "ringColor": "color.accent", "liftShadow": "0 12px 40px rgb(0 0 0 / .5)", - "source": "index.html:110", - "usedBy": "focused pill and icon buttons (primary stays white, secondary lightens to buttonSecondaryFocused)" + "source": "index.html:111", + "usedBy": "focused pill and icon buttons, including hero, title, recovery and sheet actions; existing fills, text colours, scale and lift unchanged" }, "ringAvatar": { "$type": "shadow", - "$value": "0 0 0 4px var(--bg),0 0 0 8px var(--fg)", - "gapPx": 4, + "$value": "0 0 0 2px var(--accent),inset 0 0 0 2px var(--accent)", + "gapPx": 0, "ringPx": 4, - "ringColor": "color.fg", - "source": "index.html:92", - "usedBy": "focused nav avatar and focused gear (white ring, not lilac)" + "gapDp": 0, + "ringDp": 2, + "outsidePx": 2, + "insidePx": 2, + "alignment": "straddle", + "ringColor": "color.accent", + "source": "index.html:92,312", + "usedBy": "focused nav avatar and gear; existing scale unchanged, no lift" }, "ringPicker": { "$type": "shadow", - "$value": "0 0 0 6px var(--bg),0 0 0 12px var(--fg)", - "gapPx": 6, - "ringPx": 6, - "ringColor": "color.fg", - "source": "index.html:263", - "usedBy": "focused person in Who's watching (white 6 + 6 ring; name turns fg)" + "$value": "0 0 0 2px var(--accent),inset 0 0 0 2px var(--accent)", + "gapPx": 0, + "ringPx": 4, + "gapDp": 0, + "ringDp": 2, + "outsidePx": 2, + "insidePx": 2, + "alignment": "straddle", + "ringColor": "color.accent", + "source": "index.html:265", + "usedBy": "focused person in Who's watching; scale 1.08, name turns fg, no lift" }, "fillInverse": { "$type": "rule", diff --git a/wolfskin-design/tokens.sha256 b/wolfskin-design/tokens.sha256 index dfc777fb..b3fabec1 100644 --- a/wolfskin-design/tokens.sha256 +++ b/wolfskin-design/tokens.sha256 @@ -2,7 +2,7 @@ 85109c394255f6120a621fbefe8e58df82832974ccd6a6a975a61567f641ea2a generate_tokens.py 25985087986a46f00e2819917d0421ff2b437ec21c54f6ce6412b84c0eb05302 home-tokens.json be3af0ddcb0d67abef0805d19d7a33cdb55428bcace656b50f0a38935f212028 shell-tokens.json -4f19801799936db4c126cd7442942ff8552774f141af0de42eb5b3c1ecac92fc src/main/java/org/justinvisser/wolfskin/design/WolfskinTokens.kt +c5ca037f08a0ab36139bf3805f1e0305e4c8e31e0d198cf056c791476950b96b src/main/java/org/justinvisser/wolfskin/design/WolfskinTokens.kt 8ae28b149e38a00952c07a6c27bb5df667101acf09b7b571b658774f453b2063 title-tokens.json b482e5c532b50cffae9923b86efd03d40f1a56300e1c3c6d969b965f41ea1670 tokens.css -6764fa02146c3f37cc60e298f7b13c5d2186fb2be575a39e45c7b207da5429af tokens.json +3b8b2112917dbd9fb16030cc1f3de2c35b5b6775136d16600b55ffd83936da69 tokens.json