feat: interactive site redesign with dot matrix effects and component polish

This commit is contained in:
Matt
2026-08-25 13:08:21 +00:00
parent efbdf2356f
commit 563d9c0eeb
33 changed files with 969 additions and 505 deletions
+20 -20
View File
@@ -1,4 +1,5 @@
---
import DotMatrixText from './DotMatrixText.astro'
const navItems = [
{ label: 'Dashboard', icon: 'grid', active: true },
{ label: 'Nodes', icon: 'server', active: false },
@@ -60,7 +61,7 @@ const tasks = [
{ chip: { label: 'RUNNING', cls: 'border-warning/30 bg-warning/10 text-warning' }, text: 'start VM 100', node: 'pve1', time: '12s ago' },
{ chip: { label: 'OK', cls: 'border-success/25 bg-success/10 text-success' }, text: 'backup VM 101 → local-lvm', node: 'pve2', time: '3m ago' },
{ chip: { label: 'OK', cls: 'border-success/25 bg-success/10 text-success' }, text: 'migrate VM 102', node: 'pve1 → pve3', time: '18m ago' },
{ chip: { label: 'DONE', cls: 'border-white/[0.06] bg-muted text-muted-foreground' }, text: 'update apt', node: 'pve3', time: '1h ago' },
{ chip: { label: 'DONE', cls: 'border-dotted border-white/10 bg-muted text-muted-foreground' }, text: 'update apt', node: 'pve3', time: '1h ago' },
]
const gauges = [
{ label: 'CPU', icon: 'cpu', percent: '30', width: '30', detail: '7.3 / 24 cores' },
@@ -97,14 +98,14 @@ const quickActions = [
</defs>
</svg>
<div class="overflow-hidden rounded-xl border border-white/[0.08] bg-card shadow-card">
<div class="relative flex h-9 items-center border-b border-white/[0.06] bg-background">
<div class="overflow-hidden rounded-xl border border-dotted border-white/10 bg-card shadow-card">
<div class="relative flex h-9 items-center border-b border-dotted border-white/10 bg-background">
<div class="absolute left-4 flex items-center gap-1.5" aria-hidden="true">
<span class="h-2.5 w-2.5 rounded-full border border-white/[0.06] bg-muted-foreground/40"></span>
<span class="h-2.5 w-2.5 rounded-full border border-white/[0.06] bg-muted-foreground/40"></span>
<span class="h-2.5 w-2.5 rounded-full border border-white/[0.06] bg-muted-foreground/40"></span>
<span class="h-2.5 w-2.5 rounded-full border border-dotted border-white/10 bg-muted-foreground/40"></span>
<span class="h-2.5 w-2.5 rounded-full border border-dotted border-white/10 bg-muted-foreground/40"></span>
<span class="h-2.5 w-2.5 rounded-full border border-dotted border-white/10 bg-muted-foreground/40"></span>
</div>
<p class="w-full text-center font-mono text-[10px] tabular-nums text-muted-foreground">Clustri — Ops Console</p>
<div class="w-full flex justify-center"><DotMatrixText text="CLUSTRI" size="xs" dotClass="text-muted-foreground" /></div>
</div>
<div class="flex items-center gap-2 border-b border-warning/25 bg-warning/10 px-4 py-1.5">
@@ -114,14 +115,13 @@ const quickActions = [
</div>
<div class="flex">
<aside class="hidden w-52 shrink-0 flex-col border-r border-white/[0.06] bg-card sm:flex">
<div class="flex h-14 shrink-0 items-center gap-2.5 border-b border-white/[0.06] px-4">
<aside class="hidden w-52 shrink-0 flex-col border-r border-dotted border-white/10 bg-card sm:flex">
<div class="flex h-14 shrink-0 items-center gap-2.5 border-b border-dotted border-white/10 px-4">
<div class="flex h-7 w-7 shrink-0 items-center justify-center rounded-md bg-primary/15 text-primary">
<svg class="h-4 w-4" aria-hidden="true"><use href="#i-hex" /></svg>
</div>
<div class="min-w-0 leading-tight">
<p class="truncate text-sm font-semibold tracking-tight text-foreground">Clustri</p>
<p class="font-mono text-[9px] font-medium uppercase tracking-[0.14em] text-muted-foreground">Ops Console</p>
<DotMatrixText text="CLUSTRI" size="xs" dotClass="text-foreground" />
</div>
</div>
<div class="min-h-0 flex-1 p-2">
@@ -146,7 +146,7 @@ const quickActions = [
</div>
<p class="ml-7 font-mono text-[10px] text-muted-foreground">Offline</p>
</div>
<p class="px-3 pb-1 pt-3 font-mono text-[10px] font-medium uppercase tracking-[0.14em] text-muted-foreground">Cluster nodes</p>
<div class="px-3 pb-1 pt-3"><DotMatrixText text="CLUSTER NODES" size="xs" dotClass="text-muted-foreground" /></div>
<div class="space-y-0.5">
{clusterNodes.map((n) => (
<div class="flex w-full items-center gap-2.5 rounded-md px-3 py-1.5">
@@ -158,11 +158,11 @@ const quickActions = [
</div>
</div>
</div>
<div class="space-y-1 border-t border-white/[0.06] p-2">
<div class="space-y-1 border-t border-dotted border-white/10 p-2">
<button type="button" class="flex w-full items-center gap-2.5 rounded-md px-3 py-1.5 text-[12px] font-medium text-muted-foreground transition-colors hover:bg-accent/60 hover:text-foreground">
<svg class="h-4 w-4 shrink-0" aria-hidden="true"><use href="#i-gear" /></svg><span>Settings</span>
</button>
<button type="button" class="flex w-full items-center gap-2 rounded-md border border-white/[0.06] bg-background px-3 py-1.5 text-[12px] font-medium text-muted-foreground shadow-sm transition-colors hover:bg-accent hover:text-accent-foreground">
<button type="button" class="flex w-full items-center gap-2 rounded-md border border-dotted border-white/10 bg-background px-3 py-1.5 text-[12px] font-medium text-muted-foreground shadow-sm transition-colors hover:bg-accent hover:text-accent-foreground">
<svg class="h-3.5 w-3.5 shrink-0" aria-hidden="true"><use href="#i-plus" /></svg><span>Add Connection</span>
</button>
</div>
@@ -171,7 +171,7 @@ const quickActions = [
<main class="min-w-0 flex-1 bg-background p-4">
<div class="space-y-4">
<div class="flex flex-wrap items-center justify-between gap-2">
<h2 class="text-base font-semibold tracking-tight">Overview</h2>
<DotMatrixText text="OVERVIEW" size="sm" dotClass="text-foreground" />
<div class="flex flex-wrap items-center gap-2">
<span class="font-mono text-[10px] tabular-nums text-muted-foreground">home-lab · 192.168.1.10:8006</span>
<span class="font-mono text-[10px] tabular-nums text-muted-foreground">3 nodes · 2 online</span>
@@ -181,7 +181,7 @@ const quickActions = [
<div class="grid grid-cols-1 gap-3 sm:grid-cols-2">
{nodeCards.map((node) => (
<article class="rounded-md border border-white/[0.06] bg-card p-3">
<article class="rounded-md border border-dotted border-white/10 bg-card p-3">
<div class="flex items-center justify-between gap-2">
<div class="flex min-w-0 items-center gap-2">
<span class={`h-1.5 w-1.5 shrink-0 rounded-full ${node.dot}`} aria-hidden="true"></span>
@@ -216,8 +216,8 @@ const quickActions = [
</div>
<div class="grid grid-cols-1 gap-3 sm:grid-cols-5">
<section class="rounded-md border border-white/[0.06] bg-card sm:col-span-3">
<header class="flex items-center gap-2 border-b border-white/[0.06] px-3 py-2">
<section class="rounded-md border border-dotted border-white/10 bg-card sm:col-span-3">
<header class="flex items-center gap-2 border-b border-dotted border-white/10 px-3 py-2">
<svg class="h-3.5 w-3.5 shrink-0 text-muted-foreground" aria-hidden="true"><use href="#i-activity" /></svg>
<h3 class="font-mono text-[10px] font-medium uppercase tracking-[0.14em] text-muted-foreground">Task activity</h3>
<span class="ml-auto h-1.5 w-1.5 animate-pulse rounded-full bg-success" aria-hidden="true"></span>
@@ -237,7 +237,7 @@ const quickActions = [
<div class="space-y-3 sm:col-span-2">
{gauges.map((gauge) => (
<section class="rounded-md border border-white/[0.06] bg-card p-3">
<section class="rounded-md border border-dotted border-white/10 bg-card p-3">
<div class="flex items-center gap-1.5">
<svg class="h-3.5 w-3.5 text-muted-foreground" aria-hidden="true"><use href={`#i-${gauge.icon}`} /></svg>
<span class="font-mono text-[9px] font-medium uppercase tracking-[0.14em] text-muted-foreground">{gauge.label}</span>
@@ -256,7 +256,7 @@ const quickActions = [
<div class="flex flex-wrap items-center gap-2">
{quickActions.map((action) => (
<button type="button" class="flex items-center gap-1.5 rounded border border-white/[0.06] px-3 py-1.5 text-[11px] font-medium text-muted-foreground transition-colors hover:bg-accent/60 hover:text-foreground">
<button type="button" class="flex items-center gap-1.5 rounded border border-dotted border-white/10 px-3 py-1.5 text-[11px] font-medium text-muted-foreground transition-colors hover:bg-accent/60 hover:text-foreground">
<svg class="h-3 w-3" aria-hidden="true"><use href={`#i-${action.icon}`} /></svg><span>{action.label}</span>
</button>
))}
+16 -13
View File
@@ -1,45 +1,48 @@
<section id="architecture" aria-labelledby="arch-title" class="scroll-mt-24 border-t border-white/[0.06] py-20 sm:py-28">
---
import DotMatrixText from './DotMatrixText.astro'
---
<section id="architecture" aria-labelledby="arch-title" class="scroll-mt-24 border-t border-dotted border-white/10 py-20 sm:py-28">
<div class="mx-auto max-w-7xl px-6 lg:px-8">
<div class="max-w-2xl" data-reveal>
<p class="font-mono text-[11px] uppercase tracking-[0.18em] text-primary">Architecture</p>
<DotMatrixText text="ARCHITECTURE" size="xs" dotClass="text-primary" />
<h2 id="arch-title" class="mt-4 text-3xl font-semibold tracking-[-0.03em] text-foreground sm:text-4xl">One proxy between you and the API.</h2>
<p class="mt-4 text-[15px] leading-relaxed text-muted-foreground">The frontend calls Rust commands over Tauri IPC. The backend proxies them to the Proxmox API over HTTPS.</p>
</div>
<div class="mt-10 grid gap-3 lg:grid-cols-[1fr_auto_1fr_auto_1fr] lg:items-stretch" data-stagger>
<div class="rounded-xl border border-white/[0.06] bg-card p-5" data-stagger-child>
<div class="flex items-start justify-between gap-4"><p class="text-sm font-semibold text-foreground">React frontend</p><span class="font-mono text-[10px] text-muted-foreground">01</span></div>
<div class="rounded-xl border border-dotted border-white/10 bg-card p-5 dot-grid" data-stagger-child>
<div class="flex items-start justify-between gap-4"><p class="text-sm font-semibold text-foreground">React frontend</p><DotMatrixText text="01" size="xs" dotClass="text-muted-foreground" /></div>
<div class="mt-2 space-y-1 font-mono text-[11px] text-muted-foreground"><p>TanStack Query · Zustand</p><p>Geist · JetBrains Mono</p><p>React 19</p></div>
</div>
<div class="hidden flex-col items-center justify-center gap-1 lg:flex flow-arrow" aria-hidden="true"><span class="text-sm text-primary">→</span><span class="font-mono text-[9px] text-muted-foreground">tauri invoke</span></div>
<div class="flex items-center justify-center gap-1.5 py-1 lg:hidden flow-arrow" aria-hidden="true"><span class="text-sm text-primary">↓</span><span class="font-mono text-[9px] text-muted-foreground">tauri invoke</span></div>
<div class="rounded-xl border border-primary/25 bg-primary/[0.07] p-5" data-stagger-child>
<div class="flex items-start justify-between gap-4"><p class="text-sm font-semibold text-primary">Rust backend</p><span class="font-mono text-[10px] text-muted-foreground">02</span></div>
<div class="rounded-xl border border-dotted border-primary/30 bg-primary/[0.07] p-5 dot-grid" data-stagger-child>
<div class="flex items-start justify-between gap-4"><p class="text-sm font-semibold text-primary">Rust backend</p><DotMatrixText text="02" size="xs" dotClass="text-primary" /></div>
<div class="mt-2 space-y-1 font-mono text-[11px] text-primary/75"><p>ConnectionManager</p><p>endpoint rotation · retry</p><p>TOFU cert pinning</p><p>OS keyring</p></div>
</div>
<div class="hidden flex-col items-center justify-center gap-1 lg:flex flow-arrow" aria-hidden="true"><span class="text-sm text-primary">→</span><span class="font-mono text-[9px] text-muted-foreground">HTTPS</span></div>
<div class="flex items-center justify-center gap-1.5 py-1 lg:hidden flow-arrow" aria-hidden="true"><span class="text-sm text-primary">↓</span><span class="font-mono text-[9px] text-muted-foreground">HTTPS</span></div>
<div class="rounded-xl border border-white/[0.06] bg-card p-5" data-stagger-child>
<div class="flex items-start justify-between gap-4"><p class="text-sm font-semibold text-foreground">Proxmox API</p><span class="font-mono text-[10px] text-muted-foreground">03</span></div>
<div class="rounded-xl border border-dotted border-white/10 bg-card p-5 dot-grid" data-stagger-child>
<div class="flex items-start justify-between gap-4"><p class="text-sm font-semibold text-foreground">Proxmox API</p><DotMatrixText text="03" size="xs" dotClass="text-muted-foreground" /></div>
<div class="mt-2 space-y-1 font-mono text-[11px] text-muted-foreground"><p>/api2/json</p><p>nodes · cluster · vms</p><p>self-signed TLS</p></div>
</div>
</div>
<div class="mt-8 grid gap-6 lg:grid-cols-2">
<div class="overflow-hidden rounded-xl border border-white/[0.06] bg-card" data-reveal>
<div class="border-b border-white/[0.06] bg-background px-4 py-2.5 font-mono text-[10px] text-muted-foreground">POST /nodes/pve/qemu/100/status/start</div>
<div class="overflow-hidden rounded-xl border border-dotted border-white/10 bg-card" data-reveal>
<div class="border-b border-dotted border-white/10 bg-background px-4 py-2.5 font-mono text-[10px] text-muted-foreground">POST /nodes/pve/qemu/100/status/start</div>
<div class="px-4 py-4">
<p class="truncate font-mono text-[13px] tabular-nums text-foreground">https://192.168.1.10:8006/api2/json/nodes/pve/qemu/100/status/start</p>
<div class="mt-3 flex flex-wrap gap-2">
<span class="rounded border border-white/[0.08] bg-muted px-2 py-1 font-mono text-[10px] text-muted-foreground">PVEAPIToken</span>
<span class="rounded border border-white/[0.08] bg-muted px-2 py-1 font-mono text-[10px] text-muted-foreground">PVEAuthCookie + CSRFPreventionToken</span>
<span class="rounded border border-dotted border-white/10 bg-muted px-2 py-1 font-mono text-[10px] text-muted-foreground">PVEAPIToken</span>
<span class="rounded border border-dotted border-white/10 bg-muted px-2 py-1 font-mono text-[10px] text-muted-foreground">PVEAuthCookie + CSRFPreventionToken</span>
</div>
</div>
</div>
<div class="py-2" data-reveal data-reveal-delay="100">
<p class="font-mono text-[10px] uppercase tracking-[0.08em] text-muted-foreground">Failover model</p>
<DotMatrixText text="FAILOVER MODEL" size="xs" dotClass="text-muted-foreground" />
<p class="mt-3 text-sm leading-relaxed text-muted-foreground">Ordered, deduped endpoints: primary → fallbacks → discovered nodes. Connect / timeout / DNS / transport failure → retry next endpoint.</p>
<div class="mt-4 flex gap-4 font-mono text-[10px]">
<span class="inline-flex items-center gap-1.5"><span class="h-1.5 w-1.5 rounded-full bg-success"></span> connected</span>
+9 -4
View File
@@ -1,11 +1,16 @@
---
import DotMatrixText from './DotMatrixText.astro'
---
<div id="cmdk-overlay" class="cmdk-overlay fixed inset-0 z-[100] hidden items-start justify-center px-4 pt-[20vh]" role="dialog" aria-modal="true" aria-label="Command palette">
<div id="cmdk-panel" class="cmdk-panel w-full max-w-[520px] overflow-hidden rounded-xl border border-white/[0.08] bg-card">
<div class="flex items-center gap-3 border-b border-white/[0.06] px-4">
<div id="cmdk-panel" class="cmdk-panel w-full max-w-[520px] overflow-hidden rounded-xl border border-dotted border-white/10 bg-card dot-grid">
<div class="flex items-center gap-3 border-b border-dotted border-white/10 px-4">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="h-4 w-4 shrink-0 text-muted-foreground" aria-hidden="true"><circle cx="11" cy="11" r="7"/><path d="m20 20-3.5-3.5"/></svg>
<input id="cmdk-input" type="text" placeholder="Type a command…" autocomplete="off" spellcheck="false" class="h-12 w-full bg-transparent font-mono text-sm text-foreground placeholder:text-muted-foreground/60 focus:outline-none" />
<kbd class="hidden rounded border border-white/[0.08] bg-white/[0.04] px-1.5 py-0.5 font-mono text-[10px] text-muted-foreground sm:inline">ESC</kbd>
<kbd class="hidden rounded border border-dotted border-white/10 bg-white/[0.04] px-1.5 py-0.5 font-mono text-[10px] text-muted-foreground sm:inline">ESC</kbd>
</div>
<div id="cmdk-list" class="max-h-[320px] overflow-auto p-2"></div>
<div class="border-t border-white/[0.06] px-3 py-2 font-mono text-[10px] text-muted-foreground/60">↑↓ to navigate · ↵ to select · esc to close</div>
<div class="border-t border-dotted border-white/10 px-3 py-2 flex items-center justify-between">
<DotMatrixText text="UP/DN NAVIGATE / ENTER SELECT / ESC CLOSE" size="xs" dotClass="text-muted-foreground" />
</div>
</div>
</div>
+46
View File
@@ -0,0 +1,46 @@
---
import { GLYPH_COLS, GLYPH_ROWS, getGlyph } from '../lib/dotMatrixGlyphs'
interface Props {
text: string
size?: 'xs' | 'sm' | 'md' | 'lg' | 'xl'
class?: string
dotClass?: string
}
const { text, size = 'sm', class: className, dotClass } = Astro.props as Props
const sizeMap = {
xs: { dot: 1.8, gap: 1.3, charGap: 5 },
sm: { dot: 2.2, gap: 1.5, charGap: 6 },
md: { dot: 2.6, gap: 1.8, charGap: 7 },
lg: { dot: 3.0, gap: 2.0, charGap: 9 },
xl: { dot: 3.8, gap: 2.4, charGap: 12 },
} as const
const { dot, gap, charGap } = sizeMap[size]
const chars = Array.from(text.toUpperCase())
---
<span aria-label={text} role="text" class:list={['inline-flex items-center leading-none', dotClass, className]} style={`gap:${charGap}px`}>
{chars.map((ch) => {
if (ch === ' ') return <span aria-hidden="true" style={`width:${charGap * 1.8}px`} />
const glyph = getGlyph(ch)
if (!glyph) return <span class="font-mono text-[0.7em] leading-none" aria-hidden="true">{ch}</span>
const w = GLYPH_COLS * dot + (GLYPH_COLS - 1) * gap
const h = GLYPH_ROWS * dot + (GLYPH_ROWS - 1) * gap
const r = dot / 2
return (
<svg width={w} height={h} viewBox={`0 0 ${w} ${h}`} aria-hidden="true" class="shrink-0" shape-rendering="geometricPrecision">
{glyph.map((row, y) =>
Array.from(row).map((cell, x) => {
if (cell === ' ') return null
const cx = x * (dot + gap) + r
const cy = y * (dot + gap) + r
return <circle cx={cx} cy={cy} r={r} fill="currentColor" />
}),
)}
</svg>
)
})}
</span>
+12 -9
View File
@@ -1,26 +1,29 @@
<section id="download" class="scroll-mt-24 border-t border-white/[0.06] py-20 sm:py-28">
---
import DotMatrixText from './DotMatrixText.astro'
---
<section id="download" class="scroll-mt-24 border-t border-dotted border-white/10 py-20 sm:py-28">
<div class="mx-auto max-w-3xl px-6 text-center">
<p class="font-mono text-[11px] uppercase tracking-[0.18em] text-primary" data-reveal>Releases</p>
<div data-reveal><DotMatrixText text="RELEASES" size="xs" dotClass="text-primary" /></div>
<h2 class="mt-4 text-3xl font-semibold tracking-[-0.03em] text-foreground sm:text-4xl" data-reveal data-reveal-delay="80">Coming soon.</h2>
<p class="mx-auto mt-4 max-w-[46ch] text-[15px] leading-relaxed text-muted-foreground" data-reveal data-reveal-delay="140">
Clustri is in active development. Packages for Linux, macOS, and Windows are on the way.
</p>
<div class="relative mt-10 overflow-hidden rounded-xl border border-white/[0.07] bg-card p-6 sm:p-8 shadow-card" data-reveal data-reveal-delay="200">
<div class="pointer-events-none absolute inset-0 grid-bg opacity-[0.12]" aria-hidden="true"></div>
<div class="relative mt-10 overflow-hidden rounded-xl border border-dotted border-white/10 bg-card p-6 sm:p-8 shadow-card dot-grid" data-reveal data-reveal-delay="200">
<div class="pointer-events-none absolute inset-0 dot-grid opacity-[0.10]" aria-hidden="true"></div>
<div class="pointer-events-none absolute inset-x-0 top-0 h-px bg-primary/30" aria-hidden="true"></div>
<div class="relative">
<div class="flex flex-wrap items-center justify-center gap-3">
<a href="#" class="rounded-[10px] bg-primary px-6 py-3 text-sm font-semibold text-primary-foreground transition hover:bg-primary/90 active:scale-[0.98]">Get Clustri</a>
<a href="#" class="rounded-[10px] border border-white/[0.08] bg-white/[0.03] px-6 py-3 text-sm font-semibold text-muted-foreground transition hover:border-white/15 hover:text-foreground active:scale-[0.98]">Build from source</a>
<a href="#" class="rounded-[10px] border border-dotted border-white/10 bg-white/[0.03] px-6 py-3 text-sm font-semibold text-muted-foreground transition hover:border-white/15 hover:text-foreground active:scale-[0.98]">Build from source</a>
</div>
<div class="mt-6 flex flex-wrap items-center justify-center gap-2">
<span class="rounded-md border border-white/[0.06] bg-background px-2.5 py-1 font-mono text-[11px] text-muted-foreground">Linux</span>
<span class="rounded-md border border-white/[0.06] bg-background px-2.5 py-1 font-mono text-[11px] text-muted-foreground">macOS</span>
<span class="rounded-md border border-white/[0.06] bg-background px-2.5 py-1 font-mono text-[11px] text-muted-foreground">Windows</span>
<span class="rounded-md border border-dotted border-white/10 bg-background px-2.5 py-1 font-mono text-[11px] text-muted-foreground">Linux</span>
<span class="rounded-md border border-dotted border-white/10 bg-background px-2.5 py-1 font-mono text-[11px] text-muted-foreground">macOS</span>
<span class="rounded-md border border-dotted border-white/10 bg-background px-2.5 py-1 font-mono text-[11px] text-muted-foreground">Windows</span>
</div>
<p class="mt-4 font-mono text-[10px] text-muted-foreground/60">cargo test · 64 tests · MIT · v0.1.0</p>
<div class="mt-4 flex justify-center"><DotMatrixText text="CARGO TEST / 64 TESTS / MIT / V0.1.0" size="xs" dotClass="text-muted-foreground" /></div>
</div>
</div>
</div>
+36 -31
View File
@@ -1,11 +1,13 @@
<section id="failover-playground" aria-labelledby="fp-title" class="relative overflow-hidden border-y border-white/[0.06] bg-card/40">
<div class="pointer-events-none absolute inset-0 grid-bg opacity-[0.18]" aria-hidden="true"></div>
---
import DotMatrixText from './DotMatrixText.astro'
---
<section id="failover-playground" aria-labelledby="fp-title" class="relative overflow-hidden border-y border-dotted border-white/10 bg-card/40">
<div class="pointer-events-none absolute inset-0 dot-grid opacity-[0.12]" aria-hidden="true"></div>
<div class="relative mx-auto max-w-7xl px-6 py-20 lg:px-8 lg:py-28">
<div class="grid gap-10 lg:grid-cols-12 lg:items-start">
<!-- Copy + controls -->
<div class="lg:col-span-5">
<p class="font-mono text-[11px] uppercase tracking-[0.18em] text-primary" data-reveal>Failover — try it</p>
<div data-reveal><DotMatrixText text="FAILOVER / TRY IT" size="xs" dotClass="text-primary" /></div>
<h2 id="fp-title" class="mt-4 text-3xl font-semibold tracking-[-0.03em] text-foreground sm:text-4xl" data-reveal data-reveal-delay="80">
One node dies.<br /><span class="text-muted-foreground">You don't notice.</span>
</h2>
@@ -14,9 +16,9 @@
</p>
<div class="mt-8 flex flex-wrap gap-2" data-reveal data-reveal-delay="180">
<button id="fp-kill-pve1" type="button" class="rounded-md border border-white/[0.08] bg-white/[0.04] px-3 py-2 font-mono text-[11px] font-medium text-foreground transition hover:border-white/15 hover:bg-white/[0.07]">Kill pve1</button>
<button id="fp-kill-pve2" type="button" class="rounded-md border border-white/[0.08] bg-white/[0.04] px-3 py-2 font-mono text-[11px] font-medium text-foreground transition hover:border-white/15 hover:bg-white/[0.07]">Kill pve2</button>
<button id="fp-kill-pve3" type="button" class="rounded-md border border-white/[0.08] bg-white/[0.04] px-3 py-2 font-mono text-[11px] font-medium text-foreground transition hover:border-white/15 hover:bg-white/[0.07]">Kill pve3</button>
<button id="fp-kill-pve1" type="button" class="rounded-md border border-dotted border-white/10 bg-white/[0.04] px-3 py-2 font-mono text-[11px] font-medium text-foreground transition hover:border-white/15 hover:bg-white/[0.07]">Kill pve1</button>
<button id="fp-kill-pve2" type="button" class="rounded-md border border-dotted border-white/10 bg-white/[0.04] px-3 py-2 font-mono text-[11px] font-medium text-foreground transition hover:border-white/15 hover:bg-white/[0.07]">Kill pve2</button>
<button id="fp-kill-pve3" type="button" class="rounded-md border border-dotted border-white/10 bg-white/[0.04] px-3 py-2 font-mono text-[11px] font-medium text-foreground transition hover:border-white/15 hover:bg-white/[0.07]">Kill pve3</button>
<button id="fp-heal" type="button" class="rounded-md bg-primary px-3 py-2 font-mono text-[11px] font-semibold text-primary-foreground transition hover:bg-primary/90">Heal all</button>
</div>
@@ -27,19 +29,19 @@
<span class="ml-1 text-muted-foreground">— tap a node or use the buttons</span>
</div>
<div class="mt-6 rounded-md border border-white/[0.06] bg-background px-3 py-2.5 font-mono text-[11px] leading-relaxed text-muted-foreground" data-reveal data-reveal-delay="260">
<div class="mt-6 rounded-md border border-dotted border-white/10 bg-background px-3 py-2.5 font-mono text-[11px] leading-relaxed text-muted-foreground" data-reveal data-reveal-delay="260">
<span class="text-foreground">How it works:</span> deduped order <span class="text-foreground">primary → fallbacks → discovered</span>. On connect / timeout / DNS / transport failure, retry the next endpoint. Status reflects which endpoint actually answered.
</div>
</div>
<!-- Diagram -->
<div class="lg:col-span-7" data-reveal data-reveal-delay="120">
<div class="relative overflow-hidden rounded-xl border border-white/[0.07] bg-card shadow-card">
<div class="relative overflow-hidden rounded-xl border border-dotted border-white/10 bg-card shadow-card">
<!-- top rule -->
<div id="fp-top-rule" class="h-px w-full bg-primary/30 transition-colors" aria-hidden="true"></div>
<!-- header -->
<div class="flex items-center justify-between gap-3 border-b border-white/[0.06] px-4 py-3">
<div class="flex items-center justify-between gap-3 border-b border-dotted border-white/10 px-4 py-3">
<p class="font-mono text-[10px] tracking-[0.06em] text-muted-foreground">request → endpoints[0] → retry on failure</p>
<span id="fp-status-chip" class="rounded border border-success/25 bg-success/10 px-1.5 py-0.5 font-mono text-[10px] uppercase text-success">Connected</span>
</div>
@@ -47,7 +49,7 @@
<!-- body: rail + nodes -->
<div class="relative flex gap-0">
<!-- packet rail -->
<div class="relative w-[36px] shrink-0 border-r border-white/[0.04] bg-background/40">
<div class="relative w-[36px] shrink-0 border-r border-dotted border-white/10 bg-background/40">
<div class="absolute left-1/2 top-3 bottom-3 w-px -translate-x-1/2 bg-white/[0.08]" aria-hidden="true"></div>
<!-- dots injected by JS -->
<div id="fp-rail" class="absolute inset-0 overflow-hidden" aria-hidden="true"></div>
@@ -56,7 +58,7 @@
<!-- node list -->
<div class="min-w-0 flex-1">
<button id="fp-node-0" type="button" class="flex w-full items-center justify-between gap-3 border-b border-white/[0.06] px-4 py-3.5 text-left transition hover:bg-white/[0.02]">
<button id="fp-node-0" type="button" class="flex w-full items-center justify-between gap-3 border-b border-dotted border-white/10 px-4 py-3.5 text-left transition hover:bg-white/[0.02]">
<span class="min-w-0">
<span class="flex items-center gap-2">
<span id="fp-dot-0" class="h-1.5 w-1.5 shrink-0 rounded-full bg-success shadow-[0_0_6px] shadow-success/50"></span>
@@ -66,7 +68,7 @@
</span>
<span class="shrink-0 rounded border border-primary/30 bg-primary/10 px-1.5 py-0.5 font-mono text-[10px] uppercase text-primary">Primary</span>
</button>
<button id="fp-node-1" type="button" class="flex w-full items-center justify-between gap-3 border-b border-white/[0.06] px-4 py-3.5 text-left transition hover:bg-white/[0.02]">
<button id="fp-node-1" type="button" class="flex w-full items-center justify-between gap-3 border-b border-dotted border-white/10 px-4 py-3.5 text-left transition hover:bg-white/[0.02]">
<span class="min-w-0">
<span class="flex items-center gap-2">
<span id="fp-dot-1" class="h-1.5 w-1.5 shrink-0 rounded-full bg-success"></span>
@@ -90,7 +92,7 @@
</div>
<!-- status bar -->
<div class="border-t border-white/[0.06] bg-background px-4 py-3">
<div class="border-t border-dotted border-white/10 bg-background px-4 py-3">
<div class="flex items-center justify-between gap-3">
<span id="fp-status-text" class="font-mono text-[11px] tabular-nums text-muted-foreground">status: connected — pve1 answered</span>
<span id="fp-latency" class="font-mono text-[10px] tabular-nums text-muted-foreground">~18 ms</span>
@@ -214,16 +216,20 @@
document.getElementById('fp-heal')?.addEventListener('click', healAll)
nodes.forEach((n, i) => n?.addEventListener('click', () => kill(i)))
const DOT_COUNT = 3
const CYCLE_SPEED = 0.012
function createDots() {
if (!rail) return
rail.innerHTML = ''
dotEls = []
for (let k = 0; k < 2; k++) {
for (let k = 0; k < DOT_COUNT; k++) {
const d = document.createElement('div')
d.className = 'absolute left-1/2 h-2 w-2 -translate-x-1/2 rounded-full bg-primary shadow-[0_0_8px] shadow-primary/60'
d.style.top = '8px'
d.className = 'absolute h-2 w-2 rounded-full bg-primary shadow-[0_0_8px] shadow-primary/60'
d.style.left = '50%'
d.style.top = '0px'
// @ts-ignore
d._offset = k * 0.5
d._phase = k / DOT_COUNT
rail.appendChild(d)
dotEls.push(d)
}
@@ -232,33 +238,32 @@
function restartDots() {
if (prefersReduced) return
cancelAnimationFrame(raf)
t = 0
createDots()
animate()
}
function targetYs() {
function getTargetY() {
const ai = activeIndex()
if (ai === -1) return [0.88]
// map active index to rail progress (0..1): pve1 ~0.26, pve2 ~0.55, pve3 ~0.84
if (ai === -1) return 0.88
const map = [0.28, 0.56, 0.86]
return [map[ai]]
return map[ai]
}
function animate() {
if (prefersReduced || !rail) return
t += 0.018
t += CYCLE_SPEED
const h = rail.clientHeight || 160
const targets = targetYs()
const target = getTargetY()
const ai = activeIndex()
dotEls.forEach((el) => {
const phase = (t + el._offset) % 1
// ease: move from 0 to target, then fade and reset
const ty = phase * (targets[0] * h)
const visible = phase < 0.92
// @ts-ignore
const phase = (t + el._phase) % 1
const ty = phase * target * h
const visible = phase < 0.88
el.style.transform = `translate(-50%, ${ty}px)`
el.style.opacity = visible ? '1' : '0'
// if failed, make dot red and slower
const ai = activeIndex()
if (ai === -1) {
el.style.background = 'var(--color-destructive)'
// @ts-ignore
+27 -24
View File
@@ -1,9 +1,12 @@
---
import DotMatrixText from './DotMatrixText.astro'
---
<!-- Security -->
<section id="security" aria-labelledby="sec-title" class="scroll-mt-24 py-20 sm:py-28">
<div class="mx-auto max-w-7xl px-6 lg:px-8">
<div class="grid gap-10 lg:grid-cols-12 lg:items-start">
<div class="lg:col-span-6" data-reveal>
<p class="font-mono text-[11px] uppercase tracking-[0.18em] text-primary">Security</p>
<DotMatrixText text="SECURITY" size="xs" dotClass="text-primary" />
<h2 id="sec-title" class="mt-4 text-3xl font-semibold tracking-[-0.03em] text-foreground sm:text-4xl">
Your keys stay<br /><span class="text-muted-foreground">in your pocket.</span>
</h2>
@@ -18,14 +21,14 @@
</div>
<div class="lg:col-span-6" data-reveal data-reveal-delay="120">
<div class="rounded-xl border border-white/[0.07] bg-card p-5 shadow-card">
<div class="rounded-xl border border-dotted border-white/10 bg-card p-5 shadow-card">
<div class="flex items-center gap-2">
<span class="h-2 w-2 rounded-full bg-success shadow-[0_0_6px] shadow-success/60" aria-hidden="true"></span>
<span class="font-mono text-[10px] uppercase tracking-[0.08em] text-muted-foreground">SHA-256 fingerprint — pinned</span>
<span class="ml-auto rounded border border-success/20 bg-success/10 px-1.5 py-0.5 font-mono text-[10px] uppercase text-success">Pinned</span>
</div>
<p class="mt-4 overflow-x-auto whitespace-nowrap font-mono text-[13px] tracking-[0.12em] text-foreground">A3:0F:71:C9:52:B8:4D:E0:11:2A:7F:9C:63:D4:18:0B</p>
<div class="mt-4 flex flex-wrap gap-2 border-t border-white/[0.06] pt-4 font-mono text-[10px]">
<div class="mt-4 flex flex-wrap gap-2 border-t border-dotted border-white/10 pt-4 font-mono text-[10px]">
<span class="rounded bg-white/[0.04] px-2 py-1 text-muted-foreground">OS keyring</span>
<span class="rounded bg-white/[0.04] px-2 py-1 text-muted-foreground">rustls · sha2 · x509-cert</span>
<span class="rounded bg-white/[0.04] px-2 py-1 text-muted-foreground">no telemetry</span>
@@ -34,15 +37,15 @@
</div>
<div class="mt-4 grid grid-cols-3 gap-3">
<div class="rounded-lg border border-white/[0.06] bg-background p-3 text-center">
<div class="rounded-lg border border-dotted border-white/10 bg-background p-3 text-center">
<p class="font-mono text-[10px] uppercase tracking-[0.08em] text-muted-foreground">Tokens</p>
<p class="mt-1 font-mono text-[11px] font-medium text-foreground">Keyring only</p>
</div>
<div class="rounded-lg border border-white/[0.06] bg-background p-3 text-center">
<div class="rounded-lg border border-dotted border-white/10 bg-background p-3 text-center">
<p class="font-mono text-[10px] uppercase tracking-[0.08em] text-muted-foreground">Certs</p>
<p class="mt-1 font-mono text-[11px] font-medium text-foreground">TOFU SHA-256</p>
</div>
<div class="rounded-lg border border-white/[0.06] bg-background p-3 text-center">
<div class="rounded-lg border border-dotted border-white/10 bg-background p-3 text-center">
<p class="font-mono text-[10px] uppercase tracking-[0.08em] text-muted-foreground">Transport</p>
<p class="mt-1 font-mono text-[11px] font-medium text-foreground">rustls</p>
</div>
@@ -53,11 +56,11 @@
</section>
<!-- Bold statement — full bleed -->
<section class="relative overflow-hidden border-y border-white/[0.06] bg-[#08080a]">
<section class="relative overflow-hidden border-y border-dotted border-white/10 bg-[#08080a]">
<div class="pointer-events-none absolute inset-0 grid-bg opacity-[0.22]" aria-hidden="true"></div>
<div class="relative mx-auto max-w-7xl px-6 py-20 sm:py-28 lg:px-8">
<div class="max-w-4xl" data-reveal>
<p class="font-mono text-[11px] uppercase tracking-[0.18em] text-primary">The idea</p>
<DotMatrixText text="THE IDEA" size="xs" dotClass="text-primary" />
<p class="mt-6 text-3xl font-semibold leading-[0.95] tracking-[-0.04em] text-foreground sm:text-5xl lg:text-[3.4rem]">
Proxmox is powerful.<br />
<span class="text-muted-foreground">The web UI is not the point of failure.</span>
@@ -75,27 +78,27 @@
<div class="mx-auto max-w-7xl px-6 lg:px-8">
<div class="grid gap-10 lg:grid-cols-12">
<div class="lg:col-span-5" data-reveal>
<p class="font-mono text-[11px] uppercase tracking-[0.18em] text-primary">Console</p>
<DotMatrixText text="CONSOLE" size="xs" dotClass="text-primary" />
<h2 id="console-title" class="mt-4 text-3xl font-semibold tracking-[-0.03em] text-foreground sm:text-4xl">Type like you're there.</h2>
<p class="mt-4 max-w-[44ch] text-[15px] leading-relaxed text-muted-foreground">
noVNC for VMs, xterm.js for containers. Fullscreen, keyboard passthrough, and Ctrl+Alt+Del where you expect it.
</p>
<div class="mt-6 inline-flex gap-2">
<span class="rounded border border-white/[0.06] bg-card px-2.5 py-1 font-mono text-[11px] text-muted-foreground">Fullscreen</span>
<span class="rounded border border-white/[0.06] bg-card px-2.5 py-1 font-mono text-[11px] text-muted-foreground">Ctrl+Alt+Del</span>
<span class="rounded border border-dotted border-white/10 bg-card px-2.5 py-1 font-mono text-[11px] text-muted-foreground">Fullscreen</span>
<span class="rounded border border-dotted border-white/10 bg-card px-2.5 py-1 font-mono text-[11px] text-muted-foreground">Ctrl+Alt+Del</span>
</div>
</div>
<div class="lg:col-span-7" data-reveal data-reveal-delay="120">
<div class="overflow-hidden rounded-xl border border-white/[0.07] bg-card shadow-card">
<div class="flex items-center justify-between border-b border-white/[0.06] bg-background px-4 py-2.5">
<div class="overflow-hidden rounded-xl border border-dotted border-white/10 bg-card shadow-card">
<div class="flex items-center justify-between border-b border-dotted border-white/10 bg-background px-4 py-2.5">
<div class="flex items-center gap-1.5" aria-hidden="true">
<span class="h-2.5 w-2.5 rounded-full border border-white/[0.06] bg-white/10"></span>
<span class="h-2.5 w-2.5 rounded-full border border-white/[0.06] bg-white/10"></span>
<span class="h-2.5 w-2.5 rounded-full border border-white/[0.06] bg-white/10"></span>
<span class="h-2.5 w-2.5 rounded-full border border-dotted border-white/10 bg-white/10"></span>
<span class="h-2.5 w-2.5 rounded-full border border-dotted border-white/10 bg-white/10"></span>
<span class="h-2.5 w-2.5 rounded-full border border-dotted border-white/10 bg-white/10"></span>
</div>
<p class="font-mono text-[10px] text-muted-foreground">root@pve1 · xterm</p>
<kbd class="rounded border border-white/[0.08] bg-muted px-1.5 py-0.5 font-mono text-[10px] text-muted-foreground">Ctrl+Alt+Del</kbd>
<kbd class="rounded border border-dotted border-white/10 bg-muted px-1.5 py-0.5 font-mono text-[10px] text-muted-foreground">Ctrl+Alt+Del</kbd>
</div>
<div class="bg-[#0a0a0a] px-4 py-4 font-mono text-[12px] leading-relaxed" data-terminal>
<div data-line><span class="text-primary">root@pve1:~#</span> <span class="text-foreground">pct enter 101</span></div>
@@ -110,11 +113,11 @@
</section>
<!-- Power user -->
<section id="power-user" aria-labelledby="power-title" class="scroll-mt-24 border-t border-white/[0.06] py-20 sm:py-28">
<section id="power-user" aria-labelledby="power-title" class="scroll-mt-24 border-t border-dotted border-white/10 py-20 sm:py-28">
<div class="mx-auto max-w-7xl px-6 lg:px-8">
<div class="flex flex-wrap items-end justify-between gap-6" data-reveal>
<div>
<p class="font-mono text-[11px] uppercase tracking-[0.18em] text-primary">Power user</p>
<DotMatrixText text="POWER USER" size="xs" dotClass="text-primary" />
<h2 id="power-title" class="mt-4 text-3xl font-semibold tracking-[-0.03em] text-foreground sm:text-4xl">Built for the people who keep the lights on.</h2>
</div>
<p class="max-w-[44ch] text-[15px] leading-relaxed text-muted-foreground">
@@ -122,16 +125,16 @@
</p>
</div>
<div class="mt-10 grid gap-0 border-y border-white/[0.06] sm:grid-cols-2" data-stagger>
<div class="flex items-center justify-between gap-4 border-b border-white/[0.06] px-1 py-4 sm:border-r sm:px-4" data-stagger-child>
<kbd class="rounded border border-white/[0.08] bg-muted px-2 py-1 font-mono text-[10px] text-muted-foreground">Cmd / Ctrl + K</kbd>
<div class="mt-10 grid gap-0 border-y border-dotted border-white/10 sm:grid-cols-2" data-stagger>
<div class="flex items-center justify-between gap-4 border-b border-dotted border-white/10 px-1 py-4 sm:border-r sm:px-4" data-stagger-child>
<kbd class="rounded border border-dotted border-white/10 bg-muted px-2 py-1 font-mono text-[10px] text-muted-foreground">Cmd / Ctrl + K</kbd>
<span class="text-sm text-muted-foreground">Command palette</span>
</div>
<div class="flex items-center justify-between gap-4 border-b border-white/[0.06] px-1 py-4 sm:px-4" data-stagger-child>
<div class="flex items-center justify-between gap-4 border-b border-dotted border-white/10 px-1 py-4 sm:px-4" data-stagger-child>
<span class="font-mono text-[11px] font-medium text-primary">WebSocket</span>
<span class="text-sm text-muted-foreground">Live task &amp; event stream</span>
</div>
<div class="flex items-center justify-between gap-4 border-b border-white/[0.06] px-1 py-4 sm:border-r sm:px-4 sm:border-b-0" data-stagger-child>
<div class="flex items-center justify-between gap-4 border-b border-dotted border-white/10 px-1 py-4 sm:border-r sm:px-4 sm:border-b-0" data-stagger-child>
<span class="font-mono text-[11px] font-medium text-primary">Tray menu</span>
<span class="text-sm text-muted-foreground">Show / hide · connections · quit</span>
</div>
+21 -16
View File
@@ -1,42 +1,47 @@
---
import DotMatrixText from './DotMatrixText.astro'
const year = new Date().getFullYear()
---
<footer class="border-t border-white/[0.06] bg-[#08080a]">
<footer class="border-t border-dotted border-white/10 bg-[#08080a] dot-grid">
<div class="mx-auto max-w-7xl px-6 py-10 lg:px-8">
<div class="flex flex-col gap-8 md:flex-row md:items-start md:justify-between">
<div>
<div class="flex items-center gap-2.5">
<span class="flex h-7 w-7 items-center justify-center rounded-[8px] bg-primary text-primary-foreground">
<span class="flex h-7 w-7 items-center justify-center rounded-[8px] border border-dotted border-white/10 bg-primary text-primary-foreground">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="h-3.5 w-3.5" aria-hidden="true"><path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z" /></svg>
</span>
<span class="text-sm font-semibold tracking-tight text-foreground">Clustri</span>
<span class="hidden font-mono text-[9px] uppercase tracking-[0.14em] text-muted-foreground sm:inline">Ops Console</span>
<DotMatrixText text="CLUSTRI" size="xs" dotClass="text-foreground" />
<span class="hidden sm:inline"><DotMatrixText text="OPS CONSOLE" size="xs" dotClass="text-muted-foreground opacity-70" /></span>
</div>
<p class="mt-3 font-mono text-[11px] text-muted-foreground">v0.1.0 · MIT · Not affiliated with Proxmox.</p>
</div>
<div class="flex flex-col gap-8 sm:flex-row sm:gap-16">
<nav aria-label="Navigate" class="space-y-2">
<p class="font-mono text-[10px] uppercase tracking-[0.14em] text-muted-foreground">Navigate</p>
<a href="#failover-playground" class="block font-mono text-[12px] text-muted-foreground transition-colors hover:text-foreground">Failover demo</a>
<a href="#security" class="block font-mono text-[12px] text-muted-foreground transition-colors hover:text-foreground">Security</a>
<a href="#tour" class="block font-mono text-[12px] text-muted-foreground transition-colors hover:text-foreground">Tour</a>
<a href="#console" class="block font-mono text-[12px] text-muted-foreground transition-colors hover:text-foreground">Console</a>
<a href="#architecture" class="block font-mono text-[12px] text-muted-foreground transition-colors hover:text-foreground">Architecture</a>
<a href="#download" class="block font-mono text-[12px] text-muted-foreground transition-colors hover:text-foreground">Download</a>
<DotMatrixText text="NAVIGATE" size="xs" dotClass="text-muted-foreground" />
<div class="mt-2 space-y-1">
<a href="#failover-playground" class="block font-mono text-[12px] text-muted-foreground transition-colors hover:text-foreground">Failover demo</a>
<a href="#security" class="block font-mono text-[12px] text-muted-foreground transition-colors hover:text-foreground">Security</a>
<a href="#tour" class="block font-mono text-[12px] text-muted-foreground transition-colors hover:text-foreground">Tour</a>
<a href="#console" class="block font-mono text-[12px] text-muted-foreground transition-colors hover:text-foreground">Console</a>
<a href="#architecture" class="block font-mono text-[12px] text-muted-foreground transition-colors hover:text-foreground">Architecture</a>
<a href="#download" class="block font-mono text-[12px] text-muted-foreground transition-colors hover:text-foreground">Download</a>
</div>
</nav>
<nav aria-label="Source" class="space-y-2">
<p class="font-mono text-[10px] uppercase tracking-[0.14em] text-muted-foreground">Source</p>
<a href="#" class="block font-mono text-[12px] text-muted-foreground transition-colors hover:text-foreground">GitHub</a>
<a href="#" class="block font-mono text-[12px] text-muted-foreground transition-colors hover:text-foreground">Build from source</a>
<DotMatrixText text="SOURCE" size="xs" dotClass="text-muted-foreground" />
<div class="mt-2 space-y-1">
<a href="#" class="block font-mono text-[12px] text-muted-foreground transition-colors hover:text-foreground">GitHub</a>
<a href="#" class="block font-mono text-[12px] text-muted-foreground transition-colors hover:text-foreground">Build from source</a>
</div>
</nav>
</div>
</div>
<div class="mt-10 flex flex-col gap-2 border-t border-white/[0.06] pt-6 font-mono text-[11px] text-muted-foreground/60 md:flex-row md:items-center md:justify-between">
<div class="mt-10 flex flex-col gap-2 border-t border-dotted border-white/10 pt-6 font-mono text-[11px] text-muted-foreground/60 md:flex-row md:items-center md:justify-between">
<p>© {year} Clustri. MIT license.</p>
<p class="flex items-center gap-2">Press <kbd class="rounded border border-white/[0.08] bg-white/[0.04] px-1.5 py-0.5 text-[10px]">⌘K</kbd> anywhere</p>
<p class="flex items-center gap-2">Press <kbd class="rounded border border-dotted border-white/15 bg-white/[0.04] px-1.5 py-0.5 text-[10px]">⌘K</kbd> anywhere</p>
</div>
</div>
</footer>
+13 -39
View File
@@ -1,38 +1,21 @@
---
import AppWindowMockup from './AppWindowMockup.astro'
import DotMatrixText from './DotMatrixText.astro'
---
<section class="relative overflow-hidden border-b border-white/[0.06]">
<!-- Subtle grid -->
<div class="pointer-events-none absolute inset-0 grid-bg opacity-[0.35] [mask-image:radial-gradient(ellipse_80%_60%_at_50%_0%,black_40%,transparent_85%)]" aria-hidden="true"></div>
<section class="relative overflow-hidden border-b border-dotted border-white/10">
<div class="pointer-events-none absolute inset-0 dot-grid opacity-[0.18] [mask-image:radial-gradient(ellipse_80%_60%_at_50%_0%,black_40%,transparent_85%)]" aria-hidden="true"></div>
<!-- Faint topology lines top-right -->
<svg class="pointer-events-none absolute right-0 top-0 hidden h-[520px] w-[700px] opacity-[0.07] lg:block" viewBox="0 0 700 520" fill="none" aria-hidden="true">
<circle cx="520" cy="120" r="3" fill="white" /><circle cx="620" cy="200" r="3" fill="white" /><circle cx="480" cy="260" r="3" fill="white" />
<path d="M520 120 L620 200 M520 120 L480 260 M620 200 L480 260" stroke="white" stroke-width="1" stroke-dasharray="6 6" />
<circle cx="520" cy="120" r="18" stroke="white" stroke-width="0.7" opacity="0.5" />
<circle cx="620" cy="200" r="18" stroke="white" stroke-width="0.7" opacity="0.5" />
<circle cx="480" cy="260" r="18" stroke="white" stroke-width="0.7" opacity="0.5" />
<path d="M520 120 L620 200 M520 120 L480 260 M620 200 L480 260" stroke="white" stroke-width="1" stroke-dasharray="2 6" />
<circle cx="520" cy="120" r="18" stroke="white" stroke-width="0.7" opacity="0.5" stroke-dasharray="1 4" />
<circle cx="620" cy="200" r="18" stroke="white" stroke-width="0.7" opacity="0.5" stroke-dasharray="1 4" />
<circle cx="480" cy="260" r="18" stroke="white" stroke-width="0.7" opacity="0.5" stroke-dasharray="1 4" />
</svg>
<div class="relative mx-auto max-w-7xl px-6 lg:px-8">
<!-- Copy -->
<div class="pt-28 sm:pt-36 lg:pt-40">
<div class="flex flex-wrap items-center gap-3 font-mono text-[11px]">
<span class="inline-flex items-center gap-1.5 rounded-full border border-success/20 bg-success/10 px-2.5 py-1 text-success">
<span class="h-1.5 w-1.5 rounded-full bg-success shadow-[0_0_6px] shadow-success/60"></span>
Live
</span>
<span class="text-muted-foreground">v0.1.0</span>
<span class="text-white/20">·</span>
<span class="text-muted-foreground">Linux · macOS · Windows</span>
<span class="hidden items-center gap-1.5 text-muted-foreground/60 sm:inline-flex">
<span class="text-white/10">—</span>
<button data-cmdk-trigger class="rounded border border-white/10 px-1.5 py-0.5 text-[10px] hover:border-white/15 hover:text-foreground">⌘K</button>
<span>to jump</span>
</span>
</div>
<h1 class="mt-6 max-w-[14ch] text-[2.6rem] font-semibold leading-[0.92] tracking-[-0.04em] text-foreground sm:text-[3.5rem] lg:text-[4.6rem]">
Your cluster,<br />
<span class="text-muted-foreground">not your problem.</span>
@@ -44,31 +27,22 @@ import AppWindowMockup from './AppWindowMockup.astro'
<div class="mt-8 flex flex-wrap items-center gap-3">
<a href="#download" class="rounded-[10px] bg-primary px-6 py-3 text-sm font-semibold text-primary-foreground transition hover:bg-primary/90 active:scale-[0.98]">Get Clustri</a>
<a href="#failover-playground" class="rounded-[10px] border border-white/[0.08] bg-white/[0.04] px-6 py-3 text-sm font-semibold text-foreground transition hover:border-white/15 hover:bg-white/[0.06] active:scale-[0.98]">Try the failover demo ↓</a>
<a href="#failover-playground" class="rounded-[10px] border border-dotted border-white/15 bg-white/[0.04] px-6 py-3 text-sm font-semibold text-foreground transition hover:border-white/20 hover:bg-white/[0.06] active:scale-[0.98]">Try the failover demo ↓</a>
</div>
<div class="mt-6 flex flex-wrap gap-x-4 gap-y-1 font-mono text-[10px] uppercase tracking-[0.08em] text-muted-foreground/70">
<span>Rust · Tauri 2</span><span class="text-white/10">/</span><span>React 19 · Tailwind v4</span><span class="text-white/10">/</span><span>MIT licensed</span>
<div class="mt-6 flex flex-wrap gap-x-4 gap-y-1 font-mono text-[11px] uppercase tracking-[0.08em] text-muted-foreground">
<DotMatrixText text="RUST" size="xs" dotClass="text-foreground" /><span class="text-muted-foreground">· Tauri 2</span><span class="text-white/10">/</span><DotMatrixText text="REACT" size="xs" dotClass="text-foreground" /><span class="text-muted-foreground">· Tailwind v4</span><span class="text-white/10">/</span><DotMatrixText text="MIT" size="xs" dotClass="text-foreground" /><span class="text-muted-foreground">licensed</span>
</div>
</div>
<!-- Mockup -->
<div class="mt-12 sm:mt-16">
<div class="relative overflow-hidden rounded-xl border border-white/[0.08] bg-card shadow-card" data-parallax="0.06">
<!-- thin orange top rule -->
<div class="h-px w-full bg-primary/40" aria-hidden="true"></div>
<div class="relative overflow-hidden rounded-xl border border-dotted border-white/15 bg-card shadow-card dot-grid" data-parallax="0.06">
<div class="h-px w-full bg-primary/40" style="border-top: 1px dotted oklch(0.72 0.16 52 / 0.4)" aria-hidden="true"></div>
<div class="p-2 sm:p-3">
<AppWindowMockup />
</div>
</div>
<p class="mt-3 text-center font-mono text-[10px] text-muted-foreground/60">Live demo — values tick, tasks stream. The real app looks like this.</p>
<p class="mt-3 text-center font-mono text-[10px] text-muted-foreground">Live demo — values tick, tasks stream. The real app looks like this.</p>
</div>
</div>
</section>
<style>
@keyframes hero-in {
from { opacity: 0; transform: translateY(14px); }
to { opacity: 1; transform: translateY(0); }
}
</style>
+19 -15
View File
@@ -1,37 +1,41 @@
---
import DotMatrixText from './DotMatrixText.astro'
---
<header class="nav-header fixed top-0 left-0 right-0 z-50 px-4 pt-4">
<nav
class="nav-pill mx-auto flex h-[46px] max-w-6xl items-center justify-between gap-6 rounded-[12px] border border-white/[0.07] bg-[#0f0f14]/70 px-4 backdrop-blur-xl transition-all duration-400"
class="nav-pill mx-auto flex h-[46px] max-w-6xl items-center justify-between rounded-[12px] border border-dotted border-white/15 bg-[#0f0f14]/70 px-5 backdrop-blur-xl transition-all duration-400 dot-grid"
aria-label="Primary"
>
<a href="#" class="flex items-center gap-2.5">
<span class="flex h-7 w-7 items-center justify-center rounded-[8px] bg-primary text-primary-foreground">
<a href="#" class="group flex shrink-0 items-center gap-2.5">
<span class="flex h-7 w-7 items-center justify-center rounded-[8px] border border-dotted border-white/10 bg-primary text-primary-foreground transition-colors group-hover:bg-primary/90">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="h-3.5 w-3.5" aria-hidden="true">
<path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z" />
</svg>
</span>
<span class="text-sm font-semibold tracking-tight text-foreground">Clustri</span>
<span class="hidden font-mono text-[9px] uppercase tracking-[0.14em] text-muted-foreground sm:inline">Ops Console</span>
<DotMatrixText text="CLUSTRI" size="xs" dotClass="text-foreground group-hover:text-primary transition-colors" />
</a>
<div class="hidden items-center gap-5 md:flex">
<a href="#failover-playground" class="font-mono text-[11px] uppercase tracking-[0.08em] text-muted-foreground transition-colors hover:text-foreground">Failover</a>
<a href="#security" class="font-mono text-[11px] uppercase tracking-[0.08em] text-muted-foreground transition-colors hover:text-foreground">Security</a>
<a href="#tour" class="font-mono text-[11px] uppercase tracking-[0.08em] text-muted-foreground transition-colors hover:text-foreground">Tour</a>
<a href="#architecture" class="font-mono text-[11px] uppercase tracking-[0.08em] text-muted-foreground transition-colors hover:text-foreground">Architecture</a>
<div class="hidden items-center gap-6 md:flex">
<span class="h-3 w-px bg-white/10" aria-hidden="true"></span>
<a href="#failover-playground" class="group font-mono text-[11px] tracking-wide text-muted-foreground transition-colors hover:text-foreground">FAILOVER</a>
<a href="#security" class="group font-mono text-[11px] tracking-wide text-muted-foreground transition-colors hover:text-foreground">SECURITY</a>
<a href="#tour" class="group font-mono text-[11px] tracking-wide text-muted-foreground transition-colors hover:text-foreground">TOUR</a>
<a href="#architecture" class="group font-mono text-[11px] tracking-wide text-muted-foreground transition-colors hover:text-foreground">ARCH</a>
</div>
<div class="flex items-center gap-2">
<button data-cmdk-trigger class="hidden sm:flex items-center gap-1.5 rounded-md border border-white/[0.06] bg-white/[0.04] px-2.5 py-1.5 font-mono text-[11px] text-muted-foreground transition-colors hover:border-white/10 hover:text-foreground" aria-label="Open command palette">
<div class="flex shrink-0 items-center gap-3">
<button data-cmdk-trigger class="hidden sm:flex items-center gap-1.5 rounded-md border border-dotted border-white/10 bg-white/[0.04] px-2.5 py-1.5 font-mono text-[11px] text-muted-foreground transition-colors hover:border-white/15 hover:text-foreground" aria-label="Open command palette">
<span>⌘K</span>
</button>
<a href="#download" class="rounded-[10px] bg-primary px-4 py-2 text-[13px] font-semibold text-primary-foreground transition-all hover:bg-primary/90 active:scale-[0.98]">Get Clustri</a>
<a href="#download" class="rounded-[10px] border border-dotted border-primary/30 bg-primary px-4 py-2 text-[13px] font-semibold text-primary-foreground transition-all hover:bg-primary/90 active:scale-[0.98]">Get Clustri</a>
</div>
</nav>
</header>
<style>
.nav-scrolled .nav-pill {
border-color: rgba(255, 255, 255, 0.08);
background-color: oklch(0.11 0.008 275 / 0.85);
border-color: rgba(255, 255, 255, 0.15);
background-color: oklch(0.145 0.012 250 / 0.85);
}
</style>
+18 -15
View File
@@ -1,8 +1,11 @@
---
import DotMatrixText from './DotMatrixText.astro'
---
<section id="tour" aria-labelledby="tour-title" class="scroll-mt-24 py-20 sm:py-28">
<div class="mx-auto max-w-7xl px-6 lg:px-8">
<div class="flex flex-wrap items-end justify-between gap-6">
<div data-reveal>
<p class="font-mono text-[11px] uppercase tracking-[0.18em] text-primary">Tour</p>
<DotMatrixText text="TOUR" size="xs" dotClass="text-primary" />
<h2 id="tour-title" class="mt-4 max-w-[14ch] text-3xl font-semibold tracking-[-0.03em] text-foreground sm:text-4xl">
Feels like the Proxmox UI,<br /><span class="text-muted-foreground">without the browser.</span>
</h2>
@@ -12,7 +15,7 @@
</div>
<!-- Tabs -->
<div class="flex gap-1 rounded-lg border border-white/[0.06] bg-card p-1" role="tablist" aria-label="Screenshot tour" data-reveal data-reveal-delay="100">
<div class="flex gap-1 rounded-lg border border-dotted border-white/10 bg-card p-1" role="tablist" aria-label="Screenshot tour" data-reveal data-reveal-delay="100">
<button role="tab" aria-selected="true" data-tour-tab="vms" class="rounded-md bg-primary px-3.5 py-1.5 font-mono text-[11px] font-semibold text-primary-foreground">VMs</button>
<button role="tab" aria-selected="false" data-tour-tab="console" class="rounded-md px-3.5 py-1.5 font-mono text-[11px] font-medium text-muted-foreground hover:text-foreground">Console</button>
<button role="tab" aria-selected="false" data-tour-tab="backups" class="rounded-md px-3.5 py-1.5 font-mono text-[11px] font-medium text-muted-foreground hover:text-foreground">Backups</button>
@@ -20,12 +23,12 @@
</div>
<!-- Frame -->
<div class="mt-10 overflow-hidden rounded-xl border border-white/[0.07] bg-card shadow-card" data-reveal data-reveal-delay="160">
<div class="flex h-9 items-center justify-between border-b border-white/[0.06] bg-background px-4">
<div class="mt-10 overflow-hidden rounded-xl border border-dotted border-white/10 bg-card shadow-card" data-reveal data-reveal-delay="160">
<div class="flex h-9 items-center justify-between border-b border-dotted border-white/10 bg-background px-4">
<div class="flex items-center gap-1.5" aria-hidden="true">
<span class="h-2.5 w-2.5 rounded-full border border-white/[0.06] bg-white/10"></span>
<span class="h-2.5 w-2.5 rounded-full border border-white/[0.06] bg-white/10"></span>
<span class="h-2.5 w-2.5 rounded-full border border-white/[0.06] bg-white/10"></span>
<span class="h-2.5 w-2.5 rounded-full border border-dotted border-white/10 bg-white/10"></span>
<span class="h-2.5 w-2.5 rounded-full border border-dotted border-white/10 bg-white/10"></span>
<span class="h-2.5 w-2.5 rounded-full border border-dotted border-white/10 bg-white/10"></span>
</div>
<p id="tour-frame-label" class="font-mono text-[10px] tabular-nums text-muted-foreground">Virtual Machines — 4 VMs across the cluster</p>
<span class="hidden font-mono text-[10px] text-success sm:inline">● live</span>
@@ -36,16 +39,16 @@
</div>
<div class="mt-4 grid gap-3 sm:grid-cols-3">
<div class="rounded-lg border border-white/[0.06] bg-card p-4" data-reveal>
<p class="font-mono text-[11px] font-medium uppercase tracking-[0.08em] text-foreground">VMs &amp; Containers</p>
<p class="mt-1.5 text-[13px] leading-relaxed text-muted-foreground">Filter by node, type, status. Start / stop / migrate / console in one click. QEMU and LXC.</p>
<div class="rounded-lg border border-dotted border-white/10 bg-card p-4 dot-grid" data-reveal>
<DotMatrixText text="VMS + CONTAINERS" size="xs" dotClass="text-foreground" />
<p class="mt-2 text-[13px] leading-relaxed text-muted-foreground">Filter by node, type, status. Start / stop / migrate / console in one click. QEMU and LXC.</p>
</div>
<div class="rounded-lg border border-white/[0.06] bg-card p-4" data-reveal data-reveal-delay="80">
<p class="font-mono text-[11px] font-medium uppercase tracking-[0.08em] text-foreground">Console</p>
<p class="mt-1.5 text-[13px] leading-relaxed text-muted-foreground">noVNC for VMs, xterm.js for CTs. Fullscreen, resize, Ctrl+Alt+Del passthrough.</p>
<div class="rounded-lg border border-dotted border-white/10 bg-card p-4 dot-grid" data-reveal data-reveal-delay="80">
<DotMatrixText text="CONSOLE" size="xs" dotClass="text-foreground" />
<p class="mt-2 text-[13px] leading-relaxed text-muted-foreground">noVNC for VMs, xterm.js for CTs. Fullscreen, resize, Ctrl+Alt+Del passthrough.</p>
</div>
<div class="rounded-lg border border-white/[0.06] bg-card p-4" data-reveal data-reveal-delay="160">
<p class="font-mono text-[11px] font-medium uppercase tracking-[0.08em] text-foreground">Backups</p>
<div class="rounded-lg border border-dotted border-white/10 bg-card p-4 dot-grid" data-reveal data-reveal-delay="160">
<DotMatrixText text="BACKUPS" size="xs" dotClass="text-foreground" />
<p class="mt-1.5 text-[13px] leading-relaxed text-muted-foreground">Jobs, groups, snapshots. Verify, prune (dry-run), GC, archive download via OS dialog.</p>
</div>
</div>
+19 -17
View File
@@ -1,32 +1,34 @@
<section aria-label="Facts" class="border-y border-white/[0.06] bg-[#08080a]">
---
import DotMatrixText from './DotMatrixText.astro'
---
<section aria-label="Facts" class="border-y border-dotted border-white/10 bg-[#08080a] dot-grid">
<div class="mx-auto max-w-7xl px-6 lg:px-8">
<div class="grid grid-cols-2 gap-0 py-8 sm:grid-cols-4" data-stagger>
<div class="flex flex-col items-center gap-1 border-r border-white/[0.06] px-2 py-6" data-stagger-child>
<div class="flex flex-col items-center gap-2 border-r border-dotted border-white/10 px-2 py-6" data-stagger-child>
<span class="font-mono text-3xl font-semibold tracking-[-0.03em] tabular-nums text-foreground sm:text-4xl" data-countup="0">0</span>
<span class="font-mono text-[10px] uppercase tracking-[0.14em] text-muted-foreground">Telemetry</span>
<span class="font-mono text-[10px] text-muted-foreground/60">ships with the app</span>
<DotMatrixText text="TELEMETRY" size="xs" dotClass="text-muted-foreground" />
<span class="font-mono text-[11px] text-muted-foreground">ships with the app</span>
</div>
<div class="flex flex-col items-center gap-1 border-r border-white/[0.06] px-2 py-6 sm:border-r" data-stagger-child>
<div class="flex flex-col items-center gap-2 border-r border-dotted border-white/10 px-2 py-6 sm:border-r" data-stagger-child>
<span class="font-mono text-3xl font-semibold tracking-[-0.03em] tabular-nums text-foreground sm:text-4xl" data-countup="0">0</span>
<span class="font-mono text-[10px] uppercase tracking-[0.14em] text-muted-foreground">Cloud dependency</span>
<span class="font-mono text-[10px] text-muted-foreground/60">talks to your nodes</span>
<DotMatrixText text="CLOUD" size="xs" dotClass="text-muted-foreground" />
<span class="font-mono text-[11px] text-muted-foreground">talks to your nodes</span>
</div>
<div class="flex flex-col items-center gap-1 border-r border-white/[0.06] px-2 py-6 max-sm:border-r-0" data-stagger-child>
<div class="flex flex-col items-center gap-2 border-r border-dotted border-white/10 px-2 py-6 max-sm:border-r-0" data-stagger-child>
<span class="font-mono text-3xl font-semibold tracking-[-0.03em] tabular-nums text-primary sm:text-4xl" data-countup="64">0</span>
<span class="font-mono text-[10px] uppercase tracking-[0.14em] text-muted-foreground">Rust tests</span>
<span class="font-mono text-[10px] text-muted-foreground/60">httpmock · rcgen · tempfile</span>
<DotMatrixText text="RUST TESTS" size="xs" dotClass="text-muted-foreground" />
<span class="font-mono text-[11px] text-muted-foreground">httpmock · rcgen · tempfile</span>
</div>
<div class="flex flex-col items-center gap-1 px-2 py-6" data-stagger-child>
<div class="flex flex-col items-center gap-2 px-2 py-6" data-stagger-child>
<span class="font-mono text-3xl font-semibold tracking-[-0.03em] tabular-nums text-foreground sm:text-4xl" data-countup="3">0</span>
<span class="font-mono text-[10px] uppercase tracking-[0.14em] text-muted-foreground">Platforms</span>
<span class="font-mono text-[10px] text-muted-foreground/60">Linux · macOS · Windows</span>
<DotMatrixText text="PLATFORMS" size="xs" dotClass="text-muted-foreground" />
<span class="font-mono text-[11px] text-muted-foreground">Linux · macOS · Windows</span>
</div>
</div>
<!-- mono ticker -->
<div class="overflow-hidden border-t border-white/[0.06] py-3" aria-hidden="true">
<div class="flex gap-6 whitespace-nowrap font-mono text-[10px] uppercase tracking-[0.12em] text-muted-foreground/40">
<span>start</span><span class="text-white/10">·</span><span>stop</span><span class="text-white/10">·</span><span>shutdown</span><span class="text-white/10">·</span><span>reboot</span><span class="text-white/10">·</span><span>suspend</span><span class="text-white/10">·</span><span>resume</span><span class="text-white/10">·</span><span>migrate</span><span class="text-white/10">·</span><span>snapshot</span><span class="text-white/10">·</span><span>backup</span><span class="text-white/10">·</span><span>restore</span><span class="text-white/10">·</span><span>prune</span><span class="text-white/10">·</span><span>GC</span><span class="text-white/10">·</span><span>noVNC</span><span class="text-white/10">·</span><span>xterm.js</span>
<div class="overflow-hidden border-t border-dotted border-white/10 py-3" aria-hidden="true">
<div class="flex gap-6 whitespace-nowrap font-mono text-[10px] uppercase tracking-[0.12em] text-muted-foreground/50">
<DotMatrixText text="START / STOP / SHUTDOWN / REBOOT / SUSPEND / RESUME / MIGRATE / SNAPSHOT / BACKUP / RESTORE / PRUNE / GC / NOVNC / XTERM.JS" size="xs" dotClass="text-muted-foreground/50" />
</div>
</div>
</div>
+74
View File
@@ -0,0 +1,74 @@
export type Glyph = readonly string[]
export const GLYPH_COLS = 5
export const GLYPH_ROWS = 7
export const GLYPHS: Record<string, Glyph> = {
' ': [' ', ' ', ' ', ' ', ' ', ' ', ' '],
A: [' ### ', '# #', '# #', '#####', '# #', '# #', '# #'],
B: ['#### ', '# #', '# #', '#### ', '# #', '# #', '#### '],
C: [' ### ', '# #', '# ', '# ', '# ', '# #', ' ### '],
D: ['#### ', '# #', '# #', '# #', '# #', '# #', '#### '],
E: ['#####', '# ', '# ', '#### ', '# ', '# ', '#####'],
F: ['#####', '# ', '# ', '#### ', '# ', '# ', '# '],
G: [' ### ', '# #', '# ', '# ## ', '# #', '# #', ' ### '],
H: ['# #', '# #', '# #', '#####', '# #', '# #', '# #'],
I: ['#####', ' # ', ' # ', ' # ', ' # ', ' # ', '#####'],
J: ['#####', ' # ', ' # ', ' # ', ' # ', '# # ', ' ## '],
K: ['# #', '# # ', '# # ', '## ', '# # ', '# # ', '# #'],
L: ['# ', '# ', '# ', '# ', '# ', '# ', '#####'],
M: ['# #', '## ##', '# # #', '# # #', '# #', '# #', '# #'],
N: ['# #', '## #', '# # #', '# # #', '# ##', '# #', '# #'],
O: [' ### ', '# #', '# #', '# #', '# #', '# #', ' ### '],
P: ['#### ', '# #', '# #', '#### ', '# ', '# ', '# '],
Q: [' ### ', '# #', '# #', '# #', '# # #', '# # ', ' ## #'],
R: ['#### ', '# #', '# #', '#### ', '# # ', '# # ', '# #'],
S: [' ### ', '# #', '# ', ' ### ', ' #', '# #', ' ### '],
T: ['#####', ' # ', ' # ', ' # ', ' # ', ' # ', ' # '],
U: ['# #', '# #', '# #', '# #', '# #', '# #', ' ### '],
V: ['# #', '# #', '# #', '# #', '# #', ' # # ', ' # '],
W: ['# #', '# #', '# #', '# # #', '# # #', '## ##', '# #'],
X: ['# #', '# #', ' # # ', ' # ', ' # # ', '# #', '# #'],
Y: ['# #', '# #', ' # # ', ' # ', ' # ', ' # ', ' # '],
Z: ['#####', ' #', ' # ', ' # ', ' # ', '# ', '#####'],
'0': [' ### ', '# #', '# ##', '# # #', '## #', '# #', ' ### '],
'1': [' # ', ' ## ', ' # ', ' # ', ' # ', ' # ', '#####'],
'2': [' ### ', '# #', ' #', ' ## ', ' # ', '# ', '#####'],
'3': ['#### ', ' #', ' #', ' ### ', ' #', ' #', '#### '],
'4': [' # ', ' ## ', ' # # ', '# # ', '#####', ' # ', ' # '],
'5': ['#####', '# ', '# ', '#### ', ' #', '# #', ' ### '],
'6': [' ### ', '# #', '# ', '#### ', '# #', '# #', ' ### '],
'7': ['#####', ' #', ' # ', ' # ', ' # ', ' # ', ' # '],
'8': [' ### ', '# #', '# #', ' ### ', '# #', '# #', ' ### '],
'9': [' ### ', '# #', '# #', ' ####', ' #', '# #', ' ### '],
'-': [' ', ' ', ' ', '#####', ' ', ' ', ' '],
'.': [' ', ' ', ' ', ' ', ' ', ' ## ', ' ## '],
':': [' ', ' ## ', ' ## ', ' ', ' ## ', ' ## ', ' '],
'/': [' #', ' #', ' # ', ' # ', ' # ', '# ', '# '],
'%': ['## #', '## #', ' # ', ' # ', ' # ', '# ##', '# ##'],
'(': [' ## ', ' # ', '# ', '# ', '# ', ' # ', ' ## '],
')': [' ## ', ' # ', ' #', ' #', ' #', ' # ', ' ## '],
'+': [' ', ' # ', ' # ', '#####', ' # ', ' # ', ' '],
_: [' ', ' ', ' ', ' ', ' ', ' ', '#####'],
'@': [' ### ', '# #', '# ## ', '# ## ', '# ## ', '# ', ' ### '],
'#': [' # # ', ' # # ', '#####', ' # # ', '#####', ' # # ', ' # # '],
'!': [' # ', ' # ', ' # ', ' # ', ' # ', ' ', ' # '],
'?': [' ### ', '# #', ' #', ' ## ', ' # ', ' ', ' # '],
'*': [' ', '# #', ' # # ', '#####', ' # # ', '# #', ' '],
'=': [' ', ' ', '#####', ' ', '#####', ' ', ' '],
',': [' ', ' ', ' ', ' ', ' ## ', ' ## ', ' # '],
"'": [' # ', ' # ', ' # ', ' ', ' ', ' ', ' '],
'"': [' # # ', ' # # ', ' ', ' ', ' ', ' ', ' '],
'[': [' ### ', ' # ', ' # ', ' # ', ' # ', ' # ', ' ### '],
']': [' ### ', ' # ', ' # ', ' # ', ' # ', ' # ', ' ### '],
'<': [' # ', ' # ', ' # ', '# ', ' # ', ' # ', ' # '],
'>': ['# ', ' # ', ' # ', ' # ', ' # ', ' # ', '# '],
'|': [' # ', ' # ', ' # ', ' # ', ' # ', ' # ', ' # '],
'&': [' ## ', '# # ', '# # ', ' ## ', '# # ', '# # ', ' ## #'],
$: [' ### ', '# # ', '# ', ' ### ', ' #', ' # #', ' ### '],
}
export function getGlyph(char: string): Glyph | null {
const upper = char.toUpperCase()
return GLYPHS[upper] ?? null
}
+107 -29
View File
@@ -14,25 +14,25 @@
--radius-lg: 0.625rem;
--radius-xl: 0.75rem;
/* Dark — near-black with warm charcoal, not indigo wash */
--color-background: oklch(0.11 0.008 275);
--color-foreground: oklch(0.965 0.004 275);
--color-card: oklch(0.155 0.012 275);
--color-card-foreground: oklch(0.965 0.004 275);
--color-popover: oklch(0.18 0.014 275);
--color-popover-foreground: oklch(0.965 0.004 275);
/* Dark paper — dark ink-paper, light ink, Proxmox orange ribbon */
--color-background: oklch(0.145 0.012 250);
--color-foreground: oklch(0.96 0.005 85);
--color-card: oklch(0.182 0.014 250);
--color-card-foreground: oklch(0.96 0.005 85);
--color-popover: oklch(0.2 0.015 250);
--color-popover-foreground: oklch(0.96 0.005 85);
--color-primary: oklch(0.72 0.16 52);
--color-primary-foreground: oklch(0.14 0.02 50);
--color-secondary: oklch(0.19 0.012 275);
--color-secondary-foreground: oklch(0.94 0.004 275);
--color-muted: oklch(0.19 0.012 275);
--color-muted-foreground: oklch(0.6 0.012 275);
--color-accent: oklch(0.22 0.012 275);
--color-accent-foreground: oklch(0.965 0.004 275);
--color-secondary: oklch(0.22 0.014 250);
--color-secondary-foreground: oklch(0.92 0.004 85);
--color-muted: oklch(0.22 0.014 250);
--color-muted-foreground: oklch(0.68 0.012 250);
--color-accent: oklch(0.24 0.014 250);
--color-accent-foreground: oklch(0.96 0.005 85);
--color-destructive: oklch(0.62 0.18 25);
--color-destructive-foreground: oklch(0.97 0.015 25);
--color-border: oklch(0.21 0.01 275);
--color-input: oklch(0.24 0.01 275);
--color-border: oklch(0.26 0.014 250);
--color-input: oklch(0.28 0.015 250);
--color-ring: oklch(0.72 0.16 52);
--color-success: oklch(0.7 0.14 155);
@@ -40,25 +40,103 @@
--color-info: oklch(0.68 0.11 245);
--shadow-card:
0 1px 0 0 oklch(1 0 0 / 0.04) inset, 0 1px 2px 0 oklch(0 0 0 / 0.4),
0 1px 0 0 oklch(1 0 0 / 0.05) inset, 0 1px 2px 0 oklch(0 0 0 / 0.45),
0 8px 24px -8px oklch(0 0 0 / 0.5);
}
/* Grid texture — 1px hairline grid at very low opacity */
.grid-bg {
background-image:
linear-gradient(to right, oklch(1 0 0 / 0.04) 1px, transparent 1px),
linear-gradient(to bottom, oklch(1 0 0 / 0.04) 1px, transparent 1px);
background-size: 32px 32px;
/* Dot-matrix texture utilities */
.dot-grid {
background-image: radial-gradient(
circle,
oklch(1 0 0 / 0.07) 1px,
transparent 1px
);
background-size: 14px 14px;
}
.grid-bg-strong {
background-image:
linear-gradient(to right, oklch(1 0 0 / 0.06) 1px, transparent 1px),
linear-gradient(to bottom, oklch(1 0 0 / 0.06) 1px, transparent 1px);
background-size: 24px 24px;
.dot-grid-strong {
background-image: radial-gradient(
circle,
oklch(1 0 0 / 0.1) 1.1px,
transparent 1.1px
);
background-size: 10px 10px;
}
.dot-grid-paper {
background-image: radial-gradient(
circle,
oklch(1 0 0 / 0.045) 1px,
transparent 1px
);
background-size: 16px 16px;
}
.dot-rule {
border: none;
border-top: 1.5px dotted oklch(1 0 0 / 0.12);
height: 0;
}
.perf-edge {
position: relative;
}
.perf-edge::before,
.perf-edge::after {
content: '';
position: absolute;
top: 8px;
bottom: 8px;
width: 6px;
background-image: radial-gradient(
circle,
oklch(1 0 0 / 0.1) 2px,
transparent 2px
);
background-size: 6px 12px;
background-repeat: repeat-y;
pointer-events: none;
}
.perf-edge::before { left: 4px; }
.perf-edge::after { right: 4px; }
.halftone-fill {
position: relative;
}
.halftone-fill::after {
content: '';
position: absolute;
inset: 0;
background-image: radial-gradient(
circle,
oklch(1 0 0 / 0.35) 0.9px,
transparent 0.9px
);
background-size: 5px 5px;
opacity: 0.9;
pointer-events: none;
border-radius: inherit;
}
.feed-rule {
border-bottom: 1px dotted oklch(1 0 0 / 0.08);
}
.feed-rule:last-child { border-bottom: none; }
/* Legacy grid-bg kept as alias to dot-grid for compat */
.grid-bg {
background-image: radial-gradient(
circle,
oklch(1 0 0 / 0.07) 1px,
transparent 1px
);
background-size: 14px 14px;
}
.grid-bg-strong {
background-image: radial-gradient(
circle,
oklch(1 0 0 / 0.1) 1.1px,
transparent 1.1px
);
background-size: 10px 10px;
}
/* Hairline rule utility */
.hairline {
border-color: oklch(1 0 0 / 0.06);
}
@@ -193,7 +271,7 @@
inset: 0;
z-index: 60;
pointer-events: none;
opacity: 0.025;
opacity: 0.03;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='200' height='200'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.65' numOctaves='3' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='200' height='200' filter='url(%23n)'/%3E%3C/svg%3E");
background-repeat: repeat;
}
+11 -12
View File
@@ -18,6 +18,7 @@ import { PbsDatastoreDetail } from '@/components/pbs/PbsDatastoreDetail'
import { SettingsPage } from '@/components/settings/SettingsPage'
import { ErrorBoundary } from '@/components/ErrorBoundary'
import { ToastProvider, useToast } from '@/components/ui/toast'
import { DotMatrixText } from '@/components/ui/dot-matrix'
import { Server, AlertTriangle } from 'lucide-react'
import { useConnectionStore } from '@/stores/connectionStore'
import { useUIStore } from '@/stores/uiStore'
@@ -236,15 +237,13 @@ function AppContent() {
if (!activeConnectionId) {
return (
<div className="flex h-full items-center justify-center p-6">
<div className="flex flex-col items-center text-center">
<div className="mb-4 flex h-12 w-12 items-center justify-center rounded-lg border border-border bg-card shadow-card">
<div className="flex h-full items-center justify-center p-6 dot-grid">
<div className="flex flex-col items-center text-center rounded-lg border border-dotted border-border bg-card p-8 shadow-card">
<div className="mb-4 flex h-12 w-12 items-center justify-center rounded-sm border border-dotted border-border bg-muted/40">
<Server className="h-5 w-5 text-primary" />
</div>
<h2 className="text-lg font-semibold tracking-tight">
No connection selected
</h2>
<p className="mt-1 max-w-sm text-sm text-muted-foreground">
<DotMatrixText text="NO CONNECTION" size="sm" className="text-foreground" />
<p className="mt-2 max-w-sm text-sm text-muted-foreground">
Connect to a Proxmox server to get started
</p>
</div>
@@ -375,11 +374,11 @@ function AppContent() {
}, [activeConnectionId, activeServerType, view.type])
return (
<div className="flex h-screen flex-col bg-background">
<div className="flex h-screen flex-col bg-background dot-grid">
{activeConnection?.status === 'failover' && activeConnection.currentEndpointUrl && (
<div className="flex items-center gap-2 border-b border-warning/25 bg-warning/10 px-4 py-1.5">
<div className="flex items-center gap-2 border-b border-dotted border-warning/30 bg-warning/10 px-4 py-1.5 dot-grid">
<AlertTriangle className="h-3.5 w-3.5 shrink-0 text-warning" />
<p className="text-xs font-medium text-warning">Operating on a fallback node</p>
<DotMatrixText text="FALLBACK NODE" size="xs" className="text-warning" />
<p className="truncate font-mono text-[11px] tabular-nums text-warning/80">
{activeConnection.currentEndpointUrl}
</p>
@@ -391,9 +390,9 @@ function AppContent() {
activeView={view.type}
onNavigate={(v) => handleNavigate(v as View)}
/>
<main className="flex min-h-0 flex-1 flex-col">
<main className="flex min-h-0 flex-1 flex-col bg-background/60">
{activeConnectionId && (
<div className="shrink-0 border-b px-3 py-1.5">
<div className="shrink-0 border-b border-dotted border-border px-3 py-1.5 dot-grid">
<TaskStatusBar
connectionId={activeConnectionId}
onClick={() => handleNavigate({ type: 'tasks' })}
+21 -21
View File
@@ -1,4 +1,5 @@
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Card, CardContent, CardHeader } from '@/components/ui/card'
import { DotMatrixText } from '@/components/ui/dot-matrix'
import { ScrollArea } from '@/components/ui/scroll-area'
import { Skeleton } from '@/components/ui/skeleton'
import { Activity, Clock, Loader2 } from 'lucide-react'
@@ -16,20 +17,20 @@ function isRunningTask(status?: string): boolean {
function getStatusBadgeClass(status?: string): string {
switch (status) {
case 'OK':
return 'border-success/25 bg-success/10 text-success'
return 'border-dotted border-success/30 bg-success/10 text-success'
case 'unknown':
return 'border-warning/25 bg-warning/10 text-warning'
return 'border-dotted border-warning/30 bg-warning/10 text-warning'
default:
if (!status || status === 'Running') {
return 'border-info/25 bg-info/10 text-info'
return 'border-dotted border-info/30 bg-info/10 text-info'
}
return 'border-destructive/25 bg-destructive/10 text-destructive'
return 'border-dotted border-destructive/30 bg-destructive/10 text-destructive'
}
}
function getStatusLabel(status?: string): string {
if (!status || status === 'Running') return 'Running'
return status
if (!status || status === 'Running') return 'RUNNING'
return status.toUpperCase()
}
function formatDuration(starttime: number, endtime?: number): string {
@@ -52,7 +53,6 @@ function formatTime(timestamp: number): string {
}
function getTaskTypeLabel(type: string): string {
// Capitalize and format Proxmox task types
return type
.replace(/qm/gi, 'VM')
.replace(/vz/gi, 'CT')
@@ -68,16 +68,16 @@ function getTaskTypeLabel(type: string): string {
export function ActivityFeed({ tasks, isLoading }: ActivityFeedProps) {
if (isLoading) {
return (
<Card>
<CardHeader>
<Card className="dot-grid">
<CardHeader className="border-b border-dotted border-border/60">
<div className="flex items-center gap-2.5">
<div className="flex h-7 w-7 items-center justify-center rounded-md border border-border bg-muted/40">
<div className="flex h-7 w-7 items-center justify-center rounded-sm border border-dotted border-border bg-muted/40">
<Activity className="h-3.5 w-3.5 text-muted-foreground" />
</div>
<CardTitle className="text-sm font-semibold">Recent Activity</CardTitle>
<DotMatrixText text="RECENT ACTIVITY" size="xs" className="text-foreground" />
</div>
</CardHeader>
<CardContent>
<CardContent className="pt-4">
<div className="space-y-2">
{Array.from({ length: 5 }, (_, i) => (
<Skeleton key={i} className="h-12 w-full" />
@@ -91,30 +91,30 @@ export function ActivityFeed({ tasks, isLoading }: ActivityFeedProps) {
const recentTasks = tasks?.slice(0, 15) ?? []
return (
<Card>
<CardHeader>
<Card className="dot-grid">
<CardHeader className="border-b border-dotted border-border/60">
<div className="flex items-center gap-2.5">
<div className="flex h-7 w-7 items-center justify-center rounded-md border border-border bg-muted/40">
<div className="flex h-7 w-7 items-center justify-center rounded-sm border border-dotted border-border bg-muted/40">
<Activity className="h-3.5 w-3.5 text-muted-foreground" />
</div>
<CardTitle className="text-sm font-semibold">Recent Activity</CardTitle>
<DotMatrixText text="RECENT ACTIVITY" size="xs" className="text-foreground" />
</div>
</CardHeader>
<CardContent>
<CardContent className="pt-4">
{recentTasks.length > 0 ? (
<ScrollArea className="h-[300px]">
<div className="space-y-1.5">
<div className="space-y-0">
{recentTasks.map((task) => (
<div
key={task.upid}
className="flex items-center justify-between rounded-md border border-border/70 bg-background/30 px-3 py-2.5 transition-colors duration-150 hover:border-border hover:bg-accent/40"
className="flex items-center justify-between px-3 py-2.5 transition-colors duration-150 hover:bg-accent/30 feed-rule"
>
<div className="flex min-w-0 flex-1 items-center gap-3">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<p className="truncate text-[13px] font-medium">{getTaskTypeLabel(task.type)}</p>
<span
className={`inline-flex shrink-0 items-center gap-1 rounded-sm border px-1.5 py-0.5 text-[11px] font-medium ${getStatusBadgeClass(task.status)}`}
className={`inline-flex shrink-0 items-center gap-1 rounded-sm border border-dotted px-1.5 py-0.5 text-[10px] font-medium tracking-widest ${getStatusBadgeClass(task.status)}`}
>
{isRunningTask(task.status) && (
<Loader2 className="h-3 w-3 animate-spin" />
+18 -27
View File
@@ -1,5 +1,6 @@
import { useMemo } from 'react'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Card, CardContent, CardHeader } from '@/components/ui/card'
import { DotMatrixText } from '@/components/ui/dot-matrix'
import { Cpu, MemoryStick, HardDrive, Clock, Server, Box } from 'lucide-react'
import type { ProxmoxNode, ProxmoxVM } from '@/types/proxmox'
import { formatBytes, formatUptime } from '@/lib/format'
@@ -20,30 +21,23 @@ function getStatusTextColor(status: 'online' | 'offline'): string {
return status === 'online' ? 'text-success' : 'text-destructive'
}
function getStatusLabel(status: 'online' | 'offline'): string {
return status === 'online' ? 'Online' : 'Offline'
}
function NodeCard({ node, vmCount }: { node: ProxmoxNode; vmCount: number }) {
const cpuPercent = node.maxcpu > 0 ? node.cpu : 0
const memPercent = node.maxmem > 0 ? node.mem / node.maxmem : 0
const diskPercent = node.maxdisk > 0 ? node.disk / node.maxdisk : 0
return (
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-3">
<Card className="dot-grid">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-3 border-b border-dotted border-border/60">
<div className="flex items-center gap-2.5">
<span className={cn('h-2 w-2 rounded-full', getStatusColor(node.status))} />
<CardTitle className="font-mono text-sm font-semibold tracking-tight">
<span className="font-mono text-sm font-semibold tracking-tight text-foreground">
{node.node}
</CardTitle>
</span>
</div>
<span className={cn('text-xs font-medium', getStatusTextColor(node.status))}>
{getStatusLabel(node.status)}
</span>
<DotMatrixText text={node.status === 'online' ? 'ONLINE' : 'OFFLINE'} size="xs" className={getStatusTextColor(node.status)} />
</CardHeader>
<CardContent className="space-y-3">
{/* Stats row */}
<CardContent className="space-y-3 pt-3">
<div className="grid grid-cols-3 gap-2 font-mono text-xs tabular-nums text-muted-foreground">
<div className="flex items-center gap-1.5">
<Cpu className="h-3 w-3 shrink-0" />
@@ -58,9 +52,7 @@ function NodeCard({ node, vmCount }: { node: ProxmoxNode; vmCount: number }) {
<span>{(diskPercent * 100).toFixed(0)}%</span>
</div>
</div>
{/* Bottom info */}
<div className="flex items-center justify-between border-t pt-2.5 font-mono text-xs tabular-nums text-muted-foreground">
<div className="flex items-center justify-between border-t border-dotted border-border/60 pt-2.5 font-mono text-xs tabular-nums text-muted-foreground">
<div className="flex items-center gap-1.5">
<Clock className="h-3 w-3 shrink-0" />
<span>{formatUptime(node.uptime)}</span>
@@ -80,7 +72,6 @@ function NodeCard({ node, vmCount }: { node: ProxmoxNode; vmCount: number }) {
}
export function NodeHealthGrid({ nodes, vms }: NodeHealthGridProps) {
// Count VMs per node
const vmCounts = useMemo(() => {
const counts: Record<string, number> = {}
vms?.forEach((vm) => {
@@ -91,13 +82,13 @@ export function NodeHealthGrid({ nodes, vms }: NodeHealthGridProps) {
if (!nodes || nodes.length === 0) {
return (
<Card>
<CardHeader>
<Card className="dot-grid">
<CardHeader className="border-b border-dotted border-border/60">
<div className="flex items-center gap-2.5">
<div className="flex h-7 w-7 items-center justify-center rounded-md border border-border bg-muted/40">
<div className="flex h-7 w-7 items-center justify-center rounded-sm border border-dotted border-border bg-muted/40">
<Server className="h-3.5 w-3.5 text-muted-foreground" />
</div>
<CardTitle className="text-sm font-semibold">Node Health</CardTitle>
<DotMatrixText text="NODE HEALTH" size="xs" className="text-foreground" />
</div>
</CardHeader>
<CardContent>
@@ -108,16 +99,16 @@ export function NodeHealthGrid({ nodes, vms }: NodeHealthGridProps) {
}
return (
<Card>
<CardHeader>
<Card className="dot-grid">
<CardHeader className="border-b border-dotted border-border/60">
<div className="flex items-center gap-2.5">
<div className="flex h-7 w-7 items-center justify-center rounded-md border border-border bg-muted/40">
<div className="flex h-7 w-7 items-center justify-center rounded-sm border border-dotted border-border bg-muted/40">
<Server className="h-3.5 w-3.5 text-muted-foreground" />
</div>
<CardTitle className="text-sm font-semibold">Node Health</CardTitle>
<DotMatrixText text="NODE HEALTH" size="xs" className="text-foreground" />
</div>
</CardHeader>
<CardContent>
<CardContent className="pt-4">
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{nodes.map((node) => (
<NodeCard key={node.node} node={node} vmCount={vmCounts[node.node] ?? 0} />
+19 -18
View File
@@ -1,4 +1,5 @@
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Card, CardContent, CardHeader } from '@/components/ui/card'
import { DotMatrixText } from '@/components/ui/dot-matrix'
import { Button } from '@/components/ui/button'
import { RefreshCw, Server, Box, HardDrive, ListTodo, Shield, Zap } from 'lucide-react'
@@ -10,65 +11,65 @@ interface QuickActionsProps {
export function QuickActions({ onRefresh, isRefreshing, onNavigate }: QuickActionsProps) {
return (
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0">
<Card className="dot-grid">
<CardHeader className="flex flex-row items-center justify-between space-y-0 border-b border-dotted border-border/60">
<div className="flex items-center gap-2.5">
<div className="flex h-7 w-7 items-center justify-center rounded-md border border-border bg-muted/40">
<div className="flex h-7 w-7 items-center justify-center rounded-sm border border-dotted border-border bg-muted/40">
<Zap className="h-3.5 w-3.5 text-muted-foreground" />
</div>
<CardTitle className="text-sm font-semibold">Quick Actions</CardTitle>
<DotMatrixText text="QUICK ACTIONS" size="xs" className="text-foreground" />
</div>
</CardHeader>
<CardContent>
<CardContent className="pt-4">
<div className="grid grid-cols-2 sm:grid-cols-3 gap-2">
<Button
variant="outline"
className="flex h-auto flex-col items-center gap-1.5 px-2 py-3"
className="flex h-auto flex-col items-center gap-1.5 border-dotted px-2 py-3"
onClick={onRefresh}
disabled={isRefreshing}
>
<RefreshCw className={`h-4 w-4 ${isRefreshing ? 'animate-spin' : ''}`} />
<span className="text-xs font-medium">Refresh</span>
<DotMatrixText text="REFRESH" size="xs" className="text-muted-foreground" />
</Button>
<Button
variant="outline"
className="flex h-auto flex-col items-center gap-1.5 px-2 py-3"
className="flex h-auto flex-col items-center gap-1.5 border-dotted px-2 py-3"
onClick={() => onNavigate?.('nodes')}
>
<Server className="h-4 w-4" />
<span className="text-xs font-medium">Nodes</span>
<DotMatrixText text="NODES" size="xs" className="text-muted-foreground" />
</Button>
<Button
variant="outline"
className="flex h-auto flex-col items-center gap-1.5 px-2 py-3"
className="flex h-auto flex-col items-center gap-1.5 border-dotted px-2 py-3"
onClick={() => onNavigate?.('vms')}
>
<Box className="h-4 w-4" />
<span className="text-xs font-medium">VMs</span>
<DotMatrixText text="VMS" size="xs" className="text-muted-foreground" />
</Button>
<Button
variant="outline"
className="flex h-auto flex-col items-center gap-1.5 px-2 py-3"
className="flex h-auto flex-col items-center gap-1.5 border-dotted px-2 py-3"
onClick={() => onNavigate?.('storage')}
>
<HardDrive className="h-4 w-4" />
<span className="text-xs font-medium">Storage</span>
<DotMatrixText text="STORAGE" size="xs" className="text-muted-foreground" />
</Button>
<Button
variant="outline"
className="flex h-auto flex-col items-center gap-1.5 px-2 py-3"
className="flex h-auto flex-col items-center gap-1.5 border-dotted px-2 py-3"
onClick={() => onNavigate?.('tasks')}
>
<ListTodo className="h-4 w-4" />
<span className="text-xs font-medium">Tasks</span>
<DotMatrixText text="TASKS" size="xs" className="text-muted-foreground" />
</Button>
<Button
variant="outline"
className="flex h-auto flex-col items-center gap-1.5 px-2 py-3"
className="flex h-auto flex-col items-center gap-1.5 border-dotted px-2 py-3"
onClick={() => onNavigate?.('backups')}
>
<Shield className="h-4 w-4" />
<span className="text-xs font-medium">Backups</span>
<DotMatrixText text="BACKUPS" size="xs" className="text-muted-foreground" />
</Button>
</div>
</CardContent>
+13 -19
View File
@@ -1,4 +1,5 @@
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Card, CardContent, CardHeader } from '@/components/ui/card'
import { DotMatrixText } from '@/components/ui/dot-matrix'
import { Cpu, MemoryStick, HardDrive } from 'lucide-react'
import { formatBytes } from '@/lib/format'
@@ -46,45 +47,40 @@ function getTrackColor(): string {
export function ResourceGauge({ label, used, total, icon, formatValue }: ResourceGaugeProps) {
const percent = total > 0 ? used / total : 0
const clampedPercent = Math.min(Math.max(percent, 0), 1)
const displayPercent = (clampedPercent * 100).toFixed(1)
const displayPercent = `${(clampedPercent * 100).toFixed(1)}%`
const Icon = iconMap[icon]
// SVG circle parameters
const radius = 40
const strokeWidth = 8
const circumference = 2 * Math.PI * radius
const strokeDashoffset = circumference * (1 - clampedPercent)
const defaultFormat =
icon === 'cpu'
? formatCores
: formatBytes
const defaultFormat = icon === 'cpu' ? formatCores : formatBytes
const formatter = formatValue ?? defaultFormat
const gaugeStyle = getGaugeStyle(clampedPercent)
return (
<Card>
<Card className="dot-grid">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-3">
<CardTitle className="text-sm font-medium text-muted-foreground">{label}</CardTitle>
<div className="flex h-7 w-7 items-center justify-center rounded-md border border-border bg-muted/40">
<DotMatrixText text={label} size="xs" className="text-muted-foreground" />
<div className="flex h-7 w-7 items-center justify-center rounded-sm border border-dotted border-border bg-muted/40">
<Icon className="h-3.5 w-3.5 text-muted-foreground" />
</div>
</CardHeader>
<CardContent className="flex flex-col items-center gap-3">
<div className="relative">
<svg width="108" height="108" viewBox="0 0 100 100">
{/* Background track */}
<circle
cx="50"
cy="50"
r={radius}
fill="none"
strokeWidth={strokeWidth}
strokeWidth={strokeWidth - 1}
strokeDasharray="1 3"
strokeLinecap="round"
className={getTrackColor()}
opacity="0.35"
/>
{/* Progress arc */}
<circle
cx="50"
cy="50"
@@ -92,7 +88,7 @@ export function ResourceGauge({ label, used, total, icon, formatValue }: Resourc
fill="none"
strokeWidth={strokeWidth}
strokeLinecap="round"
strokeDasharray={circumference}
strokeDasharray={`${circumference * 0.018} ${circumference * 0.012}`}
strokeDashoffset={strokeDashoffset}
className={gaugeStyle.className}
style={{
@@ -104,9 +100,7 @@ export function ResourceGauge({ label, used, total, icon, formatValue }: Resourc
/>
</svg>
<div className="absolute inset-0 flex flex-col items-center justify-center">
<span className="font-mono text-xl font-semibold leading-none tracking-tight tabular-nums">
{displayPercent}%
</span>
<DotMatrixText text={displayPercent} size="xs" className="text-foreground" />
</div>
</div>
<div className="text-center font-mono text-xs tabular-nums text-muted-foreground">
+31 -47
View File
@@ -1,7 +1,8 @@
import { useCallback, useState } from 'react'
import { useQueryClient } from '@tanstack/react-query'
import { useNodes, useVMs, useTasks, queryKeys } from '@/hooks/useProxmox'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Card, CardContent, CardHeader } from '@/components/ui/card'
import { DotMatrixText } from '@/components/ui/dot-matrix'
import { Server, Cpu, HardDrive, MemoryStick, AlertCircle } from 'lucide-react'
import { ResourceGauge } from '@/components/dashboard/ResourceGauge'
import { NodeHealthGrid } from '@/components/dashboard/NodeHealthGrid'
@@ -31,9 +32,6 @@ export function Dashboard({ connectionId, onNavigate }: DashboardProps) {
const formatPercent = (value: number) => `${(value * 100).toFixed(1)}%`
// Track actual refresh activity rather than piggybacking on tasksLoading,
// which is true whenever the tasks query is fetching (e.g. background
// polling) and would keep the Refresh button spinning continuously.
const [refreshing, setRefreshing] = useState(false)
const handleRefresh = useCallback(async () => {
@@ -60,19 +58,19 @@ export function Dashboard({ connectionId, onNavigate }: DashboardProps) {
const hasError = nodesError || vmsError || tasksError
if (hasError) {
return (
<div className="flex h-full items-center justify-center p-6">
<div className="flex flex-col items-center text-center">
<div className="mb-4 flex h-12 w-12 items-center justify-center rounded-lg border border-destructive/25 bg-destructive/10">
<div className="flex h-full items-center justify-center p-6 dot-grid">
<div className="flex flex-col items-center text-center rounded-lg border border-dotted border-destructive/30 bg-card p-8 shadow-card">
<div className="mb-4 flex h-12 w-12 items-center justify-center rounded-sm border border-dotted border-destructive/30 bg-destructive/10">
<AlertCircle className="h-5 w-5 text-destructive" />
</div>
<h3 className="text-lg font-semibold tracking-tight">Unable to load dashboard</h3>
<p className="mt-1 max-w-sm text-sm text-muted-foreground">
<DotMatrixText text="LOAD FAILED" size="sm" className="text-destructive" />
<p className="mt-2 max-w-sm text-sm text-muted-foreground">
{nodesError?.message || vmsError?.message || tasksError?.message || 'An error occurred while loading data'}
</p>
<p className="mt-1 max-w-sm text-sm text-muted-foreground">
Check the connection to your Proxmox server and try again.
</p>
<Button variant="outline" className="mt-4" onClick={handleRetry}>
<Button variant="outline" className="mt-4 border-dotted" onClick={handleRetry}>
Retry
</Button>
</div>
@@ -81,80 +79,69 @@ export function Dashboard({ connectionId, onNavigate }: DashboardProps) {
}
return (
<div className="h-full overflow-auto p-6">
<div className="h-full overflow-auto p-6 dot-grid">
<div className="space-y-6">
<div>
<h2 className="text-[1.625rem] font-semibold leading-tight tracking-[-0.02em]">
Dashboard
</h2>
<p className="mt-1 text-sm text-muted-foreground">
<DotMatrixText text="DASHBOARD" size="md" className="text-foreground" />
<p className="mt-2 text-sm text-muted-foreground">
Cluster overview and resource usage
</p>
<hr className="dot-rule mt-3" />
</div>
{/* Summary Stats Row */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
<Card>
<Card className="dot-grid">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-3">
<CardTitle className="text-sm font-medium text-muted-foreground">Nodes</CardTitle>
<div className="flex h-7 w-7 items-center justify-center rounded-md border border-border bg-muted/40">
<DotMatrixText text="NODES" size="xs" className="text-muted-foreground" />
<div className="flex h-7 w-7 items-center justify-center rounded-sm border border-dotted border-border bg-muted/40">
<Server className="h-3.5 w-3.5 text-muted-foreground" />
</div>
</CardHeader>
<CardContent>
<div className="font-mono text-2xl font-semibold leading-none tracking-tight tabular-nums">
{nodes?.filter((n) => n.status === 'online').length ?? 0}
<span className="text-muted-foreground"> / {nodes?.length ?? 0}</span>
</div>
<p className="mt-1.5 text-xs text-muted-foreground">Online</p>
<DotMatrixText text={`${nodes?.filter((n) => n.status === 'online').length ?? 0} / ${nodes?.length ?? 0}`} size="sm" className="text-foreground" />
<p className="mt-1.5 text-xs tracking-widest text-muted-foreground">ONLINE</p>
</CardContent>
</Card>
<Card>
<Card className="dot-grid">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-3">
<CardTitle className="text-sm font-medium text-muted-foreground">CPU Usage</CardTitle>
<div className="flex h-7 w-7 items-center justify-center rounded-md border border-border bg-muted/40">
<DotMatrixText text="CPU USAGE" size="xs" className="text-muted-foreground" />
<div className="flex h-7 w-7 items-center justify-center rounded-sm border border-dotted border-border bg-muted/40">
<Cpu className="h-3.5 w-3.5 text-muted-foreground" />
</div>
</CardHeader>
<CardContent>
<div className="font-mono text-2xl font-semibold leading-none tracking-tight tabular-nums">
{totalCPU > 0 ? formatPercent(usedCPU / totalCPU) : '0%'}
</div>
<DotMatrixText text={totalCPU > 0 ? formatPercent(usedCPU / totalCPU) : '0%'} size="sm" className="text-foreground" />
<p className="mt-1.5 font-mono text-xs tabular-nums text-muted-foreground">
{usedCPU.toFixed(1)} / {totalCPU} cores
</p>
</CardContent>
</Card>
<Card>
<Card className="dot-grid">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-3">
<CardTitle className="text-sm font-medium text-muted-foreground">Memory</CardTitle>
<div className="flex h-7 w-7 items-center justify-center rounded-md border border-border bg-muted/40">
<DotMatrixText text="MEMORY" size="xs" className="text-muted-foreground" />
<div className="flex h-7 w-7 items-center justify-center rounded-sm border border-dotted border-border bg-muted/40">
<MemoryStick className="h-3.5 w-3.5 text-muted-foreground" />
</div>
</CardHeader>
<CardContent>
<div className="font-mono text-2xl font-semibold leading-none tracking-tight tabular-nums">
{totalMem > 0 ? formatPercent(usedMem / totalMem) : '0%'}
</div>
<DotMatrixText text={totalMem > 0 ? formatPercent(usedMem / totalMem) : '0%'} size="sm" className="text-foreground" />
<p className="mt-1.5 font-mono text-xs tabular-nums text-muted-foreground">
{formatBytes(usedMem)} / {formatBytes(totalMem)}
</p>
</CardContent>
</Card>
<Card>
<Card className="dot-grid">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-3">
<CardTitle className="text-sm font-medium text-muted-foreground">Storage</CardTitle>
<div className="flex h-7 w-7 items-center justify-center rounded-md border border-border bg-muted/40">
<DotMatrixText text="STORAGE" size="xs" className="text-muted-foreground" />
<div className="flex h-7 w-7 items-center justify-center rounded-sm border border-dotted border-border bg-muted/40">
<HardDrive className="h-3.5 w-3.5 text-muted-foreground" />
</div>
</CardHeader>
<CardContent>
<div className="font-mono text-2xl font-semibold leading-none tracking-tight tabular-nums">
{totalDisk > 0 ? formatPercent(usedDisk / totalDisk) : '0%'}
</div>
<DotMatrixText text={totalDisk > 0 ? formatPercent(usedDisk / totalDisk) : '0%'} size="sm" className="text-foreground" />
<p className="mt-1.5 font-mono text-xs tabular-nums text-muted-foreground">
{formatBytes(usedDisk)} / {formatBytes(totalDisk)}
</p>
@@ -162,7 +149,6 @@ export function Dashboard({ connectionId, onNavigate }: DashboardProps) {
</Card>
</div>
{/* Resource Gauges */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<ResourceGauge
label="CPU"
@@ -172,14 +158,14 @@ export function Dashboard({ connectionId, onNavigate }: DashboardProps) {
formatValue={(v) => `${v.toFixed(1)} cores`}
/>
<ResourceGauge
label="Memory"
label="MEMORY"
used={usedMem}
total={totalMem}
icon="memory"
formatValue={formatBytes}
/>
<ResourceGauge
label="Storage"
label="STORAGE"
used={usedDisk}
total={totalDisk}
icon="disk"
@@ -187,10 +173,8 @@ export function Dashboard({ connectionId, onNavigate }: DashboardProps) {
/>
</div>
{/* Node Health Grid */}
<NodeHealthGrid nodes={nodes} vms={vms} />
{/* Activity Feed + Quick Actions */}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-4">
<div className="lg:col-span-2">
<ActivityFeed tasks={tasks} isLoading={tasksLoading} />
+33 -34
View File
@@ -1,6 +1,7 @@
import { useConnectionStore } from '@/stores/connectionStore'
import { ScrollArea } from '@/components/ui/scroll-area'
import { Button } from '@/components/ui/button'
import { DotMatrixText } from '@/components/ui/dot-matrix'
import { Plus, Server, LayoutDashboard, HardDrive, Box, ListTodo, Shield, Settings } from 'lucide-react'
import { cn } from '@/lib/utils'
@@ -36,16 +37,13 @@ export function Sidebar({ onAddConnection, activeView, onNavigate }: SidebarProp
}
return (
<div className="flex w-64 flex-col border-r border-border bg-card">
<div className="flex h-14 shrink-0 items-center gap-2.5 border-b border-border px-4">
<img src="/icon.png" alt="Clustri" className="h-7 w-7 rounded-md" />
<div className="flex w-64 flex-col border-r border-dotted border-border bg-card dot-grid">
<div className="flex h-14 shrink-0 items-center gap-2.5 border-b border-dotted border-border px-4 bg-card/80">
<div className="flex h-7 w-7 shrink-0 items-center justify-center rounded-sm border border-dotted border-border bg-primary/10 text-primary">
<Server className="h-3.5 w-3.5" />
</div>
<div className="min-w-0 leading-tight">
<h1 className="truncate text-sm font-semibold tracking-tight text-foreground">
Clustri
</h1>
<p className="text-[10px] font-medium uppercase tracking-[0.14em] text-muted-foreground">
Ops Console
</p>
<DotMatrixText text="CLUSTRI" size="xs" className="text-foreground" />
</div>
</div>
<ScrollArea className="flex-1">
@@ -55,10 +53,10 @@ export function Sidebar({ onAddConnection, activeView, onNavigate }: SidebarProp
<button
onClick={() => setActiveConnection(connection.id)}
className={cn(
'flex w-full items-center gap-2.5 rounded-md px-3 py-2 text-sm transition duration-150',
'flex w-full items-center gap-2.5 rounded-md px-3 py-2 text-sm transition duration-150 border border-transparent',
activeConnectionId === connection.id
? 'bg-accent text-accent-foreground'
: 'text-muted-foreground hover:bg-accent/60 hover:text-foreground'
? 'bg-accent text-accent-foreground border-dotted border-border'
: 'text-muted-foreground hover:bg-accent/60 hover:text-foreground hover:border-dotted hover:border-border/60',
)}
>
<span className={cn('h-1.5 w-1.5 shrink-0 rounded-full', getStatusColor(connection.status))} />
@@ -79,59 +77,60 @@ export function Sidebar({ onAddConnection, activeView, onNavigate }: SidebarProp
<>
<SidebarItem
icon={LayoutDashboard}
label="Overview"
label="OVERVIEW"
active={activeView === 'pbs-overview'}
onClick={() => onNavigate?.({ type: 'pbs-overview' })}
/>
<SidebarItem
icon={HardDrive}
label="Datastores"
label="DATASTORES"
active={activeView === 'pbs-datastores' || activeView === 'pbs-datastore-detail'}
onClick={() => onNavigate?.({ type: 'pbs-datastores' })}
/>
<SidebarItem icon={ListTodo} label="Tasks" active={activeView === 'tasks'} onClick={() => onNavigate?.({ type: 'tasks' })} />
<SidebarItem icon={ListTodo} label="TASKS" active={activeView === 'tasks'} onClick={() => onNavigate?.({ type: 'tasks' })} />
</>
) : (
<>
<SidebarItem
icon={LayoutDashboard}
label="Dashboard"
label="DASHBOARD"
active={activeView === 'dashboard'}
onClick={() => onNavigate?.({ type: 'dashboard' })}
/>
<SidebarItem
icon={Server}
label="Nodes"
label="NODES"
active={activeView === 'nodes'}
onClick={() => onNavigate?.({ type: 'nodes' })}
/>
<SidebarItem
icon={Box}
label="VMs"
label="VMS"
active={activeView === 'vms' || activeView === 'vm-detail'}
onClick={() => onNavigate?.({ type: 'vms' })}
/>
<SidebarItem
icon={Box}
label="Containers"
label="CONTAINERS"
active={activeView === 'containers'}
onClick={() => onNavigate?.({ type: 'containers' })}
/>
<SidebarItem icon={HardDrive} label="Storage" active={activeView === 'storage' || activeView === 'storage-detail'} onClick={() => onNavigate?.({ type: 'storage' })} />
<SidebarItem icon={ListTodo} label="Tasks" active={activeView === 'tasks'} onClick={() => onNavigate?.({ type: 'tasks' })} />
<SidebarItem icon={Shield} label="Backups" active={activeView === 'backups'} onClick={() => onNavigate?.({ type: 'backups' })} />
<SidebarItem icon={HardDrive} label="STORAGE" active={activeView === 'storage' || activeView === 'storage-detail'} onClick={() => onNavigate?.({ type: 'storage' })} />
<SidebarItem icon={ListTodo} label="TASKS" active={activeView === 'tasks'} onClick={() => onNavigate?.({ type: 'tasks' })} />
<SidebarItem icon={Shield} label="BACKUPS" active={activeView === 'backups'} onClick={() => onNavigate?.({ type: 'backups' })} />
</>
)}
{connection.serverType !== 'pbs' && connection.nodes && connection.nodes.length > 0 && (
<>
<p className="px-3 pb-1 pt-3 text-[10px] font-medium uppercase tracking-[0.14em] text-muted-foreground">
Cluster nodes
</p>
<div className="px-3 pb-1 pt-3">
<DotMatrixText text="CLUSTER NODES" size="xs" className="text-muted-foreground opacity-60" />
</div>
<hr className="dot-rule mx-2" />
{connection.nodes.map((node) => (
<button
key={node.url}
onClick={() => onNavigate?.({ type: 'node-detail', nodeName: node.name })}
className="flex w-full items-center gap-2.5 rounded-md px-3 py-1.5 text-[13px] font-medium text-muted-foreground transition duration-150 hover:bg-accent/60 hover:text-foreground"
className="flex w-full items-center gap-2.5 rounded-md px-3 py-1.5 text-[13px] font-medium text-muted-foreground transition duration-150 hover:bg-accent/60 hover:text-foreground border border-transparent hover:border-dotted hover:border-border/50"
>
<span
className={cn(
@@ -141,7 +140,7 @@ export function Sidebar({ onAddConnection, activeView, onNavigate }: SidebarProp
/>
<span className="min-w-0 flex-1 truncate font-mono">{node.name}</span>
{node.isPrimary && (
<span className="shrink-0 rounded border border-primary/30 bg-primary/10 px-1 py-px text-[10px] uppercase text-primary">
<span className="shrink-0 rounded-sm border border-dotted border-primary/40 bg-primary/10 px-1 py-px text-[10px] uppercase tracking-widest text-primary">
primary
</span>
)}
@@ -161,16 +160,16 @@ export function Sidebar({ onAddConnection, activeView, onNavigate }: SidebarProp
))}
</div>
</ScrollArea>
<div className="space-y-1 border-t border-border p-2">
<div className="space-y-1 border-t border-dotted border-border p-2 bg-card/60">
<SidebarItem
icon={Settings}
label="Settings"
label="SETTINGS"
active={activeView === 'settings'}
onClick={() => onNavigate?.({ type: 'settings' })}
/>
<Button
variant="outline"
className="w-full justify-start gap-2"
className="w-full justify-start gap-2 border-dotted"
onClick={onAddConnection}
>
<Plus className="h-4 w-4" />
@@ -199,19 +198,19 @@ function SidebarItem({
onClick={onClick}
disabled={disabled}
className={cn(
'relative flex w-full items-center gap-2.5 rounded-md px-3 py-1.5 text-[13px] font-medium transition duration-150',
'relative flex w-full items-center gap-2.5 rounded-md px-3 py-1.5 text-[13px] font-medium transition duration-150 border border-transparent',
disabled
? 'pointer-events-none cursor-not-allowed opacity-50'
: active
? 'bg-primary/10 text-primary hover:bg-primary/15'
: 'text-muted-foreground hover:bg-accent/60 hover:text-foreground'
? 'bg-primary/10 text-primary border-dotted border-primary/20'
: 'text-muted-foreground hover:bg-accent/60 hover:text-foreground hover:border-dotted hover:border-border/50',
)}
>
{active && (
<span className="absolute left-0 top-1/2 h-4 w-0.5 -translate-y-1/2 rounded-full bg-primary" />
)}
<Icon className="h-4 w-4 shrink-0" />
<span>{label}</span>
<DotMatrixText text={label} size="xs" className={active ? 'text-primary' : 'text-muted-foreground'} dotClassName={active ? 'text-primary' : ''} />
</button>
)
}
+6 -7
View File
@@ -1,5 +1,6 @@
import { useMemo } from 'react'
import { ListTodo, Loader2 } from 'lucide-react'
import { DotMatrixText } from '@/components/ui/dot-matrix'
import { useTasks } from '@/hooks/useProxmox'
import type { ProxmoxTask } from '@/types/proxmox'
@@ -27,21 +28,19 @@ export function TaskStatusBar({ connectionId, onClick }: TaskStatusBarProps) {
return (
<button
onClick={onClick}
className="w-full flex items-center gap-2 px-3 py-2 rounded-md text-sm transition-colors duration-150 hover:bg-accent/50 text-muted-foreground hover:text-accent-foreground"
className="group flex w-full items-center gap-2 rounded-md border border-transparent px-3 py-2 text-sm transition-colors duration-150 hover:border-dotted hover:border-border/60 hover:bg-accent/50 text-muted-foreground hover:text-accent-foreground"
>
<ListTodo className="h-4 w-4" />
<ListTodo className="h-4 w-4 transition-colors group-hover:text-foreground" />
<span className="flex-1 text-left">
{runningCount > 0 ? (
<span className="flex items-center gap-1.5">
<span className="font-medium text-info">{runningCount} task{runningCount !== 1 ? 's' : ''} running</span>
<DotMatrixText text={`${runningCount} TASK${runningCount !== 1 ? 'S' : ''} RUNNING`} size="xs" className="text-info group-hover:text-foreground transition-colors" />
</span>
) : (
<span>No tasks running</span>
<DotMatrixText text="NO TASKS RUNNING" size="xs" className="text-muted-foreground group-hover:text-foreground transition-colors" />
)}
</span>
{runningCount > 0 && (
<Loader2 className="h-3 w-3 text-info animate-spin" />
)}
{runningCount > 0 && <Loader2 className="h-3 w-3 text-info group-hover:text-foreground transition-colors animate-spin" />}
</button>
)
}
+3 -3
View File
@@ -13,7 +13,7 @@ const buttonVariants = cva(
destructive:
'bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90',
outline:
'border border-input bg-background shadow-sm hover:border-border hover:bg-accent hover:text-accent-foreground',
'border border-dotted border-input bg-background shadow-sm hover:border-border hover:bg-accent hover:text-accent-foreground',
secondary:
'bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80',
ghost:
@@ -31,7 +31,7 @@ const buttonVariants = cva(
variant: 'default',
size: 'default',
},
}
},
)
export interface ButtonProps
@@ -50,7 +50,7 @@ const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
{...props}
/>
)
}
},
)
Button.displayName = 'Button'
+3 -3
View File
@@ -8,8 +8,8 @@ const Card = React.forwardRef<
<div
ref={ref}
className={cn(
'rounded-lg border bg-card text-card-foreground shadow-card',
className
'rounded-lg border border-dotted bg-card text-card-foreground shadow-card',
className,
)}
{...props}
/>
@@ -36,7 +36,7 @@ const CardTitle = React.forwardRef<
ref={ref}
className={cn(
'font-semibold leading-none tracking-tight text-foreground',
className
className,
)}
{...props}
/>
+6 -6
View File
@@ -16,7 +16,7 @@ const DialogOverlay = React.forwardRef<
ref={ref}
className={cn(
'fixed inset-0 z-50 bg-black/60 backdrop-blur-sm data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
className
className,
)}
{...props}
/>
@@ -32,8 +32,8 @@ const DialogContent = React.forwardRef<
<DialogPrimitive.Content
ref={ref}
className={cn(
'fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-popover p-6 text-popover-foreground shadow-2xl shadow-black/20 duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg',
className
'fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border border-dotted bg-popover p-6 text-popover-foreground shadow-2xl shadow-black/20 duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg dot-grid',
className,
)}
{...props}
>
@@ -54,7 +54,7 @@ const DialogHeader = ({
<div
className={cn(
'flex flex-col space-y-1.5 text-center sm:text-left',
className
className,
)}
{...props}
/>
@@ -68,7 +68,7 @@ const DialogFooter = ({
<div
className={cn(
'flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2',
className
className,
)}
{...props}
/>
@@ -83,7 +83,7 @@ const DialogTitle = React.forwardRef<
ref={ref}
className={cn(
'text-base font-semibold leading-none tracking-tight',
className
className,
)}
{...props}
/>
+107
View File
@@ -0,0 +1,107 @@
import { GLYPH_COLS, GLYPH_ROWS, getGlyph } from '@/lib/dotMatrixGlyphs'
import { cn } from '@/lib/utils'
type DotMatrixSize = 'xs' | 'sm' | 'md' | 'lg' | 'xl'
const sizeMap: Record<DotMatrixSize, { dot: number; gap: number; charGap: number }> = {
xs: { dot: 1.8, gap: 1.3, charGap: 5 },
sm: { dot: 2.2, gap: 1.5, charGap: 6 },
md: { dot: 2.6, gap: 1.8, charGap: 7 },
lg: { dot: 3.0, gap: 2.0, charGap: 9 },
xl: { dot: 3.8, gap: 2.4, charGap: 12 },
}
interface DotMatrixTextProps {
text: string
size?: DotMatrixSize
className?: string
dotClassName?: string
'aria-label'?: string
}
function CharSvg({
glyph,
dot,
gap,
}: {
glyph: readonly string[]
dot: number
gap: number
}) {
const w = GLYPH_COLS * dot + (GLYPH_COLS - 1) * gap
const h = GLYPH_ROWS * dot + (GLYPH_ROWS - 1) * gap
const r = dot / 2
return (
<svg
width={w}
height={h}
viewBox={`0 0 ${w} ${h}`}
aria-hidden="true"
className="shrink-0"
shapeRendering="geometricPrecision"
>
{glyph.map((row, y) =>
Array.from(row).map((cell, x) => {
if (cell === ' ') return null
const cx = x * (dot + gap) + r
const cy = y * (dot + gap) + r
return <circle key={`${x}-${y}`} cx={cx} cy={cy} r={r} fill="currentColor" />
}),
)}
</svg>
)
}
export function DotMatrixText({
text,
size = 'sm',
className,
dotClassName,
'aria-label': ariaLabel,
}: DotMatrixTextProps) {
const { dot, gap, charGap } = sizeMap[size]
const chars = Array.from(text.toUpperCase())
const label = ariaLabel ?? text
return (
<span
aria-label={label}
role="text"
className={cn('inline-flex items-center leading-none', dotClassName, className)}
style={{ gap: charGap }}
>
{chars.map((ch, i) => {
if (ch === ' ') {
return <span key={i} style={{ width: charGap * 1.8 }} aria-hidden="true" />
}
const glyph = getGlyph(ch)
if (!glyph) {
return (
<span
key={i}
className="font-mono text-[0.7em] leading-none"
aria-hidden="true"
>
{ch}
</span>
)
}
return <CharSvg key={i} glyph={glyph} dot={dot} gap={gap} />
})}
</span>
)
}
export function DotMatrixHeading({
text,
size = 'md',
className,
dotClassName,
as: Tag = 'span',
...rest
}: DotMatrixTextProps & { as?: 'h1' | 'h2' | 'h3' | 'span' | 'p' | 'div' }) {
return (
<Tag className={cn('block', className)} {...rest}>
<DotMatrixText text={text} size={size} dotClassName={dotClassName} />
</Tag>
)
}
+5 -4
View File
@@ -1,5 +1,6 @@
import type { ReactNode } from 'react'
import type { LucideIcon } from 'lucide-react'
import { DotMatrixText } from '@/components/ui/dot-matrix'
interface EmptyStateProps {
icon: LucideIcon
@@ -10,13 +11,13 @@ interface EmptyStateProps {
export function EmptyState({ icon: Icon, title, description, action }: EmptyStateProps) {
return (
<div className="flex flex-col items-center justify-center px-6 py-12 text-center">
<div className="mb-3 flex h-12 w-12 items-center justify-center rounded-lg border border-border bg-muted/50">
<div className="flex flex-col items-center justify-center px-6 py-12 text-center dot-grid">
<div className="mb-3 flex h-12 w-12 items-center justify-center rounded-sm border border-dotted border-border bg-muted/50">
<Icon className="h-5 w-5 text-muted-foreground" />
</div>
<p className="text-sm font-medium text-foreground">{title}</p>
<DotMatrixText text={title.toUpperCase()} size="xs" className="text-foreground" />
{description && (
<p className="mt-1 max-w-sm text-sm text-muted-foreground">{description}</p>
<p className="mt-2 max-w-sm text-sm text-muted-foreground">{description}</p>
)}
{action && <div className="mt-4">{action}</div>}
</div>
+3 -3
View File
@@ -7,14 +7,14 @@ const Input = React.forwardRef<HTMLInputElement, React.ComponentProps<'input'>>(
<input
type={type}
className={cn(
'flex h-9 w-full rounded-md border border-input bg-background px-3 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50',
className
'flex h-9 w-full rounded-md border border-dotted border-input bg-background px-3 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50',
className,
)}
ref={ref}
{...props}
/>
)
}
},
)
Input.displayName = 'Input'
+14 -14
View File
@@ -10,33 +10,33 @@ interface StatusBadgeProps {
const statusConfig: Record<BadgeStatus, { label: string; color: string; icon: React.ComponentType<{ className?: string }> }> = {
running: {
label: 'Running',
color: 'border-success/25 bg-success/10 text-success',
label: 'RUNNING',
color: 'border-dotted border-success/30 bg-success/10 text-success',
icon: CheckCircle,
},
stopped: {
label: 'Stopped',
color: 'border-border bg-muted/60 text-muted-foreground',
label: 'STOPPED',
color: 'border-dotted border-border bg-muted/60 text-muted-foreground',
icon: Square,
},
paused: {
label: 'Paused',
color: 'border-warning/25 bg-warning/10 text-warning',
label: 'PAUSED',
color: 'border-dotted border-warning/30 bg-warning/10 text-warning',
icon: Pause,
},
suspended: {
label: 'Suspended',
color: 'border-warning/25 bg-warning/10 text-warning',
label: 'SUSPENDED',
color: 'border-dotted border-warning/30 bg-warning/10 text-warning',
icon: Clock,
},
online: {
label: 'Online',
color: 'border-success/25 bg-success/10 text-success',
label: 'ONLINE',
color: 'border-dotted border-success/30 bg-success/10 text-success',
icon: CheckCircle,
},
offline: {
label: 'Offline',
color: 'border-destructive/25 bg-destructive/10 text-destructive',
label: 'OFFLINE',
color: 'border-dotted border-destructive/30 bg-destructive/10 text-destructive',
icon: XCircle,
},
}
@@ -48,9 +48,9 @@ export function StatusBadge({ status, className }: StatusBadgeProps) {
return (
<span
className={cn(
'inline-flex items-center gap-1.5 rounded-sm border px-2 py-0.5 text-xs font-medium',
'inline-flex items-center gap-1.5 rounded-sm border border-dotted px-2 py-0.5 text-[11px] font-medium tracking-widest',
config.color,
className
className,
)}
>
<Icon className="h-3 w-3" />
+5 -5
View File
@@ -11,8 +11,8 @@ const TabsList = React.forwardRef<
<TabsPrimitive.List
ref={ref}
className={cn(
'inline-flex h-9 items-center justify-center rounded-md bg-secondary p-1 text-muted-foreground',
className
'inline-flex h-9 items-center justify-center rounded-md border border-dotted border-border bg-secondary p-1 text-muted-foreground dot-grid',
className,
)}
{...props}
/>
@@ -26,8 +26,8 @@ const TabsTrigger = React.forwardRef<
<TabsPrimitive.Trigger
ref={ref}
className={cn(
'inline-flex items-center justify-center whitespace-nowrap rounded-sm px-3 py-1 text-sm font-medium ring-offset-background transition-all duration-150 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=inactive]:text-muted-foreground data-[state=inactive]:hover:text-foreground data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-sm',
className
'inline-flex items-center justify-center whitespace-nowrap rounded-sm border border-transparent px-3 py-1 text-sm font-medium tracking-wide ring-offset-background transition-all duration-150 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=inactive]:text-muted-foreground data-[state=inactive]:hover:text-foreground data-[state=active]:border-dotted data-[state=active]:border-border data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-sm',
className,
)}
{...props}
/>
@@ -42,7 +42,7 @@ const TabsContent = React.forwardRef<
ref={ref}
className={cn(
'mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2',
className
className,
)}
{...props}
/>
+3 -3
View File
@@ -39,7 +39,7 @@ export function ToastProvider({ children }: { children: React.ReactNode }) {
setToasts((prev) => [...prev, { id, message, variant }])
setTimeout(() => removeToast(id), 5000)
},
[removeToast]
[removeToast],
)
return (
@@ -104,8 +104,8 @@ function ToastItem({
return (
<div
className={cn(
'flex items-start gap-3 rounded-md border border-border border-l-2 bg-card p-3.5 text-sm text-foreground shadow-lg shadow-black/10 dark:shadow-black/40',
config.bar
'flex items-start gap-3 rounded-md border border-dotted border-border border-l-2 bg-card p-3.5 text-sm text-foreground shadow-lg shadow-black/10 dark:shadow-black/40 dot-grid',
config.bar,
)}
style={{
animation: 'toast-slide-in 0.3s ease-out',
+157 -47
View File
@@ -1,9 +1,6 @@
@import 'tailwindcss';
@theme {
/* ------------------------------------------------------------------
* Typography offline-safe variable fonts (bundled via fontsource)
* ------------------------------------------------------------------ */
--font-sans:
'Geist Variable', 'Geist', ui-sans-serif, system-ui, -apple-system,
'Segoe UI', sans-serif;
@@ -11,9 +8,6 @@
'JetBrains Mono Variable', 'JetBrains Mono', ui-monospace, 'SF Mono',
Menlo, monospace;
/* ------------------------------------------------------------------
* Radius scale tighter on inner elements, softer on containers
* ------------------------------------------------------------------ */
--radius: 0.625rem;
--radius-sm: 0.375rem;
--radius-md: 0.5rem;
@@ -21,60 +15,59 @@
--radius-xl: 0.75rem;
/* ------------------------------------------------------------------
* Light theme (default) warm paper neutrals, cool ink, Proxmox accent
* Light cream paper, ink black, Proxmox orange ribbon
* ------------------------------------------------------------------ */
--color-background: oklch(0.972 0.004 85);
--color-foreground: oklch(0.24 0.012 250);
--color-card: oklch(0.998 0.002 85);
--color-card-foreground: oklch(0.24 0.012 250);
--color-popover: oklch(1 0 0);
--color-popover-foreground: oklch(0.24 0.012 250);
--color-background: oklch(0.985 0.006 85);
--color-foreground: oklch(0.18 0.012 250);
--color-card: oklch(0.994 0.003 85);
--color-card-foreground: oklch(0.18 0.012 250);
--color-popover: oklch(0.998 0.002 85);
--color-popover-foreground: oklch(0.18 0.012 250);
--color-primary: oklch(0.62 0.15 55);
--color-primary-foreground: oklch(0.985 0.008 60);
--color-secondary: oklch(0.95 0.005 85);
--color-secondary-foreground: oklch(0.3 0.012 250);
--color-muted: oklch(0.95 0.005 85);
--color-muted-foreground: oklch(0.51 0.014 250);
--color-accent: oklch(0.935 0.008 85);
--color-accent-foreground: oklch(0.28 0.012 250);
--color-secondary: oklch(0.94 0.01 85);
--color-secondary-foreground: oklch(0.22 0.012 250);
--color-muted: oklch(0.94 0.01 85);
--color-muted-foreground: oklch(0.56 0.012 250);
--color-accent: oklch(0.93 0.012 85);
--color-accent-foreground: oklch(0.22 0.012 250);
--color-destructive: oklch(0.56 0.19 25);
--color-destructive-foreground: oklch(0.98 0.008 25);
--color-border: oklch(0.905 0.006 85);
--color-input: oklch(0.885 0.007 85);
--color-border: oklch(0.88 0.012 85);
--color-input: oklch(0.86 0.014 85);
--color-ring: oklch(0.62 0.15 55);
/* Semantic status colors — data semantics, not UI accents */
--color-success: oklch(0.55 0.13 155);
--color-warning: oklch(0.62 0.13 80);
--color-info: oklch(0.55 0.11 245);
/* Tinted card shadow — carries the warm hue of the surface */
--shadow-card:
0 1px 2px 0 oklch(0.3 0.02 60 / 0.06), 0 2px 8px -2px oklch(0.3 0.02 60 / 0.05);
0 1px 0 0 oklch(0.18 0.012 250 / 0.06) inset,
0 1px 3px 0 oklch(0.18 0.012 250 / 0.07), 0 4px 12px -4px oklch(0.18 0.012 250 / 0.06);
}
.dark {
/* ------------------------------------------------------------------
* Dark theme blue-black ink surfaces, cool neutrals, muted orange
* Dark dark ink-paper, light ink, same orange ribbon
* ------------------------------------------------------------------ */
--color-background: oklch(0.16 0.011 250);
--color-foreground: oklch(0.965 0.004 250);
--color-card: oklch(0.19 0.012 250);
--color-card-foreground: oklch(0.965 0.004 250);
--color-popover: oklch(0.215 0.013 250);
--color-popover-foreground: oklch(0.965 0.004 250);
--color-background: oklch(0.145 0.012 250);
--color-foreground: oklch(0.96 0.005 85);
--color-card: oklch(0.182 0.014 250);
--color-card-foreground: oklch(0.96 0.005 85);
--color-popover: oklch(0.2 0.015 250);
--color-popover-foreground: oklch(0.96 0.005 85);
--color-primary: oklch(0.72 0.15 60);
--color-primary-foreground: oklch(0.18 0.02 50);
--color-secondary: oklch(0.235 0.011 250);
--color-secondary-foreground: oklch(0.94 0.004 250);
--color-muted: oklch(0.235 0.011 250);
--color-muted-foreground: oklch(0.655 0.012 250);
--color-accent: oklch(0.275 0.012 250);
--color-accent-foreground: oklch(0.965 0.004 250);
--color-secondary: oklch(0.22 0.014 250);
--color-secondary-foreground: oklch(0.92 0.004 85);
--color-muted: oklch(0.22 0.014 250);
--color-muted-foreground: oklch(0.68 0.012 250);
--color-accent: oklch(0.24 0.014 250);
--color-accent-foreground: oklch(0.96 0.005 85);
--color-destructive: oklch(0.645 0.18 25);
--color-destructive-foreground: oklch(0.97 0.015 25);
--color-border: oklch(0.255 0.012 250);
--color-input: oklch(0.3 0.013 250);
--color-border: oklch(0.26 0.014 250);
--color-input: oklch(0.28 0.015 250);
--color-ring: oklch(0.72 0.15 60);
--color-success: oklch(0.7 0.14 155);
@@ -82,7 +75,118 @@
--color-info: oklch(0.68 0.11 245);
--shadow-card:
0 1px 2px 0 oklch(0.06 0.01 250 / 0.4), 0 2px 10px -2px oklch(0.06 0.01 250 / 0.35);
0 1px 0 0 oklch(1 0 0 / 0.05) inset, 0 1px 2px 0 oklch(0 0 0 / 0.45),
0 8px 24px -8px oklch(0 0 0 / 0.5);
}
/* ── Dot-matrix texture utilities ───────────────────────────────── */
.dot-grid {
background-image: radial-gradient(
circle,
oklch(0.18 0.012 250 / 0.09) 1px,
transparent 1px
);
background-size: 14px 14px;
}
.dark .dot-grid {
background-image: radial-gradient(
circle,
oklch(1 0 0 / 0.07) 1px,
transparent 1px
);
}
.dot-grid-strong {
background-image: radial-gradient(
circle,
oklch(0.18 0.012 250 / 0.13) 1.1px,
transparent 1.1px
);
background-size: 10px 10px;
}
.dark .dot-grid-strong {
background-image: radial-gradient(
circle,
oklch(1 0 0 / 0.1) 1.1px,
transparent 1.1px
);
}
.dot-rule {
border: none;
border-top: 1.5px dotted oklch(0.18 0.012 250 / 0.18);
height: 0;
}
.dark .dot-rule {
border-top-color: oklch(1 0 0 / 0.12);
}
.perf-edge {
position: relative;
}
.perf-edge::before,
.perf-edge::after {
content: '';
position: absolute;
top: 8px;
bottom: 8px;
width: 6px;
background-image: radial-gradient(
circle,
oklch(0.18 0.012 250 / 0.14) 2px,
transparent 2px
);
background-size: 6px 12px;
background-repeat: repeat-y;
pointer-events: none;
}
.perf-edge::before {
left: 4px;
}
.perf-edge::after {
right: 4px;
}
.dark .perf-edge::before,
.dark .perf-edge::after {
background-image: radial-gradient(
circle,
oklch(1 0 0 / 0.1) 2px,
transparent 2px
);
}
.halftone-fill {
position: relative;
}
.halftone-fill::after {
content: '';
position: absolute;
inset: 0;
background-image: radial-gradient(
circle,
oklch(1 0 0 / 0.55) 0.9px,
transparent 0.9px
);
background-size: 5px 5px;
opacity: 0.9;
pointer-events: none;
border-radius: inherit;
}
.dark .halftone-fill::after {
background-image: radial-gradient(
circle,
oklch(1 0 0 / 0.35) 0.9px,
transparent 0.9px
);
}
.feed-rule {
border-bottom: 1px dotted oklch(0.18 0.012 250 / 0.12);
}
.dark .feed-rule {
border-bottom-color: oklch(1 0 0 / 0.08);
}
.feed-rule:last-child {
border-bottom: none;
}
@layer base {
@@ -102,12 +206,10 @@
@apply bg-background font-sans text-foreground antialiased;
}
/* Keep native form controls and webview widgets on theme */
::selection {
@apply bg-primary/20 text-foreground;
}
/* Monospace and code contexts always use tabular numerals */
code,
kbd,
samp,
@@ -115,12 +217,10 @@
font-variant-numeric: tabular-nums;
}
/* Consistent focus ring everywhere, including custom elements */
:focus-visible {
@apply outline-none ring-2 ring-ring ring-offset-2 ring-offset-background;
}
/* Native scrollbars tuned to the surface language */
* {
scrollbar-width: thin;
scrollbar-color: var(--color-border) transparent;
@@ -147,20 +247,19 @@
background-clip: padding-box;
}
/* Very subtle fixed grain overlay to break digital flatness */
body::after {
content: '';
position: fixed;
inset: 0;
z-index: 9999;
pointer-events: none;
opacity: 0.02;
opacity: 0.018;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='200' height='200'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.65' numOctaves='3' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='200' height='200' filter='url(%23n)'/%3E%3C/svg%3E");
background-repeat: repeat;
}
html.dark body::after {
opacity: 0.04;
opacity: 0.03;
}
}
@@ -174,3 +273,14 @@
opacity: 1;
}
}
@keyframes dot-reveal {
from {
opacity: 0;
filter: blur(1px);
}
to {
opacity: 1;
filter: blur(0);
}
}
+74
View File
@@ -0,0 +1,74 @@
export type Glyph = readonly string[]
export const GLYPH_COLS = 5
export const GLYPH_ROWS = 7
export const GLYPHS: Record<string, Glyph> = {
' ': [' ', ' ', ' ', ' ', ' ', ' ', ' '],
A: [' ### ', '# #', '# #', '#####', '# #', '# #', '# #'],
B: ['#### ', '# #', '# #', '#### ', '# #', '# #', '#### '],
C: [' ### ', '# #', '# ', '# ', '# ', '# #', ' ### '],
D: ['#### ', '# #', '# #', '# #', '# #', '# #', '#### '],
E: ['#####', '# ', '# ', '#### ', '# ', '# ', '#####'],
F: ['#####', '# ', '# ', '#### ', '# ', '# ', '# '],
G: [' ### ', '# #', '# ', '# ## ', '# #', '# #', ' ### '],
H: ['# #', '# #', '# #', '#####', '# #', '# #', '# #'],
I: ['#####', ' # ', ' # ', ' # ', ' # ', ' # ', '#####'],
J: ['#####', ' # ', ' # ', ' # ', ' # ', '# # ', ' ## '],
K: ['# #', '# # ', '# # ', '## ', '# # ', '# # ', '# #'],
L: ['# ', '# ', '# ', '# ', '# ', '# ', '#####'],
M: ['# #', '## ##', '# # #', '# # #', '# #', '# #', '# #'],
N: ['# #', '## #', '# # #', '# # #', '# ##', '# #', '# #'],
O: [' ### ', '# #', '# #', '# #', '# #', '# #', ' ### '],
P: ['#### ', '# #', '# #', '#### ', '# ', '# ', '# '],
Q: [' ### ', '# #', '# #', '# #', '# # #', '# # ', ' ## #'],
R: ['#### ', '# #', '# #', '#### ', '# # ', '# # ', '# #'],
S: [' ### ', '# #', '# ', ' ### ', ' #', '# #', ' ### '],
T: ['#####', ' # ', ' # ', ' # ', ' # ', ' # ', ' # '],
U: ['# #', '# #', '# #', '# #', '# #', '# #', ' ### '],
V: ['# #', '# #', '# #', '# #', '# #', ' # # ', ' # '],
W: ['# #', '# #', '# #', '# # #', '# # #', '## ##', '# #'],
X: ['# #', '# #', ' # # ', ' # ', ' # # ', '# #', '# #'],
Y: ['# #', '# #', ' # # ', ' # ', ' # ', ' # ', ' # '],
Z: ['#####', ' #', ' # ', ' # ', ' # ', '# ', '#####'],
'0': [' ### ', '# #', '# ##', '# # #', '## #', '# #', ' ### '],
'1': [' # ', ' ## ', ' # ', ' # ', ' # ', ' # ', '#####'],
'2': [' ### ', '# #', ' #', ' ## ', ' # ', '# ', '#####'],
'3': ['#### ', ' #', ' #', ' ### ', ' #', ' #', '#### '],
'4': [' # ', ' ## ', ' # # ', '# # ', '#####', ' # ', ' # '],
'5': ['#####', '# ', '# ', '#### ', ' #', '# #', ' ### '],
'6': [' ### ', '# #', '# ', '#### ', '# #', '# #', ' ### '],
'7': ['#####', ' #', ' # ', ' # ', ' # ', ' # ', ' # '],
'8': [' ### ', '# #', '# #', ' ### ', '# #', '# #', ' ### '],
'9': [' ### ', '# #', '# #', ' ####', ' #', '# #', ' ### '],
'-': [' ', ' ', ' ', '#####', ' ', ' ', ' '],
'.': [' ', ' ', ' ', ' ', ' ', ' ## ', ' ## '],
':': [' ', ' ## ', ' ## ', ' ', ' ## ', ' ## ', ' '],
'/': [' #', ' #', ' # ', ' # ', ' # ', '# ', '# '],
'%': ['## #', '## #', ' # ', ' # ', ' # ', '# ##', '# ##'],
'(': [' ## ', ' # ', '# ', '# ', '# ', ' # ', ' ## '],
')': [' ## ', ' # ', ' #', ' #', ' #', ' # ', ' ## '],
'+': [' ', ' # ', ' # ', '#####', ' # ', ' # ', ' '],
_: [' ', ' ', ' ', ' ', ' ', ' ', '#####'],
'@': [' ### ', '# #', '# ## ', '# ## ', '# ## ', '# ', ' ### '],
'#': [' # # ', ' # # ', '#####', ' # # ', '#####', ' # # ', ' # # '],
'!': [' # ', ' # ', ' # ', ' # ', ' # ', ' ', ' # '],
'?': [' ### ', '# #', ' #', ' ## ', ' # ', ' ', ' # '],
'*': [' ', '# #', ' # # ', '#####', ' # # ', '# #', ' '],
'=': [' ', ' ', '#####', ' ', '#####', ' ', ' '],
',': [' ', ' ', ' ', ' ', ' ## ', ' ## ', ' # '],
"'": [' # ', ' # ', ' # ', ' ', ' ', ' ', ' '],
'"': [' # # ', ' # # ', ' ', ' ', ' ', ' ', ' '],
'[': [' ### ', ' # ', ' # ', ' # ', ' # ', ' # ', ' ### '],
']': [' ### ', ' # ', ' # ', ' # ', ' # ', ' # ', ' ### '],
'<': [' # ', ' # ', ' # ', '# ', ' # ', ' # ', ' # '],
'>': ['# ', ' # ', ' # ', ' # ', ' # ', ' # ', '# '],
'|': [' # ', ' # ', ' # ', ' # ', ' # ', ' # ', ' # '],
'&': [' ## ', '# # ', '# # ', ' ## ', '# # ', '# # ', ' ## #'],
$: [' ### ', '# # ', '# ', ' ### ', ' #', ' # #', ' ### '],
}
export function getGlyph(char: string): Glyph | null {
const upper = char.toUpperCase()
return GLYPHS[upper] ?? null
}