From cda3e02a31a618e928a532fd7dda0181e16edea0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nick=20Rosenl=C3=B6cher?= Date: Fri, 16 Oct 2026 21:15:42 +0200 Subject: [PATCH] feat(t5): Bewegungs-Sprache mit Tokens, Stufen und Budget (T5a A11) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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) --- ApiCore-Frontend/Dockerfile | 2 + ApiCore-Frontend/package.json | 1 + .../scripts/check-motion-budget.mjs | 68 +++ .../scripts/check-motion-budget.test.ts | 52 ++ .../scripts/codemod-t5-motion.mjs | 64 +++ .../scripts/codemod-t5-motion.spec.mjs | 47 ++ .../src/app/(app)/colonies/[id]/hive/page.tsx | 2 +- ApiCore-Frontend/src/app/(app)/layout.tsx | 183 ++++--- .../src/app/(app)/page-transition.tsx | 7 +- .../settings/automation/rule-card-shell.tsx | 2 +- ApiCore-Frontend/src/app/(app)/sidebar.tsx | 9 +- .../src/app/(app)/suggestions/page.spec.tsx | 2 +- .../src/app/(app)/suggestions/page.tsx | 2 +- ApiCore-Frontend/src/app/(app)/voice/page.tsx | 13 +- ApiCore-Frontend/src/app/globals.css | 108 ++-- .../src/components/app-motion.spec.tsx | 40 ++ .../src/components/app-motion.tsx | 18 + .../compliance/restriction-override.tsx | 2 +- .../src/components/form/choice-group.tsx | 4 +- .../src/components/form/form-section.tsx | 2 +- .../src/components/form/number-stepper.tsx | 2 +- .../hive/editor/editor-stage-overlay.spec.tsx | 28 +- .../hive/editor/editor-stage-overlay.tsx | 6 +- .../src/components/hive/hive-card.tsx | 2 +- .../src/components/hive/hive-glass.ts | 7 + .../hive/hive-scene/hive-labels.tsx | 5 +- .../hive-scene/hive-scene-content.spec.tsx | 53 ++ .../src/components/hive/hive-scene/quality.ts | 5 +- .../hive/hive-scene/use-hive-motion.ts | 2 +- .../src/components/hive/hive-visual-slot.tsx | 4 +- .../labels/label-compliance-panel.tsx | 2 +- .../src/components/motion/motion-runtime.tsx | 116 ++++ .../components/motion/presence-list.spec.tsx | 156 ++++++ .../src/components/motion/presence-list.tsx | 84 +++ .../components/offline/offline-band.spec.tsx | 8 +- .../components/offline/pending-queue-body.tsx | 517 ++++++++++++++++++ .../offline/pending-queue-sheet.tsx | 517 +----------------- .../src/components/query-state.spec.tsx | 9 + .../src/components/query-state.tsx | 8 +- .../src/components/stat-bar-list.tsx | 2 +- .../suggestions/suggestion-group.spec.tsx | 2 +- .../suggestions/suggestion-group.tsx | 2 +- .../today/today-panel-plain.spec.tsx | 102 ++++ .../src/components/today/today-panel.spec.tsx | 188 +++++++ .../src/components/today/today-panel.tsx | 162 ++++-- .../src/components/today/use-task-actions.ts | 91 ++- ApiCore-Frontend/src/components/ui/badge.tsx | 2 +- ApiCore-Frontend/src/components/ui/button.tsx | 2 +- ApiCore-Frontend/src/components/ui/card.tsx | 4 +- ApiCore-Frontend/src/components/ui/dialog.tsx | 4 +- .../src/components/ui/dropdown-menu.tsx | 4 +- ApiCore-Frontend/src/components/ui/input.tsx | 2 +- .../src/components/ui/select.spec.tsx | 46 ++ ApiCore-Frontend/src/components/ui/select.tsx | 4 +- ApiCore-Frontend/src/components/ui/sheet.tsx | 4 +- ApiCore-Frontend/src/components/ui/sonner.tsx | 2 +- .../src/components/ui/textarea.tsx | 2 +- .../src/components/ui/ui-sizes.spec.tsx | 6 +- .../components/voice/voice-capture-card.tsx | 2 +- .../components/voice/voice-recorder.spec.tsx | 2 +- .../src/components/voice/voice-recorder.tsx | 2 +- .../src/lib/motion/tokens.spec.ts | 134 +++++ ApiCore-Frontend/src/lib/motion/tokens.ts | 96 ++++ .../src/lib/motion/use-after-delay.spec.ts | 25 + .../src/lib/motion/use-after-delay.ts | 16 + .../src/styles/baukasten-guard.spec.ts | 280 +++++++++- 66 files changed, 2574 insertions(+), 773 deletions(-) create mode 100644 ApiCore-Frontend/scripts/check-motion-budget.mjs create mode 100644 ApiCore-Frontend/scripts/check-motion-budget.test.ts create mode 100644 ApiCore-Frontend/scripts/codemod-t5-motion.mjs create mode 100644 ApiCore-Frontend/scripts/codemod-t5-motion.spec.mjs create mode 100644 ApiCore-Frontend/src/components/app-motion.spec.tsx create mode 100644 ApiCore-Frontend/src/components/app-motion.tsx create mode 100644 ApiCore-Frontend/src/components/motion/motion-runtime.tsx create mode 100644 ApiCore-Frontend/src/components/motion/presence-list.spec.tsx create mode 100644 ApiCore-Frontend/src/components/motion/presence-list.tsx create mode 100644 ApiCore-Frontend/src/components/offline/pending-queue-body.tsx create mode 100644 ApiCore-Frontend/src/components/today/today-panel-plain.spec.tsx create mode 100644 ApiCore-Frontend/src/components/today/today-panel.spec.tsx create mode 100644 ApiCore-Frontend/src/components/ui/select.spec.tsx create mode 100644 ApiCore-Frontend/src/lib/motion/tokens.spec.ts create mode 100644 ApiCore-Frontend/src/lib/motion/tokens.ts create mode 100644 ApiCore-Frontend/src/lib/motion/use-after-delay.spec.ts create mode 100644 ApiCore-Frontend/src/lib/motion/use-after-delay.ts diff --git a/ApiCore-Frontend/Dockerfile b/ApiCore-Frontend/Dockerfile index 4ec9a5c..1f187b7 100644 --- a/ApiCore-Frontend/Dockerfile +++ b/ApiCore-Frontend/Dockerfile @@ -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 diff --git a/ApiCore-Frontend/package.json b/ApiCore-Frontend/package.json index 5f30211..7cc6eca 100644 --- a/ApiCore-Frontend/package.json +++ b/ApiCore-Frontend/package.json @@ -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": { diff --git a/ApiCore-Frontend/scripts/check-motion-budget.mjs b/ApiCore-Frontend/scripts/check-motion-budget.mjs new file mode 100644 index 0000000..20b9e76 --- /dev/null +++ b/ApiCore-Frontend/scripts/check-motion-budget.mjs @@ -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); +} diff --git a/ApiCore-Frontend/scripts/check-motion-budget.test.ts b/ApiCore-Frontend/scripts/check-motion-budget.test.ts new file mode 100644 index 0000000..fbf864e --- /dev/null +++ b/ApiCore-Frontend/scripts/check-motion-budget.test.ts @@ -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); + }); +}); diff --git a/ApiCore-Frontend/scripts/codemod-t5-motion.mjs b/ApiCore-Frontend/scripts/codemod-t5-motion.mjs new file mode 100644 index 0000000..76f2341 --- /dev/null +++ b/ApiCore-Frontend/scripts/codemod-t5-motion.mjs @@ -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] + * --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`(? 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); +} diff --git a/ApiCore-Frontend/scripts/codemod-t5-motion.spec.mjs b/ApiCore-Frontend/scripts/codemod-t5-motion.spec.mjs new file mode 100644 index 0000000..40aa91b --- /dev/null +++ b/ApiCore-Frontend/scripts/codemod-t5-motion.spec.mjs @@ -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); + }); +}); diff --git a/ApiCore-Frontend/src/app/(app)/colonies/[id]/hive/page.tsx b/ApiCore-Frontend/src/app/(app)/colonies/[id]/hive/page.tsx index 6611db0..f290629 100644 --- a/ApiCore-Frontend/src/app/(app)/colonies/[id]/hive/page.tsx +++ b/ApiCore-Frontend/src/app/(app)/colonies/[id]/hive/page.tsx @@ -66,7 +66,7 @@ export default function HiveEditorPage() { ); } if (!colony.data || !hive.data || !catalog) { - return
; + return
; } return ( - { - // 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 - -
- -
-
-
+
- -
{ + // 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)]" > - {children} + Zum Inhalt springen + +
+
+
+
+ + + +
+ + + + + + + + + + {user.email} + + + + { + setContrast(checked ? "sun" : "normal"); + toast.success(checked ? "Sonnenmodus an" : "Sonnenmodus aus"); + }} + className="min-h-11" + > + + }> + Profil + + }> + Sprache & Datenschutz + + setIsLogoutOpen(true)} + > + Abmelden + + + +
+
+ +
+ {children} +
+
+
+ + +
+ router.push("/login")} + />
-
- - -
- router.push("/login")} - /> -
+ ); } diff --git a/ApiCore-Frontend/src/app/(app)/page-transition.tsx b/ApiCore-Frontend/src/app/(app)/page-transition.tsx index 351fdfd..8d470a3 100644 --- a/ApiCore-Frontend/src/app/(app)/page-transition.tsx +++ b/ApiCore-Frontend/src/app/(app)/page-transition.tsx @@ -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 ( -
+
{children}
); diff --git a/ApiCore-Frontend/src/app/(app)/settings/automation/rule-card-shell.tsx b/ApiCore-Frontend/src/app/(app)/settings/automation/rule-card-shell.tsx index fffbbc0..81b3248 100644 --- a/ApiCore-Frontend/src/app/(app)/settings/automation/rule-card-shell.tsx +++ b/ApiCore-Frontend/src/app/(app)/settings/automation/rule-card-shell.tsx @@ -41,7 +41,7 @@ export function RuleCardShell({ rule, open, onToggle, children }: RuleCardShellP {ruleSummary(rule)} diff --git a/ApiCore-Frontend/src/app/(app)/sidebar.tsx b/ApiCore-Frontend/src/app/(app)/sidebar.tsx index f111ab8..10fc784 100644 --- a/ApiCore-Frontend/src/app/(app)/sidebar.tsx +++ b/ApiCore-Frontend/src/app/(app)/sidebar.tsx @@ -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", )} > - + {item.label} {item.counter ? : null} diff --git a/ApiCore-Frontend/src/app/(app)/suggestions/page.spec.tsx b/ApiCore-Frontend/src/app/(app)/suggestions/page.spec.tsx index 853a9cf..6326e77 100644 --- a/ApiCore-Frontend/src/app/(app)/suggestions/page.spec.tsx +++ b/ApiCore-Frontend/src/app/(app)/suggestions/page.spec.tsx @@ -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"); }); diff --git a/ApiCore-Frontend/src/app/(app)/suggestions/page.tsx b/ApiCore-Frontend/src/app/(app)/suggestions/page.tsx index ab9ce37..15314f4 100644 --- a/ApiCore-Frontend/src/app/(app)/suggestions/page.tsx +++ b/ApiCore-Frontend/src/app/(app)/suggestions/page.tsx @@ -347,7 +347,7 @@ export default function SuggestionsPage() { > Verworfene (30 Tage) diff --git a/ApiCore-Frontend/src/app/(app)/voice/page.tsx b/ApiCore-Frontend/src/app/(app)/voice/page.tsx index 4e63507..0af7f75 100644 --- a/ApiCore-Frontend/src/app/(app)/voice/page.tsx +++ b/ApiCore-Frontend/src/app/(app)/voice/page.tsx @@ -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 : ( -
    + {local.map((note) => ( -
  • + discardLocal.mutate(note.id)} /> -
  • + ))} {listed.map((item) => ( -
  • + retry.mutate(item.id)} onDiscard={() => discard.mutate(item.id)} /> -
  • + ))} -
+ )}
); diff --git a/ApiCore-Frontend/src/app/globals.css b/ApiCore-Frontend/src/app/globals.css index 236a06b..9f3f49c 100644 --- a/ApiCore-Frontend/src/app/globals.css +++ b/ApiCore-Frontend/src/app/globals.css @@ -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; } -} \ No newline at end of file +} diff --git a/ApiCore-Frontend/src/components/app-motion.spec.tsx b/ApiCore-Frontend/src/components/app-motion.spec.tsx new file mode 100644 index 0000000..6a60688 --- /dev/null +++ b/ApiCore-Frontend/src/components/app-motion.spec.tsx @@ -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( + +

Inhalt

+
, + ); + expect(screen.getByText("Inhalt")).toBeInTheDocument(); + }); + + it("preloads the one motion chunk once the shell has mounted", async () => { + render( + +

Inhalt

+
, + ); + 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( + +

Inhalt

+
, + ); + await waitFor(() => expect(load).toHaveBeenCalled()); + expect(screen.getByText("Inhalt")).toBeInTheDocument(); + }); +}); diff --git a/ApiCore-Frontend/src/components/app-motion.tsx b/ApiCore-Frontend/src/components/app-motion.tsx new file mode 100644 index 0000000..1e160cb --- /dev/null +++ b/ApiCore-Frontend/src/components/app-motion.tsx @@ -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; +} diff --git a/ApiCore-Frontend/src/components/compliance/restriction-override.tsx b/ApiCore-Frontend/src/components/compliance/restriction-override.tsx index ea79844..f81ff8b 100644 --- a/ApiCore-Frontend/src/components/compliance/restriction-override.tsx +++ b/ApiCore-Frontend/src/components/compliance/restriction-override.tsx @@ -50,7 +50,7 @@ export function RestrictionOverride({ onReason }: RestrictionOverrideProps) { Ausnahme mit Begründung {open ? ( diff --git a/ApiCore-Frontend/src/components/form/choice-group.tsx b/ApiCore-Frontend/src/components/form/choice-group.tsx index bb5621f..6650b58 100644 --- a/ApiCore-Frontend/src/components/form/choice-group.tsx +++ b/ApiCore-Frontend/src/components/form/choice-group.tsx @@ -83,9 +83,9 @@ function targetIndex(key: string, current: number, count: number): number | null const OPTION_CLASS: Record = { 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; diff --git a/ApiCore-Frontend/src/components/form/form-section.tsx b/ApiCore-Frontend/src/components/form/form-section.tsx index 3bfac50..b3932cc 100644 --- a/ApiCore-Frontend/src/components/form/form-section.tsx +++ b/ApiCore-Frontend/src/components/form/form-section.tsx @@ -68,7 +68,7 @@ export function FormSection({ diff --git a/ApiCore-Frontend/src/components/form/number-stepper.tsx b/ApiCore-Frontend/src/components/form/number-stepper.tsx index b116ab1..c710e5e 100644 --- a/ApiCore-Frontend/src/components/form/number-stepper.tsx +++ b/ApiCore-Frontend/src/components/form/number-stepper.tsx @@ -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; diff --git a/ApiCore-Frontend/src/components/hive/editor/editor-stage-overlay.spec.tsx b/ApiCore-Frontend/src/components/hive/editor/editor-stage-overlay.spec.tsx index 492be53..cd74f34 100644 --- a/ApiCore-Frontend/src/components/hive/editor/editor-stage-overlay.spec.tsx +++ b/ApiCore-Frontend/src/components/hive/editor/editor-stage-overlay.spec.tsx @@ -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(); + 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"); + }); +}); diff --git a/ApiCore-Frontend/src/components/hive/editor/editor-stage-overlay.tsx b/ApiCore-Frontend/src/components/hive/editor/editor-stage-overlay.tsx index a8f86bb..13a256f 100644 --- a/ApiCore-Frontend/src/components/hive/editor/editor-stage-overlay.tsx +++ b/ApiCore-Frontend/src/components/hive/editor/editor-stage-overlay.tsx @@ -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
{drag.label} - {where ? {where} : null} + {where ? {where} : null}
); } diff --git a/ApiCore-Frontend/src/components/hive/hive-card.tsx b/ApiCore-Frontend/src/components/hive/hive-card.tsx index 4dbf7d8..a5ea056 100644 --- a/ApiCore-Frontend/src/components/hive/hive-card.tsx +++ b/ApiCore-Frontend/src/components/hive/hive-card.tsx @@ -135,7 +135,7 @@ function HiveCardContent({ colonyId, colony, mode = "view", onOpsChange, initial
); } diff --git a/ApiCore-Frontend/src/components/hive/hive-glass.ts b/ApiCore-Frontend/src/components/hive/hive-glass.ts index b24e0c1..7fca0bf 100644 --- a/ApiCore-Frontend/src/components/hive/hive-glass.ts +++ b/ApiCore-Frontend/src/components/hive/hive-glass.ts @@ -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"; diff --git a/ApiCore-Frontend/src/components/hive/hive-scene/hive-labels.tsx b/ApiCore-Frontend/src/components/hive/hive-scene/hive-labels.tsx index cf5c745..1ddf6cb 100644 --- a/ApiCore-Frontend/src/components/hive/hive-scene/hive-labels.tsx +++ b/ApiCore-Frontend/src/components/hive/hive-scene/hive-labels.tsx @@ -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; diff --git a/ApiCore-Frontend/src/components/hive/hive-scene/hive-scene-content.spec.tsx b/ApiCore-Frontend/src/components/hive/hive-scene/hive-scene-content.spec.tsx index e8122f0..d889749 100644 --- a/ApiCore-Frontend/src/components/hive/hive-scene/hive-scene-content.spec.tsx +++ b/ApiCore-Frontend/src/components/hive/hive-scene/hive-scene-content.spec.tsx @@ -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) => ( + + + partDisplayName(state, catalog, id)} + onSelectPart={vi.fn()} + onSelectFrame={vi.fn()} + idle={false} + editor={editor} + /> + + + ); + 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", () => { diff --git a/ApiCore-Frontend/src/components/hive/hive-scene/quality.ts b/ApiCore-Frontend/src/components/hive/hive-scene/quality.ts index c2f7952..888fdc4 100644 --- a/ApiCore-Frontend/src/components/hive/hive-scene/quality.ts +++ b/ApiCore-Frontend/src/components/hive/hive-scene/quality.ts @@ -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; diff --git a/ApiCore-Frontend/src/components/hive/hive-scene/use-hive-motion.ts b/ApiCore-Frontend/src/components/hive/hive-scene/use-hive-motion.ts index 0aaf856..5b68a61 100644 --- a/ApiCore-Frontend/src/components/hive/hive-scene/use-hive-motion.ts +++ b/ApiCore-Frontend/src/components/hive/hive-scene/use-hive-motion.ts @@ -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; diff --git a/ApiCore-Frontend/src/components/hive/hive-visual-slot.tsx b/ApiCore-Frontend/src/components/hive/hive-visual-slot.tsx index 23abbfa..b12f3fb 100644 --- a/ApiCore-Frontend/src/components/hive/hive-visual-slot.tsx +++ b/ApiCore-Frontend/src/components/hive/hive-visual-slot.tsx @@ -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 = { 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 = { 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; diff --git a/ApiCore-Frontend/src/components/labels/label-compliance-panel.tsx b/ApiCore-Frontend/src/components/labels/label-compliance-panel.tsx index da1fe2a..daac4a3 100644 --- a/ApiCore-Frontend/src/components/labels/label-compliance-panel.tsx +++ b/ApiCore-Frontend/src/components/labels/label-compliance-panel.tsx @@ -88,7 +88,7 @@ export function LabelCompliancePanel({
{summary ? (

- {pending ? : null} + {pending ? : null} {summary}

) : null} diff --git a/ApiCore-Frontend/src/components/motion/motion-runtime.tsx b/ApiCore-Frontend/src/components/motion/motion-runtime.tsx new file mode 100644 index 0000000..f1e389e --- /dev/null +++ b/ApiCore-Frontend/src/components/motion/motion-runtime.tsx @@ -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[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 = { + 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("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, enabled: boolean): void { + const tops = useRef(new Map()); + useLayoutEffect(() => { + const list = listRef.current; + if (!list) return; + const next = new Map(); + 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, "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(null); + useSlideUpAfterRemoval(listRef, level === "full"); + return ( + + + +
    + {/* "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. */} + + {children} + +
+
+
+
+ ); +} + +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 ( + )} + ref={ref} + initial={false} + exit={{ opacity: 0, transition: transitionFor(level, "exit") }} + inert={!isPresent} + aria-hidden={isPresent ? undefined : true} + > + {children} + + ); +} diff --git a/ApiCore-Frontend/src/components/motion/presence-list.spec.tsx b/ApiCore-Frontend/src/components/motion/presence-list.spec.tsx new file mode 100644 index 0000000..3eed1da --- /dev/null +++ b/ApiCore-Frontend/src/components/motion/presence-list.spec.tsx @@ -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 ( + + + {items.map((item) => ( + {item} + ))} + + + ); +} + +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(); + 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; + + 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(); + + 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(); + + 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(); + + // 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(); + + 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 ( +
  • + {JSON.stringify({ reducedMotion: config.reducedMotion, skip: config.skipAnimations ?? false })} +
  • + ); + } + const config = () => JSON.parse(screen.getByTestId("config").textContent ?? "{}"); + act(() => setMotionLevel("full")); + await loadMotionRuntime(); + render( + + + , + ); + 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(); + expect(screen.getAllByRole("listitem")).toHaveLength(2); + }); +}); diff --git a/ApiCore-Frontend/src/components/motion/presence-list.tsx b/ApiCore-Frontend/src/components/motion/presence-list.tsx new file mode 100644 index 0000000..da80087 --- /dev/null +++ b/ApiCore-Frontend/src/components/motion/presence-list.tsx @@ -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 | 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 { + 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
  • 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(runtime); + useEffect(() => { + // Without the chunk (offline, first visit) lists simply stay plain. + if (!loaded) loadMotionRuntime().catch(() => undefined); + }, [loaded]); + return loaded; +} + +const RuntimeContext = createContext(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, "ref">) { + const level = useEffectiveMotionLevel(); + const loaded = useMotionRuntime(); + const classes = cn("relative", className); + if (!loaded) { + return ( +
      + {children} +
    + ); + } + return ( + + + {children} + + + ); +} + +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
  • ; + return ; +} diff --git a/ApiCore-Frontend/src/components/offline/offline-band.spec.tsx b/ApiCore-Frontend/src/components/offline/offline-band.spec.tsx index 7639bdf..b98ce51 100644 --- a/ApiCore-Frontend/src/components/offline/offline-band.spec.tsx +++ b/ApiCore-Frontend/src/components/offline/offline-band.spec.tsx @@ -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 () => { diff --git a/ApiCore-Frontend/src/components/offline/pending-queue-body.tsx b/ApiCore-Frontend/src/components/offline/pending-queue-body.tsx new file mode 100644 index 0000000..56231c9 --- /dev/null +++ b/ApiCore-Frontend/src/components/offline/pending-queue-body.tsx @@ -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(); + 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 ? ( +

    + + + Abgelehnt:{" "} + {isVoice ? voiceRejectionText(lastError) : queueRejectionText(lastError, changesHive)} + +

    + ) : ( +

    + + Wartet auf Senden +

    + ); +} + +/** „Ohne V-3 erneut senden“ - or, when a code is unknown, without naming them. */ +function ResendWithoutButton({ + resend, + disabled, + onResend, +}: { + resend: NonNullable; + disabled: boolean; + onResend: () => void; +}) { + const isNamed = resend.codes.length === resend.colonyIds.length; + const without = isNamed ? resend.codes.join(", ") : "die abgelehnten Völker"; + return ( + + ); +} + +/** + * 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(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 ( +
    + {isSuccess && items.length === 0 && photos.length === 0 ? ( +

    Von dir wartet nichts mehr.

    + ) : null} + + {records.map((item, index) => ( + +
    +
    +

    {item.label}

    +

    + Erfasst am {queuedAtLabel(item.queuedAt)} +

    + {item.photoCount ? ( +

    + + {photosAttached(item.photoCount)} +

    + ) : null} +
    + discard.mutate(item)} + isPending={discard.isPending} + /> +
    + + {item.canResendWithoutHiveChange && typeof item.id === "number" ? ( + + ) : null} + {item.canResendWithoutVoice && typeof item.id === "number" ? ( + + ) : null} + {item.resendWithout && typeof item.id === "number" ? ( + + resendBulk.mutate({ + id: item.id as number, + colonyIds: item.resendWithout?.colonyIds ?? [], + }) + } + /> + ) : null} +
    + ))} + {photos.map((photo, index) => ( + +
    + +
    +

    Foto zur Durchsicht

    +

    + Aufgenommen am {queuedAtLabel(photo.createdAt)} +

    +
    +
    + +
    + {photo.lastError ? ( + + ) : null} + discardPhoto.mutate(photo.id)} + isPending={discardPhoto.isPending} + /> +
    +
    + ))} +
    + {voiceNotes.length > 0 ? ( +
    +
    +

    + Sprachnotizen +

    + {voiceNotes.length > 1 ? ( + discardAllVoice.mutate()} + isPending={discardAllVoice.isPending} + /> + ) : null} +
    + {voiceNotes.some((item) => item.voice?.blockedBy === "TOO_MANY_OPEN") ? ( +

    + {VOICE_TOO_MANY_OPEN_HINT} +

    + ) : null} + + {voiceNotes.map((item, index) => ( + +
    + +
    +

    {item.label}

    +

    + Aufgenommen am {queuedAtLabel(item.queuedAt)} + {item.voice ? ` · Dauer ${durationLabel(item.voice.durationSec)}` : ""} +

    +
    +
    + +
    + {item.lastError ? ( + { + // 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); + }} + > + + Erneut aufnehmen + + ) : null} + discardVoice.mutate(String(item.id))} + isPending={discardVoice.isPending} + /> +
    +
    + ))} +
    +
    + ) : null} + {foreignCount > 0 ? ( +

    {foreignEntriesWaiting(foreignCount)}

    + ) : null} +
    + ); +} diff --git a/ApiCore-Frontend/src/components/offline/pending-queue-sheet.tsx b/ApiCore-Frontend/src/components/offline/pending-queue-sheet.tsx index 0215e37..542fea1 100644 --- a/ApiCore-Frontend/src/components/offline/pending-queue-sheet.tsx +++ b/ApiCore-Frontend/src/components/offline/pending-queue-sheet.tsx @@ -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: () => ( +

    + Wird geladen… +

    + ), +}); interface PendingQueueSheetProps { userId: string; @@ -43,128 +25,7 @@ interface PendingQueueSheetProps { finalFocus?: ComponentProps["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(); - 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 ? ( -

    - - - Abgelehnt:{" "} - {isVoice ? voiceRejectionText(lastError) : queueRejectionText(lastError, changesHive)} - -

    - ) : ( -

    - - Wartet auf Senden -

    - ); -} - -/** „Ohne V-3 erneut senden“ - or, when a code is unknown, without naming them. */ -function ResendWithoutButton({ - resend, - disabled, - onResend, -}: { - resend: NonNullable; - disabled: boolean; - onResend: () => void; -}) { - const isNamed = resend.codes.length === resend.colonyIds.length; - const without = isNamed ? resend.codes.join(", ") : "die abgelehnten Völker"; - return ( - - ); -} - -/** - * 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(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 ( -
    - {isSuccess && items.length === 0 && photos.length === 0 ? ( -

    Von dir wartet nichts mehr.

    - ) : null} -
      - {records.map((item, index) => ( -
    • -
      -
      -

      {item.label}

      -

      - Erfasst am {queuedAtLabel(item.queuedAt)} -

      - {item.photoCount ? ( -

      - - {photosAttached(item.photoCount)} -

      - ) : null} -
      - discard.mutate(item)} - isPending={discard.isPending} - /> -
      - - {item.canResendWithoutHiveChange && typeof item.id === "number" ? ( - - ) : null} - {item.canResendWithoutVoice && typeof item.id === "number" ? ( - - ) : null} - {item.resendWithout && typeof item.id === "number" ? ( - - resendBulk.mutate({ - id: item.id as number, - colonyIds: item.resendWithout?.colonyIds ?? [], - }) - } - /> - ) : null} -
    • - ))} - {photos.map((photo, index) => ( -
    • -
      - -
      -

      Foto zur Durchsicht

      -

      - Aufgenommen am {queuedAtLabel(photo.createdAt)} -

      -
      -
      - -
      - {photo.lastError ? ( - - ) : null} - discardPhoto.mutate(photo.id)} - isPending={discardPhoto.isPending} - /> -
      -
    • - ))} -
    - {voiceNotes.length > 0 ? ( -
    -
    -

    - Sprachnotizen -

    - {voiceNotes.length > 1 ? ( - discardAllVoice.mutate()} - isPending={discardAllVoice.isPending} - /> - ) : null} -
    - {voiceNotes.some((item) => item.voice?.blockedBy === "TOO_MANY_OPEN") ? ( -

    - {VOICE_TOO_MANY_OPEN_HINT} -

    - ) : null} -
      - {voiceNotes.map((item, index) => ( -
    • -
      - -
      -

      {item.label}

      -

      - Aufgenommen am {queuedAtLabel(item.queuedAt)} - {item.voice ? ` · Dauer ${durationLabel(item.voice.durationSec)}` : ""} -

      -
      -
      - -
      - {item.lastError ? ( - { - // 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); - }} - > - - Erneut aufnehmen - - ) : null} - discardVoice.mutate(String(item.id))} - isPending={discardVoice.isPending} - /> -
      -
    • - ))} -
    -
    - ) : null} - {foreignCount > 0 ? ( -

    {foreignEntriesWaiting(foreignCount)}

    - ) : null} -
    + {/* Rendered only while the sheet is open (or closing): the body loads with the first opening. */} +
    ); } diff --git a/ApiCore-Frontend/src/components/query-state.spec.tsx b/ApiCore-Frontend/src/components/query-state.spec.tsx index 831df46..fec70d0 100644 --- a/ApiCore-Frontend/src/components/query-state.spec.tsx +++ b/ApiCore-Frontend/src/components/query-state.spec.tsx @@ -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( + + {() =>

    Daten

    } +
    , + ); + 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( diff --git a/ApiCore-Frontend/src/components/query-state.tsx b/ApiCore-Frontend/src/components/query-state.tsx index 72d5cdc..68f6b28 100644 --- a/ApiCore-Frontend/src/components/query-state.tsx +++ b/ApiCore-Frontend/src/components/query-state.tsx @@ -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 ( -

    - {what} werden geladen… +

    + {isShown ? `${what} werden geladen…` : null}

    ); } diff --git a/ApiCore-Frontend/src/components/stat-bar-list.tsx b/ApiCore-Frontend/src/components/stat-bar-list.tsx index 703dae1..b5d427c 100644 --- a/ApiCore-Frontend/src/components/stat-bar-list.tsx +++ b/ApiCore-Frontend/src/components/stat-bar-list.tsx @@ -30,7 +30,7 @@ export function StatBarList({ items, emptyText = "Keine Daten vorhanden.", forma
  • 0 ? Math.max((item.value / max) * 100, 4) : 0}%` }} />
    diff --git a/ApiCore-Frontend/src/components/suggestions/suggestion-group.spec.tsx b/ApiCore-Frontend/src/components/suggestions/suggestion-group.spec.tsx index 8f8b47e..e20c9f0 100644 --- a/ApiCore-Frontend/src/components/suggestions/suggestion-group.spec.tsx +++ b/ApiCore-Frontend/src/components/suggestions/suggestion-group.spec.tsx @@ -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"); }); }); diff --git a/ApiCore-Frontend/src/components/suggestions/suggestion-group.tsx b/ApiCore-Frontend/src/components/suggestions/suggestion-group.tsx index 8638ad9..a081e2d 100644 --- a/ApiCore-Frontend/src/components/suggestions/suggestion-group.tsx +++ b/ApiCore-Frontend/src/components/suggestions/suggestion-group.tsx @@ -101,7 +101,7 @@ function CombinedRow({ > {isOpen ? "Einzeln ausblenden" : "Einzeln anzeigen"} diff --git a/ApiCore-Frontend/src/components/today/today-panel-plain.spec.tsx b/ApiCore-Frontend/src/components/today/today-panel-plain.spec.tsx new file mode 100644 index 0000000..373edb6 --- /dev/null +++ b/ApiCore-Frontend/src/components/today/today-panel-plain.spec.tsx @@ -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( + + undefined} now={NOW} /> + , + ); +} + +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(); + }); +}); diff --git a/ApiCore-Frontend/src/components/today/today-panel.spec.tsx b/ApiCore-Frontend/src/components/today/today-panel.spec.tsx new file mode 100644 index 0000000..e7a9e09 --- /dev/null +++ b/ApiCore-Frontend/src/components/today/today-panel.spec.tsx @@ -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[]) => ( + + + undefined} now={NOW} /> + + + ); + 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( + + undefined} now={NOW} /> + , + ); + 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/); + } + }); +}); diff --git a/ApiCore-Frontend/src/components/today/today-panel.tsx b/ApiCore-Frontend/src/components/today/today-panel.tsx index 2cb0a46..e025ebe 100644 --- a/ApiCore-Frontend/src/components/today/today-panel.tsx +++ b/ApiCore-Frontend/src/components/today/today-panel.tsx @@ -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(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 ( -
    -
    -
    -
    -
    - ); + return ; } if (!tasks || (isError && !autopilot)) { @@ -153,7 +165,7 @@ export function TodayPanel({
    -

    +

    Außerdem heute

    ) : ( -
      + {visible.map((task) => ( 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)} /> ))} -
    + )} {hidden > 0 ? ( 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; } -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 ( -
  • -
    -
    - { - if (next) onComplete(); - }} - /> -
    - {task.title} - - {colonyCode ? ( - - {colonyCode} - - ) : null} - {task.status === "IN_PROGRESS" ? : null} - - - - -
    - {href ? ( - - - - ) : null} + 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); + }} + > +
    + { + if (next) onComplete(); + }} + /> +
    + {task.title} + + {colonyCode ? ( + + {colonyCode} + + ) : null} + {task.status === "IN_PROGRESS" ? : null} + + + +
    + {href ? ( + + + + ) : null}
    -
  • + + ); +} + +/** + * 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("[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('[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 ( +
    + {isShown ? ( + <> +
    +
    +
    + + ) : null} +
    ); } diff --git a/ApiCore-Frontend/src/components/today/use-task-actions.ts b/ApiCore-Frontend/src/components/today/use-task-actions.ts index a201ccb..7825614 100644 --- a/ApiCore-Frontend/src/components/today/use-task-actions.ts +++ b/ApiCore-Frontend/src/components/today/use-task-actions.ts @@ -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 { return new Promise((resolve) => setTimeout(resolve, ms)); } +function toggled(ids: ReadonlySet, id: string, on: boolean): ReadonlySet { + 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>(new Set()); + const [goneIds, setGoneIds] = useState>(new Set()); + const goneTimers = useRef(new Map>()); + + 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, diff --git a/ApiCore-Frontend/src/components/ui/badge.tsx b/ApiCore-Frontend/src/components/ui/badge.tsx index cf9b616..3402f42 100644 --- a/ApiCore-Frontend/src/components/ui/badge.tsx +++ b/ApiCore-Frontend/src/components/ui/badge.tsx @@ -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: { diff --git a/ApiCore-Frontend/src/components/ui/button.tsx b/ApiCore-Frontend/src/components/ui/button.tsx index 6c503eb..27ac34c 100644 --- a/ApiCore-Frontend/src/components/ui/button.tsx +++ b/ApiCore-Frontend/src/components/ui/button.tsx @@ -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: { diff --git a/ApiCore-Frontend/src/components/ui/card.tsx b/ApiCore-Frontend/src/components/ui/card.tsx index 3270e91..fd14ccf 100644 --- a/ApiCore-Frontend/src/components/ui/card.tsx +++ b/ApiCore-Frontend/src/components/ui/card.tsx @@ -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} diff --git a/ApiCore-Frontend/src/components/ui/dialog.tsx b/ApiCore-Frontend/src/components/ui/dialog.tsx index 6cf7eac..6a79da8 100644 --- a/ApiCore-Frontend/src/components/ui/dialog.tsx +++ b/ApiCore-Frontend/src/components/ui/dialog.tsx @@ -31,7 +31,7 @@ function DialogOverlay({ @@ -135,7 +135,7 @@ function DropdownMenuSubContent({ return ( ) { 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} diff --git a/ApiCore-Frontend/src/components/ui/select.spec.tsx b/ApiCore-Frontend/src/components/ui/select.spec.tsx new file mode 100644 index 0000000..ab1ca14 --- /dev/null +++ b/ApiCore-Frontend/src/components/ui/select.spec.tsx @@ -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 ( + + ); +} + +/** 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(); + 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(); + await userEvent.click(screen.getByRole("combobox", { name: "Status" })); + await userEvent.click(await screen.findByRole("option", { name: "Aktiv" })); + await waitFor(() => expect(reachableListboxes()).toEqual([])); + }); +}); diff --git a/ApiCore-Frontend/src/components/ui/select.tsx b/ApiCore-Frontend/src/components/ui/select.tsx index d6c959c..0d66808 100644 --- a/ApiCore-Frontend/src/components/ui/select.tsx +++ b/ApiCore-Frontend/src/components/ui/select.tsx @@ -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({ diff --git a/ApiCore-Frontend/src/components/ui/sheet.tsx b/ApiCore-Frontend/src/components/ui/sheet.tsx index d5d1dfe..8dd19b2 100644 --- a/ApiCore-Frontend/src/components/ui/sheet.tsx +++ b/ApiCore-Frontend/src/components/ui/sheet.tsx @@ -28,7 +28,7 @@ function SheetOverlay({ className, ...props }: SheetPrimitive.Backdrop.Props) { { ), loading: ( - + ), }} style={ diff --git a/ApiCore-Frontend/src/components/ui/textarea.tsx b/ApiCore-Frontend/src/components/ui/textarea.tsx index 7be5975..2d57c88 100644 --- a/ApiCore-Frontend/src/components/ui/textarea.tsx +++ b/ApiCore-Frontend/src/components/ui/textarea.tsx @@ -7,7 +7,7 @@ function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {