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">
{/* 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)}>
<PanelLeft className="h-4 w-4" />
</Button>
@ -511,15 +511,17 @@ export default function CalendarPage() {
Today
</Button>
<div className="md:hidden">
<Select
value={currentView}
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]) => (
<option key={view} value={view}>{label}</option>
))}
</Select>
</div>
<div className="hidden md:flex items-center rounded-md border border-border overflow-hidden">
{(Object.entries(viewLabels) as [CalendarView, string][]).map(([view, label]) => (

View File

@ -50,8 +50,10 @@ export default function CalendarWidget({ events }: CalendarWidgetProps) {
return () => clearInterval(interval);
}, []);
const hasCurrentEvent = events.some((e) => getEventTimeState(e, clientNow) === 'current');
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>
<CardTitle className="flex items-center gap-2">
<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 { useNavigate } from 'react-router-dom';
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 api from '@/lib/api';
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 [dropdownOpen, setDropdownOpen] = useState(false);
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
useEffect(() => {
@ -160,7 +192,12 @@ export default function DashboardPage() {
}
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;
return (
@ -173,7 +210,9 @@ export default function DashboardPage() {
</h1>
<div className="flex items-center gap-2 mt-1">
<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>
{updatedAgo && (
<>

View File

@ -48,8 +48,10 @@ export default function TodoWidget({ todos }: TodoWidgetProps) {
onError: () => toast.error('Failed to complete todo'),
});
const hasOverdue = todos.some((t) => isPast(endOfDay(new Date(t.due_date))));
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>
<CardTitle className="flex items-center gap-2">
<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' },
};
// Snooze "Tomorrow" targets 9 AM — a reasonable default morning time.
// Could be made configurable via user settings in the future.
function getMinutesUntilTomorrowMorning(): number {
const now = new Date();
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
onClick={() => toggleDay(dateKey)}
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',
groupIdx === 0 ? 'pt-0' : 'pt-3'
'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' : 'mt-2'
)}
>
<ChevronRight
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'
)}
/>
<span
className={cn(
'text-xs font-semibold uppercase tracking-wider',
isTodayGroup ? 'text-accent' : 'text-muted-foreground'
'text-[10px] font-semibold uppercase tracking-wider leading-none',
isTodayGroup ? 'text-accent' : 'text-muted-foreground/70'
)}
>
{label}
</span>
{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'}
</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 { Button } from '@/components/ui/button';
import Sidebar from './Sidebar';
import AppAmbientBackground from './AppAmbientBackground';
import LockOverlay from './LockOverlay';
import NotificationToaster from '@/components/notifications/NotificationToaster';
@ -33,15 +34,16 @@ export default function AppLayout() {
mobileOpen={mobileOpen}
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 */}
<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)}>
<Menu className="h-5 w-5" />
</Button>
<h1 className="text-lg font-bold text-accent ml-3">UMBRA</h1>
</div>
<main className="flex-1 overflow-y-auto mobile-scale">
<main className="relative z-10 flex-1 overflow-y-auto mobile-scale">
<Outlet />
</main>
</div>

View File

@ -9,7 +9,7 @@ const buttonVariants = cva(
variant: {
default: 'bg-accent text-accent-foreground hover:bg-accent/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',
ghost: 'hover:bg-accent/10 hover:text-accent',
link: 'text-accent underline-offset-4 hover:underline',

View File

@ -6,10 +6,10 @@
:root {
--background: 0 0% 3.9%;
--foreground: 0 0% 98%;
--card: 0 0% 5%;
--card: 0 0% 8%;
--card-foreground: 0 0% 98%;
--card-elevated: 0 0% 7%;
--popover: 0 0% 5%;
--card-elevated: 0 0% 11%;
--popover: 0 0% 8%;
--popover-foreground: 0 0% 98%;
--primary: var(--accent-h) var(--accent-s) var(--accent-l);
--primary-foreground: 0 0% 98%;
@ -93,8 +93,8 @@
--fc-event-bg-color: hsl(var(--accent-color));
--fc-event-border-color: hsl(var(--accent-color));
--fc-event-text-color: hsl(0 0% 98%);
--fc-page-bg-color: hsl(0 0% 3.9%);
--fc-neutral-bg-color: hsl(0 0% 5%);
--fc-page-bg-color: transparent;
--fc-neutral-bg-color: hsl(0 0% 8% / 0.65);
--fc-neutral-text-color: hsl(0 0% 98%);
--fc-list-event-hover-bg-color: hsl(0 0% 10%);
--fc-today-bg-color: hsl(var(--accent-color) / 0.08);
@ -144,7 +144,7 @@
}
.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);
}
@ -195,7 +195,9 @@
/* ── FullCalendar "+more" popover fixes ── */
.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-radius: 0.5rem;
min-width: 220px;
@ -203,7 +205,7 @@
}
.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%);
padding: 8px 12px;
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-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 */
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {