UI-SPEC — Native legal-consent step (OBJ-2602)
Issue: OBJ-2602 · Task package: Orion, comment 78bdba28; re-scope 39df533c · Tier: 3 (spec + HTML mockup + rendered captures + motion) Mockups: mockups/obj-2602-native-legal-consent/Status: current — the single surviving spec for this feature. Supersedes obj-2599-native-legal-consent.md as of 2026-08-18 (OBJ-2709). See §Reconciliation with OBJ-2599.
Scope
The step a native OAuth sign-up hits when Clerk's transfer comes back status: "missing_requirements" with legal_accepted outstanding. One modal, collecting agreement to the Terms of Service and Privacy Policy, then completing the sign-up against Clerk FAPI.
Covered here: the surface ruling, where it mounts, the affordance, every state, the decline path, the external-link behaviour on native, the /sso-callback backdrop this step forces to change, the flow sequence, and accessibility.
Not in scope: the FAPI completion call and the pending-consent state machine (Orion's Task 2), the sign-in / sign-up cards themselves, web (Clerk's hosted <SignUp> already collects legal_accepted and must not gain friction), and Android /sign-up, whose nativeSignUpStrategies is [] — that branch hands off to the web sign-up (OBJ-2539) and never reaches a transfer.
Reconciliation with OBJ-2599
PR #2394 (OBJ-2599 / OBJ-2613) merged to master while this spec's PR #2385 was in flight, implementing the same feature with the modal owned by SsoCallback.vue's phase state machine. Josh ruled 2026-08-18: #2385's global-overlay architecture survives, #2394 folds into it, #2394 is not reverted. Orion then diffed #2394 in full and surfaced four #2394-only UI/a11y behaviours plus a contrast question. The rulings are below; each one is binding on Codi's Task 3.
One thing that is not a finding, stated so nobody re-derives it: Capacitor Browser.open policy-link handling already exists in both modals. Not a porting gap.
Ruling A — port aria-describedby. Not a judgement call.
Port it. #2394 binds aria-describedby="agree-modal-text" at the dialog and this spec binds only aria-labelledby. That gives a screen-reader user the dialog's name and never its content — on a consent surface, the content is the entire point.
The target element differs, because the surfaces differ: #2394 pointed at a body <p> asking a question; here the description is the consent sentence inside the <label>. Bind aria-describedby to the <span> wrapping "I agree to the Terms of Service and Privacy Policy." (id="consent-modal-description"), not to the <label> — a label element as a description double-announces against the checkbox's own accessible name.
Ruling B — port the visible hint, reworded.
Port it, with different copy. #2394's hint — "Both open in your browser — come back here to finish." — solves a real problem this spec left to screen readers only: the two policy links leave the app via Browser.open, and a sighted user gets no warning before the sheet takes over. The aria-label suffix (opens in your browser) covers assistive tech and nobody else, which is backwards — the round trip is a visual surprise.
The string changes because the surface changed. #2394's modal had the user answering a question; here they are ticking a box, and "come back here to finish" understates what is preserved. Ship:
Both open in your browser. Your tick stays put.
Two short sentences, and the second is the reassurance that actually matters — the reason people hesitate to tap a link mid-form is fear of losing their place. Placement: directly under the consent row, text-sm text-muted-foreground, outside the <label> so it never becomes part of the checkbox's accessible name or its hit area. It replaces nothing; the aria-label suffixes stay.
The second sentence is a promise, so it needs a test.
agreedmust survive theBrowser.openround trip. It does today by construction — the reset only fires onopengoingfalse → true(watch(() => props.open, …)), and a system browser sheet never unmounts the modal — but that is an accident of the current watcher, not something anything asserts. Add a unit test.The one case where it is not true, stated rather than papered over: if Android kills the app while the Custom Tab is foregrounded, the pending sign-up rehydrates from
secureStorageand the modal re-presents unticked (§Flow sequence, last row). That is the cold-boot path doing its job, not a broken promise — the user returns to a clean modal, not a half-filled one. Do not try to persistagreedto storage to close this; a consent tick restored from disk is a worse record than one the user re-makes.
Ruling C — the decline surface: one path. #2394's /sso-callback re-entry is not ported.
Keep this spec's single decline path — modal leaves, notice renders on the card (iOS) or after router.replace('/sign-in') (Android). Do not port #2394's declined phase with its "Review and agree" button.
Three reasons, in order of weight:
- The re-entry affordance cannot exist under the surviving architecture without being rebuilt as something else. #2394's "Review and agree" re-ran
completeOAuthRedirect(window.location.href)from a view that still held the callback URL and owned the resolver. OnceSsoCallback.vuebecomes observe-only, neither is true — the modal is global, and the callback URL is gone from the iOS path entirely. Porting the button means inventing a new re-entry mechanism, on the one path we cannot device-verify. That is not folding #2394 in; it is new unverified work. - It only ever covered one of three paths.
/sso-callbackis Android-redirect-only. iOS Apple and iOS Google decline through the card and would need a second, differently-shaped re-entry surface — two decline surfaces for one decision. - Nothing is lost that the user needs. Declining costs the user one tap to recover: the OAuth buttons are right there, re-enabled, under a line telling them what happened. #2394's version was a full-screen state to accomplish the same thing.
The honest part, recorded rather than glossed: this spec's decline is device-verified for the in-process (iOS Apple) path only — 2026-08-18, on a real iPad. The redirect/Android decline is unverified and cannot be verified right now; Android Google native sign-in is blocked on OBJ-2528 Task 0. So this ruling picks the shape proven on the path that can be tested over the shape proven on neither. If OBJ-2528 unblocks and the Android decline turns out to strand the user, the fix is a re-spec of §Decline, not a revival of the /sso-callback phase machine.
Ruling D — nothing user-facing regresses, except one thing that would have.
Checked #2394's /sso-callback copy against this spec's collapse of that view to observe-only:
| #2394 string | Survives? |
|---|---|
One quick agreement and you're in. | Equivalent — this spec ships One quick thing first. for the same state. Keep this spec's; it doesn't promise an outcome before the network has agreed to it. |
Creating your account… (page-level) | Moves, doesn't vanish — it is the CTA's in-flight label here (§Microcopy row 4). One acknowledgment, on the control the user just pressed. |
Branched post-consent failure copy (We couldn't create your account. / Nothing was saved.) | Not ported, and not a loss — that branch existed because SsoCallback owned the resolver and knew consent had been accepted. Under the global overlay, a post-consent failure surfaces as the modal's own error row (state 4), with the tick preserved and retry available — strictly better than a terminal page that only offers "Back to sign in". |
animate="none" on the pending logo ring | Not ported, deliberate. #2394 stopped the ring because it owned the modal and the page was genuinely idle. Here the modal is global and /sso-callback keeps animate="loading" — see §/sso-callback gains a third state. |
The regression that would have shipped silently. #2394 moved both /sso-callback CTAs from bg-primary / text-primary-foreground to bg-primary-ink / text-primary-ink-foreground. This branch's SsoCallback.vue still carries the old pairing (SsoCallback.vue:27-28), which axe measured at 4.33:1 in light mode on the real render — under the AA floor, the same finding obj-2553-username-completion recorded. Collapsing #2394 by taking this branch's SsoCallback.vue verbatim un-fixes it.
Codi — this is a required change, not an optional port.
/sso-callback's failure-state "Back to sign in" button must end up onbg-primary-ink/text-primary-ink-foreground(6.19:1 light, 7.69:1 dark), notbg-primary/text-primary-foreground. Two classes, one file. Git will not flag it — master and this branch both "look right" in isolation.
Ruling E — the disarmed CTA fails the floor. Measured, and the fix is specified.
It fails, in both themes, and the fill has to become opaque. Measured in the mockup off getComputedStyle on the real buttons, composited down the live stack, and independently recomputed against the token values — the two methods agree to the second decimal:
| Treatment | Light | Dark | Verdict |
|---|---|---|---|
Armed CTA — bg-primary-ink + text-primary-ink-foreground | 6.19:1 | 7.69:1 | passes with headroom |
Disarmed CTA — bg-primary-ink/40 + text-primary-ink-foreground (what #2385 ships) | 1.96:1 | 2.30:1 | fails |
Disarmed CTA — bg-muted + text-muted-foreground (ruled) | 5.57:1 | 5.39:1 | passes |
For scale: OBJ-2599 measured #2394's gradient confirm label at 1.93:1. This is the same defect, at the same magnitude, on the surface that replaced it.
The alpha fill is the actual problem, not the ratio. bg-primary-ink/40 composites against whatever sits behind the modal, so its contrast is a property of the backdrop, not of the component. Across the backdrops this modal can legitimately open over, the same two classes measure anywhere from 1.96:1 to 4.01:1. There is no number to put in a spec, which is precisely why it was asserted as "WCAG-exempt inactive control" and never measured.
That exemption also does not hold here. WCAG 1.4.3 exempts inactive components. This button is aria-disabled="true" with no disabled attribute — deliberately, per §Accessibility, so it stays in the tab order and remains discoverable. A control kept deliberately reachable is not inactive, and axe-core skips aria-disabled nodes, so nothing was ever going to catch this automatically.
The fix, and what it costs:
- :class="armed ? 'bg-primary-ink' : 'bg-primary-ink/40'"
+ :class="armed
+ ? 'bg-primary-ink text-primary-ink-foreground border-transparent'
+ : 'bg-muted text-muted-foreground border-border'"with border border-solid moved onto the base class list. Three notes:
--mutedsits ~1.15:1 against the glass card, so the fill alone leaves the disarmed CTA reading as loose text rather than a control. Theborder-borderis what keeps it a button; the muted fill is what keeps it distinguishable from "Not now"'s transparent one. Verified in the captures, both themes.- The 150ms
transition-colorsnow carries more signal, not less. Arming animates grey → brand ink on both fill and label — a stronger, more legible state change than fading an opacity. No timing change, same curve, same vocabulary entry. - No new token.
--muted/--muted-foreground/--borderare all existing, and the pairing is already used by the close button in the superseded modal.
Ruling F — the secondary button's disabled:opacity-50 fails too. Found while measuring E.
Not on Orion's list; surfaced by the axe pass on the corrected mockup. In state 3 (submitting), "Not now" carries disabled:opacity-50 over text-muted-foreground, which measures 2.15:1 light / 2.69:1 dark. This spec's own §Accessibility rule already forbids it — "No stacked opacity on muted text anywhere in this spec" — so the spec contradicted itself.
Unlike Ruling E this button is genuinely disabled, so it is WCAG-exempt. It is still wrong: state 3 lasts a full network round-trip, and the user should be able to read that the exit exists and is temporarily unavailable.
- disabled:opacity-50
+ disabled:border-border/50 disabled:cursor-not-allowedThe label stays at full text-muted-foreground — 4.86:1 light / 7.41:1 dark. The in-flight signal already lives where it belongs: the CTA's spinner and label swap, and the consent row's opacity-60 (which is near-black text-foreground, and passes).
After E and F, the render is axe-clean: zero color-contrast violations at all three viewports in both themes. The previous "six exempt [data-disabled] nodes" note in §Accessibility is retired — there is nothing left to exempt.
The three paths this has to cover
legal_accepted gates every native path that reaches a transfer, not just the one in the issue title. Verified against origin/fix/obj-2577-google-oauth-transfer (PR #2365):
| Path | Where the user physically is when consent is needed | Code |
|---|---|---|
| iOS Google (in-process ID token) | /sign-in or /sign-up card | useClerkNative.signInWithGoogle → createSignUpWithGoogleToken |
| iOS Apple (in-process ID token) | /sign-in or /sign-up card | useClerkNative.signInWithApple → createSignUpWithAppleToken |
| Android Google (redirect) | /sso-callback | useClerkNative.completeOAuthRedirect → createSignUpFromTransfer |
That third row is what decides the mount point. See §Mount.
Component / screen inventory
| Component / screen | Status |
|---|---|
components/AgreeToTermsModal.vue | adapted — see the ruling below |
components/ui/AppModal.vue | reused unchanged (presentation="alert") |
components/NativeOAuthButtons.vue | modified — one new notice prop for the decline line |
views/SsoCallback.vue | modified — a third state while consent is pending, plus the failure CTA's ink fix (Ruling D). Its phase state machine, declined phase and branched failure copy from #2394 are removed (Rulings C, D) |
App.vue | modified — one mount, beside the existing global LegendaryCelebration |
views/SignIn.vue, views/SignUp.vue | untouched — both already render NativeOAuthButtons |
AppIcon (shield-check, alert-circle), glass-card recipe, useToast, useModalAnimations | reused unchanged |
No new component is invented, and no new token is proposed.
Ruling 1 — adapt AgreeToTermsModal.vue, don't reuse it as-is and don't replace it
Adapt. The file, its two policy links and its shield-check header idiom are correct and stay; its body, footer, dismissal contract and modal shell do not.
Reuse-as-is fails on five counts, each independently disqualifying:
- It asks a question, it doesn't record a decision. "Do you agree to the Terms of Service and Privacy Policy?" with
Cancel/Yes, I Agreeis a confirmation dialog. This modal is the only place these accounts ever accept terms — the record needs an affirmative, un-pre-ticked act. - Three ways to dismiss it, none distinguishable from a decline — an X button,
Cancel, and@didDismisson backdrop tap all emit the same bareclosed. A consent step must not be dismissible by accident, and the caller must be able to tell "declined" from "the modal closed." - No loading and no error state.
confirm()callsonSuccessand closes in the same tick. The completion call is a network round-trip that can fail; the modal has to survive it. - It hand-rolls
<ion-modal>with a bespoke<style scoped>block — nouseModalAnimations, which fires throughline g10 (bespoke per-modal timing) and duplicates the glass-card recipe a fourth time. - The prop shape is wrong for this —
openModal+ anonSuccesscallback prop instead of events, with no way to report a decline.
Replace fails too: it would throw away two verified-live policy URLs (www.objectuve.com/policies/terms and /policies/privacy, both HTTP 200 as of 2026-08-15), the header idiom, an existing Storybook story and an existing unit spec — for nothing. The component has zero call sites in src/ outside its own story, so its API is free to change.
New API (breaking, and free):
props: { open: boolean; submitting: boolean; error: string | null }
emits: { accept: []; decline: [] }decline is emitted by "Not now" and by Android hardware back — one event, so the caller has one path to clear pending state (g20, g25).
Ruling 2 — mount once in App.vue, not in NativeOAuthButtons.vue
This is a deviation from Orion's Task 3 and it is load-bearing. Orion's package mounts the step inside NativeOAuthButtons.vue because both auth views already render it. That reaches iOS and misses Android entirely.
On the Android redirect path the app-link return navigates SignIn.vue away to /sso-callback, which tears useNativeOAuth's scope down — the composable says so itself (useNativeOAuth.ts:8-11: "App.vue's appUrlOpen -> /sso-callback, which navigates SignIn/SignUp away and tears this composable's scope down"). SsoCallback.vue never renders NativeOAuthButtons. A modal mounted there is structurally unreachable at the exact moment Android needs it — which is the moment OBJ-2577 was filed about.
Two mounts (NativeOAuthButtons + SsoCallback) would work, but it is two render sites to keep in sync for one modal. One mount in App.vue, driven by the pending-consent state Task 2 owns, reaches all three paths, survives any route change mid-consent, and matches the existing global LegendaryCelebration mount at App.vue:41.
NativeOAuthButtons.vue is still modified — but for the decline notice (§Decline), not the modal.
Codi — Orion's Task 3 file list needs one correction:
App.vuein,NativeOAuthButtons.vuestays (different reason),SsoCallback.vueadded.useNativeOAuth.tsis still touched, for the loading and notice behaviour in §States.
Ruling 3 — modal, not a dedicated route
Endorsing Orion's recommendation, for one reason he didn't have: a route would have to be reachable from /sso-callback and from the two cards, survive the Android app-link round-trip as a routable state, and carry its own guard. A modal mounted globally has none of that surface. It is also the only shape that keeps the user's context — on iOS they can still see the card they tapped.
Layout
Mobile is the contract. AppModal presentation="alert" caps the width at min(380px, calc(100vw - 2.5rem)) — 350px at 390 wide, and 380px at every width above that. The modal does not grow on tablet or desktop; a single checkbox stretched to 900px is a dialog that has lost its subject.
390 × 844 (native, insets 47 / 34)
┌──────────────────────────────────┐
│ 9:41 ▮▮▮ ▮ │
│ │
│ ░░░ the card / the callback │ ← backdrop, rgb(0 0 0 / 0.32)
│ ░░░ frame, dimmed │
│ ┌────────────────────────────┐ │
│ │ ╔══╗ Before we create │ │ ← 40px gradient tile + h2
│ │ ║🛡 ║ your account │ │
│ │ ╚══╝ │ │
│ │ │ │
│ │ ☐ I agree to the Terms of │ │ ← the whole row is the hit area
│ │ Service and Privacy │ │
│ │ Policy. │ │
│ │ │ │
│ │ [ Not now ][Create my a…] │ │
│ └────────────────────────────┘ │
│ ░░░ │
│ ▂▂▂▂▂ │
└──────────────────────────────────┘- Card: glass recipe,
rounded-2xl p-6,gap-5between the three blocks (header / consent row / buttons).AppModal'salertpreset already sets--background: transparent; --box-shadow: none, so the call site owns the surface — same contract the existing modal's.modal-rootuses. - No scroll at 390×844, measured in the mockup's real scroll container: the card is ~232px centred in 844px of viewport. Nothing to scroll, at any of the four states.
- The icon tile is 40px, not the 48px the current modal uses. The title runs to two lines at 350px, and a 48px tile out-weighs the decision sitting under it.
Design tokens
Every value is an existing token. This spec proposes no new token.
| Surface | Token / class | Value (reference only) |
|---|---|---|
| Backdrop | Ionic --backdrop-opacity default | rgb(0 0 0 / 0.32) |
| Card | glass recipe — bg-card/70 backdrop-blur-xl border border-border/60 shadow-xl | dark:bg-card/40 dark:border-white/10 |
| Icon tile | bg-gradient-primary | hsl(203 78% 41%) → hsl(203 78% 51%) |
| Icon glyph | text-white | — |
| Title | text-foreground | 222 47% 11% / 210 40% 98% |
| Consent row text | text-foreground | as above |
| Policy links | text-primary-accessible font-semibold underline underline-offset-2 | 203 78% 35% / 203 78% 61% |
| Checkbox | accent-primary-ink, h-5 w-5 | 203 78% 34% / 203 78% 61% |
| Checkbox focus | focus-visible:outline-2 focus-visible:outline-ring focus-visible:outline-offset-2 | --ring |
| Primary CTA, armed | bg-primary-ink text-primary-ink-foreground border-transparent | 6.19:1 light / 7.69:1 dark — see the note below |
| Primary CTA, disarmed | bg-muted text-muted-foreground border-border | 5.57:1 light / 5.39:1 dark — Ruling E |
| Secondary ("Not now") | border border-border bg-transparent text-muted-foreground | 4.86:1 light / 7.41:1 dark |
| Secondary, disabled | disabled:border-border/50 disabled:cursor-not-allowed | label stays full-opacity — Ruling F |
| Error row | text-destructive-accessible | 0 74% 41% / 0 70% 68% |
| Decline notice | text-muted-foreground | 215 16% 40% / 215 20% 65% |
Why
--primary-inkand not--primaryfor the CTA. OBJ-2553 recorded thatbg-primary+text-primary-foregroundmeasures 4.33:1 in light mode — under the AA floor — and that the repo already carries the accessible fill (--primary-ink/--primary-ink-foreground,203 78% 34%, used byDoneSlide.vue:47and 12+ others). This is a new surface with no shared-CTA constraint, so it takes the accessible fill from the start rather than inheriting a known debt. Confirmed in the captures: the render is axe-clean in both themes, armed and disarmed.The same reasoning is now a hard requirement on
/sso-callback's failure CTA, which this branch still ships on the failingbg-primarypairing — see Ruling D.
Typography
| Element | Font | Class |
|---|---|---|
| Title | Outfit | font-display text-lg font-bold leading-tight |
| Consent row | Inter | text-sm leading-relaxed |
| Buttons | Inter | text-sm font-semibold |
| Error row | Inter | text-sm |
| Decline notice (on the card) | Inter | text-sm |
Two sizes on the surface (text-lg, text-sm); weight carries the rest. No arbitrary text-[Npx].
State catalog
Rendered light + dark at 390 / 768 / 1280 in mockups/obj-2602-native-legal-consent/screenshots/.
| # | State | Checkbox | Buttons | Extra |
|---|---|---|---|---|
| 0 | Pre-modal (tap → modal) | — | — | see below |
| 1 | Unchecked — first paint | unticked, enabled | "Not now" enabled; CTA bg-muted text-muted-foreground border-border, aria-disabled="true" | — |
| 2 | Agreed — armed | ticked | "Not now" enabled; CTA bg-primary-ink text-primary-ink-foreground | 150ms colour transition, standard curve — fill and label |
| 3 | Creating — mutation in flight | ticked, disabled, row opacity-60 | both disabled; "Not now" keeps its label at full opacity (disabled:border-border/50); CTA shows spinner + "Creating your account…", aria-busy="true" | modal not dismissible — backdrop, Esc and hardware back all blocked |
| 4 | Failed — retryable | stays ticked, enabled | both enabled; CTA back to "Create my account" | error row above the buttons, role="alert" |
| 5 | Focus — keyboard | 2px --ring outline, offset 2px | shipped focus-visible rings | tab order below |
State 0 — what the user sees between the tap and the modal. This is a real window (two FAPI round-trips) and it must not read as idle.
- iOS: the tapped button keeps its existing trailing spinner and its
Continuing with <Provider>…sr-onlylive label.nativeOAuthLoadingis not cleared when the pending-consent state is set — it clears on accept (after the route) or on decline. Clearing it first leaves the card looking idle behind an entering modal, and re-arms a button that must not be tappable (g27). - Android:
/sso-callbackis already showing its spinner; only the line changes (§SsoCallback).
On state 4, the tick survives. The user already agreed; making them re-tick is asking them to redo work they've done (g26). The failure was ours, not theirs.
Dark mode. Card dark:bg-card/40 dark:border-white/10; every *-accessible and *-ink token flips on its own. No dark-specific override is needed anywhere in this spec — verified in the dark captures.
Empty state. N/A — one decision, no list.
Microcopy
Every string, verbatim. No placeholders.
| # | Surface | String | Tone check |
|---|---|---|---|
| 1 | Modal title | Before we create your account | discloses what is about to happen ✅, no finality claim ✅ |
| 2 | Consent row | I agree to the Terms of Service and Privacy Policy . | first person — the user's statement, not ours ✅ |
| 2b | Link hint, under the consent row (new — Ruling B) | Both open in your browser. Your tick stays put. | sets the round-trip expectation ✅, answers the real hesitation ✅ |
| 3 | Primary CTA | Create my account | action verb ✅, names the outcome ✅ |
| 4 | Primary CTA, in flight | Creating your account… | honest about what is running ✅ |
| 5 | Secondary | Not now | honest exit, no guilt, not "Cancel" ✅ |
| 6 | Error | Couldn't finish. Check your connection and try again. | what failed · recovery ✅ |
| 7 | Decline notice (on the card) | No account created. You can start again anytime. | states the consequence, doesn't scold ✅ |
| 8 | /sso-callback, consent pending | One quick thing first. | honest about who is being waited on ✅ |
| 9 | Success toast | Account created | <noun> <past-tense verb> ✅ |
Why the title is not "One last thing." A native Apple or Google sign-up can hit this modal and then OBJ-2553's mandatory username gate, which opens with the eyebrow
ONE LAST THING. Two consecutive screens both claiming finality makes the first one a lie the moment the second appears (g35). "Before we create your account" states position without claiming to be the end.
There is deliberately no "why we ask" line. OBJ-2553 needs one because a username requirement is not self-evident. Everyone knows what agreeing to terms before an account is created is for; a sentence explaining it would be filler on a surface whose whole job is to be read.
Policy links on native — reuse SideMenu.vue's opener, don't use target="_blank"
The current modal's links are bare <a target="_blank">. Inside a Capacitor WebView that can open a chromeless in-app window with no way back — the exact shape of the App Store Guideline 2.1(a) rejection on build 4.2.0, and a dead end mid-consent (g38, blocking).
Use the pattern already shipped at SideMenu.vue:325-334:
- Web: let the native
<a target="_blank" rel="noopener noreferrer">behaviour handle it. - Native:
event.preventDefault()thenBrowser.open({ url })— a dismissible system browser sheet that returns the user to the modal with its state intact. - Accessible name carries the same suffix that file uses:
opens in your browser.
Both anchors also need @click.stop. They sit inside the checkbox's <label>, so without it a tap on "Privacy Policy" toggles the box. This is the one real hazard of putting the links in the label — and it is worth it, because the alternative is naming both documents twice in a three-line modal. @keydown.enter.stop too, for the keyboard path.
Decline
Declining is a choice, not a failure. No error styling, no toast, no confirm-are-you-sure (g51).
- "Not now" and Android hardware back both emit
decline— one path. - Pending consent is cleared, the sign-up id is dropped from
secureStorage,signedInnever flips, and no account exists. - iOS: the modal leaves,
nativeOAuthLoadingclears, the OAuth buttons re-enable. - Android:
router.replace('/sign-in')—replace, so/sso-callbackleaves no history entry to back into (g20, g43). - Both then show the notice below.
The notice is not optional. Without it the user taps Google, sees a modal, declines, lands on an unchanged card, and has no idea why nothing happened — then taps again and gets the same modal. That is a loop with no signal (g38).
- One new
noticeprop onNativeOAuthButtons.vue, rendering the same<p>shape as its existingerrorslot, one slot above it:text-sm text-muted-foreground text-center mt-3. - Not the
errorslot — that isrole="alert"intext-destructive-accessible, and a decline must not borrow failure styling. - Not
SignIn.vue's session-expired card shell either — abg-cardblock nested inside thecard/0.7glass card is a card-in-card, and this line belongs to the two buttons above it, not to the screen. - Clears on the next OAuth tap, on the line where
nativeOAuthError.value = ''already runs inhandleNativeOAuth. One line, no new lifecycle. - It must survive the Android
/sso-callback→/sign-inroute change, so the flag lives at module scope inuseClerkNative.ts, alongside theclientToken/pendingSignInIdmodule state already there — not inuseNativeOAuth's per-instance refs, which are destroyed by that navigation.
/sso-callback gains a third state
SsoCallback.vue has two states today: "Signing you in…" and the failure card. Neither is true while consent is pending — no one is being signed in, no account exists yet, and the app is waiting on a person, not on the network.
| Condition | Line | Logo |
|---|---|---|
| default (unchanged) | Signing you in… | animate="loading" |
| consent pending (new) | One quick thing first. | animate="loading", unchanged — something genuinely is in flight the moment they agree |
| failed (unchanged) | the existing failure card | — |
The wrapper's existing aria-live="polite" announces the swap. No role="alert" — same reasoning already recorded in that file for the failure state.
Flow sequence
The journey: native OAuth tap → transfer → consent → completion → destination.
| From | Trigger | To | Transition + duration | Feedback | Reduced-motion | Back-nav |
|---|---|---|---|---|---|---|
/sign-in or /sign-up (iOS) | tap Google / Apple | system auth sheet | OS-owned | button spinner + Continuing with <Provider>… (sr-only) | spinner motion-reduce:hidden, label carries it | sheet cancel → silent reset (existing CANCEL_CODES) |
| system sheet (iOS) | missing_requirements | consent modal | useModalAnimations enter — 250ms, cubic-bezier(0.32,0.72,0,1) | button spinner stays up until the modal is open | AppModal short-circuits to duration-0 | n/a |
/sign-in (Android) | tap Google | Custom Tab → app link → /sso-callback | routeAnimated | /sso-callback spinner | global reset | existing |
/sso-callback | missing_requirements | consent modal over it | 250ms, as above | backdrop line → One quick thing first. | duration-0 | n/a |
| consent modal | tick the box | state 2 | 150ms colour, cubic-bezier(0.4,0,0.2,1) | CTA arms — fill and label (Ruling E) | 0.01ms (global reset); the state still changes | n/a |
| consent modal | tap a policy link | system browser sheet, modal stays mounted behind it | OS-owned (Browser.open) | hint set the expectation before the tap (Ruling B) | n/a — no app-owned animation | sheet dismiss returns to the modal with agreed intact |
| consent modal | tap Create my account | state 3 | none | CTA spinner + Creating your account…, aria-busy | spinner suppressed, label carries it | blocked — not dismissible in flight |
| state 3 | completion resolves complete | redirectUrl (/dashboard default) | modal leave 250ms, then router.replace, routeAnimated | toast success, 2.5s, Account created — no haptic | duration-0 / global reset | destination's own; the modal is not a route |
| state 3 | completion rejects | state 4 | 150ms opacity on the error row | inline error role="alert", CTA re-armed, tick preserved | 0.01ms | n/a |
| consent modal | tap Not now / hardware back | card (iOS) · /sign-in (Android) | 250ms leave; Android router.replace, routeAnimated | decline notice on the card | duration-0 / global reset | /sso-callback not in history |
| consent modal | app killed, relaunched | consent modal | — | pending sign-up id rehydrated from secureStorage | — | clean re-present, no half-created account (g21) |
g21 caveat (OBJ-2660, accepted limitation): the guarantee above assumes a single in-flight sign-up. If the app is killed mid-second-account sign-up — the user was already signed in to one account and started a native sign-up for a different one — relaunch rehydrates the pending id with no way to distinguish it from a genuinely stale OBJ-2602 key. The fail-closed provenance rule (see Clerk Frontend API gotchas § The legal-consent suppression watcher only fires on a rehydrated pending id) treats it as anomalous and sweeps it — the modal does not re-present for that specific case. The original single-sign-up guarantee this row describes is unaffected; only the multi-account app-kill edge case narrows.
Destination is unchanged by this spec. Accept routes to the same redirectUrl a completed native sign-up already uses; the onboarding guard owns any /welcome hop. No interstitial, no upsell (g48).
Navigation happens after Ionic's page transition settles — SsoCallback.vue already has waitForViewEntered() for exactly this, and the accept path must go through it too (CLAUDE.md gotcha: router.replace the instant an async call resolves can freeze the outlet with no console error).
Motion
Everything below is already in the locked vocabulary; nothing new is minted.
| Moment | Motion | Source |
|---|---|---|
| Modal enter / leave | 250ms, cubic-bezier(0.32,0.72,0,1), scale 0.9→1 + translateY 10→0 | useModalAnimations.ts via <AppModal presentation="alert"> (g10) |
| Checkbox arms the CTA | 150ms background-color, cubic-bezier(0.4,0,0.2,1) | DESIGN.md Motion — standard curve, small state change resolves fast (g12) |
| Error row appearing | 150ms opacity, same curve | as above |
| Route out on accept | routeAnimated — animated mobile, instant desktop | App.vue:26,160 (g7) |
| Success toast | useToast.success — 2.5s | useToast.ts:15-21 (g30) |
Haptics: none — and that is a recorded gap, not a ruling I invented around. useToast.success fires no haptic, and motion-vocabulary.md maps notification tiers to "confirmed outcomes" without naming a tier for account creation. Adding a call-site haptics.success() here would be minting a tier assignment the vocabulary does not make. Same call OBJ-2553 made for a profile save, for the same reason. Recorded in the handoff for Maggie as a vocabulary gap; if the vocabulary later assigns a tier to first-session outcomes, this is the call site.
Reduced motion. AppModal short-circuits both animations to duration-0 under prefers-reduced-motion (Ionic's Web Animations API modal transitions escape App.vue:313-322's global CSS reset — the wrapper exists for this). The requirement is that the state still changes: the CTA fill, the error row and the spinner-vs-label are all class/attribute driven, never carried by a transform alone. The flow stays completable (g13).
Accessibility
- Dialog semantics.
role="dialog",aria-modal="true"(Ionic),aria-labelledby→ the title'sid,aria-describedby→ the consent sentence'sid(consent-modal-description, on the<span>inside the label — not the<label>itself; Ruling A). 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 trap ision-modal's. - Focus on present. Programmatic focus to the title (
<h2 tabindex="-1">) ondidPresent, so the reason the modal exists is announced before the control. Not the checkbox — landing on an unticked box with no context read first is how a consent record becomes a reflex. - Tab order. title → checkbox → Terms of Service → Privacy Policy → Not now → Create my account.
- Checkbox labelling. A real
<label for>wrapping the sentence — the whole row is both the accessible name and the hit area. The two anchors carryopens in your browserin their accessible name and@click.stopso they never toggle the box. - Disabled CTA.
aria-disabled="true"rather than thedisabledattribute, so it stays in the tab order and a screen-reader user can find out what the unmet requirement is. The consent row above it already states the rule, so the button never has to explain itself. - In flight.
aria-busy="true"on the CTA; the visible label swap to "Creating your account…" carries it for sighted users, the spinner isaria-hidden. - Error.
role="alert"so a failed completion interrupts. It is the onlyrole="alert"in this spec — the decline notice deliberately is not one. - Tap targets. Consent row
min-h-11(44px) including padding; both buttonsmin-h-11. Assert computed dimensions, not class names (tests/unit/helpers/tapTarget.ts). - Contrast. Every pairing clears AA in both themes, verified in the captures: the render is axe-clean — zero
color-contrastviolations at 390 / 768 / 1280 in light and dark. No exemption is claimed anywhere in this spec, and that is deliberate. The earlier version of this section claimed six[data-disabled]nodes were WCAG-exempt inactive controls; Ruling E shows that argument did not hold for the disarmed CTA (it isaria-disabled, notdisabled, so it stays interactive by design) and Ruling F fixed the one case where it did hold but the result was still unreadable. Do not reintroduce an alpha fill on any control in this modal — its ratio becomes a property of the backdrop, which is unspecifiable and untestable. - Links carry a non-colour signal.
underline underline-offset-2— inside a label whose surrounding text is alsotext-foreground, colour alone would be the only affordance (WCAG 1.4.1). This is a justified delta from the current modal, which underlines on hover only. - No stacked opacity on muted text anywhere in this spec.
Storybook story brief
Path: ionic_frontend/src/components/AgreeToTermsModal.stories.ts (exists — rewrite for the new API).
| Story | State |
|---|---|
Unchecked | state 1 |
Agreed | state 2 |
Creating | state 3 |
Failed | state 4 — error set |
argTypes: open (boolean), submitting (boolean), error (text), plus accept / decline actions. Add a NativeOAuthButtons story pair (WithDeclineNotice / WithError) — the two slots must be visibly distinguishable, and a modal-only story cannot show that.
Unchecked and Agreed are the pair that proves Ruling E: side by side in the Storybook dark-mode toggle, the disarmed and armed CTAs must both be legible and obviously different. If the disarmed one reads as loose text rather than a button, the border is missing.
Do not port #2394's SsoCallback.stories.ts phase stories (ConsentPending, Declined, CouldntCreate) — those phases no longer exist (Rulings C, D). Any assertion they carried that still applies belongs on the modal's own stories/spec, per Orion's "rewritten, not deleted" rule.
The existing unit spec (tests/unit/components/AgreeToTermsModal.spec.ts) is rewritten alongside: its cancel-btn / terms-agree-btn / Close Terms & Privacy assertions all target elements this spec removes.
Acceptance criteria for Codi
- [ ] Renders at 375px and 390px width without horizontal scroll, and without scrolling the modal
- [ ] The consent step is reachable on all three native paths — iOS Google, iOS Apple, Android Google — from one mount, verified on device
- [ ] Checkbox ships unticked; the CTA is not armed until it is ticked
- [ ] Disarmed CTA is
bg-muted text-muted-foreground border-border— opaque, never an alpha fill (Ruling E). Armed isbg-primary-ink text-primary-ink-foreground border-transparent - [ ] "Not now" keeps a full-opacity label while
submitting—disabled:border-border/50, notdisabled:opacity-50(Ruling F) - [ ]
aria-describedbybound to the consent sentence (consent-modal-description), landing on the element that carriesrole="dialog"(Ruling A) - [ ] The link hint
Both open in your browser. Your tick stays put.renders under the consent row, outside the<label>(Ruling B) — with a unit test asserting it is not part of the checkbox's accessible name - [ ] A ticked box survives a policy-link round trip — unit test asserting
agreedis unchanged afterBrowser.openreturns, because the hint copy promises exactly that (Ruling B) - [ ]
/sso-callback's failure CTA isbg-primary-ink/text-primary-ink-foreground, notbg-primary/text-primary-foreground— do not let the collapse un-fix #2394's correction (Ruling D) - [ ] No
/sso-callbackdeclinedphase and no "Review and agree" button (Ruling C) — decline has exactly one path - [ ]
axe-corereports zerocolor-contrastviolations in both themes; no WCAG exemption is claimed for any control in this modal - [ ] Every microcopy string matches the table verbatim
- [ ] Policy links use the
SideMenu.vue:325-334opener on native (Browser.open), never a baretarget="_blank", and carry@click.stopso they don't toggle the checkbox — with a unit test asserting the box does not toggle on a link tap - [ ] Modal is not dismissible by backdrop, Esc or hardware back while
submitting; outside that, hardware back emitsdeclineand is indistinguishable from "Not now" - [ ] Decline: no account, no session, no toast, no error styling — the muted
noticeline renders on the card and clears on the next OAuth tap - [ ] The decline flag survives the Android
/sso-callback→/sign-innavigation (module scope, not auseNativeOAuthper-instance ref) - [ ]
nativeOAuthLoadingis not cleared while consent is pending — including by theRESUME_GRACE_MSresume timer, which must be verified on an iOS device (the Google SDK sheet dismissing may fire aresume; if it does, the timer must skip while pending) - [ ]
RESUME_GRACE_MS's existing clearing behaviour (useNativeOAuth.ts:103-112) is not otherwise regressed - [ ] Failure leaves the tick in place and the CTA re-armed; retry works without re-ticking
- [ ] Exactly one acknowledgment on success: the 2.5s
successtoast. No inline confirmation, no haptic - [ ] Accept routes via
router.replaceafter the Ionic page transition settles (waitForViewEntered()on the/sso-callbackpath) - [ ]
/sso-callbackshowsOne quick thing first.while consent is pending - [ ]
AgreeToTermsModalroutes through<AppModal presentation="alert">— no hand-rolled<ion-modal>, no bespoke enter/leave timing - [ ] Tap targets ≥44px asserted by computed dimensions, not class names
- [ ] Dark-mode parity verified in Storybook; both themes captured
- [ ] Storybook stories cover all four modal variants plus the
NativeOAuthButtonsnotice/error pair - [ ] Every transition in §Flow sequence matches the locked motion/feedback vocabulary — no invented easing, toast duration or haptic tier
- [ ] The flagged deviation (mount in
App.vue, notNativeOAuthButtons.vue) is implemented as specified or bounced back with a decision — not silently reverted to Orion's file list
Open questions
BLOCKER (copy only — does not block implementation starting): Orion's §2 compliance question is still unanswered. This spec answers it the conservative way — an unticked checkbox rather than a single "I agree" button — because a pre-checked or button-only clickwrap is the weaker record, and downgrading a checkbox to clickwrap later is a two-line change while the reverse is a re-spec. Every string in §Microcopy is final and shippable as written; if the compliance owner rules that a single affirmative button satisfies the intent, rows 2 and 3 merge and the box comes out.
Nothing else is open. The one deviation from Orion's package (Ruling 2, the mount point) is a structural correction with the code cited, not a judgement call.