Unify focus rings on the approved accent straddle style
This commit is contained in:
parent
93a41ea2d3
commit
ef3ae3f21b
18 changed files with 173 additions and 54 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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. */
|
||||
|
|
|
|||
|
|
@ -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) })
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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))))
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 ->
|
||||
|
|
|
|||
|
|
@ -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>()
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue