UI-SPEC — Native legal consent on the OAuth transfer path (OBJ-2599)
⚠️ SUPERSEDED by
obj-2602-native-legal-consent.md— 2026-08-18 (OBJ-2709) Do not implement against this file. It describes the
/sso-callback-owned consent modal shipped by PR #2394. That architecture was collapsed into PR #2385's globalApp.vueoverlay by Josh's 2026-08-18 ruling — #2394 was folded in, not reverted. The reason is structural, not preference: the iOS in-process Apple / Google paths never load/sso-callback, so a modal owned by this view cannot reach them.This file is retained, not deleted, because it holds measurements nothing else does: the 1.93:1 dark-mode
color: white+--gradient-primaryconfirm label (§The dark-mode contrast finding), the 43px → 44px footer tap-target measurements at 320px (§Fit at 320px), and the two other surfaces carrying the same gradient-ink defect (ProfileCompletionModal.vue:390,AcceptPartnershipModal.vue:217) — still open, still worth fixing.Where each of its behaviours went — full reasoning in the survivor's §Reconciliation with OBJ-2599:
This spec's behaviour Disposition in OBJ-2602 aria-describedbyon the modalPorted Visible hint — "Both open in your browser — come back here to finish." Ported, reworded for the checkbox affordance /sso-callbackdeclinedphase + "Review and agree"Not ported — one decline path survives Branched post-consent failure copy Not ported — unreachable under the surviving architecture --primary-inkon both/sso-callbackCTAsPorted — and it is a regression guard, not an option Cancel→Not nowAlready in the survivor Its mockups stay at
mockups/obj-2599-native-legal-consent/as the contrast evidence. They do not depict shipping UI.
Issue: OBJ-2611 (Task 1 of OBJ-2599) · Tier: 3 (spec + HTML mockup + rendered captures + motion) Status: superseded 2026-08-18 · Superseded by: obj-2602-native-legal-consent.mdMockups: mockups/obj-2599-native-legal-consent/
Scope
ionic_frontend/src/views/SsoCallback.vue — the states around the consent pause on the Android → Google → no-existing-account transfer path. The consent surface itself is the existing AgreeToTermsModal.vue, which is reused, not redesigned: no new component, and its accept/decline contract (confirm() → onSuccess() then closed; Cancel / X / backdrop → closed alone) is unchanged.
Covered: the consent-pending state, the accept → POST in-flight state, the decline outcome, the post-consent failure state, the modal's fit and a11y at ≥320px, and the flow sequence connecting them.
Not in scope: the legal_accepted flag through clerkFrontendApi (Task 2), the confirmLegalConsent gate inside useClerkNative (Task 2), the PATCH fallback (parent Task 4), the iOS in-process path (parent Task 5), and the two /sso-callback container gotchas flagged below as context.
Component / screen inventory
| Component / screen | Status |
|---|---|
views/SsoCallback.vue | modified — owns the modal, the resolver, and four render states |
components/AgreeToTermsModal.vue | reused — five scoped changes, listed in §Modal changes. Contract, markup structure and data-testid untouched |
components/ui/AppModal.vue | reused as the modal's <ion-modal> wrapper (locked motion) |
components/ui/ObjectuveLogo.vue | reused unchanged — variant="icon", animate toggled per state |
components/icons/AppIcon.vue | reused unchanged |
No new component is introduced. data-testid="terms-agree-btn" and aria-labelledby="agree-modal-title" are preserved verbatim.
Layout
390 × 844 (native, insets 47 / 34)
1 · COMPLETING (today) 2 · CONSENT PENDING 3 · CREATING
┌────────────────────┐ ┌────────────────────┐ ┌────────────────────┐
│ │ │░░░░░░░░░░░░░░░░░░░░│ │ │
│ ◌ │ │░┌────────────────┐░│ │ ◌ │
│ Signing you in… │ │░│ ▣ Terms & Priv.│░│ │ Creating your │
│ │ │░│ Review before│░│ │ account… │
│ │ │░│ Do you agree…? │░│ │ │
│ │ │░│ Both open in… │░│ │ │
│ │ │░│ [Not now][Yes] │░│ │ │
│ │ │░└────────────────┘░│ │ │
└────────────────────┘ └────────────────────┘ └────────────────────┘
ring turning ring STOPPED ring turning
"One quick agreement
and you're in."
4 · DECLINED 5 · COULDN'T CREATE
┌────────────────────┐ ┌────────────────────┐
│ ⛊ │ │ ⚠ │
│ Nothing was │ │ We couldn't create │
│ created. │ │ your account. │
│ We need your OK on │ │ Nothing was saved. │
│ the Terms and │ │ Start again from │
│ Privacy Policy… │ │ the sign-in screen.│
│ [ Review and agree]│ │ [ Back to sign in ]│
│ Back to sign in │ │ │
└────────────────────┘ └────────────────────┘All five render inside the existing centred wrapper (SsoCallback.vue:4) — no route change, no container swap, no layout jump between them. Wider viewports change nothing but the available air; the modal caps at 480px and stays centred.
Container gotchas — flagged as context, not fixed here
/sso-callbackismeta.public, soBottomTabBarnever renders, butApp.vue:238-242still reserves60px + env(safe-area-inset-bottom)on everyion-contentunder 768px (gotchas.md:288). The captures draw that dead space rather than hide it. Recorded, not fixed — the fix is a scoped--padding-bottom: 0, and it belongs on its own ticket with the other four affected public routes.min-h-screeninsideion-content(gotchas.md:292) is already fixed on this file — line 4 readsmin-h-fulltoday. Nothing to do.
Modal changes — the five the flow demands, and nothing else
Each is scoped, none touches the emit contract, the onSuccess prop, the markup structure, or data-testid="terms-agree-btn".
| # | Change | Why the flow demands it |
|---|---|---|
| 1 | Cancel → Not now | §Cancel verdict below |
| 2 | Add one hint line under the body: "Both open in your browser — come back here to finish." | The policy links leave the app; R3 says that round trip can lose flow state. Copy is the only thing that can set the expectation before the tap |
| 3 | Footer buttons: min-height: 44px, white-space: nowrap, and .confirm-btn padding 1.5rem → 1.25rem | Measured 43px today — under the tap-target floor. See §Fit at 320px |
| 4 | .confirm-btn color: white → color: hsl(var(--primary-ink-foreground)) | Measured 1.93:1 in dark mode. See §The dark-mode contrast finding |
| 5 | <ion-modal> → <AppModal presentation="card">; add id="agree-modal-text" on the body <p> and aria-describedby="agree-modal-text" on the modal | Locked motion vocabulary (throughline g10) + the consent sentence must be announced, not just the title |
Change 5 is the only structural one. AppModal is the sanctioned wrapper — it binds useModalAnimations by reference and short-circuits to duration-0 under prefers-reduced-motion (motion-vocabulary.md, Modals). It will break tests/unit/components/AgreeToTermsModal.spec.ts, which stubs IonModal; the stub moves to AppModal and every existing assertion still holds.
Deliberately not changed: the glass recipe, the icon tile, the gradient, the title/subtitle/body strings, the :global(.dark) block, the policy URLs (the www. form is correct), and the Yes, I Agree label.
Design tokens
Every value is an existing token. This spec proposes no new token.
| Surface | Token / class | Value (reference only) |
|---|---|---|
| Page background | bg-background | 210 40% 98% / 222 47% 8% |
| Status / pending copy | text-sm text-muted-foreground | 215 16% 40% / 215 20% 65% |
| State heading (4, 5) | font-display text-lg font-semibold text-foreground | 222 47% 11% / 210 40% 98% |
| Decline icon | AppIcon name="shield-check", text-muted-foreground | decorative, aria-hidden |
| Failure icon | AppIcon name="alert-triangle", text-destructive-accessible | 0 74% 41% / 0 70% 68% |
| Primary CTA (states 4, 5) | bg-[hsl(var(--primary-ink))] text-[hsl(var(--primary-ink-foreground))] | 6.14:1 light / 8.07:1 dark — see the note below |
| Secondary CTA (state 4) | text-muted-foreground, transparent fill | 6.4:1 light |
| Focus ring (every control) | focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 | unchanged |
| Modal surface | .modal-root as shipped — hsl(var(--card)/0.7) + :global(.dark) card/0.4 | unchanged |
| Modal confirm fill | var(--gradient-primary) | unchanged; only the ink on it moves |
One deviation from the shipped CTA pairing, deliberate.
SsoCallback.vue:23-24shipsbg-primary+text-primary-foreground, which axe measures at 4.33:1 in light mode — the same app-wide findingobj-2553-username-completionrecorded. This spec adds a second primary button (state 4), so rather than duplicate a failing pairing onto new UI, both/sso-callbackCTAs move to--primary-ink/--primary-ink-foreground— the accessible fill the repo already ships in 12+ components. Two classes, one file. The app-wide wizard-CTA finding stays out of scope.
Typography
| Element | Font | Class |
|---|---|---|
| Pending / in-flight line | Inter | text-sm |
| State heading (4, 5) | Outfit | font-display text-lg font-semibold |
| State body (4, 5) | Inter | text-sm, max-w-[32ch] text-center |
| CTA label | Inter | text-sm font-semibold |
Every size is an existing ramp step already used on this file. No arbitrary text-[Npx].
State catalog
SsoCallback.vue renders exactly one of five phases. Rendered light + dark at 390 / 768 / 1280 in mockups/obj-2599-native-legal-consent/screenshots/.
1 — Completing (today, unchanged)
ObjectuveLogo variant="icon" animate="loading" + Signing you in…. Correct while the exchange is genuinely in flight.
2 — Consent pending (new)
Modal open over the dimmed backdrop. Behind it:
- The ring stops turning —
animate="none". An animated loading indicator while the app waits on a person is a lie about who is busy; this is the one detail that makes the state honest. - Copy: "One quick agreement and you're in." replacing
Signing you in…. It is what a screen reader lands on after the modal closes and what shows in the closing frame.
3 — Creating your account (new)
animate="loading" returns; copy is "Creating your account…". Same two elements as state 1, one string apart — a sign-up is not a sign-in, and this wait can outlast 3s, so it says which operation is running (throughline g17).
4 — Declined (new — replaces today's silent router.replace('/sign-in'))
Reached by Not now, the X button, backdrop tap, Esc, or Android hardware back — all four dismiss paths are identical by construction, because all of them emit closed without onSuccess.
| Element | Treatment |
|---|---|
| Icon | AppIcon name="shield-check" h-8 w-8 text-muted-foreground mb-3 — a stop, not an error |
| Heading | Nothing was created. |
| Body | We need your OK on the Terms and Privacy Policy before we can set up your account. |
| Primary | Review and agree — re-opens the modal (see below) |
| Secondary | Back to sign in — router.replace('/sign-in') |
The re-entry affordance exists, and here is why it is safe — obj-2533-auth-error-surfaces ruled out a retry on the failure state because an OAuth callback carries a single-use code. That reasoning does not transfer: the decline path never POSTed anything, so nothing was consumed. "Review and agree" re-runs completeOAuthRedirect(window.location.href) with a fresh resolver; that is a second read of client state, not a replay. If Clerk no longer reports transferable, the run fails closed exactly as it does today and lands on state 5 — an already-specified screen with a forward action, never a dead end (g38).
5 — Couldn't create (copy override on the existing failure block)
The shipped block at SsoCallback.vue:14-30 — same icon, classes, layout and CTA — with two strings swapped:
| Today | Post-consent failure | |
|---|---|---|
| Heading | That sign-in didn't finish. | We couldn't create your account. |
| Body | Something broke on the way back. Start again from the sign-in screen. | Nothing was saved. Start again from the sign-in screen. |
Today's strings stay for the pre-consent failure. No retry here — consent was already given and the POST is what failed; a second identical POST is not a different outcome.
Codi — the branch that makes state 5 reachable.
useClerkNative.completeOAuthRedirectcatches internally and resolves{ redirectTo: '/sign-in' }; it does not reject. So the existingfailedref never fires on native, and a post-consent failure would silently bounce.SsoCallbackalready holds the missing bit: it owns the resolver, so it knows consent was accepted. Render state 5 whenconsentAccepted === true && redirectTo === '/sign-in'. That needs no change to Task 2's contract. If Task 3's wiring makes an explicit reason field natural, prefer it — but do not widen the composable's return type just for this.
Cross-cutting states
- Loading: states 1 and 3 are the loading states. No skeleton — there is no content shape to reserve.
- Empty: n/a — a transient callback route has no collection.
- Disabled: n/a — no control is ever disabled; see §Double-submit.
- Focus: every button carries the shipped
focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2. - Hover (desktop): primary CTAs darken one interactive step, 150ms; the secondary goes
text-muted-foreground → text-foreground. - Active / pressed:
active:scale-[0.98], 150ms (existing). - Dark mode: no state needs a dark-specific override beyond the modal's existing
:global(.dark)block and change 4. Verified in the dark captures — axe reports zero violations in both themes. - Motion-reduced: see §Motion.
Fit at 320px, and the tap-target finding
Measured in the mockup with getBoundingClientRect(), not derived from padding arithmetic:
| Today | With change 3 | |
|---|---|---|
Cancel / Not now height | 43px ❌ | 44px ✅ |
Yes, I Agree height | 43px ❌ | 44px ✅ |
| Footer content width | — | 231px inside 254px available |
At 320px the modal is 304px (--width: min(480px, 95vw)), leaving 254px inside .modal-root's 1.5rem padding. white-space: nowrap prevents "Not now" wrapping to two lines (which is what the 44px floor alone produces), and trimming .confirm-btn's horizontal padding to match .cancel-btn buys the 23px of headroom.
Safe-area insets: nothing to add. The modal is a centred, auto-height card — not a sheet, not full-screen — so it never approaches the status bar or the home indicator at any viewport in scope. The page behind it keeps the existing min-h-full centred wrapper.
The dark-mode contrast finding
.confirm-btn pairs color: white with var(--gradient-primary). That gradient is dark in light mode and light in dark mode:
| Theme | Gradient stops | color: white | --primary-ink-foreground |
|---|---|---|---|
| Light | 203 78% 31% → 41% | 4.54:1 ✅ | 4.54:1 ✅ (identical — the token is white here) |
| Dark | 203 78% 61% → 71% | 1.93:1 ❌ | 7.69:1 ✅ |
Both ratios are computed in the mockup against the worst stop of the live gradient and printed into the captures (§3 of the mockup). White is not a mistake in light mode — tokens.css:174-176 records that --gradient-primary was deliberately re-anchored under OBJ-2247 so its worst stop clears 4.5 against white. It simply has no dark-mode answer, and this modal has never had a production consumer to expose that. --primary-ink-foreground is white in light and 222 47% 8% in dark — the one value correct in both — and the gradient stays exactly as it is.
Recorded for Maggie, not fixed here: the same
color: white+--gradient-primaryshape appears atProfileCompletionModal.vue:390andAcceptPartnershipModal.vue:217. Same one-line fix, different surfaces, outside this task's scope — worth confirming before it ships a third time.
Cancel-button verdict
Change it: Cancel → Not now.
"Cancel" is the app's word for dismiss this dialog, and it appears on dialogs where nothing is at stake. Here the same word silently ends account creation — the label under-reads the consequence while sounding routine. "Not now" says the decision is deferrable, which is exactly true: state 4's "Review and agree" is right there. The consequence itself belongs on state 4, where there is room to explain it in a sentence, not crammed into a button. A scarier label ("Don't create my account") would be a guilt-confirm on an exit — a dark pattern this repo does not ship (throughline g51).
Microcopy
Every string, verbatim. No placeholders.
| # | Surface | String | Tone check |
|---|---|---|---|
| 1 | Pending, behind the modal | One quick agreement and you're in. | brief ✅, honest about who is waiting ✅ |
| 2 | Modal title | Terms & Privacy | unchanged ✅ |
| 3 | Modal subtitle | Review before continuing | unchanged ✅ |
| 4 | Modal body | Do you agree to the Terms of Service and Privacy Policy? | unchanged ✅ |
| 5 | Modal hint (new) | Both open in your browser — come back here to finish. | sets the round-trip expectation ✅, one sentence ✅ |
| 6 | Modal decline | Not now | honest, deferrable, no guilt ✅ |
| 7 | Modal confirm | Yes, I Agree | unchanged ✅ |
| 8 | In-flight | Creating your account… | names the real operation ✅ |
| 9 | Declined heading | Nothing was created. | factual, reassuring, no blame ✅ |
| 10 | Declined body | We need your OK on the Terms and Privacy Policy before we can set up your account. | what's needed · why ✅ |
| 11 | Declined primary | Review and agree | verb-led ✅, names the outcome ✅ |
| 12 | Declined secondary | Back to sign in | existing string, unchanged ✅ |
| 13 | Failure heading | We couldn't create your account. | what failed ✅ |
| 14 | Failure body | Nothing was saved. Start again from the sign-in screen. | what failed · recovery ✅ |
No toast, on any branch — see §Flow sequence.
Accessibility
- Focus on open. Ionic traps focus inside the modal and focuses its first focusable descendant — the X button. That is a decline control, and it means the consent sentence is never read on arrival. Focus the dialog container instead:
.modal-rootgetstabindex="-1"and is focused ondidPresent, so the accessible name and the description are announced before any control. aria-labelledby="agree-modal-title"is sufficient for the name, and insufficient on its own. It gives the dialog "Terms & Privacy" — correct, and it already exists. What it does not do is announce what is being agreed to. Addaria-describedby="agree-modal-text"(change 5) pointing at the existing body paragraph. Verify in the rendered DOM that both land on the element carryingrole="dialog"— Ionic puts that on the<ion-modal>host, which is whereAppModalforwards$attrs.- Focus after accept: the
/sso-callbackstatus container (tabindex="-1", the existingaria-live="polite"wrapper). The reader lands on "Creating your account…" rather than on<body>. - Focus after decline: the state-4
<h1 tabindex="-1">, so the explanation is read first and the next Tab reaches "Review and agree". - One announcement per swap. The wrapper keeps
aria-live="polite"and every state renders inside it. Do not addrole="alert"to state 4 or 5 — nested in the polite region it double-announces (theobj-2533ruling, unchanged). - Icons are decorative. The shield and alert-triangle carry no
aria-label, soAppIconrenders themaria-hidden="true"automatically; the heading carries the meaning. - Tap targets ≥44px. Modal footer 44px (measured above), close button 44px (already), both
/sso-callbackCTAsmin-h-11. Assert computed dimensions, not class names (tests/unit/helpers/tapTarget.ts). - Contrast. Zero axe-core violations across all six captures, both themes, after changes 4 and the CTA ink swap. Policy links measure 4.55:1 in light — passing, with almost no headroom;
text-primary-accessiblewould give more, and is recorded, not specified, since the flow does not demand it.
Motion
Everything below is already in the locked vocabulary; nothing new is minted.
| Moment | Motion | Source |
|---|---|---|
| Modal enter | 250ms, cubic-bezier(0.32,0.72,0,1), backdrop fade + wrapper scale(0.9→1) / translateY(10px→0) | useModalAnimations.ts:5-55 via AppModal |
| Modal leave | the same animation reversed | as above |
| State swap inside the wrapper (1↔2↔3↔4↔5) | 200ms opacity cross-fade, cubic-bezier(0.4,0,0.2,1) | DESIGN.md Motion — standard curve; matches obj-2533's pending→failure swap |
| Logo ring | existing ObjectuveLogo animate="loading"; stopped in state 2 | component-owned |
| Route out on success | routeAnimated — animated mobile, instant desktop | App.vue:26,160 |
No toast, no haptic, on any branch. The existing successful OAuth path shows neither, and the destination screen is the acknowledgment (g28 — exactly one, never two). Adding a haptic here would assign a tier the vocabulary does not give this moment (g29).
Reduced motion. App.vue:313-322 collapses the CSS transitions; Ionic's Web-Animations modal transition is not caught by that reset, which is exactly why change 5 routes through AppModal — it short-circuits both enter and leave to duration 0 under prefers-reduced-motion. The flow stays completable, and no state carries its meaning in a transform (g13).
The modal entrance → agree → "Creating your account…" sequence is recorded in the two motion captures; the mockup runs the real 250ms curve, so the clip shows the actual timing.
Flow sequence
The journey: Android Google tap → external browser → /sso-callback → consent → account.
| From | Trigger | To | Transition + duration | Feedback | Reduced-motion | Back-nav |
|---|---|---|---|---|---|---|
/sign-in | tap Google | external browser | Browser.open fullscreen | none | n/a | returns to /sign-in |
| browser | redirect back | /sso-callback, state 1 | routeAnimated | none | global reset | n/a |
| state 1 | transferable === true | state 2 + modal | 250ms modal enter | none | duration-0 | hardware back = dismiss = decline |
| state 2 | Yes, I Agree | state 3 | 250ms modal leave, then 200ms copy cross-fade | none | duration-0 | n/a — POST in flight |
| state 3 | sign-up completes | /dashboard → gate chain | router.replace, routeAnimated | none (arrival is the acknowledgment) | global reset | /sso-callback is not in history |
| state 3 | POST fails / offline | state 5 | 200ms cross-fade | inline copy | — | n/a |
| state 2 | Not now / X / backdrop / Esc / hardware back | state 4 | 250ms modal leave, then 200ms cross-fade | inline copy | duration-0 | n/a |
| state 4 | Review and agree | state 2 (fresh resolver) | 250ms modal enter | none | duration-0 | n/a |
| state 4 | Back to sign in | /sign-in | routeAnimated | no notice on /sign-in — the user just read the explanation; a second one double-announces | global reset | n/a |
| any state | refresh / relaunch | state 1, re-derived | — | — | — | clean restart; nothing half-created (fail-closed) |
The real destination is a chain, not /dashboard. router/index.ts:123-146 redirects an authenticated user with a blank username to /profile/complete/about (OBJ-2553). A brand-new Google account has no username, so the honest sequence is /sso-callback → /dashboard → /profile/complete/about. Nothing here needs to change — the guard is correct and the copy never promises the dashboard — but Codi should expect that hop on device rather than treat it as a bug.
Double-submit is impossible by construction. The resolver is a single Promise; a second onSuccess() call resolves nothing, because a promise resolves once. No disabled state, no guard flag, no double POST (g27). This is a requirement on how the resolver is written, and it is in the acceptance criteria.
Wiring sketch (the contract, not the implementation)
const phase = ref<'completing' | 'consent' | 'creating' | 'declined' | 'failed'>('completing')
let resolveConsent: ((v: boolean) => void) | null = null
let accepted = false
function confirmLegalConsent(): Promise<boolean> {
accepted = false
phase.value = 'consent'
return new Promise<boolean>((resolve) => { resolveConsent = resolve })
}
const onConsentSuccess = () => { accepted = true } // fires only on confirm()
function onConsentClosed() { // fires on every dismiss path
phase.value = accepted ? 'creating' : 'declined'
resolveConsent?.(accepted)
resolveConsent = null
}declined is decided before completeOAuthRedirect resolves, so the returned /sign-in is rendered in place instead of navigated to. failed is accepted && redirectTo === '/sign-in'.
Storybook story brief
Path: ionic_frontend/src/components/AgreeToTermsModal.stories.ts (exists — extend it).
| Story | State |
|---|---|
Open | existing, unchanged |
Closed | existing, unchanged |
Narrow | 320px viewport — the footer fit and the 44px targets |
ReducedMotion | prefers-reduced-motion on, proving the duration-0 short-circuit |
Add ionic_frontend/src/views/SsoCallback.stories.ts (new) with one story per phase — Completing, ConsentPending, Creating, Declined, CouldntCreate — driven by a phaseargType. The five states are the deliverable here; a modal-only story cannot show four of them.
Acceptance criteria for Codi
- [ ] No new component;
data-testid="terms-agree-btn"andaria-labelledby="agree-modal-title"preserved verbatim - [ ] The modal's accept/decline contract is unchanged —
confirm()→onSuccess()+closed; Cancel / X / backdrop / Esc →closedalone - [ ] All five states render inside the existing centred wrapper, no route change between them
- [ ] State 2 stops the logo animation and replaces the "Signing you in…" copy
- [ ] State 3 exists and reads
Creating your account… - [ ] Decline renders state 4 in place — no silent
router.replace('/sign-in')survives - [ ] "Review and agree" re-runs the transfer with a fresh resolver; a failed re-run lands on state 5
- [ ] State 5 is reachable on native (
accepted && redirectTo === '/sign-in'), with the override copy - [ ] Every microcopy string matches the table verbatim
- [ ] Modal footer buttons measure ≥44px and do not wrap at 320px (assert computed dimensions)
- [ ]
.confirm-btnink is--primary-ink-foreground; both/sso-callbackCTAs use--primary-ink/--primary-ink-foreground - [ ] Modal routes through
<AppModal presentation="card">; the unit-test stub moves with it - [ ]
aria-describedby="agree-modal-text"bound, and focus lands on the dialog container — not the X button — on open - [ ] Focus after accept goes to the status container; after decline, to the state-4 heading
- [ ] Exactly one acknowledgment: the destination screen. No toast, no haptic
- [ ] Dark-mode parity verified in Storybook; axe-core clean in both themes
- [ ] The resolver is a single Promise — a second
onSuccess()cannot produce a second POST - [ ] Every transition in §Flow sequence matches the locked motion vocabulary — no invented easing, toast duration or haptic tier
Open questions
None blocking.