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
4 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 −2
app/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). Computed

view file

app/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) {

view file

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

view file

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     }

view file