Merge feature/ambient-dashboard-background into main

Global ambient background with drifting gradient orbs, glassmorphism
cards, lightened color palette, live dashboard clock, calendar UI fixes,
and Upcoming widget polish. QA reviewed — 0 critical, all suggestions
addressed.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Kyle 2026-03-12 18:36:19 +08:00
commit 3dee52b6ad
9 changed files with 155 additions and 33 deletions

View File

@ -495,7 +495,7 @@ export default function CalendarPage() {
<div ref={calendarContainerRef} className="flex-1 flex flex-col overflow-hidden"> <div ref={calendarContainerRef} className="flex-1 flex flex-col overflow-hidden">
{/* Custom toolbar */} {/* Custom toolbar */}
<div className="border-b bg-card px-4 md:px-6 min-h-[4rem] flex items-center gap-2 md:gap-4 flex-wrap py-2 md:py-0 md:h-16 md:flex-nowrap shrink-0"> <div className="border-b bg-card/95 backdrop-blur-md px-4 md:px-6 min-h-[4rem] flex items-center gap-2 md:gap-4 flex-wrap py-2 md:py-0 md:h-16 md:flex-nowrap shrink-0">
<Button variant="ghost" size="icon" className="h-8 w-8 lg:hidden" onClick={() => setMobileSidebarOpen(true)}> <Button variant="ghost" size="icon" className="h-8 w-8 lg:hidden" onClick={() => setMobileSidebarOpen(true)}>
<PanelLeft className="h-4 w-4" /> <PanelLeft className="h-4 w-4" />
</Button> </Button>
@ -511,15 +511,17 @@ export default function CalendarPage() {
Today Today
</Button> </Button>
<div className="md:hidden">
<Select <Select
value={currentView} value={currentView}
onChange={(e) => changeView(e.target.value as CalendarView)} onChange={(e) => changeView(e.target.value as CalendarView)}
className="h-8 text-sm w-auto pr-8 md:hidden" className="h-8 text-sm w-auto pr-8"
> >
{(Object.entries(viewLabels) as [CalendarView, string][]).map(([view, label]) => ( {(Object.entries(viewLabels) as [CalendarView, string][]).map(([view, label]) => (
<option key={view} value={view}>{label}</option> <option key={view} value={view}>{label}</option>
))} ))}
</Select> </Select>
</div>
<div className="hidden md:flex items-center rounded-md border border-border overflow-hidden"> <div className="hidden md:flex items-center rounded-md border border-border overflow-hidden">
{(Object.entries(viewLabels) as [CalendarView, string][]).map(([view, label]) => ( {(Object.entries(viewLabels) as [CalendarView, string][]).map(([view, label]) => (

View File

@ -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">

View File

@ -1,7 +1,7 @@
import { useState, useEffect, useRef, useCallback } from 'react'; import { useState, useEffect, useRef, useCallback } from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { useQuery, useQueryClient } from '@tanstack/react-query'; import { useQuery, useQueryClient } from '@tanstack/react-query';
import { format, formatDistanceToNow } from 'date-fns'; import { format } from 'date-fns';
import { Bell, Plus, Calendar as CalIcon, ListTodo, RefreshCw } from 'lucide-react'; import { Bell, Plus, Calendar as CalIcon, ListTodo, RefreshCw } from 'lucide-react';
import api from '@/lib/api'; import api from '@/lib/api';
import type { DashboardData, UpcomingResponse, WeatherData } from '@/types'; import type { DashboardData, UpcomingResponse, WeatherData } from '@/types';
@ -42,6 +42,38 @@ export default function DashboardPage() {
const [quickAddType, setQuickAddType] = useState<'event' | 'todo' | 'reminder' | null>(null); const [quickAddType, setQuickAddType] = useState<'event' | 'todo' | 'reminder' | null>(null);
const [dropdownOpen, setDropdownOpen] = useState(false); const [dropdownOpen, setDropdownOpen] = useState(false);
const dropdownRef = useRef<HTMLDivElement>(null); const dropdownRef = useRef<HTMLDivElement>(null);
const [clockNow, setClockNow] = useState(() => new Date());
// Live clock — synced to the minute boundary, re-syncs after tab sleep/resume
useEffect(() => {
let intervalId: ReturnType<typeof setInterval>;
let timeoutId: ReturnType<typeof setTimeout>;
function startClock() {
clearTimeout(timeoutId);
clearInterval(intervalId);
setClockNow(new Date());
const msUntilNextMinute = (60 - new Date().getSeconds()) * 1000 - new Date().getMilliseconds();
timeoutId = setTimeout(() => {
setClockNow(new Date());
intervalId = setInterval(() => setClockNow(new Date()), 60_000);
}, msUntilNextMinute);
}
startClock();
// Re-sync when tab becomes visible again (after sleep/background throttle)
function handleVisibility() {
if (document.visibilityState === 'visible') startClock();
}
document.addEventListener('visibilitychange', handleVisibility);
return () => {
clearTimeout(timeoutId);
clearInterval(intervalId);
document.removeEventListener('visibilitychange', handleVisibility);
};
}, []);
// Click outside to close dropdown // Click outside to close dropdown
useEffect(() => { useEffect(() => {
@ -160,7 +192,12 @@ export default function DashboardPage() {
} }
const updatedAgo = dataUpdatedAt const updatedAgo = dataUpdatedAt
? formatDistanceToNow(new Date(dataUpdatedAt), { addSuffix: true }) ? (() => {
const mins = Math.floor((clockNow.getTime() - dataUpdatedAt) / 60_000);
if (mins < 1) return 'just now';
if (mins === 1) return '1 min ago';
return `${mins} min ago`;
})()
: null; : null;
return ( return (
@ -173,7 +210,9 @@ export default function DashboardPage() {
</h1> </h1>
<div className="flex items-center gap-2 mt-1"> <div className="flex items-center gap-2 mt-1">
<p className="text-muted-foreground text-sm"> <p className="text-muted-foreground text-sm">
{format(new Date(), 'EEEE, MMMM d, yyyy')} <span className="tabular-nums">{format(clockNow, 'h:mm a')}</span>
<span className="mx-1.5 text-muted-foreground/30">|</span>
{format(clockNow, 'EEEE, MMMM d, yyyy')}
</p> </p>
{updatedAgo && ( {updatedAgo && (
<> <>

View File

@ -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">

View File

@ -30,6 +30,8 @@ const typeConfig: Record<string, { hoverGlow: string; pillBg: string; pillText:
reminder: { hoverGlow: 'hover:bg-orange-500/[0.08]', pillBg: 'bg-orange-500/15', pillText: 'text-orange-400', label: 'REMINDER' }, reminder: { hoverGlow: 'hover:bg-orange-500/[0.08]', pillBg: 'bg-orange-500/15', pillText: 'text-orange-400', label: 'REMINDER' },
}; };
// Snooze "Tomorrow" targets 9 AM — a reasonable default morning time.
// Could be made configurable via user settings in the future.
function getMinutesUntilTomorrowMorning(): number { function getMinutesUntilTomorrowMorning(): number {
const now = new Date(); const now = new Date();
const tomorrow = new Date(now.getFullYear(), now.getMonth(), now.getDate() + 1, 9, 0, 0); const tomorrow = new Date(now.getFullYear(), now.getMonth(), now.getDate() + 1, 9, 0, 0);
@ -251,26 +253,26 @@ export default function UpcomingWidget({ items }: UpcomingWidgetProps) {
<button <button
onClick={() => toggleDay(dateKey)} onClick={() => toggleDay(dateKey)}
className={cn( className={cn(
'sticky top-0 bg-card z-10 w-full flex items-center gap-1.5 pb-1.5 border-b border-border cursor-pointer select-none', 'sticky top-0 z-10 w-full flex items-center gap-1.5 py-0.5 border-b border-border cursor-pointer select-none leading-none',
groupIdx === 0 ? 'pt-0' : 'pt-3' groupIdx === 0 ? 'pt-0' : 'mt-2'
)} )}
> >
<ChevronRight <ChevronRight
className={cn( className={cn(
'h-3 w-3 text-muted-foreground transition-transform duration-150', 'h-2.5 w-2.5 text-muted-foreground/60 transition-transform duration-150 shrink-0',
!isCollapsed && 'rotate-90' !isCollapsed && 'rotate-90'
)} )}
/> />
<span <span
className={cn( className={cn(
'text-xs font-semibold uppercase tracking-wider', 'text-[10px] font-semibold uppercase tracking-wider leading-none',
isTodayGroup ? 'text-accent' : 'text-muted-foreground' isTodayGroup ? 'text-accent' : 'text-muted-foreground/70'
)} )}
> >
{label} {label}
</span> </span>
{isCollapsed && ( {isCollapsed && (
<span className="text-[10px] text-muted-foreground font-normal ml-auto mr-1"> <span className="text-[9px] text-muted-foreground/50 font-normal ml-auto mr-1 leading-none">
{dayItems.length} {dayItems.length === 1 ? 'item' : 'items'} {dayItems.length} {dayItems.length === 1 ? 'item' : 'items'}
</span> </span>
)} )}

View File

@ -0,0 +1,52 @@
/**
* Global ambient background effect.
*
* Uses CSS radial gradients with animated position shifting to create
* a living, breathing atmosphere behind all page content.
* The accent color is read from CSS custom properties so it adapts to any theme.
*
* Also renders a noise texture for tactile depth and a radial vignette
* to darken edges and draw focus to center content.
*/
const NOISE_SVG = `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")`;
export default function AppAmbientBackground() {
return (
<div className="pointer-events-none absolute inset-0 z-0 overflow-hidden" aria-hidden="true">
{/* Animated gradient orbs — oversized so drift never clips at visible edges */}
<div
className="absolute animate-drift-1"
style={{
inset: '-100px',
willChange: 'transform',
background: 'radial-gradient(ellipse 90% 70% at 30% 20%, hsl(var(--accent-color) / 0.45) 0%, transparent 60%)',
}}
/>
<div
className="absolute animate-drift-2"
style={{
inset: '-100px',
willChange: 'transform',
background: 'radial-gradient(ellipse 80% 60% at 75% 75%, hsl(var(--accent-color) / 0.35) 0%, transparent 60%)',
}}
/>
{/* Noise texture */}
<div
className="absolute inset-0 opacity-[0.035]"
style={{
backgroundImage: NOISE_SVG,
backgroundRepeat: 'repeat',
backgroundSize: '256px 256px',
}}
/>
{/* Radial vignette */}
<div
className="absolute inset-0"
style={{
background: 'radial-gradient(ellipse at center, transparent 50%, rgba(0, 0, 0, 0.30) 100%)',
}}
/>
</div>
);
}

View File

@ -7,6 +7,7 @@ import { LockProvider } from '@/hooks/useLock';
import { NotificationProvider } from '@/hooks/useNotifications'; import { NotificationProvider } from '@/hooks/useNotifications';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import Sidebar from './Sidebar'; import Sidebar from './Sidebar';
import AppAmbientBackground from './AppAmbientBackground';
import LockOverlay from './LockOverlay'; import LockOverlay from './LockOverlay';
import NotificationToaster from '@/components/notifications/NotificationToaster'; import NotificationToaster from '@/components/notifications/NotificationToaster';
@ -33,15 +34,16 @@ export default function AppLayout() {
mobileOpen={mobileOpen} mobileOpen={mobileOpen}
onMobileClose={() => setMobileOpen(false)} onMobileClose={() => setMobileOpen(false)}
/> />
<div className="flex-1 flex flex-col overflow-hidden"> <div className="flex-1 flex flex-col overflow-hidden relative ambient-glass">
<AppAmbientBackground />
{/* Mobile header */} {/* Mobile header */}
<div className="flex md:hidden items-center h-14 border-b bg-card px-4"> <div className="relative z-10 flex md:hidden items-center h-14 border-b bg-card px-4">
<Button variant="ghost" size="icon" onClick={() => setMobileOpen(true)}> <Button variant="ghost" size="icon" onClick={() => setMobileOpen(true)}>
<Menu className="h-5 w-5" /> <Menu className="h-5 w-5" />
</Button> </Button>
<h1 className="text-lg font-bold text-accent ml-3">UMBRA</h1> <h1 className="text-lg font-bold text-accent ml-3">UMBRA</h1>
</div> </div>
<main className="flex-1 overflow-y-auto mobile-scale"> <main className="relative z-10 flex-1 overflow-y-auto mobile-scale">
<Outlet /> <Outlet />
</main> </main>
</div> </div>

View File

@ -9,7 +9,7 @@ const buttonVariants = cva(
variant: { variant: {
default: 'bg-accent text-accent-foreground hover:bg-accent/90', default: 'bg-accent text-accent-foreground hover:bg-accent/90',
destructive: 'bg-destructive text-destructive-foreground hover:bg-destructive/90', destructive: 'bg-destructive text-destructive-foreground hover:bg-destructive/90',
outline: 'border border-input bg-background hover:bg-accent/10 hover:text-accent', outline: 'border border-input bg-transparent hover:bg-accent/10 hover:text-accent',
secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/80', secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/80',
ghost: 'hover:bg-accent/10 hover:text-accent', ghost: 'hover:bg-accent/10 hover:text-accent',
link: 'text-accent underline-offset-4 hover:underline', link: 'text-accent underline-offset-4 hover:underline',

View File

@ -6,10 +6,10 @@
:root { :root {
--background: 0 0% 3.9%; --background: 0 0% 3.9%;
--foreground: 0 0% 98%; --foreground: 0 0% 98%;
--card: 0 0% 5%; --card: 0 0% 8%;
--card-foreground: 0 0% 98%; --card-foreground: 0 0% 98%;
--card-elevated: 0 0% 7%; --card-elevated: 0 0% 11%;
--popover: 0 0% 5%; --popover: 0 0% 8%;
--popover-foreground: 0 0% 98%; --popover-foreground: 0 0% 98%;
--primary: var(--accent-h) var(--accent-s) var(--accent-l); --primary: var(--accent-h) var(--accent-s) var(--accent-l);
--primary-foreground: 0 0% 98%; --primary-foreground: 0 0% 98%;
@ -93,8 +93,8 @@
--fc-event-bg-color: hsl(var(--accent-color)); --fc-event-bg-color: hsl(var(--accent-color));
--fc-event-border-color: hsl(var(--accent-color)); --fc-event-border-color: hsl(var(--accent-color));
--fc-event-text-color: hsl(0 0% 98%); --fc-event-text-color: hsl(0 0% 98%);
--fc-page-bg-color: hsl(0 0% 3.9%); --fc-page-bg-color: transparent;
--fc-neutral-bg-color: hsl(0 0% 5%); --fc-neutral-bg-color: hsl(0 0% 8% / 0.65);
--fc-neutral-text-color: hsl(0 0% 98%); --fc-neutral-text-color: hsl(0 0% 98%);
--fc-list-event-hover-bg-color: hsl(0 0% 10%); --fc-list-event-hover-bg-color: hsl(0 0% 10%);
--fc-today-bg-color: hsl(var(--accent-color) / 0.08); --fc-today-bg-color: hsl(var(--accent-color) / 0.08);
@ -144,7 +144,7 @@
} }
.fc .fc-col-header-cell { .fc .fc-col-header-cell {
background-color: hsl(0 0% 5%); background-color: hsl(0 0% 8% / 0.65);
border-color: var(--fc-border-color); border-color: var(--fc-border-color);
} }
@ -195,7 +195,9 @@
/* ── FullCalendar "+more" popover fixes ── */ /* ── FullCalendar "+more" popover fixes ── */
.fc .fc-more-popover { .fc .fc-more-popover {
background-color: hsl(0 0% 5%); background-color: hsl(0 0% 8% / 0.85);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
border-color: hsl(0 0% 14.9%); border-color: hsl(0 0% 14.9%);
border-radius: 0.5rem; border-radius: 0.5rem;
min-width: 220px; min-width: 220px;
@ -203,7 +205,7 @@
} }
.fc .fc-more-popover .fc-popover-header { .fc .fc-more-popover .fc-popover-header {
background-color: hsl(0 0% 7%); background-color: hsl(0 0% 11% / 0.8);
color: hsl(0 0% 98%); color: hsl(0 0% 98%);
padding: 8px 12px; padding: 8px 12px;
border-radius: 0.5rem 0.5rem 0 0; border-radius: 0.5rem 0.5rem 0 0;
@ -455,6 +457,25 @@ 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 ── */
/* Glassmorphism — cards become semi-transparent to let ambient show through */
.ambient-glass .bg-card {
background-color: hsl(0 0% 8% / 0.65) !important;
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
}
/* 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 {