diff --git a/web/index.html b/web/index.html index b7c2e8b..ecf721e 100644 --- a/web/index.html +++ b/web/index.html @@ -1,14 +1,14 @@ - + Daily Briefing - + - +
diff --git a/web/src/components/Clock.tsx b/web/src/components/Clock.tsx index e521fa3..11a6a69 100644 --- a/web/src/components/Clock.tsx +++ b/web/src/components/Clock.tsx @@ -1,7 +1,5 @@ import { useEffect, useState } from "react"; -import { Clock as ClockIcon } from "lucide-react"; -/** Live clock with German-locale date. Updates every second. */ export default function Clock() { const [now, setNow] = useState(new Date()); @@ -23,23 +21,24 @@ export default function Clock() { return (
-
- -
- -
- {/* Time display */} -
- - {hours}:{minutes} +
+
+ + {hours} - - :{seconds} + + : + + + {minutes} + + + {seconds}
- - {/* Date display */} -

{dateStr}

+

+ {dateStr} +

); diff --git a/web/src/components/HomeAssistant.tsx b/web/src/components/HomeAssistant.tsx index 5364e40..a0267f6 100644 --- a/web/src/components/HomeAssistant.tsx +++ b/web/src/components/HomeAssistant.tsx @@ -1,4 +1,4 @@ -import { Home, Lightbulb, ArrowUp, ArrowDown, Thermometer, WifiOff, Wifi } from "lucide-react"; +import { Home, Lightbulb, ArrowUp, ArrowDown, Thermometer, WifiOff } from "lucide-react"; import type { HAData } from "../api"; interface HomeAssistantProps { @@ -10,12 +10,12 @@ export default function HomeAssistant({ data }: HomeAssistantProps) { if (data.error) { return ( -
-
+
+

Home Assistant nicht erreichbar

-

Verbindung fehlgeschlagen

+

Verbindung fehlgeschlagen

@@ -23,74 +23,59 @@ export default function HomeAssistant({ data }: HomeAssistantProps) { } return ( -
+
{/* Header */}
-
-
- -
-

Home Assistant

+
+ +

Home Assistant

-
-
-
- {data.online && ( -
- )} -
- +
+
+ {data.online ? "Online" : "Offline"}
- {/* Lights Section */} + {/* Lights */}
- - - Lichter - + + Lichter
- - {data.lights_on}/{data.lights_total} an + + {data.lights_on}/{data.lights_total}
{data.lights.length > 0 ? ( -
+
{data.lights.map((light) => { const isOn = light.state === "on"; return (
- + {light.name} {isOn && light.brightness > 0 && ( - + {Math.round((light.brightness / 255) * 100)}% )} @@ -99,49 +84,47 @@ export default function HomeAssistant({ data }: HomeAssistantProps) { })}
) : ( -

Keine Lichter konfiguriert

+

Keine Lichter konfiguriert

)}
- {/* Covers Section */} + {/* Covers */} {data.covers.length > 0 && (
- - - Rollos - + + Rollos
-
+
{data.covers.map((cover) => { const isOpen = cover.state === "open"; const isClosed = cover.state === "closed"; return (
{isOpen ? ( - + ) : isClosed ? ( - + ) : ( - + )} - {cover.name} + {cover.name}
{cover.position > 0 && ( - + {cover.position}% )} {isOpen ? "Offen" : isClosed ? "Zu" : cover.state} @@ -154,31 +137,26 @@ export default function HomeAssistant({ data }: HomeAssistantProps) {
)} - {/* Temperature Sensors Section */} + {/* Temperature Sensors */} {data.sensors.length > 0 && (
- - - Temperaturen - + + Temperaturen
-
+
{data.sensors.map((sensor) => (
- {sensor.name} - + {sensor.name} + {typeof sensor.state === "number" ? sensor.state.toFixed(1) : sensor.state} - {sensor.unit} + {sensor.unit}
))} diff --git a/web/src/components/HourlyForecast.tsx b/web/src/components/HourlyForecast.tsx index 21d52ad..8bd5bfd 100644 --- a/web/src/components/HourlyForecast.tsx +++ b/web/src/components/HourlyForecast.tsx @@ -9,14 +9,14 @@ export default function HourlyForecast({ slots }: HourlyForecastProps) { if (!slots || slots.length === 0) return null; return ( -
-

- Stundenverlauf -

+
+
+ Prognose + +
- {/* Horizontal scroll container - hidden scrollbar via globals.css */}
{slots.map((slot, i) => { @@ -27,49 +27,37 @@ export default function HourlyForecast({ slots }: HourlyForecastProps) {
- {/* Time label */} - + {isNow ? "Jetzt" : hour} - {/* Weather icon */} {slot.icon} - {/* Temperature */} - - {Math.round(slot.temp)}° + + {Math.round(slot.temp)}° - {/* Precipitation bar */} {slot.precip_chance > 0 && ( -
- - +
+ + {slot.precip_chance}%
)} - {/* Precip bar visual */} -
+
@@ -81,7 +69,6 @@ export default function HourlyForecast({ slots }: HourlyForecastProps) { ); } -/** Extracts "HH:mm" from an ISO time string or "HH:00" format. */ function formatHour(time: string): string { try { const d = new Date(time); diff --git a/web/src/components/MqttCard.tsx b/web/src/components/MqttCard.tsx index f2146ac..8173417 100644 --- a/web/src/components/MqttCard.tsx +++ b/web/src/components/MqttCard.tsx @@ -26,25 +26,26 @@ export default function MqttCard({ data }: Props) { const shown = expanded ? filtered : filtered.slice(0, 8); return ( -
+
{/* Header */}
-
- -

MQTT

- - {data.entities.length} Entit{data.entities.length === 1 ? "ät" : "äten"} +
+ +

MQTT

+ + {data.entities.length}
-
+ +
{data.connected ? ( - + ) : ( - + )} {data.connected ? "Verbunden" : "Getrennt"} @@ -54,14 +55,10 @@ export default function MqttCard({ data }: Props) { {/* Category filter tabs */} {categories.length > 1 && ( -
+
@@ -69,14 +66,16 @@ export default function MqttCard({ data }: Props) { ))}
@@ -84,16 +83,16 @@ export default function MqttCard({ data }: Props) { {/* Entity list */} {data.entities.length === 0 ? ( -
- -

+

+ +

{data.connected - ? "Warte auf MQTT-Nachrichten..." - : "MQTT nicht konfiguriert"} + ? "Warte auf Nachrichten..." + : "Nicht konfiguriert"}

) : ( -
+
{shown.map((entity) => ( ))} @@ -104,7 +103,7 @@ export default function MqttCard({ data }: Props) { {filtered.length > 8 && ( - ))} -
+ {/* Category tabs */} +
+ {CATEGORIES.map((cat) => ( + + ))}
{/* Articles grid */} {filteredArticles.length === 0 ? ( -
+
Keine Artikel in dieser Kategorie.
) : ( -
+
{filteredArticles.map((article) => ( ))} @@ -116,26 +113,26 @@ function ArticleCard({ article }: { article: NewsArticle }) { href={article.url} target="_blank" rel="noopener noreferrer" - className="glass-card-hover group block p-4 cursor-pointer" + className="group block bg-base-50 p-4 hover:bg-base-100 transition-colors relative" > -
- +
+ {article.source} - +
-

+

{article.title}

-
+
{article.category && ( - + {article.category} )} - + {relativeTime(article.published_at)}
diff --git a/web/src/components/ServerCard.tsx b/web/src/components/ServerCard.tsx index 50ab3f9..8ad8754 100644 --- a/web/src/components/ServerCard.tsx +++ b/web/src/components/ServerCard.tsx @@ -1,30 +1,33 @@ import { useState } from "react"; -import { Server, Cpu, HardDrive, Wifi, WifiOff, ChevronDown, ChevronUp, Box } from "lucide-react"; +import { Server, Cpu, HardDrive, WifiOff, ChevronDown, ChevronUp, Box } from "lucide-react"; import type { ServerStats } from "../api"; interface ServerCardProps { server: ServerStats; } -/** Return Tailwind text colour class based on percentage thresholds. */ function usageColor(pct: number): string { - if (pct >= 80) return "text-red-400"; - if (pct >= 60) return "text-amber-400"; - return "text-emerald-400"; + if (pct >= 80) return "text-cherry"; + if (pct >= 60) return "text-gold"; + return "text-mint"; } -/** Return SVG stroke colour (hex) based on percentage thresholds. */ function usageStroke(pct: number): string { - if (pct >= 80) return "#f87171"; // red-400 - if (pct >= 60) return "#fbbf24"; // amber-400 - return "#34d399"; // emerald-400 + if (pct >= 80) return "#e85a5a"; + if (pct >= 60) return "#e8a44a"; + return "#4ae8a8"; } -/** Return track background colour based on percentage thresholds. */ function usageBarBg(pct: number): string { - if (pct >= 80) return "bg-red-500/70"; - if (pct >= 60) return "bg-amber-500/70"; - return "bg-emerald-500/70"; + if (pct >= 80) return "bg-cherry"; + if (pct >= 60) return "bg-gold"; + return "bg-mint"; +} + +function usageGlow(pct: number): string { + if (pct >= 80) return "data-glow-cherry"; + if (pct >= 60) return "data-glow-gold"; + return "data-glow-mint"; } export default function ServerCard({ server }: ServerCardProps) { @@ -36,90 +39,88 @@ export default function ServerCard({ server }: ServerCardProps) { const ramPct = Math.round(server.ram.pct); return ( -
+
{/* Header */}
-
-
- -
+
+
-

{server.name}

-

{server.host}

+

{server.name}

+

{server.host}

- +
+
+ + {server.online ? "Online" : "Offline"} + +
{!server.online ? ( -
+
Offline
) : (
- {/* CPU Section */} + {/* CPU */}
- - +
- - - CPU - + + CPU
- + {cpuPct}% {server.cpu.temp_c !== null && ( - - {Math.round(server.cpu.temp_c)}°C + + {Math.round(server.cpu.temp_c)}°C )}
-

+

{server.cpu.cores} Kerne

- {/* RAM Section */} + {/* RAM */}
- - - RAM - + + RAM
- + {ramPct}%
-
+
-

+

{server.ram.used_gb.toFixed(1)} / {server.ram.total_gb.toFixed(1)} GB

{/* Uptime */} {server.uptime && ( -

- Uptime: {server.uptime} +

+ Uptime: {server.uptime}

)} - {/* Docker Section */} + {/* Docker */} {server.docker && (
{dockerExpanded && server.docker.containers.length > 0 && ( -
+
{server.docker.containers.map((c) => (
- {c.name} + {c.name} {c.status.toLowerCase().includes("up") ? "Running" : c.status} @@ -171,37 +170,7 @@ export default function ServerCard({ server }: ServerCardProps) { ); } -/** Green/Red online indicator dot. */ -function StatusDot({ online }: { online: boolean }) { - return ( -
-
-
- {online && ( -
- )} -
- - {online ? "Online" : "Offline"} - -
- ); -} - -/** SVG circular progress ring for CPU usage. */ -function CpuRing({ - pct, - size, - strokeWidth, -}: { - pct: number; - size: number; - strokeWidth: number; -}) { +function CpuRing({ pct, size, strokeWidth }: { pct: number; size: number; strokeWidth: number }) { const radius = (size - strokeWidth) / 2; const circumference = 2 * Math.PI * radius; const offset = circumference - (pct / 100) * circumference; @@ -209,16 +178,14 @@ function CpuRing({ return (
- {/* Track */} - {/* Progress */} - {/* Center icon */}
- +
); diff --git a/web/src/components/TasksCard.tsx b/web/src/components/TasksCard.tsx index 6edce08..4538e84 100644 --- a/web/src/components/TasksCard.tsx +++ b/web/src/components/TasksCard.tsx @@ -13,15 +13,13 @@ const TABS: { key: TabKey; label: string }[] = [ { key: "sams", label: "Sam's" }, ]; -/** Colour for task priority (1 = highest). */ function priorityIndicator(priority: number): { color: string; label: string } { - if (priority <= 1) return { color: "bg-red-500", label: "Hoch" }; - if (priority <= 2) return { color: "bg-amber-500", label: "Mittel" }; - if (priority <= 3) return { color: "bg-blue-500", label: "Normal" }; - return { color: "bg-slate-500", label: "Niedrig" }; + if (priority <= 1) return { color: "bg-cherry", label: "Hoch" }; + if (priority <= 2) return { color: "bg-gold", label: "Mittel" }; + if (priority <= 3) return { color: "bg-azure", label: "Normal" }; + return { color: "bg-base-500", label: "Niedrig" }; } -/** Format a due date string to a short German date. */ function formatDueDate(iso: string | null): string | null { if (!iso) return null; try { @@ -30,7 +28,7 @@ function formatDueDate(iso: string | null): string | null { const now = new Date(); const diffDays = Math.ceil((d.getTime() - now.getTime()) / (1000 * 60 * 60 * 24)); - if (diffDays < 0) return "Ueberfaellig"; + if (diffDays < 0) return "Überfällig"; if (diffDays === 0) return "Heute"; if (diffDays === 1) return "Morgen"; return d.toLocaleDateString("de-DE", { day: "2-digit", month: "short" }); @@ -46,12 +44,12 @@ export default function TasksCard({ data }: TasksCardProps) { if (data.error) { return ( -
-
+
+
-

Aufgaben nicht verfuegbar

-

Verbindung fehlgeschlagen

+

Aufgaben nicht verfügbar

+

Verbindung fehlgeschlagen

@@ -62,19 +60,15 @@ export default function TasksCard({ data }: TasksCardProps) { const tasks = group?.open ?? []; return ( -
+
{/* Header */} -
-
-
- -
-

Aufgaben

-
+
+ +

Aufgaben

- {/* Tab buttons */} -
+ {/* Tabs */} +
{TABS.map((tab) => { const tabGroup = tab.key === "private" ? data.private : data.sams; const openCount = tabGroup?.open_count ?? 0; @@ -84,23 +78,13 @@ export default function TasksCard({ data }: TasksCardProps) {
- {/* Task list */} + {/* Tasks */} {tasks.length === 0 ? ( -
- -

Alles erledigt!

+
+ +

Alles erledigt!

) : ( -
+
{tasks.map((task) => ( ))} @@ -129,44 +113,36 @@ export default function TasksCard({ data }: TasksCardProps) { function TaskItem({ task }: { task: Task }) { const p = priorityIndicator(task.priority); const due = formatDueDate(task.due_date); - const isOverdue = due === "Ueberfaellig"; + const isOverdue = due === "Überfällig"; return ( -
- {/* Visual checkbox */} +
{task.done ? ( - + ) : ( - + )}
- {/* Content */}
-

+

{task.title}

- {/* Project badge */} {task.project_name && ( - + {task.project_name} )} - {/* Due date */} {due && ( - + {due} @@ -174,9 +150,8 @@ function TaskItem({ task }: { task: Task }) {
- {/* Priority dot */}
-
+
); diff --git a/web/src/components/WeatherCard.tsx b/web/src/components/WeatherCard.tsx index e307e5b..b0f5a2a 100644 --- a/web/src/components/WeatherCard.tsx +++ b/web/src/components/WeatherCard.tsx @@ -6,36 +6,22 @@ interface WeatherCardProps { accent: "cyan" | "amber"; } -const accentMap = { - cyan: { - gradient: "from-cyan-500/10 to-cyan-900/5", - text: "text-cyan-400", - border: "border-cyan-500/20", - badge: "bg-cyan-500/15 text-cyan-300", - statIcon: "text-cyan-400/70", - ring: "ring-cyan-500/10", - }, - amber: { - gradient: "from-amber-500/10 to-amber-900/5", - text: "text-amber-400", - border: "border-amber-500/20", - badge: "bg-amber-500/15 text-amber-300", - statIcon: "text-amber-400/70", - ring: "ring-amber-500/10", - }, +const ACCENT = { + cyan: { strip: "gold", text: "text-gold", glow: "data-glow-gold", tag: "border-gold/30 text-gold bg-gold/5" }, + amber: { strip: "mint", text: "text-mint", glow: "data-glow-mint", tag: "border-mint/30 text-mint bg-mint/5" }, } as const; export default function WeatherCard({ data, accent }: WeatherCardProps) { - const a = accentMap[accent]; + const a = ACCENT[accent]; if (data.error) { return ( -
-
+
+
-

Wetter nicht verfuegbar

-

{data.location || "Unbekannt"}

+

Wetter nicht verfügbar

+

{data.location || "Unbekannt"}

@@ -43,73 +29,66 @@ export default function WeatherCard({ data, accent }: WeatherCardProps) { } return ( -
- {/* Header: location badge */} -
- {data.location} +
+ {/* Location tag */} +
+ {data.location}
- {/* Main row: icon + temp */} -
+ {/* Temperature hero */} +
-
- +
+ {Math.round(data.temp)} - ° + °
-

{data.description}

+

{data.description}

- + {data.icon}
- {/* Stat grid */} -
- } - label="Gefuehlt" - value={`${Math.round(data.feels_like)}\u00B0`} + {/* Stats row */} +
+ } + label="Gefühlt" + value={`${Math.round(data.feels_like)}°`} /> - } + } label="Feuchte" value={`${data.humidity}%`} /> - } + } label="Wind" - value={`${Math.round(data.wind_kmh)} km/h`} + value={`${Math.round(data.wind_kmh)}`} + unit="km/h" />
); } -function StatItem({ - icon, - label, - value, -}: { +function StatCell({ icon, label, value, unit }: { icon: React.ReactNode; label: string; value: string; + unit?: string; }) { return ( -
+
{icon} - - {value} - - {label} +
+ {value} + {unit && {unit}} +
+ {label}
); } diff --git a/web/src/pages/Dashboard.tsx b/web/src/pages/Dashboard.tsx index 4221fe3..c873097 100644 --- a/web/src/pages/Dashboard.tsx +++ b/web/src/pages/Dashboard.tsx @@ -14,135 +14,228 @@ export default function Dashboard() { const { data, loading, error, connected, refresh } = useDashboard(); return ( -
+
+ {/* Error banner */} {error && ( -
- -

{error}

-
)} -
-
-
-

Daily Briefing

+ {/* Header */} +
+
+
+

+ Daily Briefing +

+
- +
-
+
{loading && !data ? ( ) : data ? ( - <> -
- - -
- +
+ {/* Section 01 — Wetter */} +
+
+ 01 +

Wetter

+ +
+ +
+ + +
+ +
-
- {data.servers.servers.map((srv) => ( - - ))} - - + {/* Section 02 — Infrastruktur */} +
+
+ 02 +

Infrastruktur

+ +
+ +
+ {data.servers.servers.map((srv) => ( + + ))} + + +
- {(data.mqtt?.connected || (data.mqtt?.entities?.length ?? 0) > 0) && ( -
-
- + {/* Section 03 — MQTT (conditional) */} + {(data.mqtt?.connected || + (data.mqtt?.entities?.length ?? 0) > 0) && ( +
+
+ 03 +

MQTT

+
+ +
)} + {/* Section 04 — Nachrichten */}
-
-

+ {/* Footer */} +

+

Letzte Aktualisierung:{" "} - {new Date(data.timestamp).toLocaleTimeString("de-DE", { - hour: "2-digit", minute: "2-digit", second: "2-digit", - })} + + {new Date(data.timestamp).toLocaleTimeString("de-DE", { + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + })} + +

+

+ Daily Briefing v2

- +
) : null}
); } +/* ── Live indicator ─────────────────────────────────────── */ + function LiveIndicator({ connected }: { connected: boolean }) { return ( -
+
-
- {connected &&
} +
+ {connected && ( +
+ )}
- + {connected ? "Live" : "Offline"} - {connected ? : } + {connected ? ( + + ) : ( + + )}
); } +/* ── Loading skeleton ───────────────────────────────────── */ + function LoadingSkeleton() { return ( -
-
- - - -
-
- {Array.from({ length: 4 }).map((_, i) => )} -
+
+ {/* Section 01 skeleton */}
-
-
- {Array.from({ length: 8 }).map((_, i) => )} +
+ 01 +
+ +
+
+ {Array.from({ length: 4 }).map((_, i) => ( + + ))}
+ + {/* Section 02 skeleton */} +
+
+ 02 +
+ +
+
+ {Array.from({ length: 4 }).map((_, i) => ( + + ))} +
+
+ + {/* Section 04 skeleton */} +
+
+ 04 +
+ +
+
+ {Array.from({ length: 8 }).map((_, i) => ( + + ))} +
+
+ + {/* Scan loader */} +
); } function SkeletonCard({ className = "" }: { className?: string }) { return ( -
-
-
-
-
+
+
+
+
+
); diff --git a/web/src/styles/globals.css b/web/src/styles/globals.css index f2d357d..a67f031 100644 --- a/web/src/styles/globals.css +++ b/web/src/styles/globals.css @@ -8,115 +8,182 @@ } body { - font-family: "Inter", system-ui, -apple-system, sans-serif; - background: linear-gradient(135deg, #0a0e1a 0%, #0f172a 50%, #0c1220 100%); + font-family: "IBM Plex Sans", system-ui, sans-serif; + background: #0d0d0f; + color: #e8e5e0; min-height: 100vh; + position: relative; + } + + /* Subtle film grain overlay */ + body::after { + content: ""; + position: fixed; + inset: 0; + z-index: 9999; + pointer-events: none; + opacity: 0.022; + 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-size: 256px 256px; } ::-webkit-scrollbar { - width: 6px; - height: 6px; + width: 4px; + height: 4px; } ::-webkit-scrollbar-track { background: transparent; } ::-webkit-scrollbar-thumb { - background: rgba(148, 163, 184, 0.2); - border-radius: 3px; + background: #2a2a2e; + border-radius: 0; } ::-webkit-scrollbar-thumb:hover { - background: rgba(148, 163, 184, 0.4); + background: #38383d; + } + + ::selection { + background: #e8a44a40; + color: #e8e5e0; } } @layer components { - .glass-card { - @apply relative overflow-hidden rounded-2xl border border-white/[0.06] - bg-white/[0.03] backdrop-blur-md shadow-xl shadow-black/20; + /* ── Card System ─────────────────────────────────────────── */ + .deck-card { + @apply relative bg-base-50 border border-base-300 overflow-hidden; + border-radius: 0; } - .glass-card-hover { - @apply glass-card transition-all duration-300 - hover:border-white/[0.12] hover:bg-white/[0.05] - hover:shadow-2xl hover:shadow-black/30 - hover:-translate-y-0.5; - } - - .glass-card::before { + .deck-card::before { content: ""; position: absolute; - inset: 0; - border-radius: inherit; - background: linear-gradient( - 135deg, - rgba(255, 255, 255, 0.04) 0%, - transparent 50% - ); - pointer-events: none; + top: 0; + left: 0; + width: 2px; + height: 100%; + background: #2a2a2e; + transition: background 0.2s ease; } - .accent-glow { - position: relative; + .deck-card:hover::before { + background: #e8a44a; } - .accent-glow::after { + + .deck-card[data-accent="gold"]::before { background: #e8a44a; } + .deck-card[data-accent="mint"]::before { background: #4ae8a8; } + .deck-card[data-accent="cherry"]::before { background: #e85a5a; } + .deck-card[data-accent="iris"]::before { background: #a87aec; } + .deck-card[data-accent="azure"]::before { background: #5ab4e8; } + + /* Corner markers — technical drawing accent */ + .corner-marks { + @apply relative; + } + .corner-marks::before, + .corner-marks::after { content: ""; position: absolute; - inset: -1px; - border-radius: inherit; - opacity: 0; - transition: opacity 0.3s; + width: 8px; + height: 8px; + border-color: #4a4a50; + border-style: solid; pointer-events: none; + z-index: 2; } - .accent-glow:hover::after { - opacity: 1; + .corner-marks::before { + top: -1px; + right: -1px; + border-width: 1px 1px 0 0; + } + .corner-marks::after { + bottom: -1px; + left: -1px; + border-width: 0 0 1px 1px; } - .stat-value { - @apply text-3xl font-bold tracking-tight; - font-family: "JetBrains Mono", monospace; + /* ── Section Headers ─────────────────────────────────────── */ + .section-label { + @apply flex items-center gap-3 mb-5; + } + .section-number { + @apply font-mono text-[10px] font-semibold tracking-widest text-base-500 uppercase; + } + .section-title { + @apply font-serif italic text-lg text-base-900 tracking-tight; + } + .section-rule { + @apply flex-1 h-px bg-base-300; } - .stat-label { - @apply text-xs font-medium uppercase tracking-wider text-slate-400; + /* ── Data Values ─────────────────────────────────────────── */ + .data-value { + @apply font-mono font-semibold tracking-tight; + } + .data-label { + @apply text-[10px] font-mono uppercase tracking-[0.15em] text-base-600; } - .badge { - @apply inline-flex items-center rounded-full px-2.5 py-0.5 text-[10px] - font-semibold uppercase tracking-wider; + .data-glow-gold { text-shadow: 0 0 20px #e8a44a33, 0 0 40px #e8a44a15; } + .data-glow-mint { text-shadow: 0 0 20px #4ae8a833, 0 0 40px #4ae8a815; } + .data-glow-cherry { text-shadow: 0 0 20px #e85a5a33, 0 0 40px #e85a5a15; } + + /* ── Tags/Badges ─────────────────────────────────────────── */ + .tag { + @apply inline-flex items-center px-2 py-0.5 text-[10px] font-mono font-medium uppercase tracking-wider border; + border-radius: 0; } - .progress-bar { - @apply h-1.5 rounded-full bg-slate-800 overflow-hidden; + /* ── Tabs ─────────────────────────────────────────────────── */ + .tab-btn { + @apply px-3 py-1.5 text-xs font-mono font-medium tracking-wider uppercase transition-all duration-150 cursor-pointer select-none border; + border-radius: 0; + } + .tab-btn.active { + @apply bg-gold/10 border-gold/40 text-gold; + } + .tab-btn:not(.active) { + @apply bg-transparent border-base-300 text-base-600 hover:text-base-800 hover:border-base-400; } - .progress-fill { - @apply h-full rounded-full transition-all duration-700 ease-out; + /* ── Progress ────────────────────────────────────────────── */ + .bar-track { + @apply h-1 bg-base-200 overflow-hidden; + border-radius: 0; + } + .bar-fill { + @apply h-full transition-all duration-700 ease-out; + border-radius: 0; } - .category-tab { - @apply px-3 py-1.5 rounded-lg text-xs font-medium transition-all duration-200 - cursor-pointer select-none; + /* ── Scan-line loader ────────────────────────────────────── */ + .scan-loader { + @apply h-px bg-base-200 overflow-hidden relative; } - .category-tab.active { - @apply bg-white/10 text-white; + .scan-loader::after { + content: ""; + position: absolute; + top: 0; + left: 0; + width: 40%; + height: 100%; + background: linear-gradient(90deg, transparent, #e8a44a, transparent); + animation: scan 1.5s ease-in-out infinite; } - .category-tab:not(.active) { - @apply text-slate-400 hover:text-slate-200 hover:bg-white/5; + + /* ── Dividers ────────────────────────────────────────────── */ + .divider { + @apply border-none h-px bg-base-300 my-6; } } @layer utilities { - .text-gradient { - @apply bg-clip-text text-transparent; - } - .line-clamp-2 { display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; } - .line-clamp-3 { display: -webkit-box; -webkit-line-clamp: 3; diff --git a/web/tailwind.config.js b/web/tailwind.config.js index 4ffa1ca..7db3979 100644 --- a/web/tailwind.config.js +++ b/web/tailwind.config.js @@ -4,28 +4,73 @@ export default { theme: { extend: { colors: { - surface: { - 50: "rgba(255,255,255,0.05)", - 100: "rgba(255,255,255,0.08)", - 200: "rgba(255,255,255,0.12)", + base: { + DEFAULT: "#0d0d0f", + 50: "#161618", + 100: "#1c1c1f", + 200: "#232326", + 300: "#2a2a2e", + 400: "#38383d", + 500: "#4a4a50", + 600: "#6b6966", + 700: "#8a8783", + 800: "#b0ada8", + 900: "#e8e5e0", + }, + gold: { + DEFAULT: "#e8a44a", + dim: "#e8a44a80", + glow: "#e8a44a33", + muted: "#c4884a", + }, + mint: { + DEFAULT: "#4ae8a8", + dim: "#4ae8a880", + glow: "#4ae8a833", + }, + cherry: { + DEFAULT: "#e85a5a", + dim: "#e85a5a80", + glow: "#e85a5a33", + }, + iris: { + DEFAULT: "#a87aec", + dim: "#a87aec80", + glow: "#a87aec33", + }, + azure: { + DEFAULT: "#5ab4e8", + dim: "#5ab4e880", + glow: "#5ab4e833", }, }, - backdropBlur: { - xs: "2px", + fontFamily: { + serif: ['"Instrument Serif"', "Georgia", "serif"], + sans: ['"IBM Plex Sans"', "system-ui", "sans-serif"], + mono: ['"IBM Plex Mono"', "Menlo", "monospace"], }, animation: { - "fade-in": "fadeIn 0.5s ease-out", - "slide-up": "slideUp 0.4s ease-out", - "pulse-slow": "pulse 3s ease-in-out infinite", + "fade-in": "fadeIn 0.4s ease-out both", + "slide-in": "slideIn 0.3s ease-out both", + "scan": "scan 1.5s ease-in-out infinite", + "glow-pulse": "glowPulse 2s ease-in-out infinite", }, keyframes: { fadeIn: { - "0%": { opacity: "0", transform: "translateY(8px)" }, + "0%": { opacity: "0", transform: "translateY(6px)" }, "100%": { opacity: "1", transform: "translateY(0)" }, }, - slideUp: { - "0%": { opacity: "0", transform: "translateY(16px)" }, - "100%": { opacity: "1", transform: "translateY(0)" }, + slideIn: { + "0%": { opacity: "0", transform: "translateX(-8px)" }, + "100%": { opacity: "1", transform: "translateX(0)" }, + }, + scan: { + "0%, 100%": { transform: "translateX(-100%)" }, + "50%": { transform: "translateX(100%)" }, + }, + glowPulse: { + "0%, 100%": { opacity: "0.5" }, + "50%": { opacity: "1" }, }, }, },