639970cb1815

replace hand-drawn pairing birds with the extension's finch and swallow, render SAS inline instead of over them

time
2026-07-24 22:34 UTC · 3 months ago
machine
koh2
agent
claude-opus-5
session
pairing birds
parent
8dffe25307ef
3 files app/src/main/java/com/peachpasswords/ui/components/BirdAnimation.kt+54 −90app/src/main/java/com/peachpasswords/ui/components/PairingBirds.kt+319 −0app/src/main/java/com/peachpasswords/ui/pairing/ChirpPairScreen.kt+41 −37
app/src/main/java/com/peachpasswords/ui/components/BirdAnimation.kt+54 −90
@@ -1,7 +1,6 @@11 package com.peachpasswords.ui.components22 33 import androidx.compose.animation.core.FastOutSlowInEasing4-import androidx.compose.animation.core.LinearEasing54 import androidx.compose.animation.core.RepeatMode65 import androidx.compose.animation.core.animateFloat76 import androidx.compose.animation.core.animateFloatAsState@@ -10,32 +9,33 @@109 import androidx.compose.animation.core.spring1110 import androidx.compose.animation.core.tween1211 import androidx.compose.foundation.Canvas12+import androidx.compose.foundation.layout.Box13+import androidx.compose.foundation.layout.BoxWithConstraints14+import androidx.compose.foundation.layout.fillMaxSize15+import androidx.compose.foundation.layout.offset16+import androidx.compose.foundation.layout.size1317 import androidx.compose.runtime.Composable1418 import androidx.compose.runtime.getValue19+import androidx.compose.ui.Alignment1520 import androidx.compose.ui.Modifier1621 import androidx.compose.ui.geometry.Offset17-import androidx.compose.ui.geometry.Size1822 import androidx.compose.ui.graphics.Color19-import androidx.compose.ui.graphics.Path2023 import androidx.compose.ui.graphics.drawscope.DrawScope2124 import androidx.compose.ui.graphics.drawscope.Stroke25+import androidx.compose.ui.unit.dp2226 import com.peachpasswords.ui.theme.LocalPeachAccent2327 import kotlin.math.sin2428 2529 /** The three acts of the pairing animation, bound to the audio protocol. */2630 enum class PairingAnimState { WAITING, LISTENING, CONNECTED }2731 28-private val BodyHi = Color(0xFFFFB082)29-private val BodyLo = Color(0xFFE5805C)30-private val Beak = Color(0xFFF2B45A)31-private val Eye = Color(0xFF1F0A10)32-3332 /**34- * Two finches that drift toward each other as pairing progresses. Act 135- * (waiting) holds them apart with a slow breath-bob; Act 2 (listening) pulses36- * inaudible sound arcs between them; Act 3 (connected) resolves the arcs into a37- * glowing accent link and a synchronised flutter. Stylised from the browser38- * ChirpPairingFlow birds — same coral/apricot palette, simpler geometry.33+ * The two pairing birds drifting toward each other as pairing progresses. Act 134+ * (waiting) holds them apart; act 2 (listening) pulses sound arcs between them35+ * while the finch calls and the swallow listens; act 3 (connected) resolves the36+ * arcs into a glowing accent link and both birds go happy.37+ *38+ * The birds themselves are the extension's own artwork — see [PairingBird].3939  */4040 @Composable4141 fun BirdAnimation(@@ -48,7 +48,7 @@4848         targetValue = when (state) {4949             PairingAnimState.WAITING -> 0.16f5050             PairingAnimState.LISTENING -> 0.22f51-            PairingAnimState.CONNECTED -> 0.39f51+            PairingAnimState.CONNECTED -> 0.34f5252         },5353         animationSpec = spring(dampingRatio = 0.6f, stiffness = 200f),5454         label = "leftX",@@ -57,18 +57,13 @@5757         targetValue = when (state) {5858             PairingAnimState.WAITING -> 0.84f5959             PairingAnimState.LISTENING -> 0.78f60-            PairingAnimState.CONNECTED -> 0.61f60+            PairingAnimState.CONNECTED -> 0.66f6161         },6262         animationSpec = spring(dampingRatio = 0.6f, stiffness = 200f),6363         label = "rightX",6464     )6565 6666     val transition = rememberInfiniteTransition(label = "bird")67-    val bob by transition.animateFloat(68-        initialValue = 0f, targetValue = (2f * Math.PI).toFloat(),69-        animationSpec = infiniteRepeatable(tween(2200, easing = LinearEasing)),70-        label = "bob",71-    )7267     // Sound-arc sweep (LISTENING) and connection-glow pulse (CONNECTED).7368     val pulse by transition.animateFloat(7469         initialValue = 0f, targetValue = 1f,@@ -79,22 +74,46 @@7974         label = "pulse",8075     )8176 82-    Canvas(modifier = modifier) {83-        val h = size.height84-        val cy = h * 0.52f85-        val leftCenter = Offset(size.width * leftX, cy + sin(bob) * 4f)86-        val rightCenter = Offset(size.width * rightX, cy + sin(bob + Math.PI.toFloat()) * 4f)87-        val birdScale = (size.minDimension / 220f).coerceIn(0.5f, 1.4f)77+    // The extension holds the finch chirping and the swallow attentive on every78+    // pairing screen ("the bird stage — always animating"); only the distance79+    // between them changes. Happy on pairing is Android's own addition.80+    val finchPose = if (state == PairingAnimState.CONNECTED) BirdPose.HAPPY else BirdPose.CHIRPING81+    val swallowPose = if (state == PairingAnimState.CONNECTED) BirdPose.HAPPY else BirdPose.ATTENTIVE8882 89-        if (state == PairingAnimState.LISTENING) {90-            drawSoundArcs(leftCenter, rightCenter, pulse, accent)83+    BoxWithConstraints(modifier) {84+        val birdSize = (maxHeight * 0.92f).coerceAtMost(maxWidth * 0.38f)85+86+        Canvas(Modifier.fillMaxSize()) {87+            val cy = size.height * 0.48f88+            val left = Offset(size.width * leftX, cy)89+            val right = Offset(size.width * rightX, cy)90+            when (state) {91+                PairingAnimState.LISTENING -> drawSoundArcs(left, right, pulse, accent)92+                PairingAnimState.CONNECTED -> drawConnection(left, right, pulse, accent)93+                PairingAnimState.WAITING -> Unit94+            }9195         }92-        if (state == PairingAnimState.CONNECTED) {93-            drawConnection(leftCenter, rightCenter, pulse, accent)94-        }9596 96-        drawBird(leftCenter, birdScale, facingRight = true, listening = state != PairingAnimState.WAITING)97-        drawBird(rightCenter, birdScale, facingRight = false, listening = state != PairingAnimState.WAITING)97+        // leftX/rightX are centre fractions, but a bird parked at 0.16 of a98+        // narrow stage hangs its tail off the edge — Canvas clips, so keep the99+        // whole box on screen rather than losing tail feathers.100+        val span = maxWidth - birdSize101+        Box(102+            Modifier103+                .align(Alignment.CenterStart)104+                .offset(x = (maxWidth * leftX - birdSize / 2f).coerceIn(0.dp, span))105+                .size(birdSize),106+        ) {107+            PairingBird(BirdKind.FINCH, finchPose, Modifier.fillMaxSize())108+        }109+        Box(110+            Modifier111+                .align(Alignment.CenterStart)112+                .offset(x = (maxWidth * rightX - birdSize / 2f).coerceIn(0.dp, span))113+                .size(birdSize),114+        ) {115+            PairingBird(BirdKind.SWALLOW, swallowPose, Modifier.fillMaxSize())116+        }98117     }99118 }100119 @@ -115,64 +134,9 @@115134 }116135 117136 private fun DrawScope.drawConnection(left: Offset, right: Offset, pulse: Float, accent: Color) {118-    val beakL = Offset(left.x + 26f, left.y - 4f)119-    val beakR = Offset(right.x - 26f, right.y - 4f)120-    val mid = Offset((beakL.x + beakR.x) / 2f, (beakL.y + beakR.y) / 2f)121-    drawLine(accent.copy(alpha = 0.5f), beakL, beakR, strokeWidth = 1.4f)137+    val mid = Offset((left.x + right.x) / 2f, (left.y + right.y) / 2f)138+    drawLine(accent.copy(alpha = 0.5f), left, right, strokeWidth = 1.4f)122139     val glow = 3f + sin(pulse * 2f * Math.PI.toFloat()) * 2f + 4f123140     drawCircle(accent.copy(alpha = 0.22f), radius = glow * 2.4f, center = mid)124141     drawCircle(accent, radius = glow, center = mid)125-}126-127-/** A compact finch: body blob, wing, head, beak, eye. */128-private fun DrawScope.drawBird(129-    center: Offset,130-    scale: Float,131-    facingRight: Boolean,132-    listening: Boolean,133-) {134-    val dir = if (facingRight) 1f else -1f135-    fun p(dx: Float, dy: Float) = Offset(center.x + dx * scale * dir, center.y + dy * scale)136-137-    // Shadow138-    drawOval(139-        color = Color(0x33000000),140-        topLeft = Offset(center.x - 30f * scale, center.y + 42f * scale),141-        size = Size(60f * scale, 7f * scale),142-    )143-144-    // Body145-    val body = Path().apply {146-        moveTo(p(-30f, 6f).x, p(-30f, 6f).y)147-        cubicTo(p(-36f, -22f).x, p(-36f, -22f).y, p(-8f, -44f).x, p(-8f, -44f).y, p(24f, -40f).x, p(24f, -40f).y)148-        cubicTo(p(52f, -36f).x, p(52f, -36f).y, p(60f, -10f).x, p(60f, -10f).y, p(50f, 18f).x, p(50f, 18f).y)149-        cubicTo(p(40f, 40f).x, p(40f, 40f).y, p(-2f, 44f).x, p(-2f, 44f).y, p(-26f, 32f).x, p(-26f, 32f).y)150-        cubicTo(p(-34f, 24f).x, p(-34f, 24f).y, p(-32f, 14f).x, p(-32f, 14f).y, p(-30f, 6f).x, p(-30f, 6f).y)151-        close()152-    }153-    drawPath(body, BodyHi)154-155-    // Wing (lifts when listening)156-    val wingLift = if (listening) -6f else 0f157-    val wing = Path().apply {158-        moveTo(p(-6f, -14f + wingLift).x, p(-6f, -14f + wingLift).y)159-        cubicTo(p(14f, -22f + wingLift).x, p(14f, -22f + wingLift).y, p(40f, -10f).x, p(40f, -10f).y, p(34f, 6f).x, p(34f, 6f).y)160-        cubicTo(p(20f, 14f).x, p(20f, 14f).y, p(-2f, 8f).x, p(-2f, 8f).y, p(-10f, -2f).x, p(-10f, -2f).y)161-        close()162-    }163-    drawPath(wing, BodyLo)164-165-    // Head166-    drawCircle(BodyHi, radius = 22f * scale, center = p(34f, -30f))167-    // Beak168-    val beak = Path().apply {169-        moveTo(p(52f, -30f).x, p(52f, -30f).y)170-        lineTo(p(72f, -27f).x, p(72f, -27f).y)171-        lineTo(p(53f, -22f).x, p(53f, -22f).y)172-        close()173-    }174-    drawPath(beak, Beak)175-    // Eye176-    drawCircle(Eye, radius = 4.2f * scale, center = p(42f, -33f))177-    drawCircle(Color(0xFFFFF0E1), radius = 1.4f * scale, center = p(43.5f, -34.5f))178142 }

view file

app/src/main/java/com/peachpasswords/ui/components/PairingBirds.kt+319 −0
@@ -0,0 +1,319 @@1+package com.peachpasswords.ui.components2+3+import androidx.compose.foundation.Canvas4+import androidx.compose.runtime.Composable5+import androidx.compose.runtime.LaunchedEffect6+import androidx.compose.runtime.State7+import androidx.compose.runtime.getValue8+import androidx.compose.runtime.mutableLongStateOf9+import androidx.compose.runtime.remember10+import androidx.compose.runtime.withFrameMillis11+import androidx.compose.ui.Modifier12+import androidx.compose.ui.geometry.Offset13+import androidx.compose.ui.geometry.Rect14+import androidx.compose.ui.geometry.Size15+import androidx.compose.ui.graphics.Brush16+import androidx.compose.ui.graphics.Color17+import androidx.compose.ui.graphics.Path18+import androidx.compose.ui.graphics.drawscope.DrawScope19+import androidx.compose.ui.graphics.drawscope.Stroke20+import androidx.compose.ui.graphics.drawscope.rotate21+import androidx.compose.ui.graphics.drawscope.scale22+import androidx.compose.ui.graphics.drawscope.translate23+import androidx.compose.ui.graphics.vector.PathParser24+import kotlin.math.sin25+26+/**27+ * The two pairing birds, ported path-for-path from the browser extension's28+ * `ChirpPairingFlow.tsx` (a finch and a swallow) rather than redrawn by eye.29+ *30+ * Android previously carried its own simplified blob-and-triangle bird, which31+ * read as a different, worse character next to the extension's. The SVG `d`32+ * strings below are copied verbatim from that file — keep them that way, and33+ * re-copy rather than nudge if the browser art changes. Everything is drawn in34+ * the source 160x150 viewBox and scaled to fit, so the coordinates stay35+ * directly comparable to the SVG.36+ */37+enum class BirdPose { IDLE, CHIRPING, ATTENTIVE, HAPPY, SAD }38+39+enum class BirdKind { FINCH, SWALLOW }40+41+private const val VB_W = 160f42+private const val VB_H = 150f43+44+private class BirdPalette(45+    val hi: Color,46+    val mid: Color,47+    val lo: Color,48+    val shade: Color,49+    val cream: Color,50+    val beak: Color,51+    val beakShade: Color,52+    val legs: Color,53+)54+55+private val Finch = BirdPalette(56+    hi = Color(0xFFF4A6B6), mid = Color(0xFFDC8294), lo = Color(0xFFA85870),57+    shade = Color(0xFF7A3E54), cream = Color(0xFFF5D8C8),58+    beak = Color(0xFFF5C764), beakShade = Color(0xFFC8932E), legs = Color(0xFF7A3E54),59+)60+61+private val Swallow = BirdPalette(62+    hi = Color(0xFFF4C8A0), mid = Color(0xFFDC9968), lo = Color(0xFFA86B3E),63+    shade = Color(0xFF7A4A26), cream = Color(0xFFF7E0C8),64+    beak = Color(0xFFF5C764), beakShade = Color(0xFFB87A22), legs = Color(0xFF5E3A1F),65+)66+67+private val EyeDark = Color(0xFF1F0A10)68+private val EyeGlint = Color(0xF2FFF0E1)69+private val GroundShadow = Color(0x73000000)70+71+/** Parsed once per path string — parsing is not cheap enough to do per frame. */72+private val pathCache = HashMap<String, Path>()73+74+private fun svg(d: String): Path = pathCache.getOrPut(d) { PathParser().parsePathString(d).toPath() }75+76+/** SVG gradients are in objectBoundingBox units; resolve against the shape's bounds. */77+private fun radial(b: Rect, cx: Float, cy: Float, r: Float, stops: Array<Pair<Float, Color>>) =78+    Brush.radialGradient(79+        colorStops = stops,80+        center = Offset(b.left + b.width * cx, b.top + b.height * cy),81+        radius = maxOf(b.width, b.height) * r,82+    )83+84+@Composable85+fun PairingBird(86+    kind: BirdKind,87+    pose: BirdPose,88+    modifier: Modifier = Modifier,89+) {90+    val elapsed by rememberFrameMillis()91+    Canvas(modifier) {92+        val s = minOf(size.width / VB_W, size.height / VB_H)93+        translate((size.width - VB_W * s) / 2f, (size.height - VB_H * s) / 2f) {94+            scale(s, s, pivot = Offset.Zero) {95+                // The swallow is drawn mirrored in the extension so the pair96+                // faces each other; mirroring here keeps its path data verbatim.97+                if (kind == BirdKind.SWALLOW) {98+                    scale(-1f, 1f, pivot = Offset(VB_W / 2f, VB_H / 2f)) { drawBird(kind, pose, elapsed) }99+                } else {100+                    drawBird(kind, pose, elapsed)101+                }102+            }103+        }104+    }105+}106+107+/**108+ * System animator scale can be zeroed (it is on our test emulator), which109+ * silently freezes `animateFloat`. The frame clock is not scaled.110+ */111+@Composable112+private fun rememberFrameMillis(): State<Long> {113+    val millis = remember { mutableLongStateOf(0L) }114+    LaunchedEffect(Unit) {115+        val start = withFrameMillis { it }116+        while (true) withFrameMillis { millis.longValue = it - start }117+    }118+    return millis119+}120+121+/** Position within a repeating cycle, 0..1. */122+private fun cycle(elapsed: Long, periodMs: Int): Float = (elapsed % periodMs) / periodMs.toFloat()123+124+/** Eased 0→1→0 over one cycle, matching the CSS keyframes' ease-in-out feel. */125+private fun swell(t: Float): Float = sin(t * Math.PI.toFloat())126+127+/**128+ * The chirp gesture fires late in its cycle (CSS: still at 70%, peak at 78%,129+ * settling by 86%). Returns 0 outside that window, 0..1..0 inside it.130+ */131+private fun chirpBeat(t: Float): Float = when {132+    t < 0.70f || t > 0.94f -> 0f133+    else -> swell((t - 0.70f) / 0.24f)134+}135+136+private fun DrawScope.drawBird(kind: BirdKind, pose: BirdPose, elapsed: Long) {137+    val p = if (kind == BirdKind.FINCH) Finch else Swallow138+    val chirping = pose == BirdPose.CHIRPING139+    val beat = if (chirping) chirpBeat(cycle(elapsed, 1700)) else 0f140+    val bob = -1.5f * swell(cycle(elapsed, 2400))141+142+    // Blink: a 5.2s cycle that is open except for one short squash.143+    val blinkT = cycle(elapsed, 5200)144+    val eyeOpen = if (blinkT > 0.92f && blinkT < 0.96f) 0.05f else 1f145+146+    if (kind == BirdKind.FINCH) {147+        drawOval(GroundShadow, topLeft = Offset(38f, 134.5f), size = Size(80f, 7f))148+    } else {149+        drawOval(GroundShadow, topLeft = Offset(52f, 137f), size = Size(68f, 6f))150+    }151+152+    if (beat > 0f) {153+        val aura = if (kind == BirdKind.FINCH) Rect(16f, 28f, 136f, 120f) else Rect(20f, 36f, 140f, 120f)154+        drawOval(155+            p.hi.copy(alpha = 0.18f * 0.8f * beat),156+            topLeft = Offset(aura.left, aura.top),157+            size = Size(aura.width, aura.height),158+        )159+    }160+161+    drawTail(kind, p, pose, beat, elapsed)162+    drawFeet(kind, p)163+164+    // Body bob is a translate + barely-there squash; the sad pose replaces it165+    // with a static droop.166+    val droop = pose == BirdPose.SAD167+    translate(0f, if (droop) 5f else bob) {168+        rotate(if (droop) -2f else 0f, pivot = Offset(VB_W / 2f, VB_H * 0.8f)) {169+            drawBody(kind, p)170+            drawWing(kind, p, pose, elapsed)171+        }172+    }173+174+    val headPivot = if (kind == BirdKind.FINCH) Offset(95f, 52f) else Offset(116f, 64f)175+    val headTilt = when (pose) {176+        BirdPose.ATTENTIVE -> -6f177+        BirdPose.CHIRPING -> -4f * beat178+        else -> 0f179+    }180+    translate(0f, bob - 3f * beat) {181+        rotate(headTilt, pivot = headPivot) {182+            drawHead(kind, p, pose, beat, eyeOpen)183+        }184+    }185+}186+187+private fun DrawScope.drawTail(kind: BirdKind, p: BirdPalette, pose: BirdPose, beat: Float, elapsed: Long) {188+    val twitch = when (pose) {189+        BirdPose.CHIRPING -> -3f * beat190+        BirdPose.ATTENTIVE -> -4f191+        BirdPose.HAPPY -> 6f * sin(cycle(elapsed, 500) * 2f * Math.PI.toFloat())192+        else -> 0f193+    }194+    // CSS transform-origin 100% 50% — the tail hinges at the body end.195+    val pivot = if (kind == BirdKind.FINCH) Offset(32f, 95f) else Offset(38f, 80f)196+    rotate(twitch, pivot = pivot) {197+        if (kind == BirdKind.FINCH) {198+            drawPath(svg("M 32 92 Q 8 90, 6 80 Q 10 95, 28 100 Z"), p.lo)199+            drawPath(svg("M 32 95 Q 4 99, 4 92 Q 10 102, 28 102 Z"), p.shade, alpha = 0.85f)200+            drawPath(svg("M 32 98 Q 8 108, 12 110 Q 20 110, 30 105 Z"), p.lo, alpha = 0.9f)201+        } else {202+            drawPath(svg("M 38 80 Q 8 70, 0 60 Q 8 78, 28 88 Q 14 90, 6 96 Q 16 94, 32 92 L 38 84 Z"), p.lo)203+            drawPath(svg("M 30 84 Q 14 78, 6 70 Q 18 84, 30 88 Z"), p.shade, alpha = 0.7f)204+        }205+    }206+}207+208+private fun DrawScope.drawFeet(kind: BirdKind, p: BirdPalette) {209+    val leg = Stroke(width = if (kind == BirdKind.FINCH) 1.6f else 1.5f, cap = androidx.compose.ui.graphics.StrokeCap.Round)210+    val toe = Stroke(width = if (kind == BirdKind.FINCH) 1.2f else 1.1f, cap = androidx.compose.ui.graphics.StrokeCap.Round)211+    if (kind == BirdKind.FINCH) {212+        drawLine(p.legs, Offset(62f, 118f), Offset(60f, 134f), strokeWidth = leg.width, cap = leg.cap)213+        drawPath(svg("M 60 134 L 55 138 M 60 134 L 60 139 M 60 134 L 65 138"), p.legs, style = toe)214+        drawLine(p.legs, Offset(84f, 118f), Offset(86f, 134f), strokeWidth = leg.width, cap = leg.cap)215+        drawPath(svg("M 86 134 L 81 138 M 86 134 L 86 139 M 86 134 L 91 138"), p.legs, style = toe)216+    } else {217+        drawLine(p.legs, Offset(76f, 118f), Offset(74f, 136f), strokeWidth = leg.width, cap = leg.cap)218+        drawPath(svg("M 74 136 L 69 140 M 74 136 L 74 141 M 74 136 L 79 140"), p.legs, style = toe)219+        drawLine(p.legs, Offset(96f, 118f), Offset(98f, 136f), strokeWidth = leg.width, cap = leg.cap)220+        drawPath(svg("M 98 136 L 93 140 M 98 136 L 98 141 M 98 136 L 103 140"), p.legs, style = toe)221+    }222+}223+224+private fun DrawScope.drawBody(kind: BirdKind, p: BirdPalette) {225+    if (kind == BirdKind.FINCH) {226+        val body = svg("M 30 88 C 24 64, 40 38, 72 36 C 105 34, 124 56, 122 84 C 120 110, 100 122, 70 120 C 44 118, 32 108, 30 88 Z")227+        drawPath(body, radial(body.getBounds(), 0.42f, 0.32f, 0.72f, arrayOf(0f to p.hi, 0.55f to p.mid, 1f to p.lo)))228+        val belly = svg("M 40 82 C 42 100, 60 112, 80 110 C 100 108, 110 100, 112 86 C 100 102, 80 106, 60 102 C 50 100, 44 92, 40 82 Z")229+        val bb = belly.getBounds()230+        drawPath(231+            belly,232+            Brush.verticalGradient(233+                colorStops = arrayOf(234+                    0f to p.cream.copy(alpha = 0f),235+                    0.5f to p.cream.copy(alpha = 0.85f),236+                    1f to p.cream.copy(alpha = 0.5f),237+                ),238+                startY = bb.top, endY = bb.bottom,239+            ),240+        )241+    } else {242+        val body = svg("M 36 82 C 32 56, 56 42, 88 44 C 116 46, 134 60, 134 80 C 134 102, 116 118, 90 118 C 60 118, 40 104, 36 82 Z")243+        drawPath(body, radial(body.getBounds(), 0.55f, 0.35f, 0.75f, arrayOf(0f to p.hi, 0.5f to p.mid, 1f to p.lo)))244+        drawPath(245+            svg("M 50 64 C 60 52, 80 46, 100 48 C 116 50, 126 60, 128 70 C 120 60, 100 56, 80 60 C 64 64, 56 68, 50 64 Z"),246+            p.shade, alpha = 0.5f,247+        )248+        val throat = svg("M 64 82 C 66 100, 86 114, 102 112 C 116 110, 124 100, 126 88 C 116 102, 100 108, 84 104 C 74 102, 68 94, 64 82 Z")249+        val tb = throat.getBounds()250+        drawPath(251+            throat,252+            Brush.verticalGradient(253+                colorStops = arrayOf(0f to p.cream.copy(alpha = 0.95f), 1f to p.cream.copy(alpha = 0.2f)),254+                startY = tb.top, endY = tb.bottom,255+            ),256+        )257+    }258+}259+260+private fun DrawScope.drawWing(kind: BirdKind, p: BirdPalette, pose: BirdPose, elapsed: Long) {261+    val hair = Stroke(width = if (kind == BirdKind.FINCH) 0.7f else 0.6f, cap = androidx.compose.ui.graphics.StrokeCap.Round)262+    val flutter = if (pose == BirdPose.HAPPY) -16f * swell(cycle(elapsed, 320)) else 0f263+    val pivot = if (kind == BirdKind.FINCH) Offset(92f, 78f) else Offset(103f, 89f)264+    rotate(flutter, pivot = pivot) {265+        if (kind == BirdKind.FINCH) {266+            drawPath(svg("M 56 60 C 68 56, 88 60, 102 68 C 108 76, 106 86, 96 90 C 80 92, 64 86, 54 78 C 50 70, 50 64, 56 60 Z"), p.lo)267+            for (d in listOf("M 64 64 Q 72 70, 78 80", "M 74 62 Q 82 70, 88 82", "M 84 64 Q 90 72, 94 84")) {268+                drawPath(svg(d), p.shade, alpha = 0.55f, style = hair)269+            }270+            drawPath(svg("M 56 60 C 68 56, 88 60, 102 68"), p.shade, alpha = 0.4f, style = Stroke(width = 1f))271+        } else {272+            drawPath(svg("M 50 70 C 64 64, 84 66, 100 72 C 112 78, 112 90, 100 96 C 84 100, 64 96, 52 88 C 46 80, 46 74, 50 70 Z"), p.lo)273+            for (d in listOf("M 96 88 L 110 92 L 100 96 Z", "M 88 92 L 102 98 L 92 100 Z", "M 80 94 L 92 100 L 84 102 Z")) {274+                drawPath(svg(d), p.shade, alpha = 0.85f)275+            }276+            for (d in listOf("M 58 76 Q 70 82, 80 90", "M 68 72 Q 80 80, 90 88", "M 80 70 Q 90 78, 100 86")) {277+                drawPath(svg(d), p.shade, alpha = 0.5f, style = hair)278+            }279+            drawPath(svg("M 50 70 C 64 64, 84 66, 100 72"), p.shade, alpha = 0.55f, style = Stroke(width = 1f))280+        }281+    }282+}283+284+private fun DrawScope.drawHead(kind: BirdKind, p: BirdPalette, pose: BirdPose, beat: Float, eyeOpen: Float) {285+    if (kind == BirdKind.FINCH) {286+        val perk = if (pose == BirdPose.HAPPY) -15f else 0f287+        rotate(perk, pivot = Offset(88f, 30f)) {288+            drawPath(svg("M 88 22 Q 86 12, 94 12 Q 94 20, 92 26 Z"), p.lo)289+            drawPath(svg("M 96 20 Q 96 10, 104 14 Q 102 22, 100 26 Z"), p.shade, alpha = 0.95f)290+            drawPath(svg("M 104 24 Q 108 16, 114 20 Q 110 28, 106 30 Z"), p.lo, alpha = 0.85f)291+        }292+        val head = Rect(72f, 22f, 124f, 74f)293+        drawCircle(radial(head, 0.5f, 0.4f, 0.7f, arrayOf(0f to p.hi, 1f to p.mid)), radius = 26f, center = Offset(98f, 48f))294+        drawPath(svg("M 76 46 Q 80 30, 96 26 Q 86 32, 80 48 Z"), p.shade, alpha = 0.35f)295+        scale(1f, eyeOpen, pivot = Offset(108f, 42f)) {296+            drawCircle(EyeDark, radius = 4.5f, center = Offset(108f, 42f))297+            drawCircle(EyeGlint, radius = 1.4f, center = Offset(109.4f, 40.5f))298+            drawCircle(EyeGlint.copy(alpha = 0.6f), radius = 0.5f, center = Offset(106.5f, 44f))299+        }300+        rotate(-14f * beat, pivot = Offset(120f, 52f)) {301+            drawPath(svg("M 120 48 L 138 50 L 122 56 Z"), p.beak)302+            drawPath(svg("M 120 48 L 138 50 L 128 52 Z"), p.beakShade, alpha = 0.55f)303+            drawPath(svg("M 122 52 L 134 51"), p.beakShade, alpha = 0.6f, style = Stroke(width = 0.6f))304+        }305+    } else {306+        val head = Rect(98f, 44f, 138f, 84f)307+        drawCircle(radial(head, 0.45f, 0.4f, 0.7f, arrayOf(0f to p.hi, 1f to p.mid)), radius = 20f, center = Offset(118f, 64f))308+        drawPath(svg("M 100 60 Q 108 46, 124 46 Q 134 50, 136 60 Q 124 54, 108 60 Z"), p.shade, alpha = 0.55f)309+        scale(1f, eyeOpen, pivot = Offset(126f, 60f)) {310+            drawCircle(EyeDark, radius = 4f, center = Offset(126f, 60f))311+            drawCircle(EyeGlint, radius = 1.2f, center = Offset(127.2f, 58.8f))312+        }313+        rotate(-14f * beat, pivot = Offset(136f, 64f)) {314+            drawPath(svg("M 136 60 L 154 62 L 138 66 Z"), p.beak)315+            drawPath(svg("M 136 60 L 154 62 L 145 63 Z"), p.beakShade, alpha = 0.5f)316+            drawPath(svg("M 138 63 L 152 63"), p.beakShade, alpha = 0.6f, style = Stroke(width = 0.5f))317+        }318+    }319+}

view file

app/src/main/java/com/peachpasswords/ui/pairing/ChirpPairScreen.kt+41 −37
@@ -93,21 +93,24 @@9393                 )9494             }9595 96-            Copy(phase, progress.peerDeviceName, progress.errorMessage)97-98-            Spacer(Modifier.height(PeachSpacing.lg))99-            PrimaryAction(phase, vm)96+            // SAS is a phase, not a modal — the browser keeps the birds on97+            // screen above it. Scrimming them behind a full-screen overlay made98+            // the code sit in the middle of a half-lit bird.99+            val code = sas100+            if (code != null) {101+                SasBody(102+                    code = code,103+                    accent = accent,104+                    onConfirm = { if (vm.isDebug && phase == PairingPhase.HANDSHAKE) vm.demoComplete() else vm.confirmSas(true) },105+                    onCancel = { vm.confirmSas(false); vm.reset() },106+                )107+            } else {108+                Copy(phase, progress.peerDeviceName, progress.errorMessage)109+                Spacer(Modifier.height(PeachSpacing.lg))110+                PrimaryAction(phase, vm)111+            }100112             Spacer(Modifier.height(PeachSpacing.md))101113         }102-103-        if (sas != null) {104-            SasOverlay(105-                code = sas!!,106-                accent = accent,107-                onConfirm = { if (vm.isDebug && progress.phase == PairingPhase.HANDSHAKE) vm.demoComplete() else vm.confirmSas(true) },108-                onCancel = { vm.confirmSas(false); vm.reset() },109-            )110-        }111114     }112115 }113116 @@ -209,30 +212,31 @@209212 }210213 211214 @Composable212-private fun SasOverlay(code: String, accent: Color, onConfirm: () -> Unit, onCancel: () -> Unit) {213-    Box(214-        modifier = Modifier.fillMaxSize().background(Color(0xCC0C0A06)),215-        contentAlignment = Alignment.Center,215+private fun SasBody(code: String, accent: Color, onConfirm: () -> Unit, onCancel: () -> Unit) {216+    Column(217+        modifier = Modifier.fillMaxWidth().padding(horizontal = PeachSpacing.md),218+        horizontalAlignment = Alignment.CenterHorizontally,216219     ) {217-        Column(218-            modifier = Modifier.fillMaxWidth().padding(horizontal = 36.dp),219-            horizontalAlignment = Alignment.CenterHorizontally,220-        ) {221-            Text("Verify the code", color = peachColors.T0, fontFamily = PeachType.Display, fontStyle = FontStyle.Italic, fontSize = 22.sp)222-            Spacer(Modifier.height(PeachSpacing.xs))223-            Text("It must match on both devices.", color = peachColors.T2, fontFamily = PeachType.Body, fontSize = 13.sp, textAlign = TextAlign.Center)224-            Spacer(Modifier.height(PeachSpacing.xl))225-            Text(226-                text = code,227-                color = accent,228-                fontFamily = PeachType.Mono,229-                fontSize = 38.sp,230-                letterSpacing = 6.sp,231-            )232-            Spacer(Modifier.height(PeachSpacing.xxl))233-            PillButton("They match — pair", accent, filled = true, onClick = onConfirm)234-            Spacer(Modifier.height(PeachSpacing.sm))235-            PillButton("Cancel", peachColors.T2, filled = false, onClick = onCancel)236-        }220+        Text("Verify the code", color = peachColors.T0, fontFamily = PeachType.Display, fontStyle = FontStyle.Italic, fontSize = 22.sp)221+        Spacer(Modifier.height(PeachSpacing.xs))222+        Text(223+            "Only continue if this code is identical on both screens. This is what proves you're talking to your own device.",224+            color = peachColors.T2,225+            fontFamily = PeachType.Body,226+            fontSize = 13.sp,227+            textAlign = TextAlign.Center,228+        )229+        Spacer(Modifier.height(PeachSpacing.lg))230+        Text(231+            text = code,232+            color = accent,233+            fontFamily = PeachType.Mono,234+            fontSize = 38.sp,235+            letterSpacing = 6.sp,236+        )237+        Spacer(Modifier.height(PeachSpacing.lg))238+        PillButton("They match — pair", accent, filled = true, onClick = onConfirm)239+        Spacer(Modifier.height(PeachSpacing.sm))240+        PillButton("Cancel", peachColors.T2, filled = false, onClick = onCancel)237241     }238242 }

view file