feat(web): sidebar shell, dark-first theme with toggle, card and stat components
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:
1 parent
2fd0cf812a
commit
3d57472252
7 files changed
+691
-108
No files matched your search
+38
-21
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user