feat: "Heute" start page with one next step, clear task states and motion levels
Start page (AuDHD-oriented, per the agreed "Heute zuerst" layout): - One highlighted next step: started work first (easy re-entry after an interruption), then overdue, due today, then urgent undated tasks. Primary action "Erledigt", secondary "Beginnen", link to the related colony/apiary/queen. - "Außerdem heute": at most five further tasks with a large honey-cell checkbox; undated automatic suggestions are included so a queenless colony does not sit unseen in the full list. - Completing is the one authored motion moment: the cell fills, the tick draws, the row folds away and the next step arrives with a short focus pull. Every completion can be undone from the toast (10 s), restoring the previous status. - Status is shown as icon plus word (never colour alone); tasks created by automation carry a "Vorschlag" badge, recurring and inspection tasks their own - a suggestion never looks like work already done. - Friendly empty, loading and error states; key figures moved into a compact overview below; overdue tasks are no longer listed twice. Motion: per-device setting Voll / Dezent / Aus in the profile (default Voll); the OS reduce-motion setting always lowers Voll to Dezent. New 44 px "touch" button size for gloves at the apiary; themed text selection and caret. Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
1 parent
5c2ea7bf1e
commit
f9374a135a
14 files changed
+1104
-112
No files matched your search
@@ -1,5 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import {
|
||||
@@ -17,8 +18,10 @@ import {
|
||||
import { useAuth } from "@/lib/auth/auth-context";
|
||||
import { fetchDashboardAlerts, fetchDashboardSummary } from "@/lib/dashboard/api";
|
||||
import { DashboardAlert, DashboardAlertType } from "@/lib/dashboard/types";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { fetchTasks } from "@/lib/tasks/api";
|
||||
import { tasksForToday } from "@/lib/tasks/today";
|
||||
import { HoneycombPattern } from "@/components/honeycomb-pattern";
|
||||
import { TodayPanel } from "@/components/today/today-panel";
|
||||
|
||||
function alertHref(alert: DashboardAlert): string | null {
|
||||
if (alert.relatedType === "Colony") return `/colonies/${alert.relatedId}`;
|
||||
@@ -27,19 +30,25 @@ function alertHref(alert: DashboardAlert): string | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
function greeting(): string {
|
||||
const hour = new Date().getHours();
|
||||
function greeting(now: Date): string {
|
||||
const hour = now.getHours();
|
||||
if (hour < 11) return "Guten Morgen";
|
||||
if (hour < 18) return "Guten Tag";
|
||||
return "Guten Abend";
|
||||
}
|
||||
|
||||
function todaySummary(count: number): string {
|
||||
if (count === 0) return "Für heute steht nichts Festes an.";
|
||||
if (count === 1) return "Heute steht eine Sache an.";
|
||||
return `Heute stehen ${count} Sachen an – eine nach der anderen.`;
|
||||
}
|
||||
|
||||
interface StatTile {
|
||||
label: string;
|
||||
value: number;
|
||||
icon: LucideIcon;
|
||||
gradient: string;
|
||||
href: string;
|
||||
attention?: boolean;
|
||||
}
|
||||
|
||||
const ALERT_META: Record<
|
||||
@@ -75,7 +84,8 @@ const ALERT_META: Record<
|
||||
|
||||
export default function DashboardPage() {
|
||||
const { user } = useAuth();
|
||||
const { data: summary, isLoading: summaryLoading } = useQuery({
|
||||
const [now] = useState(() => new Date());
|
||||
const { data: summary } = useQuery({
|
||||
queryKey: ["dashboard", "summary"],
|
||||
queryFn: fetchDashboardSummary,
|
||||
});
|
||||
@@ -83,133 +93,133 @@ export default function DashboardPage() {
|
||||
queryKey: ["dashboard", "alerts"],
|
||||
queryFn: fetchDashboardAlerts,
|
||||
});
|
||||
const tasksQuery = useQuery({
|
||||
queryKey: ["tasks", "dashboard"],
|
||||
queryFn: () => fetchTasks({}),
|
||||
});
|
||||
|
||||
// Overdue tasks already appear in "Heute"; listing them again as alerts
|
||||
// would announce the same thing twice.
|
||||
const otherAlerts = (alerts ?? []).filter((alert) => alert.type !== "OVERDUE_TASK");
|
||||
const todayCount = tasksQuery.data ? tasksForToday(tasksQuery.data, now).length : null;
|
||||
const firstName = user?.name.trim().split(/\s+/)[0];
|
||||
|
||||
const tiles: StatTile[] = summary
|
||||
? [
|
||||
{
|
||||
label: "Aktive Bienenstände",
|
||||
value: summary.apiariesActive,
|
||||
icon: MapPin,
|
||||
gradient: "var(--gradient-honey)",
|
||||
href: "/apiaries",
|
||||
},
|
||||
{
|
||||
label: "Aktive Völker",
|
||||
value: summary.coloniesActive,
|
||||
icon: Layers,
|
||||
gradient: "var(--gradient-honey)",
|
||||
href: "/colonies",
|
||||
},
|
||||
{
|
||||
label: "Aktive Königinnen",
|
||||
value: summary.queensActive,
|
||||
icon: Crown,
|
||||
gradient: "var(--gradient-sage)",
|
||||
href: "/queens",
|
||||
},
|
||||
{
|
||||
label: "Offene Aufgaben",
|
||||
value: summary.openTasks,
|
||||
icon: ListTodo,
|
||||
gradient: "var(--gradient-sky)",
|
||||
href: "/tasks",
|
||||
},
|
||||
{ label: "Bienenstände", value: summary.apiariesActive, icon: MapPin, href: "/apiaries" },
|
||||
{ label: "Völker", value: summary.coloniesActive, icon: Layers, href: "/colonies" },
|
||||
{ label: "Königinnen", value: summary.queensActive, icon: Crown, href: "/queens" },
|
||||
{ label: "Offene Aufgaben", value: summary.openTasks, icon: ListTodo, href: "/tasks" },
|
||||
{
|
||||
label: "Honigchargen",
|
||||
value: summary.honeyBatchesTotal,
|
||||
icon: Droplet,
|
||||
gradient: "var(--gradient-honey)",
|
||||
href: "/honey-batches",
|
||||
},
|
||||
{
|
||||
label: "Artikel unter Mindestbestand",
|
||||
label: "unter Minimum",
|
||||
value: summary.stockItemsLowCount,
|
||||
icon: AlertTriangle,
|
||||
gradient:
|
||||
summary.stockItemsLowCount > 0
|
||||
? "linear-gradient(135deg, oklch(0.72 0.15 70) 0%, oklch(0.56 0.15 50) 100%)"
|
||||
: "var(--gradient-sage)",
|
||||
href: "/storage",
|
||||
attention: summary.stockItemsLowCount > 0,
|
||||
},
|
||||
]
|
||||
: [];
|
||||
|
||||
return (
|
||||
<main className="flex flex-col gap-6 p-4 sm:p-6">
|
||||
<div
|
||||
className="relative overflow-hidden rounded-2xl px-5 py-6 text-white shadow-[var(--shadow-elevated)] sm:px-8 sm:py-8"
|
||||
<main className="mx-auto flex w-full max-w-3xl flex-col gap-8 p-4 sm:p-6">
|
||||
<header
|
||||
className="relative overflow-hidden rounded-2xl px-5 py-5 text-white shadow-[var(--shadow-elevated)] sm:px-7 sm:py-6"
|
||||
style={{ background: "var(--gradient-honey)" }}
|
||||
>
|
||||
<HoneycombPattern className="pointer-events-none absolute inset-0 h-full w-full text-white/[0.08]" />
|
||||
<div className="relative flex flex-col gap-1">
|
||||
<h1 className="font-heading text-2xl font-semibold tracking-tight sm:text-3xl">
|
||||
{greeting()}
|
||||
{user ? `, ${user.email.split("@")[0]}` : ""}
|
||||
{greeting(now)}
|
||||
{firstName ? `, ${firstName}` : ""}
|
||||
</h1>
|
||||
<p className="text-sm text-white/85 sm:text-base">Überblick über deine Imkerei</p>
|
||||
<p className="text-sm text-white/90 sm:text-base">
|
||||
{now.toLocaleDateString("de-DE", { weekday: "long", day: "numeric", month: "long" })}
|
||||
{todayCount !== null ? ` · ${todaySummary(todayCount)}` : ""}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{summaryLoading ? <p className="text-sm text-muted-foreground">Lädt…</p> : null}
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3">
|
||||
{tiles.map((tile) => {
|
||||
const Icon = tile.icon;
|
||||
return (
|
||||
<Link key={tile.label} href={tile.href}>
|
||||
<Card interactive>
|
||||
<CardContent className="flex flex-col gap-3">
|
||||
<div
|
||||
className="flex size-10 items-center justify-center rounded-xl text-white shadow-[var(--shadow-soft)]"
|
||||
style={{ background: tile.gradient }}
|
||||
>
|
||||
<Icon className="size-5" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-2xl font-semibold tabular-nums">{tile.value}</p>
|
||||
<p className="text-xs text-muted-foreground">{tile.label}</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<TodayPanel
|
||||
tasks={tasksQuery.data}
|
||||
isLoading={tasksQuery.isLoading}
|
||||
isError={tasksQuery.isError}
|
||||
onRetry={() => void tasksQuery.refetch()}
|
||||
now={now}
|
||||
/>
|
||||
|
||||
<div>
|
||||
<h2 className="mb-3 font-heading text-lg font-semibold tracking-tight">Hinweise</h2>
|
||||
<div className="flex flex-col gap-2">
|
||||
{alertsLoading || otherAlerts.length > 0 ? (
|
||||
<section aria-labelledby="alerts-heading" className="flex flex-col gap-2">
|
||||
<h2 id="alerts-heading" className="font-heading text-lg font-semibold tracking-tight">
|
||||
Im Blick behalten
|
||||
</h2>
|
||||
{alertsLoading ? <p className="text-sm text-muted-foreground">Lädt…</p> : null}
|
||||
{!alertsLoading && (!alerts || alerts.length === 0) ? (
|
||||
<Card>
|
||||
<CardContent className="text-sm text-muted-foreground">
|
||||
Keine offenen Hinweise.
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : null}
|
||||
{alerts?.map((alert) => {
|
||||
const href = alertHref(alert);
|
||||
const meta = ALERT_META[alert.type];
|
||||
const AlertIcon = meta?.icon ?? AlertTriangle;
|
||||
return (
|
||||
<div
|
||||
key={`${alert.type}-${alert.relatedId}`}
|
||||
className={`flex items-center gap-3 rounded-xl border p-3 text-sm shadow-[var(--shadow-soft)] ${meta?.className ?? "border-border bg-card"}`}
|
||||
>
|
||||
<AlertIcon className="size-4.5 shrink-0 text-muted-foreground" />
|
||||
<span className="flex-1">{alert.message}</span>
|
||||
{href ? (
|
||||
<ul className="flex flex-col gap-2">
|
||||
{otherAlerts.map((alert) => {
|
||||
const href = alertHref(alert);
|
||||
const meta = ALERT_META[alert.type];
|
||||
const AlertIcon = meta?.icon ?? AlertTriangle;
|
||||
return (
|
||||
<li
|
||||
key={`${alert.type}-${alert.relatedId}`}
|
||||
className={`flex items-center gap-3 rounded-xl border p-3 text-sm ${meta?.className ?? "border-border bg-card"}`}
|
||||
>
|
||||
<AlertIcon className="size-5 shrink-0 text-foreground/70" aria-hidden />
|
||||
<span className="flex-1">
|
||||
<span className="sr-only">{meta?.label}: </span>
|
||||
{alert.message}
|
||||
</span>
|
||||
{href ? (
|
||||
<Link
|
||||
className="shrink-0 rounded-lg px-2 py-2 font-medium text-primary underline-offset-4 hover:underline"
|
||||
href={href}
|
||||
>
|
||||
Ansehen
|
||||
</Link>
|
||||
) : null}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{tiles.length > 0 ? (
|
||||
<section aria-labelledby="overview-heading" className="flex flex-col gap-2">
|
||||
<h2 id="overview-heading" className="font-heading text-lg font-semibold tracking-tight">
|
||||
Überblick
|
||||
</h2>
|
||||
<ul className="grid grid-cols-2 gap-x-4 gap-y-1 rounded-2xl border border-border/60 bg-card p-2 sm:grid-cols-3">
|
||||
{tiles.map((tile) => {
|
||||
const Icon = tile.icon;
|
||||
return (
|
||||
<li key={tile.label}>
|
||||
<Link
|
||||
className="shrink-0 font-medium text-primary underline-offset-4 hover:underline"
|
||||
href={href}
|
||||
href={tile.href}
|
||||
className="flex min-h-11 items-center gap-3 rounded-xl px-3 py-2 transition-colors hover:bg-muted focus-visible:outline-2 focus-visible:outline-ring"
|
||||
>
|
||||
Ansehen
|
||||
<Icon
|
||||
className={`size-5 shrink-0 ${tile.attention ? "text-warning" : "text-primary"}`}
|
||||
aria-hidden
|
||||
/>
|
||||
<span className="font-heading text-lg font-semibold tabular-nums">
|
||||
{tile.value}
|
||||
</span>
|
||||
<span className="min-w-0 truncate text-sm text-muted-foreground">
|
||||
{tile.label}
|
||||
</span>
|
||||
</Link>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</section>
|
||||
) : null}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
"use client";
|
||||
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { MOTION_LEVELS, MotionLevel, useMotionPreference } from "@/lib/motion/motion-preference";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const OPTIONS: Record<MotionLevel, { label: string; description: string }> = {
|
||||
full: {
|
||||
label: "Voll",
|
||||
description: "Rückmeldung beim Abhaken und Speichern, weiche Übergänge.",
|
||||
},
|
||||
subtle: {
|
||||
label: "Dezent",
|
||||
description: "Nur kurzes Ein- und Ausblenden, nichts bewegt sich über den Bildschirm.",
|
||||
},
|
||||
off: {
|
||||
label: "Aus",
|
||||
description: "Alles ändert sich sofort. Zustände bleiben an Symbol und Text erkennbar.",
|
||||
},
|
||||
};
|
||||
|
||||
export function MotionSection() {
|
||||
const [level, setLevel] = useMotionPreference();
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Bewegung & Effekte</CardTitle>
|
||||
<CardDescription>
|
||||
Gilt für dieses Gerät. Ist am Gerät „Bewegung reduzieren“ eingeschaltet, wird „Voll“
|
||||
automatisch zu „Dezent“.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<fieldset className="flex flex-col gap-2">
|
||||
<legend className="sr-only">Bewegung und Effekte</legend>
|
||||
{MOTION_LEVELS.map((option) => (
|
||||
<label
|
||||
key={option}
|
||||
className={cn(
|
||||
"flex min-h-11 cursor-pointer items-start gap-3 rounded-xl border p-3 transition-colors has-[:focus-visible]:outline-2 has-[:focus-visible]:outline-ring",
|
||||
level === option
|
||||
? "border-primary/60 bg-primary/8"
|
||||
: "border-border hover:bg-muted",
|
||||
)}
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
name="motion-level"
|
||||
value={option}
|
||||
checked={level === option}
|
||||
onChange={() => setLevel(option)}
|
||||
className="mt-1 size-4 accent-[var(--primary)]"
|
||||
/>
|
||||
<span className="flex flex-col">
|
||||
<span className="font-medium">{OPTIONS[option].label}</span>
|
||||
<span className="text-sm text-muted-foreground">{OPTIONS[option].description}</span>
|
||||
</span>
|
||||
</label>
|
||||
))}
|
||||
</fieldset>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -26,6 +26,7 @@ import {
|
||||
} from "@/components/ui/card";
|
||||
import { MfaSection } from "./mfa-section";
|
||||
import { DangerZoneSection } from "./danger-zone-section";
|
||||
import { MotionSection } from "./motion-section";
|
||||
|
||||
function splitName(fullName: string): { firstName: string; lastName: string } {
|
||||
const [firstName, ...rest] = fullName.trim().split(/\s+/);
|
||||
@@ -297,6 +298,7 @@ export default function ProfilePage() {
|
||||
<ProfileInfoSection />
|
||||
<PasswordSection />
|
||||
<MfaSection mfaEnabled={user.mfaEnabled} onChanged={() => void refreshUser()} />
|
||||
<MotionSection />
|
||||
<DangerZoneSection />
|
||||
</div>
|
||||
</main>
|
||||
|
||||
@@ -2,14 +2,14 @@
|
||||
|
||||
import { useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { ListTodo } from "lucide-react";
|
||||
import { Check, ListTodo } from "lucide-react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
import { completeTask, fetchTasks } from "@/lib/tasks/api";
|
||||
import { TaskPriority, TaskStatus } from "@/lib/tasks/types";
|
||||
import { TASK_PRIORITIES, TASK_PRIORITY_LABELS, TASK_STATUS_LABELS } from "@/lib/tasks/labels";
|
||||
import { TASK_PRIORITIES, TASK_PRIORITY_LABELS } from "@/lib/tasks/labels";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { TaskSourceBadge, TaskStatusPill } from "@/components/task-status";
|
||||
import { PageHeader } from "@/components/page-header";
|
||||
import { EmptyState } from "@/components/empty-state";
|
||||
import { EntityCard } from "@/components/entity-card";
|
||||
@@ -38,10 +38,12 @@ export default function TasksPage() {
|
||||
|
||||
const completeMutation = useMutation({
|
||||
mutationFn: completeTask,
|
||||
onSuccess: () => {
|
||||
onSuccess: (task) => {
|
||||
void queryClient.invalidateQueries({ queryKey: ["tasks"] });
|
||||
void queryClient.invalidateQueries({ queryKey: ["dashboard"] });
|
||||
toast.success(`Erledigt: ${task.title}`);
|
||||
},
|
||||
onError: () => toast.error("Aufgabe konnte nicht erledigt werden"),
|
||||
onError: () => toast.error("Konnte nicht als erledigt gespeichert werden. Bitte noch einmal tippen."),
|
||||
});
|
||||
|
||||
const [now] = useState(() => Date.now());
|
||||
@@ -136,9 +138,10 @@ export default function TasksPage() {
|
||||
gradient="var(--gradient-sky)"
|
||||
title={task.title}
|
||||
badge={
|
||||
<Badge variant={task.status === "DONE" ? "secondary" : "default"}>
|
||||
{TASK_STATUS_LABELS[task.status]}
|
||||
</Badge>
|
||||
<span className="flex flex-wrap items-center gap-1.5">
|
||||
<TaskStatusPill status={task.status} />
|
||||
<TaskSourceBadge task={task} />
|
||||
</span>
|
||||
}
|
||||
fields={[
|
||||
{ label: "Priorität", value: TASK_PRIORITY_LABELS[task.priority] },
|
||||
@@ -152,12 +155,13 @@ export default function TasksPage() {
|
||||
<TaskEditDialog task={task} />
|
||||
{task.status !== "DONE" && task.status !== "CANCELLED" ? (
|
||||
<Button
|
||||
size="sm"
|
||||
size="touch"
|
||||
variant="outline"
|
||||
disabled={completeMutation.isPending}
|
||||
onClick={() => completeMutation.mutate(task.id)}
|
||||
>
|
||||
Erledigen
|
||||
<Check aria-hidden />
|
||||
Erledigt
|
||||
</Button>
|
||||
) : null}
|
||||
</>
|
||||
|
||||
@@ -198,4 +198,179 @@
|
||||
font-family: var(--font-heading);
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
::selection {
|
||||
background: color-mix(in oklch, var(--primary) 28%, transparent);
|
||||
color: var(--foreground);
|
||||
}
|
||||
input,
|
||||
textarea {
|
||||
caret-color: var(--primary);
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------------------
|
||||
Motion levels (see lib/motion/motion-preference.ts)
|
||||
html[data-motion] = full | subtle | off, chosen per device in the profile.
|
||||
The OS "reduce motion" setting always wins over "full".
|
||||
`motion-full:` = spatial movement allowed (level full AND system allows).
|
||||
------------------------------------------------------------------------ */
|
||||
@custom-variant motion-full {
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
&:where(:root:not([data-motion="subtle"]):not([data-motion="off"]) *) {
|
||||
@slot;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
:root {
|
||||
--ease-out-expo: cubic-bezier(0.16, 1, 0.3, 1);
|
||||
}
|
||||
|
||||
/* Off: every state change is instant. Colour and icon still carry the state. */
|
||||
:root[data-motion="off"] *,
|
||||
:root[data-motion="off"] *::before,
|
||||
:root[data-motion="off"] *::after {
|
||||
animation-duration: 0.01ms !important;
|
||||
animation-iteration-count: 1 !important;
|
||||
transition-duration: 0.01ms !important;
|
||||
transition-delay: 0ms !important;
|
||||
}
|
||||
|
||||
/* Subtle / system reduce: keep fades, drop movement. */
|
||||
:root[data-motion="subtle"] .motion-safe\:animate-page-in {
|
||||
animation-name: fade-in;
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.motion-safe\:animate-page-in {
|
||||
animation: fade-in 160ms ease-out;
|
||||
}
|
||||
}
|
||||
:root[data-motion="subtle"] [data-slot="card"] {
|
||||
translate: none !important;
|
||||
}
|
||||
|
||||
@keyframes fade-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
/* --- Task completion: the one authored moment ------------------------------
|
||||
The honey cell fills, the tick draws itself, then the row folds away. */
|
||||
.honey-check {
|
||||
position: relative;
|
||||
display: inline-grid;
|
||||
place-items: center;
|
||||
flex-shrink: 0;
|
||||
width: 2.75rem;
|
||||
height: 2.75rem;
|
||||
border-radius: 9999px;
|
||||
color: var(--primary-foreground);
|
||||
outline: none;
|
||||
}
|
||||
.honey-check::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0.375rem;
|
||||
border-radius: 0.6rem;
|
||||
border: 2px solid color-mix(in oklch, var(--primary) 65%, var(--border));
|
||||
background: var(--card);
|
||||
transition:
|
||||
border-color 150ms ease-out,
|
||||
background-color 150ms ease-out;
|
||||
}
|
||||
.honey-check::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0.375rem;
|
||||
border-radius: 0.6rem;
|
||||
background: var(--gradient-honey);
|
||||
clip-path: circle(0% at 50% 60%);
|
||||
transition: clip-path 420ms var(--ease-out-expo);
|
||||
}
|
||||
.honey-check:hover::before {
|
||||
border-color: var(--primary);
|
||||
background: color-mix(in oklch, var(--primary) 8%, var(--card));
|
||||
}
|
||||
.honey-check:focus-visible::before {
|
||||
box-shadow: 0 0 0 3px color-mix(in oklch, var(--ring) 45%, transparent);
|
||||
}
|
||||
.honey-check svg {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
width: 1.1rem;
|
||||
height: 1.1rem;
|
||||
stroke-dasharray: 24;
|
||||
stroke-dashoffset: 24;
|
||||
transition: stroke-dashoffset 260ms var(--ease-out-expo) 160ms;
|
||||
}
|
||||
.honey-check[aria-checked="true"]::after {
|
||||
clip-path: circle(75% at 50% 60%);
|
||||
}
|
||||
.honey-check[aria-checked="true"] svg {
|
||||
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. */
|
||||
@keyframes next-step-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
filter: blur(6px);
|
||||
transform: translateY(8px);
|
||||
}
|
||||
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);
|
||||
}
|
||||
:root[data-motion="subtle"] .next-step-enter {
|
||||
animation: next-step-fade 200ms ease-out;
|
||||
}
|
||||
:root[data-motion="subtle"] .today-row,
|
||||
:root[data-motion="subtle"] .honey-check::after {
|
||||
transition-duration: 120ms;
|
||||
transition-delay: 0ms;
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.next-step-enter {
|
||||
animation: next-step-fade 200ms ease-out;
|
||||
}
|
||||
.today-row,
|
||||
.honey-check::after,
|
||||
.honey-check svg {
|
||||
transition-duration: 120ms;
|
||||
transition-delay: 0ms;
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,17 @@
|
||||
"use client";
|
||||
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { useState } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { AuthProvider } from "@/lib/auth/auth-context";
|
||||
import { applyMotionLevel, readMotionLevel } from "@/lib/motion/motion-preference";
|
||||
|
||||
export function Providers({ children }: { children: React.ReactNode }) {
|
||||
const [queryClient] = useState(() => new QueryClient());
|
||||
|
||||
useEffect(() => {
|
||||
applyMotionLevel(readMotionLevel());
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<AuthProvider>{children}</AuthProvider>
|
||||
|
||||
Reference in new issue
Block a user