Skip to content

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:

  1. Scattered implementations. canvas-confetti is 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.

  2. 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.

  3. 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.

  4. 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

  1. Unify all existing celebratory moments under one system.
  2. Extend celebrations to every high-value user action without lengthening session time.
  3. Preserve the anti-social philosophy — celebrations are quick (≤2s micro, ≤4s big), skippable, and never block the user's next action.
  4. Respect accessibility preferences (system + user-level).

Success Metrics

MetricTarget (30 days post-launch)
% of UserAction events that fire a tier-appropriate celebration100% 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 length0 (guardrail)
Storybook coverage1 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-motion is 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

TierNameDurationVisualHapticUse for
1Micro300–600msButton pulse / number pop / checkmark scale-in + 20–40 particles local to the elementLightDaily check-in, mood log, small XP award
2Mid1.2–1.8sCentered card slides up + 80–120 particles + scale animation on XP/streak counterMediumStreak milestones (3/7/30/100), XP level-up, goal completed, rank promotion
3Big2.5–3.5sFull-screen confetti burst (rarity-colored) + badge/hero card + subtle screen-pulse + hapticHeavyEpic/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:

ts
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 in App.vue via 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)

KindTierTrigger
first_goal_created3First time CreateGoal interaction succeeds for a user
checkin_complete1Daily check-in submitted
streak_milestone2 (3/7 days) / 3 (30/100 days)Streak hits 3, 7, 30, 100
xp_level_up2User XP crosses a level threshold
badge_unlocked2 (common/rare) / 3 (epic/legendary)UserAction.trigger_badge fires, rarity-driven
goal_completed2Goal marked complete
rank_promoted3Rank tier advances (Open Road → Horizon)
onboarding_complete3OnboardingWizard 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

  1. 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).
  2. Settings toggle: new row in Settings.vue"Reduce celebrations" (persists to local storage + user prefs mutation for cross-device sync if updateUserPreferences exists; else local-only).
    • Off (default): full system
    • On: behaves as if reduced-motion
  3. Future (v2) Settings toggles: Sound on/off, Haptics on/off.
  4. Keyboard/screen reader: tier-3 celebrations expose an aria-live="polite" region announcing "Badge unlocked: <name>" etc. Tab-to-dismiss.
  5. 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 (in useStreakCelebration.ts)
  • Badge unlock (all rarities) → ImpactStyle.Medium (in useAchievementNotifications.ts)
  • Streak milestone (3/7/14/30+) → ImpactStyle.Medium (in useStreakCelebration.ts)
  • Goal completion → ImpactStyle.Heavy (in Goal.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)
  • 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, Tailwind animate utilities, 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

ExistingAction
useAchievementNotifications.ts rarity configMigrate palettes into constants/celebrations.ts; deprecate the composable's direct confetti calls in favor of celebrate('badge_unlocked', { rarity }).
BadgeModal.vueKeep 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.vueSubsume 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.vueReplace each with a celebrate(...) call referencing the correct kind.
useAccessibility.ts reduced-motion composableReuse 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: 200 per 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 celebration payload on mutations.
  • A/B variation testing of celebration intensity.
  • Custom per-user celebration palettes.

7. Rollout Plan

  1. Phase A — Foundation (composable + host + tier primitives + Storybook).
  2. Phase B — Migration (replace existing confetti call sites; ensure no visual regressions in BadgeModal / AchievementWatcher).
  3. Phase C — New moments (wire checkin_complete, streak_milestone, xp_level_up, goal_completed, rank_promoted, onboarding_complete, first_goal_created).
  4. Phase D — Settings & accessibility (Settings toggle + aria-live + reduced-motion verification).
  5. 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_milestone days (3/7/30/100) be a config constant or tied to existing streak model thresholds?
  • Does AchievementCelebration.vue fully collapse into BigCelebration.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_preferences JSONB column?
  • Should the legendary-rarity celebration use gold stars (stars: true in existing config) or stick to the unified particle system for consistency?

9. Success Definition

This PRD ships successfully when:

  1. Every in-scope UserAction and client-side mutation success fires a tier-appropriate celebration.
  2. useCelebration() is the only public API for celebrations; no direct canvas-confetti imports remain outside ConfettiCanvas.vue.
  3. prefers-reduced-motion + Settings toggle together gate all motion/haptics.
  4. Storybook has one story per tier and per kind, dark/light verified, reduced-motion toggle working.
  5. Performance: sustained ≥55fps on iPhone 12 during tier-3 celebration.
  6. No regression in median daily session length (guardrail).
  7. Cypress smoke covers at least one tier-1 (check-in) and one tier-3 (badge unlock) celebration path.

Last updated: 2026-05-23

Loading…