feat(web): sidebar shell, dark-first theme with toggle, card and stat components
ci / check (push) Successful in 1m35s
ci / publish (push) Skipped

Restyles shared tables, headers, tabs and modals on new surface, shadow
and radius tokens; the runs list sits in a card with its filters.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
homelab-updaterandClaude Opus 5.5 committed 2026-09-30 01:22:07 +02:00
1 parent 2fd0cf812a
commit 3d57472252
7 files changed
+691 -108

No files matched your search

+38 -21
View File
@@ -7,6 +7,9 @@ import type { LiveStatus } from './live.ts';
import { formatBuild } from './format.ts';
import type { BuildInfo } from '../http/contract.ts';
import { hrefFor, parseRoute } from './route.ts';
import { useTheme } from './theme.ts';
import { Icon } from './components/ui.tsx';
import type { IconName } from './components/ui.tsx';
import type { Route } from './route.ts';
import { AppDetailView, AppsView } from './views/apps.tsx';
import { OverviewView } from './views/overview.tsx';
@@ -31,10 +34,11 @@ function useRoute(): Route {
const LIVE_LABEL: Record<LiveStatus, string> = { connecting: 'connecting…', live: 'live', offline: 'offline — retrying' };
function Nav({ route, status }: { route: Route; status: LiveStatus }) {
function Sidebar({ route, status }: { route: Route; status: LiveStatus }) {
const gates = useResource(() => api.workflowInstances({ status: 'waiting' }), []);
const { theme, toggle } = useTheme();
const waiting = gates.data?.length ?? 0;
const link = (view: Route['view'], label: string, target: Parameters<typeof hrefFor>[0], count = 0) => (
const link = (view: Route['view'], label: string, icon: IconName, target: Parameters<typeof hrefFor>[0], count = 0) => (
<a
href={hrefFor(target)}
aria-current={
@@ -47,25 +51,34 @@ function Nav({ route, status }: { route: Route; status: LiveStatus }) {
: undefined
}
>
{label}
<Icon name={icon} />
<span class="nav-label">{label}</span>
{count > 0 ? <span class="count">{count}</span> : null}
</a>
);
return (
<header class="topbar">
<span class="brand">Homelab upgrade</span>
<nav>
{link('overview', 'Overview', { view: 'overview' }, waiting)}
{link('apps', 'Apps', { view: 'apps' })}
{link('runs', 'Runs', { view: 'runs' })}
{link('workflows', 'Workflows', { view: 'workflows' })}
{link('functions', 'Functions', { view: 'functions' })}
{link('models', 'Models', { view: 'models' })}
</nav>
<span class={`live live-${status}`} title="Live updates from the server">
{LIVE_LABEL[status]}
<aside class="sidebar">
<span class="brand">
<span class="brand-mark" aria-hidden="true" />
Homelab upgrade
</span>
</header>
<nav>
{link('overview', 'Overview', 'overview', { view: 'overview' }, waiting)}
{link('apps', 'Apps', 'apps', { view: 'apps' })}
{link('runs', 'Runs', 'runs', { view: 'runs' })}
{link('workflows', 'Workflows', 'workflows', { view: 'workflows' })}
{link('functions', 'Functions', 'functions', { view: 'functions' })}
{link('models', 'Models', 'models', { view: 'models' })}
</nav>
<div class="sidebar-foot">
<span class={`live live-${status}`} title="Live updates from the server">
{LIVE_LABEL[status]}
</span>
<button type="button" class="icon-button" onClick={toggle} title={theme === 'dark' ? 'Switch to light theme' : 'Switch to dark theme'}>
<Icon name={theme === 'dark' ? 'sun' : 'moon'} />
</button>
</div>
</aside>
);
}
@@ -135,11 +148,15 @@ export function App() {
const { revision, status, refresh } = useLive();
return (
<LiveContext.Provider value={{ revision, refresh }}>
<Nav route={route} status={status} />
<main>
<View route={route} />
</main>
<Footer />
<div class="shell">
<Sidebar route={route} status={status} />
<div class="content">
<main>
<View route={route} />
</main>
<Footer />
</div>
</div>
</LiveContext.Provider>
);
}
+101
View File
@@ -0,0 +1,101 @@
import type { ComponentChildren } from 'preact';
/** Stroke icons on a 24×24 grid; one path string per icon keeps them inline and dependency-free. */
const ICONS = {
overview: 'M3 3h7v9H3zM14 3h7v5h-7zM14 12h7v9h-7zM3 16h7v5H3z',
apps: 'M21 8 12 3 3 8v8l9 5 9-5zM3 8l9 5 9-5M12 13v8',
runs: 'M22 12h-4l-3 9L9 3l-3 9H2',
workflows: 'M6 3v12M18 9a3 3 0 1 0 0-6 3 3 0 0 0 0 6zM6 21a3 3 0 1 0 0-6 3 3 0 0 0 0 6zM18 9a9 9 0 0 1-9 9',
functions: 'M16 18l6-6-6-6M8 6l-6 6 6 6',
models: 'M9 3v2M15 3v2M9 19v2M15 19v2M3 9h2M3 15h2M19 9h2M19 15h2M7 5h10a2 2 0 0 1 2 2v10a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V7a2 2 0 0 1 2-2zM9 9h6v6H9z',
sun: 'M12 17a5 5 0 1 0 0-10 5 5 0 0 0 0 10zM12 1v2M12 21v2M4.2 4.2l1.4 1.4M18.4 18.4l1.4 1.4M1 12h2M21 12h2M4.2 19.8l1.4-1.4M18.4 5.6l1.4-1.4',
moon: 'M21 12.8A9 9 0 1 1 11.2 3a7 7 0 0 0 9.8 9.8z',
clock: 'M12 22a10 10 0 1 0 0-20 10 10 0 0 0 0 20zM12 6v6l4 2',
alert: 'M10.3 3.9 1.8 18a2 2 0 0 0 1.7 3h17a2 2 0 0 0 1.7-3L13.7 3.9a2 2 0 0 0-3.4 0zM12 9v4M12 17h.01',
check: 'M20 6 9 17l-5-5',
close: 'M18 6 6 18M6 6l12 12',
arrow: 'M5 12h14M12 5l7 7-7 7',
coin: 'M12 22a10 10 0 1 0 0-20 10 10 0 0 0 0 20zM15 9.5a3 2.5 0 0 0-3-1.5c-1.7 0-3 .9-3 2s1.3 2 3 2 3 .9 3 2-1.3 2-3 2a3 2.5 0 0 1-3-1.5M12 6v2M12 16v2',
upgrade: 'M12 19V5M5 12l7-7 7 7',
grid: 'M3 3h7v7H3zM14 3h7v7h-7zM14 14h7v7h-7zM3 14h7v7H3z',
list: 'M8 6h13M8 12h13M8 18h13M3 6h.01M3 12h.01M3 18h.01',
} as const;
export type IconName = keyof typeof ICONS;
export function Icon({ name, size = 18 }: { readonly name: IconName; readonly size?: number }) {
return (
<svg class="icon" width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d={ICONS[name]} />
</svg>
);
}
/** A window on the page: a titled surface with optional actions on the right of its header. */
export function Card({
title,
icon,
actions,
tone,
class: extra,
children,
}: {
readonly title?: ComponentChildren;
readonly icon?: IconName;
readonly actions?: ComponentChildren;
readonly tone?: 'attention';
readonly class?: string;
readonly children: ComponentChildren;
}) {
const classes = ['panel', tone === undefined ? '' : `panel-${tone}`, extra ?? ''].filter((part) => part !== '').join(' ');
return (
<section class={classes}>
{title === undefined && actions === undefined ? null : (
<header class="panel-head">
{title === undefined ? <span /> : (
<h2>
{icon === undefined ? null : <Icon name={icon} />}
{title}
</h2>
)}
{actions === undefined ? null : <div class="panel-actions">{actions}</div>}
</header>
)}
<div class="panel-body">{children}</div>
</section>
);
}
export type Tone = 'ok' | 'error' | 'stop' | 'running' | 'accent' | 'neutral';
/** One number that matters, with what it means and, optionally, how it moved and a small chart under it. */
export function StatTile({
label,
value,
icon,
tone = 'neutral',
hint,
href,
children,
}: {
readonly label: string;
readonly value: ComponentChildren;
readonly icon: IconName;
readonly tone?: Tone;
readonly hint?: ComponentChildren;
readonly href?: string;
readonly children?: ComponentChildren;
}) {
const body = (
<>
<span class="stat-icon">
<Icon name={icon} size={16} />
</span>
<span class="stat-label">{label}</span>
<strong class="stat-value">{value}</strong>
{hint === undefined ? null : <span class="stat-hint">{hint}</span>}
{children}
</>
);
return href === undefined ? <div class={`stat stat-${tone}`}>{body}</div> : <a class={`stat stat-${tone}`} href={href}>{body}</a>;
}
+3
View File
@@ -1,6 +1,9 @@
import { render } from 'preact';
import { App } from './app.tsx';
import './styles.css';
import { applyInitialTheme } from './theme.ts';
applyInitialTheme();
const root = document.getElementById('app');
if (root === null) throw new Error('missing #app element');
+470 -80
View File
@@ -1,50 +1,68 @@
:root {
color-scheme: light dark;
--bg: #f6f7f9;
--surface: #ffffff;
--text: #1c2024;
--muted: #667085;
--line: #dfe3e8;
--accent: #2557d6;
--accent-text: #ffffff;
--ok: #1a7f4b;
--ok-bg: #e2f5ea;
--stop: #9a5b00;
--stop-bg: #fdf0d5;
--error: #b42318;
--error-bg: #fde4e1;
--running: #1f5fbf;
--running-bg: #e0ecfc;
--queued: #565f6c;
--queued-bg: #eceff3;
--diff-added-bg: #e2f5ea;
--diff-removed-bg: #fde4e1;
color-scheme: dark;
--bg: #0d1117;
--bg-glow: radial-gradient(1200px 600px at 10% -10%, rgba(99, 132, 255, 0.12), transparent 60%),
radial-gradient(900px 500px at 110% 10%, rgba(45, 212, 191, 0.08), transparent 60%);
--surface: #161b24;
--surface-2: #1c2330;
--surface-3: #242c3b;
--text: #e6e9ef;
--muted: #8b95a7;
--line: #273041;
--accent: #7c9cff;
--accent-soft: rgba(124, 156, 255, 0.14);
--accent-text: #0b1220;
--ok: #4ade80;
--ok-bg: rgba(74, 222, 128, 0.12);
--stop: #fbbf24;
--stop-bg: rgba(251, 191, 36, 0.13);
--error: #f87171;
--error-bg: rgba(248, 113, 113, 0.13);
--running: #60a5fa;
--running-bg: rgba(96, 165, 250, 0.14);
--queued: #9ca3af;
--queued-bg: rgba(156, 163, 175, 0.14);
--diff-added-bg: rgba(74, 222, 128, 0.12);
--diff-removed-bg: rgba(248, 113, 113, 0.13);
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.35);
--shadow-md: 0 6px 24px rgba(0, 0, 0, 0.35), 0 1px 2px rgba(0, 0, 0, 0.3);
--shadow-lg: 0 24px 64px rgba(0, 0, 0, 0.55);
--radius: 8px;
--radius-lg: 14px;
--sidebar-w: 220px;
--mono: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
}
@media (prefers-color-scheme: dark) {
:root {
--bg: #12151a;
--surface: #1a1f26;
--text: #e6e9ee;
--muted: #9aa4b2;
--line: #2b323c;
--accent: #6d9bff;
--accent-text: #0b1220;
--ok: #59d08d;
--ok-bg: #163524;
--stop: #f2b64d;
--stop-bg: #3a2b0d;
--error: #ff8a80;
--error-bg: #3d1a17;
--running: #7db1ff;
--running-bg: #17294a;
--queued: #aab3c0;
--queued-bg: #262d36;
--diff-added-bg: #163524;
--diff-removed-bg: #3d1a17;
}
/* Light: chosen with the toggle, or the system's preference before the script sets data-theme. */
:root[data-theme='light'] {
color-scheme: light;
--bg: #f4f6fa;
--bg-glow: radial-gradient(1200px 600px at 10% -10%, rgba(79, 110, 247, 0.1), transparent 60%),
radial-gradient(900px 500px at 110% 10%, rgba(20, 184, 166, 0.07), transparent 60%);
--surface: #ffffff;
--surface-2: #f7f8fb;
--surface-3: #eef1f6;
--text: #171c26;
--muted: #64708a;
--line: #e2e6ee;
--accent: #4f6ef7;
--accent-soft: rgba(79, 110, 247, 0.1);
--accent-text: #ffffff;
--ok: #15803d;
--ok-bg: #dcfce7;
--stop: #a16207;
--stop-bg: #fef3c7;
--error: #b91c1c;
--error-bg: #fee2e2;
--running: #1d4ed8;
--running-bg: #dbeafe;
--queued: #4b5563;
--queued-bg: #eceff3;
--diff-added-bg: #dcfce7;
--diff-removed-bg: #fee2e2;
--shadow-sm: 0 1px 2px rgba(16, 24, 40, 0.06);
--shadow-md: 0 4px 16px rgba(16, 24, 40, 0.07), 0 1px 2px rgba(16, 24, 40, 0.05);
--shadow-lg: 0 24px 64px rgba(16, 24, 40, 0.18);
}
* {
@@ -53,7 +71,8 @@
body {
margin: 0;
background: var(--bg);
background: var(--bg-glow), var(--bg);
background-attachment: fixed;
color: var(--text);
font: 15px/1.5 system-ui, -apple-system, 'Segoe UI', sans-serif;
}
@@ -63,22 +82,23 @@ a {
}
main {
max-width: 1100px;
max-width: 1280px;
margin: 0 auto;
padding: 16px;
padding: 24px 28px;
}
footer {
max-width: 1100px;
max-width: 1280px;
margin: 0 auto;
padding: 8px 16px 24px;
padding: 8px 28px 24px;
color: var(--muted);
font-size: 0.8rem;
}
h1 {
margin: 8px 0 4px;
font-size: 1.4rem;
margin: 0 0 16px;
font-size: 1.6rem;
letter-spacing: -0.02em;
}
code,
@@ -103,42 +123,152 @@ pre {
color: var(--muted);
}
/* top bar */
.topbar {
/* shell: sidebar on the left, the page on the right; a top bar on a phone */
.shell {
display: grid;
grid-template-columns: var(--sidebar-w) minmax(0, 1fr);
min-height: 100vh;
}
.content {
min-width: 0;
}
.sidebar {
position: sticky;
top: 0;
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px 16px;
padding: 10px 16px;
background: var(--surface);
border-bottom: 1px solid var(--line);
flex-direction: column;
gap: 20px;
height: 100vh;
padding: 20px 12px;
background: color-mix(in srgb, var(--surface) 80%, transparent);
backdrop-filter: blur(12px);
border-right: 1px solid var(--line);
}
.brand {
display: flex;
align-items: center;
gap: 10px;
padding: 0 8px;
font-weight: 700;
letter-spacing: -0.01em;
}
.topbar nav {
.brand-mark {
width: 26px;
height: 26px;
border-radius: 8px;
background: linear-gradient(135deg, var(--accent), #2dd4bf);
box-shadow: 0 4px 14px var(--accent-soft);
}
.sidebar nav {
display: flex;
flex-wrap: wrap;
gap: 4px;
flex-direction: column;
gap: 2px;
flex: 1;
}
.topbar nav a {
padding: 4px 10px;
.sidebar nav a {
display: flex;
align-items: center;
gap: 10px;
padding: 8px 10px;
border-radius: var(--radius);
color: var(--text);
color: var(--muted);
text-decoration: none;
transition: background 0.15s, color 0.15s;
}
.sidebar nav a:hover {
color: var(--text);
background: var(--surface-3);
}
.topbar nav a[aria-current='page'] {
background: var(--queued-bg);
.sidebar nav a[aria-current='page'] {
color: var(--text);
background: var(--accent-soft);
box-shadow: inset 3px 0 0 var(--accent);
font-weight: 600;
}
.sidebar nav a[aria-current='page'] .icon {
color: var(--accent);
}
.nav-label {
flex: 1;
}
.sidebar-foot {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 0 8px;
}
.icon {
flex: none;
vertical-align: middle;
}
.icon-button {
display: inline-grid;
place-items: center;
padding: 6px;
border-radius: 999px;
}
@media (max-width: 719px) {
.shell {
grid-template-columns: 1fr;
}
.sidebar {
position: sticky;
z-index: 10;
flex-direction: row;
flex-wrap: wrap;
align-items: center;
gap: 8px 12px;
height: auto;
padding: 10px 16px;
border-right: 0;
border-bottom: 1px solid var(--line);
}
.sidebar nav {
order: 3;
flex-direction: row;
flex-wrap: wrap;
flex-basis: 100%;
}
.sidebar nav a {
padding: 6px 8px;
}
.sidebar nav a[aria-current='page'] {
box-shadow: inset 0 -2px 0 var(--accent);
}
.sidebar-foot {
margin-left: auto;
padding: 0;
}
main {
padding: 16px;
}
footer {
padding: 8px 16px 24px;
}
}
.count {
margin-left: 6px;
padding: 0 7px;
@@ -180,21 +310,25 @@ select {
}
button {
padding: 5px 12px;
padding: 6px 14px;
border: 1px solid var(--line);
border-radius: var(--radius);
background: var(--surface);
background: var(--surface-2);
cursor: pointer;
transition: border-color 0.15s, background 0.15s, box-shadow 0.15s;
}
button:hover:not(:disabled) {
border-color: var(--accent);
}
button.primary {
button.primary,
button.btn-primary {
background: var(--accent);
border-color: var(--accent);
color: var(--accent-text);
font-weight: 600;
box-shadow: 0 4px 14px var(--accent-soft);
}
button:disabled {
@@ -219,7 +353,16 @@ input[type='text'] {
overflow-x: auto;
background: var(--surface);
border: 1px solid var(--line);
border-radius: var(--radius);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-md);
}
tbody tr {
transition: background 0.12s;
}
tbody tr:hover {
background: var(--surface-2);
}
table {
@@ -483,20 +626,35 @@ h2 {
.view-header {
display: flex;
flex-wrap: wrap;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
gap: 12px;
margin-bottom: 20px;
}
.view-header h1 {
margin: 0;
}
.data-table {
width: 100%;
border-collapse: collapse;
border-collapse: separate;
border-spacing: 0;
background: var(--surface);
border: 1px solid var(--line);
border-radius: var(--radius);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-md);
overflow: hidden;
}
.table-wrap .data-table,
.panel-body .data-table {
border: 0;
border-radius: 0;
box-shadow: none;
}
.data-table th,
.data-table td {
padding: 10px 14px;
@@ -505,7 +663,7 @@ h2 {
}
.data-table th {
background: var(--bg);
background: var(--surface-2);
font-weight: 600;
color: var(--muted);
font-size: 0.85rem;
@@ -548,7 +706,9 @@ h2 {
.modal-backdrop {
position: fixed;
top: 0; left: 0; right: 0; bottom: 0;
background: rgba(0, 0, 0, 0.5);
background: rgba(5, 8, 15, 0.55);
backdrop-filter: blur(4px);
animation: fade-in 0.15s ease-out;
display: flex;
align-items: center;
justify-content: center;
@@ -558,10 +718,24 @@ h2 {
.modal-dialog {
background: var(--surface);
padding: 24px;
border-radius: var(--radius);
border: 1px solid var(--line);
border-radius: var(--radius-lg);
max-width: 480px;
width: 90%;
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.2);
box-shadow: var(--shadow-lg);
animation: pop-in 0.18s ease-out;
}
.modal-dialog h2 {
margin-top: 0;
}
@keyframes fade-in {
from { opacity: 0; }
}
@keyframes pop-in {
from { opacity: 0; transform: translateY(8px) scale(0.98); }
}
.modal-actions {
@@ -752,8 +926,9 @@ h2 {
}
.tab-btn.active {
background: var(--surface);
border-color: var(--line);
background: var(--accent-soft);
border-color: color-mix(in srgb, var(--accent) 35%, transparent);
color: var(--text);
font-weight: 600;
}
@@ -1197,8 +1372,223 @@ pre.compose-diff span {
.overview-waiting { border-left: 4px solid var(--stop); }
.overview-failed { border-left: 4px solid var(--error); }
.mode-real { color: var(--error); }
.run-filters { display: flex; flex-wrap: wrap; gap: 4px; margin-bottom: 12px; }
.run-filters { display: flex; flex-wrap: wrap; gap: 4px; }
.decision-actions { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 8px; }
.run-outcome { margin: 12px 0; padding: 10px 14px; border-radius: var(--radius); background: var(--queued-bg); }
.run-outcome-failed { background: var(--error-bg); color: var(--error); }
.run-outcome-succeeded { background: var(--ok-bg); color: var(--ok); }
/* windows: titled surfaces that hold a page's parts */
.panel {
margin: 0 0 20px;
background: var(--surface);
border: 1px solid var(--line);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-md);
overflow: hidden;
}
.panel-attention {
border-color: color-mix(in srgb, var(--stop) 45%, var(--line));
box-shadow: var(--shadow-md), 0 0 0 3px var(--stop-bg);
}
.panel-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 12px 16px;
background: var(--surface-2);
border-bottom: 1px solid var(--line);
}
.panel-head h2 {
display: flex;
align-items: center;
gap: 8px;
margin: 0;
font-size: 0.95rem;
font-weight: 600;
}
.panel-head h2 .icon {
color: var(--muted);
}
.panel-attention .panel-head h2 .icon {
color: var(--stop);
}
.panel-actions {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.panel-body {
padding: 14px 16px;
}
.panel-body > .table-wrap {
margin: -14px -16px;
border: 0;
border-radius: 0;
box-shadow: none;
}
.panel-body > .empty {
border: 0;
background: transparent;
}
.panel-body > .overview-list:only-child,
.panel-body > ul:only-child {
margin: 0;
}
.panel-grid {
display: grid;
gap: 20px;
grid-template-columns: repeat(auto-fit, minmax(340px, 1fr));
}
.panel-grid > .panel {
margin: 0;
}
.panel-grid + .panel,
.panel-grid + .panel-grid,
.stats + .panel,
.stats + .panel-grid {
margin-top: 20px;
}
.page-head {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 20px;
}
.page-head h1 {
margin: 0;
font-size: 1.6rem;
letter-spacing: -0.02em;
}
.page-head p {
margin: 2px 0 0;
color: var(--muted);
}
/* stat tiles: one number that matters */
.stats {
display: grid;
gap: 16px;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
}
.stat {
position: relative;
display: grid;
gap: 2px;
padding: 14px 16px;
color: var(--text);
text-decoration: none;
background: var(--surface);
border: 1px solid var(--line);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-md);
overflow: hidden;
transition: transform 0.15s, border-color 0.15s;
}
a.stat:hover {
transform: translateY(-2px);
border-color: var(--tone);
}
.stat::after {
content: '';
position: absolute;
inset: 0 0 auto;
height: 3px;
background: var(--tone);
opacity: 0.8;
}
.stat-neutral { --tone: var(--muted); --tone-bg: var(--queued-bg); }
.stat-ok { --tone: var(--ok); --tone-bg: var(--ok-bg); }
.stat-error { --tone: var(--error); --tone-bg: var(--error-bg); }
.stat-stop { --tone: var(--stop); --tone-bg: var(--stop-bg); }
.stat-running { --tone: var(--running); --tone-bg: var(--running-bg); }
.stat-accent { --tone: var(--accent); --tone-bg: var(--accent-soft); }
.stat-icon {
position: absolute;
top: 12px;
right: 12px;
display: grid;
place-items: center;
width: 30px;
height: 30px;
border-radius: 9px;
color: var(--tone);
background: var(--tone-bg);
}
.stat-label {
font-size: 0.8rem;
font-weight: 600;
color: var(--muted);
text-transform: uppercase;
letter-spacing: 0.05em;
}
.stat-value {
font-size: 1.9rem;
line-height: 1.2;
letter-spacing: -0.02em;
font-variant-numeric: tabular-nums;
}
.stat-hint {
font-size: 0.85rem;
color: var(--muted);
}
.delta-good {
color: var(--ok);
}
.delta-bad {
color: var(--error);
}
.segmented {
display: inline-flex;
padding: 2px;
border: 1px solid var(--line);
border-radius: 999px;
background: var(--surface-2);
}
.segmented button {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 4px 12px;
border: 0;
border-radius: 999px;
background: transparent;
color: var(--muted);
}
.segmented button[aria-pressed='true'] {
color: var(--text);
background: var(--surface);
box-shadow: var(--shadow-sm);
}
+15
View File
@@ -0,0 +1,15 @@
import { describe, expect, it } from 'vitest';
import { initialTheme } from './theme.ts';
describe('initialTheme', () => {
it('keeps a saved choice over the system preference', () => {
expect(initialTheme('light', false)).toBe('light');
expect(initialTheme('dark', true)).toBe('dark');
});
it('follows the system when nothing valid is saved, dark by default', () => {
expect(initialTheme(null, true)).toBe('light');
expect(initialTheme(null, false)).toBe('dark');
expect(initialTheme('sepia', false)).toBe('dark');
});
});
+49
View File
@@ -0,0 +1,49 @@
import { useEffect, useState } from 'preact/hooks';
export type Theme = 'dark' | 'light';
const KEY = 'homelab-upgrade.theme';
/** The owner's choice if one was saved, else the system's preference; dark when the system says nothing. */
export function initialTheme(saved: string | null, prefersLight: boolean): Theme {
if (saved === 'dark' || saved === 'light') return saved;
return prefersLight ? 'light' : 'dark';
}
function savedTheme(): string | null {
try {
return window.localStorage.getItem(KEY);
} catch {
return null; // storage can be blocked (private window); the system preference decides
}
}
function prefersLight(): boolean {
return typeof window.matchMedia === 'function' && window.matchMedia('(prefers-color-scheme: light)').matches;
}
/** Sets the theme on <html> before the first render, so the page does not flash the other theme. */
export function applyInitialTheme(): Theme {
const theme = initialTheme(savedTheme(), prefersLight());
document.documentElement.dataset['theme'] = theme;
return theme;
}
/** The current theme and a toggle that applies it to <html> and remembers it in this browser. */
export function useTheme(): { theme: Theme; toggle: () => void } {
const [theme, setTheme] = useState<Theme>(() => (document.documentElement.dataset['theme'] === 'light' ? 'light' : 'dark'));
useEffect(() => {
document.documentElement.dataset['theme'] = theme;
window.dispatchEvent(new Event('themechange'));
}, [theme]);
const toggle = () => {
const next: Theme = theme === 'dark' ? 'light' : 'dark';
try {
window.localStorage.setItem(KEY, next);
} catch {
// not remembered; the toggle still works for this page
}
setTheme(next);
};
return { theme, toggle };
}
+15 -7
View File
@@ -3,6 +3,7 @@ import { formatCost, formatDuration, formatInstant } from '../format.ts';
import { api } from '../api.ts';
import { Empty, ErrorNote, Loading, ModeTag, RunBadge } from '../components.tsx';
import { useResource } from '../hooks.ts';
import { Card } from '../components/ui.tsx';
import { hrefFor } from '../route.ts';
import type { WorkflowInstanceSummaryResponse } from '../../http/contract.ts';
@@ -50,14 +51,20 @@ export function WorkflowInstancesView() {
<div class="view-header">
<h1>Runs</h1>
</div>
<div class="run-filters" role="tablist">
{FILTERS.map((option) => (
<button key={option.label} type="button" role="tab" aria-selected={filter === option.status} class={filter === option.status ? 'tab-btn active' : 'tab-btn'} onClick={() => setFilter(option.status)}>
{option.label}
</button>
))}
</div>
{error === null ? null : <ErrorNote message={error} />}
<Card
title="Workflow runs"
icon="runs"
actions={
<div class="run-filters" role="tablist">
{FILTERS.map((option) => (
<button key={option.label} type="button" role="tab" aria-selected={filter === option.status} class={filter === option.status ? 'tab-btn active' : 'tab-btn'} onClick={() => setFilter(option.status)}>
{option.label}
</button>
))}
</div>
}
>
{runs === null ? (
error === null ? <Loading /> : null
) : runs.length === 0 ? (
@@ -84,6 +91,7 @@ export function WorkflowInstancesView() {
</table>
</div>
)}
</Card>
</section>
);
}