Add ambient dashboard background: drifting orbs, noise texture, vignette, card breathe
Three layered effects to make the dashboard feel alive: 1. DashboardAmbient: two accent-colored drifting orbs at very low opacity (0.04/0.025) with 120px blur — subtle depth and movement 2. Noise texture + radial vignette via CSS pseudo-elements — breaks the flat digital surface and draws focus to center content 3. Card breathe animation: data-driven 4s pulsing glow on CalendarWidget (when event in progress) and TodoWidget (when overdue todos exist) All effects respect prefers-reduced-motion, use accent CSS vars (works with any user-chosen accent color), and are GPU-composited (transform + opacity only) for negligible performance cost. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
c21d7592ae
commit
6b02cfa1f8
@ -50,8 +50,10 @@ export default function CalendarWidget({ events }: CalendarWidgetProps) {
|
|||||||
return () => clearInterval(interval);
|
return () => clearInterval(interval);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const hasCurrentEvent = events.some((e) => getEventTimeState(e, clientNow) === 'current');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className="hover:shadow-lg hover:shadow-accent/5 hover:border-accent/20 transition-all duration-200">
|
<Card className={cn('hover:shadow-lg hover:shadow-accent/5 hover:border-accent/20 transition-all duration-200', hasCurrentEvent && 'animate-card-breathe')}>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="flex items-center gap-2">
|
<CardTitle className="flex items-center gap-2">
|
||||||
<div className="p-1.5 rounded-md bg-purple-500/10">
|
<div className="p-1.5 rounded-md bg-purple-500/10">
|
||||||
|
|||||||
30
frontend/src/components/dashboard/DashboardAmbient.tsx
Normal file
30
frontend/src/components/dashboard/DashboardAmbient.tsx
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
/**
|
||||||
|
* Muted ambient background for the dashboard.
|
||||||
|
* Two accent-colored drifting orbs at very low opacity — adds depth
|
||||||
|
* and movement without distracting from content. Reuses the same
|
||||||
|
* drift keyframes as the login AmbientBackground but at ~25% opacity.
|
||||||
|
*/
|
||||||
|
export default function DashboardAmbient() {
|
||||||
|
return (
|
||||||
|
<div className="pointer-events-none fixed inset-0 -z-10 overflow-hidden" aria-hidden="true">
|
||||||
|
{/* Primary orb — top-left drift */}
|
||||||
|
<div
|
||||||
|
className="absolute h-[600px] w-[600px] rounded-full opacity-[0.04] blur-[120px] animate-drift-1"
|
||||||
|
style={{
|
||||||
|
background: 'radial-gradient(circle, hsl(var(--accent-color)) 0%, transparent 70%)',
|
||||||
|
top: '-5%',
|
||||||
|
left: '-10%',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{/* Secondary orb — bottom-right drift */}
|
||||||
|
<div
|
||||||
|
className="absolute h-[450px] w-[450px] rounded-full opacity-[0.025] blur-[120px] animate-drift-2"
|
||||||
|
style={{
|
||||||
|
background: 'radial-gradient(circle, hsl(var(--accent-color)) 0%, transparent 70%)',
|
||||||
|
bottom: '5%',
|
||||||
|
right: '-5%',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -16,6 +16,7 @@ import DayBriefing from './DayBriefing';
|
|||||||
import CountdownWidget from './CountdownWidget';
|
import CountdownWidget from './CountdownWidget';
|
||||||
import TrackedProjectsWidget from './TrackedProjectsWidget';
|
import TrackedProjectsWidget from './TrackedProjectsWidget';
|
||||||
import AlertBanner from './AlertBanner';
|
import AlertBanner from './AlertBanner';
|
||||||
|
import DashboardAmbient from './DashboardAmbient';
|
||||||
import EventForm from '../calendar/EventForm';
|
import EventForm from '../calendar/EventForm';
|
||||||
import TodoForm from '../todos/TodoForm';
|
import TodoForm from '../todos/TodoForm';
|
||||||
import ReminderForm from '../reminders/ReminderForm';
|
import ReminderForm from '../reminders/ReminderForm';
|
||||||
@ -164,7 +165,8 @@ export default function DashboardPage() {
|
|||||||
: null;
|
: null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col h-full">
|
<div className="flex flex-col h-full dashboard-ambient">
|
||||||
|
<DashboardAmbient />
|
||||||
{/* Header — greeting + date + quick add */}
|
{/* Header — greeting + date + quick add */}
|
||||||
<div className="px-4 md:px-6 pt-4 sm:pt-6 pb-1 sm:pb-2 flex items-center justify-between">
|
<div className="px-4 md:px-6 pt-4 sm:pt-6 pb-1 sm:pb-2 flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@ -48,8 +48,10 @@ export default function TodoWidget({ todos }: TodoWidgetProps) {
|
|||||||
onError: () => toast.error('Failed to complete todo'),
|
onError: () => toast.error('Failed to complete todo'),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const hasOverdue = todos.some((t) => isPast(endOfDay(new Date(t.due_date))));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className="h-full hover:shadow-lg hover:shadow-accent/5 hover:border-accent/20 transition-all duration-200">
|
<Card className={cn('h-full hover:shadow-lg hover:shadow-accent/5 hover:border-accent/20 transition-all duration-200', hasOverdue && 'animate-card-breathe')}>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="flex items-center gap-2">
|
<CardTitle className="flex items-center gap-2">
|
||||||
<div className="p-1.5 rounded-md bg-blue-500/10">
|
<div className="p-1.5 rounded-md bg-blue-500/10">
|
||||||
|
|||||||
@ -455,6 +455,41 @@ form[data-submitted] input:invalid + button {
|
|||||||
.animate-slide-in-row { animation: slide-in-row 250ms ease-out both; }
|
.animate-slide-in-row { animation: slide-in-row 250ms ease-out both; }
|
||||||
.animate-content-reveal { animation: content-reveal 400ms ease-out both; }
|
.animate-content-reveal { animation: content-reveal 400ms ease-out both; }
|
||||||
|
|
||||||
|
/* ── Dashboard ambient layers ── */
|
||||||
|
|
||||||
|
/* SVG noise texture — adds tactile depth to flat dark backgrounds */
|
||||||
|
.dashboard-ambient::before {
|
||||||
|
content: '';
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: -1;
|
||||||
|
opacity: 0.03;
|
||||||
|
pointer-events: none;
|
||||||
|
background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 256 256' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E");
|
||||||
|
background-repeat: repeat;
|
||||||
|
background-size: 256px 256px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Radial vignette — darkens edges, draws focus to center content */
|
||||||
|
.dashboard-ambient::after {
|
||||||
|
content: '';
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: -1;
|
||||||
|
pointer-events: none;
|
||||||
|
background: radial-gradient(ellipse at center, transparent 40%, rgba(0, 0, 0, 0.35) 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Card breathing glow — data-aware "alive" pulse for urgent cards */
|
||||||
|
@keyframes card-breathe {
|
||||||
|
0%, 100% { box-shadow: 0 0 0 0 hsl(var(--accent-color) / 0.05); }
|
||||||
|
50% { box-shadow: 0 0 16px 2px hsl(var(--accent-color) / 0.10); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.animate-card-breathe {
|
||||||
|
animation: card-breathe 4s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
/* Respect reduced motion preferences */
|
/* Respect reduced motion preferences */
|
||||||
@media (prefers-reduced-motion: reduce) {
|
@media (prefers-reduced-motion: reduce) {
|
||||||
*, *::before, *::after {
|
*, *::before, *::after {
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user