502ba3b1ec00
fix the real C2 dead-space root cause: settingsDeckHeight() capped the whole deck at a fixed 462dp ported verbatim from the browser's small popup (StackDeck.tsx), and separately EXPANDED_H (258dp) capped the deckOpen picker view's active-card preview height the same way - together stranding roughly a third of the screen as dead space and hiding most of each card's content (Pro card's full 6-item feature list + price + CTA button were invisible below the fold). Made both dynamic: settingsDeckHeight now fills available height up to a much larger bound, and expandedH is computed so the active preview + all 6 fanned tabs together exactly fill the deck height, floored at the original browser-derived minimum. Live-verified: Pro card now shows its entire content (Peach Sync/Codex/Passkeys/Vault Health/Canary/Duress list, price, Get Peach Pro button) that was previously cut off. Also converted the deck's drag-to-focus/collapse motion from a tween-styled-as-spring to real spring() physics (matching PeachExpandableCard), and removed the invented 'end of card' marker from every settings card per the same no-invented-end-markers pattern as C7 and vault list's 'all caught up'. Updated SettingsStackTest's height assertions to match the new intended behavior
- time
- 2026-07-24 07:11 UTC · 3 months ago
- machine
- koh2
- agent
- claude-sonnet-5
- session
- critical-corrections-c1-c7
- parent
- 0baf831b58f8
↓ peach-android-502ba3b1.tar.gzatlas at this save
files changed4 files
app/src/main/java/com/peachpasswords/ui/settings/SettingsScreen.kt+36 −44app/src/main/java/com/peachpasswords/ui/vault/EntryDetailScreen.kt+5 −2app/src/main/java/com/peachpasswords/ui/vault/VaultListScreen.kt+10 −10app/src/test/java/com/peachpasswords/ui/settings/SettingsStackTest.kt+6 −2app/src/main/java/com/peachpasswords/ui/settings/SettingsScreen.kt+36 −44
@@ -2,12 +2,14 @@22 33 import androidx.activity.compose.BackHandler44 import androidx.compose.animation.core.Animatable5+import androidx.compose.animation.core.Spring56 import androidx.compose.animation.core.animateDpAsState67 import androidx.compose.animation.core.animateFloat78 import androidx.compose.animation.core.animateFloatAsState89 import androidx.compose.animation.core.infiniteRepeatable910 import androidx.compose.animation.core.keyframes1011 import androidx.compose.animation.core.rememberInfiniteTransition12+import androidx.compose.animation.core.spring1113 import androidx.compose.animation.core.tween1214 import androidx.compose.foundation.background1315 import androidx.compose.foundation.clickable@@ -114,17 +116,20 @@114116 * one-to-one. The active card sits at 0; the first fanned tab sits just under the115117 * expanded card and each subsequent tab steps down by one [COLLAPSED_TAB_STRIDE].116118 */117-internal fun collapsedTabTop(distance: Int): Dp =118- EXPANDED_H - TAB_OVERLAP + COLLAPSED_TAB_STRIDE * distance119+internal fun collapsedTabTop(distance: Int, expandedH: Dp = EXPANDED_H): Dp =120+ expandedH - TAB_OVERLAP + COLLAPSED_TAB_STRIDE * distance119121 120122 /**121- * Browser StackDeck parity: the deck is a bounded region, not the whole settings122- * screen. The fanned tabs live inside this box so the dark shell does not show123- * as accidental gaps below the stack.123+ * The deck fills the available screen height (minus a bottom gutter) rather124+ * than capping at [MAX_DECK_H] — that constant was ported verbatim from the125+ * browser's small popup (`StackDeck.tsx`) and, left as a hard cap, stranded126+ * a third of the screen as dead space below the stack on a phone-height127+ * viewport. [MAX_DECK_H] still bounds the deck on very tall/tablet screens128+ * so a focused card doesn't grow absurdly large.124129 */125130 internal fun settingsDeckHeight(availableHeight: Dp): Dp {126131 val constrained = availableHeight - DECK_BOTTOM_GUTTER127- return minOf(MAX_DECK_H, if (constrained < 0.dp) 0.dp else constrained)132+ return minOf(MAX_DECK_H * 2, if (constrained < 0.dp) 0.dp else constrained)128133 }129134 130135 @Composable@@ -194,6 +199,15 @@194199 BoxWithConstraints(Modifier.fillMaxSize()) {195200 val deckHeight = settingsDeckHeight(maxHeight)196201 val focusHeight = deckHeight - 8.dp202+ // The deckOpen "pick a card" fan used a fixed EXPANDED_H (a literal203+ // browser-popup constant) for the active card's preview height,204+ // leaving the same dead space below the fanned tabs on a phone205+ // screen that settingsDeckHeight itself was just fixed for. Size it206+ // so the active preview + all 6 fanned tabs together fill deckHeight,207+ // never shrinking below the original browser-derived minimum.208+ val expandedH = (209+ deckHeight + TAB_OVERLAP - COLLAPSED_TAB_STRIDE * (STACK_CARDS.size - 1) - TAB_CARD_H210+ ).coerceAtLeast(EXPANDED_H)197211 Box(Modifier.fillMaxWidth().height(deckHeight)) {198212 STACK_CARDS.forEachIndexed { index, card ->199213 val isActive = card.id == active@@ -205,33 +219,37 @@205219 val targetTop = when {206220 isActive -> 0.dp207221 !deckOpen -> focusHeight + 44.dp + (distance * 8).dp208- else -> collapsedTabTop(distance)222+ else -> collapsedTabTop(distance, expandedH)209223 }210224 val targetHeight = when {211- isActive && deckOpen -> EXPANDED_H225+ isActive && deckOpen -> expandedH212226 isActive -> focusHeight213227 else -> TAB_CARD_H214228 }215229 val concealed = !deckOpen && !isActive216230 217- val animSpec = if (fanning) {218- tween<Float>(140, easing = PeachMotion.Celebrate)219- } else {220- tween<Float>(PeachMotion.Stack, easing = PeachMotion.StackSpring)221- }231+ // The drag-to-focus/collapse gesture is a continuous, interruptible232+ // user interaction (not a one-shot navigation transition), so it gets233+ // real spring physics here rather than the fixed-duration tween used234+ // for the one-shot celebration fan — matching PeachExpandableCard and235+ // current M3 Expressive's physics-based spatial-spring motion model.236+ val stackSpringDp = spring<Dp>(stiffness = Spring.StiffnessMediumLow, dampingRatio = Spring.DampingRatioLowBouncy)237+ val stackSpringFloat = spring<Float>(stiffness = Spring.StiffnessMediumLow, dampingRatio = Spring.DampingRatioLowBouncy)222238 val top by animateDpAsState(223239 targetTop,224- animationSpec = if (fanning) tween(140, easing = PeachMotion.Celebrate)225- else tween(PeachMotion.Stack, easing = PeachMotion.StackSpring),240+ animationSpec = if (fanning) tween(140, easing = PeachMotion.Celebrate) else stackSpringDp,226241 label = "top",227242 )228243 val cardH by animateDpAsState(229244 targetHeight,230- animationSpec = if (fanning) tween(140, easing = PeachMotion.Celebrate)231- else tween(PeachMotion.Stack, easing = PeachMotion.StackSpring),245+ animationSpec = if (fanning) tween(140, easing = PeachMotion.Celebrate) else stackSpringDp,232246 label = "h",233247 )234- val alpha by animateFloatAsState(if (concealed) 0f else 1f, animSpec, label = "a")248+ val alpha by animateFloatAsState(249+ if (concealed) 0f else 1f,250+ animationSpec = if (fanning) tween(140, easing = PeachMotion.Celebrate) else stackSpringFloat,251+ label = "a",252+ )235253 val zIndex = if (isActive) 99f else (50 - distance).toFloat()236254 237255 // Celebration shake — keyframed bob + tilt as the Pro card lands.@@ -394,7 +412,6 @@394412 .verticalScroll(rememberScrollState()),395413 ) {396414 body()397- SettingsCardEndState(spec.fg)398415 }399416 }400417 } else {@@ -443,31 +460,6 @@443460 )444461 }445462 446-@Composable447-private fun SettingsCardEndState(fg: Color) {448- Column(449- Modifier450- .fillMaxWidth()451- .padding(top = PeachSpacing.md, bottom = PeachSpacing.lg),452- horizontalAlignment = Alignment.CenterHorizontally,453- verticalArrangement = Arrangement.spacedBy(PeachSpacing.xs),454- ) {455- Text(456- "end of card",457- color = fg.copy(alpha = 0.62f),458- fontFamily = PeachType.Display,459- fontStyle = FontStyle.Italic,460- fontSize = 16.sp,461- )462- Text(463- "Drag or tap the stack to move between settings.",464- color = fg.copy(alpha = 0.45f),465- fontFamily = PeachType.Mono,466- fontSize = 8.5.sp,467- letterSpacing = 0.08.em,468- )469- }470-}471463 472464 /**473465 * Settings card gradient at CSS 155° across the drawn card (spec §5.5). Computedapp/src/main/java/com/peachpasswords/ui/vault/EntryDetailScreen.kt+5 −2
@@ -401,7 +401,10 @@401401 // This entry is the post-unlock landing surface, not pushed402402 // from the vault list — there's nothing to go "back" to, so403403 // the chrome offers the one action that actually makes sense404- // here instead (lock vault list's own affordance).404+ // here instead (lock vault list's own affordance). Same405+ // 40dp tap target as the back arrow, but the padlock glyph406+ // reads visually heavier than the thin arrow chevron at the407+ // same box size, so it gets a touch more inset to match.405408 Icon(406409 Icons.Outlined.Lock,407410 contentDescription = "Lock vault",@@ -410,7 +413,7 @@410413 .size(40.dp)411414 .clip(CircleShape)412415 .clickable(onClick = onLock)413- .padding(PeachSpacing.sm),416+ .padding(11.dp),414417 )415418 }416419 if (entry != null && !confirmingDelete) {app/src/main/java/com/peachpasswords/ui/vault/VaultListScreen.kt+10 −10
@@ -624,12 +624,16 @@624624 }625625 FaviconInitials(source = entry.url ?: entry.title, size = 36.dp)626626 Column(Modifier.weight(1f)) {627- Text(628- text = entry.title,629- color = peachColors.T0,630- style = PeachType.entryNameList,631- maxLines = 1,632- )627+ Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {628+ Text(629+ text = entry.title,630+ color = peachColors.T0,631+ style = PeachType.entryNameList,632+ maxLines = 1,633+ modifier = Modifier.weight(1f, fill = false),634+ )635+ entry.tags?.takeIf { it.isNotEmpty() }?.let { tags -> EntryRowTags(tags) }636+ }633637 if (meta.isNotEmpty()) {634638 Text(635639 text = meta,@@ -637,10 +641,6 @@637641 style = TextStyle(fontFamily = PeachType.Mono, fontSize = 9.sp),638642 maxLines = 1,639643 )640- }641- entry.tags?.takeIf { it.isNotEmpty() }?.let { tags ->642- Spacer(Modifier.height(4.dp))643- EntryRowTags(tags)644644 }645645 }646646 if (entry.hasTOTP()) {app/src/test/java/com/peachpasswords/ui/settings/SettingsStackTest.kt+6 −2
@@ -25,8 +25,12 @@2525 assertEquals(collapsedTabTop(5) - collapsedTabTop(4), stride)2626 }2727 28- @Test fun settings_deck_is_bounded_like_browser_stack_deck() {29- assertEquals(462.dp, settingsDeckHeight(900.dp))28+ @Test fun settings_deck_fills_available_height_instead_of_the_browsers_small_popup_cap() {29+ // The deck used to hard-cap at 462dp (a literal port of the browser's small30+ // popup), stranding a third of a phone screen as dead space below the stack.31+ // It now fills the available height (minus the bottom gutter) up to a much32+ // larger bound that only matters on very tall/tablet screens.33+ assertEquals(864.dp, settingsDeckHeight(900.dp))3034 assertEquals(444.dp, settingsDeckHeight(480.dp))3135 assertEquals(0.dp, settingsDeckHeight(24.dp))3236 }