feat(t5): Bewegungs-Sprache mit Tokens, Stufen und Budget (T5a A11)
Backend Image / build-and-push (push) Successful in 11m29s
Frontend Image / build-and-push (push) Successful in 23m35s

- src/lib/motion/tokens.ts und --motion-* in globals.css: Dauern,
  Kurven und Feder an einer Stelle; Beute-Bühne liest dieselben Werte.
- AppMotion: LazyMotion strict, Bewegungs-Code (domAnimation) als ein
  asynchroner Chunk, 28 kB gzip; check:motion-budget im Docker-Build
  neben dem 3D-Budget (Spec 17.6).
- PresenceList: abgehakte Zeilen blenden aus, die übrigen rücken nur bei
  „Voll“ nach; „Dezent“ blendet nur, „Aus“ sofort. Die Zeile geht nach
  der festen Verzögerung, auch wenn der Server schweigt (Spec 17.5);
  Fokus wandert zum nächsten Haken, auch ohne Bewegungs-Chunk.
- Overlays (Sheet, Dialog, Menü, Select) über Base-UI-Start/Ende-Stile
  mit Token-Zeiten; Verschiebung und Skalierung nur bei motion-full.
- Kein transition-all, kein Hover-Heben, keine Breiten-Animation von
  Daten; Skeleton erst nach 150 ms; Wächter-Regeln für alles davon.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
Nick.RosenloecherandClaude Opus 5.5 committed 2026-10-16 21:15:42 +02:00
1 parent 7025a6b589
commit cda3e02a31
66 files changed
+2574 -773

No files matched your search

+2
View File
@@ -13,6 +13,8 @@ RUN npm run lint && npx tsc --noEmit && npm test
RUN npm run build
# Bundle-Budget (Spec Beute-3D §9.9): schlaegt fehl, wenn der 3D-Chunk > 320 kB gzip ist.
RUN npm run check:3d-budget
# Bundle-Budget (Spec T5 §17.6): schlaegt fehl, wenn motion zusammen > 30 kB gzip ist.
RUN npm run check:motion-budget
FROM node:22-alpine@sha256:0a7108bf6c7bf5de370ffb1a3ed6be93d405b43ff159f681a8d18c0e2bc2e402 AS runtime
ENV NODE_ENV=production
+1
View File
@@ -11,6 +11,7 @@
"test": "vitest run",
"test:watch": "vitest",
"check:3d-budget": "node scripts/check-3d-budget.mjs",
"check:motion-budget": "node scripts/check-motion-budget.mjs",
"icons": "node scripts/generate-icons.mjs"
},
"dependencies": {
@@ -0,0 +1,68 @@
// Bundle-Budget fuer `motion` im DOM (Spec T5 §17.6: hoechstens 30 kB gzip).
// Laeuft nach `next build` wie check-3d-budget.mjs; Exit 1, wenn die motion-Chunks
// zusammen das Budget ueberschreiten oder gar kein motion-Chunk gefunden wird.
import { readdirSync, readFileSync } from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { gzipSync } from "node:zlib";
// Spec §17.6: hoechstens 30 kB gzip (1 kB = 1000 Byte) fuer motion insgesamt:
// den Teil in der App-Huelle und die asynchron geladenen Features.
export const BUDGET_BYTES = 30_000;
// String-Literale, die motion auch minifiziert behaelt und die kein App-Code schreibt:
// der Stil-Schluessel der Render-Basis, das Attribut von AnimatePresence (popLayout)
// und die Gesten-Props der Features.
export const MOTION_MARKERS = ["transformPerspective", "data-motion-pop-id", "whileHover"];
const THREE_MARKERS = ["WebGLRenderer", "__THREE__"];
export function isMotionChunk(source) {
if (THREE_MARKERS.every((marker) => source.includes(marker))) return false;
return MOTION_MARKERS.some((marker) => source.includes(marker));
}
/**
* Misst alle motion-Chunks unter chunksDir (gzip level 9) und zaehlt sie zusammen.
* @returns {{ ok: boolean, files: { file: string, gzipBytes: number }[], gzipBytes: number, budget: number, message: string }}
*/
export function checkMotionBudget(chunksDir, budget = BUDGET_BYTES) {
const files = [];
for (const name of readdirSync(chunksDir)) {
if (!name.endsWith(".js")) continue;
const source = readFileSync(path.join(chunksDir, name));
if (!isMotionChunk(source.toString("latin1"))) continue;
files.push({ file: name, gzipBytes: gzipSync(source, { level: 9 }).length });
}
const gzipBytes = files.reduce((sum, entry) => sum + entry.gzipBytes, 0);
if (files.length === 0) {
return {
ok: false,
files,
gzipBytes,
budget,
message: `Kein motion-Chunk in ${chunksDir} gefunden (Marker: ${MOTION_MARKERS.join(", ")}). Check kaputt oder Bundle umgebaut?`,
};
}
const ok = gzipBytes <= budget;
const list = files.map((entry) => `${entry.file} ${entry.gzipBytes}`).join(", ");
return {
ok,
files,
gzipBytes,
budget,
message: `motion: ${gzipBytes} Byte gzip in ${files.length} Chunks (${list}), Budget ${budget} Byte${ok ? "" : " UEBERSCHRITTEN"}`,
};
}
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
const chunksDir = path.resolve(process.cwd(), ".next/static/chunks");
let result;
try {
result = checkMotionBudget(chunksDir);
} catch (error) {
console.error(`check-motion-budget: ${chunksDir} nicht lesbar (zuerst next build?): ${error.message}`);
process.exit(1);
}
(result.ok ? console.log : console.error)(result.message);
process.exit(result.ok ? 0 : 1);
}
@@ -0,0 +1,52 @@
// @vitest-environment node
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { randomBytes } from "node:crypto";
import { tmpdir } from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { BUDGET_BYTES, MOTION_MARKERS, checkMotionBudget, isMotionChunk } from "./check-motion-budget.mjs";
const motionChunk = (filler: string, marker = MOTION_MARKERS[0]) => `/*${marker}*/var a="${marker}";${filler}`;
// Hex of random bytes is barely compressible by gzip (about 50 %).
const noise = (bytes: number) => randomBytes(bytes).toString("hex");
describe("check-motion-budget (spec 17.6: motion at most 30 kB gzip)", () => {
let dir: string;
beforeEach(() => {
dir = mkdtempSync(path.join(tmpdir(), "motion-budget-"));
});
afterEach(() => rmSync(dir, { recursive: true, force: true }));
it("adds up every motion chunk (the shell part and the lazily loaded features)", () => {
writeFileSync(path.join(dir, "core.js"), motionChunk(noise(500), MOTION_MARKERS[0]));
writeFileSync(path.join(dir, "features.js"), motionChunk(noise(500), MOTION_MARKERS.at(-1)));
writeFileSync(path.join(dir, "app.js"), "console.log('Außerdem heute')");
const result = checkMotionBudget(dir);
expect(result.ok).toBe(true);
expect(result.files.map(({ file }) => file).sort()).toEqual(["core.js", "features.js"]);
expect(result.gzipBytes).toBe(result.files.reduce((sum, { gzipBytes }) => sum + gzipBytes, 0));
});
it("fails when the motion chunks together are over budget", () => {
writeFileSync(path.join(dir, "a.js"), motionChunk(noise(400)));
writeFileSync(path.join(dir, "b.js"), motionChunk(noise(400)));
const result = checkMotionBudget(dir, 600);
expect(result.ok).toBe(false);
expect(result.gzipBytes).toBeGreaterThan(600);
});
it("fails when no motion chunk exists (check broken or bundle rebuilt)", () => {
writeFileSync(path.join(dir, "app.js"), "export const a = 1;");
const result = checkMotionBudget(dir);
expect(result.ok).toBe(false);
expect(result.files).toEqual([]);
});
it("does not count the three.js chunk", () => {
expect(isMotionChunk(`/*WebGLRenderer*/window.__THREE__="186";${MOTION_MARKERS[0]}`)).toBe(false);
});
it("uses the spec budget of 30 kB by default", () => {
expect(BUDGET_BYTES).toBe(30_000);
});
});
@@ -0,0 +1,64 @@
#!/usr/bin/env node
/**
* T5 motion codemod (spec 17.2, 17.4, 17.7, Task A11). Pure and idempotent:
*
* - `motion-safe:` only asks the system and skips the level chosen in the app. Movement behind it
* (translate, scale, rotate, slide-, zoom-, animate-, transition-transform, also behind further
* variants) moves to `motion-full:`.
* - Colour and opacity transitions need no variant ("Aus" sets every duration to zero):
* `motion-safe:transition-colors`/`-opacity` lose the variant.
* - Pulsing and spinning are full motion: a bare `animate-pulse`/`animate-spin` gets
* `motion-full:`, and `motion-reduce:animate-none` (the old opt-out) goes.
*
* Hover lifts, `transition-all` and overlay timings are changed by hand (they need a judgement).
*
* CLI: node scripts/codemod-t5-motion.mjs [--check] <file or directory…>
* --check lists the files that would change and exits 1 if there are any.
*/
import { readdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
import { join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
/** Characters that belong to a class token (so a match never starts inside one). */
const TOKEN_CHAR = String.raw`[\w:/\[\]().%-]`;
/** Further variants between the motion variant and the utility (`data-open:`, `group-hover:`). */
const VARIANTS = String.raw`(?:[^\s"'\`:]+:)*`;
const MOVEMENT = String.raw`-?(?:translate|scale|rotate|slide-|zoom-|animate-|transition-transform)`;
const SAFE_MOVEMENT = new RegExp(String.raw`(?<!${TOKEN_CHAR})motion-safe:(?=${VARIANTS}${MOVEMENT})`, "g");
const SAFE_FADE = new RegExp(String.raw`(?<!${TOKEN_CHAR})motion-safe:(?=transition-(?:colors|opacity)(?!${TOKEN_CHAR}))`, "g");
const REDUCE_NONE = new RegExp(String.raw` ?(?<!${TOKEN_CHAR})motion-reduce:animate-none(?!${TOKEN_CHAR})`, "g");
const BARE_PULSE_SPIN = new RegExp(String.raw`(?<!${TOKEN_CHAR})animate-(pulse|spin)(?![\w-])`, "g");
/** Rewrites the motion classes of one source text. */
export function transform(text) {
return text
.replace(SAFE_MOVEMENT, "motion-full:")
.replace(SAFE_FADE, "")
.replace(REDUCE_NONE, "")
.replace(BARE_PULSE_SPIN, "motion-full:animate-$1");
}
function filesOf(path) {
if (statSync(path).isDirectory()) {
return readdirSync(path).flatMap((name) => filesOf(join(path, name)));
}
return /\.tsx?$/.test(path) && !/\.spec\./.test(path) ? [path] : [];
}
if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
const args = process.argv.slice(2);
const check = args.includes("--check");
const changed = args
.filter((arg) => arg !== "--check")
.flatMap((arg) => filesOf(resolve(arg)))
.filter((file) => {
const before = readFileSync(file, "utf8");
const after = transform(before);
if (after === before) return false;
if (!check) writeFileSync(file, after);
return true;
});
for (const file of changed) console.log(file);
if (check && changed.length > 0) process.exit(1);
}
@@ -0,0 +1,47 @@
import { describe, expect, it } from "vitest";
import { transform } from "./codemod-t5-motion.mjs";
describe("codemod t5 motion (spec 17.2, 17.4, 17.7)", () => {
it("puts movement behind motion-full: instead of motion-safe:", () => {
expect(transform('cn("size-4 motion-safe:transition-transform", open && "rotate-180")')).toBe(
'cn("size-4 motion-full:transition-transform", open && "rotate-180")',
);
expect(transform('"holo-stage bg-holo-bg motion-safe:animate-pulse"')).toBe('"holo-stage bg-holo-bg motion-full:animate-pulse"');
expect(transform('" motion-safe:animate-spin"')).toBe('" motion-full:animate-spin"');
expect(transform('"motion-safe:data-open:zoom-in-95 motion-safe:slide-in-from-top-2"')).toBe(
'"motion-full:data-open:zoom-in-95 motion-full:slide-in-from-top-2"',
);
expect(transform('"motion-safe:-translate-y-1 motion-safe:scale-95 motion-safe:rotate-90"')).toBe(
'"motion-full:-translate-y-1 motion-full:scale-95 motion-full:rotate-90"',
);
});
it("drops the variant from colour and opacity transitions (off sets their duration to zero)", () => {
expect(transform('"rounded-lg px-2 motion-safe:transition-colors hover:bg-muted"')).toBe(
'"rounded-lg px-2 transition-colors hover:bg-muted"',
);
expect(transform('"motion-safe:transition-opacity"')).toBe('"transition-opacity"');
});
it("gates bare pulse and spin behind full motion and drops motion-reduce:animate-none", () => {
expect(transform('"size-4 animate-spin motion-reduce:animate-none"')).toBe('"size-4 motion-full:animate-spin"');
expect(transform('"h-14 animate-pulse rounded-xl"')).toBe('"h-14 motion-full:animate-pulse rounded-xl"');
expect(transform('"size-4 animate-spin"')).toBe('"size-4 motion-full:animate-spin"');
});
it("leaves other animate classes, tokens and identifiers alone", () => {
const untouched = [
'"data-open:animate-in data-closed:animate-out"',
'"motion-full:animate-pulse"',
"const isAnimating = animate-spinner;",
'"animate-page-in"',
"// motion-safe: is forbidden for movement",
];
for (const text of untouched) expect(transform(text)).toBe(text);
});
it("is idempotent", () => {
const once = transform('"a motion-safe:transition-transform animate-spin motion-reduce:animate-none motion-safe:transition-colors"');
expect(transform(once)).toBe(once);
});
});
@@ -66,7 +66,7 @@ export default function HiveEditorPage() {
);
}
if (!colony.data || !hive.data || !catalog) {
return <div role="status" aria-label="Beute wird geladen" className="holo-stage fixed inset-0 z-40 bg-holo-bg motion-safe:animate-pulse" />;
return <div role="status" aria-label="Beute wird geladen" className="holo-stage fixed inset-0 z-40 bg-holo-bg motion-full:animate-pulse" />;
}
return (
<HiveEditor
+93 -90
View File
@@ -8,6 +8,7 @@ import { toast } from "sonner";
import { useAuth } from "@/lib/auth/auth-context";
import { useAppearance } from "@/lib/appearance/appearance";
import { finishHandOvers, purgeStaleDraftPhotos } from "@/lib/photos/store";
import { AppMotion } from "@/components/app-motion";
import { LogoutConfirmDialog } from "@/components/auth/logout-confirm-dialog";
import { BrandMark } from "@/components/brand-mark";
import { NotificationBell } from "@/components/notification-bell";
@@ -84,99 +85,101 @@ export default function AppLayout({ children }: { children: ReactNode }) {
// scroll container (overflow auto/scroll/hidden): it would capture
// `position: sticky` and the StickySubmit save bar would never stick.
return (
<div className="flex min-h-screen pr-(--safe-area-right) pl-(--safe-area-left)">
<a
href={`#${MAIN_CONTENT_ID}`}
inert={isShellInert}
onClick={(event) => {
// The hash alone would not move the focus into a Next.js page reliably.
event.preventDefault();
pageContentRef.current?.focus();
pageContentRef.current?.scrollIntoView?.({ block: "start" });
}}
className="sr-only focus:not-sr-only focus:fixed focus:top-2 focus:left-2 focus:z-50 focus:rounded-lg focus:bg-card focus:px-4 focus:py-3 focus:text-sm focus:font-medium focus:shadow-[var(--shadow-elevated)]"
>
Zum Inhalt springen
</a>
<div className="contents" inert={isShellInert}>
<Sidebar user={user} />
</div>
<div className="flex min-w-0 flex-1 flex-col">
<header
<AppMotion>
<div className="flex min-h-screen pr-(--safe-area-right) pl-(--safe-area-left)">
<a
href={`#${MAIN_CONTENT_ID}`}
inert={isShellInert}
className="sticky top-0 z-10 flex items-center justify-between gap-3 bg-card/85 px-4 py-3 shadow-[var(--shadow-soft)] backdrop-blur-md sm:justify-end sm:px-6">
<Link
href="/dashboard"
className="flex items-center font-heading font-semibold sm:hidden"
>
<BrandMark withWordmark />
</Link>
<div className="flex shrink-0 items-center gap-2 text-sm">
<QuickAddSheet user={user} variant="header" />
<NotificationBell />
<DropdownMenu>
<DropdownMenuTrigger className="rounded-full" aria-label="Konto-Menü">
<UserAvatar name={user.name} hasAvatar={user.hasAvatar} size="sm" />
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuGroup>
<DropdownMenuLabel className="max-w-48 truncate font-normal text-muted-foreground">
{user.email}
</DropdownMenuLabel>
</DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuCheckboxItem
checked={sunActive}
onCheckedChange={(checked) => {
setContrast(checked ? "sun" : "normal");
toast.success(checked ? "Sonnenmodus an" : "Sonnenmodus aus");
}}
className="min-h-11"
>
<Sun aria-hidden="true" />
Sonnenmodus
</DropdownMenuCheckboxItem>
<DropdownMenuItem render={<Link href="/profile" />}>
Profil
</DropdownMenuItem>
<DropdownMenuItem render={<Link href="/settings/voice" />}>
Sprache &amp; Datenschutz
</DropdownMenuItem>
<DropdownMenuItem
variant="destructive"
onClick={() => setIsLogoutOpen(true)}
>
Abmelden
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</header>
<OfflineBand
userId={user.id}
pageContentRef={pageContentRef}
syncEnabled={sessionMode === "online"}
inert={isShellInert}
/>
<div
id={MAIN_CONTENT_ID}
ref={pageContentRef}
tabIndex={-1}
className={`flex-1 ${contentPaddingClass} sm:pb-(--safe-area-bottom)`}
onClick={(event) => {
// The hash alone would not move the focus into a Next.js page reliably.
event.preventDefault();
pageContentRef.current?.focus();
pageContentRef.current?.scrollIntoView?.({ block: "start" });
}}
className="sr-only focus:not-sr-only focus:fixed focus:top-2 focus:left-2 focus:z-50 focus:rounded-lg focus:bg-card focus:px-4 focus:py-3 focus:text-sm focus:font-medium focus:shadow-[var(--shadow-elevated)]"
>
<PageTransition>{children}</PageTransition>
Zum Inhalt springen
</a>
<div className="contents" inert={isShellInert}>
<Sidebar user={user} />
</div>
<div className="flex min-w-0 flex-1 flex-col">
<header
inert={isShellInert}
className="sticky top-0 z-10 flex items-center justify-between gap-3 bg-card/85 px-4 py-3 shadow-[var(--shadow-soft)] backdrop-blur-md sm:justify-end sm:px-6">
<Link
href="/dashboard"
className="flex items-center font-heading font-semibold sm:hidden"
>
<BrandMark withWordmark />
</Link>
<div className="flex shrink-0 items-center gap-2 text-sm">
<QuickAddSheet user={user} variant="header" />
<NotificationBell />
<DropdownMenu>
<DropdownMenuTrigger className="rounded-full" aria-label="Konto-Menü">
<UserAvatar name={user.name} hasAvatar={user.hasAvatar} size="sm" />
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuGroup>
<DropdownMenuLabel className="max-w-48 truncate font-normal text-muted-foreground">
{user.email}
</DropdownMenuLabel>
</DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuCheckboxItem
checked={sunActive}
onCheckedChange={(checked) => {
setContrast(checked ? "sun" : "normal");
toast.success(checked ? "Sonnenmodus an" : "Sonnenmodus aus");
}}
className="min-h-11"
>
<Sun aria-hidden="true" />
Sonnenmodus
</DropdownMenuCheckboxItem>
<DropdownMenuItem render={<Link href="/profile" />}>
Profil
</DropdownMenuItem>
<DropdownMenuItem render={<Link href="/settings/voice" />}>
Sprache &amp; Datenschutz
</DropdownMenuItem>
<DropdownMenuItem
variant="destructive"
onClick={() => setIsLogoutOpen(true)}
>
Abmelden
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</header>
<OfflineBand
userId={user.id}
pageContentRef={pageContentRef}
syncEnabled={sessionMode === "online"}
inert={isShellInert}
/>
<div
id={MAIN_CONTENT_ID}
ref={pageContentRef}
tabIndex={-1}
className={`flex-1 ${contentPaddingClass} sm:pb-(--safe-area-bottom)`}
>
<PageTransition>{children}</PageTransition>
</div>
</div>
<div className="contents" inert={isShellInert}>
<BottomNav user={user} />
<QuickAddSheet user={user} variant="fab" />
</div>
<LogoutConfirmDialog
open={isLogoutOpen}
onOpenChange={setIsLogoutOpen}
userId={user.id}
onLoggedOut={() => router.push("/login")}
/>
</div>
<div className="contents" inert={isShellInert}>
<BottomNav user={user} />
<QuickAddSheet user={user} variant="fab" />
</div>
<LogoutConfirmDialog
open={isLogoutOpen}
onOpenChange={setIsLogoutOpen}
userId={user.id}
onLoggedOut={() => router.push("/login")}
/>
</div>
</AppMotion>
);
}
@@ -3,10 +3,15 @@
import { ReactNode } from "react";
import { usePathname } from "next/navigation";
/**
* A new page comes in (spec 17.4): at "Voll" it fades in and rises 6 px (--motion-enter,
* --ease-out-expo), at "Dezent" or with the system's reduce motion it only fades (globals.css),
* at "Aus" it stands at once. There is no exit: the old page goes at once, navigation never waits.
*/
export function PageTransition({ children }: { children: ReactNode }) {
const pathname = usePathname();
return (
<div key={pathname} className="motion-safe:animate-page-in">
<div key={pathname} className="animate-page-in">
{children}
</div>
);
@@ -41,7 +41,7 @@ export function RuleCardShell({ rule, open, onToggle, children }: RuleCardShellP
<span className="line-clamp-2 text-xs text-muted-foreground">{ruleSummary(rule)}</span>
</span>
<ChevronDown
className={cn("mt-1 size-4 shrink-0 motion-safe:transition-transform", open && "rotate-180")}
className={cn("mt-1 size-4 shrink-0 motion-full:transition-transform motion-full:duration-(--motion-settle)", open && "rotate-180")}
aria-hidden
/>
</button>
+2 -7
View File
@@ -17,18 +17,13 @@ function SidebarLink({ item, pathname }: { item: NavItem; pathname: string }) {
href={item.href}
aria-current={isActive ? "page" : undefined}
className={cn(
"group relative flex items-center gap-2.5 rounded-xl px-3 py-2 text-sm font-medium transition-all duration-150",
"group relative flex items-center gap-2.5 rounded-xl px-3 py-2 text-sm font-medium transition-colors",
isActive
? "bg-primary text-primary-foreground shadow-[var(--shadow-soft)]"
: "text-muted-foreground hover:bg-accent/70 hover:text-foreground",
)}
>
<Icon
className={cn(
"size-4.5 shrink-0 transition-transform duration-150",
!isActive && "motion-full:group-hover:scale-110",
)}
/>
<Icon className="size-4.5 shrink-0" />
<span className="truncate">{item.label}</span>
{item.counter ? <NavCounter counter={item.counter} active={isActive} /> : null}
</Link>
@@ -505,7 +505,7 @@ describe("SuggestionsPage", () => {
const toggle = await screen.findByRole("button", { name: "Verworfene (30 Tage)" });
const chevron = toggle.querySelector("svg");
expect(chevron).toHaveClass("motion-safe:transition-transform");
expect(chevron).toHaveClass("motion-full:transition-transform");
expect(chevron).not.toHaveClass("transition-transform");
});
@@ -347,7 +347,7 @@ export default function SuggestionsPage() {
>
<ChevronDown
aria-hidden
className={cn("motion-safe:transition-transform", showDismissed && "rotate-180")}
className={cn("motion-full:transition-transform motion-full:duration-(--motion-settle)", showDismissed && "rotate-180")}
/>
Verworfene (30 Tage)
</Button>
@@ -26,6 +26,7 @@ import {
} from "@/lib/voice/use-voice-recording-switch";
import { LocalVoiceCard, VoiceCaptureCard } from "@/components/voice/voice-capture-card";
import { EmptyState } from "@/components/empty-state";
import { PresenceItem, PresenceList } from "@/components/motion/presence-list";
import { PageHeader } from "@/components/page-header";
import { ErrorState, LoadingState } from "@/components/query-state";
import { Button } from "@/components/ui/button";
@@ -175,19 +176,19 @@ function VoiceNotes() {
/>
) : null}
{isEmpty ? null : (
<ul className="flex flex-col gap-3" aria-label="Sprachnotizen">
<PresenceList className="flex flex-col gap-3" aria-label="Sprachnotizen">
{local.map((note) => (
<li key={note.id}>
<PresenceItem key={note.id}>
<LocalVoiceCard
note={note}
colonyName={colonyName(note.colonyId)}
online={online}
onDiscard={() => discardLocal.mutate(note.id)}
/>
</li>
</PresenceItem>
))}
{listed.map((item) => (
<li key={item.id}>
<PresenceItem key={item.id}>
<VoiceCaptureCard
item={item}
extractionEnabled={extractionEnabled}
@@ -195,9 +196,9 @@ function VoiceNotes() {
onRetry={() => retry.mutate(item.id)}
onDiscard={() => discard.mutate(item.id)}
/>
</li>
</PresenceItem>
))}
</ul>
</PresenceList>
)}
</div>
);
+61 -47
View File
@@ -93,7 +93,7 @@
--radius-2xl: calc(var(--radius) * 1.8);
--radius-3xl: calc(var(--radius) * 2.2);
--radius-4xl: calc(var(--radius) * 2.6);
--animate-page-in: page-in 260ms ease-out;
--animate-page-in: page-in var(--motion-enter) var(--ease-out-expo);
}
@keyframes page-in {
@@ -564,8 +564,19 @@ html {
}
}
/* Motion tokens (spec 17.3). Mirrored in lib/motion/tokens.ts; tokens.spec.ts keeps both equal.
No DOM transition is longer than --motion-moment; exits are never longer than --motion-fade. */
:root {
--motion-fade: 150ms;
--motion-enter: 200ms;
--motion-settle: 350ms;
--motion-moment: 420ms;
--motion-shift: 8px;
--motion-spring: 500ms;
--ease-out-expo: cubic-bezier(0.16, 1, 0.3, 1);
--ease-in: cubic-bezier(0.4, 0, 1, 1);
/* The hive's flight spring (stiffness 210, damping 21) over --motion-spring: springEasing(). */
--ease-spring: linear(0, 0.039, 0.113, 0.209, 0.314, 0.421, 0.524, 0.618, 0.702, 0.775, 0.837, 0.887, 0.928, 0.959, 0.983, 1, 1.012, 1.02, 1.024, 1.026, 1.026, 1.024, 1.022, 1.02, 1.017, 1.014, 1.012, 1.009, 1.007, 1.005, 1.004, 1);
}
/* Off: every state change is instant. Colour and icon still carry the state. */
@@ -578,17 +589,47 @@ html {
transition-delay: 0ms !important;
}
/* Subtle / system reduce: keep fades, drop movement. */
:root[data-motion="subtle"] .motion-safe\:animate-page-in {
/* Subtle / system reduce: keep fades, drop movement (spec 17.2). */
:root[data-motion="subtle"] .animate-page-in {
animation-name: fade-in;
animation-duration: var(--motion-fade);
}
@media (prefers-reduced-motion: reduce) {
.motion-safe\:animate-page-in {
animation: fade-in 160ms ease-out;
.animate-page-in {
animation-name: fade-in;
animation-duration: var(--motion-fade);
}
}
:root[data-motion="subtle"] [data-slot="card"] {
translate: none !important;
/* Overlays at "Dezent" and with the system's reduce motion: they only fade, and in --motion-fade
both ways (spec 17.2); the move behind motion-full: is off anyway. */
:root[data-motion="subtle"] [data-slot="sheet-content"],
:root[data-motion="subtle"] [data-slot="sheet-overlay"],
:root[data-motion="subtle"] [data-slot="dialog-content"],
:root[data-motion="subtle"] [data-slot="dialog-overlay"],
:root[data-motion="subtle"] [data-slot="dropdown-menu-content"],
:root[data-motion="subtle"] [data-slot="dropdown-menu-sub-content"],
:root[data-motion="subtle"] [data-slot="select-content"] {
transition-duration: var(--motion-fade);
}
@media (prefers-reduced-motion: reduce) {
[data-slot="sheet-content"],
[data-slot="sheet-overlay"],
[data-slot="dialog-content"],
[data-slot="dialog-overlay"],
[data-slot="dropdown-menu-content"],
[data-slot="dropdown-menu-sub-content"],
[data-slot="select-content"] {
transition-duration: var(--motion-fade);
}
}
/* Toasts (spec 17.4): Sonner's own slide is full motion. At "Dezent" a toast stands at its place
at once and only fades; Sonner itself drops every transition under system reduce. */
:root[data-motion="subtle"] [data-sonner-toast],
:root[data-motion="subtle"] [data-sonner-toast] > * {
transition-property: opacity !important;
transition-duration: var(--motion-fade) !important;
}
@keyframes fade-in {
@@ -601,7 +642,8 @@ html {
}
/* --- Task completion: the one authored moment ------------------------------
The honey cell fills, the tick draws itself, then the row folds away. */
The honey cell fills (--motion-moment), the tick draws itself, then the row leaves the list
(TodayPanel: AnimatePresence, the others move up with the flight spring). */
.honey-check {
position: relative;
display: inline-grid;
@@ -620,8 +662,8 @@ html {
border: 2px solid var(--input);
background: var(--card);
transition:
border-color 150ms ease-out,
background-color 150ms ease-out;
border-color var(--motion-fade) ease-out,
background-color var(--motion-fade) ease-out;
}
.honey-check::after {
content: "";
@@ -630,7 +672,7 @@ html {
border-radius: 0.6rem;
background: var(--primary);
clip-path: circle(0% at 50% 60%);
transition: clip-path 420ms var(--ease-out-expo);
transition: clip-path var(--motion-moment) var(--ease-out-expo);
}
.honey-check:hover::before {
border-color: var(--primary);
@@ -643,7 +685,8 @@ html {
height: 1.1rem;
stroke-dasharray: 24;
stroke-dashoffset: 24;
transition: stroke-dashoffset 260ms var(--ease-out-expo) 160ms;
/* Starts as the cell is half full and ends with it: inside the moment. */
transition: stroke-dashoffset calc(var(--motion-moment) - 160ms) var(--ease-out-expo) 160ms;
}
.honey-check[aria-checked="true"]::after {
clip-path: circle(75% at 50% 60%);
@@ -652,53 +695,25 @@ html {
stroke-dashoffset: 0;
}
.today-row {
display: grid;
grid-template-rows: 1fr;
opacity: 1;
transition:
grid-template-rows 320ms var(--ease-out-expo) 380ms,
opacity 220ms ease-out 360ms;
}
.today-row > * {
min-height: 0;
overflow: hidden;
}
.today-row[data-leaving="true"] {
grid-template-rows: 0fr;
opacity: 0;
}
/* The next step changes: bring the new one in with a short focus pull. */
/* The next step changes: the new one comes in by --motion-shift (no blur: filter never moves). */
@keyframes next-step-in {
from {
opacity: 0;
filter: blur(6px);
transform: translateY(8px);
transform: translateY(var(--motion-shift));
}
to {
opacity: 1;
filter: blur(0);
transform: none;
}
}
@keyframes next-step-fade {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
.next-step-enter {
animation: next-step-in 480ms var(--ease-out-expo);
animation: next-step-in var(--motion-enter) var(--ease-out-expo);
}
:root[data-motion="subtle"] .next-step-enter {
animation: next-step-fade 200ms ease-out;
animation: fade-in var(--motion-fade) ease-out;
}
/* The honey-check moment (cell fills, tick draws itself) belongs to full motion; subtle keeps
the tick but drops the slow fill and the stroke delay. */
:root[data-motion="subtle"] .today-row,
:root[data-motion="subtle"] .honey-check::after,
:root[data-motion="subtle"] .honey-check svg {
transition-duration: 120ms;
@@ -706,12 +721,11 @@ html {
}
@media (prefers-reduced-motion: reduce) {
.next-step-enter {
animation: next-step-fade 200ms ease-out;
animation: fade-in var(--motion-fade) ease-out;
}
.today-row,
.honey-check::after,
.honey-check svg {
transition-duration: 120ms;
transition-delay: 0ms;
}
}
}
@@ -0,0 +1,40 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { render, screen, waitFor } from "@testing-library/react";
const load = vi.fn(() => Promise.resolve({}));
vi.mock("@/components/motion/presence-list", () => ({ loadMotionRuntime: () => load() }));
const { AppMotion } = await import("./app-motion");
describe("AppMotion (spec 17.2, 17.6)", () => {
afterEach(() => load.mockClear());
it("renders its children right away, without waiting for motion", () => {
render(
<AppMotion>
<p>Inhalt</p>
</AppMotion>,
);
expect(screen.getByText("Inhalt")).toBeInTheDocument();
});
it("preloads the one motion chunk once the shell has mounted", async () => {
render(
<AppMotion>
<p>Inhalt</p>
</AppMotion>,
);
await waitFor(() => expect(load).toHaveBeenCalledTimes(1));
});
it("keeps the app running when the chunk cannot be loaded (offline, first visit)", async () => {
load.mockReturnValueOnce(Promise.reject(new Error("offline")));
render(
<AppMotion>
<p>Inhalt</p>
</AppMotion>,
);
await waitFor(() => expect(load).toHaveBeenCalled());
expect(screen.getByText("Inhalt")).toBeInTheDocument();
});
});
@@ -0,0 +1,18 @@
"use client";
import { useEffect, type ReactNode } from "react";
import { loadMotionRuntime } from "@/components/motion/presence-list";
/**
* The motion frame of the (app) layout (spec 17.2, 17.6). It carries no `motion` code itself: once
* the shell has mounted it preloads the one async motion chunk (motion-runtime.tsx: LazyMotion,
* MotionConfig per level, the animated list), so lists are animated by the time they need it and
* login, Herkunft and every first load stay free of motion.
*/
export function AppMotion({ children }: { children: ReactNode }) {
useEffect(() => {
// Without the chunk (offline, first visit) lists simply stay plain.
loadMotionRuntime().catch(() => undefined);
}, []);
return children;
}
@@ -50,7 +50,7 @@ export function RestrictionOverride({ onReason }: RestrictionOverrideProps) {
Ausnahme mit Begründung
<ChevronDown
aria-hidden
className={cn("size-4 motion-safe:transition-transform", open && "rotate-180")}
className={cn("size-4 motion-full:transition-transform motion-full:duration-(--motion-settle)", open && "rotate-180")}
/>
</button>
{open ? (
@@ -83,9 +83,9 @@ function targetIndex(key: string, current: number, count: number): number | null
const OPTION_CLASS: Record<ChoiceAppearance, string> = {
buttons:
"inline-flex min-h-11 min-w-0 items-center justify-center gap-1.5 rounded-lg px-2 py-1 text-base motion-safe:transition-colors",
"inline-flex min-h-11 min-w-0 items-center justify-center gap-1.5 rounded-lg px-2 py-1 text-base transition-colors",
cards:
"flex min-h-14 w-full items-start gap-3 rounded-lg px-3 py-2 text-left text-base motion-safe:transition-colors",
"flex min-h-14 w-full items-start gap-3 rounded-lg px-3 py-2 text-left text-base transition-colors",
};
const MARK_ICON = { tick: Check, dash: Minus } as const;
@@ -68,7 +68,7 @@ export function FormSection({
<ChevronDown
aria-hidden
className={cn(
"size-4 motion-safe:transition-transform",
"size-4 motion-full:transition-transform motion-full:duration-(--motion-settle)",
open && "rotate-180",
)}
/>
@@ -27,7 +27,7 @@ export interface NumberStepperProps {
}
const STEP_BUTTON_CLASS =
"inline-flex min-h-11 min-w-11 items-center justify-center rounded-xl border border-input bg-card text-xl font-semibold motion-safe:transition-colors hover:bg-muted disabled:pointer-events-none disabled:opacity-50";
"inline-flex min-h-11 min-w-11 items-center justify-center rounded-xl border border-input bg-card text-xl font-semibold transition-colors hover:bg-muted disabled:pointer-events-none disabled:opacity-50";
const MAX_DIGITS = 4;
const MAX_VALUE = 10 ** MAX_DIGITS - 1;
@@ -1,7 +1,8 @@
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, expect, it, vi } from "vitest";
import { ZoomControls } from "./editor-stage-overlay";
import { DragChip, ZoomControls } from "./editor-stage-overlay";
import type { HiveDragView } from "./use-hive-drag";
describe("ZoomControls", () => {
it("zooms in and out in steps", async () => {
@@ -26,3 +27,28 @@ describe("ZoomControls", () => {
expect(screen.getByRole("button", { name: "Verkleinern" })).toHaveAttribute("aria-disabled", "false");
});
});
describe("DragChip", () => {
const drag: HiveDragView = {
source: { partTypeId: "box" },
label: "Zarge 3",
slots: [],
hover: { kind: "stack", index: 0 } as unknown as HiveDragView["hover"],
overRemove: false,
};
it("in sun sits on the same solid plate as the hover label: 16 px, white text, no blur (A12 rule)", () => {
render(<DragChip drag={drag} pointer={{ current: null }} />);
const where = screen.getByText("Loslassen setzt ein");
const chip = where.parentElement as HTMLElement;
const classes = chip.className.split(/\s+/);
for (const sunClass of ["sun:bg-holo-label-bg", "sun:border-holo-edge", "sun:text-base", "sun:backdrop-blur-none", "sun:shadow-none"]) {
expect(classes).toContain(sunClass);
}
// The hint line is full white and 16 px in sun, not the dimmed 13 px of the normal stage.
const whereClasses = where.className.split(/\s+/);
expect(whereClasses).toContain("sun:text-holo-label-fg");
expect(whereClasses).toContain("sun:text-base");
expect(whereClasses).not.toContain("text-xs");
});
});
@@ -3,7 +3,7 @@
import { useEffect, useRef } from "react";
import type { RefObject } from "react";
import { Trash2, ZoomIn, ZoomOut } from "lucide-react";
import { GLASS_BUTTON, GLASS_SURFACE } from "../hive-glass";
import { GLASS_BUTTON, GLASS_SURFACE, HOLO_LABEL_PILL } from "../hive-glass";
import { ZOOM_MAX, ZOOM_MIN, stepZoom } from "../hive-scene/camera-fit";
import type { DragPointer, HiveDragView } from "./use-hive-drag";
@@ -91,10 +91,10 @@ export function DragChip({ drag, pointer }: { drag: HiveDragView; pointer: RefOb
<div
ref={chip}
aria-hidden
className={`pointer-events-none fixed start-0 top-0 z-50 rounded-xl px-3 py-1.5 text-sm font-medium ${GLASS_SURFACE}`}
className={`pointer-events-none fixed start-0 top-0 z-50 ${HOLO_LABEL_PILL}`}
>
{drag.label}
{where ? <span className="block text-xs text-holo-label-fg/80">{where}</span> : null}
{where ? <span className="block text-sm text-holo-label-fg/80 sun:text-base sun:text-holo-label-fg">{where}</span> : null}
</div>
);
}
@@ -135,7 +135,7 @@ function HiveCardContent({ colonyId, colony, mode = "view", onOpsChange, initial
<div
role="status"
aria-label="Beute wird geladen"
className="holo-stage min-h-40 flex-1 bg-holo-bg motion-safe:animate-pulse"
className="holo-stage min-h-40 flex-1 bg-holo-bg motion-full:animate-pulse"
/>
);
}
@@ -11,3 +11,10 @@ export const GLASS_SURFACE = `border border-white/15 shadow-[0_8px_24px_-12px_rg
export const GLASS_BUTTON = `inline-flex h-11 shrink-0 items-center justify-center gap-1.5 rounded-xl px-3 text-sm sm:gap-2 sm:px-4 font-medium transition-colors hover:bg-holo-label-bg/90 disabled:cursor-not-allowed disabled:opacity-60 aria-pressed:border-2 aria-pressed:border-holo-edge-strong aria-pressed:bg-holo-edge/25 [&_svg]:size-5 border border-holo-edge/35 shadow-[0_0_18px_-8px_var(--holo-edge)] sun:shadow-none ${GLASS_FILL}`;
export const GLASS_PILL = `inline-flex h-11 shrink-0 items-center gap-2 rounded-full px-4 text-sm font-medium transition-colors hover:bg-holo-label-bg/90 ${GLASS_SURFACE}`;
/**
* A label on the stage: hover labels in the scene and the drag chip in the editor. In sun 16 px on
* a solid plate with white text, without blur and glow (Beute spec 9.3, T5 17.6).
*/
export const HOLO_LABEL_PILL =
"whitespace-nowrap rounded-xl border border-holo-edge/45 bg-holo-label-bg/75 px-3 py-1.5 text-sm font-medium text-holo-label-fg shadow-[0_0_22px_-6px_var(--holo-edge)] backdrop-blur-md sun:border-holo-edge sun:bg-holo-label-bg sun:text-base sun:shadow-none sun:backdrop-blur-none";
@@ -3,13 +3,14 @@
import { Html } from "@react-three/drei";
import type { Vec3 } from "@/lib/hive/layout";
import type { SceneLabel } from "./scene-model";
import { HOLO_LABEL_PILL } from "../hive-glass";
// DOM labels in the scene (spec 9.3): sharp, translatable text in a glass
// pill with a thin leader line. At most three at once (sceneLabels caps it).
// In sun 16 px on a solid plate, without blur and glow (spec 9.3, T5 17.6).
export const HOLO_LABEL_PILL =
"whitespace-nowrap rounded-xl border border-holo-edge/45 bg-holo-label-bg/75 px-3 py-1.5 text-sm font-medium text-holo-label-fg shadow-[0_0_22px_-6px_var(--holo-edge)] backdrop-blur-md sun:border-holo-edge sun:bg-holo-label-bg sun:text-base sun:shadow-none sun:backdrop-blur-none";
// The pill lives in hive-glass.ts (DOM only), so the editor's drag chip can share it without three.
export { HOLO_LABEL_PILL } from "../hive-glass";
export interface HoloLabelProps {
position: Vec3;
@@ -418,6 +418,59 @@ describe("HiveSceneContent in sun: state cues without colour", () => {
expect(idle[0].uniforms.uLinePixels.value).toBe(2);
for (const material of markers) expect(material.uniforms.uTubeCorner.value).toBeCloseTo(0.015 * Math.SQRT2, 9);
});
it("in sun the active slot follows the hover during a drag (white 3 px moves, the rest stays grey 2 px)", async () => {
document.documentElement.setAttribute("data-contrast", "sun");
const state = hiveState();
const layout = hiveLayout(state, catalog);
const slots = dropSlots(state, catalog, { partTypeId: "box" });
expect(slots.length).toBeGreaterThan(1);
const editorFor = (hover: DropSlot | null): SceneEditor => ({
drag: { source: { partTypeId: "box" }, slots, hover },
pointer: { current: null },
subscribePointer: () => () => {},
onHoverSlot: vi.fn(),
onPartPress: vi.fn(),
zoom: 1,
onZoom: vi.fn(),
});
const store = createHiveViewStore();
const scene = (editor: SceneEditor) => (
<HiveViewProvider store={store}>
<SceneKitProvider motion="off">
<HiveSceneContent
layout={layout}
state={state}
catalog={catalog}
partName={(id) => partDisplayName(state, catalog, id)}
onSelectPart={vi.fn()}
onSelectFrame={vi.fn()}
idle={false}
editor={editor}
/>
</SceneKitProvider>
</HiveViewProvider>
);
const renderer = await ReactThreeTestRenderer.create(scene(editorFor(null)));
current = { renderer, store, onSelectPart: vi.fn(), onSelectFrame: vi.fn() };
const activeIndexes = () =>
renderer.scene
.findAll((node) => node.props.name === "drop-slot")
.map((node) => (node.instance as unknown as Mesh).material as ShaderMaterial)
.flatMap((material, index) =>
material.uniforms.uSunGrey.value === 0 ? [{ index, pixels: material.uniforms.uLinePixels.value }] : [],
);
await renderer.advanceFrames(1, 1 / 60);
expect(activeIndexes()).toEqual([]);
await renderer.update(scene(editorFor(slots[1])));
await renderer.advanceFrames(1, 1 / 60);
expect(activeIndexes()).toEqual([{ index: 1, pixels: 3 }]);
await renderer.update(scene(editorFor(slots[0])));
await renderer.advanceFrames(1, 1 / 60);
expect(activeIndexes()).toEqual([{ index: 0, pixels: 3 }]);
});
});
describe("HiveSceneContent drop anchors", () => {
@@ -1,4 +1,5 @@
// Adaptive quality and the flight spring, as pure functions.
import { SPRING_FLIGHT } from "@/lib/motion/tokens";
/** High: reflective floor, MSAA, particles, dispersion, depth of field when fast. Low: none of these. */
export type StageTier = "high" | "low";
@@ -52,8 +53,8 @@ export function createQualityMonitor(
}
/** Spring for flights at motion `full`: a touch of overshoot (damping ratio ≈ 0.72), settled in ~0.5 s. */
export const SPRING_STIFFNESS = 210;
export const SPRING_DAMPING = 21;
export const SPRING_STIFFNESS = SPRING_FLIGHT.stiffness;
export const SPRING_DAMPING = SPRING_FLIGHT.damping;
const SPRING_SUBSTEP = 1 / 120;
const SPRING_EPSILON = 1e-3;
@@ -289,7 +289,7 @@ export function stepToward(current: number, target: number, deltaSeconds: number
}
/** Linear fade used at motion level `subtle` (150 ms). */
export const SUBTLE_FADE_SECONDS = 0.15;
export { SUBTLE_FADE_SECONDS } from "@/lib/motion/tokens";
export function fadeToward(current: number, target: number, deltaSeconds: number, seconds: number): number {
if (seconds <= 0) return target;
@@ -10,12 +10,14 @@ import type { SceneEditor } from "./hive-scene/scene-editor";
import { canUseWebGL, shouldUse3D } from "@/lib/hive/webgl";
import { useEffectiveMotionLevel } from "@/lib/motion/motion-preference";
import type { MotionLevel } from "@/lib/motion/motion-preference";
import { MOTION } from "@/lib/motion/tokens";
import { HiveSvg } from "./hive-svg";
// three, fiber and friends live only in this chunk, fetched when the card is on screen.
const HiveCanvas = dynamic(() => import("./hive-scene/hive-canvas"), { ssr: false });
const FADE_MS: Record<MotionLevel, number> = { full: 400, subtle: 150, off: 0 };
// Full: the stage arrives with its own glow (Beute §9.8); subtle uses the app-wide fade token.
const FADE_MS: Record<MotionLevel, number> = { full: 400, subtle: MOTION.fadeMs, off: 0 };
const FORCED_COLORS_QUERY = "(forced-colors: active)";
/** First context loss remounts the scene once, the second keeps the SVG for good. */
const MAX_CONTEXT_LOSSES = 1;
@@ -88,7 +88,7 @@ export function LabelCompliancePanel({
<div className="flex flex-col gap-3">
{summary ? (
<p aria-live="polite" className="flex items-center gap-2 text-sm font-medium">
{pending ? <Loader aria-hidden className="size-4 animate-spin motion-reduce:animate-none" /> : null}
{pending ? <Loader aria-hidden className="size-4 motion-full:animate-spin" /> : null}
{summary}
</p>
) : null}
@@ -0,0 +1,116 @@
"use client";
import { createContext, useContext, useLayoutEffect, useRef, type ComponentProps, type RefObject } from "react";
import { AnimatePresence, LazyMotion, MotionConfig, domAnimation, useIsPresent } from "motion/react";
import * as m from "motion/react-m";
import type { MotionLevel } from "@/lib/motion/motion-preference";
import { EASE_OUT_EXPO, MOTION, SPRING_EASING_MS, springEasing, transitionFor } from "@/lib/motion/tokens";
/**
* Everything of `motion` the DOM uses, in one module that is only ever loaded with a dynamic
* import (spec 17.6): LazyMotion with its features, MotionConfig per level and the animated list.
* One async chunk for the whole app (AppMotion preloads it), never in a first load, never
* duplicated per page.
*
* Features: domAnimation, not domMax. The `layout` projection of domMax alone is about 14 kB gzip
* more and breaks the 30 kB budget; rows that move up after a removal slide by a measured
* transform instead (FLIP on the flight spring as CSS linear()).
*/
type MotionConfigProps = Omit<Parameters<typeof MotionConfig>[0], "children">;
/**
* Per level (spec 17.2): at "Voll" motion must not ask the system again (the effective level has
* it folded in already); at "Dezent" motion keeps opacity and colour and drops every transform;
* at "Aus" everything is instant.
*/
export const MOTION_CONFIG: Record<MotionLevel, MotionConfigProps> = {
full: { reducedMotion: "never" },
subtle: { reducedMotion: "always" },
off: { reducedMotion: "always", skipAnimations: true, transition: { duration: 0 } },
};
const SPRING_EASING = springEasing();
const SETTLE_EASING = `cubic-bezier(${EASE_OUT_EXPO.join(", ")})`;
/** The motion level, read once per list (not once per row). */
const ListLevel = createContext<MotionLevel>("full");
/**
* Moves an element from `distancePx` lower back to its place on the flight spring (WAAPI, transform
* only). Browsers without CSS `linear()` easing take the settle token instead.
*/
function slideUp(element: HTMLElement, distancePx: number): void {
if (typeof element.animate !== "function") return;
const keyframes = [{ transform: `translateY(${distancePx}px)` }, { transform: "none" }];
try {
element.animate(keyframes, { duration: SPRING_EASING_MS, easing: SPRING_EASING });
} catch {
element.animate(keyframes, { duration: MOTION.settleMs, easing: SETTLE_EASING });
}
}
/**
* Rows that moved up after a render slide from where they were (FLIP). One measurement per row and
* render (offsetTop, which a running transform does not change), then only `transform` moves.
* Leaving rows are out of the flow (popLayout) and hidden, and are not measured.
*/
function useSlideUpAfterRemoval(listRef: RefObject<HTMLUListElement | null>, enabled: boolean): void {
const tops = useRef(new Map<Element, number>());
useLayoutEffect(() => {
const list = listRef.current;
if (!list) return;
const next = new Map<Element, number>();
for (const child of Array.from(list.children)) {
if (!(child instanceof HTMLElement) || child.getAttribute("aria-hidden") === "true") continue;
const top = child.offsetTop;
const before = tops.current.get(child);
if (enabled && before !== undefined && before > top) slideUp(child, before - top);
next.set(child, top);
}
tops.current = next;
});
}
export type MotionListProps = Omit<ComponentProps<"ul">, "ref"> & { level: MotionLevel };
/** The animated PresenceList (see presence-list.tsx for the behaviour per level). */
export function MotionList({ level, children, ...props }: MotionListProps) {
const listRef = useRef<HTMLUListElement>(null);
useSlideUpAfterRemoval(listRef, level === "full");
return (
<LazyMotion features={domAnimation} strict>
<MotionConfig {...MOTION_CONFIG[level]}>
<ListLevel value={level}>
<ul ref={listRef} {...props}>
{/* "Voll": popLayout takes the leaving row out of the flow, positioned against this list.
"Dezent": the row fades in the flow, so it never lies on top of the row below. */}
<AnimatePresence initial={false} mode={level === "full" ? "popLayout" : "sync"}>
{children}
</AnimatePresence>
</ul>
</ListLevel>
</MotionConfig>
</LazyMotion>
);
}
export type MotionItemProps = ComponentProps<"li">;
/** The animated PresenceItem: while it leaves, it is inert and hidden from assistive tech. */
export function MotionItem({ children, ref, ...props }: MotionItemProps) {
const level = useContext(ListLevel);
const isPresent = useIsPresent();
return (
<m.li
{...(props as ComponentProps<typeof m.li>)}
ref={ref}
initial={false}
exit={{ opacity: 0, transition: transitionFor(level, "exit") }}
inert={!isPresent}
aria-hidden={isPresent ? undefined : true}
>
{children}
</m.li>
);
}
@@ -0,0 +1,156 @@
import { useContext } from "react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { MotionConfigContext } from "motion/react";
import { act, render, screen, waitFor } from "@testing-library/react";
import { AppMotion } from "@/components/app-motion";
import { setMotionLevel, type MotionLevel } from "@/lib/motion/motion-preference";
import { MOTION, SPRING_EASING_MS, springEasing } from "@/lib/motion/tokens";
import { PresenceItem, PresenceList, loadMotionRuntime } from "./presence-list";
const ROW_PX = 64;
const originalMatchMedia = window.matchMedia;
const originalAnimate = HTMLElement.prototype.animate;
const offsetTop = Object.getOwnPropertyDescriptor(HTMLElement.prototype, "offsetTop");
function List({ items }: { items: string[] }) {
return (
<AppMotion>
<PresenceList>
{items.map((item) => (
<PresenceItem key={item}>{item}</PresenceItem>
))}
</PresenceList>
</AppMotion>
);
}
async function renderAt(level: MotionLevel, items: string[]) {
act(() => setMotionLevel(level));
// AppMotion preloads the chunk with the shell; a list mounted before it would stay plain.
await loadMotionRuntime();
const view = render(<List items={items} />);
await act(async () => {
await loadMotionRuntime();
await new Promise((resolve) => setTimeout(resolve, 50));
});
return view;
}
const row = (label: string) => screen.getByText(label).closest("li") as HTMLElement;
describe("PresenceList (spec 17.4 „Listenzeile weg“)", () => {
// jsdom has no layout: rows get a size, and offsetTop follows the rows still in the flow.
const rowSize = document.createElement("style");
rowSize.textContent = `li { width: 320px; height: ${ROW_PX - 8}px; }`;
let animate: ReturnType<typeof vi.fn>;
beforeEach(() => {
document.head.appendChild(rowSize);
window.localStorage.clear();
window.matchMedia = (() => ({
matches: false,
addEventListener: () => undefined,
removeEventListener: () => undefined,
})) as unknown as typeof window.matchMedia;
Object.defineProperty(HTMLElement.prototype, "offsetTop", {
configurable: true,
get(this: HTMLElement) {
const inFlow = Array.from(this.parentElement?.children ?? []).filter(
(element) => !element.hasAttribute("data-motion-pop-id"),
);
return Math.max(0, inFlow.indexOf(this)) * ROW_PX;
},
});
animate = vi.fn();
HTMLElement.prototype.animate = animate as unknown as typeof HTMLElement.prototype.animate;
});
afterEach(() => {
rowSize.remove();
window.matchMedia = originalMatchMedia;
HTMLElement.prototype.animate = originalAnimate;
if (offsetTop) Object.defineProperty(HTMLElement.prototype, "offsetTop", offsetTop);
delete document.documentElement.dataset.motion;
});
it("a leaving row is inert and hidden from assistive tech", async () => {
const view = await renderAt("full", ["a", "b", "c"]);
const leaving = row("b");
expect(leaving).not.toHaveAttribute("aria-hidden");
view.rerender(<List items={["a", "c"]} />);
expect(leaving).toHaveAttribute("aria-hidden", "true");
expect(leaving).toHaveAttribute("inert");
await waitFor(() => expect(leaving).not.toBeInTheDocument(), { timeout: 1000 });
});
it("at Voll the rows below slide up from where they were, on the flight spring", async () => {
const view = await renderAt("full", ["a", "b", "c"]);
const below = row("c");
view.rerender(<List items={["a", "c"]} />);
const calls = animate.mock.calls.filter(([, options]) => options?.duration === SPRING_EASING_MS);
expect(calls.map(([keyframes]) => keyframes)).toEqual([
[{ transform: `translateY(${ROW_PX}px)` }, { transform: "none" }],
]);
expect(animate.mock.contexts.filter((_, index) => calls.includes(animate.mock.calls[index]))).toEqual([below]);
expect(calls[0][1]).toMatchObject({ easing: springEasing() });
});
it("at Dezent the leaving row only fades in place and nothing slides", async () => {
const view = await renderAt("subtle", ["a", "b", "c"]);
const leaving = row("b");
view.rerender(<List items={["a", "c"]} />);
// In the flow (no popLayout): it does not lie on top of the row below while it fades.
expect(leaving).not.toHaveAttribute("data-motion-pop-id");
expect(animate.mock.calls.filter(([, options]) => options?.duration === SPRING_EASING_MS)).toEqual([]);
await waitFor(() => expect(leaving).not.toBeInTheDocument(), { timeout: MOTION.fadeMs * 6 });
});
it("at Aus the leaving row is gone at once and nothing slides", async () => {
const view = await renderAt("off", ["a", "b", "c"]);
const leaving = row("b");
view.rerender(<List items={["a", "c"]} />);
await waitFor(() => expect(leaving).not.toBeInTheDocument(), { timeout: 50 });
expect(animate.mock.calls.filter(([, options]) => options?.duration === SPRING_EASING_MS)).toEqual([]);
});
it("sets reducedMotion per level and follows a level change in the same tab (spec 17.2)", async () => {
function ConfigProbe() {
const config = useContext(MotionConfigContext);
return (
<li data-testid="config">
{JSON.stringify({ reducedMotion: config.reducedMotion, skip: config.skipAnimations ?? false })}
</li>
);
}
const config = () => JSON.parse(screen.getByTestId("config").textContent ?? "{}");
act(() => setMotionLevel("full"));
await loadMotionRuntime();
render(
<PresenceList>
<ConfigProbe key="probe" />
</PresenceList>,
);
await act(async () => {
await loadMotionRuntime();
await new Promise((resolve) => setTimeout(resolve, 0));
});
// Full: the effective level already folds in the system setting, so motion must not ask again.
expect(config()).toEqual({ reducedMotion: "never", skip: false });
act(() => setMotionLevel("subtle"));
expect(config()).toEqual({ reducedMotion: "always", skip: false });
act(() => setMotionLevel("off"));
expect(config()).toEqual({ reducedMotion: "always", skip: true });
});
it("renders its rows at once", () => {
render(<List items={["a", "b"]} />);
expect(screen.getAllByRole("listitem")).toHaveLength(2);
});
});
@@ -0,0 +1,84 @@
"use client";
import { createContext, useContext, useEffect, useState, type ComponentProps } from "react";
import { cn } from "@/lib/utils";
import { useEffectiveMotionLevel } from "@/lib/motion/motion-preference";
type MotionRuntime = typeof import("./motion-runtime");
let runtime: MotionRuntime | null = null;
let runtimeLoad: Promise<MotionRuntime> | null = null;
/**
* The one dynamic import of `motion` in the DOM (spec 17.6): AppMotion's features and the animated
* list come from the same async chunk, loaded once after the (app) shell has mounted.
*/
export function loadMotionRuntime(): Promise<MotionRuntime> {
runtimeLoad ??= import("./motion-runtime").then((module) => {
runtime = module;
return module;
});
return runtimeLoad;
}
/**
* The runtime if it was there when the list mounted. A list never switches from plain to animated
* while it lives: that would swap its <li> elements and remount every row (focus, open dialogs,
* input). AppMotion preloads the chunk with the shell, so lists that mount later are animated; one
* that mounted earlier stays plain until it mounts again.
*/
function useMotionRuntime(): MotionRuntime | null {
const [loaded] = useState<MotionRuntime | null>(runtime);
useEffect(() => {
// Without the chunk (offline, first visit) lists simply stay plain.
if (!loaded) loadMotionRuntime().catch(() => undefined);
}, [loaded]);
return loaded;
}
const RuntimeContext = createContext<MotionRuntime | null>(null);
/**
* A list whose rows can leave (spec 17.4 „Listenzeile weg“):
* - "Voll": the row fades out of the flow (--motion-fade, ease-in) while the rows below slide up on
* the flight spring, over `transform` only.
* - "Dezent": the row fades in place (it stays in the flow, so nothing overlaps), then the rest
* stands at its place at once.
* - "Aus": the row is gone at once.
* Rows of the first render and rows arriving later appear at once (data stands still, spec 17.5).
* A list that mounts before the motion chunk is there stays plain: rows come and go at once.
*
* Children are PresenceItem rows (directly or inside a row component that passes `ref` on to it),
* each with a stable `key`.
*/
export function PresenceList({ className, children, ...props }: Omit<ComponentProps<"ul">, "ref">) {
const level = useEffectiveMotionLevel();
const loaded = useMotionRuntime();
const classes = cn("relative", className);
if (!loaded) {
return (
<ul className={classes} {...props}>
{children}
</ul>
);
}
return (
<RuntimeContext value={loaded}>
<loaded.MotionList level={level} className={classes} {...props}>
{children}
</loaded.MotionList>
</RuntimeContext>
);
}
type PresenceItemProps = ComponentProps<"li"> & { [data: `data-${string}`]: string | undefined };
/**
* One row of a PresenceList. While it fades out it is inert and hidden from assistive tech; in a
* plain list it is simply gone at once.
*/
export function PresenceItem(props: PresenceItemProps) {
const loaded = useContext(RuntimeContext);
if (!loaded) return <li {...props} />;
return <loaded.MotionItem {...props} />;
}
@@ -61,11 +61,11 @@ async function discardRejectedEntry(nextSummary: { own: number; failed: number;
await screen.findByRole("button", { name: "1 Eintrag abgelehnt – ansehen" }),
);
const sheet = await screen.findByRole("dialog", { name: "Wartende Einträge" });
// The list loads with the first opening (lazy body): wait for the entry before the next state.
const discardButton = await within(sheet).findByRole("button", { name: /^Verwerfen: Durchsicht vom / });
vi.mocked(summarizeQueue).mockResolvedValue(nextSummary);
vi.mocked(listQueue).mockResolvedValue(nextSummary.own > 0 ? [WAITING] : []);
await userEvent.click(
await within(sheet).findByRole("button", { name: /^Verwerfen: Durchsicht vom / }),
);
await userEvent.click(discardButton);
const confirm = await screen.findByRole("alertdialog", { name: /^Durchsicht vom .+ verwerfen\?$/ });
await userEvent.click(within(confirm).getByRole("button", { name: "Verwerfen" }));
await waitFor(() => expect(discardQueueItem).toHaveBeenCalledWith(REJECTED));
@@ -251,7 +251,7 @@ describe("OfflineBand", () => {
);
expect(await screen.findByRole("dialog", { name: "Wartende Einträge" })).toBeInTheDocument();
expect(listQueue).toHaveBeenCalledWith("user-1");
await waitFor(() => expect(listQueue).toHaveBeenCalledWith("user-1"));
});
it("closes the sheet once nothing is left and keeps focus in the band while it has content", async () => {
@@ -0,0 +1,517 @@
"use client";
import { useRef, type MouseEvent } from "react";
import Link from "next/link";
import { AlertTriangle, Camera, Clock, Mic, RotateCw } from "lucide-react";
import { toast } from "sonner";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { ConfirmDialog } from "@/components/confirm-dialog";
import { PresenceItem, PresenceList } from "@/components/motion/presence-list";
import { PhotoThumbnail } from "@/components/photos/photo-capture-field";
import { Button, buttonVariants } from "@/components/ui/button";
import {
discardQueueItem,
listQueue,
retryBulkWithout,
retryInspectionWithoutHiveChange,
retryInspectionWithoutVoice,
type QueueError,
type QueueItem,
} from "@/lib/offline/db";
import {
entriesSynced,
foreignEntriesWaiting,
photosAttached,
queueRejectionText,
VOICE_TOO_MANY_OPEN_HINT,
voiceRejectionText,
} from "@/lib/offline/queue-texts";
import { syncPendingEntries } from "@/lib/offline/sync";
import { OFFLINE_QUEUE_QUERY_KEY } from "@/lib/offline/use-capture-mutation";
import { discardAllPendingVoice, discardPendingVoice } from "@/lib/voice/pending-voice";
import { discardReadyPhoto, listPendingPhotos, resetPhotoError } from "@/lib/photos/store";
interface PendingQueueBodyProps {
userId: string;
/** Entries of another account on this device - shown only as a count. */
foreignCount: number;
onOpenChange: (open: boolean) => void;
}
interface Named {
label: string;
queuedAt: string;
}
const PHOTO_LABEL = "Foto";
const VOICE_STORE = "pending-voice";
/** 75 seconds as "1:15". */
function durationLabel(totalSec: number): string {
const seconds = Math.round(totalSec);
return `${Math.floor(seconds / 60)}:${String(seconds % 60).padStart(2, "0")}`;
}
/** True for a plain left click; ctrl, meta, shift, alt and middle click open a link elsewhere. */
function isPlainLeftClick(event: MouseEvent): boolean {
return (
event.button === 0 &&
!event.defaultPrevented &&
!event.metaKey &&
!event.ctrlKey &&
!event.shiftKey &&
!event.altKey
);
}
/** The recording page, for the colony of a rejected note. */
function recordAgainHref(item: QueueItem): string {
return `/voice/new?${new URLSearchParams({ colonyId: item.voice?.colonyId ?? "" }).toString()}`;
}
function queuedAtLabel(queuedAt: string): string {
return new Date(queuedAt).toLocaleString("de-DE", { dateStyle: "short", timeStyle: "short" });
}
/**
* „Durchsicht vom 13.10.26, 10:15:05" - a name per entry for its buttons and
* dialog. Seconds tell apart entries of the same minute; a running number
* „(2)" those queued in the same second.
*/
function entryNames(items: readonly Named[]): string[] {
const seen = new Map<string, number>();
return items.map((item) => {
const time = new Date(item.queuedAt).toLocaleString("de-DE", {
dateStyle: "short",
timeStyle: "medium",
});
const name = `${item.label} vom ${time}`;
const occurrence = (seen.get(name) ?? 0) + 1;
seen.set(name, occurrence);
return occurrence === 1 ? name : `${name} (${occurrence})`;
});
}
/** A queued inspection takes its photos along when it is discarded. */
function discardDescription(item: QueueItem): string {
if (!item.photoCount) {
return "Der Eintrag wird von diesem Gerät gelöscht und nicht mehr gesendet.";
}
const photos = item.photoCount === 1 ? "sein Foto" : `seine ${item.photoCount} Fotos`;
return `Der Eintrag und ${photos} werden von diesem Gerät gelöscht und nicht mehr gesendet.`;
}
/** Rejected with the server's reason, or waiting - in words, never by colour alone. */
function QueueStatus({
lastError,
changesHive = false,
isVoice = false,
}: {
lastError?: QueueError;
changesHive?: boolean;
isVoice?: boolean;
}) {
return lastError ? (
<p className="flex items-start gap-2 text-sm text-destructive">
<AlertTriangle className="mt-0.5 size-4 shrink-0" aria-hidden />
<span>
Abgelehnt:{" "}
{isVoice ? voiceRejectionText(lastError) : queueRejectionText(lastError, changesHive)}
</span>
</p>
) : (
<p className="flex items-start gap-2 text-sm text-muted-foreground">
<Clock className="mt-0.5 size-4 shrink-0" aria-hidden />
<span>Wartet auf Senden</span>
</p>
);
}
/** „Ohne V-3 erneut senden“ - or, when a code is unknown, without naming them. */
function ResendWithoutButton({
resend,
disabled,
onResend,
}: {
resend: NonNullable<QueueItem["resendWithout"]>;
disabled: boolean;
onResend: () => void;
}) {
const isNamed = resend.codes.length === resend.colonyIds.length;
const without = isNamed ? resend.codes.join(", ") : "die abgelehnten Völker";
return (
<Button
type="button"
variant="outline"
size="touch"
className="self-start"
disabled={disabled}
onClick={onResend}
>
<RotateCw aria-hidden />
Ohne {without} erneut senden
</Button>
);
}
/**
* What this device still has to send for the signed-in user: each entry with
* its state, a rejected one with the server's reason, and a way to discard it.
* Photos whose inspection is already on the server are listed on their own;
* a rejected one can be sent again. Mounted only while the sheet is open, and
* loaded with it (PendingQueueSheet), so the shell carries none of it.
*/
export function PendingQueueBody({ userId, foreignCount, onOpenChange }: PendingQueueBodyProps) {
const queryClient = useQueryClient();
const { data: items = [], isSuccess } = useQuery({
queryKey: [...OFFLINE_QUEUE_QUERY_KEY, userId, "items"],
queryFn: () => listQueue(userId),
// Reads this device's storage, never the network.
networkMode: "always",
});
const { data: photos = [] } = useQuery({
queryKey: [...OFFLINE_QUEUE_QUERY_KEY, userId, "photos"],
queryFn: () => listPendingPhotos(userId),
networkMode: "always",
});
const discard = useMutation({
mutationFn: (item: QueueItem) => discardQueueItem(item),
networkMode: "always",
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: OFFLINE_QUEUE_QUERY_KEY });
toast.success("Eintrag verworfen");
},
onError: () => toast.error("Eintrag konnte nicht verworfen werden."),
});
const voiceHeadingRef = useRef<HTMLHeadingElement>(null);
const discardVoice = useMutation({
mutationFn: (noteId: string) => discardPendingVoice(noteId, userId),
networkMode: "always",
onSuccess: () => {
// The discard button is gone: keep the focus inside the list.
voiceHeadingRef.current?.focus();
void queryClient.invalidateQueries({ queryKey: OFFLINE_QUEUE_QUERY_KEY });
toast.success("Sprachnotiz verworfen");
},
onError: () => toast.error("Sprachnotiz konnte nicht verworfen werden."),
});
const discardAllVoice = useMutation({
mutationFn: () => discardAllPendingVoice(userId),
networkMode: "always",
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: OFFLINE_QUEUE_QUERY_KEY });
toast.success("Sprachnotizen verworfen");
},
onError: () => toast.error("Sprachnotizen konnten nicht verworfen werden."),
});
const discardPhoto = useMutation({
mutationFn: (photoId: string) => discardReadyPhoto(photoId),
networkMode: "always",
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: OFFLINE_QUEUE_QUERY_KEY });
toast.success("Foto verworfen");
},
onError: () => toast.error("Foto konnte nicht verworfen werden."),
});
// „Erneut senden": clear the rejection, then a normal sync run sends it.
const retryPhoto = useMutation({
mutationFn: async (photoId: string) => {
await resetPhotoError(photoId);
// A sync already running may have passed this photo: it runs once more.
return syncPendingEntries(userId, { afterChange: true });
},
networkMode: "always",
onSuccess: (result) => {
if (result.synced > 0) {
toast.success(entriesSynced(result.synced));
// New files on the server: document lists may be stale now.
void queryClient.invalidateQueries();
return;
}
void queryClient.invalidateQueries({ queryKey: OFFLINE_QUEUE_QUERY_KEY });
},
onError: () => toast.error("Foto konnte nicht erneut gesendet werden."),
});
// „Ohne … erneut senden“: the refused colonies leave the bulk entry (same
// key, rejection cleared), then a normal run sends the rest.
const resendBulk = useMutation({
mutationFn: async ({ id, colonyIds }: { id: number; colonyIds: string[] }) => {
await retryBulkWithout(id, colonyIds);
// A sync already running may have passed this entry: it runs once more.
return syncPendingEntries(userId, { afterChange: true });
},
networkMode: "always",
onSuccess: (result) => {
if (result.synced > 0) {
toast.success(entriesSynced(result.synced));
void queryClient.invalidateQueries();
return;
}
void queryClient.invalidateQueries({ queryKey: OFFLINE_QUEUE_QUERY_KEY });
},
onError: () => toast.error("Eintrag konnte nicht erneut gesendet werden."),
});
// „Ohne Beute-Änderung senden“: the hive change leaves the inspection (same
// key, rejection cleared), then a normal run sends it.
const resendWithoutHive = useMutation({
mutationFn: async (id: number) => {
await retryInspectionWithoutHiveChange(id);
return syncPendingEntries(userId, { afterChange: true });
},
networkMode: "always",
onSuccess: (result) => {
if (result.synced > 0) {
toast.success(entriesSynced(result.synced));
void queryClient.invalidateQueries();
return;
}
void queryClient.invalidateQueries({ queryKey: OFFLINE_QUEUE_QUERY_KEY });
},
onError: () => toast.error("Eintrag konnte nicht erneut gesendet werden."),
});
// „Ohne Sprachnotiz senden“: only after the server said the note is another colony's.
const resendWithoutVoice = useMutation({
mutationFn: async (id: number) => {
await retryInspectionWithoutVoice(id);
return syncPendingEntries(userId, { afterChange: true });
},
networkMode: "always",
onSuccess: (result) => {
if (result.synced > 0) {
toast.success(entriesSynced(result.synced));
void queryClient.invalidateQueries();
return;
}
void queryClient.invalidateQueries({ queryKey: OFFLINE_QUEUE_QUERY_KEY });
},
onError: () => toast.error("Eintrag konnte nicht erneut gesendet werden."),
});
const records = items.filter((item) => item.store !== VOICE_STORE);
const voiceNotes = items.filter((item) => item.store === VOICE_STORE);
const names = entryNames(records);
const voiceNames = entryNames(voiceNotes);
const photoNames = entryNames(
photos.map((photo) => ({ label: PHOTO_LABEL, queuedAt: photo.createdAt })),
);
return (
<div className="flex flex-col gap-3 px-4 pb-4">
{isSuccess && items.length === 0 && photos.length === 0 ? (
<p className="text-sm text-muted-foreground">Von dir wartet nichts mehr.</p>
) : null}
<PresenceList className="flex flex-col gap-2">
{records.map((item, index) => (
<PresenceItem
key={`${item.store}-${item.id}`}
className="flex flex-col gap-2 rounded-xl border border-border/60 p-3"
>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<p className="font-medium">{item.label}</p>
<p className="text-sm text-muted-foreground">
Erfasst am {queuedAtLabel(item.queuedAt)}
</p>
{item.photoCount ? (
<p className="flex items-center gap-1.5 text-sm text-muted-foreground">
<Camera className="size-4 shrink-0" aria-hidden />
{photosAttached(item.photoCount)}
</p>
) : null}
</div>
<ConfirmDialog
triggerLabel="Verwerfen"
triggerAriaLabel={`Verwerfen: ${names[index]}`}
triggerVariant="ghost"
triggerSize="touch"
title={`${names[index]} verwerfen?`}
description={discardDescription(item)}
confirmLabel="Verwerfen"
onConfirm={() => discard.mutate(item)}
isPending={discard.isPending}
/>
</div>
<QueueStatus lastError={item.lastError} changesHive={item.hasHiveChange} />
{item.canResendWithoutHiveChange && typeof item.id === "number" ? (
<Button
type="button"
variant="outline"
size="touch"
className="self-start"
disabled={resendWithoutHive.isPending}
onClick={() => resendWithoutHive.mutate(item.id as number)}
>
<RotateCw aria-hidden />
Ohne Beute-Änderung senden
</Button>
) : null}
{item.canResendWithoutVoice && typeof item.id === "number" ? (
<Button
type="button"
variant="outline"
size="touch"
className="self-start"
disabled={resendWithoutVoice.isPending}
onClick={() => resendWithoutVoice.mutate(item.id as number)}
>
<RotateCw aria-hidden />
Ohne Sprachnotiz senden
</Button>
) : null}
{item.resendWithout && typeof item.id === "number" ? (
<ResendWithoutButton
resend={item.resendWithout}
disabled={resendBulk.isPending}
onResend={() =>
resendBulk.mutate({
id: item.id as number,
colonyIds: item.resendWithout?.colonyIds ?? [],
})
}
/>
) : null}
</PresenceItem>
))}
{photos.map((photo, index) => (
<PresenceItem
key={`photo-${photo.id}`}
className="flex flex-col gap-2 rounded-xl border border-border/60 p-3"
>
<div className="flex items-start gap-3">
<PhotoThumbnail
blob={photo.blob}
alt=""
className="size-14 shrink-0 rounded-lg border border-border/60 object-cover"
/>
<div className="min-w-0">
<p className="font-medium">Foto zur Durchsicht</p>
<p className="text-sm text-muted-foreground">
Aufgenommen am {queuedAtLabel(photo.createdAt)}
</p>
</div>
</div>
<QueueStatus lastError={photo.lastError} />
<div className="flex flex-wrap gap-2">
{photo.lastError ? (
<Button
type="button"
variant="outline"
size="touch"
aria-label={`Erneut senden: ${photoNames[index]}`}
disabled={retryPhoto.isPending}
onClick={() => retryPhoto.mutate(photo.id)}
>
<RotateCw aria-hidden />
Erneut senden
</Button>
) : null}
<ConfirmDialog
triggerLabel="Verwerfen"
triggerAriaLabel={`Verwerfen: ${photoNames[index]}`}
triggerVariant="ghost"
triggerSize="touch"
title={`${photoNames[index]} verwerfen?`}
description="Das Foto wird von diesem Gerät gelöscht und nicht mehr gesendet."
confirmLabel="Verwerfen"
onConfirm={() => discardPhoto.mutate(photo.id)}
isPending={discardPhoto.isPending}
/>
</div>
</PresenceItem>
))}
</PresenceList>
{voiceNotes.length > 0 ? (
<section aria-labelledby="queue-voice-heading" className="flex flex-col gap-2">
<div className="flex flex-wrap items-center justify-between gap-2">
<h3
id="queue-voice-heading"
ref={voiceHeadingRef}
tabIndex={-1}
className="font-medium"
>
Sprachnotizen
</h3>
{voiceNotes.length > 1 ? (
<ConfirmDialog
triggerLabel="Alle Sprachnotizen verwerfen"
triggerVariant="ghost"
triggerSize="touch"
title="Alle Sprachnotizen verwerfen?"
description="Die Sprachnotizen werden von diesem Gerät gelöscht und nicht mehr gesendet."
confirmLabel="Alle verwerfen"
onConfirm={() => discardAllVoice.mutate()}
isPending={discardAllVoice.isPending}
/>
) : null}
</div>
{voiceNotes.some((item) => item.voice?.blockedBy === "TOO_MANY_OPEN") ? (
<p role="status" className="text-sm text-muted-foreground">
{VOICE_TOO_MANY_OPEN_HINT}
</p>
) : null}
<PresenceList className="flex flex-col gap-2">
{voiceNotes.map((item, index) => (
<PresenceItem
key={`voice-${item.id}`}
className="flex flex-col gap-2 rounded-xl border border-border/60 p-3"
>
<div className="flex items-start gap-3">
<Mic className="mt-0.5 size-5 shrink-0 text-muted-foreground" aria-hidden />
<div className="min-w-0">
<p className="font-medium">{item.label}</p>
<p className="text-sm text-muted-foreground">
Aufgenommen am {queuedAtLabel(item.queuedAt)}
{item.voice ? ` · Dauer ${durationLabel(item.voice.durationSec)}` : ""}
</p>
</div>
</div>
<QueueStatus lastError={item.lastError} isVoice />
<div className="flex flex-wrap gap-2">
{item.lastError ? (
<Link
href={recordAgainHref(item)}
className={buttonVariants({ variant: "outline", size: "touch" })}
aria-label={`Erneut aufnehmen: ${voiceNames[index]}`}
onClick={(event) => {
// Only a plain click stays in this tab; the refused audio is of no
// use any more and the new recording replaces it.
if (!isPlainLeftClick(event)) return;
discardVoice.mutate(String(item.id));
onOpenChange(false);
}}
>
<RotateCw aria-hidden />
Erneut aufnehmen
</Link>
) : null}
<ConfirmDialog
triggerLabel="Verwerfen"
triggerAriaLabel={`Verwerfen: ${voiceNames[index]}`}
triggerVariant="ghost"
triggerSize="touch"
title={`${voiceNames[index]} verwerfen?`}
description="Die Sprachnotiz wird von diesem Gerät gelöscht und nicht mehr gesendet."
confirmLabel="Verwerfen"
onConfirm={() => discardVoice.mutate(String(item.id))}
isPending={discardVoice.isPending}
/>
</div>
</PresenceItem>
))}
</PresenceList>
</section>
) : null}
{foreignCount > 0 ? (
<p className="text-sm text-muted-foreground">{foreignEntriesWaiting(foreignCount)}</p>
) : null}
</div>
);
}
@@ -1,35 +1,17 @@
"use client";
import { useRef, type ComponentProps, type MouseEvent, type ReactNode } from "react";
import Link from "next/link";
import { AlertTriangle, Camera, Clock, Mic, RotateCw } from "lucide-react";
import { toast } from "sonner";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import type { ComponentProps, ReactNode } from "react";
import dynamic from "next/dynamic";
import { NavSheet } from "@/components/nav/nav-sheet";
import { ConfirmDialog } from "@/components/confirm-dialog";
import { PhotoThumbnail } from "@/components/photos/photo-capture-field";
import { Button, buttonVariants } from "@/components/ui/button";
import {
discardQueueItem,
listQueue,
retryBulkWithout,
retryInspectionWithoutHiveChange,
retryInspectionWithoutVoice,
type QueueError,
type QueueItem,
} from "@/lib/offline/db";
import {
entriesSynced,
foreignEntriesWaiting,
photosAttached,
queueRejectionText,
VOICE_TOO_MANY_OPEN_HINT,
voiceRejectionText,
} from "@/lib/offline/queue-texts";
import { syncPendingEntries } from "@/lib/offline/sync";
import { OFFLINE_QUEUE_QUERY_KEY } from "@/lib/offline/use-capture-mutation";
import { discardAllPendingVoice, discardPendingVoice } from "@/lib/voice/pending-voice";
import { discardReadyPhoto, listPendingPhotos, resetPhotoError } from "@/lib/photos/store";
// The list (with its motion) loads with the first opening, not with the app shell (spec 17.6).
const PendingQueueBody = dynamic(() => import("./pending-queue-body").then((module) => module.PendingQueueBody), {
loading: () => (
<p role="status" className="min-h-5 px-4 pb-4 text-sm text-muted-foreground">
Wird geladen…
</p>
),
});
interface PendingQueueSheetProps {
userId: string;
@@ -43,128 +25,7 @@ interface PendingQueueSheetProps {
finalFocus?: ComponentProps<typeof NavSheet>["finalFocus"];
}
interface Named {
label: string;
queuedAt: string;
}
const PHOTO_LABEL = "Foto";
const VOICE_STORE = "pending-voice";
/** 75 seconds as "1:15". */
function durationLabel(totalSec: number): string {
const seconds = Math.round(totalSec);
return `${Math.floor(seconds / 60)}:${String(seconds % 60).padStart(2, "0")}`;
}
/** True for a plain left click; ctrl, meta, shift, alt and middle click open a link elsewhere. */
function isPlainLeftClick(event: MouseEvent): boolean {
return (
event.button === 0 &&
!event.defaultPrevented &&
!event.metaKey &&
!event.ctrlKey &&
!event.shiftKey &&
!event.altKey
);
}
/** The recording page, for the colony of a rejected note. */
function recordAgainHref(item: QueueItem): string {
return `/voice/new?${new URLSearchParams({ colonyId: item.voice?.colonyId ?? "" }).toString()}`;
}
function queuedAtLabel(queuedAt: string): string {
return new Date(queuedAt).toLocaleString("de-DE", { dateStyle: "short", timeStyle: "short" });
}
/**
* „Durchsicht vom 13.10.26, 10:15:05" - a name per entry for its buttons and
* dialog. Seconds tell apart entries of the same minute; a running number
* „(2)" those queued in the same second.
*/
function entryNames(items: readonly Named[]): string[] {
const seen = new Map<string, number>();
return items.map((item) => {
const time = new Date(item.queuedAt).toLocaleString("de-DE", {
dateStyle: "short",
timeStyle: "medium",
});
const name = `${item.label} vom ${time}`;
const occurrence = (seen.get(name) ?? 0) + 1;
seen.set(name, occurrence);
return occurrence === 1 ? name : `${name} (${occurrence})`;
});
}
/** A queued inspection takes its photos along when it is discarded. */
function discardDescription(item: QueueItem): string {
if (!item.photoCount) {
return "Der Eintrag wird von diesem Gerät gelöscht und nicht mehr gesendet.";
}
const photos = item.photoCount === 1 ? "sein Foto" : `seine ${item.photoCount} Fotos`;
return `Der Eintrag und ${photos} werden von diesem Gerät gelöscht und nicht mehr gesendet.`;
}
/** Rejected with the server's reason, or waiting - in words, never by colour alone. */
function QueueStatus({
lastError,
changesHive = false,
isVoice = false,
}: {
lastError?: QueueError;
changesHive?: boolean;
isVoice?: boolean;
}) {
return lastError ? (
<p className="flex items-start gap-2 text-sm text-destructive">
<AlertTriangle className="mt-0.5 size-4 shrink-0" aria-hidden />
<span>
Abgelehnt:{" "}
{isVoice ? voiceRejectionText(lastError) : queueRejectionText(lastError, changesHive)}
</span>
</p>
) : (
<p className="flex items-start gap-2 text-sm text-muted-foreground">
<Clock className="mt-0.5 size-4 shrink-0" aria-hidden />
<span>Wartet auf Senden</span>
</p>
);
}
/** „Ohne V-3 erneut senden“ - or, when a code is unknown, without naming them. */
function ResendWithoutButton({
resend,
disabled,
onResend,
}: {
resend: NonNullable<QueueItem["resendWithout"]>;
disabled: boolean;
onResend: () => void;
}) {
const isNamed = resend.codes.length === resend.colonyIds.length;
const without = isNamed ? resend.codes.join(", ") : "die abgelehnten Völker";
return (
<Button
type="button"
variant="outline"
size="touch"
className="self-start"
disabled={disabled}
onClick={onResend}
>
<RotateCw aria-hidden />
Ohne {without} erneut senden
</Button>
);
}
/**
* What this device still has to send for the signed-in user: each entry with
* its state, a rejected one with the server's reason, and a way to discard it.
* Photos whose inspection is already on the server are listed on their own;
* a rejected one can be sent again.
*/
/** The sheet „Wartende Einträge“: what this device still has to send (PendingQueueBody). */
export function PendingQueueSheet({
userId,
foreignCount,
@@ -173,149 +34,6 @@ export function PendingQueueSheet({
trigger,
finalFocus,
}: PendingQueueSheetProps) {
const queryClient = useQueryClient();
const { data: items = [], isSuccess } = useQuery({
queryKey: [...OFFLINE_QUEUE_QUERY_KEY, userId, "items"],
queryFn: () => listQueue(userId),
enabled: open,
// Reads this device's storage, never the network.
networkMode: "always",
});
const { data: photos = [] } = useQuery({
queryKey: [...OFFLINE_QUEUE_QUERY_KEY, userId, "photos"],
queryFn: () => listPendingPhotos(userId),
enabled: open,
networkMode: "always",
});
const discard = useMutation({
mutationFn: (item: QueueItem) => discardQueueItem(item),
networkMode: "always",
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: OFFLINE_QUEUE_QUERY_KEY });
toast.success("Eintrag verworfen");
},
onError: () => toast.error("Eintrag konnte nicht verworfen werden."),
});
const voiceHeadingRef = useRef<HTMLHeadingElement>(null);
const discardVoice = useMutation({
mutationFn: (noteId: string) => discardPendingVoice(noteId, userId),
networkMode: "always",
onSuccess: () => {
// The discard button is gone: keep the focus inside the list.
voiceHeadingRef.current?.focus();
void queryClient.invalidateQueries({ queryKey: OFFLINE_QUEUE_QUERY_KEY });
toast.success("Sprachnotiz verworfen");
},
onError: () => toast.error("Sprachnotiz konnte nicht verworfen werden."),
});
const discardAllVoice = useMutation({
mutationFn: () => discardAllPendingVoice(userId),
networkMode: "always",
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: OFFLINE_QUEUE_QUERY_KEY });
toast.success("Sprachnotizen verworfen");
},
onError: () => toast.error("Sprachnotizen konnten nicht verworfen werden."),
});
const discardPhoto = useMutation({
mutationFn: (photoId: string) => discardReadyPhoto(photoId),
networkMode: "always",
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: OFFLINE_QUEUE_QUERY_KEY });
toast.success("Foto verworfen");
},
onError: () => toast.error("Foto konnte nicht verworfen werden."),
});
// „Erneut senden": clear the rejection, then a normal sync run sends it.
const retryPhoto = useMutation({
mutationFn: async (photoId: string) => {
await resetPhotoError(photoId);
// A sync already running may have passed this photo: it runs once more.
return syncPendingEntries(userId, { afterChange: true });
},
networkMode: "always",
onSuccess: (result) => {
if (result.synced > 0) {
toast.success(entriesSynced(result.synced));
// New files on the server: document lists may be stale now.
void queryClient.invalidateQueries();
return;
}
void queryClient.invalidateQueries({ queryKey: OFFLINE_QUEUE_QUERY_KEY });
},
onError: () => toast.error("Foto konnte nicht erneut gesendet werden."),
});
// „Ohne … erneut senden“: the refused colonies leave the bulk entry (same
// key, rejection cleared), then a normal run sends the rest.
const resendBulk = useMutation({
mutationFn: async ({ id, colonyIds }: { id: number; colonyIds: string[] }) => {
await retryBulkWithout(id, colonyIds);
// A sync already running may have passed this entry: it runs once more.
return syncPendingEntries(userId, { afterChange: true });
},
networkMode: "always",
onSuccess: (result) => {
if (result.synced > 0) {
toast.success(entriesSynced(result.synced));
void queryClient.invalidateQueries();
return;
}
void queryClient.invalidateQueries({ queryKey: OFFLINE_QUEUE_QUERY_KEY });
},
onError: () => toast.error("Eintrag konnte nicht erneut gesendet werden."),
});
// „Ohne Beute-Änderung senden“: the hive change leaves the inspection (same
// key, rejection cleared), then a normal run sends it.
const resendWithoutHive = useMutation({
mutationFn: async (id: number) => {
await retryInspectionWithoutHiveChange(id);
return syncPendingEntries(userId, { afterChange: true });
},
networkMode: "always",
onSuccess: (result) => {
if (result.synced > 0) {
toast.success(entriesSynced(result.synced));
void queryClient.invalidateQueries();
return;
}
void queryClient.invalidateQueries({ queryKey: OFFLINE_QUEUE_QUERY_KEY });
},
onError: () => toast.error("Eintrag konnte nicht erneut gesendet werden."),
});
// „Ohne Sprachnotiz senden“: only after the server said the note is another colony's.
const resendWithoutVoice = useMutation({
mutationFn: async (id: number) => {
await retryInspectionWithoutVoice(id);
return syncPendingEntries(userId, { afterChange: true });
},
networkMode: "always",
onSuccess: (result) => {
if (result.synced > 0) {
toast.success(entriesSynced(result.synced));
void queryClient.invalidateQueries();
return;
}
void queryClient.invalidateQueries({ queryKey: OFFLINE_QUEUE_QUERY_KEY });
},
onError: () => toast.error("Eintrag konnte nicht erneut gesendet werden."),
});
const records = items.filter((item) => item.store !== VOICE_STORE);
const voiceNotes = items.filter((item) => item.store === VOICE_STORE);
const names = entryNames(records);
const voiceNames = entryNames(voiceNotes);
const photoNames = entryNames(
photos.map((photo) => ({ label: PHOTO_LABEL, queuedAt: photo.createdAt })),
);
return (
<NavSheet
open={open}
@@ -324,215 +42,8 @@ export function PendingQueueSheet({
title="Wartende Einträge"
finalFocus={finalFocus}
>
<div className="flex flex-col gap-3 px-4 pb-4">
{isSuccess && items.length === 0 && photos.length === 0 ? (
<p className="text-sm text-muted-foreground">Von dir wartet nichts mehr.</p>
) : null}
<ul className="flex flex-col gap-2">
{records.map((item, index) => (
<li
key={`${item.store}-${item.id}`}
className="flex flex-col gap-2 rounded-xl border border-border/60 p-3"
>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<p className="font-medium">{item.label}</p>
<p className="text-sm text-muted-foreground">
Erfasst am {queuedAtLabel(item.queuedAt)}
</p>
{item.photoCount ? (
<p className="flex items-center gap-1.5 text-sm text-muted-foreground">
<Camera className="size-4 shrink-0" aria-hidden />
{photosAttached(item.photoCount)}
</p>
) : null}
</div>
<ConfirmDialog
triggerLabel="Verwerfen"
triggerAriaLabel={`Verwerfen: ${names[index]}`}
triggerVariant="ghost"
triggerSize="touch"
title={`${names[index]} verwerfen?`}
description={discardDescription(item)}
confirmLabel="Verwerfen"
onConfirm={() => discard.mutate(item)}
isPending={discard.isPending}
/>
</div>
<QueueStatus lastError={item.lastError} changesHive={item.hasHiveChange} />
{item.canResendWithoutHiveChange && typeof item.id === "number" ? (
<Button
type="button"
variant="outline"
size="touch"
className="self-start"
disabled={resendWithoutHive.isPending}
onClick={() => resendWithoutHive.mutate(item.id as number)}
>
<RotateCw aria-hidden />
Ohne Beute-Änderung senden
</Button>
) : null}
{item.canResendWithoutVoice && typeof item.id === "number" ? (
<Button
type="button"
variant="outline"
size="touch"
className="self-start"
disabled={resendWithoutVoice.isPending}
onClick={() => resendWithoutVoice.mutate(item.id as number)}
>
<RotateCw aria-hidden />
Ohne Sprachnotiz senden
</Button>
) : null}
{item.resendWithout && typeof item.id === "number" ? (
<ResendWithoutButton
resend={item.resendWithout}
disabled={resendBulk.isPending}
onResend={() =>
resendBulk.mutate({
id: item.id as number,
colonyIds: item.resendWithout?.colonyIds ?? [],
})
}
/>
) : null}
</li>
))}
{photos.map((photo, index) => (
<li
key={`photo-${photo.id}`}
className="flex flex-col gap-2 rounded-xl border border-border/60 p-3"
>
<div className="flex items-start gap-3">
<PhotoThumbnail
blob={photo.blob}
alt=""
className="size-14 shrink-0 rounded-lg border border-border/60 object-cover"
/>
<div className="min-w-0">
<p className="font-medium">Foto zur Durchsicht</p>
<p className="text-sm text-muted-foreground">
Aufgenommen am {queuedAtLabel(photo.createdAt)}
</p>
</div>
</div>
<QueueStatus lastError={photo.lastError} />
<div className="flex flex-wrap gap-2">
{photo.lastError ? (
<Button
type="button"
variant="outline"
size="touch"
aria-label={`Erneut senden: ${photoNames[index]}`}
disabled={retryPhoto.isPending}
onClick={() => retryPhoto.mutate(photo.id)}
>
<RotateCw aria-hidden />
Erneut senden
</Button>
) : null}
<ConfirmDialog
triggerLabel="Verwerfen"
triggerAriaLabel={`Verwerfen: ${photoNames[index]}`}
triggerVariant="ghost"
triggerSize="touch"
title={`${photoNames[index]} verwerfen?`}
description="Das Foto wird von diesem Gerät gelöscht und nicht mehr gesendet."
confirmLabel="Verwerfen"
onConfirm={() => discardPhoto.mutate(photo.id)}
isPending={discardPhoto.isPending}
/>
</div>
</li>
))}
</ul>
{voiceNotes.length > 0 ? (
<section aria-labelledby="queue-voice-heading" className="flex flex-col gap-2">
<div className="flex flex-wrap items-center justify-between gap-2">
<h3
id="queue-voice-heading"
ref={voiceHeadingRef}
tabIndex={-1}
className="font-medium"
>
Sprachnotizen
</h3>
{voiceNotes.length > 1 ? (
<ConfirmDialog
triggerLabel="Alle Sprachnotizen verwerfen"
triggerVariant="ghost"
triggerSize="touch"
title="Alle Sprachnotizen verwerfen?"
description="Die Sprachnotizen werden von diesem Gerät gelöscht und nicht mehr gesendet."
confirmLabel="Alle verwerfen"
onConfirm={() => discardAllVoice.mutate()}
isPending={discardAllVoice.isPending}
/>
) : null}
</div>
{voiceNotes.some((item) => item.voice?.blockedBy === "TOO_MANY_OPEN") ? (
<p role="status" className="text-sm text-muted-foreground">
{VOICE_TOO_MANY_OPEN_HINT}
</p>
) : null}
<ul className="flex flex-col gap-2">
{voiceNotes.map((item, index) => (
<li
key={`voice-${item.id}`}
className="flex flex-col gap-2 rounded-xl border border-border/60 p-3"
>
<div className="flex items-start gap-3">
<Mic className="mt-0.5 size-5 shrink-0 text-muted-foreground" aria-hidden />
<div className="min-w-0">
<p className="font-medium">{item.label}</p>
<p className="text-sm text-muted-foreground">
Aufgenommen am {queuedAtLabel(item.queuedAt)}
{item.voice ? ` · Dauer ${durationLabel(item.voice.durationSec)}` : ""}
</p>
</div>
</div>
<QueueStatus lastError={item.lastError} isVoice />
<div className="flex flex-wrap gap-2">
{item.lastError ? (
<Link
href={recordAgainHref(item)}
className={buttonVariants({ variant: "outline", size: "touch" })}
aria-label={`Erneut aufnehmen: ${voiceNames[index]}`}
onClick={(event) => {
// Only a plain click stays in this tab; the refused audio is of no
// use any more and the new recording replaces it.
if (!isPlainLeftClick(event)) return;
discardVoice.mutate(String(item.id));
onOpenChange(false);
}}
>
<RotateCw aria-hidden />
Erneut aufnehmen
</Link>
) : null}
<ConfirmDialog
triggerLabel="Verwerfen"
triggerAriaLabel={`Verwerfen: ${voiceNames[index]}`}
triggerVariant="ghost"
triggerSize="touch"
title={`${voiceNames[index]} verwerfen?`}
description="Die Sprachnotiz wird von diesem Gerät gelöscht und nicht mehr gesendet."
confirmLabel="Verwerfen"
onConfirm={() => discardVoice.mutate(String(item.id))}
isPending={discardVoice.isPending}
/>
</div>
</li>
))}
</ul>
</section>
) : null}
{foreignCount > 0 ? (
<p className="text-sm text-muted-foreground">{foreignEntriesWaiting(foreignCount)}</p>
) : null}
</div>
{/* Rendered only while the sheet is open (or closing): the body loads with the first opening. */}
<PendingQueueBody userId={userId} foreignCount={foreignCount} onOpenChange={onOpenChange} />
</NavSheet>
);
}
@@ -6,6 +6,15 @@ import { QueryState } from "./query-state";
const base = { isLoading: false, isError: false, isFetching: false, refetch: vi.fn() };
describe("QueryState", () => {
it("keeps the line height for the loading status while its text waits (no jump after 150 ms)", () => {
render(
<QueryState query={{ ...base, data: undefined, isLoading: true }} what="Völker">
{() => <p>Daten</p>}
</QueryState>,
);
expect(screen.getByRole("status")).toHaveClass("min-h-5");
});
it("never shows the empty state for a failed load, and offers a retry", async () => {
const refetch = vi.fn();
render(
@@ -3,6 +3,8 @@
import { ReactNode } from "react";
import { AlertCircle, RotateCw } from "lucide-react";
import { Button } from "@/components/ui/button";
import { SKELETON_DELAY_MS } from "@/lib/motion/tokens";
import { useAfterDelay } from "@/lib/motion/use-after-delay";
interface ErrorStateProps {
/** What could not be loaded, e.g. "Völker". */
@@ -35,10 +37,12 @@ export function ErrorState({ what, onRetry, isRetrying }: ErrorStateProps) {
);
}
/** The status region is there at once; its line follows after SKELETON_DELAY_MS (no blinking). */
export function LoadingState({ what }: { what: string }) {
const isShown = useAfterDelay(SKELETON_DELAY_MS);
return (
<p role="status" className="text-sm text-muted-foreground">
{what} werden geladen…
<p role="status" className="min-h-5 text-sm text-muted-foreground">
{isShown ? `${what} werden geladen…` : null}
</p>
);
}
@@ -30,7 +30,7 @@ export function StatBarList({ items, emptyText = "Keine Daten vorhanden.", forma
</div>
<div className="h-2 w-full overflow-hidden rounded-full bg-muted">
<div
className="h-full rounded-full bg-primary motion-full:transition-[width] motion-full:duration-700 motion-full:ease-out"
className="h-full rounded-full bg-primary"
style={{ width: `${item.value > 0 ? Math.max((item.value / max) * 100, 4) : 0}%` }}
/>
</div>
@@ -157,7 +157,7 @@ describe("SuggestionGroup", () => {
setup(THREE_PLAN);
const chevron = screen.getByRole("button", { name: /Einzeln anzeigen/ }).querySelector("svg");
expect(chevron).toHaveClass("motion-safe:transition-transform");
expect(chevron).toHaveClass("motion-full:transition-transform");
expect(chevron).not.toHaveClass("transition-transform");
});
});
@@ -101,7 +101,7 @@ function CombinedRow({
>
<ChevronDown
aria-hidden
className={cn("motion-safe:transition-transform", isOpen && "rotate-180")}
className={cn("motion-full:transition-transform motion-full:duration-(--motion-settle)", isOpen && "rotate-180")}
/>
{isOpen ? "Einzeln ausblenden" : "Einzeln anzeigen"}
</Button>
@@ -0,0 +1,102 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { act, render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { completeTask } from "@/lib/tasks/api";
import { setMotionLevel } from "@/lib/motion/motion-preference";
import type { Task } from "@/lib/tasks/types";
import { TodayPanel } from "./today-panel";
// The motion chunk never arrives in this file: the list stays plain (the usual first dashboard).
vi.mock("@/components/motion/motion-runtime", () => new Promise(() => undefined));
vi.mock("@/lib/auth/auth-context", () => ({
useAuth: () => ({ user: { name: "Nick Test" }, hasPermission: () => true }),
useCurrentUserId: () => "user-1",
}));
vi.mock("@/lib/tasks/api", () => ({ completeTask: vi.fn(), updateTask: vi.fn() }));
vi.mock("@/lib/colonies/api", () => ({ fetchColonies: vi.fn().mockResolvedValue([]) }));
vi.mock("sonner", () => ({ toast: Object.assign(vi.fn(), { success: vi.fn(), error: vi.fn() }) }));
const NOW = new Date();
const originalMatchMedia = window.matchMedia;
function task(id: string, title: string): Task {
const yesterday = new Date(NOW.getTime() - 24 * 60 * 60 * 1000).toISOString();
return {
id,
title,
description: null,
priority: "NORMAL",
dueDate: yesterday,
status: "OPEN",
assignedUserId: null,
createdByUserId: null,
relatedType: "GENERAL",
relatedId: null,
sourceInspectionId: null,
sourceTemplateId: null,
sourceAutomationTrigger: null,
createdAt: yesterday,
completedAt: null,
};
}
const TASKS = [task("t1", "Erste Aufgabe"), task("t2", "Zweite Aufgabe"), task("t3", "Dritte Aufgabe")];
const tickBox = (title: string) => screen.getByRole("checkbox", { name: `„${title}“ als erledigt markieren` });
function renderPanel(tasks: Task[]) {
render(
<QueryClientProvider client={new QueryClient()}>
<TodayPanel tasks={tasks} isLoading={false} isError={false} onRetry={() => undefined} now={NOW} />
</QueryClientProvider>,
);
}
describe("TodayPanel without the motion chunk (plain list)", () => {
beforeEach(() => {
window.localStorage.clear();
window.matchMedia = (() => ({
matches: false,
addEventListener: () => undefined,
removeEventListener: () => undefined,
})) as unknown as typeof window.matchMedia;
vi.mocked(completeTask).mockReturnValue(new Promise(() => undefined));
});
afterEach(() => {
window.matchMedia = originalMatchMedia;
delete document.documentElement.dataset.motion;
});
it("ticking the focused row moves the focus to the next row's tick", async () => {
act(() => setMotionLevel("full"));
renderPanel([...TASKS, task("t4", "Vierte Aufgabe")]);
tickBox("Zweite Aufgabe").focus();
await userEvent.keyboard(" ");
await waitFor(() => expect(screen.queryByText("Zweite Aufgabe")).not.toBeInTheDocument(), { timeout: 1500 });
expect(tickBox("Dritte Aufgabe")).toHaveFocus();
});
it("ticking the focused last row moves the focus to the list heading", async () => {
act(() => setMotionLevel("off"));
renderPanel(TASKS);
tickBox("Dritte Aufgabe").focus();
await userEvent.keyboard(" ");
await waitFor(() => expect(screen.getByRole("heading", { name: "Außerdem heute" })).toHaveFocus());
});
it("does not take the focus when it was elsewhere", async () => {
act(() => setMotionLevel("off"));
renderPanel(TASKS);
await userEvent.click(tickBox("Zweite Aufgabe"));
const link = screen.getByRole("link", { name: "Alle Aufgaben" });
link.focus();
await waitFor(() => expect(screen.queryByText("Zweite Aufgabe")).not.toBeInTheDocument());
expect(link).toHaveFocus();
});
});
@@ -0,0 +1,188 @@
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { act, render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { AppMotion } from "@/components/app-motion";
import { loadMotionRuntime } from "@/components/motion/presence-list";
import { completeTask } from "@/lib/tasks/api";
import { setMotionLevel, type MotionLevel } from "@/lib/motion/motion-preference";
import type { Task } from "@/lib/tasks/types";
import { TodayPanel } from "./today-panel";
vi.mock("@/lib/auth/auth-context", () => ({
useAuth: () => ({ user: { name: "Nick Test" }, hasPermission: () => true }),
useCurrentUserId: () => "user-1",
}));
vi.mock("@/lib/tasks/api", () => ({ completeTask: vi.fn(), updateTask: vi.fn() }));
vi.mock("@/lib/colonies/api", () => ({ fetchColonies: vi.fn().mockResolvedValue([]) }));
vi.mock("sonner", () => ({
toast: Object.assign(vi.fn(), { success: vi.fn(), error: vi.fn() }),
}));
const NOW = new Date();
const originalMatchMedia = window.matchMedia;
function task(id: string, title: string): Task {
const yesterday = new Date(NOW.getTime() - 24 * 60 * 60 * 1000).toISOString();
return {
id,
title,
description: null,
priority: "NORMAL",
dueDate: yesterday,
status: "OPEN",
assignedUserId: null,
createdByUserId: null,
relatedType: "GENERAL",
relatedId: null,
sourceInspectionId: null,
sourceTemplateId: null,
sourceAutomationTrigger: null,
createdAt: yesterday,
completedAt: null,
};
}
// The first task becomes „Als Nächstes“; the others are rows of „Außerdem heute“.
const TASKS = [task("t1", "Erste Aufgabe"), task("t2", "Zweite Aufgabe"), task("t3", "Dritte Aufgabe")];
function renderPanel(tasks: Task[]) {
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
const panel = (list: Task[]) => (
<QueryClientProvider client={client}>
<AppMotion>
<TodayPanel tasks={list} isLoading={false} isError={false} onRetry={() => undefined} now={NOW} />
</AppMotion>
</QueryClientProvider>
);
const view = render(panel(tasks));
return { ...view, rerenderWith: (list: Task[]) => view.rerender(panel(list)) };
}
/** Renders the panel at a motion level with the animation features loaded (LazyMotion is async). */
async function renderAt(level: MotionLevel, tasks: Task[] = TASKS) {
act(() => setMotionLevel(level));
// AppMotion preloads the chunk with the shell; a list mounted before it would stay plain.
await loadMotionRuntime();
const view = renderPanel(tasks);
await act(async () => {
await loadMotionRuntime();
await new Promise((resolve) => setTimeout(resolve, 50));
});
return view;
}
const rowOf = (title: string) => screen.getByText(title).closest("li") as HTMLElement;
const tickBox = (title: string) =>
screen.getByRole("checkbox", { name: `„${title}“ als erledigt markieren` });
describe("TodayPanel – a ticked row leaves (spec 17.4)", () => {
// jsdom has no layout: give the rows a size, so the leaving row can be taken out of the flow.
const rowSize = document.createElement("style");
rowSize.textContent = "li { width: 320px; height: 56px; }";
beforeEach(() => {
document.head.appendChild(rowSize);
window.localStorage.clear();
window.matchMedia = (() => ({
matches: false,
addEventListener: () => undefined,
removeEventListener: () => undefined,
})) as unknown as typeof window.matchMedia;
vi.mocked(completeTask).mockReset();
});
afterEach(() => {
rowSize.remove();
window.matchMedia = originalMatchMedia;
delete document.documentElement.dataset.motion;
});
it("a ticked task leaves after the moment even when the network is slow (nothing waits for a click)", async () => {
// The server never answers: the row must not wait for it.
vi.mocked(completeTask).mockReturnValue(new Promise(() => undefined));
await renderAt("full");
const leaving = rowOf("Zweite Aufgabe");
const clicked = performance.now();
await userEvent.click(tickBox("Zweite Aufgabe"));
// The honey-check moment first: the row is still there and ticked.
expect(leaving).toBeInTheDocument();
expect(tickBox("Zweite Aufgabe")).toBeChecked();
// Then, after EXIT_DELAY_MS (720 ms at Voll) and not later, it fades out of the flow
// (popLayout) while the rows below move up - the server still has not answered.
await waitFor(() => expect(leaving).toHaveAttribute("data-motion-pop-id"), { timeout: 1500 });
const leftAfter = performance.now() - clicked;
expect(leftAfter).toBeGreaterThanOrEqual(700);
expect(leftAfter).toBeLessThan(1400);
await waitFor(() => expect(leaving).not.toBeInTheDocument(), { timeout: 1500 });
expect(screen.getByText("Dritte Aufgabe")).toBeInTheDocument();
});
it("at off the ticked task is gone at once, before the server answers", async () => {
vi.mocked(completeTask).mockReturnValue(new Promise(() => undefined));
await renderAt("off");
const leaving = rowOf("Zweite Aufgabe");
await userEvent.click(tickBox("Zweite Aufgabe"));
await waitFor(() => expect(leaving).not.toBeInTheDocument(), { timeout: 50 });
expect(screen.getByText("Dritte Aufgabe")).toBeInTheDocument();
});
it("a failed save brings the ticked task back", async () => {
vi.mocked(completeTask).mockRejectedValue(new Error("offline"));
await renderAt("off");
await userEvent.click(tickBox("Zweite Aufgabe"));
await waitFor(() => expect(tickBox("Zweite Aufgabe")).not.toBeChecked());
expect(tickBox("Zweite Aufgabe")).toBeEnabled();
});
it("when the focused row leaves, the focus moves to the next row's checkbox", async () => {
vi.mocked(completeTask).mockReturnValue(new Promise(() => undefined));
await renderAt("off", [...TASKS, task("t4", "Vierte Aufgabe")]);
tickBox("Zweite Aufgabe").focus();
await userEvent.keyboard(" ");
await waitFor(() => expect(tickBox("Dritte Aufgabe")).toHaveFocus());
});
it("when the focused last row leaves, the focus moves to the list heading", async () => {
vi.mocked(completeTask).mockReturnValue(new Promise(() => undefined));
await renderAt("off");
tickBox("Dritte Aufgabe").focus();
await userEvent.keyboard(" ");
await waitFor(() => expect(screen.getByRole("heading", { name: "Außerdem heute" })).toHaveFocus());
});
it("a ticked task never collapses grid-template-rows", () => {
renderPanel(TASKS);
const row = screen.getByText("Zweite Aufgabe").closest("li") as HTMLElement;
expect(row.className).not.toMatch(/today-row/);
expect(row).not.toHaveAttribute("data-leaving");
const globals = readFileSync(join(__dirname, "..", "..", "app", "globals.css"), "utf8");
expect(globals).not.toMatch(/\.today-row|grid-template-rows/);
});
it("the loading skeleton appears after 150 ms and pulses only at full motion", async () => {
render(
<QueryClientProvider client={new QueryClient()}>
<TodayPanel tasks={undefined} isLoading isError={false} onRetry={() => undefined} now={NOW} />
</QueryClientProvider>,
);
const skeleton = screen.getByLabelText("Aufgaben werden geladen");
// A quick load never blinks: nothing is drawn at first.
expect(skeleton.children).toHaveLength(0);
await waitFor(() => expect(skeleton.children).toHaveLength(3));
for (const block of Array.from(skeleton.children)) {
expect(block.className).toMatch(/(?:^|\s)motion-full:animate-pulse(?:\s|$)/);
expect(block.className).not.toMatch(/(?:^|\s)animate-pulse/);
}
});
});
@@ -1,11 +1,13 @@
"use client";
import { useRef, type Ref } from "react";
import Link from "next/link";
import { useQuery } from "@tanstack/react-query";
import { ArrowRight, Check, ClipboardPlus, Lightbulb, ListPlus, Play, Sun } from "lucide-react";
import { Task, TaskRelatedType } from "@/lib/tasks/types";
import { dueState, pickNextStep, relatedHref, tasksForToday } from "@/lib/tasks/today";
import { CheckboxField } from "@/components/form/checkbox-field";
import { PresenceItem, PresenceList } from "@/components/motion/presence-list";
import { Button } from "@/components/ui/button";
import { DueChip, TaskSourceBadge, TaskStatusPill } from "@/components/task-status";
import { OnlineOnlyHint, useOnlineOnly } from "@/components/offline/stand-note";
@@ -19,6 +21,8 @@ import { useAuth } from "@/lib/auth/auth-context";
import { fetchColonies } from "@/lib/colonies/api";
import { todayLocalIsoDate } from "@/lib/dates";
import { INTENT_ICONS } from "@/lib/suggestions/labels";
import { SKELETON_DELAY_MS } from "@/lib/motion/tokens";
import { useAfterDelay } from "@/lib/motion/use-after-delay";
import { InspectionDueCard } from "./inspection-due-card";
import { useTaskActions } from "./use-task-actions";
@@ -65,21 +69,29 @@ export function TodayPanel({
now,
autopilot = null,
}: TodayPanelProps) {
const actions = useTaskActions();
// The row that holds the focus (also after its tick turned disabled and dropped it to body).
const focusedRowId = useRef<string | null>(null);
const actions = useTaskActions({
// Decided when the row is marked gone, with or without the motion chunk.
onGone: (id) => {
if (focusedRowId.current !== id) return;
focusedRowId.current = null;
focusAfterLeaving(id);
},
});
const onRowFocus = (id: string, focused: boolean) => {
if (focused) focusedRowId.current = id;
else if (focusedRowId.current === id) focusedRowId.current = null;
};
const stepActions = useStepActions("next-step-heading");
const { hasPermission } = useAuth();
const colonyCodes = useColonyCodes(loadedTasks);
// The autopilot is enough to work with when the task list is missing.
const tasks = loadedTasks ?? (autopilot ? [] : undefined);
// A ticked task whose moment is over is gone at once, whatever the server is still doing.
const tasks = (loadedTasks ?? (autopilot ? [] : undefined))?.filter((task) => !actions.goneIds.has(task.id));
if (isLoading) {
return (
<div aria-busy="true" aria-label="Aufgaben werden geladen" className="flex flex-col gap-3">
<div className="h-40 animate-pulse rounded-2xl bg-muted/70" />
<div className="h-14 animate-pulse rounded-xl bg-muted/50" />
<div className="h-14 animate-pulse rounded-xl bg-muted/40" />
</div>
);
return <TodaySkeleton />;
}
if (!tasks || (isError && !autopilot)) {
@@ -153,7 +165,7 @@ export function TodayPanel({
<section aria-labelledby="today-heading" className="flex flex-col gap-2">
<div className="flex items-baseline justify-between gap-3">
<h2 id="today-heading" className="font-heading text-lg font-semibold tracking-tight">
<h2 id="today-heading" tabIndex={-1} className="font-heading text-lg font-semibold tracking-tight">
Außerdem heute
</h2>
<Link
@@ -178,10 +190,11 @@ export function TodayPanel({
Sonst ist für heute nichts fällig. Ein Schritt reicht.
</p>
) : (
<ul className="flex flex-col gap-2">
<PresenceList className="flex flex-col gap-2">
{visible.map((task) => (
<TodayRow
key={task.id}
onFocusChange={(focused) => onRowFocus(task.id, focused)}
task={task}
now={now}
leaving={actions.leavingIds.has(task.id)}
@@ -189,7 +202,7 @@ export function TodayPanel({
onComplete={() => actions.complete(task)}
/>
))}
</ul>
</PresenceList>
)}
{hidden > 0 ? (
<Link
@@ -383,51 +396,100 @@ interface TodayRowProps {
/** Code of the colony the task is about, when known. */
colonyCode?: string;
onComplete: () => void;
/** Focus entered the row (true) or moved elsewhere on purpose (false). */
onFocusChange: (focused: boolean) => void;
/** Handed on by the PresenceList: the leaving row is taken out of the flow by its element. */
ref?: Ref<HTMLLIElement>;
}
function TodayRow({ task, now, leaving, colonyCode, onComplete }: TodayRowProps) {
/**
* One row of „Außerdem heute“. Ticked, it shows the honey-check moment and stays until the list is
* refreshed (EXIT_DELAY_MS in use-task-actions.ts); then it fades out and the rows below move up.
*/
function TodayRow({ task, now, leaving, colonyCode, onComplete, onFocusChange, ref }: TodayRowProps) {
const href = relatedHref(task);
const { isOffline, hintId, describedBy } = useOnlineOnly();
return (
<li className="today-row" data-leaving={leaving}>
<div>
<div className="flex items-center gap-2 rounded-xl border border-border/60 bg-card py-1.5 pr-2 pl-1 shadow-[var(--shadow-soft)]">
<CheckboxField
appearance="task"
label={`„${task.title}“ als erledigt markieren`}
checked={leaving}
disabled={leaving || isOffline}
describedBy={describedBy}
onCheckedChange={(next) => {
if (next) onComplete();
}}
/>
<div className="flex min-w-0 flex-1 flex-col gap-1 py-1">
<span className="line-clamp-2 font-medium">{task.title}</span>
<span className="flex flex-wrap items-center gap-1.5">
{colonyCode ? (
<span className="rounded-full bg-muted px-2 py-0.5 text-xs font-medium">
{colonyCode}
</span>
) : null}
{task.status === "IN_PROGRESS" ? <TaskStatusPill status={task.status} /> : null}
<DueChip state={dueState(task, now)} dueDate={task.dueDate} />
<TaskSourceBadge task={task} />
</span>
<OnlineOnlyHint id={hintId} />
</div>
{href ? (
<Link
href={href}
aria-label={OPEN_RELATED_LABEL[task.relatedType]}
className="grid size-11 shrink-0 place-items-center rounded-full text-muted-foreground hover:bg-muted hover:text-foreground"
>
<ArrowRight className="size-5" aria-hidden />
</Link>
) : null}
<PresenceItem
ref={ref}
data-task-row={task.id}
onFocus={() => onFocusChange(true)}
onBlur={(event) => {
// A disabled tick drops the focus to body (no relatedTarget): that still counts as here.
if (event.relatedTarget && !event.currentTarget.contains(event.relatedTarget)) onFocusChange(false);
}}
>
<div className="flex items-center gap-2 rounded-xl border border-border/60 bg-card py-1.5 pr-2 pl-1 shadow-[var(--shadow-soft)]">
<CheckboxField
appearance="task"
label={`„${task.title}“ als erledigt markieren`}
checked={leaving}
disabled={leaving || isOffline}
describedBy={describedBy}
onCheckedChange={(next) => {
if (next) onComplete();
}}
/>
<div className="flex min-w-0 flex-1 flex-col gap-1 py-1">
<span className="line-clamp-2 font-medium">{task.title}</span>
<span className="flex flex-wrap items-center gap-1.5">
{colonyCode ? (
<span className="rounded-full bg-muted px-2 py-0.5 text-xs font-medium">
{colonyCode}
</span>
) : null}
{task.status === "IN_PROGRESS" ? <TaskStatusPill status={task.status} /> : null}
<DueChip state={dueState(task, now)} dueDate={task.dueDate} />
<TaskSourceBadge task={task} />
</span>
<OnlineOnlyHint id={hintId} />
</div>
{href ? (
<Link
href={href}
aria-label={OPEN_RELATED_LABEL[task.relatedType]}
className="grid size-11 shrink-0 place-items-center rounded-full text-muted-foreground hover:bg-muted hover:text-foreground"
>
<ArrowRight className="size-5" aria-hidden />
</Link>
) : null}
</div>
</li>
</PresenceItem>
);
}
/**
* The focused row leaves: the focus goes to the tick of the next row still in the list, or to the
* list heading when it was the last one, so keyboard and screen reader users stay in place. Runs
* when the row is marked gone (still in the DOM), with or without the motion chunk.
*/
function focusAfterLeaving(id: string): void {
const row = Array.from(document.querySelectorAll<HTMLElement>("[data-task-row]")).find(
(element) => element.dataset.taskRow === id,
);
if (!row) return;
const active = document.activeElement;
if (active && active !== document.body && !row.contains(active)) return;
let next = row.nextElementSibling;
while (next && next.getAttribute("aria-hidden") === "true") next = next.nextElementSibling;
const tick = next?.querySelector<HTMLElement>('[role="checkbox"]:not([disabled])');
(tick ?? document.getElementById("today-heading"))?.focus();
}
/** Appears after SKELETON_DELAY_MS so a quick load never blinks; pulses only at full motion. */
function TodaySkeleton() {
const isShown = useAfterDelay(SKELETON_DELAY_MS);
return (
<div aria-busy="true" aria-label="Aufgaben werden geladen" className="flex min-h-74 flex-col gap-3">
{isShown ? (
<>
<div className="h-40 rounded-2xl bg-muted/70 motion-full:animate-pulse" />
<div className="h-14 rounded-xl bg-muted/50 motion-full:animate-pulse" />
<div className="h-14 rounded-xl bg-muted/40 motion-full:animate-pulse" />
</>
) : null}
</div>
);
}
@@ -1,13 +1,15 @@
"use client";
import { useState } from "react";
import { useEffect, useRef, useState } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import { completeTask, updateTask } from "@/lib/tasks/api";
import { Task } from "@/lib/tasks/types";
import { effectiveMotionLevel } from "@/lib/motion/motion-preference";
// How long the honey cell fill + row fold take per motion level (globals.css).
// How long a ticked row stays per motion level before it leaves the list: the honey-check
// moment (--motion-moment, 420 ms) plus a beat to see the tick at full motion. The row leaves on
// this timer, never on the server's answer (spec 17.5: no click waits).
const EXIT_DELAY_MS = { full: 720, subtle: 200, off: 0 } as const;
const UNDO_WINDOW_MS = 10_000;
@@ -15,15 +17,70 @@ function wait(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
function toggled(ids: ReadonlySet<string>, id: string, on: boolean): ReadonlySet<string> {
if (ids.has(id) === on) return ids;
const next = new Set(ids);
if (on) next.add(id);
else next.delete(id);
return next;
}
/**
* Complete / start / undo for tasks on the start page. Completing shows the
* tick immediately, lets the row fold away and only then refreshes the list,
* so the finished task visibly leaves instead of blinking out. Every
* completion can be taken back from the toast.
* tick immediately; after the moment (EXIT_DELAY_MS) the row leaves the list
* (`goneIds`), however long the server takes. The list is refreshed after both.
* A failed save or an undo brings the row back. Every completion can be taken
* back from the toast.
*/
export function useTaskActions() {
interface TaskActionsOptions {
/** Called right before a ticked task leaves the list (the row is still in the DOM). */
onGone?: (id: string) => void;
}
export function useTaskActions({ onGone }: TaskActionsOptions = {}) {
const queryClient = useQueryClient();
const onGoneRef = useRef(onGone);
useEffect(() => {
onGoneRef.current = onGone;
});
const [leavingIds, setLeavingIds] = useState<ReadonlySet<string>>(new Set());
const [goneIds, setGoneIds] = useState<ReadonlySet<string>>(new Set());
const goneTimers = useRef(new Map<string, ReturnType<typeof setTimeout>>());
useEffect(() => {
const timers = goneTimers.current;
return () => timers.forEach((timer) => clearTimeout(timer));
}, []);
const setGone = (id: string, gone: boolean) => setGoneIds((current) => toggled(current, id, gone));
const markGone = (id: string) => {
onGoneRef.current?.(id);
setGone(id, true);
};
/** The row leaves after the moment of the current motion level ("Aus": at once). */
const leaveAfterMoment = (id: string) => {
const delay = EXIT_DELAY_MS[effectiveMotionLevel()];
if (delay === 0) {
markGone(id);
return;
}
goneTimers.current.set(
id,
setTimeout(() => {
goneTimers.current.delete(id);
markGone(id);
}, delay),
);
};
const comeBack = (id: string) => {
clearTimeout(goneTimers.current.get(id));
goneTimers.current.delete(id);
setGone(id, false);
setLeaving(id, false);
};
const refresh = () =>
Promise.all([
@@ -31,19 +88,15 @@ export function useTaskActions() {
queryClient.invalidateQueries({ queryKey: ["dashboard"] }),
]);
const setLeaving = (id: string, leaving: boolean) =>
setLeavingIds((current) => {
const next = new Set(current);
if (leaving) next.add(id);
else next.delete(id);
return next;
});
function setLeaving(id: string, leaving: boolean) {
setLeavingIds((current) => toggled(current, id, leaving));
}
const undo = useMutation({
mutationFn: (task: Task) => updateTask(task.id, { status: task.status }),
onSuccess: async (_result, task) => {
await refresh();
setLeaving(task.id, false);
comeBack(task.id);
toast.success("Wieder offen");
},
onError: () =>
@@ -51,6 +104,7 @@ export function useTaskActions() {
});
const complete = useMutation({
// The refresh waits for the moment too, so fresh data never cuts the tick short.
mutationFn: async (task: Task) => {
const [done] = await Promise.all([
completeTask(task.id),
@@ -58,7 +112,10 @@ export function useTaskActions() {
]);
return done;
},
onMutate: (task) => setLeaving(task.id, true),
onMutate: (task) => {
setLeaving(task.id, true);
leaveAfterMoment(task.id);
},
onSuccess: async (_done, task) => {
await refresh();
toast.success(`Erledigt: ${task.title}`, {
@@ -68,7 +125,7 @@ export function useTaskActions() {
});
},
onError: (_error, task) => {
setLeaving(task.id, false);
comeBack(task.id);
toast.error("Konnte nicht als erledigt gespeichert werden. Bitte noch einmal tippen.");
},
});
@@ -84,6 +141,8 @@ export function useTaskActions() {
return {
leavingIds,
/** Ticked tasks whose moment is over: they are no longer shown, whatever the server says. */
goneIds,
complete: (task: Task) => complete.mutate(task),
start: (task: Task) => start.mutate(task),
isStarting: start.isPending,
+1 -1
View File
@@ -5,7 +5,7 @@ import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const badgeVariants = cva(
"group/badge inline-flex h-5.5 w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-4xl border border-transparent px-2.5 py-0.5 text-xs font-semibold whitespace-nowrap transition-all has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 aria-invalid:border-destructive [&>svg]:pointer-events-none [&>svg]:size-3!",
"group/badge inline-flex h-5.5 w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-4xl border border-transparent px-2.5 py-0.5 text-xs font-semibold whitespace-nowrap transition-colors has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 aria-invalid:border-destructive [&>svg]:pointer-events-none [&>svg]:size-3!",
{
variants: {
variant: {
@@ -9,7 +9,7 @@ const HIT_AREA =
"relative pointer-coarse:after:absolute pointer-coarse:after:top-1/2 pointer-coarse:after:left-1/2 pointer-coarse:after:size-11 pointer-coarse:after:-translate-1/2"
const buttonVariants = cva(
"group/button inline-flex shrink-0 items-center justify-center rounded-xl border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all select-none motion-full:active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-1 aria-invalid:ring-inset aria-invalid:ring-destructive [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
"group/button inline-flex shrink-0 items-center justify-center rounded-xl border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-[color,background-color,border-color,opacity,scale] duration-100 select-none motion-full:active:not-aria-[haspopup]:scale-[0.97] disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-1 aria-invalid:ring-inset aria-invalid:ring-destructive [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
{
variants: {
variant: {
+2 -2
View File
@@ -16,9 +16,9 @@ function Card({
data-slot="card"
data-size={size}
className={cn(
"group/card flex flex-col gap-(--card-spacing) overflow-clip rounded-2xl border border-border/50 bg-card py-(--card-spacing) text-sm text-card-foreground shadow-[var(--shadow-soft)] transition-all duration-200 [--card-spacing:--spacing(4)] has-data-[slot=card-footer]:pb-0 has-[>img:first-child]:pt-0 data-[size=sm]:[--card-spacing:--spacing(3)] data-[size=sm]:has-data-[slot=card-footer]:pb-0 *:[img:first-child]:rounded-t-2xl *:[img:last-child]:rounded-b-2xl",
"group/card flex flex-col gap-(--card-spacing) overflow-clip rounded-2xl border border-border/50 bg-card py-(--card-spacing) text-sm text-card-foreground shadow-[var(--shadow-soft)] [--card-spacing:--spacing(4)] has-data-[slot=card-footer]:pb-0 has-[>img:first-child]:pt-0 data-[size=sm]:[--card-spacing:--spacing(3)] data-[size=sm]:has-data-[slot=card-footer]:pb-0 *:[img:first-child]:rounded-t-2xl *:[img:last-child]:rounded-b-2xl",
interactive &&
"motion-safe:hover:-translate-y-0.5 hover:shadow-[var(--shadow-elevated)] hover:border-border",
"transition-colors hover:border-border",
className
)}
{...props}
@@ -31,7 +31,7 @@ function DialogOverlay({
<DialogPrimitive.Backdrop
data-slot="dialog-overlay"
className={cn(
"fixed inset-0 isolate z-50 bg-black/10 duration-100 supports-backdrop-filter:backdrop-blur-xs data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0",
"fixed inset-0 isolate z-50 bg-black/10 transition-opacity duration-(--motion-enter) ease-(--ease-out-expo) data-starting-style:opacity-0 data-ending-style:opacity-0 data-ending-style:duration-(--motion-fade) data-ending-style:ease-(--ease-in) supports-backdrop-filter:backdrop-blur-xs",
className
)}
{...props}
@@ -53,7 +53,7 @@ function DialogContent({
<DialogPrimitive.Popup
data-slot="dialog-content"
className={cn(
"fixed top-1/2 left-1/2 z-50 grid w-full max-w-[calc(100%-2rem)] -translate-x-1/2 -translate-y-1/2 gap-4 rounded-2xl bg-popover p-5 text-sm text-popover-foreground shadow-[var(--shadow-elevated)] ring-1 ring-foreground/10 max-h-[calc(100dvh-2rem)] overflow-y-auto overscroll-contain duration-100 sm:max-w-sm data-open:animate-in data-open:fade-in-0 motion-full:data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 motion-full:data-closed:zoom-out-95",
"fixed top-1/2 left-1/2 z-50 grid w-full max-w-[calc(100%-2rem)] -translate-x-1/2 -translate-y-1/2 gap-4 rounded-2xl bg-popover p-5 text-sm text-popover-foreground shadow-[var(--shadow-elevated)] ring-1 ring-foreground/10 max-h-[calc(100dvh-2rem)] overflow-y-auto overscroll-contain sm:max-w-sm transition-[opacity,scale] duration-(--motion-enter) ease-(--ease-out-expo) data-starting-style:opacity-0 data-ending-style:opacity-0 data-ending-style:duration-(--motion-fade) data-ending-style:ease-(--ease-in) motion-full:data-starting-style:scale-[0.97] motion-full:data-ending-style:scale-[0.97]",
className
)}
{...props}
@@ -41,7 +41,7 @@ function DropdownMenuContent({
>
<MenuPrimitive.Popup
data-slot="dropdown-menu-content"
className={cn("z-50 max-h-(--available-height) w-(--anchor-width) min-w-32 origin-(--transform-origin) overflow-x-hidden overflow-y-auto rounded-lg bg-popover p-1 text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:overflow-hidden data-closed:fade-out-0 data-closed:zoom-out-95", className )}
className={cn("z-50 max-h-(--available-height) w-(--anchor-width) min-w-32 origin-(--transform-origin) overflow-x-hidden overflow-y-auto rounded-lg bg-popover p-1 text-popover-foreground shadow-md ring-1 ring-foreground/10 transition-[opacity,scale] duration-(--motion-enter) ease-(--ease-out-expo) data-starting-style:opacity-0 data-ending-style:opacity-0 data-ending-style:duration-(--motion-fade) data-ending-style:ease-(--ease-in) motion-full:data-starting-style:scale-[0.97] motion-full:data-ending-style:scale-[0.97] data-closed:overflow-hidden", className )}
{...props}
/>
</MenuPrimitive.Positioner>
@@ -135,7 +135,7 @@ function DropdownMenuSubContent({
return (
<DropdownMenuContent
data-slot="dropdown-menu-sub-content"
className={cn("w-auto min-w-[96px] rounded-lg bg-popover p-1 text-popover-foreground shadow-lg ring-1 ring-foreground/10 duration-100 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95", className )}
className={cn("w-auto min-w-[96px] rounded-lg bg-popover p-1 text-popover-foreground shadow-lg ring-1 ring-foreground/10 transition-[opacity,scale] duration-(--motion-enter) ease-(--ease-out-expo) data-starting-style:opacity-0 data-ending-style:opacity-0 data-ending-style:duration-(--motion-fade) data-ending-style:ease-(--ease-in) motion-full:data-starting-style:scale-[0.97] motion-full:data-ending-style:scale-[0.97]", className )}
align={align}
alignOffset={alignOffset}
side={side}
+1 -1
View File
@@ -9,7 +9,7 @@ function Input({ className, type, ...props }: React.ComponentProps<"input">) {
type={type}
data-slot="input"
className={cn(
"h-11 w-full min-w-0 pointer-fine:h-9 rounded-xl border border-input bg-card px-3 py-1 text-base shadow-xs transition-all file:inline-flex file:h-6 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground hover:border-foreground/20 disabled:pointer-events-none disabled:cursor-not-allowed disabled:bg-muted disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-1 aria-invalid:ring-inset aria-invalid:ring-destructive",
"h-11 w-full min-w-0 pointer-fine:h-9 rounded-xl border border-input bg-card px-3 py-1 text-base shadow-xs transition-colors file:inline-flex file:h-6 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground hover:border-foreground/20 disabled:pointer-events-none disabled:cursor-not-allowed disabled:bg-muted disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-1 aria-invalid:ring-inset aria-invalid:ring-destructive",
className
)}
{...props}
@@ -0,0 +1,46 @@
import { describe, expect, it } from "vitest";
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./select";
function StatusSelect() {
return (
<Select defaultValue="all">
<SelectTrigger aria-label="Status">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">Alle Status</SelectItem>
<SelectItem value="active">Aktiv</SelectItem>
</SelectContent>
</Select>
);
}
/** Listboxes a screen reader could still reach: not inside a hidden or inert ancestor. */
function reachableListboxes(): Element[] {
return Array.from(document.querySelectorAll('[role="listbox"]')).filter(
(element) => !element.closest("[hidden], [inert], [aria-hidden='true']"),
);
}
describe("SelectContent (spec 17.4)", () => {
it("fades in and out with the overlay tokens, also when aligned with the trigger", async () => {
render(<StatusSelect />);
await userEvent.click(screen.getByRole("combobox", { name: "Status" }));
const popup = await screen.findByRole("listbox");
const classes = (popup.closest('[data-slot="select-content"]') ?? popup).className;
expect(classes).toMatch(/duration-\(--motion-enter\)/);
expect(classes).toMatch(/data-ending-style:duration-\(--motion-fade\)/);
expect(classes).not.toMatch(/transition-none/);
// Aligned with the trigger it lies over it: fade only, no scale.
expect(classes).toMatch(/data-\[align-trigger=true\]:transition-opacity/);
});
it("leaves no listbox a screen reader could reach after closing", async () => {
render(<StatusSelect />);
await userEvent.click(screen.getByRole("combobox", { name: "Status" }));
await userEvent.click(await screen.findByRole("option", { name: "Aktiv" }));
await waitFor(() => expect(reachableListboxes()).toEqual([]));
});
});
@@ -41,7 +41,7 @@ function SelectTrigger({
data-slot="select-trigger"
data-size={size}
className={cn(
"flex w-fit items-center justify-between gap-1.5 rounded-xl border border-input bg-card py-2 pr-2.5 pl-3 text-sm whitespace-nowrap shadow-xs transition-all select-none hover:border-foreground/20 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-1 aria-invalid:ring-inset aria-invalid:ring-destructive data-placeholder:text-muted-foreground data-[size=default]:h-11 pointer-fine:data-[size=default]:h-9 data-[size=sm]:h-7 data-[size=sm]:rounded-[min(var(--radius-md),10px)] *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-1.5 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
"flex w-fit items-center justify-between gap-1.5 rounded-xl border border-input bg-card py-2 pr-2.5 pl-3 text-sm whitespace-nowrap shadow-xs transition-colors select-none hover:border-foreground/20 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-1 aria-invalid:ring-inset aria-invalid:ring-destructive data-placeholder:text-muted-foreground data-[size=default]:h-11 pointer-fine:data-[size=default]:h-9 data-[size=sm]:h-7 data-[size=sm]:rounded-[min(var(--radius-md),10px)] *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-1.5 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
@@ -83,7 +83,7 @@ function SelectContent({
<SelectPrimitive.Popup
data-slot="select-content"
data-align-trigger={alignItemWithTrigger}
className={cn("relative isolate z-50 max-h-(--available-height) w-(--anchor-width) min-w-36 origin-(--transform-origin) overflow-x-hidden overflow-y-auto rounded-xl bg-popover p-1 text-popover-foreground shadow-[var(--shadow-elevated)] ring-1 ring-foreground/10 duration-100 data-[align-trigger=true]:animate-none data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95", className )}
className={cn("relative isolate z-50 max-h-(--available-height) w-(--anchor-width) min-w-36 origin-(--transform-origin) overflow-x-hidden overflow-y-auto rounded-xl bg-popover p-1 text-popover-foreground shadow-[var(--shadow-elevated)] ring-1 ring-foreground/10 transition-[opacity,scale] duration-(--motion-enter) ease-(--ease-out-expo) data-starting-style:opacity-0 data-ending-style:opacity-0 data-ending-style:duration-(--motion-fade) data-ending-style:ease-(--ease-in) motion-full:data-starting-style:scale-[0.97] motion-full:data-ending-style:scale-[0.97] data-[align-trigger=true]:transition-opacity", className )}
{...props}
>
<SelectScrollUpButton />
+2 -2
View File
@@ -28,7 +28,7 @@ function SheetOverlay({ className, ...props }: SheetPrimitive.Backdrop.Props) {
<SheetPrimitive.Backdrop
data-slot="sheet-overlay"
className={cn(
"fixed inset-0 z-50 bg-black/10 transition-opacity duration-150 data-ending-style:opacity-0 data-starting-style:opacity-0 supports-backdrop-filter:backdrop-blur-xs",
"fixed inset-0 z-50 bg-black/10 transition-opacity duration-(--motion-enter) ease-(--ease-out-expo) data-starting-style:opacity-0 data-ending-style:opacity-0 data-ending-style:duration-(--motion-fade) data-ending-style:ease-(--ease-in) supports-backdrop-filter:backdrop-blur-xs",
className
)}
{...props}
@@ -53,7 +53,7 @@ function SheetContent({
data-slot="sheet-content"
data-side={side}
className={cn(
"fixed z-50 flex flex-col gap-4 bg-popover bg-clip-padding text-sm text-popover-foreground shadow-lg transition duration-200 ease-in-out data-ending-style:opacity-0 data-starting-style:opacity-0 data-[side=bottom]:inset-x-0 data-[side=bottom]:bottom-0 data-[side=bottom]:h-auto data-[side=bottom]:border-t motion-full:data-[side=bottom]:data-ending-style:translate-y-[2.5rem] motion-full:data-[side=bottom]:data-starting-style:translate-y-[2.5rem] data-[side=left]:inset-y-0 data-[side=left]:left-0 data-[side=left]:h-full data-[side=left]:w-3/4 data-[side=left]:border-r motion-full:data-[side=left]:data-ending-style:translate-x-[-2.5rem] motion-full:data-[side=left]:data-starting-style:translate-x-[-2.5rem] data-[side=right]:inset-y-0 data-[side=right]:right-0 data-[side=right]:h-full data-[side=right]:w-3/4 data-[side=right]:border-l motion-full:data-[side=right]:data-ending-style:translate-x-[2.5rem] motion-full:data-[side=right]:data-starting-style:translate-x-[2.5rem] data-[side=top]:inset-x-0 data-[side=top]:top-0 data-[side=top]:h-auto data-[side=top]:border-b motion-full:data-[side=top]:data-ending-style:translate-y-[-2.5rem] motion-full:data-[side=top]:data-starting-style:translate-y-[-2.5rem] data-[side=left]:sm:max-w-sm data-[side=right]:sm:max-w-sm",
"fixed z-50 flex flex-col gap-4 bg-popover bg-clip-padding text-sm text-popover-foreground shadow-lg transition-[opacity,translate] duration-(--motion-enter) ease-(--ease-out-expo) data-starting-style:opacity-0 data-ending-style:opacity-0 data-ending-style:duration-(--motion-fade) data-ending-style:ease-(--ease-in) data-[side=bottom]:inset-x-0 data-[side=bottom]:bottom-0 data-[side=bottom]:h-auto data-[side=bottom]:border-t motion-full:data-[side=bottom]:data-ending-style:translate-y-[2.5rem] motion-full:data-[side=bottom]:data-starting-style:translate-y-[2.5rem] data-[side=left]:inset-y-0 data-[side=left]:left-0 data-[side=left]:h-full data-[side=left]:w-3/4 data-[side=left]:border-r motion-full:data-[side=left]:data-ending-style:translate-x-[-2.5rem] motion-full:data-[side=left]:data-starting-style:translate-x-[-2.5rem] data-[side=right]:inset-y-0 data-[side=right]:right-0 data-[side=right]:h-full data-[side=right]:w-3/4 data-[side=right]:border-l motion-full:data-[side=right]:data-ending-style:translate-x-[2.5rem] motion-full:data-[side=right]:data-starting-style:translate-x-[2.5rem] data-[side=top]:inset-x-0 data-[side=top]:top-0 data-[side=top]:h-auto data-[side=top]:border-b motion-full:data-[side=top]:data-ending-style:translate-y-[-2.5rem] motion-full:data-[side=top]:data-starting-style:translate-y-[-2.5rem] data-[side=left]:sm:max-w-sm data-[side=right]:sm:max-w-sm",
className
)}
{...props}
@@ -35,7 +35,7 @@ const Toaster = ({ ...props }: ToasterProps) => {
<OctagonXIcon className="size-4" />
),
loading: (
<Loader2Icon className="size-4 animate-spin" />
<Loader2Icon className="size-4 motion-full:animate-spin" />
),
}}
style={
@@ -7,7 +7,7 @@ function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
<textarea
data-slot="textarea"
className={cn(
"flex field-sizing-content min-h-16 w-full rounded-xl border border-input bg-card px-3 py-2 text-base shadow-xs transition-all placeholder:text-muted-foreground hover:border-foreground/20 disabled:cursor-not-allowed disabled:bg-muted disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-1 aria-invalid:ring-inset aria-invalid:ring-destructive",
"flex field-sizing-content min-h-16 w-full rounded-xl border border-input bg-card px-3 py-2 text-base shadow-xs transition-colors placeholder:text-muted-foreground hover:border-foreground/20 disabled:cursor-not-allowed disabled:bg-muted disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-1 aria-invalid:ring-inset aria-invalid:ring-destructive",
className
)}
{...props}
@@ -100,7 +100,9 @@ describe("ui sizes, type and focus (spec 4.5, 4.6)", () => {
it("the press nudge of a button only moves with the motion level full", () => {
render(<Button>Los</Button>);
const classes = screen.getByRole("button", { name: "Los" }).className;
expect(has(classes, "motion-full:active:not-aria-[haspopup]:translate-y-px")).toBe(true);
expect(classes).not.toMatch(/(^|\s)active:not-aria-\[haspopup\]:translate-y-px/);
// Spec 17.4: the button gives way (scale 0.97, 100 ms) at full motion only.
expect(has(classes, "motion-full:active:not-aria-[haspopup]:scale-[0.97]")).toBe(true);
expect(classes).not.toMatch(/(^|\s)active:not-aria-\[haspopup\]:(?:scale|translate)/);
expect(classes).not.toMatch(/translate-y-px|transition-all/);
});
});
@@ -53,7 +53,7 @@ function StatusLine({ status }: { status: VoiceStatusText }) {
return (
<p className="flex items-center gap-2 text-sm font-medium">
<Icon
className={`size-4 shrink-0${status.icon === "work" ? " motion-safe:animate-spin" : ""}`}
className={`size-4 shrink-0${status.icon === "work" ? " motion-full:animate-spin" : ""}`}
aria-hidden
/>
{status.label}
@@ -98,7 +98,7 @@ describe("VoiceRecorder", () => {
await startAndRecord(user);
const dot = screen.getByText("Aufnahme läuft", { selector: "p" }).querySelector("span");
expect(dot?.className).toContain("motion-safe:animate-pulse");
expect(dot?.className).toContain("motion-full:animate-pulse");
expect(dot?.className.split(" ")).not.toContain("animate-pulse");
});
@@ -378,7 +378,7 @@ export function VoiceRecorder({
<p className="flex items-center gap-2 text-lg font-semibold text-destructive">
<span
aria-hidden
className="size-3 rounded-full bg-destructive motion-safe:animate-pulse"
className="size-3 rounded-full bg-destructive motion-full:animate-pulse"
/>
Aufnahme läuft
</p>
@@ -0,0 +1,134 @@
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
import { SPRING_DAMPING, SPRING_STIFFNESS, springStep } from "@/components/hive/hive-scene/quality";
import {
EASE_IN,
EASE_OUT_EXPO,
MOTION,
SPRING_EASING_MS,
SPRING_FLIGHT,
SUBTLE_FADE_SECONDS,
scrollBehaviorFor,
springEasing,
transitionFor,
} from "./tokens";
import type { MotionLevel } from "./motion-preference";
const GLOBALS = readFileSync(join(__dirname, "..", "..", "app", "globals.css"), "utf8");
/** Value of a custom property declared in globals.css (first declaration). */
function cssVar(name: string): string {
const match = new RegExp(`${name}:\\s*([^;]+);`).exec(GLOBALS);
if (!match) throw new Error(`${name} missing in globals.css`);
return match[1].trim().replace(/\s+/g, " ");
}
/** Position of the flight spring from 0 toward 1 after `seconds`, stepped like the hive does it. */
function springAt(seconds: number): number {
let state = { value: 0, velocity: 0 };
// The hive steps once per frame; 1/60 s frames, remainder last.
let left = seconds;
while (left > 1e-9) {
const dt = Math.min(1 / 60, left);
state = springStep(state.value, state.velocity, 1, dt);
left -= dt;
}
return state.value;
}
function linearStops(easing: string): number[] {
const body = /^linear\((.*)\)$/.exec(easing)?.[1];
if (!body) throw new Error(`not a linear() easing: ${easing}`);
return body.split(",").map((stop) => Number(stop.trim()));
}
const LEVELS: MotionLevel[] = ["full", "subtle", "off"];
const KINDS = ["enter", "exit", "fade", "layout"] as const;
describe("motion tokens (spec 17.3)", () => {
it("css motion variables equal the TS tokens", () => {
expect(cssVar("--motion-fade")).toBe(`${MOTION.fadeMs}ms`);
expect(cssVar("--motion-enter")).toBe(`${MOTION.enterMs}ms`);
expect(cssVar("--motion-settle")).toBe(`${MOTION.settleMs}ms`);
expect(cssVar("--motion-moment")).toBe(`${MOTION.momentMs}ms`);
expect(cssVar("--motion-shift")).toBe(`${MOTION.shiftPx}px`);
expect(cssVar("--motion-spring")).toBe(`${SPRING_EASING_MS}ms`);
expect(cssVar("--ease-out-expo")).toBe(`cubic-bezier(${EASE_OUT_EXPO.join(", ")})`);
expect(cssVar("--ease-in")).toBe(`cubic-bezier(${EASE_IN.join(", ")})`);
expect(cssVar("--ease-spring")).toBe(springEasing());
// The page enters with the enter token (it was 260 ms before spec 17).
expect(cssVar("--animate-page-in")).toBe("page-in var(--motion-enter) var(--ease-out-expo)");
expect(SUBTLE_FADE_SECONDS).toBe(MOTION.fadeMs / 1000);
});
it("the flight spring equals the hive spring in quality.ts", () => {
expect(SPRING_FLIGHT).toEqual({ type: "spring", stiffness: SPRING_STIFFNESS, damping: SPRING_DAMPING, mass: 1 });
});
it("--ease-spring samples stay within 0.02 of springStep", () => {
const stops = linearStops(springEasing());
expect(stops[0]).toBe(0);
expect(stops.at(-1)).toBe(1);
// A touch of overshoot, as on the hive stage.
expect(Math.max(...stops)).toBeGreaterThan(1);
const seconds = SPRING_EASING_MS / 1000;
const last = stops.length - 1;
for (let i = 0; i < last; i += 1) {
for (const fraction of [0, 0.5]) {
const at = (i + fraction) / last;
const eased = stops[i] + (stops[i + 1] - stops[i]) * fraction;
expect(Math.abs(eased - springAt(at * seconds)), `t = ${at.toFixed(3)}`).toBeLessThanOrEqual(0.02);
}
}
});
it("transitionFor off has zero duration and subtle has no spatial part", () => {
for (const kind of KINDS) {
expect(transitionFor("off", kind)).toEqual({ duration: 0 });
const subtle = transitionFor("subtle", kind);
expect(subtle).not.toHaveProperty("type", "spring");
expect((subtle as { duration?: number }).duration ?? 0).toBeLessThanOrEqual(SUBTLE_FADE_SECONDS);
}
expect(transitionFor("subtle", "layout")).toEqual({ duration: 0 });
expect(transitionFor("full", "layout")).toEqual(SPRING_FLIGHT);
expect(transitionFor("full", "enter")).toEqual({ duration: MOTION.enterMs / 1000, ease: EASE_OUT_EXPO });
// Exits are never longer than the fade and leave faster than they came (ease-in).
for (const level of LEVELS) {
const exit = transitionFor(level, "exit") as { duration: number; ease?: unknown };
expect(exit.duration).toBeLessThanOrEqual(SUBTLE_FADE_SECONDS);
if (level !== "off") expect(exit.ease).toEqual(EASE_IN);
}
});
it("scrollBehaviorFor is smooth only at full and below one viewport", () => {
expect(scrollBehaviorFor("full", 300, 800)).toBe("smooth");
expect(scrollBehaviorFor("full", -300, 800)).toBe("smooth");
expect(scrollBehaviorFor("full", 800, 800)).toBe("instant");
expect(scrollBehaviorFor("full", 2400, 800)).toBe("instant");
expect(scrollBehaviorFor("subtle", 100, 800)).toBe("instant");
expect(scrollBehaviorFor("off", 100, 800)).toBe("instant");
});
it("the hive reads its spring and its fade from tokens.ts (one source)", () => {
const hive = join(__dirname, "..", "..", "components", "hive", "hive-scene");
const quality = readFileSync(join(hive, "quality.ts"), "utf8");
const hiveMotion = readFileSync(join(hive, "use-hive-motion.ts"), "utf8");
for (const text of [quality, hiveMotion]) expect(text).toMatch(/from\s+["']@\/lib\/motion\/tokens["']/);
expect(quality).not.toMatch(/SPRING_(?:STIFFNESS|DAMPING)\s*=\s*\d/);
expect(hiveMotion).not.toMatch(/SUBTLE_FADE_SECONDS\s*=\s*[\d.]/);
});
it("tokens.ts stays tiny and motion-free at runtime (type imports only)", () => {
const tokens = readFileSync(join(__dirname, "tokens.ts"), "utf8");
const imports = [...tokens.matchAll(/^import\s.*$/gm)].map(([line]) => line);
expect(imports.every((line) => line.startsWith("import type "))).toBe(true);
expect(tokens.length).toBeLessThan(8_000);
});
it("no DOM duration token is longer than the moment", () => {
for (const ms of [MOTION.fadeMs, MOTION.enterMs, MOTION.settleMs]) {
expect(ms).toBeLessThanOrEqual(MOTION.momentMs);
}
});
});
+96
View File
@@ -0,0 +1,96 @@
import type { Transition } from "motion/react";
import type { MotionLevel } from "./motion-preference";
/**
* Motion tokens (spec 17.3): one source for CSS (`--motion-*`, `--ease-*` in globals.css, kept
* equal by tokens.spec.ts) and for TypeScript (`motion` and the hive stage). The values come from
* what the hive already ships, so the stage and the app feel like one app.
*/
export const MOTION = {
/** Every fade and exit, and every change at "Dezent". */
fadeMs: 150,
/** Entry of sheet, dialog, popover, menu, page and a new list row. */
enterMs: 200,
/** A move without a spring: a chevron turning, a section opening. */
settleMs: 350,
/** The one moment: ticking a task (the honey cell fills). No DOM transition is longer. */
momentMs: 420,
/** Distance content travels on entry. */
shiftPx: 8,
} as const;
/** A skeleton or loading line appears only after this, so short loads do not blink (spec 17.4). */
export const SKELETON_DELAY_MS = 150;
/** `cubic-bezier` points of `--ease-out-expo` (entries, the moment). */
export const EASE_OUT_EXPO = [0.16, 1, 0.3, 1] as const;
/** `cubic-bezier` points of `--ease-in` (exits: gone faster than they came). */
export const EASE_IN = [0.4, 0, 1, 1] as const;
/** The hive's flight spring (damping ratio ≈ 0.72, settled after ≈ 0.5 s), for `layout` moves. */
export const SPRING_FLIGHT = { type: "spring", stiffness: 210, damping: 21, mass: 1 } as const;
/** Fade at "Dezent" in seconds (the hive stage uses the same). */
export const SUBTLE_FADE_SECONDS = MOTION.fadeMs / 1000;
/** Duration of a CSS move on `--ease-spring` (the spring is at rest by then). */
export const SPRING_EASING_MS = 500;
const SPRING_SUBSTEP_SECONDS = 1 / 120;
const DEFAULT_SPRING_POINTS = 32;
/**
* The flight spring as a CSS `linear()` easing over SPRING_EASING_MS: position from 0 toward 1,
* stepped like the hive steps it (semi-implicit Euler, 1/120 s substeps), sampled at `points`
* evenly spaced stops. The last stop is exactly 1. Generates `--ease-spring` in globals.css.
*/
export function springEasing(points: number = DEFAULT_SPRING_POINTS): string {
const seconds = SPRING_EASING_MS / 1000;
const last = Math.max(2, Math.round(points)) - 1;
const stops: number[] = [0];
let x = 0;
let v = 0;
let elapsed = 0;
for (let i = 1; i <= last; i += 1) {
const until = (i / last) * seconds;
while (until - elapsed > 1e-9) {
const dt = Math.min(SPRING_SUBSTEP_SECONDS, until - elapsed);
v += (SPRING_FLIGHT.stiffness * (1 - x) - SPRING_FLIGHT.damping * v) * dt;
x += v * dt;
elapsed += dt;
}
stops.push(i === last ? 1 : Math.round(x * 1000) / 1000);
}
return `linear(${stops.join(", ")})`;
}
export type TransitionKind = "enter" | "exit" | "fade" | "layout";
const INSTANT: Transition = { duration: 0 };
const FADE: Transition = { duration: SUBTLE_FADE_SECONDS, ease: "easeOut" };
const EXIT: Transition = { duration: SUBTLE_FADE_SECONDS, ease: EASE_IN };
const TRANSITIONS: Record<MotionLevel, Record<TransitionKind, Transition>> = {
full: {
enter: { duration: MOTION.enterMs / 1000, ease: EASE_OUT_EXPO },
exit: EXIT,
fade: FADE,
layout: SPRING_FLIGHT,
},
// "Dezent": only opacity and colour change; things stand at their target at once.
subtle: { enter: FADE, exit: EXIT, fade: FADE, layout: INSTANT },
off: { enter: INSTANT, exit: INSTANT, fade: INSTANT, layout: INSTANT },
};
/** The `motion` transition for one kind of change at a motion level ("Aus" → no duration). */
export function transitionFor(level: MotionLevel, kind: TransitionKind): Transition {
return TRANSITIONS[level][kind];
}
/**
* Scrolling to a programmatic focus target (first error field, an opened section): smooth only
* at "Voll" and for less than one screen height, so nobody waits for a long glide.
*/
export function scrollBehaviorFor(level: MotionLevel, distancePx: number, viewportPx: number): ScrollBehavior {
return level === "full" && Math.abs(distancePx) < viewportPx ? "smooth" : "instant";
}
@@ -0,0 +1,25 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { act, renderHook } from "@testing-library/react";
import { useAfterDelay } from "./use-after-delay";
describe("useAfterDelay (spec 17.4: a skeleton appears only after 150 ms)", () => {
beforeEach(() => vi.useFakeTimers());
afterEach(() => vi.useRealTimers());
it("is false first and true once the delay has passed", () => {
const { result } = renderHook(() => useAfterDelay(150));
expect(result.current).toBe(false);
act(() => vi.advanceTimersByTime(149));
expect(result.current).toBe(false);
act(() => vi.advanceTimersByTime(1));
expect(result.current).toBe(true);
});
it("does not fire after unmount (a short load never blinks)", () => {
const clear = vi.spyOn(globalThis, "clearTimeout");
const { unmount } = renderHook(() => useAfterDelay(150));
unmount();
expect(clear).toHaveBeenCalled();
clear.mockRestore();
});
});
@@ -0,0 +1,16 @@
"use client";
import { useEffect, useState } from "react";
/**
* True once `ms` have passed since mount. A skeleton or a loading line waits for it, so a short
* load never makes the page blink (spec 17.4: skeleton after SKELETON_DELAY_MS).
*/
export function useAfterDelay(ms: number): boolean {
const [passed, setPassed] = useState(false);
useEffect(() => {
const timer = setTimeout(() => setPassed(true), ms);
return () => clearTimeout(timer);
}, [ms]);
return passed;
}
@@ -14,6 +14,8 @@ interface GuardRule {
pattern: RegExp;
scope: GuardScope;
allow: { file: string; reason: string }[];
/** Directories the rule does not cover at all (path prefix relative to ApiCore-Frontend). */
outside?: { prefix: string; reason: string }[];
}
const FRONTEND = join(__dirname, "..", "..");
@@ -33,6 +35,21 @@ function ownRole(keys: string, value: string): RegExp {
return new RegExp(String.raw`(?<!<CheckboxField\b[^<>]*)(?:${attribute}|${setAttribute})`);
}
/** Spec 17.8: the hive stage has its own motion (Beute §9.8): flights, lifting, idle motion. */
const HIVE_STAGE: NonNullable<GuardRule["outside"]> = [
{
prefix: "src/components/hive/",
reason: "The hive stage moves on purpose (Beute §9.4, §9.8): a part lifts on hover and flies into focus.",
},
{
prefix: "src/app/(app)/colonies/[id]/hive/",
reason: "The full-screen hive editor is the same stage (spec 17.8).",
},
];
/** A duration above --motion-moment (420 ms): Tailwind steps and arbitrary ms values. */
const OVER_MOMENT_MS = String.raw`(?:42[1-9]|4[3-9]\d|[5-9]\d\d|[1-9]\d{3,})`;
const RULES: GuardRule[] = [
// Task A3/A4: the area tone replaced the gradients (spec 11); the gradient tokens are gone.
{
@@ -153,6 +170,49 @@ const RULES: GuardRule[] = [
scope: "src/**/*.{ts,tsx}",
allow: [],
},
// Task A11: motion language (spec 17.2, 17.6, 17.7). motion-safe: only asks the system and
// skips the level chosen in the app, so movement sits behind motion-full:.
{
id: "no motion-safe: on translate, scale, rotate, slide, zoom or animate classes",
pattern: /(?<![\w-])motion-safe:(?:[^\s"'`:]+:)*-?(?:translate|scale|rotate|slide-|zoom-|animate-|transition-transform)/,
scope: "src/**/*.{ts,tsx}",
allow: [],
},
{
id: "pulse and spin only behind motion-full:",
pattern: /(?<!motion-full:)(?<![\w-])animate-(?:pulse|spin)\b/,
scope: "src/**/*.{ts,tsx}",
allow: [],
},
{
id: "no transition-all",
pattern: /(?<![\w-])transition-all\b|transition:\s*all\b/,
scope: "src/**/*.{ts,tsx,css}",
allow: [],
},
{
id: "no transition-[width|height] and no duration above 420 ms outside the hive stage",
pattern: new RegExp(
String.raw`transition-\[[^\]]*(?:width|height)|(?<![\w-])duration-(?:${OVER_MOMENT_MS}(?!\d)|\[${OVER_MOMENT_MS}ms\]|\[[1-9][\d.]*s\])`,
),
scope: "src/**/*.{ts,tsx}",
allow: [],
outside: HIVE_STAGE,
},
{
id: "hover translate or scale only on the hive stage",
pattern: /(?<![\w-])(?:group-|peer-)?hover:(?:[^\s"'`:]+:)*-?(?:translate|scale)-/,
scope: "src/**/*.{ts,tsx}",
allow: [],
outside: HIVE_STAGE,
},
{
id: "motion transitions come from the tokens (transitionFor), not inline",
pattern: /\btransition=\{\{/,
scope: "src/**/*.tsx",
allow: [],
outside: HIVE_STAGE,
},
];
const SCOPE_FILE: Record<GuardScope, RegExp> = {
@@ -175,9 +235,10 @@ const FILES = sourceFiles(SRC).map((path) => ({
}));
function offenders(rule: GuardRule): string[] {
return FILES.filter(({ file, text }) => SCOPE_FILE[rule.scope].test(file) && rule.pattern.test(text)).map(
({ file }) => file,
);
const isOutside = (file: string) => (rule.outside ?? []).some(({ prefix }) => file.startsWith(prefix));
return FILES.filter(
({ file, text }) => SCOPE_FILE[rule.scope].test(file) && !isOutside(file) && rule.pattern.test(text),
).map(({ file }) => file);
}
function violations(rule: GuardRule): string[] {
@@ -399,3 +460,216 @@ describe("icon buttons side by side (spec 4.5)", () => {
expect(overlapping).toEqual([]);
});
});
/**
* Motion language (spec 17.6, 17.7). `motion` reaches the DOM only through LazyMotion (strict) with
* `m.*` elements, so the feature bundle loads once and asynchronously in the (app) layout.
*/
const MOTION_REACT_ALLOWED = new Set(["LazyMotion", "MotionConfig", "AnimatePresence", "LayoutGroup"]);
/** Hooks of motion/react in use, by name: a new one is a decision, not a side effect. */
const MOTION_REACT_HOOKS = new Set(["useIsPresent", "usePresence"]);
/** Properties that never move (spec 17.6): they lay out or repaint on every frame. */
const FORBIDDEN_ANIMATED = [
"width",
"height",
"top",
"left",
"margin",
"padding",
"grid-template-rows",
"box-shadow",
"filter",
"backdrop-filter",
];
/** Components that show data values the beekeeper reads (spec 17.5): nothing grows or counts. */
const DATA_VALUE_FILES = [
"src/components/stat-bar-list.tsx",
"src/app/(app)/colonies/[id]/colony-varroa.tsx",
"src/components/honeycomb-pattern.tsx",
"src/components/brand-mark.tsx",
];
/** Overlays of the kit (spec 17.4): enter with --motion-enter, leave with --motion-fade and --ease-in. */
const OVERLAY_FILES = ["sheet", "dialog", "dropdown-menu", "select"].map((name) => `src/components/ui/${name}.tsx`);
function source(file: string): string {
const found = FILES.find((entry) => entry.file === file);
if (!found) throw new Error(`${file} missing`);
return found.text;
}
/** Named imports of a value import from `module` (type-only imports and specifiers are skipped). */
function namedImports(text: string, module: string): string[] {
const from = module.replaceAll("/", "\\/");
const pattern = new RegExp(String.raw`import\s+(?!type\b)\{([^}]*)\}\s+from\s+["']${from}["']`, "g");
return [...text.matchAll(pattern)].flatMap((match) =>
match[1]
.split(",")
.map((name) => name.trim())
.filter((name) => name !== "" && !name.startsWith("type "))
.map((name) => name.split(/\s+as\s+/)[0]),
);
}
/** Animated properties of the @keyframes blocks and the transition declarations in a stylesheet. */
function animatedProperties(css: string): { where: string; property: string }[] {
const found: { where: string; property: string }[] = [];
for (const block of css.matchAll(/@keyframes\s+([\w-]+)\s*\{((?:[^{}]*\{[^{}]*\})*[^{}]*)\}/g)) {
for (const declaration of block[2].matchAll(/([\w-]+)\s*:/g)) {
found.push({ where: `@keyframes ${block[1]}`, property: declaration[1] });
}
}
for (const declaration of css.matchAll(/transition(?:-property)?\s*:\s*([^;]+);/g)) {
for (const part of declaration[1].split(",")) {
const property = part.trim().split(/\s+/)[0];
if (property) found.push({ where: `transition: ${declaration[1].trim()}`, property });
}
}
return found;
}
describe("motion language (spec 17.7)", () => {
it("motion/react is used only for LazyMotion, MotionConfig, AnimatePresence and hooks; elements come from motion/react-m", () => {
const misuse = FILES.flatMap(({ file, text }) => {
const wrong = namedImports(text, "motion/react").filter(
(name) =>
!MOTION_REACT_ALLOWED.has(name) &&
!MOTION_REACT_HOOKS.has(name) &&
// The feature bundle itself, inside the one runtime module (only ever imported dynamically).
!(file === "src/components/motion/motion-runtime.tsx" && name === "domAnimation"),
);
const otherEntry =
/from\s+["'](?:framer-motion|motion|motion\/react-client|motion\/mini)["']|import\s+\*\s+as\s+\w+\s+from\s+["']motion\/react["']/.test(
text,
) || /export\s[^;]*from\s+["']motion\/react["']/.test(text);
const ownMImport =
/from\s+["']motion\/react-m["']/.test(text) && !/import\s+\*\s+as\s+m\s+from\s+["']motion\/react-m["']/.test(text);
return [
...wrong.map((name) => `${file}: ${name}`),
...(otherEntry ? [`${file}: other motion entry`] : []),
...(ownMImport ? [`${file}: m elements not imported as * as m`] : []),
];
});
expect(misuse).toEqual([]);
// The feature bundle (domAnimation) is only ever loaded with a dynamic import.
const features = FILES.filter(({ text }) => /\bdomMax\b|\bdomAnimation\b/.test(text)).map(({ file }) => file);
expect(features).toEqual(["src/components/motion/motion-runtime.tsx"]);
const staticFeatureImports = FILES.filter(({ text }) =>
/import\s[^;]*from\s+["'][^"']*motion-runtime["']/.test(text),
);
expect(staticFeatureImports.map(({ file }) => file)).toEqual([]);
// One async chunk: only the runtime imports motion; AppMotion only preloads it.
const motionUsers = FILES.filter(({ text }) =>
/^import\s+(?!type\b)[^;]*from\s+["']motion\/react(?:-m)?["']/m.test(text),
);
expect(motionUsers.map(({ file }) => file)).toEqual(["src/components/motion/motion-runtime.tsx"]);
// The three.js scene keeps its own springs (spec 17.6).
const sceneUsers = FILES.filter(
({ file, text }) => file.startsWith("src/components/hive/hive-scene/") && /["']motion\//.test(text),
);
expect(sceneUsers.map(({ file }) => file)).toEqual([]);
expect(source("src/app/(app)/layout.tsx")).toMatch(/<AppMotion>/);
expect(source("src/components/app-motion.tsx")).toMatch(/loadMotionRuntime\(\)/);
expect(source("src/components/motion/motion-runtime.tsx")).toMatch(/<LazyMotion[^>]*\bstrict\b/);
});
it("at Dezent and with the system's reduce motion the overlays fade with --motion-fade (spec 17.2)", () => {
const slots = [
"sheet-content",
"sheet-overlay",
"dialog-content",
"dialog-overlay",
"dropdown-menu-content",
"select-content",
];
// Innermost rules: selector list and body.
const rules = [...GLOBALS.matchAll(/([^{}]+)\{([^{}]*)\}/g)].map(([, selector, body]) => ({ selector, body }));
const fadeRules = rules.filter(({ body }) => /transition-duration:\s*var\(--motion-fade\)/.test(body));
const subtle = fadeRules.find(({ selector }) => selector.includes(':root[data-motion="subtle"] [data-slot="sheet-content"]'));
const reduce = fadeRules.find(
({ selector }) => !selector.includes("data-motion") && selector.includes('[data-slot="sheet-content"]'),
);
for (const slot of slots) {
expect(subtle?.selector, slot).toContain(`:root[data-motion="subtle"] [data-slot="${slot}"]`);
expect(reduce?.selector, slot).toContain(`[data-slot="${slot}"]`);
}
const reduceAt = GLOBALS.indexOf(reduce?.selector ?? "\u0000");
expect(GLOBALS.lastIndexOf("@media (prefers-reduced-motion: reduce)", reduceAt)).toBeGreaterThan(-1);
});
it("globals.css keyframes and transitions do not animate width, height, grid-template-rows, filter or box-shadow", () => {
const animated = animatedProperties(GLOBALS);
expect(animated.length).toBeGreaterThan(4);
const wrong = animated.filter(({ property }) => FORBIDDEN_ANIMATED.includes(property) || property === "all");
expect(wrong).toEqual([]);
});
it("stat bars and other data values have no transition", () => {
for (const file of DATA_VALUE_FILES) {
expect(source(file), file).not.toMatch(/(?<![\w-])(?:[^\s"'`:]+:)*(?:transition|animate-|duration-)/);
}
});
it("sheet, dialog, menu and select take their timing from the motion tokens", () => {
for (const file of OVERLAY_FILES) {
const text = source(file);
expect(text, file).toMatch(/--motion-enter/);
expect(text, file).toMatch(/--motion-fade/);
expect(text, file).toMatch(/--ease-in\b/);
expect(text, file).toMatch(/--ease-out-expo/);
expect(text, file).not.toMatch(/(?<![\w-])duration-\d|ease-in-out/);
// tw-animate-css keyframes animate `filter` too; overlays use Base UI's starting/ending style.
expect(text, file).not.toMatch(/(?<![\w-])(?:[^\s"'`:]+:)*animate-(?:in|out)\b/);
// Movement of an overlay (slide, zoom, translate) belongs to full motion.
expect(text, file).not.toMatch(/(?<![\w:-])(?:data-[\w-]+:|data-\[[^\]]+\]:)*(?:slide-in|slide-out|zoom-in|zoom-out)-/);
}
});
it("the button gives way on press at full motion only, and cards do not lift on hover", () => {
const button = source("src/components/ui/button.tsx");
expect(button).toMatch(/motion-full:active:(?:[^\s"'`:]+:)*scale-\[0\.97\]/);
expect(button).not.toMatch(/translate-y-px/);
expect(source("src/components/ui/card.tsx")).not.toMatch(/hover:-?translate|hover:shadow/);
expect(source("src/app/(app)/sidebar.tsx")).not.toMatch(/scale-110/);
});
it("the motion patterns catch what they are meant to catch", () => {
const rule = (id: string) => RULES.find((entry) => entry.id.startsWith(id)) as GuardRule;
const safe = rule("no motion-safe:").pattern;
expect(safe.test('"motion-safe:transition-transform"')).toBe(true);
expect(safe.test('"motion-safe:data-open:zoom-in-95"')).toBe(true);
expect(safe.test('"motion-safe:-translate-y-0.5"')).toBe(true);
expect(safe.test('"motion-safe:animate-pulse"')).toBe(true);
expect(safe.test('"motion-full:animate-pulse"')).toBe(false);
const pulse = rule("pulse and spin").pattern;
expect(pulse.test('"size-4 animate-spin"')).toBe(true);
expect(pulse.test('"motion-safe:animate-pulse"')).toBe(true);
expect(pulse.test('"motion-full:animate-spin"')).toBe(false);
const all = rule("no transition-all").pattern;
expect(all.test('"transition-all duration-150"')).toBe(true);
expect(all.test("transition: all 200ms;")).toBe(true);
expect(all.test('"transition-colors"')).toBe(false);
const long = rule("no transition-[width|height]").pattern;
expect(long.test('"motion-full:transition-[width] motion-full:duration-700"')).toBe(true);
expect(long.test('"duration-500"')).toBe(true);
expect(long.test('"duration-[600ms]"')).toBe(true);
expect(long.test('"duration-[1s]"')).toBe(true);
expect(long.test('"duration-420 duration-300 duration-[0.2s] duration-(--motion-enter)"')).toBe(false);
const hover = rule("hover translate or scale").pattern;
expect(hover.test('"motion-full:group-hover:scale-110"')).toBe(true);
expect(hover.test('"motion-safe:hover:-translate-y-0.5"')).toBe(true);
expect(hover.test('"hover:bg-muted"')).toBe(false);
expect(rule("motion transitions come from").pattern.test("<m.li transition={{ duration: 0.3 }} />")).toBe(true);
expect(namedImports('import { AnimatePresence, motion } from "motion/react";', "motion/react")).toEqual([
"AnimatePresence",
"motion",
]);
expect(namedImports('import type { Transition } from "motion/react";', "motion/react")).toEqual([]);
expect(MOTION_REACT_HOOKS.has("useScroll")).toBe(false);
expect(
animatedProperties(".a { transition: grid-template-rows 1s, opacity 1s; }").map(({ property }) => property),
).toEqual(["grid-template-rows", "opacity"]);
expect(
animatedProperties("@keyframes x { from { filter: blur(1px); } to { opacity: 1; } }").map(({ property }) => property),
).toEqual(["filter", "opacity"]);
});
});