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
↓ peach-android-270e5244.tar.gzatlas at this save
files changed2 files
app/src/main/java/com/peachpasswords/ui/vault/ImportScreen.kt+22 −5app/src/main/java/com/peachpasswords/ui/vault/RuneResolve.kt+181 −0app/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)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+}