Objectuve — Motion Vocabulary (Toast Dwell Tiers)
Decision record for toast dwell duration (how long a toast stays on screen before auto-dismissing). Companion to interaction-design.md, which covers enter/exit animation timing (how fast a toast appears/disappears) but not dwell. Consult this doc before adding a new toast call site or a new duration override — do not invent a new dwell value; if a case genuinely needs one, it's a design decision to record here, not a number to pick inline.
For the full component-level audit reference (route transitions, modals, haptics, skeletons) used by the objectuve-throughline gate runner, see .claude/skills/objectuve-throughline/references/motion-vocabulary.md. That file is the audit tool's exhaustive source of truth for gate citations; this page is the narrower, human-facing decision record for the dwell-tier ruling below.
Locked toast dwell tiers
All toasts render through useToast() (ionic_frontend/src/composables/useToast.ts). Every tier has a locked default duration — do not pass an inline duration override to ship a bespoke dwell time:
| Tier | Duration | Haptic | Source |
|---|---|---|---|
success | 2.5s | none | useToast.ts:15-21 |
error | 3s | haptics.error() | useToast.ts:23-30 |
warning | 3s | haptics.warning() | useToast.ts:32-39 |
info | 3s | none | useToast.ts:41-47 |
achievement | 5s | none (caller owns haptic ownership) | useToast.ts:49-60 |
levelUp | 5s | haptics.impactHeavy() | useToast.ts:62-73 |
Raw <ion-toast> / <IonToast> with an inline :duration prop bypasses this vocabulary entirely and should be converted to useToast() at the correct tier.
Ruling: achievement rarity does not get its own dwell tier
Decision (2026-07-09, OBJ-1230 / gate g30): RARITY_DURATIONS (common 3500ms / rare 4500ms / epic 5500ms, formerly in useAchievementNotifications.ts) is removed, not exceptioned. All achievement toasts — regardless of badge rarity — use the single locked achievement tier (5000ms, useToast.ts:49-60).
Why collapse instead of codifying the per-rarity scale:
- Rarity is already carried by three other channels: badge art, the rarity-scaled glow overlay (
OVERLAY_DURATIONSinuseAchievementNotifications.ts, 1200ms→3000ms by rarity — this stays; it's a distinct visual effect, not a toast dwell), and haptic weight. A fourth rarity-driven channel on toast dwell is redundant over-signaling the user can't perceive as a duration difference. - The old per-rarity scale was internally inconsistent with its own intent: a common badge unlock toast dismissed after 3500ms — shorter than the canonical achievement dwell (5000ms) — so the most frequent unlock got a shorter celebration than the tier promises everyone else.
- One locked value is auditable by gate g30 and can't silently drift back per-rarity.
- Legendary is unaffected by this ruling — it never used a toast; legendary unlocks route to
LegendaryCelebration.vue(a full-screen overlay, not a toast).
Fix landed: ionic_frontend/src/composables/useAchievementNotifications.ts no longer passes a duration override to toast.achievement(), so it falls through to the composable's 5000ms default. Three raw <ion-toast>/<IonToast> call sites that bypassed useToast with off-tier durations were converted to useToast() at their correct tier: EnneagramAssessment.vue (was 5000ms on an error → now error(), 3s), AllyDiscoveryPage.vue (was 3000ms → now success()/error()), and allies/InviteShareSheet.vue (was 2000ms, under tier → now success(), 2.5s). See PR #1358.
Sanctioned persistent-toast exceptions
Two surfaces intentionally opt out of the dwell tiers above by design — both are persistent (no auto-dismiss) because the action they carry shouldn't disappear before the user acts on it:
AllyRequestToast.vue(ionic_frontend/src/components/allies/AllyRequestToast.vue) — a bespoke inline component (not auseToast()/vue-sonner call), stays visible until the user accepts, declines, or the request queue advances. It never had a duration to conform to.- PWA update toast (
ionic_frontend/src/composables/usePwaUpdateToast.ts:36) — callstoast(...)directly (not throughuseToast()) withduration: Infinity. A "new version available" prompt that auto-dismissed would let a user miss it indefinitely; it stays until they refresh.
grep -rn ':duration=' ionic_frontend/src should return zero matches (neither exception uses the raw-template :duration= binding pattern this gate checks for).
Ruling: bottom sheets must route through AppModal
Decision (2026-09-12, OBJ-3062 / OBJ-3677): any bottom sheet ships through <AppModal presentation="sheet"> (ionic_frontend/src/components/ui/AppModal.vue). A raw <ion-modal presentation="bottom-sheet"> bypasses AppModal entirely and with it the reduced-motion short-circuit — a user with prefers-reduced-motion set still gets the full slide-up animation.
ionic_frontend/src/components/ui/AppModal.vue:51 maps presentation="sheet" to Ionic's bottom-sheet presentation. Lines 54 and 60 short-circuit enterAnim/leaveAnim to instantAnimation (a 0ms createAnimation(), defined line 49) whenever reducedMotion.value is set — the check runs before either computed property looks at presentation, so the reduced-motion short-circuit applies identically to the card, alert, and sheet presentations.
Full mechanics — including why sheets deliberately skip the useModalAnimations scale-fade curve that card/alert use — live in .claude/skills/objectuve-throughline/references/motion-vocabulary.md § Sheets — Ionic presentation="bottom-sheet"; this entry is the narrower ruling a call-site author needs.
Why this is written down instead of left to code review: wave 1's W1-03 and OBJ-3062 both shipped a raw <ion-modal presentation="bottom-sheet"> against this exact gap — three separate authors missed it independently because this doc never mentioned bottom sheets, reduced motion, or AppModal at all. That silence, not the composable, was the traceable cause.
VitePress surfaces — the n/a-vs-pass ruling
Decision (2026-09-11, OBJ-3627): everything above this section — and the whole component-level vocabulary in the throughline reference — is Ionic-scoped. The two VitePress surfaces, help.objectuve.com (guide_site/) and docs.objectuve.com (docs_site/), now have a documented vocabulary of their own: .claude/skills/objectuve-throughline/references/motion-vocabulary.md § VitePress surfaces — six timing tiers (Tick 150ms / State 200ms / Reveal 250–300ms / Move 350ms / Fill 400–500ms / Loop 2s), the three --guide-ease* tokens, and the reduced-motion discipline those surfaces need.
The ruling a spec author needs from this page:
Four Ionic concepts have no VitePress equivalent, and their gates are n/a, not pass. routeAnimated (g7), useModalAnimations (g10), useToast (g30), and useHaptics (g29) are structurally absent on a documentation site — there is no transition layer, no modal composable, no toast system, and no Capacitor bridge for a gate to be true of. With g29–g34, six of the seven E-category gates go the same way: no earning events, no celebrations, nothing to dedupe. g28 is the exception — exactly one acknowledgment per mutation still applies, re-read as the assistant's inline answer panel, and must not be waved through with the rest of the category.
A gate table that writes pass where it means n/a claims the surface does something right that it does not do at all. Write n/a — <why>. The absence is sometimes itself worth raising.
Two things the absence does not license. A new overlay still matches the shipped da-fade / da-pop pair rather than inventing timing, and prefers-reduced-motion still has to be handled — VitePress has no blanket reset equivalent to ionic_frontend/src/theme/tailwind.css, so each animated rule carries its own fallback. Both are specified in the reference above.
No tokens were minted for this. The tiers are named from durations already shipped. The one genuine gap — docs_site has no easing token layer, only bare ease keywords — is recorded there with its argument, because adopting --guide-ease would visibly change how two live surfaces move and that is a design decision on its own ticket.
Status surfaces — auto-dismiss dwell (open item)
Not a locked ruling. ConnectionStatusBar.vue (ionic_frontend/src/components/pwa/ConnectionStatusBar.vue, v4.58 Phase 4b, OBJ-3743) is a persistent, non-modal chrome bar that reports offline/syncing/synced/failed sync state — it does not go through useToast(), so none of the locked tiers above apply to it directly. Its one auto-dismissing state (synced, which clears the bar ~2.5s after the sync queue drains to zero) uses SYNCED_DWELL_MS = 2500, chosen by the component's own author as a fallback aligned to the success tier's 2.5s rather than a freshly invented number — the component's source comment (ConnectionStatusBar.vue:98-102) explicitly routes the real decision here and asks that it not be treated as minted by precedent.
This page doesn't rule on it: recording a new tier is a design decision made outside an audit (see "Lock statement" below), and status-bar dwell is a different surface shape from a toast (persistent chrome vs. a stacked, auto-managed overlay) that this doc has no existing tier for. Flagging the open question rather than picking a number: does a persistent status bar's "cleared" state need its own tier, or does it inherit success's 2.5s by declared convention? offline_status_bar_enabled reached 100% rollout in production at v4.58 Phase 5b (OBJ-3800), 2026-09-17 — the unlocked fallback already shipped to every real user with this question still open; it is no longer a pre-go-live decision point.
Lock statement
The tier table above is locked. Extending it — a new toast dwell duration, a new per-context override — is a deliberate design decision made outside an audit, not something to add to make a gate pass. If a flow needs a new dwell pattern, record the ruling here first. The same rule governs the VitePress tiers: a duration those surfaces don't already ship is a gap to report, not a number to pick.
Last updated: 2026-09-17 (v4.58 Phase 6, OBJ-3745) — corrected the status-surface auto-dismiss dwell question's rollout note: offline_status_bar_enabled reached 100% (Phase 5b, OBJ-3800), so the open question shipped to real users rather than being settled pre-go-live; still not a locked tier. · Previously 2026-09-14 (OBJ-3743) — recorded the open status-surface auto-dismiss dwell question (ConnectionStatusBar's synced state, currently a 2.5s fallback, not a locked tier). · Previously 2026-09-12 (OBJ-3677) — added the sheet-wrapper AppModal ruling. · Previously 2026-09-11 (OBJ-3627) — added the VitePress n/a-vs-pass ruling and the pointer to the VitePress timing tiers. · Previously 2026-07-09 (v3.13.4) — OBJ-1230, gate g30 ruling.