Compare commits

..

16 Commits

Author SHA1 Message Date
3dee52b6ad 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>
2026-03-12 18:36:19 +08:00
2770a9e88e Address remaining QA suggestions S-02 through S-06
S-02: Confirmed drift-3 is used by auth/AmbientBackground — not dead code.
S-03: Extracted noise SVG data URI to module-level NOISE_SVG constant.
S-04: Added will-change: transform to drift orbs for GPU layer promotion.
S-05: Documented the 9 AM snooze default in getMinutesUntilTomorrowMorning.
S-06: Made calendar toolbar bg-card/95 with backdrop-blur-md for better
      readability over the transparent FullCalendar grid.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 18:35:44 +08:00
6e0a848c45 Fix QA findings: rename ambient component, add clock tab-resume sync
W-02: Renamed layout/AmbientBackground → AppAmbientBackground to avoid
naming collision with auth/AmbientBackground (IDE auto-import confusion).

S-01: Added visibilitychange listener to re-sync clock after tab
sleep/resume. Previously the interval would drift after laptop sleep
or long tab backgrounding.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 18:28:14 +08:00
b663455c26 Sync clock to minute boundary and stabilize "Updated" text
Clock: Instead of starting a 60s interval from mount time (which drifts
from the system clock), calculate ms until the next :00 second mark,
setTimeout to that point, then setInterval every 60s from there.

Updated text: Replaced formatDistanceToNow (which flickered between
"less than a minute ago" / "a minute ago" / "2 minutes ago" on each
render) with a stable minute-based calculation derived from clockNow:
"just now" / "1 min ago" / "N min ago".

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 18:18:00 +08:00
3afa894e1b Add live clock to dashboard header in 12hr format
Displays current time before the date separated by a vertical bar:
"6:30 PM | Thursday, March 12, 2026". Updates every 60 seconds.
Uses tabular-nums for stable digit widths.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 18:08:05 +08:00
246b54d10c Increase day header separator line visibility
Border was at 30% opacity — nearly invisible against the glassmorphic
card background. Restored to full border-border opacity for clear
section separation.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 18:02:03 +08:00
b2e68d3100 Refine Upcoming day headers: thinner, subtler, aligned
- Removed bg-card from sticky headers (was creating opaque bars against
  glassmorphic card background)
- Reduced padding from pb-1.5 to py-0.5 for slimmer profile
- Added leading-none for proper vertical centering of chevron + text
- Softened border opacity to 30%, text to 70%, chevron to 60%
- Shrunk text from text-xs to text-[10px], chevron from h-3 to h-2.5

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 17:56:23 +08:00
39a42d08ec Fix phantom dropdown arrow next to Today button on desktop
The mobile view Select had md:hidden on the <select> element, but the
Select component wraps it in a <div> with an absolute ChevronDown icon
that remained visible. Moved md:hidden to a wrapper div so the entire
Select (including the chevron) is hidden on desktop.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 15:31:44 +08:00
91f929c39b Fix outline button background for glassmorphism consistency
The outline variant used bg-background (opaque near-black) which created
a visible dark rectangle against semi-transparent card toolbars. Changed
to bg-transparent so outline buttons blend with their parent container.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 15:27:00 +08:00
8d854b703e Fix calendar view visual inconsistencies with glassmorphism
FullCalendar backgrounds were opaque while the toolbar used semi-transparent
glassmorphism cards, creating a patchy look. Now all FC elements match:
- Page background: transparent (ambient shows through grid)
- Column headers: semi-transparent (0.65 opacity)
- Neutral background: semi-transparent (0.65 opacity)
- More-popover: semi-transparent with backdrop blur

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 13:59:15 +08:00
01c276fc8d Make ambient background global and lighten card colors
- Moved ambient from DashboardPage to AppLayout so all pages get the
  drifting gradient effect, not just the dashboard
- Lightened card colors: --card 5% → 8%, --card-elevated 7% → 11%,
  popover and FullCalendar backgrounds updated to match
- Renamed DashboardAmbient → AmbientBackground in layout/
- Glassmorphism class renamed dashboard-glass → ambient-glass,
  applied at AppLayout content wrapper level

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 12:59:12 +08:00
62949c997f Fix ambient edge clipping: extend orb layers with -100px inset
Drift animations translate orbs up to 80px, causing hard cutoff at
container edges. Giving orb layers inset: -100px provides enough
bleed room so the gradient edges are always beyond the overflow-hidden
boundary.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 11:18:46 +08:00
34ea31421f Boost ambient visibility: stronger orbs, reduced vignette, transparent cards
- Orbs repositioned centrally with larger ellipses (90%/80%) and higher
  opacity (0.45/0.35) so glow is visible through glassmorphism cards
- Vignette reduced from 0.45 to 0.30, transparent zone expanded to 50%
- Card opacity reduced from 0.80 to 0.65 to let more ambient bleed through
- Added overflow-hidden on ambient container to prevent black bar artifacts
  during drift animations

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 09:55:39 +08:00
a4b3a8f7fe Switch ambient background to radial gradients + glassmorphism cards
Blurred circle approach was invisible on near-black backgrounds.
Use radial-gradient orbs at 25%/15% opacity instead, with semi-transparent
cards (backdrop-filter: blur) so the ambient effect shows through.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 09:44:38 +08:00
11fe3df513 Fix ambient background: use positive z-index layering instead of negative
Negative z-index (-z-10) placed orbs behind the body's opaque background,
making them invisible. Moved all ambient layers (orbs, noise texture,
vignette) into the DashboardAmbient component as absolute-positioned
children at z-0, with content at z-10. Boosted orb opacities to 12%/7%
for perceptible effect. Removed CSS pseudo-element approach in favor of
inline React elements for better stacking control.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 09:26:02 +08:00
6b02cfa1f8 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>
2026-03-12 00:54:00 +08:00
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 {