270e5244bf8f

port RestoreSymbols' chaos-snap-bloom sequence to the PeachScript import (new RuneResolve.kt, wired into ImportScreen's Working and Done states): glyphs drift while the backup decrypts, snap into the 8x6 grid over 400ms once it lands, then dissolve as the peach mark blooms over 900ms before the result count shows. Source read correction: the browser's symbols restore tab is PeachScript (the rune glyphs), NOT a recovery-words restore as ONBOARDING_ADDENDUM_2 guessed, so Android's existing PeachScript/CSV/Codex tabs already cover all three browser tabs and only this animation was missing. Caught a hang in live testing where SNAP never advanced because onFinished only fired for BLOOM; now every timed phase reports completion. Live-verified end to end on a real .peach restore into a fresh install: chaos frame, grid-snap frame, and settling on 6 entries imported.

time
2026-07-24 19:26 UTC · 3 months ago
machine
koh2
agent
claude-opus-5
session
peachscript rune resolve
parent
789289291437
2 files app/src/main/java/com/peachpasswords/ui/vault/ImportScreen.kt+22 −5app/src/main/java/com/peachpasswords/ui/vault/RuneResolve.kt+181 −0
app/src/main/java/com/peachpasswords/ui/vault/ImportScreen.kt+22 −5
@@ -347,6 +347,9 @@347347     val state by vm.state.collectAsState()348348     val vaultUnlocked by vm.vaultUnlocked.collectAsState()349349     var route by remember { mutableStateOf(ImportRoute.CHOOSE) }350+    // Latches once the rune resolve has played, so recomposition of a settled351+    // Done state doesn't replay it.352+    var resolveDone by remember(state is ImportUiState.Done) { mutableStateOf(false) }350353 351354     ShellBackground {352355         Column(@@ -397,10 +400,23 @@397400                             onConfirm = { mode, newPassword -> vm.confirmImport(s.preview, mode, newPassword) },398401                             onCancel = { vm.reset() },399402                         )400-                        is ImportUiState.Done -> DoneView(401-                            count = s.count,402-                            onDone = if (s.createdVault) onVaultCreated else onBack,403-                        )403+                        is ImportUiState.Done -> {404+                            // PeachScript lands through the rune resolve (browser405+                            // RestoreSymbols): snap the drifting glyphs into a grid,406+                            // bloom the mark, then show the count.407+                            if (route == ImportRoute.PEACHSCRIPT && !resolveDone) {408+                                var bloomed by remember { mutableStateOf(false) }409+                                RuneResolve(410+                                    phase = if (bloomed) RunePhase.BLOOM else RunePhase.SNAP,411+                                    onFinished = { if (bloomed) resolveDone = true else bloomed = true },412+                                )413+                            } else {414+                                DoneView(415+                                    count = s.count,416+                                    onDone = if (s.createdVault) onVaultCreated else onBack,417+                                )418+                            }419+                        }404420                         is ImportUiState.Failed -> {405421                             ErrorBanner(s.message)406422                             Spacer(Modifier.size(16.dp))@@ -413,7 +429,8 @@413429                                 )414430                             }415431                         }416-                        ImportUiState.Working -> WorkingView()432+                        ImportUiState.Working ->433+                            if (route == ImportRoute.PEACHSCRIPT) RuneResolve(RunePhase.CHAOS) else WorkingView()417434                         ImportUiState.Idle -> when (route) {418435                             ImportRoute.CHOOSE -> Unit // handled above, outside the scroll container419436                             ImportRoute.PEACHSCRIPT -> PeachScriptView(vm)

view file

app/src/main/java/com/peachpasswords/ui/vault/RuneResolve.kt+181 −0
@@ -0,0 +1,181 @@1+package com.peachpasswords.ui.vault2+3+import androidx.compose.foundation.Canvas4+import androidx.compose.foundation.layout.Box5+import androidx.compose.foundation.layout.fillMaxWidth6+import androidx.compose.foundation.layout.height7+import androidx.compose.runtime.Composable8+import androidx.compose.runtime.LaunchedEffect9+import androidx.compose.runtime.getValue10+import androidx.compose.runtime.mutableFloatStateOf11+import androidx.compose.runtime.remember12+import androidx.compose.runtime.setValue13+import androidx.compose.runtime.withFrameMillis14+import androidx.compose.ui.Alignment15+import androidx.compose.ui.Modifier16+import androidx.compose.ui.geometry.Offset17+import androidx.compose.ui.graphics.Color18+import androidx.compose.ui.graphics.drawscope.rotate19+import androidx.compose.ui.graphics.graphicsLayer20+import androidx.compose.ui.text.TextStyle21+import androidx.compose.ui.text.drawText22+import androidx.compose.ui.text.rememberTextMeasurer23+import androidx.compose.ui.unit.dp24+import androidx.compose.ui.unit.sp25+import com.peachpasswords.ui.components.PeachMark26+import com.peachpasswords.ui.theme.LocalPeachAccent27+import com.peachpasswords.ui.theme.LocalReduceMotion28+import com.peachpasswords.ui.theme.PeachType29+import kotlin.math.PI30+import kotlin.math.min31+import kotlin.math.sin32+import kotlin.random.Random33+34+/** Which leg of the restore animation is running (browser: RestoreSymbols' `animating`). */35+enum class RunePhase { CHAOS, SNAP, BLOOM }36+37+private const val RUNE_COUNT = 6038+private const val GRID_COLS = 839+private const val GRID_ROWS = 640+private const val SNAP_MS = 400f41+private const val BLOOM_MS = 900f42+43+private class Rune(44+    var x: Float, var y: Float,45+    val vx: Float, val vy: Float,46+    val glyph: String, val size: Float,47+    var angle: Float, val va: Float,48+)49+50+/**51+ * The PeachScript restore's chaos → snap → bloom sequence, ported from the52+ * browser's RestoreSymbols (popup/views/Lock.tsx): loose runes drift while the53+ * backup decrypts, snap into a grid once it lands, then dissolve as the peach54+ * mark blooms through. [phase] is driven by the import state; [onFinished]55+ * fires when BLOOM completes so the caller can reveal its result screen.56+ */57+@Composable58+fun RuneResolve(59+    phase: RunePhase,60+    modifier: Modifier = Modifier,61+    onFinished: () -> Unit = {},62+) {63+    val accent = LocalPeachAccent.current64+    val reduceMotion = LocalReduceMotion.current65+    val measurer = rememberTextMeasurer()66+67+    // Fixed seed: the texture should look the same every restore, like the68+    // browser's deterministic rune string.69+    val runes = remember {70+        val rng = Random(7)71+        List(RUNE_COUNT) {72+            Rune(73+                x = rng.nextFloat(), y = rng.nextFloat(),74+                vx = (rng.nextFloat() - 0.5f) * 3f, vy = (rng.nextFloat() - 0.5f) * 3f,75+                glyph = (0xE000 + rng.nextInt(40)).toChar().toString(),76+                size = 14f + rng.nextFloat() * 20f,77+                angle = rng.nextFloat() * 2f * PI.toFloat(), va = (rng.nextFloat() - 0.5f) * 0.1f,78+            )79+        }80+    }81+    // Normalised x/y are seeded once per size; kept in px after first layout.82+    var seeded by remember { mutableFloatStateOf(0f) }83+    var elapsed by remember(phase) { mutableFloatStateOf(0f) }84+    var clock by remember { mutableFloatStateOf(0f) }85+86+    LaunchedEffect(phase) {87+        if (reduceMotion) {88+            onFinished()89+            return@LaunchedEffect90+        }91+        var last = withFrameMillis { it }92+        while (true) {93+            val now = withFrameMillis { it }94+            val dt = (now - last).toFloat().coerceAtMost(64f)95+            last = now96+            clock += dt97+            elapsed += dt98+            val done = when (phase) {99+                RunePhase.CHAOS -> false100+                RunePhase.SNAP -> elapsed >= SNAP_MS101+                RunePhase.BLOOM -> elapsed >= BLOOM_MS102+            }103+            if (done) break104+        }105+        // CHAOS runs until the import state moves it on; the timed phases report106+        // completion so the caller can advance (SNAP → BLOOM → result).107+        onFinished()108+    }109+110+    // Peach mark blooms in over the second fifth of BLOOM, then fades back out.111+    val markT = if (phase == RunePhase.BLOOM) ((elapsed / BLOOM_MS) - 0.2f) / 0.5f else 0f112+    val markAlpha = when {113+        markT <= 0f -> 0f114+        markT < 0.5f -> markT * 2f115+        else -> (2f - markT * 2f).coerceAtLeast(0f)116+    }117+118+    Box(modifier.fillMaxWidth().height(180.dp), contentAlignment = Alignment.Center) {119+        Canvas(Modifier.fillMaxWidth().height(180.dp)) {120+            if (seeded == 0f) {121+                runes.forEach { it.x *= size.width; it.y *= size.height }122+                seeded = 1f123+            }124+            val progress = (elapsed / SNAP_MS).coerceIn(0f, 1f)125+            val ease = 1f - (1f - progress) * (1f - progress) * (1f - progress)126+            val cellW = size.width / GRID_COLS127+            val cellH = size.height / GRID_ROWS128+129+            runes.forEachIndexed { i, r ->130+                when (phase) {131+                    RunePhase.CHAOS -> {132+                        r.x = (r.x + r.vx + size.width) % size.width133+                        r.y = (r.y + r.vy + size.height) % size.height134+                        r.angle += r.va135+                        val a = 0.5f + 0.5f * sin(clock / 400f + r.x)136+                        drawGlyph(measurer, r.glyph, r.size, accent.copy(alpha = a), r.x, r.y, r.angle)137+                    }138+                    RunePhase.SNAP -> {139+                        if (i >= GRID_COLS * GRID_ROWS) return@forEachIndexed140+                        val tx = (i % GRID_COLS) * cellW + cellW / 2f141+                        val ty = (i / GRID_COLS) * cellH + cellH / 2f142+                        val cx = r.x + (tx - r.x) * ease143+                        val cy = r.y + (ty - r.y) * ease144+                        drawGlyph(measurer, r.glyph, 16f, accent.copy(alpha = 0.8f * ease), cx, cy, 0f)145+                        if (progress >= 1f) { r.x = tx; r.y = ty }146+                    }147+                    RunePhase.BLOOM -> {148+                        val a = 1f - min((elapsed / BLOOM_MS) / 0.4f, 1f)149+                        if (a > 0f && i < 48) {150+                            drawGlyph(measurer, r.glyph, 16f, accent.copy(alpha = a * 0.8f), r.x, r.y, 0f)151+                        }152+                    }153+                }154+            }155+        }156+        if (markAlpha > 0f) {157+            PeachMark(158+                size = 64.dp,159+                modifier = Modifier.graphicsLayer {160+                    val s = min(markT * 1.2f, 1f)161+                    scaleX = s; scaleY = s; alpha = markAlpha162+                },163+            )164+        }165+    }166+}167+168+private fun androidx.compose.ui.graphics.drawscope.DrawScope.drawGlyph(169+    measurer: androidx.compose.ui.text.TextMeasurer,170+    glyph: String,171+    sizeSp: Float,172+    color: Color,173+    x: Float,174+    y: Float,175+    angle: Float,176+) {177+    val laid = measurer.measure(glyph, TextStyle(fontFamily = PeachType.Script, fontSize = sizeSp.sp, color = color))178+    rotate(degrees = angle * 180f / PI.toFloat(), pivot = Offset(x, y)) {179+        drawText(laid, topLeft = Offset(x - laid.size.width / 2f, y - laid.size.height / 2f))180+    }181+}

view file