Compare commits
16 Commits
c21d7592ae
...
3dee52b6ad
| Author | SHA1 | Date | |
|---|---|---|---|
| 3dee52b6ad | |||
| 2770a9e88e | |||
| 6e0a848c45 | |||
| b663455c26 | |||
| 3afa894e1b | |||
| 246b54d10c | |||
| b2e68d3100 | |||
| 39a42d08ec | |||
| 91f929c39b | |||
| 8d854b703e | |||
| 01c276fc8d | |||
| 62949c997f | |||
| 34ea31421f | |||
| a4b3a8f7fe | |||
| 11fe3df513 | |||
| 6b02cfa1f8 |
@ -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>
|
||||||
|
|
||||||
<Select
|
<div className="md:hidden">
|
||||||
value={currentView}
|
<Select
|
||||||
onChange={(e) => changeView(e.target.value as CalendarView)}
|
value={currentView}
|
||||||
className="h-8 text-sm w-auto pr-8 md:hidden"
|
onChange={(e) => changeView(e.target.value as CalendarView)}
|
||||||
>
|
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>
|
{(Object.entries(viewLabels) as [CalendarView, string][]).map(([view, label]) => (
|
||||||
))}
|
<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]) => (
|
||||||
|
|||||||
@ -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">
|
||||||
|
|||||||
@ -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 && (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@ -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">
|
||||||
|
|||||||
@ -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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
52
frontend/src/components/layout/AppAmbientBackground.tsx
Normal file
52
frontend/src/components/layout/AppAmbientBackground.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -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>
|
||||||
|
|||||||
@ -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',
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user