feat: align hermes tasks with shared ui

This commit is contained in:
Hermes VM 2026-05-31 10:43:19 +00:00
parent 8085501506
commit 1b957cf6d9
9 changed files with 1184 additions and 218 deletions

1
dashboard/.npmrc Normal file
View File

@ -0,0 +1 @@
@bytelyst:registry=http://localhost:3300/api/packages/learning_ai_user/npm/

959
dashboard/pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@ -13,9 +13,13 @@ RUN corepack enable && corepack prepare pnpm@10.6.5 --activate
WORKDIR /app
ARG GITEA_NPM_HOST=host.docker.internal
ARG GITEA_NPM_OWNER=learning_ai_user
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml .pnpmfile.cjs ./
COPY web/package.json ./web/
RUN printf '@bytelyst:registry=http://%s:3300/api/packages/%s/npm/\n' "$GITEA_NPM_HOST" "$GITEA_NPM_OWNER" > .npmrc
RUN pnpm install --frozen-lockfile --filter "@bytelyst/devops-web..." --ignore-scripts
COPY web/tsconfig.json ./web/

View File

@ -16,6 +16,8 @@
"test:e2e:ui": "playwright test --ui"
},
"dependencies": {
"@bytelyst/design-tokens": "0.2.0",
"@bytelyst/ui": "0.1.11",
"clsx": "^2.1.1",
"lucide-react": "^0.562.0",
"next": "16.0.0",

View File

@ -1,4 +1,4 @@
@import "../styles/tokens.css";
@import "@bytelyst/design-tokens/css";
@tailwind base;
@tailwind components;

View File

@ -4,7 +4,7 @@ import Link from 'next/link';
import { useParams } from 'next/navigation';
import { useEffect, useMemo, useState } from 'react';
import { ArrowLeft, CircleDashed, Clock3, ShieldAlert, Sparkles } from 'lucide-react';
import { Badge, Button } from '@/components/ui/Primitives';
import { Badge, Button, Card, Timeline, type TimelineItem } from '@/components/ui/Primitives';
import { HermesShell, MetricCard, SectionCard } from '@/components/hermes-shell';
import { getHermesProductById, getHermesTaskById, getHermesTaskEvents } from '@/lib/hermes';
import {
@ -17,11 +17,11 @@ import {
const fmt = new Intl.DateTimeFormat('en', { month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' });
function levelTone(level: 'debug' | 'info' | 'warn' | 'error' | 'success'): 'success' | 'warning' | 'error' | 'neutral' | 'info' {
function levelTone(level: 'debug' | 'info' | 'warn' | 'error' | 'success'): 'success' | 'warning' | 'danger' | 'neutral' | 'info' {
switch (level) {
case 'success': return 'success';
case 'warn': return 'warning';
case 'error': return 'error';
case 'error': return 'danger';
case 'debug': return 'neutral';
default: return 'info';
}
@ -72,6 +72,20 @@ export default function HermesTaskDetailPage({ params }: { params: { id: string
const product = getHermesProductById(task.productId);
const timeline = events.slice().sort((a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime());
const timelineItems: TimelineItem[] = timeline.map((event) => ({
id: event.id,
title: event.message,
description: (
<div className="grid gap-1">
{event.command ? <p><span className="text-[var(--bl-text-tertiary)]">Command:</span> {event.command}</p> : null}
{event.toolName ? <p><span className="text-[var(--bl-text-tertiary)]">Tool:</span> {event.toolName}</p> : null}
{event.artifactUrl ? <p><span className="text-[var(--bl-text-tertiary)]">Artifact:</span> {event.artifactUrl}</p> : null}
</div>
),
meta: fmt.format(new Date(event.timestamp)),
status: event.eventType,
tone: levelTone(event.level),
}));
return (
<HermesShell
@ -89,7 +103,7 @@ export default function HermesTaskDetailPage({ params }: { params: { id: string
<div className="grid gap-6 xl:grid-cols-[1.2fr_0.8fr]">
<SectionCard title="Summary" subtitle="Everything Hermes knows about this task in one place.">
<div className="grid gap-4 lg:grid-cols-2">
<div className="rounded-2xl border border-[var(--bl-border)] bg-[var(--bl-surface-muted)] p-4 space-y-3">
<Card variant="muted" padding="sm" className="space-y-3">
<div className="flex items-center gap-2">
<Badge variant={task.status === 'completed' ? 'success' : task.status === 'failed' ? 'error' : task.status === 'blocked' ? 'warning' : 'neutral'}>{task.status}</Badge>
<Badge variant="neutral">{task.source}</Badge>
@ -101,8 +115,8 @@ export default function HermesTaskDetailPage({ params }: { params: { id: string
<p><span className="text-[var(--bl-text-tertiary)]">Result:</span> {task.result ?? 'n/a'}</p>
<p><span className="text-[var(--bl-text-tertiary)]">Blocker:</span> {task.blockerReason ?? 'n/a'}</p>
</div>
</div>
<div className="rounded-2xl border border-[var(--bl-border)] bg-[var(--bl-surface-muted)] p-4 space-y-3">
</Card>
<Card variant="muted" padding="sm" className="space-y-3">
<p className="text-xs uppercase tracking-[0.2em] text-[var(--bl-text-tertiary)]">Execution details</p>
<div className="space-y-2 text-sm text-[var(--bl-text-secondary)]">
<p><span className="text-[var(--bl-text-tertiary)]">Created:</span> {fmt.format(new Date(task.createdAt))}</p>
@ -114,28 +128,28 @@ export default function HermesTaskDetailPage({ params }: { params: { id: string
<div className="flex flex-wrap gap-2">
{task.tags.map((tag) => <Badge key={tag} variant="neutral">{tag}</Badge>)}
</div>
</div>
</Card>
</div>
</SectionCard>
<SectionCard title="Hermes learning" subtitle="A place to capture the memory and prevention pattern for next time.">
<div className="space-y-3 text-sm text-[var(--bl-text-secondary)]">
<div className="rounded-2xl border border-[var(--bl-border)] bg-[var(--bl-surface-muted)] p-4">
<Card variant="muted" padding="sm">
<p className="text-xs uppercase tracking-[0.2em] text-[var(--bl-text-tertiary)]">Lesson learned</p>
<p className="mt-2">{task.status === 'failed' ? 'Capture the failing command, dependency, and the exact resolution before retrying the lane.' : 'Preserve the successful execution path as a repeatable pattern.'}</p>
</div>
<div className="rounded-2xl border border-[var(--bl-border)] bg-[var(--bl-surface-muted)] p-4">
</Card>
<Card variant="muted" padding="sm">
<p className="text-xs uppercase tracking-[0.2em] text-[var(--bl-text-tertiary)]">Suggested memory update</p>
<p className="mt-2">{task.status === 'blocked' ? 'Remember that this workflow requires founder approval or a credential refresh before execution can continue.' : 'Document the command sequence and verification checks for future reuse.'}</p>
</div>
<div className="rounded-2xl border border-[var(--bl-border)] bg-[var(--bl-surface-muted)] p-4">
</Card>
<Card variant="muted" padding="sm">
<p className="text-xs uppercase tracking-[0.2em] text-[var(--bl-text-tertiary)]">Prevention for next time</p>
<p className="mt-2">{task.nextAction ?? 'Keep telemetry wired into the dashboard for follow-up visibility.'}</p>
</div>
<div className="rounded-2xl border border-[var(--bl-border)] bg-[var(--bl-surface-muted)] p-4">
</Card>
<Card variant="muted" padding="sm">
<p className="text-xs uppercase tracking-[0.2em] text-[var(--bl-text-tertiary)]">Recurring issue detection</p>
<p className="mt-2">{task.retryCount > 0 ? 'Multiple retries detected; this lane should be watched for recurrence.' : 'No recurring pattern detected for this task.'}</p>
</div>
</Card>
</div>
</SectionCard>
</div>
@ -192,24 +206,7 @@ export default function HermesTaskDetailPage({ params }: { params: { id: string
</SectionCard>
<SectionCard title="Timeline" subtitle="Chronological event stream for the task lifecycle.">
<ol className="space-y-4">
{timeline.map((event) => (
<li key={event.id} className="rounded-2xl border border-[var(--bl-border)] bg-[var(--bl-surface-muted)] p-4">
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="space-y-1">
<div className="flex flex-wrap items-center gap-2">
<Badge variant={levelTone(event.level)}>{event.eventType}</Badge>
<span className="text-sm font-medium text-[var(--bl-text-primary)]">{event.message}</span>
</div>
{event.command ? <p className="text-sm text-[var(--bl-text-secondary)]"><span className="text-[var(--bl-text-tertiary)]">Command:</span> {event.command}</p> : null}
{event.toolName ? <p className="text-sm text-[var(--bl-text-secondary)]"><span className="text-[var(--bl-text-tertiary)]">Tool:</span> {event.toolName}</p> : null}
{event.artifactUrl ? <p className="text-sm text-[var(--bl-text-secondary)]"><span className="text-[var(--bl-text-tertiary)]">Artifact:</span> {event.artifactUrl}</p> : null}
</div>
<p className="text-xs text-[var(--bl-text-tertiary)]">{fmt.format(new Date(event.timestamp))}</p>
</div>
</li>
))}
</ol>
<Timeline items={timelineItems} />
</SectionCard>
<div className="grid gap-6 lg:grid-cols-2">

View File

@ -3,7 +3,19 @@
import { Fragment, useEffect, useMemo, useState } from 'react';
import Link from 'next/link';
import { Download, Filter, Search, ChevronDown, ChevronUp, ArrowLeftRight, Activity } from 'lucide-react';
import { Badge, Button, Input } from '@/components/ui/Primitives';
import {
Badge,
Button,
Card,
DataTable,
DataTableBody,
DataTableCell,
DataTableHead,
DataTableHeader,
DataTableRow,
Input,
Select,
} from '@/components/ui/Primitives';
import { HermesShell, MetricCard, SectionCard } from '@/components/hermes-shell';
import { HermesInstanceBadge } from '@/components/hermes-instance-switcher';
import { useHermesInstance } from '@/lib/hermes-instance-context';
@ -35,6 +47,11 @@ const sources: Array<HermesTaskSource | 'all'> = ['all', 'manual', 'cron', 'gith
const sortOptions = ['newest', 'oldest', 'priority', 'status'] as const;
const pageSize = 8;
const optionize = (items: string[], allLabel: string) => items.map((item) => ({
value: item,
label: item === 'all' ? allLabel : item,
}));
function prettyDate(value?: string) {
return value ? new Intl.DateTimeFormat('en', { month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' }).format(new Date(value)) : '—';
}
@ -225,31 +242,13 @@ export default function HermesTaskLedgerPage() {
<SectionCard title="Filters" subtitle="Find work by status, product, priority, type, source, or age.">
<div className="grid gap-3 lg:grid-cols-4 xl:grid-cols-7">
<Input value={query} onChange={(event) => { setQuery(event.target.value); setPage(1); }} placeholder="Search tasks..." aria-label="Search tasks" className="xl:col-span-2" />
<select value={status} onChange={(event) => { setStatus(event.target.value as HermesTaskStatus | 'all'); setPage(1); }} className="h-10 rounded-md border border-[var(--bl-border)] bg-[var(--bl-surface-card)] px-3 text-sm text-[var(--bl-text-primary)]">
{statuses.map((item) => <option key={item} value={item}>{item === 'all' ? 'All statuses' : item}</option>)}
</select>
<select value={productId} onChange={(event) => { setProductId(event.target.value); setPage(1); }} className="h-10 rounded-md border border-[var(--bl-border)] bg-[var(--bl-surface-card)] px-3 text-sm text-[var(--bl-text-primary)]">
<option value="all">All products</option>
{visibleProducts.map((product) => <option key={product.id} value={product.id}>{product.name}</option>)}
</select>
<select value={priority} onChange={(event) => { setPriority(event.target.value as HermesPriority | 'all'); setPage(1); }} className="h-10 rounded-md border border-[var(--bl-border)] bg-[var(--bl-surface-card)] px-3 text-sm text-[var(--bl-text-primary)]">
{priorities.map((item) => <option key={item} value={item}>{item === 'all' ? 'All priorities' : item}</option>)}
</select>
<select value={type} onChange={(event) => { setType(event.target.value as HermesTaskType | 'all'); setPage(1); }} className="h-10 rounded-md border border-[var(--bl-border)] bg-[var(--bl-surface-card)] px-3 text-sm text-[var(--bl-text-primary)]">
{taskTypes.map((item) => <option key={item} value={item}>{item === 'all' ? 'All types' : item}</option>)}
</select>
<select value={source} onChange={(event) => { setSource(event.target.value as HermesTaskSource | 'all'); setPage(1); }} className="h-10 rounded-md border border-[var(--bl-border)] bg-[var(--bl-surface-card)] px-3 text-sm text-[var(--bl-text-primary)]">
{sources.map((item) => <option key={item} value={item}>{item === 'all' ? 'All sources' : item}</option>)}
</select>
<select value={updatedWithinDays} onChange={(event) => { setUpdatedWithinDays(event.target.value === 'all' ? 'all' : Number(event.target.value)); setPage(1); }} className="h-10 rounded-md border border-[var(--bl-border)] bg-[var(--bl-surface-card)] px-3 text-sm text-[var(--bl-text-primary)]">
<option value="all">Any time</option>
<option value="1">Last 24h</option>
<option value="7">Last 7d</option>
<option value="30">Last 30d</option>
</select>
<select value={sort} onChange={(event) => { setSort(event.target.value as typeof sort); setPage(1); }} className="h-10 rounded-md border border-[var(--bl-border)] bg-[var(--bl-surface-card)] px-3 text-sm text-[var(--bl-text-primary)]">
{sortOptions.map((item) => <option key={item} value={item}>{item}</option>)}
</select>
<Select value={status} onChange={(event) => { setStatus(event.target.value as HermesTaskStatus | 'all'); setPage(1); }} options={optionize(statuses, 'All statuses')} aria-label="Status" />
<Select value={productId} onChange={(event) => { setProductId(event.target.value); setPage(1); }} options={[{ value: 'all', label: 'All products' }, ...visibleProducts.map((product) => ({ value: product.id, label: product.name }))]} aria-label="Product" />
<Select value={priority} onChange={(event) => { setPriority(event.target.value as HermesPriority | 'all'); setPage(1); }} options={optionize(priorities, 'All priorities')} aria-label="Priority" />
<Select value={type} onChange={(event) => { setType(event.target.value as HermesTaskType | 'all'); setPage(1); }} options={optionize(taskTypes, 'All types')} aria-label="Type" />
<Select value={source} onChange={(event) => { setSource(event.target.value as HermesTaskSource | 'all'); setPage(1); }} options={optionize(sources, 'All sources')} aria-label="Source" />
<Select value={updatedWithinDays} onChange={(event) => { setUpdatedWithinDays(event.target.value === 'all' ? 'all' : Number(event.target.value)); setPage(1); }} options={[{ value: 'all', label: 'Any time' }, { value: '1', label: 'Last 24h' }, { value: '7', label: 'Last 7d' }, { value: '30', label: 'Last 30d' }]} aria-label="Updated within" />
<Select value={sort} onChange={(event) => { setSort(event.target.value as typeof sort); setPage(1); }} options={sortOptions.map((item) => ({ value: item, label: item }))} aria-label="Sort" />
</div>
<div className="mt-3 flex flex-wrap gap-2 text-sm text-[var(--bl-text-secondary)]">
<span className="inline-flex items-center gap-2 rounded-full border border-[var(--bl-border)] bg-[var(--bl-surface-muted)] px-3 py-1"><Filter className="h-3.5 w-3.5" />{tasks.length} matches</span>
@ -258,48 +257,46 @@ export default function HermesTaskLedgerPage() {
</SectionCard>
<SectionCard title="Task table" subtitle="Click any task title to inspect the full detail view." actions={<Badge variant="neutral">Page {page} of {totalPages}</Badge>}>
<div className="overflow-hidden rounded-2xl border border-[var(--bl-border)]">
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-[var(--bl-border)] text-left text-sm">
<thead className="bg-[var(--bl-surface-muted)] text-xs uppercase tracking-[0.18em] text-[var(--bl-text-tertiary)]">
<tr>
<th className="px-4 py-3">Task</th>
<th className="px-4 py-3">Product</th>
<th className="px-4 py-3">Status</th>
<th className="px-4 py-3">Priority</th>
<th className="px-4 py-3">Type</th>
<th className="px-4 py-3">Source</th>
<th className="px-4 py-3">Created</th>
<th className="px-4 py-3">Duration</th>
<th className="px-4 py-3 text-right">Actions</th>
</tr>
</thead>
<tbody className="divide-y divide-[var(--bl-border)] bg-[var(--bl-surface-card)]">
<DataTable>
<DataTableHeader>
<DataTableRow>
<DataTableHead>Task</DataTableHead>
<DataTableHead>Product</DataTableHead>
<DataTableHead>Status</DataTableHead>
<DataTableHead>Priority</DataTableHead>
<DataTableHead>Type</DataTableHead>
<DataTableHead>Source</DataTableHead>
<DataTableHead>Created</DataTableHead>
<DataTableHead>Duration</DataTableHead>
<DataTableHead className="text-right">Actions</DataTableHead>
</DataTableRow>
</DataTableHeader>
<DataTableBody>
{pagedTasks.map((task) => {
const product = getHermesProductById(task.productId);
const expanded = expandedTaskId === task.id;
return (
<Fragment key={task.id}>
<tr className="align-top hover:bg-[var(--bl-surface-muted)]/60">
<td className="px-4 py-4">
<DataTableRow className="align-top">
<DataTableCell>
<div className="max-w-[24rem] space-y-1">
<Link href={`/hermes/tasks/${task.id}`} className="font-medium text-[var(--bl-text-primary)] hover:underline">{task.title}</Link>
<p className="line-clamp-2 text-xs leading-5 text-[var(--bl-text-secondary)]">{task.description}</p>
</div>
</td>
<td className="px-4 py-4 text-[var(--bl-text-secondary)]">
</DataTableCell>
<DataTableCell className="text-[var(--bl-text-secondary)]">
<div className="flex flex-wrap items-center gap-2">
<span>{product?.name ?? 'Unknown'}</span>
<HermesInstanceBadge instanceId={task.instanceId} />
</div>
</td>
<td className="px-4 py-4"><Badge variant={task.status === 'completed' ? 'success' : task.status === 'failed' ? 'error' : task.status === 'blocked' ? 'warning' : 'neutral'}>{task.status}</Badge></td>
<td className="px-4 py-4"><Badge variant={task.priority === 'P0' ? 'error' : task.priority === 'P1' ? 'warning' : 'neutral'}>{task.priority}</Badge></td>
<td className="px-4 py-4 text-[var(--bl-text-secondary)]">{task.type}</td>
<td className="px-4 py-4 text-[var(--bl-text-secondary)]">{task.source}</td>
<td className="px-4 py-4 text-[var(--bl-text-secondary)]">{prettyDate(task.createdAt)}</td>
<td className="px-4 py-4 text-[var(--bl-text-secondary)]">{task.durationMs ? `${Math.round(task.durationMs / 60000)}m` : '—'}</td>
<td className="px-4 py-4 text-right">
</DataTableCell>
<DataTableCell><Badge variant={task.status === 'completed' ? 'success' : task.status === 'failed' ? 'error' : task.status === 'blocked' ? 'warning' : 'neutral'}>{task.status}</Badge></DataTableCell>
<DataTableCell><Badge variant={task.priority === 'P0' ? 'error' : task.priority === 'P1' ? 'warning' : 'neutral'}>{task.priority}</Badge></DataTableCell>
<DataTableCell className="text-[var(--bl-text-secondary)]">{task.type}</DataTableCell>
<DataTableCell className="text-[var(--bl-text-secondary)]">{task.source}</DataTableCell>
<DataTableCell className="text-[var(--bl-text-secondary)]">{prettyDate(task.createdAt)}</DataTableCell>
<DataTableCell className="text-[var(--bl-text-secondary)]">{task.durationMs ? `${Math.round(task.durationMs / 60000)}m` : '—'}</DataTableCell>
<DataTableCell className="text-right">
<div className="inline-flex items-center gap-2">
<Button variant="ghost" size="sm" onClick={() => setExpandedTaskId(expanded ? null : task.id)}>
{expanded ? <ChevronUp className="mr-1 h-4 w-4" /> : <ChevronDown className="mr-1 h-4 w-4" />}
@ -307,13 +304,13 @@ export default function HermesTaskLedgerPage() {
</Button>
<Button asChild variant="secondary" size="sm"><Link href={`/hermes/tasks/${task.id}`}>Open</Link></Button>
</div>
</td>
</tr>
</DataTableCell>
</DataTableRow>
{expanded ? (
<tr key={`${task.id}-details`}>
<td colSpan={9} className="bg-[var(--bl-surface-muted)] px-4 py-4">
<DataTableRow key={`${task.id}-details`}>
<DataTableCell colSpan={9} className="bg-[var(--bl-surface-muted)]">
<div className="grid gap-4 lg:grid-cols-2">
<div className="rounded-2xl border border-[var(--bl-border)] bg-[var(--bl-surface-card)] p-4">
<Card padding="sm">
<p className="text-xs uppercase tracking-[0.2em] text-[var(--bl-text-tertiary)]">Summary</p>
<p className="mt-2 text-sm text-[var(--bl-text-secondary)]">{task.summary}</p>
<div className="mt-3 space-y-2 text-sm text-[var(--bl-text-secondary)]">
@ -321,8 +318,8 @@ export default function HermesTaskLedgerPage() {
<div>Last action: {task.lastAction ?? 'n/a'}</div>
<div>Next action: {task.nextAction ?? 'n/a'}</div>
</div>
</div>
<div className="rounded-2xl border border-[var(--bl-border)] bg-[var(--bl-surface-card)] p-4">
</Card>
<Card padding="sm">
<p className="text-xs uppercase tracking-[0.2em] text-[var(--bl-text-tertiary)]">Signals</p>
<div className="mt-3 flex flex-wrap gap-2">
{task.tags.map((tag) => <Badge key={tag} variant="neutral">{tag}</Badge>)}
@ -333,23 +330,21 @@ export default function HermesTaskLedgerPage() {
<div>Started: {prettyDate(task.startedAt)}</div>
<div>Completed: {prettyDate(task.completedAt)}</div>
</div>
</div>
</Card>
</div>
</td>
</tr>
</DataTableCell>
</DataTableRow>
) : null}
</Fragment>
);
})}
{pagedTasks.length === 0 ? (
<tr>
<td colSpan={9} className="px-4 py-10 text-center text-[var(--bl-text-secondary)]">No tasks matched the current filters.</td>
</tr>
<DataTableRow>
<DataTableCell colSpan={9} className="px-4 py-10 text-center text-[var(--bl-text-secondary)]">No tasks matched the current filters.</DataTableCell>
</DataTableRow>
) : null}
</tbody>
</table>
</div>
</div>
</DataTableBody>
</DataTable>
<div className="mt-4 flex flex-wrap items-center justify-between gap-3">
<p className="text-sm text-[var(--bl-text-secondary)]">Showing {pagedTasks.length} of {tasks.length} filtered tasks.</p>
<div className="flex items-center gap-2">

View File

@ -1,6 +1,15 @@
import type { ReactNode } from 'react';
import { cn } from '@/lib/utils';
import { Badge } from '@/components/ui/Primitives';
import {
Badge,
MetricCard as CommonMetricCard,
PageHeader,
Panel,
PanelBody,
PanelDescription,
PanelHeader,
PanelTitle,
} from '@/components/ui/Primitives';
interface HermesShellProps {
title: string;
@ -14,18 +23,12 @@ interface HermesShellProps {
export function HermesShell({ title, description, badge = 'Hermes Mission Control', actions, children, className }: HermesShellProps) {
return (
<div className={cn('space-y-6', className)}>
<header className="rounded-3xl border border-[var(--bl-border)] bg-[linear-gradient(135deg,rgba(90,140,255,0.18),rgba(46,230,214,0.08))] p-6 shadow-[var(--bl-shadow-md)] backdrop-blur-sm lg:p-8">
<div className="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
<div className="max-w-3xl space-y-3">
<Badge variant="info">{badge}</Badge>
<div>
<h1 className="text-3xl font-semibold tracking-tight text-[var(--bl-text-primary)] lg:text-4xl">{title}</h1>
<p className="mt-2 max-w-2xl text-sm leading-6 text-[var(--bl-text-secondary)] lg:text-base">{description}</p>
</div>
</div>
{actions ? <div className="flex flex-wrap gap-3">{actions}</div> : null}
</div>
</header>
<PageHeader
eyebrow={<Badge variant="info">{badge}</Badge>}
title={title}
description={description}
actions={actions}
/>
{children}
</div>
);
@ -41,16 +44,16 @@ interface SectionCardProps {
export function SectionCard({ title, subtitle, actions, children, className }: SectionCardProps) {
return (
<section className={cn('rounded-3xl border border-[var(--bl-border)] bg-[var(--bl-surface-card)] p-5 shadow-[var(--bl-shadow-sm)] lg:p-6', className)}>
<div className="mb-4 flex flex-wrap items-start justify-between gap-3">
<div>
<h2 className="text-lg font-semibold text-[var(--bl-text-primary)]">{title}</h2>
{subtitle ? <p className="mt-1 text-sm text-[var(--bl-text-secondary)]">{subtitle}</p> : null}
<Panel density="normal" className={className}>
<PanelHeader className="mb-4">
<div className="min-w-0">
<PanelTitle>{title}</PanelTitle>
{subtitle ? <PanelDescription>{subtitle}</PanelDescription> : null}
</div>
{actions}
</div>
{children}
</section>
</PanelHeader>
<PanelBody>{children}</PanelBody>
</Panel>
);
}
@ -63,24 +66,20 @@ interface MetricCardProps {
}
export function MetricCard({ label, value, helpText, tone = 'default', icon }: MetricCardProps) {
const toneStyles: Record<NonNullable<MetricCardProps['tone']>, string> = {
default: 'text-[var(--bl-text-primary)]',
success: 'text-[var(--bl-success)]',
warning: 'text-[var(--bl-warning)]',
danger: 'text-[var(--bl-danger)]',
info: 'text-[var(--bl-accent)]',
const commonTone: Record<NonNullable<MetricCardProps['tone']>, 'neutral' | 'success' | 'warning' | 'danger' | 'info'> = {
default: 'neutral',
success: 'success',
warning: 'warning',
danger: 'danger',
info: 'info',
};
return (
<div className="rounded-2xl border border-[var(--bl-border)] bg-[var(--bl-surface-card)] p-4 shadow-[var(--bl-shadow-sm)]">
<div className="flex items-start justify-between gap-3">
<div>
<p className="text-xs font-medium uppercase tracking-[0.22em] text-[var(--bl-text-tertiary)]">{label}</p>
<p className={cn('mt-2 text-3xl font-semibold tracking-tight', toneStyles[tone])}>{value}</p>
{helpText ? <p className="mt-1 text-sm text-[var(--bl-text-secondary)]">{helpText}</p> : null}
</div>
{icon ? <div className="rounded-2xl border border-[var(--bl-border)] bg-[var(--bl-surface-muted)] p-2 text-[var(--bl-text-secondary)]">{icon}</div> : null}
</div>
</div>
<CommonMetricCard
label={<span className="flex items-center justify-between gap-3">{label}{icon ? <span className="text-[var(--bl-text-tertiary)]">{icon}</span> : null}</span>}
value={value}
helper={helpText}
tone={commonTone[tone]}
/>
);
}

View File

@ -1,107 +1,116 @@
import * as React from 'react';
import {
Badge as CommonBadge,
Button as CommonButton,
Input as CommonInput,
Select as CommonSelect,
type BadgeProps as CommonBadgeProps,
type ButtonProps as CommonButtonProps,
type InputProps as CommonInputProps,
type SelectProps as CommonSelectProps,
} from '@bytelyst/ui';
import { cn } from '@/lib/utils';
// Basic button component using design tokens
export interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
variant?: 'primary' | 'secondary' | 'ghost' | 'link';
size?: 'sm' | 'md' | 'lg';
asChild?: boolean;
}
export {
Card,
CardDescription,
CardHeader,
CardTitle,
DataTable,
DataTableBody,
DataTableCell,
DataTableHead,
DataTableHeader,
DataTableRow,
MetricCard,
PageHeader,
Panel,
PanelBody,
PanelDescription,
PanelHeader,
PanelTitle,
Timeline,
type TimelineItem,
} from '@bytelyst/ui';
export type ButtonProps = CommonButtonProps;
export const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ variant = 'primary', size = 'md', asChild = false, className, children, ...props }, ref) => {
const baseStyles = 'inline-flex items-center justify-center rounded-md font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50';
({ variant = 'primary', size = 'md', asChild, className, children, ...props }, ref) => {
if (asChild) {
const child = React.Children.toArray(children).find(React.isValidElement);
const classes = cn(
'inline-flex shrink-0 items-center justify-center whitespace-nowrap rounded-lg font-semibold tracking-normal transition duration-150 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--bl-focus-ring)] focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--bl-bg-canvas)] disabled:pointer-events-none disabled:opacity-50',
variant === 'primary' && 'border border-transparent bg-[var(--bl-accent)] text-[var(--bl-accent-foreground)] shadow-sm shadow-black/10 hover:brightness-105 active:brightness-95',
variant === 'secondary' && 'border border-[var(--bl-border)] bg-[var(--bl-surface-card)] text-[var(--bl-text-primary)] shadow-sm shadow-black/5 hover:border-[var(--bl-border-strong)] hover:bg-[var(--bl-surface-highlight)]',
variant === 'ghost' && 'border border-transparent text-[var(--bl-text-secondary)] hover:bg-[var(--bl-surface-muted)] hover:text-[var(--bl-text-primary)]',
variant === 'destructive' && 'border border-transparent bg-[var(--bl-danger)] text-[var(--bl-danger-foreground)] shadow-sm shadow-black/10 hover:brightness-105 active:brightness-95',
variant === 'outline' && 'border border-[var(--bl-border)] bg-transparent text-[var(--bl-text-primary)] hover:border-[var(--bl-accent)] hover:bg-[var(--bl-accent-muted)]',
variant === 'subtle' && 'border border-transparent bg-[var(--bl-surface-muted)] text-[var(--bl-text-primary)] hover:bg-[var(--bl-surface-highlight)]',
variant === 'link' && 'h-auto rounded-md border border-transparent p-0 text-[var(--bl-accent)] underline-offset-4 hover:underline',
size === 'sm' && 'h-8 px-3 text-xs gap-1.5',
size === 'md' && 'h-10 px-4 text-sm gap-2',
size === 'lg' && 'h-11 px-5 text-sm gap-2.5',
className,
);
const variantStyles = {
primary: 'bg-[var(--bl-accent)] text-[var(--bl-accent-foreground)] hover:opacity-90 focus-visible:ring-[var(--bl-focus-ring)]',
secondary: 'bg-[var(--bl-surface-muted)] text-[var(--bl-text-primary)] hover:bg-[var(--bl-surface-highlight)] focus-visible:ring-[var(--bl-focus-ring)]',
ghost: 'text-[var(--bl-text-primary)] hover:bg-[var(--bl-surface-muted)] focus-visible:ring-[var(--bl-focus-ring)]',
link: 'text-[var(--bl-accent)] hover:underline focus-visible:ring-[var(--bl-focus-ring)]',
};
const sizeStyles = {
sm: 'h-9 px-3 text-sm',
md: 'h-10 px-4 text-sm',
lg: 'h-11 px-8 text-base',
};
const classes = cn(baseStyles, variantStyles[variant], sizeStyles[size], className);
if (asChild && React.isValidElement(children)) {
const typedChild = children as React.ReactElement<{ className?: string }>;
return React.cloneElement(typedChild, {
className: cn(typedChild.props.className, classes),
});
if (React.isValidElement<{ className?: string }>(child)) {
return React.cloneElement(child, {
className: cn(child.props.className, classes),
});
}
}
return (
<button
<CommonButton
ref={ref}
className={classes}
variant={variant}
size={size}
className={className}
{...props}
>
{children}
</button>
</CommonButton>
);
},
);
Button.displayName = 'Button';
// Basic badge component using design tokens
export interface BadgeProps extends React.HTMLAttributes<HTMLDivElement> {
variant?: 'neutral' | 'success' | 'warning' | 'error' | 'info';
dot?: boolean;
export interface BadgeProps extends Omit<CommonBadgeProps, 'variant'> {
variant?: CommonBadgeProps['variant'] | 'danger';
}
export function Badge({ variant = 'neutral', dot = false, className, children, ...props }: BadgeProps) {
const variantStyles = {
neutral: 'bg-[var(--bl-surface-muted)] text-[var(--bl-fg-muted)]',
success: 'bg-[var(--bl-success-bg)] text-[var(--bl-success-fg)]',
warning: 'bg-[var(--bl-warning-bg)] text-[var(--bl-warning-fg)]',
error: 'bg-[var(--bl-danger-bg)] text-[var(--bl-danger-fg)]',
info: 'bg-[var(--bl-info-bg)] text-[var(--bl-info-fg)]',
};
return (
<div
className={cn(
'inline-flex items-center gap-1.5 rounded-full px-2.5 py-0.5 text-xs font-medium',
variantStyles[variant],
className,
)}
{...props}
>
{dot && <span className="h-1.5 w-1.5 rounded-full current-color" />}
<CommonBadge variant={variant === 'danger' ? 'error' : variant} dot={dot} className={className} {...props}>
{children}
</div>
</CommonBadge>
);
}
// Input component using design tokens
export interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {
variant?: 'surface' | 'muted';
}
export type InputProps = CommonInputProps;
export const Input = React.forwardRef<HTMLInputElement, InputProps>(
({ variant = 'surface', className, ...props }, ref) => {
const variantStyles = {
surface: 'bg-[var(--bl-input)] border-[var(--bl-border)]',
muted: 'bg-[var(--bl-surface-muted)] border-[var(--bl-border)]',
};
return (
<input
ref={ref}
className={cn(
'flex h-10 w-full rounded-md border px-3.5 py-2.5 text-sm placeholder:text-[var(--bl-fg-muted)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--bl-primary)] focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50',
variantStyles[variant],
className,
)}
{...props}
/>
);
},
({ className, ...props }, ref) => <CommonInput ref={ref} className={className} {...props} />,
);
Input.displayName = 'Input';
export interface SelectProps extends Omit<CommonSelectProps, 'options'> {
options: CommonSelectProps['options'];
}
export const Select = React.forwardRef<HTMLSelectElement, SelectProps>(
({ controlSize = 'md', variant = 'surface', className, ...props }, ref) => (
<CommonSelect
ref={ref}
controlSize={controlSize}
variant={variant}
className={cn('min-w-0', className)}
{...props}
/>
),
);
Select.displayName = 'Select';