c072a6b4976e
four onboarding fixes from live review: (1) rune background was frozen solid — it was driven by animateFloat/infiniteRepeatable, which the system animator_duration_scale can zero out, so the ambient field stopped dead wherever that setting is turned down; moved it to the frame clock like the browser's rAF loop and gave each row its own drift speed (8/11/6, RuneBackground.tsx SPEEDS) so the field shears horizontally instead of sliding as one block, verified moving with animator scale still at 0. (2) onboarding cards were vertically centred, leaving dead space above and below on a tall phone — card now sits high with the nav pinned to the bottom, and the card's type/padding scaled up (body 13 to 16sp, vertical padding 28 to 44dp) since it was sized for a 520px browser popup and read as a small box adrift on a 570dp screen; tried the browser's literal flex-1 full-height card first, it looked wrong, reverted to content height. (3) restore chooser was missing Codex — added a third From Peach Codex option handing off to the existing CodexRestoreScreen, which already works pre-vault, and a completed Codex restore now lands in the vault instead of popping back to the chooser underneath it. (4) onboarding's wordmark had lost its peach mark; extracted the mark-plus-text lockup into PeachWordmark and used it on both Lock and Onboarding, which is where the two copies had drifted apart.
- time
- 2026-07-24 19:57 UTC · 3 months ago
- machine
- koh2
- agent
- claude-opus-5
- session
- onboarding polish
- parent
- 270e5244bf8f
↓ peach-android-c072a6b4.tar.gzatlas at this save
files changed6 files
app/src/main/java/com/peachpasswords/ui/PeachAppNavHost.kt+11 −1app/src/main/java/com/peachpasswords/ui/components/PeachMark.kt+21 −0app/src/main/java/com/peachpasswords/ui/components/RuneGridBackground.kt+28 −32app/src/main/java/com/peachpasswords/ui/lock/LockScreen.kt+3 −11app/src/main/java/com/peachpasswords/ui/onboarding/OnboardingScreen.kt+16 −14app/src/main/java/com/peachpasswords/ui/vault/ImportScreen.kt+12 −0app/src/main/java/com/peachpasswords/ui/PeachAppNavHost.kt+11 −1
@@ -219,7 +219,16 @@219219 onUpgradePro = { nav(PeachNav.Settings) },220220 )221221 }222- composable(PeachNav.CodexRestore.route) { CodexRestoreScreen(onBack = back, onRestored = back) }222+ composable(PeachNav.CodexRestore.route) {223+ CodexRestoreScreen(224+ onBack = back,225+ onRestored = {226+ navController.navigate(PeachNav.VaultList.route) {227+ popUpTo(0) { inclusive = true }228+ }229+ },230+ )231+ }223232 composable(PeachNav.ChirpPair.route) { ChirpPairScreen(onBack = back) }224233 composable(PeachNav.Health.route) {225234 VaultHealthScreen(onBack = back, onEditEntry = { nav(PeachNav.EntryDetail(it)) })@@ -228,6 +237,7 @@228237 composable(PeachNav.Import.route) {229238 ImportScreen(230239 onBack = back,240+ onCodex = { nav(PeachNav.CodexRestore) },231241 onVaultCreated = {232242 navController.navigate(PeachNav.RecoverySetup.route) {233243 popUpTo(0) { inclusive = true }app/src/main/java/com/peachpasswords/ui/components/PeachMark.kt+21 −0
@@ -2,6 +2,7 @@22 33 import androidx.compose.foundation.Canvas44 import androidx.compose.foundation.layout.size5+import androidx.compose.foundation.layout.padding56 import androidx.compose.runtime.Composable67 import androidx.compose.runtime.remember78 import androidx.compose.ui.Modifier@@ -49,5 +50,25 @@4950 drawPath(leaf, Color(0xFF92AC65), alpha = alpha)5051 drawPath(vein, Color(0xFF5E7A42), alpha = alpha, style = Stroke(width = 2.6f))5152 }53+ }54+}55+56+/**57+ * Mark + "peach" lockup, top-left on the pre-vault screens. Extracted because58+ * Lock and Onboarding each hand-rolled it and drifted apart — onboarding lost59+ * the mark entirely.60+ */61+@Composable62+fun PeachWordmark(modifier: Modifier = Modifier) {63+ androidx.compose.foundation.layout.Row(64+ modifier = modifier,65+ verticalAlignment = androidx.compose.ui.Alignment.CenterVertically,66+ ) {67+ PeachMark(modifier = Modifier.padding(end = com.peachpasswords.ui.theme.PeachSpacing.xs))68+ androidx.compose.material3.Text(69+ text = "peach",70+ color = com.peachpasswords.ui.theme.LocalPeachAccent.current,71+ style = com.peachpasswords.ui.theme.PeachType.wordmark,72+ )5273 }5374 }app/src/main/java/com/peachpasswords/ui/components/RuneGridBackground.kt+28 −32
@@ -1,13 +1,10 @@11 package com.peachpasswords.ui.components22 3-import androidx.compose.animation.core.LinearEasing4-import androidx.compose.animation.core.animateFloat5-import androidx.compose.animation.core.infiniteRepeatable6-import androidx.compose.animation.core.rememberInfiniteTransition7-import androidx.compose.animation.core.tween83 import androidx.compose.foundation.Canvas94 import androidx.compose.runtime.Composable10-import androidx.compose.runtime.getValue5+import androidx.compose.runtime.LaunchedEffect6+import androidx.compose.runtime.mutableFloatStateOf7+import androidx.compose.runtime.withFrameMillis118 import androidx.compose.runtime.remember129 import androidx.compose.ui.Modifier1310 import androidx.compose.ui.geometry.Offset@@ -43,13 +40,27 @@4340 fontSize = 24,4441 tileWidth = 54.dp,4542 rowHeight = 42.dp,46- speedDpPerSecond = 8f,4743 seed = 19,4844 )49- val offset = rememberRuneOffset(layer.patternWidthDp, layer.speedDpPerSecond, reduceMotion)5045 46+ // Driven off the frame clock, not animateFloat/infiniteRepeatable: those47+ // are scaled by the system animator duration setting, so an ambient48+ // background silently froze solid wherever that's turned down to 0 (the49+ // browser's requestAnimationFrame loop has no such dependency). Read in50+ // the draw scope so a frame is a redraw, not a recomposition.51+ val elapsedSeconds = remember { mutableFloatStateOf(0f) }52+ LaunchedEffect(reduceMotion) {53+ if (reduceMotion) return@LaunchedEffect54+ var last = withFrameMillis { it }55+ while (true) {56+ val now = withFrameMillis { it }57+ elapsedSeconds.floatValue += (now - last).coerceAtMost(64L) / 1000f58+ last = now59+ }60+ }61+5162 Canvas(modifier) {52- drawRuneLayer(layer, offset)63+ drawRuneLayer(layer, elapsedSeconds.floatValue)5364 }5465 }5566 @@ -59,7 +70,6 @@5970 fontSize: Int,6071 tileWidth: Dp,6172 rowHeight: Dp,62- speedDpPerSecond: Float,6373 seed: Int,6474 ): RuneLayer {6575 val measurer = rememberTextMeasurer()@@ -76,52 +86,38 @@7686 tileWidth = tileWidth,7787 rowHeight = rowHeight,7888 patternWidthDp = tileWidth * PATTERN_COLUMNS,79- speedDpPerSecond = speedDpPerSecond,8089 seed = seed,8190 )8291 }8392 84-@Composable85-private fun rememberRuneOffset(86- patternWidth: Dp,87- speedDpPerSecond: Float,88- reduceMotion: Boolean,89-): Float {90- if (reduceMotion) return 0f91- val durationMillis = ((patternWidth.value / speedDpPerSecond) * 1000f).toInt().coerceAtLeast(1)92- val transition = rememberInfiniteTransition(label = "rune_grid")93- val offset by transition.animateFloat(94- initialValue = 0f,95- targetValue = patternWidth.value,96- animationSpec = infiniteRepeatable(tween(durationMillis, easing = LinearEasing)),97- label = "rune_grid_offset",98- )99- return offset100-}101-10293 private data class RuneLayer(10394 val glyphs: List<androidx.compose.ui.text.TextLayoutResult>,10495 val tileWidth: Dp,10596 val rowHeight: Dp,10697 val patternWidthDp: Dp,107- val speedDpPerSecond: Float,10898 val seed: Int,10999 )110100 101+/** Browser parity (RuneBackground.tsx SPEEDS): three drift rates, cycled by row. */102+private val ROW_SPEEDS_DP = floatArrayOf(8f, 11f, 6f)103+111104 private fun androidx.compose.ui.graphics.drawscope.DrawScope.drawRuneLayer(112105 layer: RuneLayer,113- offsetDp: Float,106+ elapsedSeconds: Float,114107 ) {115108 val tileWidth = layer.tileWidth.toPx()116109 val rowHeight = layer.rowHeight.toPx()117110 val patternWidth = layer.patternWidthDp.toPx()118- val offset = (offsetDp / layer.patternWidthDp.value) * patternWidth119111 val columns = ceil(size.width / tileWidth).toInt() + PATTERN_COLUMNS * 2120112 val rows = ceil(size.height / rowHeight).toInt() + 2121113 val startColumn = -PATTERN_COLUMNS122114 123115 for (row in -1 until rows) {124116 val y = row * rowHeight117+ // Each row drifts at its own speed, so the field shears instead of118+ // sliding as one rigid block.119+ val speedPx = ROW_SPEEDS_DP[positiveMod(row, ROW_SPEEDS_DP.size)] * density120+ val offset = (elapsedSeconds * speedPx) % patternWidth125121 for (i in 0..columns) {126122 val column = startColumn + i127123 val x = column * tileWidth - offsetapp/src/main/java/com/peachpasswords/ui/lock/LockScreen.kt+3 −11
@@ -57,6 +57,7 @@5757 import com.peachpasswords.ui.PeachNav5858 import com.peachpasswords.ui.components.LockRuneString5959 import com.peachpasswords.ui.components.PeachMark60+import com.peachpasswords.ui.components.PeachWordmark6061 import com.peachpasswords.ui.theme.LocalPeachAccent6162 import com.peachpasswords.ui.theme.PeachSpacing6263 import com.peachpasswords.ui.theme.peachColors@@ -120,18 +121,9 @@120121 }121122 122123 ShellBackground {123- // Wordmark, top-left.124- Row(124+ PeachWordmark(125125 modifier = Modifier.align(Alignment.TopStart).systemBarsPadding().padding(start = 20.dp, top = 8.dp),126- verticalAlignment = Alignment.CenterVertically,127- ) {128- PeachMark(modifier = Modifier.padding(end = PeachSpacing.xs))129- Text(130- text = "peach",131- color = accent,132- style = PeachType.wordmark,133- )134- }126+ )135127 136128 Column(137129 modifier = Modifierapp/src/main/java/com/peachpasswords/ui/onboarding/OnboardingScreen.kt+16 −14
@@ -46,6 +46,7 @@4646 import androidx.compose.ui.unit.em4747 import androidx.compose.ui.unit.sp4848 import androidx.compose.material3.Text49+import com.peachpasswords.ui.components.PeachWordmark4950 import com.peachpasswords.ui.components.RuneGridBackground5051 import com.peachpasswords.ui.theme.LocalPeachAccent5152 import com.peachpasswords.ui.theme.peachColors@@ -139,11 +140,7 @@139140 ShellBackground {140141 RuneGridBackground(modifier = Modifier.fillMaxSize())141142 142- // Wordmark, top-left — matches LockScreen.143- Text(144- text = "peach",145- color = accent,146- style = PeachType.wordmark,143+ PeachWordmark(147144 modifier = Modifier148145 .align(Alignment.TopStart)149146 .systemBarsPadding()@@ -161,9 +158,12 @@161158 .fillMaxWidth()162159 .systemBarsPadding()163160 .padding(horizontal = 28.dp),164- verticalArrangement = Arrangement.Center,165161 horizontalAlignment = Alignment.CenterHorizontally,166162 ) {163+ // Card sits high, nav pinned to the bottom edge — the browser's164+ // flex column (header / card / nav), not a centred block that165+ // leaves dead space above and below on a tall phone.166+ Spacer(Modifier.height(40.dp))167167 CardSwitcher(card, onClick = advance) { active ->168168 GlassCardPanel {169169 CardContent(ONBOARDING_CARDS[active])@@ -175,7 +175,7 @@175175 }176176 }177177 178- Spacer(Modifier.height(28.dp))178+ Spacer(Modifier.weight(1f))179179 180180 NavRow(181181 card = card,@@ -183,6 +183,7 @@183183 onBack = { if (card > 0) card-- },184184 onNext = advance,185185 )186+ Spacer(Modifier.height(12.dp))186187 }187188 }188189 }@@ -206,6 +207,7 @@206207 private fun CardSwitcher(207208 target: Int,208209 onClick: () -> Unit,210+ modifier: Modifier = Modifier,209211 content: @Composable (Int) -> Unit,210212 ) {211213 val density = LocalDensity.current@@ -230,7 +232,7 @@230232 }231233 232234 Box(233- modifier = Modifier235+ modifier = modifier234236 .fillMaxWidth()235237 .offset { IntOffset(translate.value.toInt(), 0) }236238 .alpha(fade.value)@@ -256,7 +258,7 @@256258 .clip(RoundedCornerShape(18.dp))257259 .background(peachColors.SurfaceElevated)258260 .border(1.dp, peachColors.T3, RoundedCornerShape(18.dp))259- .padding(horizontal = 24.dp, vertical = 28.dp),261+ .padding(horizontal = 26.dp, vertical = 44.dp),260262 content = content,261263 )262264 }@@ -266,20 +268,20 @@266268 private fun headline() = TextStyle(267269 fontFamily = PeachType.Display,268270 fontStyle = FontStyle.Italic,269- fontSize = 40.sp,270- lineHeight = (40 * 0.98).sp,271+ fontSize = 44.sp,272+ lineHeight = (44 * 1.0).sp,271273 )272274 273275 @Composable274276 private fun CardContent(copy: OnboardingCardCopy) {275277 Text(copy.headline, color = peachColors.T0, style = headline())276- Spacer(Modifier.height(18.dp))278+ Spacer(Modifier.height(24.dp))277279 copy.body.forEachIndexed { i, para ->278- if (i > 0) Spacer(Modifier.height(12.dp))280+ if (i > 0) Spacer(Modifier.height(18.dp))279281 Text(280282 para,281283 color = peachColors.T1,282- style = PeachType.body.copy(fontSize = 13.sp, lineHeight = (13 * 1.5).sp),284+ style = PeachType.body.copy(fontSize = 16.sp, lineHeight = (16 * 1.55).sp),283285 )284286 }285287 copy.warning?.let { WarningRow(it) }app/src/main/java/com/peachpasswords/ui/vault/ImportScreen.kt+12 −0
@@ -337,6 +337,9 @@337337 @Composable338338 fun ImportScreen(339339 onBack: () -> Unit,340+ // Codex is the third restore format (browser RestoreTab: symbols|codex|csv).341+ // It has its own screen, so this hands off rather than rendering inline.342+ onCodex: () -> Unit = {},340343 // Only reached when confirmImport had to create a new vault first (fresh341344 // install or "forgot password" recovery) — routes to recovery-word setup,342345 // same as LockScreen.create, instead of popping back to a stale Lock/Import@@ -388,6 +391,7 @@388391 ChooseView(389392 onPeachScript = { route = ImportRoute.PEACHSCRIPT },390393 onFile = { route = ImportRoute.FILE },394+ onCodex = onCodex,391395 modifier = Modifier.weight(1f).fillMaxWidth(),392396 composeRemainingSpace = true,393397 )@@ -426,6 +430,7 @@426430 ImportRoute.CHOOSE -> ChooseView(427431 onPeachScript = { route = ImportRoute.PEACHSCRIPT },428432 onFile = { route = ImportRoute.FILE },433+ onCodex = onCodex,429434 )430435 }431436 }@@ -453,6 +458,7 @@453458 private fun ChooseView(454459 onPeachScript: () -> Unit,455460 onFile: () -> Unit,461+ onCodex: () -> Unit,456462 modifier: Modifier = Modifier,457463 // Only true from the non-scrolling call site, where `modifier` carries a458464 // Column-scope weight and the remaining height is real and boundable.@@ -471,6 +477,12 @@471477 title = "From file",472478 subtitle = "Bitwarden, Proton Pass, 1Password, Chrome, Safari, Edge CSV",473479 onClick = onFile,480+ )481+ Spacer(Modifier.size(12.dp))482+ OptionCard(483+ title = "From Peach Codex",484+ subtitle = "Scan or open a Codex PDF you printed",485+ onClick = onCodex,474486 )475487 if (composeRemainingSpace) {476488 Box(Modifier.weight(1f).fillMaxWidth(), contentAlignment = Alignment.Center) {