feat: build dashboard design system

This commit is contained in:
vince 2026-06-18 18:27:54 +02:00
parent 171cb05305
commit fb266cf17a
23 changed files with 1449 additions and 84 deletions

View file

@ -0,0 +1,116 @@
<script lang="ts">
import type { UiDashboardPreview } from "../types";
import ModuleCard from "./ModuleCard.svelte";
import ServicePanel from "./ServicePanel.svelte";
import StatusStrip from "./StatusStrip.svelte";
import TelemetryGrid from "./TelemetryGrid.svelte";
const generatedTitleId = $props.id();
let {
dashboard,
titleId = `${generatedTitleId}-title`,
}: {
dashboard: UiDashboardPreview;
titleId?: string;
} = $props();
</script>
<main class="dashboard-frame" aria-labelledby={titleId}>
<header class="dashboard-frame__header">
<div>
{#if dashboard.eyebrow}
<p>{dashboard.eyebrow}</p>
{/if}
<h1 id={titleId}>{dashboard.title}</h1>
{#if dashboard.subtitle}
<span>{dashboard.subtitle}</span>
{/if}
</div>
{#if dashboard.modules.length}
<div class="dashboard-frame__modules">
{#each dashboard.modules as module (module.id)}
<ModuleCard {module} />
{/each}
</div>
{/if}
</header>
<TelemetryGrid cards={dashboard.telemetry} />
<section class="dashboard-frame__panels" aria-label="Service groups">
{#each dashboard.serviceGroups as group (group.id)}
<ServicePanel {group} />
{/each}
</section>
<StatusStrip items={dashboard.statusItems} />
</main>
<style>
.dashboard-frame {
display: grid;
min-height: 100vh;
gap: var(--ui-space-3);
padding: clamp(0.75rem, 1.3vw, 1.25rem);
background:
linear-gradient(90deg, transparent 0 49%, rgba(255, 255, 255, 0.08) 50%, transparent 51%),
rgba(0, 0, 0, 0.18);
}
.dashboard-frame__header {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: var(--ui-space-4);
align-items: start;
border-bottom: var(--ui-border);
padding-bottom: var(--ui-space-3);
}
.dashboard-frame__header p,
.dashboard-frame__header span,
h1 {
margin: 0;
text-transform: uppercase;
}
.dashboard-frame__header p,
.dashboard-frame__header span {
color: var(--ui-color-muted);
font-size: 0.78rem;
font-weight: 800;
}
h1 {
max-width: 100%;
overflow-wrap: anywhere;
font-family: var(--ui-font-display);
font-size: clamp(2.45rem, 5vw, 4.2rem);
font-weight: 850;
letter-spacing: 0;
line-height: 0.85;
}
.dashboard-frame__modules {
display: flex;
flex-wrap: wrap;
justify-content: end;
gap: var(--ui-space-3);
}
.dashboard-frame__panels {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(min(100%, 18rem), 1fr));
gap: var(--ui-space-3);
}
@media (max-width: 780px) {
.dashboard-frame__header {
grid-template-columns: 1fr;
}
.dashboard-frame__modules {
justify-content: stretch;
}
}
</style>

View file

@ -0,0 +1,89 @@
<script lang="ts">
import type { UiStatusItem } from "../types";
let { item }: { item: UiStatusItem } = $props();
let target = $derived(item.link?.external ? "_blank" : undefined);
let rel = $derived(item.link?.external ? "noreferrer" : undefined);
</script>
{#snippet cellContent()}
<span>{item.label}</span>
<strong>{item.value}</strong>
{/snippet}
{#if item.link}
<a
class="footer-cell"
data-severity={item.severity || "neutral"}
href={item.link.href}
target={target}
rel={rel}
aria-label={item.link.label}
>
{@render cellContent()}
</a>
{:else}
<div class="footer-cell" data-severity={item.severity || "neutral"}>
{@render cellContent()}
</div>
{/if}
<style>
.footer-cell {
display: grid;
grid-template-columns: auto minmax(0, 1fr);
min-height: 2.65rem;
align-items: center;
background: rgba(2, 3, 2, 0.92);
color: inherit;
text-decoration: none;
}
span,
strong {
min-width: 0;
padding: var(--ui-space-2) var(--ui-space-3);
overflow-wrap: anywhere;
text-transform: uppercase;
}
span {
height: 100%;
border-right: var(--ui-border);
color: var(--ui-color-muted);
font-size: 0.68rem;
font-weight: 800;
}
strong {
color: var(--ui-color-text);
font-size: 0.76rem;
}
.footer-cell[data-severity="ok"] strong {
background: var(--ui-color-accent);
color: #060706;
}
.footer-cell[data-severity="warning"] strong {
color: var(--ui-color-warning);
}
.footer-cell[data-severity="danger"] strong {
color: var(--ui-color-danger);
}
.footer-cell[data-severity="stale"] strong,
.footer-cell[data-severity="unavailable"] strong {
color: var(--ui-color-stale);
}
.footer-cell[data-severity="loading"] strong {
color: var(--ui-color-accent);
}
.footer-cell:where(a):hover strong {
color: var(--ui-color-accent);
}
</style>

View file

@ -0,0 +1,53 @@
<script lang="ts">
import Icon from "@iconify/svelte";
let {
name,
label,
size = "md",
}: {
name?: string;
label?: string;
size?: "sm" | "md" | "lg";
} = $props();
</script>
<span
class="icon-glyph"
data-size={size}
data-icon-name={name}
aria-label={label}
aria-hidden={label ? undefined : "true"}
>
{#if name}
<Icon icon={name} />
{/if}
</span>
<style>
.icon-glyph {
display: inline-grid;
width: 2.35rem;
height: 2.35rem;
place-items: center;
border: var(--ui-border);
background: #050605;
color: currentColor;
line-height: 1;
}
.icon-glyph :global(svg) {
width: 1.35rem;
height: 1.35rem;
}
.icon-glyph[data-size="sm"] {
width: 1.75rem;
height: 1.75rem;
}
.icon-glyph[data-size="lg"] {
width: 3rem;
height: 3rem;
}
</style>

View file

@ -0,0 +1,86 @@
<script lang="ts">
import type { UiModuleBlock } from "../types";
import IconGlyph from "./IconGlyph.svelte";
let { module }: { module: UiModuleBlock } = $props();
</script>
<aside class="module-card" data-severity={module.severity || "neutral"}>
<div>
{#if module.title}
<h2>{module.title}</h2>
{/if}
{#if module.value}
<strong>{module.value}</strong>
{/if}
{#if module.detail || module.label}
<p>{module.detail || module.label}</p>
{/if}
</div>
{#if module.icon}
<IconGlyph name={module.icon} size="lg" />
{/if}
</aside>
<style>
.module-card {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: var(--ui-space-4);
align-items: start;
min-width: min(100%, 18rem);
border: var(--ui-border);
background: rgba(6, 8, 7, 0.82);
padding: var(--ui-space-3);
}
h2,
p {
margin: 0;
}
h2 {
color: var(--ui-color-muted);
font-size: 0.82rem;
text-transform: uppercase;
}
strong {
display: block;
margin-top: var(--ui-space-1);
font-family: var(--ui-font-display);
font-size: 2.25rem;
line-height: 0.9;
}
p {
margin-top: var(--ui-space-1);
color: var(--ui-color-muted);
font-size: 0.72rem;
text-transform: uppercase;
}
.module-card[data-severity="ok"] :global(.icon-glyph) {
background: var(--ui-color-accent);
color: #050605;
}
.module-card[data-severity="warning"] {
border-color: color-mix(in srgb, var(--ui-color-warning), transparent 42%);
color: var(--ui-color-warning);
}
.module-card[data-severity="danger"] {
border-color: color-mix(in srgb, var(--ui-color-danger), transparent 32%);
color: var(--ui-color-danger);
}
.module-card[data-severity="stale"],
.module-card[data-severity="unavailable"] {
color: var(--ui-color-stale);
}
.module-card[data-severity="loading"] {
color: var(--ui-color-accent);
}
</style>

View file

@ -0,0 +1,81 @@
<script lang="ts">
import type { Snippet } from "svelte";
let {
title,
density = "dense",
children,
}: {
title?: string;
density?: "compact" | "dense";
children?: Snippet;
} = $props();
</script>
<section class="panel" data-density={density}>
{#if title}
<header class="panel__header">
<h2>{title}</h2>
</header>
{/if}
<div class="panel__body">
{@render children?.()}
</div>
</section>
<style>
.panel {
position: relative;
min-width: 0;
border: var(--ui-border);
background: rgba(3, 4, 3, 0.86);
box-shadow: var(--ui-shadow-hard);
}
.panel::before,
.panel::after {
position: absolute;
width: 0.55rem;
height: 0.55rem;
border-color: var(--ui-color-line-strong);
content: "";
}
.panel::before {
top: 0.2rem;
right: 0.2rem;
border-top: 1px solid;
border-right: 1px solid;
}
.panel::after {
right: 0.2rem;
bottom: 0.2rem;
border-right: 1px solid;
border-bottom: 1px solid;
}
.panel__header {
padding: var(--ui-space-3) var(--ui-space-3) 0;
}
h2 {
margin: 0;
font-family: var(--ui-font-display);
font-size: clamp(1.75rem, 3vw, 2.5rem);
font-weight: 800;
letter-spacing: 0;
line-height: 0.9;
text-transform: uppercase;
}
.panel__body {
display: grid;
gap: var(--ui-space-2);
padding: var(--ui-space-3);
}
.panel[data-density="compact"] .panel__body {
padding: var(--ui-space-2);
}
</style>

View file

@ -0,0 +1,17 @@
<script lang="ts">
import type { UiServiceGroup } from "../types";
import Panel from "./Panel.svelte";
import ServiceRow from "./ServiceRow.svelte";
import StatusStrip from "./StatusStrip.svelte";
let { group }: { group: UiServiceGroup } = $props();
</script>
<Panel title={group.title}>
{#if group.summary?.length}
<StatusStrip items={group.summary} compact />
{/if}
{#each group.services as service (service.id)}
<ServiceRow {service} />
{/each}
</Panel>

View file

@ -0,0 +1,106 @@
<script lang="ts">
import type { UiServiceRow } from "../types";
import IconGlyph from "./IconGlyph.svelte";
import StatusBadge from "./StatusBadge.svelte";
let { service }: { service: UiServiceRow } = $props();
let target = $derived(service.link?.external ? "_blank" : undefined);
let rel = $derived(service.link?.external ? "noreferrer" : undefined);
</script>
{#snippet rowContent()}
<IconGlyph name={service.icon} />
<div class="service-row__main">
<h3>{service.label}</h3>
<p>{service.description}</p>
</div>
{#if service.detail}
<StatusBadge label={service.detail} severity={service.severity} />
{/if}
{/snippet}
{#if service.link}
<a
class="service-row"
data-severity={service.severity}
href={service.link.href}
target={target}
rel={rel}
aria-label={service.link.label}
>
{@render rowContent()}
</a>
{:else}
<article class="service-row" data-severity={service.severity}>
{@render rowContent()}
</article>
{/if}
<style>
.service-row {
display: grid;
grid-template-columns: auto minmax(0, 1fr) auto;
gap: var(--ui-space-3);
align-items: center;
min-height: 3.75rem;
border: var(--ui-border);
background: rgba(12, 13, 12, 0.72);
color: inherit;
padding: var(--ui-space-2);
text-decoration: none;
}
.service-row[data-severity="warning"] {
border-color: color-mix(in srgb, var(--ui-color-warning), transparent 54%);
}
.service-row[data-severity="danger"],
.service-row[data-severity="unavailable"] {
border-color: color-mix(in srgb, var(--ui-color-danger), transparent 50%);
}
.service-row[data-severity="loading"] {
border-color: color-mix(in srgb, var(--ui-color-accent), transparent 56%);
}
.service-row:where(a):hover {
border-color: var(--ui-color-accent);
}
.service-row__main {
min-width: 0;
}
h3,
p {
margin: 0;
}
h3 {
overflow-wrap: anywhere;
font-size: 0.88rem;
font-weight: 850;
letter-spacing: 0;
text-transform: uppercase;
}
p {
margin-top: 0.22rem;
color: var(--ui-color-muted);
font-size: 0.68rem;
line-height: 1.25;
text-transform: uppercase;
}
@media (max-width: 580px) {
.service-row {
grid-template-columns: auto minmax(0, 1fr);
}
.service-row :global(.status-badge) {
grid-column: 2;
justify-self: start;
}
}
</style>

View file

@ -0,0 +1,52 @@
<script lang="ts">
import type { UiSeverity } from "../types";
let {
label,
severity = "neutral",
}: {
label: string;
severity?: UiSeverity;
} = $props();
</script>
<span class="status-badge" data-severity={severity}>{label}</span>
<style>
.status-badge {
display: inline-grid;
min-height: 1.6rem;
align-items: center;
border: var(--ui-border);
padding: 0 var(--ui-space-2);
color: var(--ui-color-muted);
font-size: 0.72rem;
font-weight: 800;
letter-spacing: 0;
text-transform: uppercase;
}
.status-badge[data-severity="ok"] {
border-color: color-mix(in srgb, var(--ui-color-ok), transparent 42%);
color: var(--ui-color-ok);
}
.status-badge[data-severity="warning"] {
border-color: color-mix(in srgb, var(--ui-color-warning), transparent 35%);
color: var(--ui-color-warning);
}
.status-badge[data-severity="danger"] {
border-color: color-mix(in srgb, var(--ui-color-danger), transparent 30%);
color: var(--ui-color-danger);
}
.status-badge[data-severity="stale"],
.status-badge[data-severity="unavailable"] {
color: var(--ui-color-stale);
}
.status-badge[data-severity="loading"] {
color: var(--ui-color-accent);
}
</style>

View file

@ -0,0 +1,32 @@
<script lang="ts">
import type { UiStatusItem } from "../types";
import FooterCell from "./FooterCell.svelte";
let {
items,
compact = false,
}: {
items: UiStatusItem[];
compact?: boolean;
} = $props();
</script>
<section class="status-strip" data-compact={compact}>
{#each items as item (item.id)}
<FooterCell {item} />
{/each}
</section>
<style>
.status-strip {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(11rem, 1fr));
gap: 1px;
border: var(--ui-border);
background: var(--ui-color-line);
}
.status-strip[data-compact="true"] {
grid-template-columns: repeat(auto-fit, minmax(7rem, 1fr));
}
</style>

View file

@ -0,0 +1,158 @@
<script lang="ts">
import { formatMetricValue } from "../format";
import type { UiTelemetryCard } from "../types";
import IconGlyph from "./IconGlyph.svelte";
let { card }: { card: UiTelemetryCard } = $props();
let progress = $derived(resolveProgress(card));
let value = $derived(formatMetricValue(card.value));
</script>
<article class="telemetry-card" data-severity={card.severity}>
<header>
{#if card.icon}
<IconGlyph name={card.icon} size="sm" />
{/if}
<h3>{card.label}</h3>
</header>
<strong>{value}</strong>
{#if progress !== null}
<div class="telemetry-card__bar" aria-hidden="true">
<span style={`--metric-progress: ${progress}%`}></span>
</div>
{/if}
{#if card.sparkline?.length}
<svg class="telemetry-card__sparkline" viewBox="0 0 100 24" aria-hidden="true">
<polyline points={sparklinePoints(card.sparkline)} />
</svg>
{/if}
{#if card.detail || card.description}
<p>{card.detail || card.description}</p>
{/if}
</article>
<style>
.telemetry-card {
display: grid;
min-height: 7.15rem;
border: var(--ui-border);
background: linear-gradient(180deg, rgba(13, 15, 14, 0.82), rgba(2, 3, 2, 0.92));
padding: var(--ui-space-3);
color: var(--ui-color-text);
gap: var(--ui-space-2);
}
.telemetry-card[data-severity="warning"] {
border-color: color-mix(in srgb, var(--ui-color-warning), transparent 42%);
}
.telemetry-card[data-severity="danger"] {
border-color: color-mix(in srgb, var(--ui-color-danger), transparent 22%);
color: var(--ui-color-danger);
}
.telemetry-card[data-severity="stale"],
.telemetry-card[data-severity="unavailable"] {
color: var(--ui-color-stale);
}
.telemetry-card[data-severity="loading"] {
color: var(--ui-color-accent);
}
.telemetry-card[data-severity="loading"] .telemetry-card__bar span {
min-width: 1.25rem;
}
header {
display: flex;
min-width: 0;
align-items: center;
gap: var(--ui-space-2);
}
h3,
p {
margin: 0;
}
h3 {
overflow-wrap: anywhere;
color: inherit;
font-size: 0.78rem;
font-weight: 800;
letter-spacing: 0;
text-transform: uppercase;
}
strong {
font-family: var(--ui-font-display);
font-size: clamp(2rem, 4vw, 3rem);
font-weight: 800;
letter-spacing: 0;
line-height: 0.82;
}
p {
color: var(--ui-color-muted);
font-size: 0.67rem;
line-height: 1.2;
text-transform: uppercase;
}
.telemetry-card__bar {
height: 0.35rem;
border: var(--ui-border);
background: #030403;
}
.telemetry-card__bar span {
display: block;
width: var(--metric-progress);
height: 100%;
background: currentColor;
}
.telemetry-card__sparkline {
width: 100%;
height: 1.5rem;
color: var(--ui-color-accent);
}
.telemetry-card__sparkline polyline {
fill: none;
stroke: currentColor;
stroke-linecap: square;
stroke-linejoin: miter;
stroke-width: 2;
}
</style>
<script lang="ts" module>
import { clampPercent } from "../format";
function resolveProgress(card: {
progress?: number;
value: { kind: string; value: number | string };
}): number | null {
if (typeof card.progress === "number") return clampPercent(card.progress);
if (card.value.kind !== "percent") return null;
const progress = Number(card.value.value);
return Number.isFinite(progress) ? clampPercent(progress) : null;
}
function sparklinePoints(values: number[]): string {
const max = Math.max(...values, 1);
const step = values.length > 1 ? 100 / (values.length - 1) : 100;
return values
.map((value, index) => {
const x = index * step;
const y = 24 - (Math.max(0, value) / max) * 22;
return `${x.toFixed(2)},${y.toFixed(2)}`;
})
.join(" ");
}
</script>

View file

@ -0,0 +1,22 @@
<script lang="ts">
import type { UiTelemetryCard } from "../types";
import TelemetryCard from "./TelemetryCard.svelte";
let { cards }: { cards: UiTelemetryCard[] } = $props();
</script>
<section class="telemetry-grid" aria-label="Telemetry">
{#each cards as card (card.id)}
<TelemetryCard {card} />
{/each}
</section>
<style>
.telemetry-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(var(--ui-density-card-min), 1fr));
gap: 1px;
border: var(--ui-border);
background: var(--ui-color-line);
}
</style>

View file

@ -0,0 +1,97 @@
import { render } from "svelte/server";
import { describe, expect, test } from "vitest";
import DashboardFrame from "./DashboardFrame.svelte";
import FooterCell from "./FooterCell.svelte";
import ServiceRow from "./ServiceRow.svelte";
import TelemetryCard from "./TelemetryCard.svelte";
import { dashboardPreviewFixtures } from "../fixtures";
describe("dashboard UI components", () => {
test("renders the primary generic dashboard fixture", () => {
const { body } = render(DashboardFrame, {
props: {
dashboard: dashboardPreviewFixtures.primary,
},
});
expect(body).toContain("Operations Console");
expect(body).toContain("Core Throughput");
expect(body).toContain("Queue Workers");
expect(body).toContain("data-icon-name=\"mdi:server-network\"");
expect(body).toContain("data-severity=\"warning\"");
expect(body).not.toContain("dashboard-title");
});
test("renders another generic dashboard fixture through the same component", () => {
const { body } = render(DashboardFrame, {
props: {
dashboard: dashboardPreviewFixtures.secondary,
},
});
expect(body).toContain("Support Desk");
expect(body).toContain("Response Window");
expect(body).toContain("Regional Nodes");
expect(body).toContain("data-icon-name=\"mdi:headset\"");
expect(body).toContain("data-severity=\"loading\"");
});
test("renders optional service and status links as focusable anchors", () => {
const service = render(ServiceRow, {
props: {
service: {
id: "linked-service",
label: "Linked Service",
description: "Generic linked destination",
severity: "ok",
detail: "ready",
link: { href: "https://example.test/service", external: true },
},
},
});
const footer = render(FooterCell, {
props: {
item: {
id: "linked-status",
label: "Linked Status",
value: "open",
severity: "neutral",
link: { href: "https://example.test/status" },
},
},
});
expect(service.body).toContain("<a ");
expect(service.body).toContain("href=\"https://example.test/service\"");
expect(service.body).toContain("target=\"_blank\"");
expect(footer.body).toContain("<a ");
expect(footer.body).toContain("href=\"https://example.test/status\"");
});
test("does not render progress bars for non-percent metrics without explicit progress", () => {
const withoutProgress = render(TelemetryCard, {
props: {
card: {
id: "bytes",
label: "Bytes Metric",
value: { kind: "bytes", value: 2048 },
severity: "neutral",
},
},
});
const withProgress = render(TelemetryCard, {
props: {
card: {
id: "bytes-with-progress",
label: "Bytes With Progress",
value: { kind: "bytes", value: 2048 },
progress: 42,
severity: "neutral",
},
},
});
expect(withoutProgress.body).not.toContain("telemetry-card__bar");
expect(withProgress.body).toContain("--metric-progress: 42%");
});
});