# EnvoyTxnAuthStack

The transaction-authorisation flow on the **Stack** version of Envoy Home. A pending agent purchase shows as a card in a fanned stack. The user approves it in a bottom sheet, authenticates (passkey / Face ID, OTP, or both), and sees the Shatter success mark.

| | |
|---|---|
| **Proto (iOS, Stack)** | https://envoy-txn-auth.pages.dev/stack. On a laptop the flow chart is on the left; tap **RN** to open these notes in a side panel (hover to preview, click to pin). The Settle carousel is not part of this handoff. |
| **Dev handoff page** | https://envoy-txn-auth.pages.dev/handoff |
| **Figma** | File `1nu4wMdlWocsKiHPNz9KmJ`, page *Transaction Auth* `599:1546`. *Engineering handoff · unique screens* `711:6033` (H01–H19, each a component), plus *Flow A — Stack* (A01–A20, instances of H). |
| **Success Lottie** | `assets/success-shatter.json` (lottie-web / lottie-react-native) and `assets/success-shatter.lottie` (dotLottie) |
| **Lab (tweak the animation)** | https://envoy-txn-auth.pages.dev/success-lab |

Type-checked under `strict` against `apps/elixir-card`'s real dependencies (RN 0.83.4, Reanimated 4.2, RNGH 2.30, lottie-react-native 7.3, the `@elixir/ui-kit/envoy` sources), with 0 errors. Not yet run on a device.

---

## Files

```
EnvoyTxnAuthStack/
├── index.tsx            flow controller: Home section + rail + sheet, one `step`
├── styles.ts            createEnvoyStyles(theme) — every value mapped to theme keys
├── types.ts             view-model types + props
├── motion.ts            every duration / delay / angle / easing in one place
├── tokens.proposed.ts   colours the Envoy theme doesn't name yet (move into palette.ts)
├── components/
│   ├── SectionHeader.tsx       "Needs approval" · underlined "1 of 9" CTA
│   ├── CardStack.tsx           3-card fan, swipe-to-back, tap → rail
│   ├── NeedsApprovalCard.tsx   the card (live + ghost)
│   ├── Thumb.tsx               merchant logo + agent badge
│   ├── Timers.tsx              donut + pill countdowns, useRemaining()
│   ├── DiamondLoader.tsx       the three-diamond busy state
│   ├── ApprovedRow.tsx         approved row + land-then-tick
│   ├── NotificationRail.tsx    blurred full-stack list
│   └── ApprovalSheet.tsx       sheet: approve → biometric → approving | OTP → success | expired
└── assets/              success-shatter.json / .lottie / .mp4 / .webm
```

## How it plugs in

```tsx
import { EnvoyTxnAuthStack } from "@/rn-components/EnvoyTxnAuthStack"; // move under apps/elixir-card/src/screens/envoy/

<EnvoyTxnAuthStack
  pending={pending}               // map from core/data/envoyApprovals once, in the screen
  approved={approved}
  renderHomeTop={() => <EnvoyHomeTop />}  // balance, agent pill, quick actions: keep what EnvoyHomeScreen has
  authenticate={(id) => passkey.approve(id)}  // the same module ACTION_TYPES.OPEN_AGENT_APPROVAL raises
  onApprove={(id) => api.approve(id)}         // resolves "done" | "otp"; the bank decides, not the amount
  onSubmitOtp={(id, code) => api.otp(id, code)}
  onResendOtp={(id) => api.resend(id)}
  onDeny={(id) => api.deny(id)}
  onBackToAgent={(agent) => Linking.openURL(AGENT_RETURN[agent])}
  initialRequestId={fromPush}     // push tap → sheet directly (Figma A01 → A05), NOT Home
  showFlowTags={__DEV__}
/>
```

It is a view. Fetching, the passkey module and the push handler stay where they are. The four rules in `EnvoyApprovalsScreen` still apply: the server buckets, `placeholder` rows aren't actionable, empty and degraded are different screens, and Approve is one dispatch.

---

## Where this deviates from the `html-to-rn` skill, and why

The skill is written for the Elixir proto kit. Envoy has its own design system (`packages/ui-kit/src/envoy`, see `envoy-design-system.md`), and its rules win:

| Skill says | This does | Why |
|---|---|---|
| Hex literals in `styles.ts` from tokens.css | `theme.colors.*`; missing ones in `tokens.proposed.ts` | Envoy rule 1: no hex outside `palette.ts`. Dark mode is live (`themeMode: "system"`). |
| `StyleSheet.create` | `createEnvoyStyles(theme => …)` | Cached per theme; required for light/dark. |
| `Platform.select` SF Pro / Google Sans | `theme.text.*` / `theme.fonts.*` (Geist, Geist Mono, Faculty Glyphic) | Envoy's type scale; weight comes from the family. |
| `@gorhom/bottom-sheet` | `EnvoySheet` (RN `Modal`) | EnvoySheet header: the gorhom double-present race (RN7) and the DISMISSING deadlock. |
| shadows → `shadowColor…` | `boxShadow` from `theme.elevation.*` | RN 0.83 supports `boxShadow`; Envoy rule 3. |
| Only the 7 approved deps | also uses Reanimated, RNGH, lottie-react-native, masked-view, expo-linear-gradient | All are **already installed in `apps/elixir-card`**. The skill's list needs updating. |
| `FastImage` | RN `Image` | Merchant logos are local assets. The app uses `expo-image` for network images. |

## Dependencies

- [x] `expo-blur`: rail backdrop (approved)
- [x] `react-native-safe-area-context`: insets (approved)
- [x] `react-native-svg`: timers, tick, clock (approved)
- [x] `react-native-reanimated` 4.2: every animation (installed; add to the skill list)
- [x] `react-native-gesture-handler` 2.30: stack swipe (installed; add to the skill list)
- [x] `lottie-react-native` 7.3: success mark (installed; add to the skill list)
- [x] `@react-native-masked-view/masked-view` + `expo-linear-gradient`: rail top fade (installed; add to the skill list)
- [ ] `EnvoyIcon`: add a `clock` glyph (Hugeicons clock-01, stroke 1.5). Timers.tsx inlines it until then.
- [ ] Agent marks: badges are monograms until the licensing call lands (design-system "Open").

## New tokens (move from `tokens.proposed.ts` into the theme)

| Proposed key | Light | Figma variable | Used by |
|---|---|---|---|
| `successMark` | `#10A37F` | (approved tick fill) | approved tick, Shatter |
| `timerTrack` | `rgba(217,119,87,.18)` | | pill + donut track |
| `flowTagBg` | `#F1F1F3` | `color/flow-tag-bg` | PASSKEY / OTP tag |
| `skeleton`, `skeletonSoft` | `#ECECEE`, `#F1F1F3` | | ghost cards |
| `railTint` | `rgba(10,10,10,.42)` | | rail over blur |
| `otpLine`, `otpLineActive` | `#DDE3EA`, `#000` | | OTP underlines |
| `ctaUnderline` | `rgba(10,10,10,.45)` | `color/cta-underline` | "1 of 9" |
| `primaryInset`, `secondaryInset` | `rgba(255,255,255,.25)`, `rgba(0,0,0,.05)` | | button gloss |

Also proposed: a text variant `amountCaption` (Faculty Glyphic 12/16) for card prices, and an elevation level `stackFront` (`0 6 16 -10 rgba(0,0,0,.18)` + 1px `rgba(0,0,0,.04)` ring) to match the proto exactly. `cardLifted` is used until then. Dark values in `tokens.proposed.ts` are derived and not signed off.

---

## Motion spec (all in `motion.ts`)

| What | Spec | Where |
|---|---|---|
| Stack tilt | positional: front −0.3°, middle +1.7°, back +3.7°; rotation only, shared centre | CardStack |
| Swipe to back | either direction past 90pt → fling 460pt / 260ms ease-ios, then to the end of the queue; every card rotates to its new slot angle in 460ms; under 90pt springs back; drag adds dx/30° | CardStack |
| Tap stack / "1 of 9" | opens the rail (tap = under 6pt of travel) | CardStack, SectionHeader |
| Rail open | overlay fades 260ms; ghosts travel from the stack position at 0, then 70+45i ms (460ms ease-ios), top → bottom; content fills at max(d+460, 460+95i) | NotificationRail |
| Rail top fade | mask height = clamp(scrollY, 0, 40) | NotificationRail |
| Deny in rail | card slides off left 280ms; the cards below close up 320ms | NotificationRail |
| Card / Approve in rail | sheet comes up, rail fades out underneath | index |
| Sheet | EnvoySheet: scrim fades, card travels its measured height (280ms ease-ios); steps cross-fade 180/120 and the height animates | ApprovalSheet |
| Pill timer | outline starts and ends at top centre; the remaining arc shrinks anticlockwise, linear to expiry; text ticks at 1 Hz | Timers |
| Donut timer | r 8.4, stroke 2.2, empties clockwise from 12 o'clock | Timers |
| Diamonds | 3 × 4pt, gap 7, each 0.25 → 1 → 0.25 over a 900ms cycle, 150ms stagger; shown for at least 900ms | DiamondLoader |
| OTP error | underlines red, digits stay ink, no shake; typing clears it | ApprovalSheet |
| Success | Lottie once, 2.85s, holds the tick | ApprovalSheet |
| Approved row lands | drops in 380ms, then the disc scales 0.2 → 1 (320ms ease-out), then the check strokes on (300ms, +200ms) | ApprovedRow |

## The success Lottie

- `assets/success-shatter.json`: 120×120, 60fps, 2.847s, 638 layers, about 1.9 MB. It's generated from the same engine the proto runs (`envoy-app/success-shatter.js`) with the lab's saved settings (`envoy-app/lottie/success-shatter.params.json`).
  - Regenerate it with `node tools/gen-shatter-lottie.mjs lottie/success-shatter.params.json` in `envoy-app`.
- `assets/success-shatter.lottie`: the same animation as dotLottie, 130 KB. Prefer this if the app moves to `@lottiefiles/dotlottie-react-native`; `lottie-react-native` 7 also reads `.lottie`.
- `assets/success-shatter.mp4` / `.webm`: 480², with a 0.8s hold, on #FAFAFA / transparent. For Figma, decks and the web; don't ship these in the app.
- No dynamic text. Colours are baked in: ink `#0A0A0A`, green `#10A37F`, white tick. A dark-mode version needs a regenerated file with the dark `successMark`.
- Performance: 638 shape layers is heavy for a 120pt mark. If it drops frames on low-end Android, use `renderMode="HARDWARE"` or swap to the `.lottie`. The fallback is the mp4 via `expo-video` (already installed).

## Platform notes

- **iOS:**
  - The OTP field sets `textContentType="oneTimeCode"`, so the keyboard shows the "From Messages" code.
  - The rail uses `BlurView` intensity 40 (`tint="dark"`), roughly CSS blur 22px.
  - `textDecorationColor` on "1 of 9" is iOS-only.
- **Android:**
  - `autoComplete="sms-otp"` for SMS Retriever autofill.
  - Every `Pressable` has `android_ripple`.
  - `BlurView` on Android needs `experimentalBlurMethod="dimezisBlurView"` for a real blur; otherwise the tint alone carries it, which is acceptable.
  - The underline uses the text colour.
- **Both:**
  - Swiping is disabled while the sheet is up (`swipeEnabled={step === "home"}`).
  - The rail is an in-screen overlay, not a Modal, so the sheet Modal can present over it.

## Validation checklist (skill)

- [x] Every annotated element in the proto (`data-rn-component`, see the RN overlay) has a component here
- [x] Every dep in a `data-rn-note` is imported, and the extras are listed above
- [x] No `TouchableOpacity`, only `Pressable`
- [x] Every `Pressable` has `android_ripple` (`null` on purpose for the large card / backdrop / cell targets, where a ripple would flood the card)
- [x] No hard-coded colours in components. Theme or `PROPOSED` only (the mask's black/transparent are mask alpha, not colour)
- [x] `SafeAreaView` wraps the screen (`edges={["top"]}`; the tab bar owns the bottom)
- [x] No CSS shorthands in StyleSheets (individual sides everywhere)
- [x] Font families come from the theme (Envoy's equivalent of `Platform.select`)
- [ ] On-device pass (iPhone 15 Pro, a Pixel) for gesture feel, blur and Lottie frame rate
