feat(dashboard): GuruConnect v2 operator console (pass 1)
All checks were successful
All checks were successful
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:
25
dashboard/src/components/ui/Badge.tsx
Normal file
25
dashboard/src/components/ui/Badge.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
53
dashboard/src/components/ui/Button.tsx
Normal file
53
dashboard/src/components/ui/Button.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
55
dashboard/src/components/ui/ConfirmDialog.tsx
Normal file
55
dashboard/src/components/ui/ConfirmDialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
150
dashboard/src/components/ui/Drawer.tsx
Normal file
150
dashboard/src/components/ui/Drawer.tsx
Normal 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,
|
||||
);
|
||||
}
|
||||
36
dashboard/src/components/ui/Input.tsx
Normal file
36
dashboard/src/components/ui/Input.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
160
dashboard/src/components/ui/Modal.tsx
Normal file
160
dashboard/src/components/ui/Modal.tsx
Normal 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,
|
||||
);
|
||||
}
|
||||
27
dashboard/src/components/ui/Panel.tsx
Normal file
27
dashboard/src/components/ui/Panel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
14
dashboard/src/components/ui/Spinner.tsx
Normal file
14
dashboard/src/components/ui/Spinner.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
30
dashboard/src/components/ui/States.tsx
Normal file
30
dashboard/src/components/ui/States.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
24
dashboard/src/components/ui/StatusDot.tsx
Normal file
24
dashboard/src/components/ui/StatusDot.tsx
Normal 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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
95
dashboard/src/components/ui/Table.tsx
Normal file
95
dashboard/src/components/ui/Table.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
54
dashboard/src/components/ui/TableSkeleton.tsx
Normal file
54
dashboard/src/components/ui/TableSkeleton.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
28
dashboard/src/components/ui/dialogStack.ts
Normal file
28
dashboard/src/components/ui/dialogStack.ts
Normal 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;
|
||||
}
|
||||
28
dashboard/src/components/ui/status.ts
Normal file
28
dashboard/src/components/ui/status.ts
Normal 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";
|
||||
}
|
||||
}
|
||||
153
dashboard/src/components/ui/table.css
Normal file
153
dashboard/src/components/ui/table.css
Normal 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);
|
||||
}
|
||||
25
dashboard/src/components/ui/toast-context.ts
Normal file
25
dashboard/src/components/ui/toast-context.ts
Normal 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;
|
||||
}
|
||||
116
dashboard/src/components/ui/toast.tsx
Normal file
116
dashboard/src/components/ui/toast.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
454
dashboard/src/components/ui/ui.css
Normal file
454
dashboard/src/components/ui/ui.css
Normal 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);
|
||||
}
|
||||
Reference in New Issue
Block a user