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
↓ peach-android-7e62540b.tar.gzatlas at this save
files changed4 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 −9app/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) {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 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)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 }