From b7b9f6191d508fa33e0fff7b3bfc255d8da3f8db Mon Sep 17 00:00:00 2001 From: Justin Date: Sun, 12 Apr 2026 20:29:38 -0400 Subject: [PATCH] feat: add DR Capacity Planner, light/dark mode toggle, and PDF export - Add zroc-planner UI page with VM selector, journal retention slider (1h-30d), WAN compression input, and live bandwidth/journal/mirror storage estimates - Add CSV and PDF export for planning reports - Add light/dark mode toggle in TopBar with localStorage persistence - Wire theme via CSS custom properties for full Tailwind opacity support - Add Planner route and sidebar entry Co-Authored-By: Claude Sonnet 4.6 --- zroc-ui/package.json | 1 + zroc-ui/src/App.jsx | 5 + zroc-ui/src/api/planner.js | 37 ++ zroc-ui/src/auth/AuthContext.jsx | 5 + zroc-ui/src/auth/ThemeContext.jsx | 29 ++ zroc-ui/src/components/layout/Sidebar.jsx | 3 +- zroc-ui/src/components/layout/TopBar.jsx | 9 +- zroc-ui/src/pages/Planner.jsx | 473 ++++++++++++++++++++++ zroc-ui/src/styles/index.css | 24 ++ zroc-ui/tailwind.config.js | 16 +- 10 files changed, 592 insertions(+), 10 deletions(-) create mode 100644 zroc-ui/src/api/planner.js create mode 100644 zroc-ui/src/auth/ThemeContext.jsx create mode 100644 zroc-ui/src/pages/Planner.jsx diff --git a/zroc-ui/package.json b/zroc-ui/package.json index 13c682b..3c05be9 100644 --- a/zroc-ui/package.json +++ b/zroc-ui/package.json @@ -11,6 +11,7 @@ "dependencies": { "@tanstack/react-query": "^5.40.0", "clsx": "^2.1.1", + "jspdf": "^4.2.1", "lucide-react": "^0.395.0", "react": "^18.3.1", "react-dom": "^18.3.1", diff --git a/zroc-ui/src/App.jsx b/zroc-ui/src/App.jsx index bc60d8c..8d253e3 100644 --- a/zroc-ui/src/App.jsx +++ b/zroc-ui/src/App.jsx @@ -2,6 +2,7 @@ import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { AuthProvider } from '@/auth/AuthContext'; +import { ThemeProvider } from '@/auth/ThemeContext'; import { ProtectedRoute, AdminRoute } from '@/auth/ProtectedRoute'; import AppShell from '@/components/layout/AppShell'; import Overview from '@/pages/Overview'; @@ -12,6 +13,7 @@ import Storage from '@/pages/Storage'; import UserManagement from '@/pages/Settings/UserManagement'; import VMDetail from '@/pages/VMDetail'; import Placeholder from '@/pages/Placeholder'; +import Planner from '@/pages/Planner'; const queryClient = new QueryClient({ defaultOptions: { @@ -26,6 +28,7 @@ const queryClient = new QueryClient({ export default function App() { return ( + @@ -36,6 +39,7 @@ export default function App() { } /> } /> } /> + } /> } /> + ); } diff --git a/zroc-ui/src/api/planner.js b/zroc-ui/src/api/planner.js new file mode 100644 index 0000000..39096ad --- /dev/null +++ b/zroc-ui/src/api/planner.js @@ -0,0 +1,37 @@ +// src/api/planner.js +// Queries the vcenter_vm_disk_* metrics exposed by zroc-planner collector. +import { instantQuery } from './prometheus'; + +export async function queryPlannerVms() { + const [throughput, iops, latency, provisioned] = await Promise.all([ + instantQuery('vcenter_vm_disk_write_throughput_mbps'), + instantQuery('vcenter_vm_disk_write_iops'), + instantQuery('vcenter_vm_disk_write_latency_ms'), + instantQuery('vcenter_vm_disk_provisioned_gb'), + ]); + + const byMoref = {}; + + const idx = (vec, field, transform = parseFloat) => { + for (const { metric, value } of vec) { + const id = metric.vm_moref || metric.vm_name; + if (!byMoref[id]) byMoref[id] = { + moref: metric.vm_moref || id, + name: metric.vm_name || id, + cluster: metric.cluster || '', + host: metric.host || '', + datacenter: metric.datacenter || '', + }; + byMoref[id][field] = transform(value[1]); + } + }; + + idx(throughput, 'writeThroughputMbps'); + idx(iops, 'writeIops'); + idx(latency, 'writeLatencyMs'); + idx(provisioned, 'provisionedGb'); + + return Object.values(byMoref).sort((a, b) => + (b.writeThroughputMbps ?? 0) - (a.writeThroughputMbps ?? 0) + ); +} diff --git a/zroc-ui/src/auth/AuthContext.jsx b/zroc-ui/src/auth/AuthContext.jsx index 7793aff..f36753c 100644 --- a/zroc-ui/src/auth/AuthContext.jsx +++ b/zroc-ui/src/auth/AuthContext.jsx @@ -8,6 +8,11 @@ export function AuthProvider({ children }) { const [loading, setLoading] = useState(true); const checkSession = useCallback(async () => { + if (import.meta.env.VITE_MOCK_AUTH === 'true') { + setUser({ name: 'Demo User', email: 'demo@zroc.local', role: 'admin' }); + setLoading(false); + return; + } try { const res = await fetch('/api/auth/status', { credentials: 'include' }); if (res.ok) { diff --git a/zroc-ui/src/auth/ThemeContext.jsx b/zroc-ui/src/auth/ThemeContext.jsx new file mode 100644 index 0000000..020c89f --- /dev/null +++ b/zroc-ui/src/auth/ThemeContext.jsx @@ -0,0 +1,29 @@ +// src/auth/ThemeContext.jsx +import { createContext, useContext, useState, useEffect } from 'react'; + +const ThemeContext = createContext(null); + +export function ThemeProvider({ children }) { + const [theme, setTheme] = useState(() => + localStorage.getItem('zroc-theme') || 'dark' + ); + + useEffect(() => { + document.documentElement.setAttribute('data-theme', theme); + localStorage.setItem('zroc-theme', theme); + }, [theme]); + + const toggle = () => setTheme((t) => t === 'dark' ? 'light' : 'dark'); + + return ( + + {children} + + ); +} + +export function useTheme() { + const ctx = useContext(ThemeContext); + if (!ctx) throw new Error('useTheme must be used within ThemeProvider'); + return ctx; +} diff --git a/zroc-ui/src/components/layout/Sidebar.jsx b/zroc-ui/src/components/layout/Sidebar.jsx index 6c23404..1c27e63 100644 --- a/zroc-ui/src/components/layout/Sidebar.jsx +++ b/zroc-ui/src/components/layout/Sidebar.jsx @@ -3,7 +3,7 @@ import { NavLink } from 'react-router-dom'; import { LayoutDashboard, GitFork, Server, Cpu, ShieldAlert, Database, Settings, ChevronLeft, - ChevronRight, Activity, + ChevronRight, Activity, Calculator, } from 'lucide-react'; import { useAuth } from '@/auth/AuthContext'; import clsx from 'clsx'; @@ -41,6 +41,7 @@ const NAV_ITEMS = [ { to: '/vras', label: 'VRAs', icon: Cpu }, { to: '/encryption', label: 'Encryption', icon: ShieldAlert }, { to: '/storage', label: 'Storage', icon: Database }, + { to: '/planner', label: 'Planner', icon: Calculator }, ]; const ADMIN_ITEMS = [ diff --git a/zroc-ui/src/components/layout/TopBar.jsx b/zroc-ui/src/components/layout/TopBar.jsx index c003a07..86cec39 100644 --- a/zroc-ui/src/components/layout/TopBar.jsx +++ b/zroc-ui/src/components/layout/TopBar.jsx @@ -1,8 +1,9 @@ // src/components/layout/TopBar.jsx import { useState, useEffect, useRef } from 'react'; import { useLocation } from 'react-router-dom'; -import { Menu, RefreshCw, ChevronDown, LogOut } from 'lucide-react'; +import { Menu, RefreshCw, ChevronDown, LogOut, Sun, Moon } from 'lucide-react'; import { useAuth } from '@/auth/AuthContext'; +import { useTheme } from '@/auth/ThemeContext'; import { useQueryClient } from '@tanstack/react-query'; import clsx from 'clsx'; @@ -13,6 +14,7 @@ const PAGE_TITLES = { '/vras': 'VRA Infrastructure', '/encryption': 'Encryption Detection', '/storage': 'Storage & Datastores', + '/planner': 'DR Capacity Planner', '/settings/users': 'User Management', '/settings': 'Settings', }; @@ -64,6 +66,7 @@ function UserMenu({ user, onLogout }) { export default function TopBar({ sidebarOpen, onMenuToggle }) { const { user, logout } = useAuth(); + const { theme, toggle: toggleTheme } = useTheme(); const location = useLocation(); const queryClient = useQueryClient(); const [refreshing, setRefreshing] = useState(false); @@ -91,6 +94,10 @@ export default function TopBar({ sidebarOpen, onMenuToggle }) { className="p-1.5 rounded text-text-muted hover:text-accent hover:bg-accent/10 transition-all duration-150"> + {user && } diff --git a/zroc-ui/src/pages/Planner.jsx b/zroc-ui/src/pages/Planner.jsx new file mode 100644 index 0000000..bb282fe --- /dev/null +++ b/zroc-ui/src/pages/Planner.jsx @@ -0,0 +1,473 @@ +// src/pages/Planner.jsx — DR capacity planner +import { useState, useMemo } from 'react'; +import { useQuery } from '@tanstack/react-query'; +import { Calculator, HardDrive, Wifi, Database, Download, Search, FileText } from 'lucide-react'; +import { queryPlannerVms } from '@/api/planner'; +import clsx from 'clsx'; +import { jsPDF } from 'jspdf'; + +const REFRESH = 60_000; + +// ── Helpers ────────────────────────────────────────────────────────────────── + +function fmtGb(gb) { + if (gb == null || isNaN(gb)) return '—'; + if (gb >= 1024) return `${(gb / 1024).toFixed(2)} TB`; + return `${gb.toFixed(1)} GB`; +} + +function fmtMbps(mbps) { + if (mbps == null || isNaN(mbps)) return '—'; + if (mbps >= 1000) return `${(mbps / 1000).toFixed(2)} Gbps`; + return `${mbps.toFixed(1)} Mbps`; +} + +const JOURNAL_OPTIONS = [ + { label: '1 hour', seconds: 3600 }, + { label: '4 hours', seconds: 14400 }, + { label: '8 hours', seconds: 28800 }, + ...Array.from({ length: 30 }, (_, i) => ({ + label: i === 0 ? '1 day' : `${i + 1} days`, + seconds: (i + 1) * 86400, + })), +]; + +// ── Result card ─────────────────────────────────────────────────────────────── + +function ResultCard({ icon: Icon, label, value, sub, color = 'accent' }) { + return ( +
+
+ +
+
+

{label}

+

{value}

+ {sub &&

{sub}

} +
+
+ ); +} + +// ── VM row ──────────────────────────────────────────────────────────────────── + +function VmRow({ vm, selected, onToggle }) { + return ( + + + e.stopPropagation()} + className="accent-accent" + /> + + {vm.name} + {vm.cluster || '—'} + {vm.datacenter || '—'} + {fmtGb(vm.provisionedGb)} + {fmtMbps(vm.writeThroughputMbps)} + + {vm.writeIops != null ? vm.writeIops.toFixed(0) : '—'} + + + ); +} + +// ── Mock data for preview ───────────────────────────────────────────────────── + +const MOCK_VMS = [ + { moref: 'vm-101', name: 'web-prod-01', cluster: 'Cluster-01', datacenter: 'DC-East', provisionedGb: 120, writeThroughputMbps: 45.2, writeIops: 1820, writeLatencyMs: 3.1 }, + { moref: 'vm-102', name: 'db-prod-01', cluster: 'Cluster-01', datacenter: 'DC-East', provisionedGb: 2048, writeThroughputMbps: 312.8, writeIops: 12400, writeLatencyMs: 1.8 }, + { moref: 'vm-103', name: 'db-prod-02', cluster: 'Cluster-01', datacenter: 'DC-East', provisionedGb: 2048, writeThroughputMbps: 287.4, writeIops: 11200, writeLatencyMs: 2.0 }, + { moref: 'vm-104', name: 'app-prod-01', cluster: 'Cluster-02', datacenter: 'DC-East', provisionedGb: 256, writeThroughputMbps: 18.6, writeIops: 640, writeLatencyMs: 4.2 }, + { moref: 'vm-105', name: 'app-prod-02', cluster: 'Cluster-02', datacenter: 'DC-East', provisionedGb: 256, writeThroughputMbps: 21.3, writeIops: 780, writeLatencyMs: 3.9 }, + { moref: 'vm-106', name: 'cache-01', cluster: 'Cluster-02', datacenter: 'DC-East', provisionedGb: 512, writeThroughputMbps: 8.1, writeIops: 310, writeLatencyMs: 5.5 }, + { moref: 'vm-107', name: 'file-srv-01', cluster: 'Cluster-03', datacenter: 'DC-West', provisionedGb: 4096, writeThroughputMbps: 92.0, writeIops: 3200, writeLatencyMs: 6.1 }, + { moref: 'vm-108', name: 'infra-dc-01', cluster: 'Cluster-03', datacenter: 'DC-West', provisionedGb: 80, writeThroughputMbps: 2.4, writeIops: 120, writeLatencyMs: 8.2 }, + { moref: 'vm-109', name: 'backup-srv-01',cluster: 'Cluster-03', datacenter: 'DC-West', provisionedGb: 8192, writeThroughputMbps: 180.0,writeIops: 5600, writeLatencyMs: 12.0 }, + { moref: 'vm-110', name: 'mon-01', cluster: 'Cluster-01', datacenter: 'DC-East', provisionedGb: 100, writeThroughputMbps: 1.2, writeIops: 55, writeLatencyMs: 9.0 }, +]; + +// ── Main page ───────────────────────────────────────────────────────────────── + +export default function Planner() { + const [selected, setSelected] = useState(new Set()); + const [journalIdx, setJournalIdx] = useState(3); // default: 1 day + const [compression, setCompression] = useState(50); // default: 50% + const [search, setSearch] = useState(''); + + const isMock = import.meta.env.VITE_MOCK_AUTH === 'true'; + + const { data: liveVms = [], isLoading } = useQuery({ + queryKey: ['planner-vms'], + queryFn: queryPlannerVms, + refetchInterval: REFRESH, + enabled: !isMock, + }); + + const vms = isMock ? MOCK_VMS : liveVms; + + const filtered = useMemo(() => + vms.filter((vm) => + !search || vm.name.toLowerCase().includes(search.toLowerCase()) || + vm.cluster.toLowerCase().includes(search.toLowerCase()) || + vm.datacenter.toLowerCase().includes(search.toLowerCase()) + ), + [vms, search] + ); + + const toggle = (moref) => + setSelected((prev) => { + const next = new Set(prev); + next.has(moref) ? next.delete(moref) : next.add(moref); + return next; + }); + + const toggleAll = () => { + if (selected.size === filtered.length) { + setSelected(new Set()); + } else { + setSelected(new Set(filtered.map((v) => v.moref))); + } + }; + + const selectedVms = vms.filter((v) => selected.has(v.moref)); + const journalSec = JOURNAL_OPTIONS[journalIdx].seconds; + const ratio = compression / 100; + + // ── Calculations ─────────────────────────────────────────────────────────── + const totalThroughputMbps = selectedVms.reduce((s, v) => s + (v.writeThroughputMbps ?? 0), 0); + const totalProvisionedGb = selectedVms.reduce((s, v) => s + (v.provisionedGb ?? 0), 0); + + const bwRequiredMbps = totalThroughputMbps * (1 - ratio); + const journalStorageGb = (totalThroughputMbps * (1 - ratio)) * (journalSec / 1024); // MB/s → GB over period + const mirrorStorageGb = totalProvisionedGb; + const totalDrStorageGb = journalStorageGb + mirrorStorageGb; + + // ── Export ───────────────────────────────────────────────────────────────── + const exportCsv = () => { + const rows = [ + ['VM Name', 'Cluster', 'Datacenter', 'Provisioned (GB)', 'Write Throughput (Mbps)', 'Write IOPS'], + ...selectedVms.map((v) => [ + v.name, v.cluster, v.datacenter, + (v.provisionedGb ?? 0).toFixed(1), + (v.writeThroughputMbps ?? 0).toFixed(2), + (v.writeIops ?? 0).toFixed(0), + ]), + [], + ['--- Summary ---'], + ['Journal Retention', JOURNAL_OPTIONS[journalIdx].label], + ['Compression', `${compression}%`], + ['Bandwidth Required', `${fmtMbps(bwRequiredMbps)}`], + ['Journal Storage', `${fmtGb(journalStorageGb)}`], + ['Mirror Storage', `${fmtGb(mirrorStorageGb)}`], + ['Total DR Storage', `${fmtGb(totalDrStorageGb)}`], + ]; + const csv = rows.map((r) => r.map((c) => `"${c}"`).join(',')).join('\n'); + const blob = new Blob([csv], { type: 'text/csv' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; a.download = 'zroc-planner-report.csv'; a.click(); + URL.revokeObjectURL(url); + }; + + const exportPdf = () => { + const doc = new jsPDF({ orientation: 'portrait', unit: 'mm', format: 'a4' }); + const margin = 15; + const pageW = 210; + const colW = pageW - margin * 2; + let y = margin; + + // Title + doc.setFontSize(18); + doc.setFont('helvetica', 'bold'); + doc.text('zROC — DR Capacity Planner Report', margin, y); + y += 8; + doc.setFontSize(9); + doc.setFont('helvetica', 'normal'); + doc.setTextColor(100); + doc.text(`Generated: ${new Date().toLocaleString()}`, margin, y); + y += 10; + + // Summary box + doc.setDrawColor(14, 165, 233); + doc.setFillColor(240, 248, 255); + doc.roundedRect(margin, y, colW, 40, 2, 2, 'FD'); + doc.setTextColor(0); + doc.setFontSize(10); + doc.setFont('helvetica', 'bold'); + doc.text('Planning Parameters', margin + 4, y + 7); + doc.setFont('helvetica', 'normal'); + doc.setFontSize(9); + const params = [ + ['VMs selected', `${selected.size}`], + ['Journal retention', JOURNAL_OPTIONS[journalIdx].label], + ['WAN compression', `${compression}%`], + ]; + params.forEach(([label, val], i) => { + doc.setTextColor(80); doc.text(label, margin + 4, y + 15 + i * 7); + doc.setTextColor(0); doc.text(val, margin + 60, y + 15 + i * 7); + }); + y += 48; + + // Results + doc.setFont('helvetica', 'bold'); + doc.setFontSize(11); + doc.text('Capacity Estimates', margin, y); + y += 6; + const results = [ + ['Bandwidth Required', fmtMbps(bwRequiredMbps), `Raw ${fmtMbps(totalThroughputMbps)} × ${100 - compression}%`], + ['Journal Storage', fmtGb(journalStorageGb), `${JOURNAL_OPTIONS[journalIdx].label} at ${fmtMbps(bwRequiredMbps)}`], + ['Mirror Storage', fmtGb(mirrorStorageGb), 'Full copy of selected VM disks'], + ['Total DR Storage Footprint', fmtGb(totalDrStorageGb), 'Journal + Mirror combined'], + ]; + results.forEach(([label, val, note]) => { + doc.setFontSize(9); doc.setFont('helvetica', 'normal'); doc.setTextColor(80); + doc.text(label, margin, y); + doc.setFont('helvetica', 'bold'); doc.setTextColor(0); doc.setFontSize(12); + doc.text(val, margin + 70, y); + doc.setFont('helvetica', 'normal'); doc.setFontSize(8); doc.setTextColor(120); + doc.text(note, margin + 110, y); + y += 9; + }); + y += 6; + + // VM table header + doc.setFont('helvetica', 'bold'); + doc.setFontSize(11); + doc.setTextColor(0); + doc.text('Selected VMs', margin, y); + y += 5; + doc.setFillColor(230, 240, 255); + doc.rect(margin, y, colW, 6, 'F'); + doc.setFontSize(8); + ['VM Name', 'Cluster', 'Datacenter', 'Disk (GB)', 'Write BW', 'IOPS'].forEach((h, i) => { + doc.text(h, margin + [0, 50, 85, 120, 143, 163][i], y + 4); + }); + y += 7; + + // VM rows + doc.setFont('helvetica', 'normal'); + selectedVms.forEach((vm, idx) => { + if (y > 270) { doc.addPage(); y = margin; } + if (idx % 2 === 0) { doc.setFillColor(248, 250, 252); doc.rect(margin, y - 1, colW, 6, 'F'); } + doc.setTextColor(0); + doc.setFontSize(8); + [ + vm.name.slice(0, 24), + (vm.cluster || '—').slice(0, 16), + (vm.datacenter || '—').slice(0, 14), + (vm.provisionedGb ?? 0).toFixed(1), + fmtMbps(vm.writeThroughputMbps), + (vm.writeIops ?? 0).toFixed(0), + ].forEach((val, i) => doc.text(val, margin + [0, 50, 85, 120, 143, 163][i], y + 4)); + y += 6; + }); + + doc.save('zroc-planner-report.pdf'); + }; + + return ( +
+ {/* Header */} +
+
+ +

DR Capacity Planner

+
+
+ + +
+
+ +
+ {/* Left — VM selector */} +
+
+

+ Select VMs to model +

+ + {selected.size} / {vms.length} selected + +
+ + {/* Search */} +
+
+ + setSearch(e.target.value)} + placeholder="Filter VMs…" + className="w-full bg-raised border border-border rounded-md pl-7 pr-3 py-1.5 text-xs font-mono text-text-primary placeholder:text-text-muted focus:outline-none focus:border-accent" + /> +
+
+ +
+ + + + + + + + + + + + + + {isLoading && !isMock ? ( + + ) : filtered.length === 0 ? ( + + ) : ( + filtered.map((vm) => ( + toggle(vm.moref)} + /> + )) + )} + +
+ 0 && selected.size === filtered.length} + onChange={toggleAll} + className="accent-accent" + /> + VMClusterDatacenterDisk SizeWrite BWWrite IOPS
Loading VMs…
No VMs found
+
+
+ + {/* Right — inputs + results */} +
+ {/* Inputs */} +
+

+ Planning Inputs +

+ + {/* Journal retention */} +
+
+ + + {JOURNAL_OPTIONS[journalIdx].label} + +
+ setJournalIdx(Number(e.target.value))} + className="w-full accent-accent" + /> +
+ 1h8h7d15d30d +
+
+ + {/* Compression */} +
+
+ + {compression}% +
+ setCompression(Number(e.target.value))} + className="w-full accent-accent" + /> +
+ 0%40%80% +
+
+
+ + {/* Results */} +
+ {selected.size === 0 && ( +

+ Select VMs to see estimates +

+ )} + + + +
+

Total DR Storage Footprint

+

+ {fmtGb(totalDrStorageGb)} +

+

+ Journal + Mirror across {selected.size} VM{selected.size !== 1 ? 's' : ''} +

+
+
+
+
+
+ ); +} diff --git a/zroc-ui/src/styles/index.css b/zroc-ui/src/styles/index.css index e1af472..efa8a9e 100644 --- a/zroc-ui/src/styles/index.css +++ b/zroc-ui/src/styles/index.css @@ -3,6 +3,30 @@ @tailwind components; @tailwind utilities; +/* ── Theme tokens (space-separated RGB for Tailwind opacity support) ── */ +:root, +[data-theme="dark"] { + --color-canvas: 8 13 26; + --color-surface: 13 21 38; + --color-raised: 19 31 53; + --color-border: 30 45 71; + --color-border-bright: 42 64 102; + --color-text-primary: 226 232 240; + --color-text-secondary: 124 147 181; + --color-text-muted: 74 96 128; +} + +[data-theme="light"] { + --color-canvas: 240 244 248; + --color-surface: 255 255 255; + --color-raised: 248 250 252; + --color-border: 226 232 240; + --color-border-bright: 203 213 225; + --color-text-primary: 15 23 42; + --color-text-secondary: 71 85 105; + --color-text-muted: 148 163 184; +} + @layer base { html { @apply scroll-smooth; } diff --git a/zroc-ui/tailwind.config.js b/zroc-ui/tailwind.config.js index 7a048ef..58e5b4a 100644 --- a/zroc-ui/tailwind.config.js +++ b/zroc-ui/tailwind.config.js @@ -5,11 +5,11 @@ export default { theme: { extend: { colors: { - canvas: '#080d1a', - surface: '#0d1526', - raised: '#131f35', - border: '#1e2d47', - 'border-bright': '#2a4066', + canvas: 'rgb(var(--color-canvas) / )', + surface: 'rgb(var(--color-surface) / )', + raised: 'rgb(var(--color-raised) / )', + border: 'rgb(var(--color-border) / )', + 'border-bright': 'rgb(var(--color-border-bright) / )', accent: { DEFAULT: '#0ea5e9', dim: '#0284c7', @@ -20,9 +20,9 @@ export default { warn: '#f59e0b', crit: '#ef4444', info: '#818cf8', - 'text-primary': '#e2e8f0', - 'text-secondary': '#7c93b5', - 'text-muted': '#4a6080', + 'text-primary': 'rgb(var(--color-text-primary) / )', + 'text-secondary': 'rgb(var(--color-text-secondary) / )', + 'text-muted': 'rgb(var(--color-text-muted) / )', }, fontFamily: { mono: ['"IBM Plex Mono"', 'monospace'],