feat(t5): Bewegungs-Sprache mit Tokens, Stufen und Budget (T5a A11)
- 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:
1 parent
7025a6b589
commit
cda3e02a31
66 files changed
+2574
-773
No files matched your search
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 & 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 & 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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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"]);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user