diff --git a/apps/web/src/components/terminal/TerminalPanel.tsx b/apps/web/src/components/terminal/TerminalPanel.tsx new file mode 100644 index 0000000..3f0ac80 --- /dev/null +++ b/apps/web/src/components/terminal/TerminalPanel.tsx @@ -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 ( +
+ {/* Header */} +
+ {/* Tab bar */} +
+ {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 ( + + ); + })} +
+ + {/* Action buttons */} +
+ +
+
+ + {/* Body */} +
+ {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 ( +
+ + {line.content} + {isLast && +
+ ); + })} + + {/* Show cursor even when no lines */} + {lines.length === 0 && ( +
+ + +
+ )} +
+
+ ); +} diff --git a/apps/web/src/components/terminal/index.ts b/apps/web/src/components/terminal/index.ts new file mode 100644 index 0000000..cd385a0 --- /dev/null +++ b/apps/web/src/components/terminal/index.ts @@ -0,0 +1,2 @@ +export type { TerminalLine, TerminalTab, TerminalPanelProps } from "./TerminalPanel"; +export { TerminalPanel } from "./TerminalPanel"; diff --git a/packages/ui/src/components/DataTable.tsx b/packages/ui/src/components/DataTable.tsx new file mode 100644 index 0000000..dcecefa --- /dev/null +++ b/packages/ui/src/components/DataTable.tsx @@ -0,0 +1,102 @@ +import { useState, type ReactElement, type ReactNode } from "react"; + +export interface DataTableColumn { + key: string; + header: string; + render?: (row: T) => ReactNode; + className?: string; +} + +export interface DataTableProps> { + columns: DataTableColumn[]; + data: T[]; + className?: string; + onRowClick?: (row: T) => void; +} + +export function DataTable>({ + columns, + data, + className = "", + onRowClick, +}: DataTableProps): ReactElement { + const [hoveredRow, setHoveredRow] = useState(null); + + return ( + + + + {columns.map((col) => ( + + ))} + + + + {data.map((row, rowIndex) => { + const isLast = rowIndex === data.length - 1; + const isHovered = hoveredRow === rowIndex; + + return ( + { + onRowClick(row); + } + : undefined + } + onMouseEnter={(): void => { + setHoveredRow(rowIndex); + }} + onMouseLeave={(): void => { + setHoveredRow(null); + }} + > + {columns.map((col) => ( + + ))} + + ); + })} + +
+ {col.header} +
+ {col.render !== undefined ? col.render(row) : (row[col.key] as ReactNode)} +
+ ); +} diff --git a/packages/ui/src/components/FilterTabs.tsx b/packages/ui/src/components/FilterTabs.tsx new file mode 100644 index 0000000..4f98d67 --- /dev/null +++ b/packages/ui/src/components/FilterTabs.tsx @@ -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 ( + + ); +} + +export function FilterTabs({ + tabs, + activeTab, + onTabChange, + className = "", +}: FilterTabsProps): ReactElement { + return ( +
+ {tabs.map((tab) => ( + { + onTabChange(tab.id); + }} + /> + ))} +
+ ); +} diff --git a/packages/ui/src/components/LogLine.tsx b/packages/ui/src/components/LogLine.tsx new file mode 100644 index 0000000..07a3877 --- /dev/null +++ b/packages/ui/src/components/LogLine.tsx @@ -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 = { + 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 ( +
+ {timestamp} + {level} + {message} +
+ ); +} diff --git a/packages/ui/src/components/MetricsStrip.tsx b/packages/ui/src/components/MetricsStrip.tsx new file mode 100644 index 0000000..5cd84b0 --- /dev/null +++ b/packages/ui/src/components/MetricsStrip.tsx @@ -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 ( +
{ + 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", + }} + > +
+ {cell.value} +
+
+ {cell.label} +
+ {cell.trend && ( +
+ {cell.trend.text} +
+ )} +
+ ); +} + +export function MetricsStrip({ cells, className = "" }: MetricsStripProps): ReactElement { + return ( +
+ {cells.map((cell, index) => ( + + ))} +
+ ); +} diff --git a/packages/ui/src/components/ProgressBar.tsx b/packages/ui/src/components/ProgressBar.tsx new file mode 100644 index 0000000..77f5c16 --- /dev/null +++ b/packages/ui/src/components/ProgressBar.tsx @@ -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 = { + 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 ( +
+
+
+ ); +} diff --git a/packages/ui/src/components/SectionHeader.tsx b/packages/ui/src/components/SectionHeader.tsx new file mode 100644 index 0000000..b34dc4e --- /dev/null +++ b/packages/ui/src/components/SectionHeader.tsx @@ -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 ( +
+
+
+ {title} +
+ {subtitle !== undefined && ( +
+ {subtitle} +
+ )} +
+ {actions !== undefined && ( +
+ {actions} +
+ )} +
+ ); +} diff --git a/packages/ui/src/index.ts b/packages/ui/src/index.ts index 51d263e..cea56bf 100644 --- a/packages/ui/src/index.ts +++ b/packages/ui/src/index.ts @@ -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";