feat(ui,web): Phase 2 — Shared Components & Terminal Panel (#449) #452

Merged
jason.woltje merged 3 commits from feat/ms15-shared-components into main 2026-02-22 21:12:14 +00:00
9 changed files with 724 additions and 0 deletions
Showing only changes of commit 9b0445cd5b - Show all commits

View File

@@ -0,0 +1,257 @@
import type { ReactElement, CSSProperties } from "react";
export interface TerminalLine {
type: "prompt" | "command" | "output" | "error" | "warning" | "success";
content: string;
}
export interface TerminalTab {
id: string;
label: string;
}
export interface TerminalPanelProps {
open: boolean;
onClose: () => void;
tabs?: TerminalTab[];
activeTab?: string;
onTabChange?: (id: string) => void;
lines?: TerminalLine[];
className?: string;
}
const defaultTabs: TerminalTab[] = [
{ id: "main", label: "main" },
{ id: "build", label: "build" },
{ id: "logs", label: "logs" },
];
const blinkKeyframes = `
@keyframes ms-terminal-blink {
0%, 100% { opacity: 1; }
50% { opacity: 0; }
}
`;
let blinkStyleInjected = false;
function ensureBlinkStyle(): void {
if (blinkStyleInjected || typeof document === "undefined") return;
const styleEl = document.createElement("style");
styleEl.textContent = blinkKeyframes;
document.head.appendChild(styleEl);
blinkStyleInjected = true;
}
function getLineColor(type: TerminalLine["type"]): string {
switch (type) {
case "prompt":
return "var(--success)";
case "command":
return "var(--text-2)";
case "output":
return "var(--muted)";
case "error":
return "var(--danger)";
case "warning":
return "var(--warn)";
case "success":
return "var(--success)";
default:
return "var(--muted)";
}
}
export function TerminalPanel({
open,
onClose,
tabs,
activeTab,
onTabChange,
lines = [],
className = "",
}: TerminalPanelProps): ReactElement {
ensureBlinkStyle();
const resolvedTabs = tabs ?? defaultTabs;
const resolvedActiveTab = activeTab ?? resolvedTabs[0]?.id ?? "";
const panelStyle: CSSProperties = {
background: "var(--bg-deep)",
borderTop: "1px solid var(--border)",
overflow: "hidden",
flexShrink: 0,
display: "flex",
flexDirection: "column",
height: open ? 280 : 0,
transition: "height 0.3s ease",
};
const headerStyle: CSSProperties = {
display: "flex",
alignItems: "center",
gap: 10,
padding: "6px 16px",
borderBottom: "1px solid var(--border)",
flexShrink: 0,
};
const tabBarStyle: CSSProperties = {
display: "flex",
gap: 2,
};
const actionsStyle: CSSProperties = {
marginLeft: "auto",
display: "flex",
gap: 4,
};
const bodyStyle: CSSProperties = {
flex: 1,
overflowY: "auto",
padding: "10px 16px",
fontFamily: "var(--mono)",
fontSize: "0.78rem",
lineHeight: 1.6,
};
const cursorStyle: CSSProperties = {
display: "inline-block",
width: 7,
height: 14,
background: "var(--success)",
marginLeft: 2,
animation: "ms-terminal-blink 1s step-end infinite",
verticalAlign: "text-bottom",
};
return (
<div
className={className}
style={panelStyle}
role="region"
aria-label="Terminal panel"
aria-hidden={!open}
>
{/* Header */}
<div style={headerStyle}>
{/* Tab bar */}
<div style={tabBarStyle} role="tablist" aria-label="Terminal tabs">
{resolvedTabs.map((tab) => {
const isActive = tab.id === resolvedActiveTab;
const tabStyle: CSSProperties = {
padding: "3px 10px",
borderRadius: 4,
fontSize: "0.75rem",
fontFamily: "var(--mono)",
color: isActive ? "var(--success)" : "var(--muted)",
cursor: "pointer",
background: isActive ? "var(--surface)" : "transparent",
border: "none",
outline: "none",
};
return (
<button
key={tab.id}
role="tab"
aria-selected={isActive}
style={tabStyle}
onClick={(): void => {
onTabChange?.(tab.id);
}}
onMouseEnter={(e): void => {
if (!isActive) {
(e.currentTarget as HTMLButtonElement).style.background = "var(--surface)";
(e.currentTarget as HTMLButtonElement).style.color = "var(--text-2)";
}
}}
onMouseLeave={(e): void => {
if (!isActive) {
(e.currentTarget as HTMLButtonElement).style.background = "transparent";
(e.currentTarget as HTMLButtonElement).style.color = "var(--muted)";
}
}}
>
{tab.label}
</button>
);
})}
</div>
{/* Action buttons */}
<div style={actionsStyle}>
<button
aria-label="Close terminal"
style={{
width: 22,
height: 22,
borderRadius: 4,
display: "flex",
alignItems: "center",
justifyContent: "center",
color: "var(--muted)",
cursor: "pointer",
background: "transparent",
border: "none",
outline: "none",
padding: 0,
}}
onClick={onClose}
onMouseEnter={(e): void => {
(e.currentTarget as HTMLButtonElement).style.background = "var(--surface)";
(e.currentTarget as HTMLButtonElement).style.color = "var(--text)";
}}
onMouseLeave={(e): void => {
(e.currentTarget as HTMLButtonElement).style.background = "transparent";
(e.currentTarget as HTMLButtonElement).style.color = "var(--muted)";
}}
>
{/* Close icon — simple X using SVG */}
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" aria-hidden="true">
<path
d="M1 1L11 11M11 1L1 11"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
/>
</svg>
</button>
</div>
</div>
{/* Body */}
<div style={bodyStyle} role="log" aria-live="polite" aria-label="Terminal output">
{lines.map((line, index) => {
const isLast = index === lines.length - 1;
const lineStyle: CSSProperties = {
display: "flex",
gap: 8,
};
const contentStyle: CSSProperties = {
color: getLineColor(line.type),
};
return (
<div key={index} style={lineStyle}>
<span style={contentStyle}>
{line.content}
{isLast && <span aria-hidden="true" style={cursorStyle} />}
</span>
</div>
);
})}
{/* Show cursor even when no lines */}
{lines.length === 0 && (
<div style={{ display: "flex", gap: 8 }}>
<span style={{ color: "var(--success)" }}>
<span aria-hidden="true" style={cursorStyle} />
</span>
</div>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,2 @@
export type { TerminalLine, TerminalTab, TerminalPanelProps } from "./TerminalPanel";
export { TerminalPanel } from "./TerminalPanel";

View File

@@ -0,0 +1,102 @@
import { useState, type ReactElement, type ReactNode } from "react";
export interface DataTableColumn<T> {
key: string;
header: string;
render?: (row: T) => ReactNode;
className?: string;
}
export interface DataTableProps<T extends Record<string, unknown>> {
columns: DataTableColumn<T>[];
data: T[];
className?: string;
onRowClick?: (row: T) => void;
}
export function DataTable<T extends Record<string, unknown>>({
columns,
data,
className = "",
onRowClick,
}: DataTableProps<T>): ReactElement {
const [hoveredRow, setHoveredRow] = useState<number | null>(null);
return (
<table
className={className}
style={{
width: "100%",
borderCollapse: "collapse",
fontSize: "0.83rem",
}}
>
<thead>
<tr>
{columns.map((col) => (
<th
key={col.key}
className={col.className}
style={{
textAlign: "left",
padding: "8px 12px",
fontSize: "0.7rem",
fontWeight: 600,
textTransform: "uppercase",
letterSpacing: "0.07em",
color: "var(--muted)",
borderBottom: "1px solid var(--border)",
background: "var(--surface)",
}}
>
{col.header}
</th>
))}
</tr>
</thead>
<tbody>
{data.map((row, rowIndex) => {
const isLast = rowIndex === data.length - 1;
const isHovered = hoveredRow === rowIndex;
return (
<tr
key={rowIndex}
style={{
background: isHovered ? "var(--surface)" : undefined,
cursor: onRowClick !== undefined ? "pointer" : undefined,
}}
onClick={
onRowClick !== undefined
? (): void => {
onRowClick(row);
}
: undefined
}
onMouseEnter={(): void => {
setHoveredRow(rowIndex);
}}
onMouseLeave={(): void => {
setHoveredRow(null);
}}
>
{columns.map((col) => (
<td
key={col.key}
className={col.className}
style={{
padding: "10px 12px",
borderBottom: isLast ? undefined : "1px solid var(--border)",
color: isHovered ? "var(--text)" : "var(--text-2)",
}}
>
{col.render !== undefined ? col.render(row) : (row[col.key] as ReactNode)}
</td>
))}
</tr>
);
})}
</tbody>
</table>
);
}

View File

@@ -0,0 +1,85 @@
import { useState } from "react";
import type { ReactElement } from "react";
export interface FilterTab {
id: string;
label: string;
}
export interface FilterTabsProps {
tabs: FilterTab[];
activeTab: string;
onTabChange: (id: string) => void;
className?: string;
}
function FilterTabItem({
tab,
isActive,
onClick,
}: {
tab: FilterTab;
isActive: boolean;
onClick: () => void;
}): ReactElement {
const [hovered, setHovered] = useState(false);
return (
<button
type="button"
onClick={onClick}
onMouseEnter={(): void => {
setHovered(true);
}}
onMouseLeave={(): void => {
setHovered(false);
}}
style={{
padding: "5px 14px",
borderRadius: 6,
fontSize: "0.8rem",
fontWeight: 600,
color: isActive ? "var(--text)" : hovered ? "var(--text-2)" : "var(--muted)",
background: isActive ? "var(--surface-2)" : "transparent",
border: "none",
cursor: "pointer",
transition: "color 0.15s ease, background 0.15s ease",
outline: "none",
}}
>
{tab.label}
</button>
);
}
export function FilterTabs({
tabs,
activeTab,
onTabChange,
className = "",
}: FilterTabsProps): ReactElement {
return (
<div
className={className}
style={{
display: "inline-flex",
gap: 2,
padding: 3,
background: "var(--surface)",
border: "1px solid var(--border)",
borderRadius: "var(--r)",
}}
>
{tabs.map((tab) => (
<FilterTabItem
key={tab.id}
tab={tab}
isActive={tab.id === activeTab}
onClick={(): void => {
onTabChange(tab.id);
}}
/>
))}
</div>
);
}

View File

@@ -0,0 +1,40 @@
import type { ReactElement } from "react";
export type LogLevel = "info" | "warn" | "error" | "debug" | "success";
export interface LogLineProps {
timestamp: string;
level: LogLevel;
message: string;
className?: string;
}
const levelColors: Record<LogLevel, string> = {
info: "var(--primary-l)",
warn: "var(--warn)",
error: "var(--danger)",
debug: "var(--muted)",
success: "var(--success)",
};
export function LogLine({ timestamp, level, message, className = "" }: LogLineProps): ReactElement {
return (
<div
className={className}
style={{
display: "grid",
gridTemplateColumns: "100px 50px 1fr",
gap: 10,
padding: "4px 0",
fontFamily: "var(--mono)",
fontSize: "0.76rem",
borderBottom: "1px solid rgba(47,59,82,0.3)",
alignItems: "start",
}}
>
<span style={{ color: "var(--muted)" }}>{timestamp}</span>
<span style={{ fontWeight: 600, color: levelColors[level] }}>{level}</span>
<span style={{ color: "var(--text-2)", wordBreak: "break-all" }}>{message}</span>
</div>
);
}

View File

@@ -0,0 +1,99 @@
import { useState } from "react";
import type { ReactElement } from "react";
export interface MetricCell {
label: string;
value: string;
color: string; // CSS color, e.g., "var(--ms-blue-400)"
trend?: {
direction: "up" | "down" | "neutral";
text: string;
};
}
export interface MetricsStripProps {
cells: MetricCell[];
className?: string;
}
function MetricCellItem({ cell, isFirst }: { cell: MetricCell; isFirst: boolean }): ReactElement {
const [hovered, setHovered] = useState(false);
const trendColor =
cell.trend?.direction === "up"
? "var(--success)"
: cell.trend?.direction === "down"
? "var(--danger)"
: "var(--muted)";
return (
<div
onMouseEnter={(): void => {
setHovered(true);
}}
onMouseLeave={(): void => {
setHovered(false);
}}
style={{
padding: "14px 16px",
background: hovered ? "var(--surface-2)" : "var(--surface)",
borderLeft: isFirst ? "none" : "1px solid var(--border)",
borderTop: `2px solid ${cell.color}`,
transition: "background 0.15s ease",
}}
>
<div
style={{
fontSize: "1.4rem",
fontWeight: 800,
fontFamily: "var(--mono)",
lineHeight: 1.1,
color: cell.color,
}}
>
{cell.value}
</div>
<div
style={{
fontSize: "0.72rem",
color: "var(--muted)",
marginTop: 3,
whiteSpace: "nowrap",
}}
>
{cell.label}
</div>
{cell.trend && (
<div
style={{
fontSize: "0.68rem",
fontFamily: "var(--mono)",
marginTop: 4,
color: trendColor,
}}
>
{cell.trend.text}
</div>
)}
</div>
);
}
export function MetricsStrip({ cells, className = "" }: MetricsStripProps): ReactElement {
return (
<div
className={className}
style={{
display: "grid",
gridTemplateColumns: `repeat(${String(cells.length)}, 1fr)`,
borderRadius: "var(--r-lg)",
overflow: "hidden",
border: "1px solid var(--border)",
}}
>
{cells.map((cell, index) => (
<MetricCellItem key={cell.label} cell={cell} isFirst={index === 0} />
))}
</div>
);
}

View File

@@ -0,0 +1,53 @@
import type { ReactElement } from "react";
export type ProgressBarVariant = "blue" | "teal" | "purple" | "amber";
export interface ProgressBarProps {
value: number; // 0-100
variant?: ProgressBarVariant;
className?: string;
label?: string; // screen reader label
}
const variantColors: Record<ProgressBarVariant, string> = {
blue: "var(--primary)",
teal: "var(--success)",
purple: "var(--purple)",
amber: "var(--warn)",
};
export function ProgressBar({
value,
variant = "blue",
className = "",
label,
}: ProgressBarProps): ReactElement {
const clampedValue = Math.min(100, Math.max(0, value));
return (
<div
className={className}
role="progressbar"
aria-valuenow={clampedValue}
aria-valuemin={0}
aria-valuemax={100}
aria-label={label}
style={{
height: 4,
borderRadius: 4,
background: "var(--surface-2)",
overflow: "hidden",
}}
>
<div
style={{
height: "100%",
borderRadius: 4,
width: `${String(clampedValue)}%`,
background: variantColors[variant],
transition: "width 0.4s ease",
}}
/>
</div>
);
}

View File

@@ -0,0 +1,62 @@
import type { ReactElement, ReactNode } from "react";
export interface SectionHeaderProps {
title: string;
subtitle?: string;
actions?: ReactNode;
className?: string;
}
export function SectionHeader({
title,
subtitle,
actions,
className = "",
}: SectionHeaderProps): ReactElement {
return (
<div
className={className}
style={{
display: "flex",
alignItems: "center",
gap: 12,
marginBottom: 16,
}}
>
<div>
<div
style={{
fontSize: "0.95rem",
fontWeight: 700,
color: "var(--text)",
}}
>
{title}
</div>
{subtitle !== undefined && (
<div
style={{
fontSize: "0.78rem",
color: "var(--muted)",
marginTop: 2,
}}
>
{subtitle}
</div>
)}
</div>
{actions !== undefined && (
<div
style={{
marginLeft: "auto",
display: "flex",
gap: 8,
alignItems: "center",
}}
>
{actions}
</div>
)}
</div>
);
}

View File

@@ -64,3 +64,27 @@ export type {
// Dot
export { Dot } from "./components/Dot.js";
export type { DotProps, DotVariant } from "./components/Dot.js";
// MetricsStrip
export { MetricsStrip } from "./components/MetricsStrip.js";
export type { MetricsStripProps, MetricCell } from "./components/MetricsStrip.js";
// ProgressBar
export { ProgressBar } from "./components/ProgressBar.js";
export type { ProgressBarProps, ProgressBarVariant } from "./components/ProgressBar.js";
// FilterTabs
export { FilterTabs } from "./components/FilterTabs.js";
export type { FilterTabsProps, FilterTab } from "./components/FilterTabs.js";
// SectionHeader
export { SectionHeader } from "./components/SectionHeader.js";
export type { SectionHeaderProps } from "./components/SectionHeader.js";
// DataTable
export { DataTable } from "./components/DataTable.js";
export type { DataTableColumn, DataTableProps } from "./components/DataTable.js";
// LogLine
export { LogLine } from "./components/LogLine.js";
export type { LogLineProps, LogLevel } from "./components/LogLine.js";