Objectuve — Interaction Design Reference
A living reference for all user interaction patterns in the Enkidu app. Consult this document when building or updating any interactive component. It is a companion to desi-design-context.md (tokens, spacing, typography) and docs/brand/brand.md (voice and philosophy).
Source of truth for motion tokens: DESIGN.md (the motion: section). Source of truth for component tokens: design_system/tailwind-tokens.cjs.
Quick-reference table
| Pattern | Duration | Easing | Tailwind utility |
|---|---|---|---|
| Hover color / background | 150ms | ease-in (cubic-bezier(0.4,0,1,1)) | transition-colors duration-150 |
| Hover shadow / elevation | 300ms | standard (cubic-bezier(0.4,0,0.2,1)) | transition-shadow duration-300 |
| Card lift on hover | 300ms | standard | transition-transform duration-300 |
| Button press | 150ms | ease-in | transition-transform duration-150 |
| Focus ring | instant | — | focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2 |
| Tooltip appear/disappear | 200ms | ease-out / ease-in | CSS transition: opacity 200ms |
| Modal / sheet enter | 400–500ms | standard | scale-in + fade-in |
| Modal / sheet exit | 200ms | ease-in | opacity fade |
| Page enter | 400ms | standard | slide-up or fade-in |
| Toast enter | 300ms | standard | slide-up + fade |
| Toast exit | 200ms | ease-in | opacity fade |
| Gamification pop (XP, badge) | 400ms | bounce (cubic-bezier(0.34,1.56,0.64,1)) | animate-point-pop |
| Skeleton shimmer | 2s | linear | animate-shimmer |
| Streak fire pulse | 2s | ease-in-out | animate-pulse-glow |
Toast dwell duration (how long a toast stays on screen before auto-dismissing) is a separate, locked vocabulary — see
motion-vocabulary.mdfor the tier table and the achievement-rarity dwell ruling. The rows above only cover toast enter/exit animation timing, not dwell.
1. Hover states
1.1 Buttons
Every button uses a three-property transition:
transition: background-color 200ms cubic-bezier(0.4,0,1,1),
transform 150ms cubic-bezier(0.4,0,1,1),
box-shadow 300ms cubic-bezier(0.4,0,0.2,1);Primary button (bg-primary text-primary-foreground)
| State | Background | Shadow |
|---|---|---|
| Default | bg-primary | none |
| Hover | bg-primary-light | shadow-glow (brand blue halo) |
| Active / pressed | bg-primary-interactive, translateY(1px) | none |
| Focus | unchanged + ring-2 ring-primary ring-offset-2 | — |
| Disabled | opacity-50, pointer-events-none | — |
The blue glow (shadow-glow = 0 0 40px hsla(203,78%,61%,0.30)) renders only on hover and fades on press — it signals "ready" not "active."
Secondary / ghost buttons follow the same timing but use muted surface colors: hover:bg-muted for ghost, hover:bg-secondary/80 for secondary. No glow shadow.
Destructive button — hover:bg-destructive/90. Never carries a glow shadow.
Touch note: On touch devices (no pointer: fine) hover states do not fire. Buttons must have sufficient visual weight at rest. Never make the rest state invisible and rely on hover to communicate affordance.
1.2 Cards
Cards lift on hover. This is the primary elevation vocabulary — cards do not glow or change color.
transition: transform 300ms cubic-bezier(0.4,0,0.2,1),
box-shadow 300ms cubic-bezier(0.4,0,0.2,1);| State | Transform | Shadow |
|---|---|---|
| Default | none | shadow-sm |
| Hover | translateY(-4px) | shadow-xl |
| Active / pressed | translateY(-2px) | shadow-md |
The 4 px lift is deliberate — enough to signal interactivity, not so much it looks cartoonish. Cards that are not interactive should not receive the lift. Add cursor-pointer and the transition only on cards with an @click handler.
Glass cards follow the same transform/shadow pattern. They also gain a subtle brightness overlay on hover: add a pseudo-element or overlay div with bg-white/5 that transitions from opacity-0 to opacity-100 in 200ms.
1.3 Navigation items (bottom tab bar, sidebar)
Navigation items do not lift. They shift color only:
transition: color 150ms ease-in, background-color 150ms ease-in;| State | Icon + label color | Background |
|---|---|---|
| Default | text-muted-foreground | transparent |
| Hover (desktop) | text-foreground | bg-muted/50 |
| Active (current route) | text-primary-interactive | bg-primary/10 |
| Focus | unchanged + ring-2 ring-primary ring-offset-2 | — |
Active state uses --primary-interactive (hsl(203,78%,35%) in light mode) — 6 points darker than canonical primary — to clear WCAG AA on the composite background. Do not use text-primary here; it fails contrast on the bg-primary/10 tint.
1.4 Icon buttons and FABs
Icon buttons (close, back, icon-only actions) use the same color transition as navigation items. They must be at least 44 × 44 px and carry an aria-label.
FABs (primary and coach variants) get the full glow treatment on hover, same as the primary button — shadow-glow for the primary FAB, shadow-coach for the coach FAB.
1.5 Inline links and text actions
transition: color 150ms ease-in;| State | Color |
|---|---|
| Default | text-primary |
| Hover | text-primary-light |
| Active | text-primary-interactive |
Never add text-decoration on hover for navigation links inside the app. Underlines belong in long-form content, not interactive chrome.
1.6 List rows and feed items
Goal rows, feed rows, settings rows — subtle background change only. No lift.
transition: background-color 150ms ease-in;| State | Background |
|---|---|
| Default | transparent |
| Hover | bg-muted/50 |
| Active / pressed | bg-muted |
2. Tooltips
2.1 When to use
Use tooltips for:
- Icon-only controls where the label cannot always be shown (e.g., a toolbar with five unlabeled icons).
- Supplementary detail that would clutter the primary layout if always visible (e.g., a stat label that expands with a precise definition).
- Keyboard shortcut hints for power-user paths.
Do not use tooltips for:
- Primary actions — the label must be visible at rest. Never hide a CTA label in a tooltip.
- Error messages — use inline validation copy, not a tooltip.
- Long explanatory copy — if the tooltip needs more than one sentence, use a bottom sheet or an info modal instead.
2.2 Appearance and timing
Tooltips use the UiTooltip.vue component (ionic_frontend/src/components/ui/tooltip/UiTooltip.vue). Do not re-implement tooltip behavior inline.
| Property | Value |
|---|---|
| Enter delay | 400ms — prevents tooltips flashing on fast cursor passes |
| Enter animation | opacity 0 → 1 over 200ms ease-out |
| Exit animation | opacity 1 → 0 over 200ms ease |
| Background | hsl(var(--popover)) |
| Text | hsl(var(--popover-foreground)), text-xs |
| Border radius | rounded-md (0.875rem) |
| Padding | py-2 px-3 |
| Shadow | shadow-lg |
| Max width | 200px — overflow wraps |
| Z-index | z-index: 1000 |
| Pointer events | none — tooltips are inert overlays |
The 400 ms hover delay is required. Triggering a tooltip immediately feels noisy and makes hover feel fragile. Adjust only if the context is a touch-only device (where the concept of "hover delay" does not apply).
2.3 Placement
Default placement is top. Preference order if top would be clipped: bottom → right → left.
The tooltip arrow (8 × 8 px diamond, rotate(45deg)) centers on the trigger element and adjusts with placement. Gap between trigger and tooltip bubble is 8px (the translate(calc(100% + 8px)) offset in UiTooltip.vue).
Viewport clipping rule: If a tooltip in its default position would render partially off-screen, flip to the opposite side. This is not yet automated — check manually at mobile widths for tooltips on edge-positioned controls.
2.4 Accessibility
- The trigger element must have
aria-describedby="<tooltip-id>"so screen readers announce the tooltip content. UiTooltip.vueauto-generates theidand setsrole="tooltip"on the bubble.- Tooltips must be keyboard-discoverable: they show on
focusand hide onblur, same as mouse. - On touch devices, tooltips activate on
touchstartand dismiss ontouchend. If the content is critical, consider an alternative disclosure pattern (an info icon that opens a sheet) since there is no hover on touch.
2.5 Dark mode
UiTooltip.vue uses hsl(var(--popover)) which automatically resolves to the correct dark-mode surface. No additional classes needed. Verify contrast: text-popover-foreground on bg-popover should meet WCAG AA in both modes.
3. Animations
3.1 Core principle
Every animation must map to a state change. Motion that runs without a corresponding state transition (decorative spin, idle bounce on a non-interactive element) violates the brand's anti-engagement-loop rule. Motion earns its place by making the app feel more responsive, not more alive.
The exception: looping motion on brand marks and marketing illustrations (the floating arc, the streak fire) is decorative but contained — it should never appear inside the main dashboard chrome.
3.2 Duration scale
| Name | Duration | When to use |
|---|---|---|
instant | 100ms | Immediate state acknowledgement (press, selection) |
fast | 150ms | Micro-interactions: hover color, press transform |
medium | 200ms | Accordion, color/shadow transitions, tooltip enter |
default | 300ms | Card hover, most UI transitions, toast enter |
slow | 400ms | Scale-in, page enter, modal appear |
slower | 500ms | Slide-in, bottom sheet open |
Nothing in the main app UI should take longer than 500ms. If something feels slow at 500ms, the design needs a different reveal pattern — not a longer animation.
3.3 Easing curves
| Name | Curve | When to use |
|---|---|---|
standard | cubic-bezier(0.4, 0, 0.2, 1) | Default for enter/exit, page transitions, most movements |
ease-in | cubic-bezier(0.4, 0, 1, 1) | Elements leaving the screen; hover color |
ease-out | cubic-bezier(0, 0, 0.2, 1) | Elements entering; tooltip appear |
ease-in-out | cubic-bezier(0.4, 0, 0.6, 1) | Looping animations (streak fire, float) |
bounce | cubic-bezier(0.34, 1.56, 0.64, 1) | Achievement pops, gold progress dot — the overshoot is the signal of "earned" |
linear | linear | Progress bars, shimmer, continuous loading |
The bounce curve is reserved exclusively for gamification moments. Using it on ordinary UI interactions will dilute the signal. If it doesn't say "you earned something," it should not bounce.
3.4 Named keyframes and when to use them
fade-in — opacity: 0 → 1 over 300ms ease-in. Use for: any element appearing on the canvas where positioning is already final. Tooltips, inline hints, score labels fading in after data loads.
scale-in — transform: scale(0.9) opacity: 0 → scale(1) opacity: 1 over 400ms standard. Use for: modals and dialogs appearing. The gentle scale-in communicates "this is a new layer appearing above the current one." Do not use for cards in a list — staggered fade-in reads better for list entries.
slide-up — translateY(20px) opacity: 0 → translateY(0) opacity: 1 over 500ms standard. Use for: bottom sheets, bottom tab transitions, content that arrives from below.
slide-in-right — translateX(100%) opacity: 0 → translateX(0) opacity: 1 over 500ms standard. Use for: page-level navigation to a detail view (Goal Detail, Community Detail).
page-enter — opacity: 0, translateX(20px) → opacity: 1, translateX(0) over 400ms standard. Use for: all route transitions where no specific directional slide is warranted. A subtle 20 px slide communicates forward momentum without being distracting.
pulse-glow — drop-shadow oscillates between 8px and 20px on the accent/streak color over 2s ease-in-out infinite. Use for: the streak fire icon when the user has an active streak. Also for attention anchors (e.g., a "you have an unread Coach insight" indicator). Never on ordinary CTAs.
float — vertical bob ±10px over 3s ease-in-out infinite. Use for: brand mark illustrations in onboarding and empty states, not in app chrome.
shimmer — 200% wide linear gradient translates across the element over 2s linear infinite. Use for: skeleton loaders only. A skeleton should look like content on the way in, not a loading indicator.
confetti — elements translate up 100px, rotate 720°, fade out over 1s ease-out. Use for: rank-up celebration. Full-screen burst, auto-dismisses. Never loops.
point-pop — radius grows from 0 with cubic-bezier(0.34,1.56,0.64,1) overshoot, fades in over 400ms. Use for: the gold progress dot appearing on a milestone ring when it unlocks. The bouncy overshoot is the "earned" signal.
bounce-subtle — vertical bounce ±8px over 1s ease-in-out infinite. Use for: an attention anchor that needs more urgency than pulse-glow — e.g., a "streak at risk" badge. Looping, so use sparingly.
spin — 360° continuous rotation over 1s linear infinite. Use for: loading spinners and button loading states. Never use spin without a visible loading label or aria-live region update.
3.5 Reduced-motion rule
Always honor prefers-reduced-motion: reduce. Replace the following with a simple opacity fade (200ms ease):
- All
transform-based keyframes (scale-in,slide-up,slide-in-right,page-enter,point-pop,bounce-subtle,float) - All looping animations (
pulse-glow,float,bounce-subtle,shimmer)
Opacity fades are acceptable under reduced-motion because they convey the state change without triggering vestibular discomfort. Do not remove all visual feedback — just remove the motion.
Tailwind pattern:
<div class="transition-opacity duration-200 motion-reduce:transform-none motion-reduce:transition-none">For custom keyframes, wrap them in a media query:
@media (prefers-reduced-motion: no-preference) {
.animate-point-pop { animation: point-pop 400ms cubic-bezier(0.34,1.56,0.64,1); }
}
@media (prefers-reduced-motion: reduce) {
.animate-point-pop { animation: fade-in 200ms ease; }
}3.6 Staggering list animations
When a list of cards appears simultaneously (e.g., the goal list loading after a skeleton), stagger their fade-in by 50ms per item up to a maximum of 200ms total offset (i.e., 5 items). Beyond 5 items, hold the 200 ms offset for all remaining items — the user should not wait for item 12 to animate in.
.goal-card:nth-child(1) { animation-delay: 0ms; }
.goal-card:nth-child(2) { animation-delay: 50ms; }
.goal-card:nth-child(3) { animation-delay: 100ms; }
.goal-card:nth-child(4) { animation-delay: 150ms; }
/* 5th item and beyond */ .goal-card:nth-child(n+5) { animation-delay: 200ms; }4. Modal and overlay interactions
4.1 Types of overlay in the app
| Type | Mobile behavior | Desktop behavior |
|---|---|---|
| Dialog | Bottom sheet (slides up from screen bottom) | Centered overlay, max min(600px, 95vw) |
| Sheet | Full or half-screen slide-up panel | Can also be a side drawer |
| Popover | Not used on mobile — use a sheet | Positioned relative to trigger |
| Alert | Centered dialog regardless of platform | Same |
4.2 Opening behavior
Enter animation — dialog/sheet:
- Backdrop fades in:
opacity: 0 → 0.45over300ms ease-out. - Backdrop blur:
backdrop-blur(4px)activates simultaneously with the opacity fade. - Dialog surface:
scale-in + fade-inover400ms standardfor centered dialogs;slide-upover500ms standardfor bottom sheets.
The scale-in communicates layer hierarchy — the user understands a new layer has appeared. Do not skip it.
Scroll lock: When a modal opens, overflow: hidden must be set on <body> to prevent background scroll. Restore on close.
Initial focus: Move focus to the first interactive element inside the modal immediately on open. If the modal has a close button at the top, focus goes there first (it is the escape hatch). If the modal has a primary action, focus the first input in a form flow instead.
Use useFocusTrap (available at ionic_frontend/src/composables/useAccessibility.ts) to trap focus inside the modal for the duration it is open. See BadgeModal.vue for the reference implementation.
4.3 Closing behavior
Exit animation:
- Dialog surface fades out only:
opacity: 1 → 0over200ms ease-in. No reverse scale animation — exits should be faster than entrances. - Backdrop fades out:
opacity: 0.45 → 0over200ms ease-in.
Do not play a slide-down or scale-out on close. Reverse animations on exit feel sluggish.
Return focus: On close, return focus to the element that triggered the modal open. Track the triggering element with a ref before activate() is called. useFocusTrap's deactivate() always returns focus to the element that triggered open.
4.4 Backdrop interaction
Clicking or tapping the backdrop should close the modal — with one exception:
| Modal type | Backdrop click closes? |
|---|---|
| Informational (Badge details, Changelog) | Yes |
| Form (Create goal, Edit profile) | No — unsaved data requires explicit dismiss |
| Alert / destructive confirm | No — user must choose an explicit action |
| Bottom sheet (share, quick actions) | Yes |
For forms, visually shake the modal (translateX(4px) → translateX(-4px) → 0 over 300ms) when the user clicks the backdrop — signal that the close was blocked without a toast. Include a subtle haptic vibration hint via navigator.vibrate([50]) where the API is available.
Implement backdrop click: bind @click.self="close" on the overlay wrapper, not on the dialog surface itself.
4.5 Keyboard navigation
| Key | Behavior |
|---|---|
Escape | Close the modal (same as backdrop click — respects the "close-blocked" rules above for forms) |
Tab | Cycle forward through focusable elements inside modal |
Shift+Tab | Cycle backward |
Enter / Space | Activate focused button |
Focusable elements inside a modal must follow a logical reading order (top-to-bottom, left-to-right). The close button is always last in DOM order so it is last in Tab order — this mirrors the spatial position (top-right corner) with the reading flow.
4.6 ARIA
Minimum required ARIA on every dialog:
<div
role="dialog"
aria-modal="true"
aria-labelledby="dialog-title-id"
>
<h2 id="dialog-title-id">...</h2>
...
</div>If the dialog has no visible title (rare), use aria-label on the role="dialog" element instead.
Screen readers announce: "dialog, [title]" when focus enters. This is the user's only orientation signal. A missing or vague title (e.g., aria-label="Modal") fails them.
4.7 Mobile-specific bottom sheet
Bottom sheets on mobile slide in from below with slide-up (500ms standard). Additional rules:
- Drag handle: Include a 4 × 32 px rounded pill handle at the top of the sheet,
bg-bordercolor. The handle communicates dismissability to touch users even before they interact. - Swipe to dismiss: Bind a touch-gesture handler to the drag handle and track downward swipe velocity. Dismiss if velocity exceeds
300px/sdownward or if displacement exceeds 40% of sheet height. Mirror the position in real-time withtranslateY(). - Maximum height:
max-h-[90vh]on mobile. Sheets taller than 90vh should become a full-page modal instead. - Safe-area bottom: Always apply
pb-[env(safe-area-inset-bottom)]to the sheet footer so CTAs clear the iPhone home indicator.
5. Visual flow and polish
5.1 Page transitions
Route transitions use page-enter (opacity: 0, translateX(20px) → opacity: 1, translateX(0) over 400ms standard) for forward navigation and a mirrored slide-in-left for back navigation. The 20 px x-axis shift communicates directionality without overcommitting to a full-screen slide.
Ionic's built-in page transition animation (ios or md) fires by default. If overriding it with a custom transition, disable Ionic's transition by setting animated: false on IonRouter and apply the CSS class transition manually.
Reduced-motion: replace page-enter with fade-in only (no translate). The app should still feel navigable.
5.2 Loading states
Skeleton loaders — use for any content-heavy surface (goal list, feed, dashboard cards) that takes over 200ms to load. The skeleton should approximate the final layout: same card shape, same number of placeholders as the expected content count (cap at 5 skeleton items, not 20).
background: linear-gradient(90deg, bg-muted 25%, bg-muted-foreground/10 50%, bg-muted 75%);
background-size: 200%;
animation: shimmer 2s linear infinite;Spinners — use only for short indeterminate waits (button loading state, file upload) where a skeleton would misrepresent the eventual content shape. A spinner inside a button replaces the button label; set aria-busy="true" on the button and include a visually-hidden Loading… label.
Do not show a spinner for actions that take less than 200ms. Use the button disabled state instead. Flashing a spinner for 100ms and removing it feels broken.
5.3 Gamification feedback
These are the four reserved celebration patterns. Do not reuse them for non-gamification moments.
Streak tick (+1 counter increment)
- The streak counter increments with a brief
bounce-subtlepulse (1s, 1 cycle). - Simultaneously: a soft
shadow-accentglow fades in on the streak card over300ms, then fades out over600ms. - Microcopy: "Streak +1. Day N." (do not say "Amazing job!").
XP pop (experience points earned)
- A
+NNN XPlabel fades in and translates up16pxover500ms standard, then fades out over300ms. Triggered from the point of the action (goal card, check-in button). - Simultaneously: the XP bar's fill animates to its new value over
500ms cubic-bezier(0.4,0,0.2,1).
Badge unlock
BadgeModal.vueopens withscale-in + fade-in(400ms standard).- Inside the modal: the badge image appears with
point-popbounce curve (400ms). - A rarity-tinted glow shadow activates on the badge icon:
shadow-goldfor legendary,shadow-coachfor epic, primary blue glow for rare. - For legendary only: a
legendary-pulseCSS animation (scale(1) → scale(1.02)over2s ease-in-out, infinite) runs on the badge icon to maintain celebratory energy while the modal is open. - Toast on dismiss:
"<Badge name> unlocked."— not"Amazing! You unlocked...".
Rank up
- Full-screen confetti burst:
confettikeyframe on 40–80 particle elements positioned absolutely. Particles translate up100px, rotate720°, fade out over1s ease-out. Particles arearia-hidden. - A rank-up card (glass card, gold accent border
border-gold/60,shadow-gold) slides in from below over500ms. - Text:
"Level N · <Rank Name>"— brief. Celebratory without excess. - Auto-dismiss after
3s; user can also tap anywhere to dismiss. No looping.
5.4 State transitions on interactive elements
Elements should never snap from one state to another. Any property change triggered by user action needs a transition duration. The minimum is 150ms for micro-interactions; 200ms for color/opacity; 300ms for spatial changes.
Checkbox / toggle completion (goal event log)
- The checkmark icon draws from
0 → 100%stroke-dashoffset over300ms ease-out. - The surrounding circle fills with
bg-success/20over200ms. - The row's text transitions to
text-muted-foregroundover300msto signal completion.
Progress ring (goal completion, momentum bar)
stroke-dashoffsetanimates to its new value over500ms cubic-bezier(0.4,0,0.2,1).- When the ring reaches 100%, a single
pulse-glowfires once (non-looping):shadow-accentexpands from 0 to 20px and back over600ms. - If a milestone is reached: the gold progress dot pops in with
point-pop.
Input focus
- Border transitions from
border-bordertoborder-ringover200ms. - A
ring-2 ring-primary ring-offset-2focus ring appears immediately (no transition on the ring itself — instant visual feedback is required for accessibility). - Helper text (below the input) fades in at
opacity-0 → 1over200msonly if the field has a conditional helper.
5.5 Theme switching (light ↔ dark)
Theme toggle applies a transient html.transitioning class for the duration of the animation:
html.transitioning * {
transition:
background-color 300ms ease,
border-color 300ms ease,
color 200ms ease,
fill 200ms ease,
stroke 200ms ease,
box-shadow 300ms ease !important;
}The !important ensures component-level transitions are overridden by the global theme transition. Remove the transitioning class after 300ms in JavaScript.
Colored shadows (shadow-glow, shadow-gold, shadow-coach) persist at the same values in both themes — they read well on both the light and dark canvas and do not need per-theme variants.
5.6 Coach / AI surfaces
AI-generated content (insight cards, suggestion banners, the Coach FAB) carries the coach-purple accent and a shadow-coach glow. The FAB opens with a scale-in animation from its resting position (bottom-right corner) at 400ms standard. The insight card slides up from below the fold when new insights are available.
The Coach FAB does not pulse or animate continuously at rest. An unread-insight indicator uses a simple emerald dot (bg-success, 8px) positioned at the top-right of the FAB, with fade-in on appearance. No bounce, no glow, no looping animation — the dot is a signal, not an advertisement.
6. Anti-patterns
These behaviors violate brand principles or degrade the user experience. Do not ship them.
| Anti-pattern | Why it's wrong | Correct alternative |
|---|---|---|
| Animation on an element with no state change | Creates visual noise; feels like engagement bait | Remove the animation |
bounce curve on ordinary UI (non-gamification) | Dilutes the "earned" signal of the bounce | Use standard or ease-out |
| Looping animation in dashboard chrome | Competes for attention; the brand is anti-engagement-loop | Reserve loops for gamification overlays |
| Spinner on actions < 200ms | Makes fast actions feel slower | Use button disabled state |
| Tooltip immediately on hover (0ms delay) | Flickers on fast cursor passes | Use 400ms delay |
| Tooltip with > 1 sentence | Information overload in a tooltip context | Use a bottom sheet or info modal |
| Backdrop click always closes a form modal | User loses unsaved data | Block close; show shake animation |
| Scale-out / slide-down on modal exit | Slows down close; reverses the entry feel | Fade-out only (200ms) |
| Hover lift on non-interactive card | Misleads user into thinking the card is clickable | Remove transition + translateY from non-interactive cards |
| No reduced-motion fallback | Triggers vestibular discomfort for affected users | Replace transforms with opacity fades |
Focus ring suppressed (outline: none without replacement) | Breaks keyboard navigation | Provide visible :focus-visible ring |
| Dark-mode state not tested | Component often breaks at dark-mode boundary | Every state must be verified in both themes |
7. Component cross-reference
| Component | Hover pattern | Modal behavior | Animation notes |
|---|---|---|---|
GoalCard.vue | translateY(-4px) lift, shadow-xl | Opens Goal Detail view (page transition) | slide-in-right on nav |
BadgeModal.vue | N/A | Bottom sheet on mobile, centered on desktop | scale-in enter; badge uses point-pop |
BottomTabBar.vue | Color shift text-muted → text-primary-interactive | N/A | Active tab: no animation; tap: fast color |
UiTooltip.vue | N/A | Positioned bubble | 400ms delay; 200ms fade enter/exit |
| Coach FAB | Glow expand shadow-coach | Opens Coach sheet | scale-in 400ms |
| Progress ring (various) | N/A | N/A | stroke-dashoffset 500ms on value change |
| XP progress bar | N/A | N/A | Fill animates on XP gain, width 500ms |
| Toast | N/A | N/A | slide-up + fade enter; fade exit |
| Skeleton loaders | N/A | N/A | shimmer 2s linear infinite |
8. Maintenance notes
This document is manually maintained. Update it when:
- A new interaction pattern is introduced that is not covered here.
- A token value or timing constant changes in
DESIGN.mdordesign_system/. - A component diverges from these patterns and the divergence is intentional (document the exception here).
- A new gamification mechanic requires a new celebration pattern.
When updating, add your change under the relevant section and note the date in a comment inside the file. Do not change the section numbering — it is referenced from the Storybook story briefs in some component files.
Last substantively updated: 2026-05-22 (Phase 94 audit: motion tokens and interaction patterns verified)
Last updated: 2026-07-09