7e62540bd7b5

fix onboarding's 3-card intro against the real browser source (ONBOARDING_ADDENDUM.md), correcting a prior pass's guesses: (1) moved the warning from card 3 to card 2 where it actually belongs, card 3 has none in source; (2) added card 3's missing restore-from-backup choice row, wired to the existing Import screen (I already have Peach/adopt intentionally not shown yet, its real semantics aren't confirmed on the Android side per addendum §4/task #36); (3) fixed the rune background from a guessed two-layer 8.5%/13.5% parallax to the real single-layer 0.07 opacity; (4) fixed card transition timing to run translateX+alpha in parallel (was sequential, doubling the exit duration) with the real 120ms ease-in exit, 60ms hold, 200ms ease-out-expo enter, matching CARD_EXIT_MS/CARD_ENTER_GAP_MS exactly; (5) made the whole card tappable to advance, not just the next button; (6) fixed nav button labels/glyphs to the real '← back'/'next →'/'create vault' (was 'back'/'next'/'Get started', lowercase to match the app's own established convention verified against LockScreen's sibling button); (7) fixed a real state-loss bug found during live verification — going to restore-from-backup and back reset onboarding to card 1 because used plain remember instead of rememberSaveable, which doesn't survive the Compose Navigation back-stack entry recreation that a real pushed route (unlike the browser's in-place view swap) causes. Updated OnboardingTest.kt to match. Live-verified every fix on-device: rune background visibly quieter, card-2 warning/card-3-no-warning, whole-card tap-advance, restore-from-backup reaching Import and returning to the correct card, create vault reaching the real create-vault screen.

time
2026-07-24 18:40 UTC · 3 months ago
machine
koh2
agent
claude-sonnet-5
session
onboarding 3-card intro parity fix
parent
d8c36c86dde0
4 files app/src/main/java/com/peachpasswords/ui/PeachAppNavHost.kt+1 −0app/src/main/java/com/peachpasswords/ui/components/RuneGridBackground.kt+9 −16app/src/main/java/com/peachpasswords/ui/onboarding/OnboardingScreen.kt+96 −29app/src/test/java/com/peachpasswords/ui/onboarding/OnboardingTest.kt+11 −9
app/src/main/java/com/peachpasswords/ui/PeachAppNavHost.kt+1 −0
@@ -133,6 +133,7 @@133133                         popUpTo(PeachNav.Onboarding.route) { inclusive = true }134134                     }135135                 },136+                onRestore = { nav(PeachNav.Import) },136137             )137138         }138139         composable(PeachNav.Lock.route) {

view file

app/src/main/java/com/peachpasswords/ui/components/RuneGridBackground.kt+9 −16
@@ -29,34 +29,27 @@2929 /**3030  * Continuous PeachScript rune grid for onboarding only. This is deliberately3131  * separate from LockRuneString, which is a static lock-screen accent row.32+ *33+ * Single layer at exactly 0.07 opacity — the real value from the browser's34+ * `<RuneBackground opacity={0.07} />` (RuneBackground.tsx), not the ~8-15%35+ * range a prior pass guessed. The browser has no second/parallax layer;36+ * matching that means not adding one here either (ONBOARDING_ADDENDUM.md §2).3237  */3338 @Composable3439 fun RuneGridBackground(modifier: Modifier = Modifier) {3540     val reduceMotion = LocalReduceMotion.current36-    val baseColor = peachColors.T037-    val back = rememberRuneLayer(38-        color = baseColor.copy(alpha = 0.085f),41+    val layer = rememberRuneLayer(42+        color = peachColors.T0.copy(alpha = 0.07f),3943         fontSize = 24,4044         tileWidth = 54.dp,4145         rowHeight = 42.dp,4246         speedDpPerSecond = 8f,4347         seed = 19,4448     )45-    val front = rememberRuneLayer(46-        color = baseColor.copy(alpha = 0.135f),47-        fontSize = 29,48-        tileWidth = 62.dp,49-        rowHeight = 48.dp,50-        speedDpPerSecond = 13f,51-        seed = 73,52-    )49+    val offset = rememberRuneOffset(layer.patternWidthDp, layer.speedDpPerSecond, reduceMotion)5350 54-    val backOffset = rememberRuneOffset(back.patternWidthDp, back.speedDpPerSecond, reduceMotion)55-    val frontOffset = rememberRuneOffset(front.patternWidthDp, front.speedDpPerSecond, reduceMotion)56-5751     Canvas(modifier) {58-        drawRuneLayer(back, backOffset)59-        drawRuneLayer(front, frontOffset)52+        drawRuneLayer(layer, offset)6053     }6154 }6255 

view file

app/src/main/java/com/peachpasswords/ui/onboarding/OnboardingScreen.kt+96 −29
@@ -6,6 +6,7 @@66 import androidx.compose.foundation.background77 import androidx.compose.foundation.border88 import androidx.compose.foundation.clickable9+import androidx.compose.foundation.interaction.MutableInteractionSource910 import androidx.compose.foundation.layout.Arrangement1011 import androidx.compose.foundation.layout.Box1112 import androidx.compose.foundation.layout.Column@@ -27,6 +28,7 @@2728 import androidx.compose.runtime.getValue2829 import androidx.compose.runtime.mutableIntStateOf2930 import androidx.compose.runtime.remember31+import androidx.compose.runtime.saveable.rememberSaveable3032 import androidx.compose.runtime.setValue3133 import androidx.compose.ui.Alignment3234 import androidx.compose.ui.Modifier@@ -49,6 +51,9 @@4951 import com.peachpasswords.ui.theme.peachColors5052 import com.peachpasswords.ui.theme.PeachType5153 import com.peachpasswords.ui.theme.ShellBackground54+import kotlinx.coroutines.coroutineScope55+import kotlinx.coroutines.delay56+import kotlinx.coroutines.launch5257 5358 /**5459  * One onboarding card's copy. Drives [OnboardingScreen] so the wording is a@@ -62,7 +67,13 @@6267     val warning: String?,6368 )6469 65-/** The three onboarding cards, in order. */70+/**71+ * The three onboarding cards, in order. Ported verbatim from the browser's72+ * `BASE_CARDS` (popup/views/Onboarding.tsx) — the warning belongs to card 273+ * ("two keys.", the master-password/recovery-words card), NOT card 3, which74+ * carries no warning at all (ONBOARDING_ADDENDUM.md §1 — a prior pass had75+ * this on the wrong card).76+ */6677 val ONBOARDING_CARDS: List<OnboardingCardCopy> = listOf(6778     OnboardingCardCopy(6879         headline = "your passwords\nlive here.\nnowhere else.",@@ -78,7 +89,7 @@7889             "Your master password is yours alone. Peach never sees it.",7990             "If you forget it, your 12 recovery words can reset it from settings — save them somewhere safe.",8091         ),81-        warning = null,92+        warning = "Lose both and your vault cannot be opened.",8293     ),8394     OnboardingCardCopy(8495         headline = "everything\nyou need.",@@ -86,7 +97,7 @@8697             "Free includes passwords, cards, secure notes, two-factor codes, autofill, strong passwords, and breach checks.",8798             "Pro adds encrypted sync, Peach Codex paper restore, passkeys, advanced vault health, canary entries, and duress tools.",8899         ),89-        warning = "Lose both your password and your recovery words and your vault cannot be opened.",100+        warning = null,90101     ),91102 )92103 @@ -101,9 +112,17 @@101112 @Composable102113 fun OnboardingScreen(103114     onFinished: () -> Unit,115+    // Card 3's "restore from backup" choice (Onboarding.tsx's onRestore prop).116+    // "I already have Peach" (onAdopt) isn't wired yet — AdoptVaultView's real117+    // semantics aren't confirmed on the Android side, so it isn't shown rather118+    // than shipping a button that does the wrong thing or nothing at all.119+    onRestore: () -> Unit = {},104120 ) {105121     val context = LocalContext.current106-    var card by remember { mutableIntStateOf(0) }122+    // rememberSaveable, not plain remember — navigating to "restore from123+    // backup" (a real pushed route, unlike the browser's in-place view swap)124+    // and back must not reset progress to card 1.125+    var card by rememberSaveable { mutableIntStateOf(0) }107126     val cardCount = ONBOARDING_CARDS.size108127     val accent = LocalPeachAccent.current109128 @@ -115,6 +134,7 @@115134             .apply()116135         onFinished()117136     }137+    val advance: () -> Unit = { if (card < cardCount - 1) card++ else finish() }118138 119139     ShellBackground {120140         RuneGridBackground(modifier = Modifier.fillMaxSize())@@ -144,9 +164,14 @@144164             verticalArrangement = Arrangement.Center,145165             horizontalAlignment = Alignment.CenterHorizontally,146166         ) {147-            CardSwitcher(card) { active ->167+            CardSwitcher(card, onClick = advance) { active ->148168                 GlassCardPanel {149169                     CardContent(ONBOARDING_CARDS[active])170+                    // The restore/adopt choice row only appears on the final171+                    // card (Onboarding.tsx's `card() === CARDS.length - 1`).172+                    if (active == cardCount - 1) {173+                        ChoiceRow(onRestore = onRestore)174+                    }150175                 }151176             }152177 @@ -156,7 +181,7 @@156181                 card = card,157182                 cardCount = cardCount,158183                 onBack = { if (card > 0) card-- },159-                onNext = { if (card < cardCount - 1) card++ else finish() },184+                onNext = advance,160185             )161186         }162187     }@@ -164,17 +189,23 @@164189 165190 // ── Card switcher (enter/exit transition) ───────────────────────────────────166191 192+// CSS cubic-bezier(0.16, 1, 0.3, 1) — peach-card-in's easing (pronounced193+// ease-out-expo). ease-in (translate+fade exit, peach-card-out) is CSS's194+// standard cubic-bezier(0.42, 0, 1, 1).195+private val CardEnterEasing = androidx.compose.animation.core.CubicBezierEasing(0.16f, 1f, 0.3f, 1f)196+private val CardExitEasing = androidx.compose.animation.core.CubicBezierEasing(0.42f, 0f, 1f, 1f)197+167198 /**168- * Crossfade-style switcher that runs the spec's directional transition:169- *   exit  translateX(0 → -12.dp) + alpha(1 → 0) over 120ms ease-in170- *   enter translateX(12.dp → 0)  + alpha(0 → 1) over 200ms spring171- *172- * Implemented as a two-phase animation keyed on [target]: the old card slides173- * left and fades, then the new card slides in from the right.199+ * Runs Onboarding.tsx's exact directional transition: the outgoing card slides200+ * to translateX(-12dp) fading out over CARD_EXIT_MS=120ms (ease-in, both201+ * properties together — not sequential), holds invisible for202+ * CARD_ENTER_GAP_MS=60ms, then the new card content swaps in and slides from203+ * translateX(12dp) to 0 fading in over 200ms (ease-out-expo).174204  */175205 @Composable176206 private fun CardSwitcher(177207     target: Int,208+    onClick: () -> Unit,178209     content: @Composable (Int) -> Unit,179210 ) {180211     val density = LocalDensity.current@@ -184,25 +215,32 @@184215 185216     LaunchedEffect(target) {186217         if (target == shown) return@LaunchedEffect187-        // Exit current card.188218         val exitPx = with(density) { (-12).dp.toPx() }189-        translate.animateTo(exitPx, tween(120))190-        fade.animateTo(0f, tween(120))191-        // Swap, then enter from the right.219+        coroutineScope {220+            launch { translate.animateTo(exitPx, tween(120, easing = CardExitEasing)) }221+            launch { fade.animateTo(0f, tween(120, easing = CardExitEasing)) }222+        }223+        delay(60)192224         shown = target193225         translate.snapTo(with(density) { 12.dp.toPx() })194-        // Spring per spec; PeachMotion.Spring is the app's shared spring easing.195-        translate.animateTo(0f, tween(200, easing = com.peachpasswords.ui.theme.PeachMotion.Spring))226+        coroutineScope {227+            launch { translate.animateTo(0f, tween(200, easing = CardEnterEasing)) }228+            launch { fade.animateTo(1f, tween(200, easing = CardEnterEasing)) }229+        }196230     }197-    LaunchedEffect(shown) {198-        if (fade.value < 1f) fade.animateTo(1f, tween(200))199-    }200231 201232     Box(202233         modifier = Modifier203234             .fillMaxWidth()204235             .offset { IntOffset(translate.value.toInt(), 0) }205-            .alpha(fade.value),236+            .alpha(fade.value)237+            // The whole card is a single tap target that advances, matching238+            // Onboarding.tsx's card-level onClick (plus Enter/Space there).239+            .clickable(240+                interactionSource = remember { MutableInteractionSource() },241+                indication = null,242+                onClick = onClick,243+            ),206244     ) {207245         content(shown)208246     }@@ -278,14 +316,40 @@278316     }279317 }280318 319+/**320+ * Card 3's choice row (Onboarding.tsx `peach-onboarding-choice-row`). The321+ * browser also has an "I already have Peach" button here (onAdopt) — not322+ * shown yet, see [OnboardingScreen]'s onRestore doc.323+ */324+@Composable325+private fun ChoiceRow(onRestore: () -> Unit) {326+    Spacer(Modifier.height(14.dp))327+    Text(328+        "restore from backup",329+        color = peachColors.T0,330+        style = PeachType.labelMono.copy(fontSize = 11.sp, letterSpacing = 0.04.em),331+        modifier = Modifier332+            .fillMaxWidth()333+            .clip(RoundedCornerShape(8.dp))334+            .background(peachColors.T0.copy(alpha = 0.07f))335+            .border(1.dp, peachColors.T0.copy(alpha = 0.16f), RoundedCornerShape(8.dp))336+            .clickable(337+                interactionSource = remember { MutableInteractionSource() },338+                indication = null,339+                onClick = onRestore,340+            )341+            .padding(vertical = 8.dp, horizontal = 10.dp),342+    )343+}344+281345 // ── Nav row ─────────────────────────────────────────────────────────────────282346 283347 /** Back is hidden (space reserved) on the first card; visible from card 2 on. */284348 internal fun onboardingBackVisible(card: Int): Boolean = card > 0285349 286-/** The forward button reads "Get started" on the final card, else "next". */350+/** The forward button reads "create vault" on the final card, else "next →". */287351 internal fun onboardingNextLabel(card: Int, count: Int): String =288-    if (card == count - 1) "Get started" else "next"352+    if (card == count - 1) "create vault" else "next →"289353 290354 @Composable291355 private fun NavRow(@@ -299,11 +363,13 @@299363         modifier = Modifier.fillMaxWidth(),300364         verticalAlignment = Alignment.CenterVertically,301365     ) {302-        // Back — hidden (but space reserved) on card 1.303-        Box(modifier = Modifier.width(64.dp), contentAlignment = Alignment.CenterStart) {366+        // Back — hidden (but space reserved) on card 1. Same min width as the367+        // next box below so the dots land roughly centered either way; not a368+        // fixed width, since "create vault" is longer than "next →"/"← back".369+        Box(modifier = Modifier.widthIn(min = 72.dp), contentAlignment = Alignment.CenterStart) {304370             if (onboardingBackVisible(card)) {305371                 Text(306-                    "back",372+                    "← back",307373                     color = peachColors.T2,308374                     style = PeachType.labelMono.copy(fontSize = 11.sp, letterSpacing = 0.04.em),309375                     modifier = Modifier@@ -332,12 +398,13 @@332398             }333399         }334400 335-        // Next / Get started.336-        Box(modifier = Modifier.width(96.dp), contentAlignment = Alignment.CenterEnd) {401+        // Next / create vault.402+        Box(modifier = Modifier.widthIn(min = 72.dp), contentAlignment = Alignment.CenterEnd) {337403             Text(338404                 onboardingNextLabel(card, cardCount),339405                 color = peachColors.T1,340406                 style = PeachType.labelMono.copy(fontSize = 11.sp, letterSpacing = 0.04.em),407+                maxLines = 1,341408                 modifier = Modifier342409                     .clip(RoundedCornerShape(6.dp))343410                     .clickable(onClick = onNext)

view file

app/src/test/java/com/peachpasswords/ui/onboarding/OnboardingTest.kt+11 −9
@@ -54,12 +54,14 @@5454         assertTrue(c.body[1].contains("Pro adds"))5555     }5656 57-    @Test fun onboarding_card3_has_warning_row() {58-        assertNotNull("card 3 carries the warning", ONBOARDING_CARDS[2].warning)59-        assertTrue(ONBOARDING_CARDS[2].warning!!.contains("cannot be opened"))60-        // The first two cards do not show a warning row.57+    // Card 2 ("two keys.") carries the warning, not card 3 — verified against58+    // the browser's BASE_CARDS (Onboarding.tsx); a prior pass had this on the59+    // wrong card (ONBOARDING_ADDENDUM.md §1).60+    @Test fun onboarding_card2_has_warning_row() {61+        assertNotNull("card 2 carries the warning", ONBOARDING_CARDS[1].warning)62+        assertTrue(ONBOARDING_CARDS[1].warning!!.contains("cannot be opened"))6163         assertNull(ONBOARDING_CARDS[0].warning)62-        assertNull(ONBOARDING_CARDS[1].warning)64+        assertNull(ONBOARDING_CARDS[2].warning)6365     }6466 6567     // ── Nav row ───────────────────────────────────────────────────────────────@@ -75,12 +77,12 @@7577 7678     @Test fun forward_label_is_next_until_last_card() {7779         val count = ONBOARDING_CARDS.size78-        assertEquals("next", onboardingNextLabel(0, count))79-        assertEquals("next", onboardingNextLabel(1, count))80+        assertEquals("next →", onboardingNextLabel(0, count))81+        assertEquals("next →", onboardingNextLabel(1, count))8082     }8183 82-    @Test fun forward_label_is_get_started_on_last_card() {84+    @Test fun forward_label_is_create_vault_on_last_card() {8385         val count = ONBOARDING_CARDS.size84-        assertEquals("Get started", onboardingNextLabel(count - 1, count))86+        assertEquals("create vault", onboardingNextLabel(count - 1, count))8587     }8688 }

view file