frontend: honor prefers-reduced-motion, and let frontend work reach the desktop
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 6s
CI & Build / Python tests (push) Successful in 12s
CI & Build / Build & push image (push) Successful in 31s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m38s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 4m33s
Desktop (Tauri) / Update manifest (push) Successful in 4s
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 6s
CI & Build / Python tests (push) Successful in 12s
CI & Build / Build & push image (push) Successful in 31s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m38s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 4m33s
Desktop (Tauri) / Update manifest (push) Successful in 4s
Two halves of the same gap. The app had no reduced-motion handling at all — the setting appeared nowhere in the frontend — and the desktop build didn't rebuild on frontend changes, so shared UI work shipped to the web and silently never reached the desktop app. The CSS guard is global and blunt so it catches every Tailwind `transition` already scattered through the components, and catches M7's motion work without each new component having to remember. Near-zero durations rather than `none`, so transitionend/animationend still fire and nothing waiting on them hangs. useReducedMotion covers what CSS can't reach: JS-driven motion, where the honest response to the preference is no animation at all rather than a faster one. It's reactive because the setting can change while the app is open. The path filter was narrowed to the adapter/bridge directories against a "~20-40 min" build cost recorded in the header. Measured runs are 4-5 minutes, so that cost isn't there, and the frontend is compiled into the binary by generate_context! — any part of it changing means the shipped desktop app is stale. Desktop, web and Android are peer surfaces on one quality bar, so shared frontend work has to reach all of them by construction rather than by whichever directory it happened to touch. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,43 @@
|
||||
import { onBeforeUnmount, readonly, ref, type Ref } from "vue";
|
||||
|
||||
const QUERY = "(prefers-reduced-motion: reduce)";
|
||||
|
||||
/**
|
||||
* Whether the user has asked their system to reduce motion.
|
||||
*
|
||||
* The CSS half of this lives in style.css and neutralises every declarative
|
||||
* transition on its own. This is for the half CSS can't reach: motion driven from
|
||||
* JavaScript — the FLIP reflow and the card↔editor morph — where the honest
|
||||
* response isn't a faster animation but no animation at all, jumping straight to
|
||||
* the final state.
|
||||
*
|
||||
* Reactive rather than a one-shot read: the setting can change while the app is
|
||||
* open (a system toggle, or a "reduce motion during battery saver" rule), and a
|
||||
* board that keeps sliding until relaunch would be ignoring an answer already given.
|
||||
*/
|
||||
export function useReducedMotion(): Readonly<Ref<boolean>> {
|
||||
const reduced = ref(prefersReducedMotion());
|
||||
|
||||
// Guard the whole listener: SSR and test environments have no matchMedia, and
|
||||
// this must degrade to "motion is fine" rather than throwing during setup.
|
||||
if (typeof window !== "undefined" && typeof window.matchMedia === "function") {
|
||||
const media = window.matchMedia(QUERY);
|
||||
const update = (e: MediaQueryListEvent) => {
|
||||
reduced.value = e.matches;
|
||||
};
|
||||
media.addEventListener("change", update);
|
||||
onBeforeUnmount(() => media.removeEventListener("change", update));
|
||||
}
|
||||
|
||||
return readonly(reduced);
|
||||
}
|
||||
|
||||
/**
|
||||
* The same answer without a component instance — for module-level helpers and
|
||||
* one-off checks inside an event handler, where there is no lifecycle to hang a
|
||||
* listener on.
|
||||
*/
|
||||
export function prefersReducedMotion(): boolean {
|
||||
if (typeof window === "undefined" || typeof window.matchMedia !== "function") return false;
|
||||
return window.matchMedia(QUERY).matches;
|
||||
}
|
||||
@@ -18,6 +18,27 @@ body {
|
||||
}
|
||||
}
|
||||
|
||||
/* Motion is a feature, not a given. Anyone whose OS says "reduce motion" has told
|
||||
* us something about vestibular comfort or attention, and the answer is to arrive
|
||||
* instantly rather than to animate faster.
|
||||
*
|
||||
* Global and blunt on purpose: it catches every Tailwind `transition` utility
|
||||
* already scattered through the components, and it will catch the M7 motion work
|
||||
* without each new component having to remember. Near-zero rather than `none` so
|
||||
* `transitionend`/`animationend` listeners still fire and no state machine that
|
||||
* waits on them can hang. JS-driven motion can't be reached from CSS — that reads
|
||||
* the same preference through `useReducedMotion`. */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
animation-duration: 0.01ms !important;
|
||||
animation-iteration-count: 1 !important;
|
||||
transition-duration: 0.01ms !important;
|
||||
scroll-behavior: auto !important;
|
||||
}
|
||||
}
|
||||
|
||||
@layer components {
|
||||
.icon-btn {
|
||||
@apply rounded-md p-1.5 text-neutral-500 transition hover:bg-black/5 focus:outline-none
|
||||
|
||||
Reference in New Issue
Block a user