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