feat: respect prefers reduced motion#334
Conversation
404-Page-Found
left a comment
There was a problem hiding this comment.
Findings
-
Bug:
simpleModePausednot cleared when user switches back to normal motion- File:
src/ui/settings.js(PR diff line ~1001-1006) - When reduced motion is detected,
videoEl.dataset.simpleModePaused = 'true'is set. When the user later switches back to normal motion, theelse ifbranch callsvideoEl.play()— which works for the immediate event — butsimpleModePausedis never cleared. The visibility-resume handler atsettings.js:630checkssimpleModePaused !== 'true'and will block video resume on subsequent tab hide/show cycles. - Fix: Add
delete videoEl.dataset.simpleModePaused;(or set to'false') in the non-reduced-motion branch.
- File:
-
Medium: Duplicated
isReducedMotion()/crossfadeDelayMs()wrappers- Files:
src/data/custom-backgrounds.js:14-21,src/ui/settings.js:85-87 - Both files define local
isReducedMotion()wrappingwindow.prefersReducedMotion(), andcustom-backgrounds.jsdefines a localcrossfadeDelayMs()that shadows the global fromsrc/core/motion.js. This duplication means the two modules can drift. They should both use thewindow.crossfadeDelayMs/window.prefersReducedMotionhelpers directly.
- Files:
-
Medium: Misuse of
simpleModePausedflag for reduced motion- Files:
src/ui/settings.js:521,src/data/custom-backgrounds.js:495 - The PR reuses
videoEl.dataset.simpleModePaused = 'true'to mean "video paused because of reduced motion." This flag was designed for the simple-mode feature and is checked in the visibility handler and insimple-mode.js. Reusing it for a different purpose creates semantic confusion. A dedicatedreducedMotionPauseddata attribute would be cleaner.
- Files:
-
Low:
showDateUpdateFeedbackreduced-motion path provides no visual feedback- File:
src/features/todo.js(PR diff ~line 1083-1094) - The reduced-motion branch sets
backgroundColorthen clears it in the samerequestAnimationFramecallback, so the highlight is visible for essentially zero frames. Consider either showing a brief non-animated highlight (e.g., clear after ~50ms) or removing the highlight entirely and relying on the toast.
- File:
-
Low:
custom-backgrounds.jsdoesn't handle dynamic reduced-motion changes- File:
src/data/custom-backgrounds.js settings.js's change event listener was updated to handle the user togglingprefers-reduced-motionwhile a background video is active. The equivalent code path incustom-backgrounds.jswas not. Minor gap for an uncommon edge case.
- File:
-
Cosmetic:
window.showDateUpdateFeedbackexported solely for tests- File:
src/features/todo.js:1812— this function isn't called outside the module. The export exists only for testability. Follows the existing pattern, so non-blocking.
- File:
Positive notes: The motion.js helper is well-structured with proper error handling, test hooks, and a subscriber API. The CSS scroll-behavior: auto and .drag-drop-landed/.note-item defensive rules are thorough. The drag-drop.js fallback setTimeout(cleanup, 500) is a smart guard against class leaks when animation: none prevents animationend. Test coverage for the core helper and main.js paths looks solid.
Open Questions
- Should the
simpleModePausedflag be cleared when switching back to normal motion, or should a new dedicated flag be introduced? The latter is cleaner but requires touching the visibility-resume handler in bothsettings.jsandcustom-backgrounds.js. - Is the intent of the reduced-motion
showDateUpdateFeedbackto show zero visual highlight (toast only), or a brief non-animated highlight?
Summary
Request changes. The PR is well-structured and the motion helper is solid. However, the simpleModePaused bug (finding 1) will break video resume on tab visibility changes when the user has toggled their motion preference. The duplicated wrappers and semantic misuse of simpleModePaused (findings 2–3) are worth cleaning up to prevent drift between the two background modules. Non-blocking items (4–6) can be addressed in follow-up if preferred.
fdd7f72 to
556035e
Compare
404-Page-Found
left a comment
There was a problem hiding this comment.
Findings
-
High:
core.cssdefensive rules forceopacity: 1on.note-item— could break hidden/collapsed notes- File:
css/core.css— the new.note-item { opacity: 1 !important }under@media (prefers-reduced-motion: reduce)will override any JS or CSS that relies on opacity to hide, collapse, or dim a note. If simple mode or any future feature hides notes via opacity, this rule will keep them visible. Consider a more targeted selector or removing the opacity override since the global*block already handles suppressed transitions ending at full opacity.
- File:
-
High: Two competing
@media (prefers-reduced-motion: reduce)blocks infeatures.cssvscore.cssmay diverge- Files:
css/features.css:2018andcss/core.css:261 - The same media query is split across two files with overlapping responsibilities.
features.csssetsanimation-duration: 0.01ms/transition-duration: 0.01mson*, whilecore.csssetsanimation: none/opacity: 1on specific selectors. Thefeatures.cssapproach (near-zero duration) lets transitions "complete" instantly, while thecore.cssapproach (animation: none) removes them entirely. These two strategies conflict for.app-icon.drag-drop-landedand.note-item: thefeatures.cssrule would create a 0.01ms transition, whilecore.csswould suppress the animation entirely. The!importanton both makes the result order-dependent. Consolidate into one block to avoid subtle cascade issues.
- Files:
-
Medium:
drag-drop.jssetTimeout(cleanup, 500)is never cleared — leaks a timer per drop- File:
src/features/drag-drop.js:453-460 - After each drop, both
animationendandsetTimeout(cleanup, 500)are scheduled. ThesetTimeouthandle is never stored or cleared. For heavy drag-and-drop sessions this accumulates pending timers. Consider storing the timeout handle and clearing it inside theanimationendcallback.
- File:
-
Medium:
showDateUpdateFeedbackreduced-motion path still flashes a highlight for 150ms- File:
src/features/todo.js:1086-1090 - The reduced-motion branch applies a
backgroundColorchange and clears it after 150ms viasetTimeout. While this isn't "motion" per se, WCAG 2.3.3 cautions against flashes. Users who enabled reduced motion specifically to avoid sudden visual changes may find the 150ms color flash jarring. Consider making the highlight permanent until the user navigates away, or removing it entirely in reduced-motion mode.
- File:
-
Medium:
syncVideoToMotionPreferenceandsyncCustomVideoToMotionPreferenceboth subscribe but discard the unsubscribe handle- Files:
src/ui/settings.js:1408-1410andsrc/data/custom-backgrounds.js:736-738 window.onReducedMotionChange(fn)returns an unsubscribe function, but both call sites discard it. IfinitSettings()were ever called twice (e.g., during testing or re-initialization), the handler would be registered twice, causing duplicate play/pause cycles. The risk is low in production (initSettings runs once), but it's a latent bug for test harnesses.
- Files:
-
Medium: Test load order comment may be inaccurate
- File:
tests/prefers-reduced-motion.test.js:30-35 - The comment says "Production order (New-Tab.html) loads custom-backgrounds.js BEFORE settings.js". Please verify this matches the actual
<script>tag ordering inNew-Tab.html.
- File:
-
Low:
crossfadeDelayMsexposed onwindowbut defined inside an IIFE — fragile global coupling- File:
src/core/motion.js:83-85 - Every consumer calls
crossfadeDelayMsas a bare global. Ifmotion.jsever fails to load (e.g., CSP blocking), all crossfade timers would throw a ReferenceError. Consider adding a guard or exporting a safe fallback.
- File:
-
Low: Test hooks on
windowin production- File:
src/core/motion.js:120-138 _setReducedForTests,_setMqlForTests,_resetSubscribersForTestsare accessible from the devtools console. Not a security concern for a browser extension, but worth noting.
- File:
Open Questions
- Is the
core.cssdefensive block (.app-icon.drag-drop-landed, .note-item) meant to be permanent, or is it scaffolding for future CSS that might loosen the*selector? - Should the reduced-motion path in
showDateUpdateFeedbackskip the highlight entirely, or is the 150ms color flash intentional? - Are there plans to consolidate the two
@media (prefers-reduced-motion: reduce)blocks into a single file?
Summary
The overall approach is solid: a centralized motion.js helper with a subscription API, CSS class toggling, and JS-side animation guards. The test suite is thorough. However, the two high-severity findings (the .note-item { opacity: 1 !important } override that could break note hiding, and the conflicting strategies in the two media-query blocks) should be resolved before merge. The medium issues (timer leaks, discarded unsubscribe handles, misleading comment) are worth addressing but won't cause user-facing bugs in the near term.
|
Thanks for the thorough re-review @404-Page-Found. Six follow-up commits address every finding from the second review pass: HIGH (core.css + features.css)
MEDIUM
LOW
Verified
Re-requesting review. The only items from the original review pass that remain unaddressed are the two cosmetic ones you marked non-blocking: |
404-Page-Found
left a comment
There was a problem hiding this comment.
Findings
-
Low: Unrelated drag-drop refactor bundled into an a11y PR
- File:
src/features/drag-drop.js:450-477 - The PR replaces the
animationend-only cleanup with a dualanimationend/setTimeout+ mutual-cancellation pattern. This is a solid robustness improvement (prevents stale timers during heavy drag sessions), but it's unrelated toprefers-reduced-motion. Under reduced motion, the CSS global override (animation-duration: 0.01ms) still firesanimationend, and.app-icon.drag-drop-landedis explicitly not overridden incore.css(the comments explain why). This should ideally be a separate commit or PR so the change is easy to revert independently.
- File:
-
Low: Minor scope expansion —
window.showDateUpdateFeedbackexport- File:
src/features/todo.js:1810 - The PR exposes
showDateUpdateFeedbackonwindowsolely for test access. This function was previously internal-only. The export is harmless, but the convention in this codebase is to avoid leaking helpers towindowunless they're part of the public API. The test harness'sinjectScriptalready makes all IIFE-levelvar/functiondeclarations visible within the eval scope — verify whethershowDateUpdateFeedbackactually needs thewindow.assignment for the test to reach it, or if it's accessible via the existing scope.
- File:
-
Low:
eslint-disable-next-line no-useless-assignmentin custom-backgrounds.js- File:
src/data/custom-backgrounds.js(motion subscriber registration) - The
unsubscribeCustomVideoMotion = window.onReducedMotionChange(...)line is suppressed withno-useless-assignment. The explanation is correct (re-injection teardown), but it would read better as a dedicated comment above the suppression explaining why the captured return value serves a future teardown purpose rather than an inline disable.
- File:
-
Nit:
crossfadeDelayMswrapping on already-bypassed timeouts- Files:
src/data/custom-backgrounds.js:507,src/ui/settings.js:412,558,684 - In
triggerCrossfade, when reduced motion is on, the earlyreturnprevents the thumbnail-hide timeout from ever being created. ThecrossfadeDelayMs()wrapper around those timeouts is therefore unreachable in that code path. It acts as a safety net if the preference toggles mid-flight, but the motion subscriber handles the live toggle independently. The wrappers are harmless but add cognitive overhead — a brief comment noting "belt-and-suspenders" would help.
- Files:
Open Questions
- Is the
drag-drop.jscleanup refactor intentionally part of this PR (e.g. to guarantee cleanup whenanimationendis suppressed), or should it be split out?
Summary
The PR is well-structured and thorough. The motion.js helper is cleanly designed with a subscription API, test hooks, and a reduce-motion class on <html>. The JS-driven animation paths (motto fade, todo FLIP, date highlight, background video crossfade) all correctly check prefersReducedMotion() and resolve instantly when the preference is active. The background video pause/resume is correctly partitioned between settings.js (built-in videos) and custom-backgrounds.js (custom videos) via mutual isCustom guards. CSS changes are well-commented explaining the backwards vs forwards fill-mode distinction. Test coverage is strong (403 lines covering the helper, each animation path, and a regression test for the flag-clearing bug). No correctness bugs found. Recommend approval with minor cleanup — consider splitting the drag-drop refactor into a separate commit and verifying the window.showDateUpdateFeedback export is necessary.
Review 2 findings 1 and 2: drop the .app-icon.drag-drop-landed, .note-item defensive block in core.css (both animations use forwards fill mode so the global near-zero-duration override in features.css already collapses them to the final state). The .note-item opacity 1 !important rule in particular could shadow future opacity-based hiding of notes. Document the division of responsibility between the two prefers-reduced-motion blocks: core.css owns entrance/glow animations that need explicit 'animation: none; opacity: 1' because they use backwards or no-fill-mode with animation-delay; features.css owns the global near-zero-duration override that handles everything else including forwards fill-mode animations.
The bounce-landing cleanup that fires after a successful drop scheduled both an animationend listener and a 500ms setTimeout fallback. The fallback covers edge cases where animationend never fires (the element is removed from the DOM mid-animation, or a browser quirk swallows the event), but the timer handle was never tracked. During heavy drag-and-drop sessions this leaked one pending timer per drop. Store the handle in a closure variable and clear it inside the cleanup callback so animationend always wins, and the setTimeout path becomes a one-shot safety net rather than a parallel cleanup. This is a robustness fix and is unrelated to the prefers-reduced-motion work; it is being moved to its own commit so the a11y PR stays focused and this change can be reverted independently if needed.
Review 2 finding 4: the 150ms backgroundColor flash we added in the previous round of review is itself a sudden visual change, which conflicts with the spirit of prefers-reduced-motion (WCAG 2.3.3). The toast notification already communicates the date update, so under reduced motion we drop the in-place highlight entirely. Update the prefers-reduced-motion test to assert no backgroundColor, no transform, and no transition are applied when the user prefers reduced motion, and to skip the 150ms clearing path.
Review 2 finding 5: window.onReducedMotionChange returns an unsubscribe function, but both settings.js (syncVideoToMotionPreference) and custom-backgrounds.js (syncCustomVideoToMotionPreference) discard it. In production the handlers register once, so the leak is latent; but in test harnesses that re-inject the module, a second call to initSettings() would register a duplicate handler and fire play/pause cycles twice per change. Capture the handle and detach the previous subscriber before re-subscribing.
…test Review 2 finding 6: the previous comment was self-contradictory (it claimed the test loads custom-backgrounds.js after settings.js 'so that _customBackgrounds is available before the settings.js test setup runs', which is the opposite of what loading-after means). Rewrite to state the actual intent: production loads custom-backgrounds.js before settings.js, the test reverses the order so the settings.js change-handler tests run first against a clean module state, and both orders are safe because cross-module references are resolved at call time only.
Review 2 finding 7: crossfadeDelayMs and the other motion helpers are bare globals, so a load failure (CSP block, network error) would throw ReferenceError at every call site. Install identity / no-op stubs on window just before the motion.js script tag so the helpers always exist; motion.js's IIFE overwrites them with the real implementations on a normal load.
Review 3 finding 2: showDateUpdateFeedback and the drag/drop handlers are exposed on window solely so the prefers-reduced-motion test can reach them. The test harness (tests/helpers/inject-script.js) loads this file via globalThis.eval(code), which scopes the IIFE's function and var declarations to the eval scope and hides them from the test; the window.* re-export is the only path that works. Add a comment block above the test-only exports so future readers know why the global leak exists and that nothing at runtime depends on it.
Review 3 findings 3 and 4: * custom-backgrounds.js: expand the comment above the eslint-disable-next-line no-useless-assignment on the unsubscribeCustomVideoMotion capture. The disable is needed because the assigned value is consumed by the *next* module injection (test harness, HMR) via the detach call above, not by the current pass. Make the rationale explicit so the suppression reads as a documented design choice rather than a stray lint override. * custom-backgrounds.js and settings.js: add a brief belt-and-suspenders note on each of the four crossfadeDelayMs() wrapper sites. The reduced-motion early return in triggerCrossfade normally prevents the thumbnail-hide setTimeout from being created, so the wrapper is unreachable in that code path. The wrapper still collapses the duration if the preference flips between scheduling and firing, so the call is kept as a safety net; the comment makes that intent obvious to future readers.
9f0999f to
45b3da4
Compare
|
PR changed again? Review this PR in Change Stack to compare snapshots and stay oriented. No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Pro Run ID: 📒 Files selected for processing (3)
✅ Files skipped from review due to trivial changes (1)
📝 WalkthroughWalkthroughAdds pervasive prefers-reduced-motion support: new motion.js exposes prefersReducedMotion/onReducedMotionChange/crossfadeDelayMs and initializes root state; CSS and JS animation paths (motto, todos, drag-drop) and background video playback now respect and synchronize with the OS reduced-motion preference. ChangesAccessibility: Reduced-Motion Support
Estimated code review effort🎯 4 (Complex) | ⏱️ ~45 minutes Possibly related issues
Poem
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches🧪 Generate unit tests (beta)
Comment |
|
Thanks for the re-review @404-Page-Found. Per your "approval with minor cleanup" recommendation, this pass addresses all four low/nit findings. The history was rebased to keep the a11y PR focused and split the drag-drop refactor out as its own commit. LOW
LOW
Verified
Re-requesting review. |
There was a problem hiding this comment.
Actionable comments posted: 2
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@CHANGELOG.md`:
- Around line 16-18: Update the changelog entry so it correctly reflects the
final CSS behavior: replace the sentence claiming targeted defensive rules were
added for `.app-icon.drag-drop-landed` and `.note-item` with wording that states
those defensive overrides were removed (or never present in the final build) and
explain that the implementation now relies on the consolidated/global styles
instead; ensure the text mentions the two class selectors
`.app-icon.drag-drop-landed` and `.note-item` so readers can map the note to the
code.
In `@src/ui/settings.js`:
- Around line 1379-1390: syncVideoToMotionPreference sets
videoEl.dataset.reducedMotionPaused = 'true' when reduced motion is enabled but
the visibility-resume logic later (the tab-visible subscriber that replays based
on data-wasPlaying) does not check this flag; update that visibility-resume
handler to skip restarting the video if videoEl.dataset.reducedMotionPaused ===
'true' (and continue to respect custom backgrounds via
window._customBackgrounds.isCustom and loadVideoAutoplay() checks). In short: in
the visibility resume path that calls videoEl.play() when data-wasPlaying ===
'true', add a guard that returns early when data-reducedMotionPaused === 'true'
so reduced-motion preference remains honored across background/foreground
transitions.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro
Run ID: 4868977b-8fda-4526-a643-daf8df240766
📒 Files selected for processing (13)
CHANGELOG.mdNew-Tab.htmlcss/core.csscss/features.csseslint.config.jssrc/core/main.jssrc/core/motion.jssrc/data/custom-backgrounds.jssrc/features/drag-drop.jssrc/features/todo.jssrc/ui/settings.jstests/prefers-reduced-motion.test.jstests/settings.test.js
- settings.js: initVideoVisibilityHandler now checks simpleModePaused and reducedMotionPaused before resuming the video on tab-visible, preventing reduced-motion preference from being silently ignored when the user enables it while the tab is hidden. - CHANGELOG.md: replace inaccurate claim that defensive CSS overrides were added for .app-icon.drag-drop-landed and .note-item; the global near-zero-duration override in features.css handles these via forwards fill-mode and no redundant rules were introduced. - Add 3 regression tests covering the visibility-resume guard for reducedMotionPaused, simpleModePaused, and the normal resume path.
|
Addressed both CodeRabbit findings from the latest review (�17c29b): CHANGELOG.md (lines 16–18)
src/ui/settings.js visibility-resume path
Tests
|
|
@coderabbitai review |
✅ Action performedReview finished.
|
Summary by CodeRabbit