Celebration System — PRD
Product: Objectuve (codename: Enkidu) Feature: Duolingo-style celebrations & micro-animations across the app Status: Shipped — April 2026 Date: April 2026 Owner: Josh Lockhart PRD file: docs/product/celebration-system-prd.md
1. Problem Statement
Objectuve already has real gamification bones — streaks, XP, badges (Common → Legendary), ranks (Open Road → Horizon), and a rarity-tiered confetti helper in useAchievementNotifications.ts. But the celebratory layer is fragmented and under-used:
Scattered implementations.
canvas-confettiis called directly from at least six places (OnboardingWizard.vue,Dashboard.vue,Goal.vue,MeetCoach.vue,DoneCard.vue,Achievements.vue) with inconsistent parameters, durations, and visual language. Each new moment reinvents the wheel.Most worthwhile moments are silent. Daily check-ins, streak milestones, XP level-ups, rank promotions, and goal completions either fire nothing or fire a toast. These are the anchor moments of the anti-social loop — the reason users come back tomorrow. They should feel like small, delightful rewards, not system notifications.
No reduced-motion or user control. Individual confetti calls don't uniformly honor
prefers-reduced-motion. There is no Settings toggle. Users can't turn celebrations down, and accessibility is inconsistent.Anti-social app tension. Duolingo-style celebrations could easily violate the "10 min/day, then leave" philosophy if they become long, interrupting, or sound-heavy. We need a disciplined system — quick, delightful, skippable — not a party that slows users down.
This PRD defines a unified Celebration System: one composable, one host, three tiers, sensible defaults, accessible, and wired into the moments that matter.
2. Goals & Success Metrics
Primary Goals
- Unify all existing celebratory moments under one system.
- Extend celebrations to every high-value user action without lengthening session time.
- Preserve the anti-social philosophy — celebrations are quick (≤2s micro, ≤4s big), skippable, and never block the user's next action.
- Respect accessibility preferences (system + user-level).
Success Metrics
| Metric | Target (30 days post-launch) |
|---|---|
% of UserAction events that fire a tier-appropriate celebration | 100% of in-scope actions |
| Median celebration duration (micro / mid / big) | ≤600ms / ≤1.5s / ≤3.5s |
| Frame rate on mid-tier mobile (iPhone 12 / Pixel 6) during big celebration | ≥55 fps sustained |
| Users who disable celebrations in Settings | <5% (signal: over-celebrated) |
| Regression in median daily session length | 0 (guardrail) |
| Storybook coverage | 1 story per tier + per celebration kind |
Guardrails
- No celebration ever blocks input. Every tier is non-modal or auto-dismisses.
- No sound in v1. (v2 adds optional, off-by-default.)
- 60fps target on iPhone 12 / mid-tier Android. Verified via Chrome DevTools performance profile.
prefers-reduced-motionis honored automatically — particle effects replaced with static fade/scale.- Dark + light mode parity — all celebrations tested in both themes in Storybook.
- Anti-social discipline: no celebration auto-queues more than 2 pending items; excess are collapsed.
3. User Stories
- US-1: As a user completing a daily check-in, I want immediate, brief feedback (tier-1 pop) so I feel the streak tick without being interrupted.
- US-2: As a user hitting a 7-day streak, I want a mid-tier celebration (streak flame + XP pop) so the milestone feels real.
- US-3: As a user unlocking a Legendary badge, I want a big-tier celebration (full-screen particles + badge card + haptic) that feels earned.
- US-4: As a user who's sensitive to motion, I want celebrations to respect my system preference and/or let me reduce them in Settings.
- US-5: As a user on iOS/Android, I want tactile haptic feedback proportionate to the moment.
- US-6: As a developer, I want one API —
celebrate({ kind, tier, payload })— not six confetti implementations. - US-7: As a designer, I want every tier visible in Storybook with dark/light toggle and reduced-motion toggle so visual QA is fast.
4. Feature Design
4a. Three Intensity Tiers
| Tier | Name | Duration | Visual | Haptic | Use for |
|---|---|---|---|---|---|
| 1 | Micro | 300–600ms | Button pulse / number pop / checkmark scale-in + 20–40 particles local to the element | Light | Daily check-in, mood log, small XP award |
| 2 | Mid | 1.2–1.8s | Centered card slides up + 80–120 particles + scale animation on XP/streak counter | Medium | Streak milestones (3/7/30/100), XP level-up, goal completed, rank promotion |
| 3 | Big | 2.5–3.5s | Full-screen confetti burst (rarity-colored) + badge/hero card + subtle screen-pulse + haptic | Heavy | Epic/Legendary badge unlock, first-ever goal, onboarding complete |
Durations are upper bounds; the system exits the moment animations settle. Users can tap anywhere to dismiss tier-3 immediately.
4b. Central Architecture
ionic_frontend/src/
├── composables/
│ └── useCelebration.ts # Public API + queue + throttle + reduced-motion
├── components/celebration/
│ ├── CelebrationHost.vue # Teleport target, mounted once in App.vue
│ ├── MicroPulse.vue # Tier 1 primitive
│ ├── MidBurst.vue # Tier 2 primitive
│ ├── BigCelebration.vue # Tier 3 primitive (supersedes BadgeModal internals)
│ ├── ConfettiCanvas.vue # Wraps canvas-confetti with reduced-motion guard
│ └── *.stories.ts # Per-tier + per-kind coverage
└── constants/
└── celebrations.ts # Kind → tier/palette/copy map (single source of truth)Public API:
const { celebrate } = useCelebration()
celebrate('checkin_complete') // tier inferred from kind map
celebrate('badge_unlocked', { rarity: 'legendary' }) // payload refines visuals
celebrate('streak_milestone', { days: 30 })<CelebrationHost />is mounted once inApp.vuevia Teleport.- The composable exposes a reactive queue with:
- Throttle: coalesce duplicate events within 400ms.
- Cap: max 2 pending; additional events collapse into the highest-tier one.
- Reduced-motion adapter: substitute particles for a 150ms opacity+scale fade and skip haptics.
- Dismiss handle:
celebrate(...).dismiss()returns a handle for edge cases.
4c. In-scope Celebration Kinds (v1)
| Kind | Tier | Trigger |
|---|---|---|
first_goal_created | 3 | First time CreateGoal interaction succeeds for a user |
checkin_complete | 1 | Daily check-in submitted |
streak_milestone | 2 (3/7 days) / 3 (30/100 days) | Streak hits 3, 7, 30, 100 |
xp_level_up | 2 | User XP crosses a level threshold |
badge_unlocked | 2 (common/rare) / 3 (epic/legendary) | UserAction.trigger_badge fires, rarity-driven |
goal_completed | 2 | Goal marked complete |
rank_promoted | 3 | Rank tier advances (Open Road → Horizon) |
onboarding_complete | 3 | OnboardingWizard final slide confirmed |
Out of v1 (explicit): community encouragements/likes, ally invite accepted, mood-log streak, Meet Coach flow complete. These can land in v2.
4d. Accessibility & User Controls
- System preference:
prefers-reduced-motion: reduce→ all particle effects are replaced with a 150ms opacity+scale transform. Confetti is skipped entirely. Haptics still fire (they are not a motion issue). - Settings toggle: new row in
Settings.vue— "Reduce celebrations" (persists to local storage + user prefs mutation for cross-device sync ifupdateUserPreferencesexists; else local-only).- Off (default): full system
- On: behaves as if reduced-motion
- Future (v2) Settings toggles: Sound on/off, Haptics on/off.
- Keyboard/screen reader: tier-3 celebrations expose an
aria-live="polite"region announcing"Badge unlocked: <name>"etc. Tab-to-dismiss. - Dark mode: palettes defined with
dark:variants; tested per story.
4e. Haptics (Mobile)
Uses @capacitor/haptics (already installed). Wrapper in useHaptics composable (ionic_frontend/src/composables/useHaptics.ts):
- Streak tick (non-milestone) →
ImpactStyle.Light(inuseStreakCelebration.ts) - Badge unlock (all rarities) →
ImpactStyle.Medium(inuseAchievementNotifications.ts) - Streak milestone (3/7/14/30+) →
ImpactStyle.Medium(inuseStreakCelebration.ts) - Goal completion →
ImpactStyle.Heavy(inGoal.vue)
No-ops on web / reduced-motion-off-but-haptics-disabled-in-settings (v2).
4f. Visual Language
- Palette (inherits existing rarity config in
useAchievementNotifications.ts):- Common:
#3b82f6 / #60a5fa(blue) - Rare:
#8b5cf6 / #a855f7(purple) - Epic:
#f28529 / #fcc419(brand accent + gold) - Legendary:
#fbbf24 / #f59e0b / #fcd34d / #fde047(gold shower)
- Common:
- No mascot. Typographic + particles + gradient energy only. A future v2 may introduce an abstract energy-mark (brand logo pulsing) but not a character.
- Motion feel: spring-like via CSS
cubic-bezier(0.22, 1, 0.36, 1)easing (no new deps). XP/streak number counters use CSS counter animation or a small inline tween (≤30 LOC, no library).
5. Technical Approach
5a. Stack Decisions
- No new dependencies. Use what's installed:
canvas-confetti,@capacitor/haptics, Tailwindanimateutilities, Vue<Transition>. - Rationale: keeps bundle size flat, avoids Lottie asset pipeline, ships faster. If Duolingo-grade character animations are desired later, Lottie is a v2 scope.
5b. Existing Code to Fold In
| Existing | Action |
|---|---|
useAchievementNotifications.ts rarity config | Migrate palettes into constants/celebrations.ts; deprecate the composable's direct confetti calls in favor of celebrate('badge_unlocked', { rarity }). |
BadgeModal.vue | Keep the visual card; move the trigger logic into BigCelebration.vue as an internal child. BadgeModal's public API stays so AchievementWatcher.vue keeps working. |
AchievementCelebration.vue | Subsume into BigCelebration.vue or keep as a tier-3 variant. Decision during plan phase. |
Direct confetti(...) calls in OnboardingWizard.vue, Dashboard.vue, Goal.vue, MeetCoach.vue, DoneCard.vue, Achievements.vue | Replace each with a celebrate(...) call referencing the correct kind. |
useAccessibility.ts reduced-motion composable | Reuse inside useCelebration. |
5c. Backend Coupling
v1: pure client-side. Celebrations fire from the client on mutation success or watcher events. No schema changes.
v2 (explicit future work, not in this PRD): extend GraphQL mutation responses with an optional celebration: { kind, tier, payload } field so async-awarded badges (e.g., via Gamification::CreateFirstGoalActionJob) can surface a celebration when the client next reconciles.
5d. Performance
- Confetti canvas is created lazily and destroyed on completion.
- Tier-3 celebrations clamp to
particleCount: 200per the existing legendary config — verified acceptable. - Reduced-motion path avoids canvas creation entirely.
- Storybook includes a "mid-tier mobile" viewport performance story to spot regressions.
5e. Analytics (minimal)
Fire a single PostHog event celebration_shown with { kind, tier, rarity?, reduced_motion: bool }. Used only to answer: "Are the right moments firing?" No target; pure instrumentation.
6. Out of Scope (v1)
- Sound / audio layer.
- Lottie / character mascot animations.
- Community-surface celebrations (likes, encouragements, ally invites).
- Backend
celebrationpayload on mutations. - A/B variation testing of celebration intensity.
- Custom per-user celebration palettes.
7. Rollout Plan
- Phase A — Foundation (composable + host + tier primitives + Storybook).
- Phase B — Migration (replace existing confetti call sites; ensure no visual regressions in
BadgeModal/AchievementWatcher). - Phase C — New moments (wire
checkin_complete,streak_milestone,xp_level_up,goal_completed,rank_promoted,onboarding_complete,first_goal_created). - Phase D — Settings & accessibility (Settings toggle + aria-live + reduced-motion verification).
- Phase E — Analytics + QA (PostHog event + performance pass on mid-tier mobile + Cypress smoke coverage).
Each phase is GSD-plannable as its own phase.
8. Open Questions (defer to plan phase)
- Should
streak_milestonedays (3/7/30/100) be a config constant or tied to existing streak model thresholds? - Does
AchievementCelebration.vuefully collapse intoBigCelebration.vue, or do we keep it as a back-compat wrapper? - Is there a user preference model/table we can persist "Reduce celebrations" to, or do we need a new
user_preferencesJSONB column? - Should the legendary-rarity celebration use gold stars (
stars: truein existing config) or stick to the unified particle system for consistency?
9. Success Definition
This PRD ships successfully when:
- Every in-scope
UserActionand client-side mutation success fires a tier-appropriate celebration. useCelebration()is the only public API for celebrations; no directcanvas-confettiimports remain outsideConfettiCanvas.vue.prefers-reduced-motion+ Settings toggle together gate all motion/haptics.- Storybook has one story per tier and per kind, dark/light verified, reduced-motion toggle working.
- Performance: sustained ≥55fps on iPhone 12 during tier-3 celebration.
- No regression in median daily session length (guardrail).
- Cypress smoke covers at least one tier-1 (check-in) and one tier-3 (badge unlock) celebration path.
Last updated: 2026-05-23