feat(dashboard): GuruConnect v2 operator console (pass 1)
All checks were successful
Build and Test / Build Agent (Windows) (push) Successful in 6m56s
Build and Test / Build Server (Linux) (push) Successful in 10m15s
Build and Test / Security Audit (push) Successful in 4m12s
Build and Test / Build Summary (push) Successful in 10s

React + Vite + TypeScript SPA: scaffold, operations-terminal design
system, Bearer-token auth, and the Machines view.

- Design system: OKLCH-tinted dark theme (ink-slate + signal-cyan),
  Hanken Grotesk + JetBrains Mono, status-color language
  (online/offline/granted/pending/denied/not_required), motion with
  prefers-reduced-motion honored.
- Auth: token in sessionStorage via ref (never React state), protected
  routes, 401 session teardown, admin-gated per-agent-key UI.
- Machines view: data table (sticky header, keyboard-activated rows,
  skeleton loading, actionable empty/error states), non-blocking detail
  drawer, delete confirm, admin key management with copy-once reveal.
- UI primitives: Modal (focus trap + inert + portal + dialogStack),
  Drawer, Table, Badge/StatusDot, toast, states.
- Typed API client normalizing the two error-envelope shapes.

Passed Code Review (no blockers), impeccable critique-and-polish, and
local gates (tsc/lint/build green). Dev-only Vite proxy to :3002.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-30 12:51:11 -07:00
parent f9bdecbfdb
commit 43a9432b81
66 changed files with 7777 additions and 995 deletions

View File

@@ -1,215 +0,0 @@
/**
* RemoteViewer Component
*
* Canvas-based remote desktop viewer that connects to a GuruConnect
* agent via the relay server. Handles frame rendering and input capture.
*/
import React, { useRef, useEffect, useCallback, useState } from 'react';
import { useRemoteSession, createMouseEvent, createKeyEvent } from '../hooks/useRemoteSession';
import type { VideoFrame, ConnectionStatus, MouseEventType } from '../types/protocol';
interface RemoteViewerProps {
serverUrl: string;
sessionId: string;
className?: string;
onStatusChange?: (status: ConnectionStatus) => void;
autoConnect?: boolean;
showStatusBar?: boolean;
}
export const RemoteViewer: React.FC<RemoteViewerProps> = ({
serverUrl,
sessionId,
className = '',
onStatusChange,
autoConnect = true,
showStatusBar = true,
}) => {
const canvasRef = useRef<HTMLCanvasElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
const ctxRef = useRef<CanvasRenderingContext2D | null>(null);
// Display dimensions from received frames
const [displaySize, setDisplaySize] = useState({ width: 1920, height: 1080 });
// Frame buffer for rendering
const frameBufferRef = useRef<ImageData | null>(null);
// Handle incoming video frames
const handleFrame = useCallback((frame: VideoFrame) => {
if (!frame.raw || !canvasRef.current) return;
const { width, height, data, compressed, isKeyframe } = frame.raw;
// Update display size if changed
if (width !== displaySize.width || height !== displaySize.height) {
setDisplaySize({ width, height });
}
// Get or create context
if (!ctxRef.current) {
ctxRef.current = canvasRef.current.getContext('2d', {
alpha: false,
desynchronized: true,
});
}
const ctx = ctxRef.current;
if (!ctx) return;
// For MVP, we assume raw BGRA frames
// In production, handle compressed frames with fzstd
let frameData = data;
// Create or reuse ImageData
if (!frameBufferRef.current ||
frameBufferRef.current.width !== width ||
frameBufferRef.current.height !== height) {
frameBufferRef.current = ctx.createImageData(width, height);
}
const imageData = frameBufferRef.current;
// Convert BGRA to RGBA for canvas
const pixels = imageData.data;
const len = Math.min(frameData.length, pixels.length);
for (let i = 0; i < len; i += 4) {
pixels[i] = frameData[i + 2]; // R <- B
pixels[i + 1] = frameData[i + 1]; // G <- G
pixels[i + 2] = frameData[i]; // B <- R
pixels[i + 3] = 255; // A (opaque)
}
// Draw to canvas
ctx.putImageData(imageData, 0, 0);
}, [displaySize]);
// Set up session
const { status, connect, disconnect, sendMouseEvent, sendKeyEvent } = useRemoteSession({
serverUrl,
sessionId,
onFrame: handleFrame,
onStatusChange,
});
// Auto-connect on mount
useEffect(() => {
if (autoConnect) {
connect();
}
return () => {
disconnect();
};
}, [autoConnect, connect, disconnect]);
// Update canvas size when display size changes
useEffect(() => {
if (canvasRef.current) {
canvasRef.current.width = displaySize.width;
canvasRef.current.height = displaySize.height;
// Reset context reference
ctxRef.current = null;
frameBufferRef.current = null;
}
}, [displaySize]);
// Get canvas rect for coordinate translation
const getCanvasRect = useCallback(() => {
return canvasRef.current?.getBoundingClientRect() ?? new DOMRect();
}, []);
// Mouse event handlers
const handleMouseMove = useCallback((e: React.MouseEvent<HTMLCanvasElement>) => {
const event = createMouseEvent(e, getCanvasRect(), displaySize.width, displaySize.height, 0);
sendMouseEvent(event);
}, [getCanvasRect, displaySize, sendMouseEvent]);
const handleMouseDown = useCallback((e: React.MouseEvent<HTMLCanvasElement>) => {
e.preventDefault();
const event = createMouseEvent(e, getCanvasRect(), displaySize.width, displaySize.height, 1);
sendMouseEvent(event);
}, [getCanvasRect, displaySize, sendMouseEvent]);
const handleMouseUp = useCallback((e: React.MouseEvent<HTMLCanvasElement>) => {
const event = createMouseEvent(e, getCanvasRect(), displaySize.width, displaySize.height, 2);
sendMouseEvent(event);
}, [getCanvasRect, displaySize, sendMouseEvent]);
const handleWheel = useCallback((e: React.WheelEvent<HTMLCanvasElement>) => {
e.preventDefault();
const baseEvent = createMouseEvent(e, getCanvasRect(), displaySize.width, displaySize.height, 3);
sendMouseEvent({
...baseEvent,
wheelDeltaX: Math.round(e.deltaX),
wheelDeltaY: Math.round(e.deltaY),
});
}, [getCanvasRect, displaySize, sendMouseEvent]);
const handleContextMenu = useCallback((e: React.MouseEvent<HTMLCanvasElement>) => {
e.preventDefault(); // Prevent browser context menu
}, []);
// Keyboard event handlers
const handleKeyDown = useCallback((e: React.KeyboardEvent<HTMLCanvasElement>) => {
e.preventDefault();
const event = createKeyEvent(e, true);
sendKeyEvent(event);
}, [sendKeyEvent]);
const handleKeyUp = useCallback((e: React.KeyboardEvent<HTMLCanvasElement>) => {
e.preventDefault();
const event = createKeyEvent(e, false);
sendKeyEvent(event);
}, [sendKeyEvent]);
return (
<div ref={containerRef} className={`remote-viewer ${className}`}>
<canvas
ref={canvasRef}
tabIndex={0}
onMouseMove={handleMouseMove}
onMouseDown={handleMouseDown}
onMouseUp={handleMouseUp}
onWheel={handleWheel}
onContextMenu={handleContextMenu}
onKeyDown={handleKeyDown}
onKeyUp={handleKeyUp}
style={{
width: '100%',
height: 'auto',
aspectRatio: `${displaySize.width} / ${displaySize.height}`,
cursor: 'none', // Hide cursor, remote cursor is shown in frame
outline: 'none',
backgroundColor: '#1a1a1a',
}}
/>
{showStatusBar && (
<div className="remote-viewer-status" style={{
display: 'flex',
justifyContent: 'space-between',
padding: '4px 8px',
backgroundColor: '#333',
color: '#fff',
fontSize: '12px',
fontFamily: 'monospace',
}}>
<span>
{status.connected ? (
<span style={{ color: '#4ade80' }}>Connected</span>
) : (
<span style={{ color: '#f87171' }}>Disconnected</span>
)}
</span>
<span>{displaySize.width}x{displaySize.height}</span>
{status.fps !== undefined && <span>{status.fps} FPS</span>}
{status.latencyMs !== undefined && <span>{status.latencyMs}ms</span>}
</div>
)}
</div>
);
};
export default RemoteViewer;

View File

@@ -1,187 +0,0 @@
/**
* Session Controls Component
*
* Toolbar for controlling the remote session (quality, displays, special keys)
*/
import React, { useState } from 'react';
import type { QualitySettings, Display } from '../types/protocol';
interface SessionControlsProps {
displays?: Display[];
currentDisplay?: number;
onDisplayChange?: (displayId: number) => void;
quality?: QualitySettings;
onQualityChange?: (settings: QualitySettings) => void;
onSpecialKey?: (key: 'ctrl-alt-del' | 'lock-screen' | 'print-screen') => void;
onDisconnect?: () => void;
}
export const SessionControls: React.FC<SessionControlsProps> = ({
displays = [],
currentDisplay = 0,
onDisplayChange,
quality,
onQualityChange,
onSpecialKey,
onDisconnect,
}) => {
const [showQuality, setShowQuality] = useState(false);
const handleQualityPreset = (preset: 'auto' | 'low' | 'balanced' | 'high') => {
onQualityChange?.({
preset,
codec: 'auto',
});
};
return (
<div className="session-controls" style={{
display: 'flex',
gap: '8px',
padding: '8px',
backgroundColor: '#222',
borderBottom: '1px solid #444',
}}>
{/* Display selector */}
{displays.length > 1 && (
<select
value={currentDisplay}
onChange={(e) => onDisplayChange?.(Number(e.target.value))}
style={{
padding: '4px 8px',
backgroundColor: '#333',
color: '#fff',
border: '1px solid #555',
borderRadius: '4px',
}}
>
{displays.map((d) => (
<option key={d.id} value={d.id}>
{d.name || `Display ${d.id + 1}`}
{d.isPrimary ? ' (Primary)' : ''}
</option>
))}
</select>
)}
{/* Quality dropdown */}
<div style={{ position: 'relative' }}>
<button
onClick={() => setShowQuality(!showQuality)}
style={{
padding: '4px 12px',
backgroundColor: '#333',
color: '#fff',
border: '1px solid #555',
borderRadius: '4px',
cursor: 'pointer',
}}
>
Quality: {quality?.preset || 'auto'}
</button>
{showQuality && (
<div style={{
position: 'absolute',
top: '100%',
left: 0,
marginTop: '4px',
backgroundColor: '#333',
border: '1px solid #555',
borderRadius: '4px',
zIndex: 100,
}}>
{(['auto', 'low', 'balanced', 'high'] as const).map((preset) => (
<button
key={preset}
onClick={() => {
handleQualityPreset(preset);
setShowQuality(false);
}}
style={{
display: 'block',
width: '100%',
padding: '8px 16px',
backgroundColor: quality?.preset === preset ? '#444' : 'transparent',
color: '#fff',
border: 'none',
textAlign: 'left',
cursor: 'pointer',
}}
>
{preset.charAt(0).toUpperCase() + preset.slice(1)}
</button>
))}
</div>
)}
</div>
{/* Special keys */}
<button
onClick={() => onSpecialKey?.('ctrl-alt-del')}
title="Send Ctrl+Alt+Delete"
style={{
padding: '4px 12px',
backgroundColor: '#333',
color: '#fff',
border: '1px solid #555',
borderRadius: '4px',
cursor: 'pointer',
}}
>
Ctrl+Alt+Del
</button>
<button
onClick={() => onSpecialKey?.('lock-screen')}
title="Lock Screen (Win+L)"
style={{
padding: '4px 12px',
backgroundColor: '#333',
color: '#fff',
border: '1px solid #555',
borderRadius: '4px',
cursor: 'pointer',
}}
>
Lock
</button>
<button
onClick={() => onSpecialKey?.('print-screen')}
title="Print Screen"
style={{
padding: '4px 12px',
backgroundColor: '#333',
color: '#fff',
border: '1px solid #555',
borderRadius: '4px',
cursor: 'pointer',
}}
>
PrtSc
</button>
{/* Spacer */}
<div style={{ flex: 1 }} />
{/* Disconnect */}
<button
onClick={onDisconnect}
style={{
padding: '4px 12px',
backgroundColor: '#dc2626',
color: '#fff',
border: 'none',
borderRadius: '4px',
cursor: 'pointer',
}}
>
Disconnect
</button>
</div>
);
};
export default SessionControls;

View File

@@ -1,22 +0,0 @@
/**
* GuruConnect Dashboard Components
*
* Export all components for use in GuruRMM dashboard
*/
export { RemoteViewer } from './RemoteViewer';
export { SessionControls } from './SessionControls';
// Re-export types
export type {
ConnectionStatus,
Display,
DisplayInfo,
QualitySettings,
VideoFrame,
MouseEvent as ProtoMouseEvent,
KeyEvent as ProtoKeyEvent,
} from '../types/protocol';
// Re-export hooks
export { useRemoteSession, createMouseEvent, createKeyEvent } from '../hooks/useRemoteSession';

View File

@@ -0,0 +1,17 @@
import { Outlet } from "react-router-dom";
import "./layout.css";
import { Sidebar } from "./Sidebar";
import { Topbar } from "./Topbar";
/** Persistent chrome: left sidebar + top bar around the routed page. */
export function AppShell() {
return (
<div className="shell">
<Sidebar />
<Topbar />
<main className="main">
<Outlet />
</main>
</div>
);
}

View File

@@ -0,0 +1,21 @@
import type { ReactNode } from "react";
interface PageHeaderProps {
title: string;
subtitle?: ReactNode;
/** Primary action slot, right-aligned. */
actions?: ReactNode;
}
/** Standard page title block with an action slot. */
export function PageHeader({ title, subtitle, actions }: PageHeaderProps) {
return (
<div className="page__header">
<div className="page__titles">
<h1>{title}</h1>
{subtitle && <div className="page__subtitle">{subtitle}</div>}
</div>
{actions && <div className="page__actions">{actions}</div>}
</div>
);
}

View File

@@ -0,0 +1,71 @@
import { NavLink } from "react-router-dom";
import type { ComponentType, SVGProps } from "react";
import {
CodesIcon,
MachinesIcon,
SessionsIcon,
UsersIcon,
} from "./icons";
interface NavItem {
to: string;
label: string;
Icon: ComponentType<SVGProps<SVGSVGElement>>;
/** Pass-1 stubs are disabled until their views land in later passes. */
enabled: boolean;
}
const NAV: NavItem[] = [
{ to: "/machines", label: "Machines", Icon: MachinesIcon, enabled: true },
{ to: "/sessions", label: "Sessions", Icon: SessionsIcon, enabled: false },
{ to: "/codes", label: "Codes", Icon: CodesIcon, enabled: false },
{ to: "/users", label: "Users", Icon: UsersIcon, enabled: false },
];
export function Sidebar() {
return (
<aside className="sidebar">
<div className="sidebar__brand">
<span className="sidebar__logo" aria-hidden="true">
GC
</span>
<span className="sidebar__name">
GuruConnect
<small>Operator Console</small>
</span>
</div>
<nav className="sidebar__nav" aria-label="Primary">
<span className="sidebar__section">Operations</span>
{NAV.map(({ to, label, Icon, enabled }) =>
enabled ? (
<NavLink
key={to}
to={to}
className={({ isActive }) =>
`navlink${isActive ? " navlink--active" : ""}`
}
>
<span className="navlink__icon">
<Icon />
</span>
{label}
</NavLink>
) : (
<span
key={to}
className="navlink navlink--disabled"
aria-disabled="true"
title={`${label} — coming in a later pass`}
>
<span className="navlink__icon">
<Icon />
</span>
{label}
<span className="navlink__soon">Soon</span>
</span>
),
)}
</nav>
</aside>
);
}

View File

@@ -0,0 +1,51 @@
import { useAuth } from "../../auth/AuthContext";
import { useRelayStatus } from "../../lib/useRelayStatus";
import { Badge } from "../ui/Badge";
import { Button } from "../ui/Button";
import { LogoutIcon } from "./icons";
function roleTone(role: string | undefined): "accent" | "ok" | "neutral" {
if (role === "admin") return "accent";
if (role === "operator") return "ok";
return "neutral";
}
export function Topbar() {
const { user, logout } = useAuth();
const { live, checking } = useRelayStatus();
const relayClass = live ? "relay relay--live" : "relay relay--down";
const relayLabel = checking ? "probing" : live ? "live" : "offline";
return (
<header className="topbar">
<div
className={relayClass}
title="GuruConnect relay connection"
aria-label={`Relay ${relayLabel}`}
>
<span className="relay__pip" aria-hidden="true" />
<span>Relay</span>
<span className="relay__label mono">{relayLabel}</span>
</div>
<div className="topbar__spacer" />
<div className="topbar__user">
<div className="topbar__id">
<span className="topbar__username">{user?.username}</span>
</div>
<Badge tone={roleTone(user?.role)}>{user?.role ?? "—"}</Badge>
<Button
variant="ghost"
size="sm"
onClick={() => void logout()}
aria-label="Log out"
>
<LogoutIcon width={15} height={15} />
Logout
</Button>
</div>
</header>
);
}

View File

@@ -0,0 +1,113 @@
// Inline stroke icons (no icon-library dependency). 18px on a 24 viewBox.
import type { SVGProps } from "react";
type IconProps = SVGProps<SVGSVGElement>;
function base(props: IconProps) {
return {
width: 18,
height: 18,
viewBox: "0 0 24 24",
fill: "none",
stroke: "currentColor",
strokeWidth: 1.8,
strokeLinecap: "round" as const,
strokeLinejoin: "round" as const,
...props,
};
}
export function MachinesIcon(props: IconProps) {
return (
<svg {...base(props)}>
<rect x="2" y="4" width="20" height="13" rx="2" />
<path d="M8 21h8M12 17v4" />
</svg>
);
}
export function SessionsIcon(props: IconProps) {
return (
<svg {...base(props)}>
<path d="M4 6h16M4 12h16M4 18h10" />
</svg>
);
}
export function CodesIcon(props: IconProps) {
return (
<svg {...base(props)}>
<path d="M8 6 3 12l5 6M16 6l5 6-5 6M13 4l-2 16" />
</svg>
);
}
export function UsersIcon(props: IconProps) {
return (
<svg {...base(props)}>
<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2" />
<circle cx="9" cy="7" r="4" />
<path d="M22 21v-2a4 4 0 0 0-3-3.87M16 3.13A4 4 0 0 1 16 11" />
</svg>
);
}
export function LogoutIcon(props: IconProps) {
return (
<svg {...base(props)}>
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4M16 17l5-5-5-5M21 12H9" />
</svg>
);
}
export function KeyIcon(props: IconProps) {
return (
<svg {...base(props)}>
<circle cx="7.5" cy="15.5" r="4.5" />
<path d="m10.7 12.3 8.3-8.3M16 6l3 3M14 8l2 2" />
</svg>
);
}
export function TrashIcon(props: IconProps) {
return (
<svg {...base(props)}>
<path d="M3 6h18M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6" />
</svg>
);
}
export function InfoIcon(props: IconProps) {
return (
<svg {...base(props)}>
<circle cx="12" cy="12" r="9" />
<path d="M12 16v-4M12 8h.01" />
</svg>
);
}
export function SearchIcon(props: IconProps) {
return (
<svg {...base(props)}>
<circle cx="11" cy="11" r="7" />
<path d="m21 21-4.3-4.3" />
</svg>
);
}
export function RefreshIcon(props: IconProps) {
return (
<svg {...base(props)}>
<path d="M21 12a9 9 0 1 1-3-6.7L21 8M21 3v5h-5" />
</svg>
);
}
export function CopyIcon(props: IconProps) {
return (
<svg {...base(props)}>
<rect x="9" y="9" width="12" height="12" rx="2" />
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" />
</svg>
);
}

View File

@@ -0,0 +1,215 @@
/* ============================================================= App shell === */
.shell {
display: grid;
grid-template-columns: var(--sidebar-w) 1fr;
grid-template-rows: var(--topbar-h) 1fr;
grid-template-areas:
"sidebar topbar"
"sidebar main";
height: 100vh;
overflow: hidden;
}
/* ================================================================ Sidebar === */
.sidebar {
grid-area: sidebar;
background: var(--panel-2);
border-right: 1px solid var(--border);
display: flex;
flex-direction: column;
min-height: 0;
}
.sidebar__brand {
display: flex;
align-items: center;
gap: 10px;
height: var(--topbar-h);
padding: 0 16px;
border-bottom: 1px solid var(--border);
flex: 0 0 auto;
}
.sidebar__logo {
width: 26px;
height: 26px;
border-radius: 6px;
background: linear-gradient(135deg, var(--accent), var(--accent-press));
display: grid;
place-items: center;
color: var(--accent-ink);
font-weight: 800;
font-size: 14px;
flex: 0 0 auto;
}
.sidebar__name {
font-weight: 700;
font-size: 15px;
letter-spacing: 0.01em;
}
.sidebar__name small {
display: block;
font-size: 10px;
font-weight: 600;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--text-faint);
}
.sidebar__nav {
display: flex;
flex-direction: column;
gap: 2px;
padding: 12px 10px;
overflow-y: auto;
}
.sidebar__section {
font-size: 10px;
font-weight: 700;
letter-spacing: 0.1em;
text-transform: uppercase;
color: var(--text-faint);
padding: 14px 10px 6px;
}
.navlink {
display: flex;
align-items: center;
gap: 11px;
height: 38px;
padding: 0 11px;
border-radius: var(--radius-sm);
color: var(--text-muted);
font-size: 14px;
font-weight: 500;
transition:
background var(--dur-fast) var(--ease),
color var(--dur-fast) var(--ease);
border: 1px solid transparent;
}
.navlink:hover {
background: var(--panel);
color: var(--text);
}
.navlink--active {
background: var(--accent-soft);
color: var(--accent);
border-color: var(--accent-ring);
}
.navlink--disabled {
color: var(--text-faint);
cursor: not-allowed;
pointer-events: none;
}
.navlink__icon {
flex: 0 0 auto;
width: 18px;
height: 18px;
display: grid;
place-items: center;
}
.navlink__soon {
margin-left: auto;
font-size: 9px;
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--text-faint);
border: 1px solid var(--border);
border-radius: 999px;
padding: 1px 6px;
}
/* ================================================================= Topbar === */
.topbar {
grid-area: topbar;
display: flex;
align-items: center;
gap: 16px;
padding: 0 20px;
background: var(--panel);
border-bottom: 1px solid var(--border);
}
.topbar__spacer {
flex: 1;
}
.relay {
display: inline-flex;
align-items: center;
gap: 8px;
font-size: 12px;
color: var(--text-muted);
padding: 5px 10px;
border: 1px solid var(--border);
border-radius: 999px;
background: var(--panel-2);
}
.relay__pip {
width: 7px;
height: 7px;
border-radius: 50%;
}
.relay--live .relay__pip {
background: var(--ok);
box-shadow: 0 0 8px var(--ok);
animation: gc-live 1.8s var(--ease) infinite;
}
.relay--down .relay__pip {
background: var(--bad);
}
.relay__label.mono {
font-size: 11px;
}
.topbar__user {
display: flex;
align-items: center;
gap: 10px;
}
.topbar__id {
display: flex;
flex-direction: column;
align-items: flex-end;
line-height: 1.2;
}
.topbar__username {
font-size: 13px;
font-weight: 600;
color: var(--text);
}
/* ================================================================== Main === */
.main {
grid-area: main;
overflow-y: auto;
min-height: 0;
}
.page {
padding: 22px 24px 40px;
max-width: 1320px;
margin: 0 auto;
}
.page__header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
margin-bottom: 18px;
}
.page__titles h1 {
font-size: 22px;
font-weight: 700;
margin: 0;
letter-spacing: -0.015em;
}
.page__subtitle {
color: var(--text-muted);
font-size: 13px;
margin-top: 3px;
}
.page__actions {
display: flex;
align-items: center;
gap: 10px;
}
.auth-gate {
display: grid;
place-items: center;
height: 100vh;
}

View File

@@ -0,0 +1,25 @@
import type { ReactNode } from "react";
import type { StatusTone } from "./status";
import { StatusDot } from "./StatusDot";
type BadgeTone = StatusTone | "accent";
interface BadgeProps {
tone?: BadgeTone;
/** Render a leading status dot inside the badge. */
dot?: boolean;
children: ReactNode;
}
/**
* A pill label using the status vocabulary. With `dot`, pairs the label with a
* matching StatusDot so the dot+label convention reads consistently.
*/
export function Badge({ tone = "neutral", dot = false, children }: BadgeProps) {
return (
<span className={`badge badge--${tone}`}>
{dot && tone !== "accent" && <StatusDot tone={tone} />}
{children}
</span>
);
}

View File

@@ -0,0 +1,53 @@
import type { ButtonHTMLAttributes, ReactNode } from "react";
type Variant = "primary" | "ghost" | "danger";
interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
variant?: Variant;
/** Compact 28px height for table-row actions and tight toolbars. */
size?: "sm" | "md";
/** Stretch to fill the container width (e.g. login submit). */
block?: boolean;
/** Show a spinner and disable while an async action is in flight. */
loading?: boolean;
children: ReactNode;
}
/**
* The one button. Variants map to the design language:
* - primary: accent-solid, the single high-signal action per surface
* - ghost: bordered, secondary
* - danger: destructive (delete machine, revoke key)
*/
export function Button({
variant = "ghost",
size = "md",
block = false,
loading = false,
disabled,
className,
children,
...rest
}: ButtonProps) {
const classes = [
"btn",
`btn--${variant}`,
size === "sm" && "btn--sm",
block && "btn--block",
className,
]
.filter(Boolean)
.join(" ");
return (
<button
className={classes}
disabled={disabled || loading}
aria-busy={loading || undefined}
{...rest}
>
{loading && <span className="btn__spin" aria-hidden="true" />}
{children}
</button>
);
}

View File

@@ -0,0 +1,55 @@
import type { ReactNode } from "react";
import { Button } from "./Button";
import { Modal } from "./Modal";
interface ConfirmDialogProps {
open: boolean;
title: string;
body: ReactNode;
confirmLabel?: string;
cancelLabel?: string;
/** Style the confirm button as destructive. */
danger?: boolean;
/** Disable controls + spin the confirm button while the action runs. */
busy?: boolean;
onConfirm: () => void;
onCancel: () => void;
}
/** Small yes/no confirmation built on Modal. */
export function ConfirmDialog({
open,
title,
body,
confirmLabel = "Confirm",
cancelLabel = "Cancel",
danger = false,
busy = false,
onConfirm,
onCancel,
}: ConfirmDialogProps) {
return (
<Modal
open={open}
title={title}
onClose={busy ? () => {} : onCancel}
dismissable={!busy}
footer={
<>
<Button variant="ghost" onClick={onCancel} disabled={busy}>
{cancelLabel}
</Button>
<Button
variant={danger ? "danger" : "primary"}
onClick={onConfirm}
loading={busy}
>
{confirmLabel}
</Button>
</>
}
>
{body}
</Modal>
);
}

View File

@@ -0,0 +1,150 @@
import { useEffect, useId, useRef } from "react";
import { createPortal } from "react-dom";
import type { ReactNode } from "react";
import {
hasOpenDialog,
isTopDialog,
popDialog,
pushDialog,
} from "./dialogStack";
interface DrawerProps {
open: boolean;
title: ReactNode;
/** Accessible name when `title` is not plain text. */
ariaLabel?: string;
/** Optional secondary line under the title (status, id). */
subtitle?: ReactNode;
onClose: () => void;
/** Sticky footer slot for actions. */
footer?: ReactNode;
children: ReactNode;
}
const FOCUSABLE =
'a[href],button:not([disabled]),textarea,input,select,[tabindex]:not([tabindex="-1"])';
/**
* Right-anchored side panel for read and inspect flows (machine detail and
* history) where a modal would over-interrupt. Shares the dialog a11y contract:
* Tab focus is trapped, the rest of the page is inert, Escape closes, and focus
* returns to the trigger on close.
*/
export function Drawer({
open,
title,
ariaLabel,
subtitle,
onClose,
footer,
children,
}: DrawerProps) {
const panelRef = useRef<HTMLDivElement>(null);
const lastFocused = useRef<HTMLElement | null>(null);
const titleId = useId();
useEffect(() => {
if (!open) return;
const panel = panelRef.current;
lastFocused.current = document.activeElement as HTMLElement | null;
const token = pushDialog();
const root = document.getElementById("root");
root?.setAttribute("inert", "");
const first = panel?.querySelector<HTMLElement>(FOCUSABLE);
(first ?? panel)?.focus();
function onKey(e: KeyboardEvent) {
if (!isTopDialog(token)) return;
if (e.key === "Escape") {
e.stopPropagation();
onClose();
return;
}
if (e.key !== "Tab" || !panel) return;
const items = Array.from(
panel.querySelectorAll<HTMLElement>(FOCUSABLE),
).filter((el) => el.offsetParent !== null || el === document.activeElement);
if (items.length === 0) {
e.preventDefault();
panel.focus();
return;
}
const firstEl = items[0];
const lastEl = items[items.length - 1];
const active = document.activeElement as HTMLElement;
if (e.shiftKey && (active === firstEl || active === panel)) {
e.preventDefault();
lastEl.focus();
} else if (!e.shiftKey && active === lastEl) {
e.preventDefault();
firstEl.focus();
}
}
document.addEventListener("keydown", onKey, true);
const prevOverflow = document.body.style.overflow;
document.body.style.overflow = "hidden";
return () => {
document.removeEventListener("keydown", onKey, true);
document.body.style.overflow = prevOverflow;
popDialog(token);
if (!hasOpenDialog()) root?.removeAttribute("inert");
lastFocused.current?.focus?.();
};
}, [open, onClose]);
if (!open) return null;
return createPortal(
<div
className="drawer__scrim"
onMouseDown={(e) => {
if (e.target === e.currentTarget) onClose();
}}
>
<aside
ref={panelRef}
className="drawer"
role="dialog"
aria-modal="true"
aria-label={ariaLabel}
aria-labelledby={ariaLabel ? undefined : titleId}
tabIndex={-1}
>
<header className="drawer__head">
<div className="drawer__titles">
<h2 className="drawer__title" id={titleId}>
{title}
</h2>
{subtitle && <div className="drawer__subtitle">{subtitle}</div>}
</div>
<button
type="button"
className="iconbtn"
onClick={onClose}
aria-label="Close panel"
>
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
aria-hidden="true"
>
<path d="M6 6l12 12M18 6 6 18" />
</svg>
</button>
</header>
<div className="drawer__body">{children}</div>
{footer && <footer className="drawer__footer">{footer}</footer>}
</aside>
</div>,
document.body,
);
}

View File

@@ -0,0 +1,36 @@
import { forwardRef } from "react";
import type { InputHTMLAttributes, ReactNode } from "react";
interface InputProps extends InputHTMLAttributes<HTMLInputElement> {
/** Render technical/data values in JetBrains Mono. */
mono?: boolean;
}
/** Bare styled text input. Compose with <Field> for a labeled control. */
export const Input = forwardRef<HTMLInputElement, InputProps>(function Input(
{ mono, className, ...rest },
ref,
) {
const classes = ["input", mono && "input--mono", className]
.filter(Boolean)
.join(" ");
return <input ref={ref} className={classes} {...rest} />;
});
interface FieldProps {
label: string;
htmlFor: string;
children: ReactNode;
}
/** Label + control wrapper for forms. */
export function Field({ label, htmlFor, children }: FieldProps) {
return (
<div className="field">
<label className="field__label" htmlFor={htmlFor}>
{label}
</label>
{children}
</div>
);
}

View File

@@ -0,0 +1,160 @@
import { useEffect, useId, useRef } from "react";
import { createPortal } from "react-dom";
import type { ReactNode } from "react";
import {
hasOpenDialog,
isTopDialog,
popDialog,
pushDialog,
} from "./dialogStack";
interface ModalProps {
open: boolean;
title: ReactNode;
/** Accessible name for the dialog when `title` is not plain text. */
ariaLabel?: string;
onClose: () => void;
/** Footer slot, typically the action buttons. */
footer?: ReactNode;
/** Wider layout for content-heavy dialogs (key management). */
wide?: boolean;
/** Disable overlay-click and Escape dismissal (e.g. during a pending action). */
dismissable?: boolean;
children: ReactNode;
}
const FOCUSABLE =
'a[href],button:not([disabled]),textarea,input,select,[tabindex]:not([tabindex="-1"])';
/**
* Accessible modal dialog. Closes on Escape and overlay click (unless
* `dismissable` is false), traps Tab focus inside, marks the rest of the page
* inert, and restores focus to the trigger on close.
*/
export function Modal({
open,
title,
ariaLabel,
onClose,
footer,
wide,
dismissable = true,
children,
}: ModalProps) {
const panelRef = useRef<HTMLDivElement>(null);
const lastFocused = useRef<HTMLElement | null>(null);
const titleId = useId();
useEffect(() => {
if (!open) return;
const panel = panelRef.current;
lastFocused.current = document.activeElement as HTMLElement | null;
const token = pushDialog();
// Mark everything outside the dialog inert so focus and clicks can't reach
// the page behind. Dialogs are portaled to <body>, so this targets #root.
const root = document.getElementById("root");
root?.setAttribute("inert", "");
// Move focus to the first focusable control, falling back to the panel.
const first = panel?.querySelector<HTMLElement>(FOCUSABLE);
(first ?? panel)?.focus();
function onKey(e: KeyboardEvent) {
// Only the topmost dialog reacts (don't close a stack all at once).
if (!isTopDialog(token)) return;
if (e.key === "Escape" && dismissable) {
e.stopPropagation();
onClose();
return;
}
if (e.key !== "Tab" || !panel) return;
// Cycle focus within the dialog.
const items = Array.from(
panel.querySelectorAll<HTMLElement>(FOCUSABLE),
).filter((el) => el.offsetParent !== null || el === document.activeElement);
if (items.length === 0) {
e.preventDefault();
panel.focus();
return;
}
const firstEl = items[0];
const lastEl = items[items.length - 1];
const active = document.activeElement as HTMLElement;
if (e.shiftKey && (active === firstEl || active === panel)) {
e.preventDefault();
lastEl.focus();
} else if (!e.shiftKey && active === lastEl) {
e.preventDefault();
firstEl.focus();
}
}
document.addEventListener("keydown", onKey, true);
// Lock background scroll while the dialog is open.
const prevOverflow = document.body.style.overflow;
document.body.style.overflow = "hidden";
return () => {
document.removeEventListener("keydown", onKey, true);
document.body.style.overflow = prevOverflow;
popDialog(token);
// Only lift inert once the last dialog has closed.
if (!hasOpenDialog()) root?.removeAttribute("inert");
lastFocused.current?.focus?.();
};
}, [open, dismissable, onClose]);
if (!open) return null;
const labelledBy = ariaLabel ? undefined : titleId;
return createPortal(
<div
className="modal__overlay"
onMouseDown={(e) => {
if (e.target === e.currentTarget && dismissable) onClose();
}}
>
<div
ref={panelRef}
className={`modal${wide ? " modal--wide" : ""}`}
role="dialog"
aria-modal="true"
aria-label={ariaLabel}
aria-labelledby={labelledBy}
tabIndex={-1}
>
<header className="modal__head">
<h2 className="modal__title" id={titleId}>
{title}
</h2>
{dismissable && (
<button
type="button"
className="iconbtn"
onClick={onClose}
aria-label="Close dialog"
>
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
aria-hidden="true"
>
<path d="M6 6l12 12M18 6 6 18" />
</svg>
</button>
)}
</header>
<div className="modal__body">{children}</div>
{footer && <footer className="modal__footer">{footer}</footer>}
</div>
</div>,
document.body,
);
}

View File

@@ -0,0 +1,27 @@
import type { ReactNode } from "react";
interface PanelProps {
/** Optional header title. When omitted, no header bar is rendered. */
title?: ReactNode;
/** Optional right-aligned header slot (actions, counts). */
actions?: ReactNode;
/** Remove default body padding (e.g. when embedding a flush table). */
flush?: boolean;
className?: string;
children: ReactNode;
}
/** A bordered surface card. The base building block for content panels. */
export function Panel({ title, actions, flush, className, children }: PanelProps) {
return (
<section className={["panel", className].filter(Boolean).join(" ")}>
{(title || actions) && (
<header className="panel__header">
{title ? <h2 className="panel__title">{title}</h2> : <span />}
{actions}
</header>
)}
<div className={flush ? undefined : "panel__body"}>{children}</div>
</section>
);
}

View File

@@ -0,0 +1,14 @@
interface SpinnerProps {
/** Optional caption rendered under the ring. */
label?: string;
}
/** Indeterminate loading ring with an optional label. */
export function Spinner({ label }: SpinnerProps) {
return (
<div className="spinner" role="status" aria-live="polite">
<span className="spinner__ring" aria-hidden="true" />
{label && <span>{label}</span>}
</div>
);
}

View File

@@ -0,0 +1,30 @@
import type { ReactNode } from "react";
interface StateProps {
title: string;
message?: ReactNode;
/** Optional action (e.g. a retry button). */
action?: ReactNode;
}
/** Neutral "nothing here" placeholder. */
export function EmptyState({ title, message, action }: StateProps) {
return (
<div className="state">
<div className="state__title">{title}</div>
{message && <div className="state__msg">{message}</div>}
{action}
</div>
);
}
/** Error placeholder — surfaces a failure instead of silently empty. */
export function ErrorState({ title, message, action }: StateProps) {
return (
<div className="state state--error" role="alert">
<div className="state__title">{title}</div>
{message && <div className="state__msg">{message}</div>}
{action}
</div>
);
}

View File

@@ -0,0 +1,24 @@
import type { StatusTone } from "./status";
interface StatusDotProps {
tone: StatusTone;
/** Accessible label describing what the dot represents. */
label?: string;
}
/**
* A small colored status dot. `warn` pulses (consent-pending language). When a
* `label` is given it is an accessible image; without one (e.g. paired with a
* visible label inside a Badge) it is decorative and hidden from assistive tech.
*/
export function StatusDot({ tone, label }: StatusDotProps) {
return (
<span
className={`statusdot statusdot--${tone}`}
role={label ? "img" : undefined}
aria-label={label}
aria-hidden={label ? undefined : true}
title={label}
/>
);
}

View File

@@ -0,0 +1,95 @@
import type { ReactNode } from "react";
import "./table.css";
export interface Column<T> {
/** Unique column key. */
key: string;
/** Header label. Omit for the status / actions rails. */
header?: ReactNode;
/** Cell renderer. */
render: (row: T) => ReactNode;
/** Extra class on the <td> (e.g. dt__status, dt__actions). */
cellClass?: string;
}
interface TableProps<T> {
columns: Column<T>[];
rows: T[];
rowKey: (row: T) => string;
/** Optional per-row activation (opens detail). Bound to click, Enter, Space. */
onRowClick?: (row: T) => void;
/** Accessible label for the row's primary activation, e.g. the hostname. */
rowLabel?: (row: T) => string;
/** Cap the staggered fade-in so large lists don't crawl in. */
maxStaggerRows?: number;
}
/**
* Dense, console-style data table. Sticky header, hover highlight, hover-
* revealed row actions, and a staggered fade-in on mount (capped so big lists
* appear promptly). Column-driven so callers compose cells declaratively.
*/
export function Table<T>({
columns,
rows,
rowKey,
onRowClick,
rowLabel,
maxStaggerRows = 14,
}: TableProps<T>) {
return (
<div className="dt-wrap">
<table className="dt">
<thead>
<tr>
{columns.map((c) => (
<th key={c.key} className={c.cellClass}>
{c.header}
</th>
))}
</tr>
</thead>
<tbody>
{rows.map((row, i) => {
const delay = i < maxStaggerRows ? `${i * 22}ms` : "0ms";
return (
<tr
key={rowKey(row)}
style={{
animationDelay: delay,
cursor: onRowClick ? "pointer" : undefined,
}}
onClick={onRowClick ? () => onRowClick(row) : undefined}
tabIndex={onRowClick ? 0 : undefined}
aria-label={
onRowClick && rowLabel
? `Open detail for ${rowLabel(row)}`
: undefined
}
onKeyDown={
onRowClick
? (e) => {
// Activate on Enter or Space, the standard for a
// button-like row. Space must not scroll the page.
if (e.key === "Enter" || e.key === " ") {
if (e.target !== e.currentTarget) return;
e.preventDefault();
onRowClick(row);
}
}
: undefined
}
>
{columns.map((c) => (
<td key={c.key} className={c.cellClass}>
{c.render(row)}
</td>
))}
</tr>
);
})}
</tbody>
</table>
</div>
);
}

View File

@@ -0,0 +1,54 @@
interface TableSkeletonProps {
/** Header labels, rendered in the sticky head so columns line up. */
headers: string[];
/** Number of placeholder rows. */
rows?: number;
/** Per-column placeholder bar widths (CSS lengths). Falls back to a default. */
widths?: string[];
}
/**
* Skeleton table that mirrors the real table's layout while data loads. Shows
* the column structure so the page does not jump when rows arrive, and reads as
* progress without a blocking spinner.
*/
export function TableSkeleton({
headers,
rows = 8,
widths = [],
}: TableSkeletonProps) {
const colWidths =
widths.length === headers.length
? widths
: headers.map((_, i) => (i === 0 ? "8px" : `${60 + ((i * 23) % 40)}%`));
return (
<div className="dt-wrap" aria-hidden="true">
<table className="dt">
<thead>
<tr>
{headers.map((h, i) => (
<th key={i} className={i === 0 ? "dt__status" : undefined}>
{h}
</th>
))}
</tr>
</thead>
<tbody>
{Array.from({ length: rows }).map((_, r) => (
<tr key={r}>
{headers.map((_, c) => (
<td key={c} className={c === 0 ? "dt__status" : undefined}>
<span
className={`dt__skel${c === 0 ? " dt__skel--dot" : ""}`}
style={c === 0 ? undefined : { width: colWidths[c] }}
/>
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
);
}

View File

@@ -0,0 +1,28 @@
// A tiny module-level stack so only the topmost open dialog (Modal or Drawer)
// reacts to Escape and owns the background `inert` toggle. This keeps stacked
// dialogs (e.g. a confirm on top of a management modal) from all closing at once.
const stack: symbol[] = [];
/** Push a dialog onto the stack. Returns its token. */
export function pushDialog(): symbol {
const token = Symbol("dialog");
stack.push(token);
return token;
}
/** Remove a dialog from the stack by token. */
export function popDialog(token: symbol): void {
const i = stack.lastIndexOf(token);
if (i !== -1) stack.splice(i, 1);
}
/** True when `token` is the topmost open dialog. */
export function isTopDialog(token: symbol): boolean {
return stack.length > 0 && stack[stack.length - 1] === token;
}
/** True when any dialog is open. */
export function hasOpenDialog(): boolean {
return stack.length > 0;
}

View File

@@ -0,0 +1,28 @@
// Central status-language mapping. Every status indicator in the app resolves
// through here so the dot color + label vocabulary stays consistent:
// ok = online / granted / success -> --ok (green)
// warn = pending (gets the consent pulse) -> --warn (amber)
// bad = denied / offline / error -> --bad (red)
// neutral = not_required / unknown -> --neutral (slate)
export type StatusTone = "ok" | "warn" | "bad" | "neutral";
/** Map a machine `status` string to a tone. */
export function machineTone(status: string): StatusTone {
return status === "online" ? "ok" : "bad";
}
/** Map an attended-consent state to a tone. `pending` pulses. */
export function consentTone(state: string): StatusTone {
switch (state) {
case "granted":
return "ok";
case "pending":
return "warn";
case "denied":
return "bad";
case "not_required":
default:
return "neutral";
}
}

View File

@@ -0,0 +1,153 @@
/* ============================================================ Data table === */
.dt {
width: 100%;
border-collapse: collapse;
font-size: 13px;
}
.dt thead th {
position: sticky;
top: 0;
z-index: 2;
background: var(--panel-2);
text-align: left;
font-size: 11px;
font-weight: 700;
letter-spacing: 0.05em;
text-transform: uppercase;
color: var(--text-faint);
padding: 0 14px;
height: 36px;
border-bottom: 1px solid var(--border);
white-space: nowrap;
}
.dt tbody td {
padding: 0 14px;
height: var(--row-h);
border-bottom: 1px solid var(--border);
color: var(--text);
vertical-align: middle;
white-space: nowrap;
}
.dt tbody tr {
transition: background var(--dur-fast) var(--ease);
animation: gc-row-in var(--dur) var(--ease) both;
}
.dt tbody tr:hover {
background: var(--panel-2);
}
.dt tbody tr:hover .dt__rowactions,
.dt tbody tr:focus-within .dt__rowactions {
opacity: 1;
}
/* Keyboard focus on the row itself reads as a clear inset ring. */
.dt tbody tr:focus-visible {
outline: none;
background: var(--panel-2);
box-shadow: inset 0 0 0 1px var(--accent-ring);
}
/* Status-dot column — fixed narrow left rail. */
.dt__status {
width: 30px;
padding-left: 16px !important;
padding-right: 0 !important;
}
/* Cell affordances. */
.dt__mono {
font-family: var(--font-mono);
font-size: 12px;
color: var(--text-muted);
}
.dt__strong {
font-weight: 600;
color: var(--text);
}
.dt__muted {
color: var(--text-muted);
}
/* Right-aligned row actions. Dimmed at rest, full on row hover/focus, but
always present and reachable by keyboard and touch (never pointer-events:none,
which would hide them from Tab and tap). */
.dt__actions {
width: 1%;
text-align: right;
}
.dt__rowactions {
display: inline-flex;
gap: 6px;
justify-content: flex-end;
opacity: 0.5;
transition: opacity var(--dur-fast) var(--ease);
}
/* When any action button is keyboard-focused, surface the whole group. */
.dt__rowactions:focus-within {
opacity: 1;
}
@media (hover: none) {
/* Touch devices have no hover: keep actions fully legible at all times. */
.dt__rowactions {
opacity: 1;
}
}
.dt-wrap {
max-height: calc(100vh - 230px);
overflow: auto;
}
/* Skeleton loading rows: preview the table shape instead of a bare spinner. */
.dt__skel {
display: inline-block;
height: 10px;
border-radius: 999px;
background:
linear-gradient(
90deg,
var(--border) 0%,
var(--border-strong) 50%,
var(--border) 100%
);
background-size: 200% 100%;
animation: gc-shimmer 1.4s var(--ease) infinite;
vertical-align: middle;
}
.dt__skel--dot {
width: 8px;
height: 8px;
border-radius: 50%;
}
/* Search / toolbar above the table. */
.toolbar {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 12px;
}
.searchbox {
position: relative;
flex: 0 0 320px;
max-width: 100%;
}
.searchbox__icon {
position: absolute;
left: 11px;
top: 50%;
transform: translateY(-50%);
color: var(--text-faint);
pointer-events: none;
}
.searchbox .input {
width: 100%;
padding-left: 34px;
}
.toolbar__count {
margin-left: auto;
font-size: 12px;
color: var(--text-muted);
}
.toolbar__count .mono {
color: var(--text);
}

View File

@@ -0,0 +1,25 @@
import { createContext, useContext } from "react";
export type ToastTone = "success" | "error" | "info";
export interface ToastItem {
id: number;
tone: ToastTone;
title: string;
message?: string;
}
export interface ToastApi {
success: (title: string, message?: string) => void;
error: (title: string, message?: string) => void;
info: (title: string, message?: string) => void;
}
export const ToastContext = createContext<ToastApi | null>(null);
/** Imperative toast notifications. Auto-dismiss after a few seconds. */
export function useToast(): ToastApi {
const ctx = useContext(ToastContext);
if (!ctx) throw new Error("useToast must be used within <ToastProvider>");
return ctx;
}

View File

@@ -0,0 +1,116 @@
import { useCallback, useMemo, useRef, useState } from "react";
import type { ReactNode } from "react";
import {
ToastContext,
type ToastApi,
type ToastItem,
type ToastTone,
} from "./toast-context";
const AUTO_DISMISS_MS = 4500;
/** Mounts the toast stack and provides the imperative toast API to descendants. */
export function ToastProvider({ children }: { children: ReactNode }) {
const [toasts, setToasts] = useState<ToastItem[]>([]);
const nextId = useRef(1);
const dismiss = useCallback((id: number) => {
setToasts((prev) => prev.filter((t) => t.id !== id));
}, []);
const push = useCallback(
(tone: ToastTone, title: string, message?: string) => {
const id = nextId.current++;
setToasts((prev) => [...prev, { id, tone, title, message }]);
window.setTimeout(() => dismiss(id), AUTO_DISMISS_MS);
},
[dismiss],
);
const api = useMemo<ToastApi>(
() => ({
success: (title, message) => push("success", title, message),
error: (title, message) => push("error", title, message),
info: (title, message) => push("info", title, message),
}),
[push],
);
return (
<ToastContext.Provider value={api}>
{children}
{/* Polite region for success/info; errors below are assertive. */}
<div className="toast-stack">
{toasts.map((t) => (
<div
key={t.id}
className={`toast toast--${t.tone}`}
role={t.tone === "error" ? "alert" : "status"}
aria-live={t.tone === "error" ? "assertive" : "polite"}
>
<span className={`toast__icon toast__icon--${t.tone}`} aria-hidden="true">
<ToastGlyph tone={t.tone} />
</span>
<div className="toast__body">
<div className="toast__title">{t.title}</div>
{t.message && <div className="toast__msg">{t.message}</div>}
</div>
<button
type="button"
className="iconbtn"
onClick={() => dismiss(t.id)}
aria-label="Dismiss notification"
>
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
aria-hidden="true"
>
<path d="M6 6l12 12M18 6 6 18" />
</svg>
</button>
</div>
))}
</div>
</ToastContext.Provider>
);
}
function ToastGlyph({ tone }: { tone: ToastTone }) {
const common = {
width: 16,
height: 16,
viewBox: "0 0 24 24",
fill: "none",
stroke: "currentColor",
strokeWidth: 2,
strokeLinecap: "round" as const,
strokeLinejoin: "round" as const,
};
if (tone === "success") {
return (
<svg {...common}>
<path d="M20 6 9 17l-5-5" />
</svg>
);
}
if (tone === "error") {
return (
<svg {...common}>
<circle cx="12" cy="12" r="9" />
<path d="M12 8v5M12 16h.01" />
</svg>
);
}
return (
<svg {...common}>
<circle cx="12" cy="12" r="9" />
<path d="M12 11v5M12 8h.01" />
</svg>
);
}

View File

@@ -0,0 +1,454 @@
/* ------------------------------------------------------------------ Button */
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 7px;
height: 34px;
padding: 0 14px;
border-radius: var(--radius-sm);
border: 1px solid transparent;
font-size: 13px;
font-weight: 600;
letter-spacing: 0.01em;
cursor: pointer;
white-space: nowrap;
transition:
background var(--dur-fast) var(--ease),
border-color var(--dur-fast) var(--ease),
color var(--dur-fast) var(--ease),
opacity var(--dur-fast) var(--ease);
user-select: none;
}
.btn:focus-visible {
outline: none;
box-shadow: 0 0 0 2px var(--bg), 0 0 0 4px var(--accent-ring);
}
.btn:disabled {
opacity: 0.45;
cursor: not-allowed;
}
.btn--sm {
height: 28px;
padding: 0 10px;
font-size: 12px;
}
.btn--primary {
background: var(--accent);
color: var(--accent-ink);
}
.btn--primary:hover:not(:disabled) {
background: var(--accent-press);
}
.btn--ghost {
background: transparent;
border-color: var(--border-strong);
color: var(--text);
}
.btn--ghost:hover:not(:disabled) {
background: var(--panel);
border-color: var(--accent);
color: var(--accent);
}
.btn--danger {
background: transparent;
border-color: var(--bad-line);
color: var(--bad);
}
.btn--danger:hover:not(:disabled) {
background: var(--bad-soft);
border-color: var(--bad);
}
.btn--block {
width: 100%;
}
.btn__spin {
width: 13px;
height: 13px;
border-radius: 50%;
border: 2px solid currentColor;
border-top-color: transparent;
opacity: 0.85;
animation: gc-spin 0.7s linear infinite;
}
/* --------------------------------------------------------- Status dot/badge */
.statusdot {
display: inline-block;
width: 8px;
height: 8px;
border-radius: 50%;
flex: 0 0 auto;
}
.statusdot--ok {
background: var(--ok);
box-shadow: 0 0 6px var(--ok-soft);
}
.statusdot--warn {
background: var(--warn);
animation: gc-pulse 1.6s var(--ease) infinite;
}
.statusdot--bad {
background: var(--bad);
}
.statusdot--neutral {
background: var(--neutral);
}
.badge {
display: inline-flex;
align-items: center;
gap: 6px;
height: 22px;
padding: 0 8px;
border-radius: 999px;
font-size: 11px;
font-weight: 600;
letter-spacing: 0.02em;
text-transform: uppercase;
border: 1px solid var(--border);
color: var(--text-muted);
background: var(--panel-2);
}
.badge--ok {
color: var(--ok);
background: var(--ok-soft);
border-color: transparent;
}
.badge--warn {
color: var(--warn);
background: var(--warn-soft);
border-color: transparent;
}
.badge--bad {
color: var(--bad);
background: var(--bad-soft);
border-color: transparent;
}
.badge--neutral {
color: var(--text-muted);
background: var(--neutral-soft);
border-color: transparent;
}
.badge--accent {
color: var(--accent);
background: var(--accent-soft);
border-color: transparent;
}
/* ------------------------------------------------------------- Card / Panel */
.panel {
background: var(--panel);
border: 1px solid var(--border);
border-radius: var(--radius);
box-shadow: var(--shadow-1);
}
.panel__header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 14px 16px;
border-bottom: 1px solid var(--border);
}
.panel__title {
font-size: 15px;
font-weight: 600;
letter-spacing: -0.005em;
color: var(--text);
margin: 0;
}
.panel__body {
padding: 16px;
}
/* ------------------------------------------------------------------ Spinner */
.spinner {
display: inline-flex;
flex-direction: column;
align-items: center;
gap: 10px;
color: var(--text-muted);
font-size: 13px;
}
.spinner__ring {
width: 22px;
height: 22px;
border-radius: 50%;
border: 2px solid var(--border-strong);
border-top-color: var(--accent);
animation: gc-spin 0.8s linear infinite;
}
@keyframes gc-spin {
to {
transform: rotate(360deg);
}
}
/* ----------------------------------------------------- Empty / Error states */
.state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 8px;
padding: 48px 24px;
text-align: center;
color: var(--text-muted);
}
.state__title {
font-size: 15px;
font-weight: 600;
color: var(--text);
}
.state__msg {
font-size: 13px;
max-width: 380px;
}
.state--error .state__title {
color: var(--bad);
}
/* --------------------------------------------------------------------- Modal */
/* Shared icon button (modal close, toast dismiss). 28px square hit target. */
.iconbtn {
display: inline-grid;
place-items: center;
width: 28px;
height: 28px;
flex: 0 0 auto;
background: transparent;
border: 1px solid transparent;
border-radius: var(--radius-sm);
color: var(--text-muted);
cursor: pointer;
transition:
color var(--dur-fast) var(--ease),
background var(--dur-fast) var(--ease);
}
.iconbtn:hover {
color: var(--text);
background: var(--panel-2);
}
.iconbtn:focus-visible {
outline: none;
color: var(--text);
box-shadow: 0 0 0 2px var(--bg), 0 0 0 4px var(--accent-ring);
}
.modal__overlay {
position: fixed;
inset: 0;
background: oklch(15% 0.01 var(--brand-hue) / 0.66);
display: flex;
align-items: center;
justify-content: center;
padding: 24px;
z-index: var(--z-modal);
animation: gc-fade 120ms var(--ease);
}
@keyframes gc-fade {
from {
opacity: 0;
}
}
.modal {
width: 100%;
max-width: 460px;
background: var(--panel);
border: 1px solid var(--border-strong);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-pop);
animation: gc-pop 140ms var(--ease);
}
@keyframes gc-pop {
from {
opacity: 0;
transform: translateY(8px) scale(0.98);
}
}
.modal--wide {
max-width: 720px;
}
.modal__head {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 18px;
border-bottom: 1px solid var(--border);
}
.modal__title {
font-size: 16px;
font-weight: 600;
letter-spacing: -0.01em;
margin: 0;
}
.modal__body {
padding: 18px;
}
.modal__footer {
display: flex;
justify-content: flex-end;
gap: 10px;
padding: 14px 18px;
border-top: 1px solid var(--border);
}
/* --------------------------------------------------------------------- Drawer */
.drawer__scrim {
position: fixed;
inset: 0;
background: oklch(15% 0.01 var(--brand-hue) / 0.5);
display: flex;
justify-content: flex-end;
z-index: var(--z-drawer);
animation: gc-fade 120ms var(--ease);
}
.drawer {
width: min(520px, 100%);
height: 100%;
display: flex;
flex-direction: column;
background: var(--panel);
border-left: 1px solid var(--border-strong);
box-shadow: var(--shadow-pop);
animation: gc-drawer-in var(--dur-panel) var(--ease-out);
}
.drawer__head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
padding: 16px 18px;
border-bottom: 1px solid var(--border);
flex: 0 0 auto;
}
.drawer__titles {
min-width: 0;
}
.drawer__title {
font-size: 16px;
font-weight: 600;
letter-spacing: -0.01em;
margin: 0;
display: flex;
align-items: center;
gap: 10px;
}
.drawer__subtitle {
margin-top: 4px;
font-size: 12px;
color: var(--text-muted);
}
.drawer__body {
padding: 18px;
overflow-y: auto;
flex: 1 1 auto;
min-height: 0;
}
.drawer__footer {
display: flex;
justify-content: flex-end;
gap: 10px;
padding: 14px 18px;
border-top: 1px solid var(--border);
flex: 0 0 auto;
}
/* --------------------------------------------------------------------- Toast */
.toast-stack {
position: fixed;
bottom: 20px;
right: 20px;
display: flex;
flex-direction: column;
gap: 10px;
z-index: var(--z-toast);
max-width: 360px;
}
.toast {
display: flex;
align-items: flex-start;
gap: 10px;
padding: 12px 14px;
border-radius: var(--radius);
background: var(--panel);
border: 1px solid var(--border-strong);
box-shadow: var(--shadow-2);
font-size: 13px;
animation: gc-toast-in 180ms var(--ease);
}
@keyframes gc-toast-in {
from {
opacity: 0;
transform: translateX(12px);
}
}
.toast__icon {
display: grid;
place-items: center;
width: 26px;
height: 26px;
flex: 0 0 auto;
border-radius: 50%;
}
.toast__icon--success {
color: var(--ok);
background: var(--ok-soft);
}
.toast__icon--error {
color: var(--bad);
background: var(--bad-soft);
}
.toast__icon--info {
color: var(--accent);
background: var(--accent-soft);
}
.toast__body {
flex: 1;
color: var(--text);
}
.toast__title {
font-weight: 600;
margin-bottom: 2px;
}
.toast__msg {
color: var(--text-muted);
word-break: break-word;
}
/* --------------------------------------------------------------------- Input */
.field {
display: flex;
flex-direction: column;
gap: 6px;
}
.field__label {
font-size: 12px;
font-weight: 600;
color: var(--text-muted);
letter-spacing: 0.02em;
}
.input {
height: 36px;
padding: 0 12px;
background: var(--panel-2);
border: 1px solid var(--border-strong);
border-radius: var(--radius-sm);
color: var(--text);
font-size: 14px;
font-family: inherit;
transition:
border-color var(--dur-fast) var(--ease),
box-shadow var(--dur-fast) var(--ease);
}
.input::placeholder {
color: var(--text-faint);
}
.input:focus {
outline: none;
border-color: var(--accent);
box-shadow: 0 0 0 3px var(--accent-soft);
}
.input--mono {
font-family: var(--font-mono);
}