Unify focus rings on the approved accent straddle style

This commit is contained in:
justinzeus 2026-11-06 15:52:02 +01:00
parent 93a41ea2d3
commit ef3ae3f21b
18 changed files with 173 additions and 54 deletions

View file

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

View file

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

View file

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

View file

@ -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. */

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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() {

View file

@ -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<ComponentActivity>()
@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)
}
}
}

View file

@ -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")
}

View file

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

View file

@ -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<TextLayoutResult>()