Derive table layout from measured bounds instead of tuned constants

The recurring spacing and crowding problems shared one cause: geometry was
tuned against the Pixel emulator (427 x 952dp) while the target device is a
Galaxy S24+ (SM-S926U, 1080x2340 @ 450dpi = 384 x 832dp). That is 43dp
narrower and 120dp shorter — roughly two button rows — so the emulator
consistently hid the failures instead of showing them.

Seat crowding. Seat width was fixed at 88dp while positions were fractions of
width, so seats collided as the screen narrowed. Measured at 384dp, ALL FOUR
adjacent pairs overlapped: -30.4, -3.5, -3.5, -30.4 dp. Seat width now derives
from the measured container so five seats always fit with equal real gaps, and
revealed cards scale within the slot rather than widening it. TableOrbitTest
asserts no overlap and even spacing at 320/360/384/411/427/480dp, so the narrow
case cannot regress unnoticed again.

Raise panel rhythm. A Material Slider paints a ~16dp track inside a 48dp
accessibility touch target. Laid out at 48dp it injected 16dp of invisible
padding above and below, so a uniform declared gap rendered as ~20dp around the
slider and ~4dp between the filled buttons. That is why tuning the uniform
number never worked: the error is a constant offset, not a proportional one, and
tightening 12dp to 4dp made the ratio worse (2.3x to 5x). The slider now
reserves only its painted height via requiredHeight while keeping the 48dp touch
target.

Control contrast. Back and +/- buttons were 9% white on near-black, too faint to
read as blocks, so the eye measured gaps between ink rather than layout bounds.
Raised to 16%, presets to 12%.

Grouping. The confirm action is separated from the three sizing rows rather than
evenly spaced among them; they set a value, it commits one.

Measured on S24+ geometry, gaps between the sizing rows went from
[23.1, 20.3, 3.9] (spread 19.2dp, visibly shrinking) to [14.6, 14.6] with a
deliberate 26.3dp break before Confirm — spread 0.0dp within the group.

Reclaimed space. The table was aspect-ratio locked, leaving 178dp of dead felt
(21% of the display) between the hero cards and the controls once the two-stage
raise freed it. The table now fills the available height and the felt is an oval
sized to its container rather than a width-derived circle.

259 tests, 0 failures. Lint 0 errors.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jay
2026-07-27 19:01:23 -04:00
parent e6a387478f
commit 10a2056f7f
2 changed files with 214 additions and 77 deletions
@@ -19,6 +19,7 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.requiredHeight
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.systemBarsPadding
import androidx.compose.foundation.layout.width
@@ -76,6 +77,25 @@ private val Muted = Color(0xFF9FB0A6)
private val Danger = Color(0xFF8C242A)
private val Panel = Color(0xE604120D)
/**
* Minimum accessible touch target. Controls keep this even when their painted
* area is smaller — see [SLIDER_VISIBLE_HEIGHT].
*/
private val TOUCH_TARGET = 48.dp
/**
* How much vertical space the slider actually paints. Material reserves a 48dp
* touch target around a ~16dp track; laying it out at the full 48dp injects
* invisible padding that makes uniform gaps render unevenly.
*/
private val SLIDER_VISIBLE_HEIGHT = 16.dp
/** One consistent rhythm for stacked controls. */
private val CONTROL_GAP = 8.dp
/** Separation between a group of controls and the action that commits them. */
private val GROUP_GAP = 18.dp
internal fun visibleChipCount(amount: Int): Int = when {
amount <= 0 -> 0
amount <= 2 -> 1
@@ -87,26 +107,51 @@ internal fun visibleChipCount(amount: Int): Int = when {
else -> 7
}
internal data class NormalizedSeatPosition(
val centerX: Float,
val topY: Float,
internal data class SeatPlacement(
/** Distance in dp from the table's left edge to this seat's left edge. */
val leftDp: Float,
/** Vertical lane as a fraction of table height. */
val topFraction: Float,
)
/** Space kept clear at the table's left and right edges. */
private const val SEAT_EDGE_MARGIN_DP = 6f
/** Smallest horizontal gap permitted between two neighbouring seats. */
private const val SEAT_MIN_GAP_DP = 6f
/**
* Five seats around the outside of a circular felt.
* Width one opponent seat may occupy, derived from the table's real width.
*
* Keeping this geometry separate from Compose makes the table relationship
* testable. The three top seats have independent horizontal lanes; the two side
* seats straddle the rail instead of consuming the board's middle.
* Seat width MUST come from the measured container. A fixed width combined with
* fractional positions silently overlaps as the screen narrows: an 88dp seat at
* centre fractions 0.13/0.28 overlaps by 30dp at 384dp wide (Galaxy S24+) while
* merely looking tight at 427dp (Pixel emulator). Sizing from the container
* makes five seats fit at any width by construction.
*/
internal fun opponentSeatOrbit(): List<NormalizedSeatPosition> =
listOf(
NormalizedSeatPosition(centerX = 0.13f, topY = 0.18f),
NormalizedSeatPosition(centerX = 0.28f, topY = 0.01f),
NormalizedSeatPosition(centerX = 0.50f, topY = 0f),
NormalizedSeatPosition(centerX = 0.72f, topY = 0.01f),
NormalizedSeatPosition(centerX = 0.87f, topY = 0.18f),
internal fun opponentSeatWidthDp(availableWidthDp: Float): Float {
val usable = availableWidthDp - 2f * SEAT_EDGE_MARGIN_DP - 4f * SEAT_MIN_GAP_DP
return (usable / 5f).coerceIn(52f, 96f)
}
/**
* Five seats spread edge to edge with equal gaps, staggered into three lanes so
* the row still reads as an arc around the felt rather than a straight line.
*/
internal fun opponentSeatPlacements(
availableWidthDp: Float,
seatWidthDp: Float = opponentSeatWidthDp(availableWidthDp),
): List<SeatPlacement> {
val lanes = listOf(0.30f, 0.06f, 0f, 0.06f, 0.30f)
val span = availableWidthDp - 2f * SEAT_EDGE_MARGIN_DP - seatWidthDp
val step = span / 4f
return List(5) { index ->
SeatPlacement(
leftDp = SEAT_EDGE_MARGIN_DP + step * index,
topFraction = lanes[index],
)
}
}
@Composable
fun TableScreen(vm: PokerViewModel) {
@@ -131,11 +176,12 @@ fun TableScreen(vm: PokerViewModel) {
status = status,
tableTalk = tableTalk,
handSummary = handSummary,
// Fill the space the two-stage raise control freed instead of locking
// an aspect ratio and leaving ~178dp of dead felt below the hero.
modifier = Modifier
.fillMaxWidth()
.aspectRatio(0.80f),
.weight(1f),
)
Spacer(Modifier.weight(1f))
TableControls(
offer = offer,
handSummary = handSummary,
@@ -223,18 +269,21 @@ private fun PokerTable(
) {
TableFelt(Modifier.fillMaxSize())
val orbit = opponentSeatOrbit()
// Seat width and position both come from the measured table, so five
// seats fit with real gaps on a 384dp phone as well as a 427dp emulator.
val seatWidth = opponentSeatWidthDp(maxWidth.value)
val placements = opponentSeatPlacements(maxWidth.value, seatWidth)
seats.filter { it.index != HERO_SEAT }
.forEachIndexed { index, seat ->
val position = orbit.getOrElse(index) { orbit.last() }
val seatWidth = if (seat.revealed && seat.hole != null) 108.dp else 88.dp
val placement = placements.getOrElse(index) { placements.last() }
OpponentSeat(
seat = seat,
isTurn = snapshot?.toAct == seat.index,
isWinner = seat.index in winners,
width = seatWidth.dp,
modifier = Modifier.offset(
x = maxWidth * position.centerX - seatWidth / 2,
y = maxWidth * position.topY,
x = placement.leftDp.dp,
y = maxHeight * placement.topFraction,
),
)
}
@@ -243,9 +292,11 @@ private fun PokerTable(
snapshot = snapshot,
status = status,
tableTalk = tableTalk,
// Below the side-seat lane (0.30 of height plus the seat itself),
// measured against height so it tracks the taller felt.
modifier = Modifier
.align(Alignment.TopCenter)
.offset(y = maxWidth * 0.40f),
.offset(y = maxHeight * 0.46f),
)
HeroSeat(
@@ -274,33 +325,47 @@ private fun PokerTable(
@Composable
private fun TableFelt(modifier: Modifier = Modifier) {
Canvas(modifier) {
val center = Offset(size.width * 0.5f, size.width * 0.5f)
val railRadius = size.minDimension * 0.405f
drawCircle(
// An oval sized to the container, so the felt grows into whatever height
// the table is given rather than staying a width-sized circle.
val inset = 9.dp.toPx()
val railTop = size.height * 0.055f
val railRect = androidx.compose.ui.geometry.Rect(
left = size.width * 0.012f,
top = railTop,
right = size.width * 0.988f,
bottom = size.height * 0.965f,
)
drawOval(
brush = Brush.linearGradient(
colors = listOf(RailLight, RailDark),
start = Offset(center.x - railRadius, center.y - railRadius),
end = Offset(size.width, size.height),
start = Offset(railRect.left, railRect.top),
end = Offset(railRect.right, railRect.bottom),
),
radius = railRadius,
center = center,
topLeft = Offset(railRect.left, railRect.top),
size = androidx.compose.ui.geometry.Size(railRect.width, railRect.height),
)
val inset = 9.dp.toPx()
val feltRadius = railRadius - inset
drawCircle(
val feltTopLeft = Offset(railRect.left + inset, railRect.top + inset)
val feltSize = androidx.compose.ui.geometry.Size(
railRect.width - inset * 2,
railRect.height - inset * 2,
)
drawOval(
brush = Brush.radialGradient(
colors = listOf(FeltLight, FeltMid, FeltDark),
center = Offset(center.x, center.y - feltRadius * 0.18f),
radius = feltRadius * 1.15f,
center = Offset(
feltTopLeft.x + feltSize.width * 0.5f,
feltTopLeft.y + feltSize.height * 0.34f,
),
radius = feltRadius,
center = center,
radius = feltSize.minDimension * 1.15f,
),
topLeft = feltTopLeft,
size = feltSize,
)
drawCircle(
color = TableGold.copy(alpha = 0.34f),
radius = feltRadius,
center = center,
drawOval(
color = TableGold.copy(alpha = 0.30f),
topLeft = feltTopLeft,
size = feltSize,
style = Stroke(width = 1.dp.toPx()),
)
}
@@ -311,15 +376,18 @@ private fun OpponentSeat(
seat: SeatSnapshot,
isTurn: Boolean,
isWinner: Boolean,
width: Dp,
modifier: Modifier = Modifier,
) {
val faded = seat.folded && !isWinner
val revealed = seat.revealed && seat.hole != null
val faceWidth = 50.dp
val faceHeight = 75.dp
// Revealed cards scale with the slot instead of widening it. Growing the seat
// would reintroduce the overlap the measured layout exists to prevent.
val faceWidth = ((width - 14.dp) / 2).coerceIn(30.dp, 54.dp)
val faceHeight = faceWidth * 1.5f
Column(
modifier = modifier
.width(if (revealed) 108.dp else 88.dp)
.width(width)
.alpha(if (faded) 0.38f else 1f),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(4.dp),
@@ -773,8 +841,8 @@ private fun DecisionControls(
listOf(Color.Transparent, Night.copy(alpha = 0.95f), Night),
),
)
.padding(horizontal = 14.dp, vertical = 8.dp),
verticalArrangement = Arrangement.spacedBy(4.dp),
.padding(horizontal = 14.dp, vertical = 10.dp),
verticalArrangement = Arrangement.spacedBy(CONTROL_GAP),
) {
if (!sizingRaise) {
PrimaryDecisionButtons(
@@ -787,7 +855,9 @@ private fun DecisionControls(
} else {
val amount = raiseConfirmationAmount(buttons, raiseTo)
Row(
modifier = Modifier.fillMaxWidth(),
modifier = Modifier
.fillMaxWidth()
.height(TOUCH_TARGET),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
@@ -795,7 +865,7 @@ private fun DecisionControls(
Column(horizontalAlignment = Alignment.End) {
Text(
"RAISE TO",
color = Muted.copy(alpha = 0.82f),
color = Muted,
fontWeight = FontWeight.Bold,
fontSize = 12.sp,
letterSpacing = 1.4.sp,
@@ -811,13 +881,27 @@ private fun DecisionControls(
if (buttons.showSlider) {
Row(
modifier = Modifier.fillMaxWidth(),
modifier = Modifier
.fillMaxWidth()
.height(TOUCH_TARGET),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
FineTuneButton("") {
raiseTo = adjustRaiseAmount(raiseTo, -1, buttons)
}
// A Slider draws a ~16dp track inside a 48dp accessibility touch
// target, so 32dp of it is invisible. Laying it out at 48dp adds
// 16dp of phantom space above and below, which is why a uniform
// gap rendered as ~20dp around the slider and ~4dp between the
// filled buttons. Reserve only the visible height and let the
// touch target overflow, so declared gaps are the real gaps.
Box(
modifier = Modifier
.weight(1f)
.height(SLIDER_VISIBLE_HEIGHT),
contentAlignment = Alignment.Center,
) {
Slider(
value = amount.toFloat(),
onValueChange = { raiseTo = it.roundToInt() },
@@ -827,10 +911,9 @@ private fun DecisionControls(
activeTrackColor = TableGold,
inactiveTrackColor = Color.White.copy(alpha = 0.12f),
),
modifier = Modifier
.weight(1f)
.height(48.dp),
modifier = Modifier.requiredHeight(TOUCH_TARGET),
)
}
FineTuneButton("+") {
raiseTo = adjustRaiseAmount(raiseTo, 1, buttons)
}
@@ -851,7 +934,7 @@ private fun DecisionControls(
containerColor = if (amount == preset.amount) {
TableGold.copy(alpha = 0.2f)
} else {
Color.White.copy(alpha = 0.06f)
Color.White.copy(alpha = 0.12f)
},
contentColor = if (amount == preset.amount) GoldLight else Muted,
),
@@ -882,6 +965,7 @@ private fun DecisionControls(
)
}
Spacer(Modifier.height(GROUP_GAP - CONTROL_GAP))
ActionButton(
title = if (amount >= buttons.sliderMax) "Confirm all in" else "Confirm raise",
detail = if (amount >= buttons.sliderMax) "$amount" else "to $amount",
@@ -948,7 +1032,7 @@ private fun BackToActionsButton(onClick: () -> Unit) {
modifier = Modifier.height(48.dp),
contentPadding = PaddingValues(horizontal = 14.dp),
colors = ButtonDefaults.buttonColors(
containerColor = Color.White.copy(alpha = 0.09f),
containerColor = Color.White.copy(alpha = 0.16f),
contentColor = Cream,
),
shape = RoundedCornerShape(12.dp),
@@ -967,7 +1051,7 @@ private fun FineTuneButton(
modifier = Modifier.size(48.dp),
contentPadding = PaddingValues(0.dp),
colors = ButtonDefaults.buttonColors(
containerColor = Color.White.copy(alpha = 0.09f),
containerColor = Color.White.copy(alpha = 0.16f),
contentColor = GoldLight,
),
shape = RoundedCornerShape(12.dp),
@@ -4,23 +4,76 @@ import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* Seat geometry must hold on real phones, not just the development emulator.
*
* The previous layout paired a fixed 88dp seat with fractional centres. That
* overlapped by 30dp at 384dp wide (Galaxy S24+) while looking merely tight at
* 427dp (Pixel emulator) — which is why the crowding stayed invisible in
* testing. These widths are asserted explicitly so the narrow case cannot
* regress unnoticed again.
*/
class TableOrbitTest {
@Test
fun `five opponents form a symmetric perimeter around the circular felt`() {
val positions = opponentSeatOrbit()
/** Small phone, common phone, Galaxy S24+, Pixel, emulator, large phone. */
private val widths = listOf(320f, 360f, 384f, 411f, 427f, 480f)
assertEquals(5, positions.size)
assertTrue(positions.zipWithNext().all { (left, right) -> left.centerX < right.centerX })
assertTrue(positions.all { it.centerX in 0f..1f })
@Test
fun `five seats never overlap at any supported width`() {
for (width in widths) {
val seatWidth = opponentSeatWidthDp(width)
val placements = opponentSeatPlacements(width, seatWidth)
assertEquals(5, placements.size)
placements.zipWithNext { left, right ->
val gap = right.leftDp - (left.leftDp + seatWidth)
assertTrue(
"all opponents stay around the upper perimeter",
positions.all { it.topY < 0.30f },
"at ${width}dp seats overlap by ${-gap}dp (seat width $seatWidth)",
gap >= -0.01f,
)
assertEquals(1f, positions.first().centerX + positions.last().centerX, 0.001f)
assertEquals(positions.first().topY, positions.last().topY, 0.001f)
assertEquals(1f, positions[1].centerX + positions[3].centerX, 0.001f)
assertEquals(positions[1].topY, positions[3].topY, 0.001f)
}
}
}
@Test
fun `seats stay inside the table at any supported width`() {
for (width in widths) {
val seatWidth = opponentSeatWidthDp(width)
val placements = opponentSeatPlacements(width, seatWidth)
assertTrue("at ${width}dp the first seat is off-screen", placements.first().leftDp >= 0f)
assertTrue(
"at ${width}dp the last seat overflows by " +
"${placements.last().leftDp + seatWidth - width}dp",
placements.last().leftDp + seatWidth <= width + 0.01f,
)
}
}
@Test
fun `seat width grows with the table but stays legible`() {
val narrow = opponentSeatWidthDp(320f)
val phone = opponentSeatWidthDp(384f)
val wide = opponentSeatWidthDp(480f)
assertTrue("a wider table should never shrink seats", narrow <= phone && phone <= wide)
assertTrue("a seat must stay wide enough to read a name", narrow >= 52f)
}
@Test
fun `seats are staggered into an arc rather than one straight row`() {
val lanes = opponentSeatPlacements(384f).map { it.topFraction }
assertTrue("outer seats should sit lower than the crown", lanes[0] > lanes[2])
assertTrue("the arc should be symmetric", lanes[0] == lanes[4] && lanes[1] == lanes[3])
assertEquals("the middle seat crowns the arc", 0f, lanes[2], 0.001f)
}
@Test
fun `spacing is even so the row reads as a deliberate arrangement`() {
val width = 384f
val seatWidth = opponentSeatWidthDp(width)
val gaps = opponentSeatPlacements(width, seatWidth)
.zipWithNext { a, b -> b.leftDp - (a.leftDp + seatWidth) }
val first = gaps.first()
assertTrue("uneven seat gaps: $gaps", gaps.all { kotlin.math.abs(it - first) < 0.01f })
}
@Test