UI Ascend: completion fixes and polish#24
Merged
Conversation
- Darken --text-tertiary from #6E6A82 to #656180 (4.9:1 vs 4.45:1) - Toast progress bar: linear → ease-out for natural deceleration - Add shimmerSweep keyframe for completed card effects Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Tier 1 (ambient) and Tier 3 (celebration) fully disable when user prefers reduced motion. Tier 2 (responsive) degrades to opacity-only. Uses getter-based approach so check happens at render time, not module load time. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Add shimmerSweep animation to PactCard complete button on completion - Add content-visibility: auto to DailySummaryCard and GroupStats for better rendering performance on below-fold components Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Custom particle system using Framer Motion — no external dependency. Respects prefers-reduced-motion. Cleanup timer prevents memory leaks. Legacy named exports preserved as no-ops for backwards compatibility. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
There was a problem hiding this comment.
Pull request overview
This PR polishes UI motion/performance and removes the canvas-confetti dependency by introducing a Framer Motion–based particle confetti system, while also improving readability and perceived smoothness across the dashboard UI.
Changes:
- Remove
canvas-confettiand implement a custom confetti particle system viauseConfetti(). - Add reduced-motion-aware Framer Motion variants and tweak toast/checkmark visual polish.
- Improve rendering performance with
content-visibility: autoand adjust global text contrast and keyframes.
Reviewed changes
Copilot reviewed 9 out of 10 changed files in this pull request and generated 3 comments.
Show a summary per file
| File | Description |
|---|---|
| package.json | Removes canvas-confetti dependency. |
| package-lock.json | Updates lockfile to reflect dependency removal. |
| lib/confetti.js | Replaces canvas-based confetti with a hook-driven Framer Motion particle overlay. |
| lib/animations.js | Adds reduced-motion support via a motionSafe wrapper and updates many exported variants to use it. |
| components/Toast.module.css | Changes toast progress easing from linear to ease-out. |
| components/PactCard.module.css | Adds shimmer sweep styling for the completed checkmark button. |
| components/PactCard.js | Applies shimmer class when completion bounce is shown. |
| components/GroupStats.module.css | Adds content-visibility and intrinsic sizing hints for performance. |
| components/DailySummaryCard.module.css | Adds content-visibility and intrinsic sizing hints for performance. |
| app/globals.css | Improves --text-tertiary contrast and adds @keyframes shimmerSweep. |
Comments suppressed due to low confidence (1)
components/PactCard.js:229
- The shimmer sweep is defined as a 2s animation in CSS, but
showBounce(which controls applyingstyles.completedCheck) is cleared after 1s. That means the pseudo-element (and its animation) will be removed halfway through, so the shimmer likely won’t complete.
Consider using a separate state/timer for the shimmer duration or aligning the showBounce timeout with the shimmer animation length.
disabled={isLoading}
className={`${styles.completeBtn} ${showBounce ? styles.completedCheck : ''}`}
aria-label="Mark pact as complete"
whileHover={buttonHover}
whileTap={buttonTap}
{...(showBounce ? celebrationBounce : {})}
>
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
Comment on lines
+85
to
99
| const [particles, setParticles] = useState([]); | ||
|
|
||
| const fire = useCallback((opts = {}) => { | ||
| if (prefersReducedMotion()) return; | ||
| const { x, y } = opts; | ||
| const origin = {}; | ||
| if (x !== undefined) origin.x = typeof x === 'string' ? 0.5 : x / window.innerWidth; | ||
| if (y !== undefined) origin.y = typeof y === 'string' ? 0.5 : y / window.innerHeight; | ||
| confetti({ | ||
| particleCount: 80, | ||
| spread: 70, | ||
| origin: { y: 0.6, ...origin }, | ||
| colors: BRAND_COLORS, | ||
| }); | ||
| const w = typeof window !== 'undefined' ? window.innerWidth : 800; | ||
| const h = typeof window !== 'undefined' ? window.innerHeight : 600; | ||
| const origin = { | ||
| x: opts.x !== undefined ? opts.x / w : 0.5, | ||
| y: opts.y !== undefined ? opts.y / h : 0.6, | ||
| }; | ||
| const newParticles = createParticles(opts.count || 40, origin); | ||
| setParticles(newParticles); | ||
| const timer = setTimeout(() => setParticles([]), 2000); | ||
| return () => clearTimeout(timer); | ||
| }, []); |
Comment on lines
+17
to
+29
| function createParticles(count = 40, origin = { x: 0.5, y: 0.6 }) { | ||
| const w = typeof window !== 'undefined' ? window.innerWidth : 800; | ||
| const h = typeof window !== 'undefined' ? window.innerHeight : 600; | ||
| return Array.from({ length: count }, (_, i) => ({ | ||
| id: `${Date.now()}-${i}`, | ||
| x: origin.x * w, | ||
| y: origin.y * h, | ||
| color: BRAND_COLORS[Math.floor(Math.random() * BRAND_COLORS.length)], | ||
| size: randomBetween(6, 12), | ||
| angle: randomBetween(0, 360), | ||
| velocity: randomBetween(200, 500), | ||
| rotation: randomBetween(-180, 180), | ||
| })); |
Comment on lines
+49
to
+71
| /** | ||
| * Wraps a variant object so its properties check prefers-reduced-motion | ||
| * at access time (i.e., when spread into a component during render). | ||
| * | ||
| * Tier 1 (ambient) + Tier 3 (celebration): fully disabled — returns empty objects. | ||
| * Tier 2 (responsive): degrades to opacity-only transitions. | ||
| */ | ||
| function motionSafe(variant, tier) { | ||
| const result = {}; | ||
| for (const [key, value] of Object.entries(variant)) { | ||
| Object.defineProperty(result, key, { | ||
| get() { | ||
| if (typeof window !== 'undefined' && window.matchMedia('(prefers-reduced-motion: reduce)').matches) { | ||
| if (tier === 1 || tier === 3) { | ||
| // Kill ambient and celebration animations entirely | ||
| return {}; | ||
| } | ||
| if (tier === 2) { | ||
| if (key === 'initial') return { opacity: 0 }; | ||
| if (key === 'animate') return { opacity: 1, transition: { duration: 0.2 } }; | ||
| if (key === 'exit') return { opacity: 0, transition: { duration: 0.1 } }; | ||
| } | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
--text-tertiaryWCAG AA contrast (4.45:1 → 4.9:1)prefers-reduced-motion(Tier 1+3 disable, Tier 2 degrades to opacity-only)content-visibility: autoon DailySummaryCard and GroupStatsTest plan
🤖 Generated with Claude Code