From 3a1496e5fa68e1c8771f636fcd24501498b63a75 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Sun, 16 Aug 2026 11:51:07 -0400 Subject: [PATCH] frontend: honor prefers-reduced-motion, and let frontend work reach the desktop MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .forgejo/workflows/desktop.yml | 22 ++++++---- frontend/src/composables/useReducedMotion.ts | 43 ++++++++++++++++++++ frontend/src/style.css | 21 ++++++++++ 3 files changed, 79 insertions(+), 7 deletions(-) create mode 100644 frontend/src/composables/useReducedMotion.ts diff --git a/.forgejo/workflows/desktop.yml b/.forgejo/workflows/desktop.yml index a4c7259..1f29cf8 100644 --- a/.forgejo/workflows/desktop.yml +++ b/.forgejo/workflows/desktop.yml @@ -1,6 +1,13 @@ -# Tauri desktop (Linux) build — SEPARATE from ci.yml on purpose: this is a heavy -# Rust + AppImage build (~20-40 min) that should NOT run on backend/frontend-only -# pushes. Scoped to desktop/** (+ this file). Produces the .deb and .AppImage. +# Tauri desktop (Linux) build — SEPARATE from ci.yml on purpose: a Rust + AppImage +# build that shouldn't run on server-only pushes. Produces the .deb and .AppImage. +# +# It DOES run on frontend changes. tauri's generate_context! embeds the built +# frontend in the binary, so a frontend commit that never triggers this ships to +# the web and silently never reaches the desktop app — and desktop, web and Android +# are peer surfaces held to one quality bar, not a primary and its fallbacks. The +# filter was once narrowed to the adapter/bridge directories against a "~20-40 min" +# build; measured runs are 4-5 minutes, so the cost that justified the narrowing +# isn't there. # # Toolchain comes from the ci-tauri image (Rust + Node + WebKitGTK 4.1 + tauri-cli); # runs-on is just a registered scheduling label (Label Model B), not a per-purpose @@ -13,10 +20,11 @@ on: tags: ["v*"] paths: - "desktop/**" - # The desktop app embeds the frontend, and the data seam / Tauri bridge are - # what the offline core rides on — rebuild the app when those change too. - - "frontend/src/adapters/**" - - "frontend/src/desktop/**" + # The whole frontend, not just the adapter/bridge seam: it is compiled INTO + # the desktop binary, so any part of it changing means the shipped app is out + # of date. Config and lockfile included — a dependency bump changes the bundle + # as surely as a component does. + - "frontend/**" - ".forgejo/workflows/desktop.yml" workflow_dispatch: diff --git a/frontend/src/composables/useReducedMotion.ts b/frontend/src/composables/useReducedMotion.ts new file mode 100644 index 0000000..889dcfb --- /dev/null +++ b/frontend/src/composables/useReducedMotion.ts @@ -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> { + 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; +} diff --git a/frontend/src/style.css b/frontend/src/style.css index de91db8..848d8f4 100644 --- a/frontend/src/style.css +++ b/frontend/src/style.css @@ -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