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

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

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

No files matched your search

@@ -66,7 +66,7 @@ export default function HiveEditorPage() {
);
}
if (!colony.data || !hive.data || !catalog) {
return <div role="status" aria-label="Beute wird geladen" className="holo-stage fixed inset-0 z-40 bg-holo-bg motion-safe:animate-pulse" />;
return <div role="status" aria-label="Beute wird geladen" className="holo-stage fixed inset-0 z-40 bg-holo-bg motion-full:animate-pulse" />;
}
return (
<HiveEditor
+93 -90
View File
@@ -8,6 +8,7 @@ import { toast } from "sonner";
import { useAuth } from "@/lib/auth/auth-context";
import { useAppearance } from "@/lib/appearance/appearance";
import { finishHandOvers, purgeStaleDraftPhotos } from "@/lib/photos/store";
import { AppMotion } from "@/components/app-motion";
import { LogoutConfirmDialog } from "@/components/auth/logout-confirm-dialog";
import { BrandMark } from "@/components/brand-mark";
import { NotificationBell } from "@/components/notification-bell";
@@ -84,99 +85,101 @@ export default function AppLayout({ children }: { children: ReactNode }) {
// scroll container (overflow auto/scroll/hidden): it would capture
// `position: sticky` and the StickySubmit save bar would never stick.
return (
<div className="flex min-h-screen pr-(--safe-area-right) pl-(--safe-area-left)">
<a
href={`#${MAIN_CONTENT_ID}`}
inert={isShellInert}
onClick={(event) => {
// The hash alone would not move the focus into a Next.js page reliably.
event.preventDefault();
pageContentRef.current?.focus();
pageContentRef.current?.scrollIntoView?.({ block: "start" });
}}
className="sr-only focus:not-sr-only focus:fixed focus:top-2 focus:left-2 focus:z-50 focus:rounded-lg focus:bg-card focus:px-4 focus:py-3 focus:text-sm focus:font-medium focus:shadow-[var(--shadow-elevated)]"
>
Zum Inhalt springen
</a>
<div className="contents" inert={isShellInert}>
<Sidebar user={user} />
</div>
<div className="flex min-w-0 flex-1 flex-col">
<header
<AppMotion>
<div className="flex min-h-screen pr-(--safe-area-right) pl-(--safe-area-left)">
<a
href={`#${MAIN_CONTENT_ID}`}
inert={isShellInert}
className="sticky top-0 z-10 flex items-center justify-between gap-3 bg-card/85 px-4 py-3 shadow-[var(--shadow-soft)] backdrop-blur-md sm:justify-end sm:px-6">
<Link
href="/dashboard"
className="flex items-center font-heading font-semibold sm:hidden"
>
<BrandMark withWordmark />
</Link>
<div className="flex shrink-0 items-center gap-2 text-sm">
<QuickAddSheet user={user} variant="header" />
<NotificationBell />
<DropdownMenu>
<DropdownMenuTrigger className="rounded-full" aria-label="Konto-Menü">
<UserAvatar name={user.name} hasAvatar={user.hasAvatar} size="sm" />
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuGroup>
<DropdownMenuLabel className="max-w-48 truncate font-normal text-muted-foreground">
{user.email}
</DropdownMenuLabel>
</DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuCheckboxItem
checked={sunActive}
onCheckedChange={(checked) => {
setContrast(checked ? "sun" : "normal");
toast.success(checked ? "Sonnenmodus an" : "Sonnenmodus aus");
}}
className="min-h-11"
>
<Sun aria-hidden="true" />
Sonnenmodus
</DropdownMenuCheckboxItem>
<DropdownMenuItem render={<Link href="/profile" />}>
Profil
</DropdownMenuItem>
<DropdownMenuItem render={<Link href="/settings/voice" />}>
Sprache &amp; Datenschutz
</DropdownMenuItem>
<DropdownMenuItem
variant="destructive"
onClick={() => setIsLogoutOpen(true)}
>
Abmelden
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</header>
<OfflineBand
userId={user.id}
pageContentRef={pageContentRef}
syncEnabled={sessionMode === "online"}
inert={isShellInert}
/>
<div
id={MAIN_CONTENT_ID}
ref={pageContentRef}
tabIndex={-1}
className={`flex-1 ${contentPaddingClass} sm:pb-(--safe-area-bottom)`}
onClick={(event) => {
// The hash alone would not move the focus into a Next.js page reliably.
event.preventDefault();
pageContentRef.current?.focus();
pageContentRef.current?.scrollIntoView?.({ block: "start" });
}}
className="sr-only focus:not-sr-only focus:fixed focus:top-2 focus:left-2 focus:z-50 focus:rounded-lg focus:bg-card focus:px-4 focus:py-3 focus:text-sm focus:font-medium focus:shadow-[var(--shadow-elevated)]"
>
<PageTransition>{children}</PageTransition>
Zum Inhalt springen
</a>
<div className="contents" inert={isShellInert}>
<Sidebar user={user} />
</div>
<div className="flex min-w-0 flex-1 flex-col">
<header
inert={isShellInert}
className="sticky top-0 z-10 flex items-center justify-between gap-3 bg-card/85 px-4 py-3 shadow-[var(--shadow-soft)] backdrop-blur-md sm:justify-end sm:px-6">
<Link
href="/dashboard"
className="flex items-center font-heading font-semibold sm:hidden"
>
<BrandMark withWordmark />
</Link>
<div className="flex shrink-0 items-center gap-2 text-sm">
<QuickAddSheet user={user} variant="header" />
<NotificationBell />
<DropdownMenu>
<DropdownMenuTrigger className="rounded-full" aria-label="Konto-Menü">
<UserAvatar name={user.name} hasAvatar={user.hasAvatar} size="sm" />
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuGroup>
<DropdownMenuLabel className="max-w-48 truncate font-normal text-muted-foreground">
{user.email}
</DropdownMenuLabel>
</DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuCheckboxItem
checked={sunActive}
onCheckedChange={(checked) => {
setContrast(checked ? "sun" : "normal");
toast.success(checked ? "Sonnenmodus an" : "Sonnenmodus aus");
}}
className="min-h-11"
>
<Sun aria-hidden="true" />
Sonnenmodus
</DropdownMenuCheckboxItem>
<DropdownMenuItem render={<Link href="/profile" />}>
Profil
</DropdownMenuItem>
<DropdownMenuItem render={<Link href="/settings/voice" />}>
Sprache &amp; Datenschutz
</DropdownMenuItem>
<DropdownMenuItem
variant="destructive"
onClick={() => setIsLogoutOpen(true)}
>
Abmelden
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</header>
<OfflineBand
userId={user.id}
pageContentRef={pageContentRef}
syncEnabled={sessionMode === "online"}
inert={isShellInert}
/>
<div
id={MAIN_CONTENT_ID}
ref={pageContentRef}
tabIndex={-1}
className={`flex-1 ${contentPaddingClass} sm:pb-(--safe-area-bottom)`}
>
<PageTransition>{children}</PageTransition>
</div>
</div>
<div className="contents" inert={isShellInert}>
<BottomNav user={user} />
<QuickAddSheet user={user} variant="fab" />
</div>
<LogoutConfirmDialog
open={isLogoutOpen}
onOpenChange={setIsLogoutOpen}
userId={user.id}
onLoggedOut={() => router.push("/login")}
/>
</div>
<div className="contents" inert={isShellInert}>
<BottomNav user={user} />
<QuickAddSheet user={user} variant="fab" />
</div>
<LogoutConfirmDialog
open={isLogoutOpen}
onOpenChange={setIsLogoutOpen}
userId={user.id}
onLoggedOut={() => router.push("/login")}
/>
</div>
</AppMotion>
);
}
@@ -3,10 +3,15 @@
import { ReactNode } from "react";
import { usePathname } from "next/navigation";
/**
* A new page comes in (spec 17.4): at "Voll" it fades in and rises 6 px (--motion-enter,
* --ease-out-expo), at "Dezent" or with the system's reduce motion it only fades (globals.css),
* at "Aus" it stands at once. There is no exit: the old page goes at once, navigation never waits.
*/
export function PageTransition({ children }: { children: ReactNode }) {
const pathname = usePathname();
return (
<div key={pathname} className="motion-safe:animate-page-in">
<div key={pathname} className="animate-page-in">
{children}
</div>
);
@@ -41,7 +41,7 @@ export function RuleCardShell({ rule, open, onToggle, children }: RuleCardShellP
<span className="line-clamp-2 text-xs text-muted-foreground">{ruleSummary(rule)}</span>
</span>
<ChevronDown
className={cn("mt-1 size-4 shrink-0 motion-safe:transition-transform", open && "rotate-180")}
className={cn("mt-1 size-4 shrink-0 motion-full:transition-transform motion-full:duration-(--motion-settle)", open && "rotate-180")}
aria-hidden
/>
</button>
+2 -7
View File
@@ -17,18 +17,13 @@ function SidebarLink({ item, pathname }: { item: NavItem; pathname: string }) {
href={item.href}
aria-current={isActive ? "page" : undefined}
className={cn(
"group relative flex items-center gap-2.5 rounded-xl px-3 py-2 text-sm font-medium transition-all duration-150",
"group relative flex items-center gap-2.5 rounded-xl px-3 py-2 text-sm font-medium transition-colors",
isActive
? "bg-primary text-primary-foreground shadow-[var(--shadow-soft)]"
: "text-muted-foreground hover:bg-accent/70 hover:text-foreground",
)}
>
<Icon
className={cn(
"size-4.5 shrink-0 transition-transform duration-150",
!isActive && "motion-full:group-hover:scale-110",
)}
/>
<Icon className="size-4.5 shrink-0" />
<span className="truncate">{item.label}</span>
{item.counter ? <NavCounter counter={item.counter} active={isActive} /> : null}
</Link>
@@ -505,7 +505,7 @@ describe("SuggestionsPage", () => {
const toggle = await screen.findByRole("button", { name: "Verworfene (30 Tage)" });
const chevron = toggle.querySelector("svg");
expect(chevron).toHaveClass("motion-safe:transition-transform");
expect(chevron).toHaveClass("motion-full:transition-transform");
expect(chevron).not.toHaveClass("transition-transform");
});
@@ -347,7 +347,7 @@ export default function SuggestionsPage() {
>
<ChevronDown
aria-hidden
className={cn("motion-safe:transition-transform", showDismissed && "rotate-180")}
className={cn("motion-full:transition-transform motion-full:duration-(--motion-settle)", showDismissed && "rotate-180")}
/>
Verworfene (30 Tage)
</Button>
@@ -26,6 +26,7 @@ import {
} from "@/lib/voice/use-voice-recording-switch";
import { LocalVoiceCard, VoiceCaptureCard } from "@/components/voice/voice-capture-card";
import { EmptyState } from "@/components/empty-state";
import { PresenceItem, PresenceList } from "@/components/motion/presence-list";
import { PageHeader } from "@/components/page-header";
import { ErrorState, LoadingState } from "@/components/query-state";
import { Button } from "@/components/ui/button";
@@ -175,19 +176,19 @@ function VoiceNotes() {
/>
) : null}
{isEmpty ? null : (
<ul className="flex flex-col gap-3" aria-label="Sprachnotizen">
<PresenceList className="flex flex-col gap-3" aria-label="Sprachnotizen">
{local.map((note) => (
<li key={note.id}>
<PresenceItem key={note.id}>
<LocalVoiceCard
note={note}
colonyName={colonyName(note.colonyId)}
online={online}
onDiscard={() => discardLocal.mutate(note.id)}
/>
</li>
</PresenceItem>
))}
{listed.map((item) => (
<li key={item.id}>
<PresenceItem key={item.id}>
<VoiceCaptureCard
item={item}
extractionEnabled={extractionEnabled}
@@ -195,9 +196,9 @@ function VoiceNotes() {
onRetry={() => retry.mutate(item.id)}
onDiscard={() => discard.mutate(item.id)}
/>
</li>
</PresenceItem>
))}
</ul>
</PresenceList>
)}
</div>
);
+61 -47
View File
@@ -93,7 +93,7 @@
--radius-2xl: calc(var(--radius) * 1.8);
--radius-3xl: calc(var(--radius) * 2.2);
--radius-4xl: calc(var(--radius) * 2.6);
--animate-page-in: page-in 260ms ease-out;
--animate-page-in: page-in var(--motion-enter) var(--ease-out-expo);
}
@keyframes page-in {
@@ -564,8 +564,19 @@ html {
}
}
/* Motion tokens (spec 17.3). Mirrored in lib/motion/tokens.ts; tokens.spec.ts keeps both equal.
No DOM transition is longer than --motion-moment; exits are never longer than --motion-fade. */
:root {
--motion-fade: 150ms;
--motion-enter: 200ms;
--motion-settle: 350ms;
--motion-moment: 420ms;
--motion-shift: 8px;
--motion-spring: 500ms;
--ease-out-expo: cubic-bezier(0.16, 1, 0.3, 1);
--ease-in: cubic-bezier(0.4, 0, 1, 1);
/* The hive's flight spring (stiffness 210, damping 21) over --motion-spring: springEasing(). */
--ease-spring: linear(0, 0.039, 0.113, 0.209, 0.314, 0.421, 0.524, 0.618, 0.702, 0.775, 0.837, 0.887, 0.928, 0.959, 0.983, 1, 1.012, 1.02, 1.024, 1.026, 1.026, 1.024, 1.022, 1.02, 1.017, 1.014, 1.012, 1.009, 1.007, 1.005, 1.004, 1);
}
/* Off: every state change is instant. Colour and icon still carry the state. */
@@ -578,17 +589,47 @@ html {
transition-delay: 0ms !important;
}
/* Subtle / system reduce: keep fades, drop movement. */
:root[data-motion="subtle"] .motion-safe\:animate-page-in {
/* Subtle / system reduce: keep fades, drop movement (spec 17.2). */
:root[data-motion="subtle"] .animate-page-in {
animation-name: fade-in;
animation-duration: var(--motion-fade);
}
@media (prefers-reduced-motion: reduce) {
.motion-safe\:animate-page-in {
animation: fade-in 160ms ease-out;
.animate-page-in {
animation-name: fade-in;
animation-duration: var(--motion-fade);
}
}
:root[data-motion="subtle"] [data-slot="card"] {
translate: none !important;
/* Overlays at "Dezent" and with the system's reduce motion: they only fade, and in --motion-fade
both ways (spec 17.2); the move behind motion-full: is off anyway. */
:root[data-motion="subtle"] [data-slot="sheet-content"],
:root[data-motion="subtle"] [data-slot="sheet-overlay"],
:root[data-motion="subtle"] [data-slot="dialog-content"],
:root[data-motion="subtle"] [data-slot="dialog-overlay"],
:root[data-motion="subtle"] [data-slot="dropdown-menu-content"],
:root[data-motion="subtle"] [data-slot="dropdown-menu-sub-content"],
:root[data-motion="subtle"] [data-slot="select-content"] {
transition-duration: var(--motion-fade);
}
@media (prefers-reduced-motion: reduce) {
[data-slot="sheet-content"],
[data-slot="sheet-overlay"],
[data-slot="dialog-content"],
[data-slot="dialog-overlay"],
[data-slot="dropdown-menu-content"],
[data-slot="dropdown-menu-sub-content"],
[data-slot="select-content"] {
transition-duration: var(--motion-fade);
}
}
/* Toasts (spec 17.4): Sonner's own slide is full motion. At "Dezent" a toast stands at its place
at once and only fades; Sonner itself drops every transition under system reduce. */
:root[data-motion="subtle"] [data-sonner-toast],
:root[data-motion="subtle"] [data-sonner-toast] > * {
transition-property: opacity !important;
transition-duration: var(--motion-fade) !important;
}
@keyframes fade-in {
@@ -601,7 +642,8 @@ html {
}
/* --- Task completion: the one authored moment ------------------------------
The honey cell fills, the tick draws itself, then the row folds away. */
The honey cell fills (--motion-moment), the tick draws itself, then the row leaves the list
(TodayPanel: AnimatePresence, the others move up with the flight spring). */
.honey-check {
position: relative;
display: inline-grid;
@@ -620,8 +662,8 @@ html {
border: 2px solid var(--input);
background: var(--card);
transition:
border-color 150ms ease-out,
background-color 150ms ease-out;
border-color var(--motion-fade) ease-out,
background-color var(--motion-fade) ease-out;
}
.honey-check::after {
content: "";
@@ -630,7 +672,7 @@ html {
border-radius: 0.6rem;
background: var(--primary);
clip-path: circle(0% at 50% 60%);
transition: clip-path 420ms var(--ease-out-expo);
transition: clip-path var(--motion-moment) var(--ease-out-expo);
}
.honey-check:hover::before {
border-color: var(--primary);
@@ -643,7 +685,8 @@ html {
height: 1.1rem;
stroke-dasharray: 24;
stroke-dashoffset: 24;
transition: stroke-dashoffset 260ms var(--ease-out-expo) 160ms;
/* Starts as the cell is half full and ends with it: inside the moment. */
transition: stroke-dashoffset calc(var(--motion-moment) - 160ms) var(--ease-out-expo) 160ms;
}
.honey-check[aria-checked="true"]::after {
clip-path: circle(75% at 50% 60%);
@@ -652,53 +695,25 @@ html {
stroke-dashoffset: 0;
}
.today-row {
display: grid;
grid-template-rows: 1fr;
opacity: 1;
transition:
grid-template-rows 320ms var(--ease-out-expo) 380ms,
opacity 220ms ease-out 360ms;
}
.today-row > * {
min-height: 0;
overflow: hidden;
}
.today-row[data-leaving="true"] {
grid-template-rows: 0fr;
opacity: 0;
}
/* The next step changes: bring the new one in with a short focus pull. */
/* The next step changes: the new one comes in by --motion-shift (no blur: filter never moves). */
@keyframes next-step-in {
from {
opacity: 0;
filter: blur(6px);
transform: translateY(8px);
transform: translateY(var(--motion-shift));
}
to {
opacity: 1;
filter: blur(0);
transform: none;
}
}
@keyframes next-step-fade {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
.next-step-enter {
animation: next-step-in 480ms var(--ease-out-expo);
animation: next-step-in var(--motion-enter) var(--ease-out-expo);
}
:root[data-motion="subtle"] .next-step-enter {
animation: next-step-fade 200ms ease-out;
animation: fade-in var(--motion-fade) ease-out;
}
/* The honey-check moment (cell fills, tick draws itself) belongs to full motion; subtle keeps
the tick but drops the slow fill and the stroke delay. */
:root[data-motion="subtle"] .today-row,
:root[data-motion="subtle"] .honey-check::after,
:root[data-motion="subtle"] .honey-check svg {
transition-duration: 120ms;
@@ -706,12 +721,11 @@ html {
}
@media (prefers-reduced-motion: reduce) {
.next-step-enter {
animation: next-step-fade 200ms ease-out;
animation: fade-in var(--motion-fade) ease-out;
}
.today-row,
.honey-check::after,
.honey-check svg {
transition-duration: 120ms;
transition-delay: 0ms;
}
}
}