M12 — the Android client, end to end #2

Merged
bvandeusen merged 86 commits from dev into main 2026-08-21 08:53:58 -04:00
3 changed files with 79 additions and 7 deletions
Showing only changes of commit 3a1496e5fa - Show all commits
+15 -7
View File
@@ -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;
}
+21
View File
@@ -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