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:
@@ -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:
|
||||
|
||||
|
||||
@@ -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