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/docs/tasks.md b/docs/tasks.md index 9852f54..00643be 100644 --- a/docs/tasks.md +++ b/docs/tasks.md @@ -16,11 +16,11 @@ | MS15-FE-004 | done | Topbar/Header component (logo, search, status, notifications, theme toggle, avatar dropdown) | #448 | web | feat/ms15-design-system | MS15-FE-002 | MS15-FE-005 | w-4 | 2026-02-22T15:30Z | 2026-02-22T16:00Z | 25K | 44K | Search, status, notifications, avatar dropdown. Commit 04f9918. | | MS15-FE-005 | done | Responsive layout (breakpoints, hamburger, sidebar auto-hide at mobile) | #448 | web | feat/ms15-design-system | MS15-FE-003,MS15-FE-004 | MS15-QA-001 | w-5 | 2026-02-22T16:00Z | 2026-02-22T16:30Z | 20K | 57K | Mobile overlay, hamburger, matchMedia. Commit 28620b2. | | MS15-FE-006 | done | Loading spinner (Mosaic logo icon with rotation animation, site-wide) | #448 | web | feat/ms15-design-system | MS15-FE-001 | | w-2 | 2026-02-22T14:30Z | 2026-02-22T15:00Z | 10K | 8K | MosaicLogo + MosaicSpinner components. Same commit e615fa8. | -| MS15-UI-001 | not-started | Align packages/ui tokens with new CSS variable design system | #449 | ui | feat/ms15-shared-components | MS15-FE-001 | MS15-UI-002,MS15-UI-003,MS15-UI-004 | | | | 20K | | | -| MS15-UI-002 | not-started | Update Card, Badge, Button, Dot component variants to match reference | #449 | ui | feat/ms15-shared-components | MS15-UI-001 | MS15-DASH-001 | | | | 25K | | | -| MS15-UI-003 | not-started | Create MetricsStrip, ProgressBar, FilterTabs shared components | #449 | ui | feat/ms15-shared-components | MS15-UI-001 | MS15-DASH-001 | | | | 20K | | | -| MS15-UI-004 | not-started | Create SectionHeader, Table, LogLine shared components | #449 | ui | feat/ms15-shared-components | MS15-UI-001 | MS15-DASH-002 | | | | 15K | | | -| MS15-UI-005 | not-started | Create Terminal panel component (bottom drawer, tabs, output) | #449 | web | feat/ms15-shared-components | MS15-UI-001 | | | | | 20K | | | +| MS15-UI-001 | done | Align packages/ui tokens with new CSS variable design system | #449 | ui | feat/ms15-shared-components | MS15-FE-001 | MS15-UI-002,MS15-UI-003,MS15-UI-004 | w-6 | 2026-02-22T17:00Z | 2026-02-22T17:30Z | 20K | 35K | Combined with UI-002. Commit 44011f4. Build passes. | +| MS15-UI-002 | done | Update Card, Badge, Button, Dot component variants to match reference | #449 | ui | feat/ms15-shared-components | MS15-UI-001 | MS15-DASH-001 | w-6 | 2026-02-22T17:00Z | 2026-02-22T17:30Z | 25K | 0K | Combined with UI-001 (same commit 44011f4). | +| MS15-UI-003 | done | Create MetricsStrip, ProgressBar, FilterTabs shared components | #449 | ui | feat/ms15-shared-components | MS15-UI-001 | MS15-DASH-001 | w-7 | 2026-02-22T17:30Z | 2026-02-22T18:00Z | 20K | 30K | Commit 9b0445c. Build passes. | +| MS15-UI-004 | done | Create SectionHeader, Table, LogLine shared components | #449 | ui | feat/ms15-shared-components | MS15-UI-001 | MS15-DASH-002 | w-8 | 2026-02-22T17:30Z | 2026-02-22T18:00Z | 15K | 25K | Commit 9b0445c. Build passes. | +| MS15-UI-005 | done | Create Terminal panel component (bottom drawer, tabs, output) | #449 | web | feat/ms15-shared-components | MS15-UI-001 | | w-9 | 2026-02-22T17:30Z | 2026-02-22T18:00Z | 20K | 30K | Commit 9b0445c. Build passes. | | MS15-DASH-001 | not-started | Dashboard metrics strip (6 cells, colored borders, values, trends) | #450 | web | feat/ms15-dashboard-page | MS15-UI-002,MS15-UI-003 | | | | | 15K | | | | MS15-DASH-002 | not-started | Active Orchestrator Sessions card with agent nodes | #450 | web | feat/ms15-dashboard-page | MS15-UI-004 | | | | | 20K | | | | MS15-DASH-003 | not-started | Quick Actions 2x2 grid | #450 | web | feat/ms15-dashboard-page | MS15-UI-002 | | | | | 10K | | | diff --git a/packages/ui/src/components/Avatar.tsx b/packages/ui/src/components/Avatar.tsx index a6c0743..a0c02d8 100644 --- a/packages/ui/src/components/Avatar.tsx +++ b/packages/ui/src/components/Avatar.tsx @@ -15,41 +15,51 @@ export function Avatar({ fallback, initials, className = "", + style, ...props }: AvatarProps): ReactElement { - const sizeStyles = { + type AvatarSize = "sm" | "md" | "lg" | "xl"; + const sizeStyles: Record = { sm: "w-6 h-6 text-xs", md: "w-8 h-8 text-sm", lg: "w-12 h-12 text-base", xl: "w-16 h-16 text-xl", }; - const baseStyles = - "rounded-full overflow-hidden flex items-center justify-center bg-gray-200 font-medium text-gray-600"; + const baseClass = `rounded-full overflow-hidden flex items-center justify-center font-medium ${sizeStyles[size]} ${className}`; - const combinedClassName = [baseStyles, sizeStyles[size], className].filter(Boolean).join(" "); + const gradientStyle: React.CSSProperties = { + background: "linear-gradient(135deg, var(--ms-blue-500), var(--ms-purple-500))", + color: "#fff", + ...style, + }; if (src) { - return {alt}; + return ( + {alt} + ); } if (fallback) { - return
{fallback}
; + return ( +
+ {fallback} +
+ ); } if (initials) { - return
{initials}
; + return ( +
+ {initials} +
+ ); } // Default fallback with user icon return ( -
-
+ + + {def.pulse && ( + ); diff --git a/packages/ui/src/components/Button.tsx b/packages/ui/src/components/Button.tsx index 047e5a3..080380f 100644 --- a/packages/ui/src/components/Button.tsx +++ b/packages/ui/src/components/Button.tsx @@ -1,39 +1,120 @@ -import type { ButtonHTMLAttributes, ReactNode, ReactElement } from "react"; +import { useState, type ButtonHTMLAttributes, type ReactNode, type ReactElement } from "react"; export interface ButtonProps extends ButtonHTMLAttributes { - variant?: "primary" | "secondary" | "danger" | "ghost"; + variant?: "primary" | "secondary" | "ghost" | "danger" | "success"; size?: "sm" | "md" | "lg"; children: ReactNode; } +interface VariantStyle { + base: React.CSSProperties; + hover: React.CSSProperties; +} + +type ButtonVariant = "primary" | "secondary" | "ghost" | "danger" | "success"; + +const variantStyles: Record = { + primary: { + base: { + background: "var(--ms-blue-500)", + color: "#fff", + border: "none", + }, + hover: { + background: "var(--ms-blue-400)", + boxShadow: "0 4px 16px rgba(47,128,255,0.3)", + }, + }, + secondary: { + base: { + background: "transparent", + border: "1px solid var(--border)", + color: "var(--text-2)", + }, + hover: { + background: "var(--surface)", + color: "var(--text)", + }, + }, + ghost: { + base: { + background: "transparent", + border: "1px solid var(--border)", + color: "var(--text-2)", + }, + hover: { + background: "var(--surface)", + color: "var(--text)", + }, + }, + danger: { + base: { + background: "rgba(229,72,77,0.12)", + border: "1px solid rgba(229,72,77,0.3)", + color: "var(--danger)", + }, + hover: { + background: "rgba(229,72,77,0.2)", + }, + }, + success: { + base: { + background: "rgba(20,184,166,0.12)", + border: "1px solid rgba(20,184,166,0.3)", + color: "var(--success)", + }, + hover: { + background: "rgba(20,184,166,0.2)", + }, + }, +}; + +type ButtonSize = "sm" | "md" | "lg"; + +const sizeStyles: Record = { + sm: "px-3 py-1.5 text-sm", + md: "px-4 py-2 text-base", + lg: "px-6 py-3 text-lg", +}; + export function Button({ variant = "primary", size = "md", children, className = "", + style, + onMouseEnter, + onMouseLeave, + disabled, ...props }: ButtonProps): ReactElement { - const baseStyles = "inline-flex items-center justify-center font-medium rounded-md"; + const [isHovered, setIsHovered] = useState(false); - const variantStyles = { - primary: "bg-blue-600 text-white hover:bg-blue-700", - secondary: "bg-gray-200 text-gray-900 hover:bg-gray-300", - danger: "bg-red-600 text-white hover:bg-red-700", - ghost: "bg-transparent text-gray-700 hover:bg-gray-100 border border-gray-300", + const vStyles = variantStyles[variant]; + const baseClass = `inline-flex items-center justify-center font-medium rounded-md transition-colors ${sizeStyles[size]} ${className}`; + + const computedStyle: React.CSSProperties = { + ...vStyles.base, + ...(isHovered && !disabled ? vStyles.hover : {}), + ...(disabled ? { opacity: 0.5, cursor: "not-allowed" } : { cursor: "pointer" }), + ...style, }; - const sizeStyles = { - sm: "px-3 py-1.5 text-sm", - md: "px-4 py-2 text-base", - lg: "px-6 py-3 text-lg", - }; - - const combinedClassName = [baseStyles, variantStyles[variant], sizeStyles[size], className] - .filter(Boolean) - .join(" "); - return ( - ); diff --git a/packages/ui/src/components/Card.tsx b/packages/ui/src/components/Card.tsx index 938a647..9c06e59 100644 --- a/packages/ui/src/components/Card.tsx +++ b/packages/ui/src/components/Card.tsx @@ -3,6 +3,7 @@ import type { ReactNode, ReactElement } from "react"; export interface CardProps { children: ReactNode; className?: string; + style?: React.CSSProperties; id?: string; onMouseEnter?: () => void; onMouseLeave?: () => void; @@ -11,21 +12,25 @@ export interface CardProps { export interface CardHeaderProps { children: ReactNode; className?: string; + style?: React.CSSProperties; } export interface CardContentProps { children: ReactNode; className?: string; + style?: React.CSSProperties; } export interface CardFooterProps { children: ReactNode; className?: string; + style?: React.CSSProperties; } export function Card({ children, className = "", + style, id, onMouseEnter, onMouseLeave, @@ -35,24 +40,52 @@ export function Card({ id={id} onMouseEnter={onMouseEnter} onMouseLeave={onMouseLeave} - className={`bg-white rounded-lg shadow-md border border-gray-200 ${className}`} + className={className} + style={{ + background: "var(--surface)", + border: "1px solid var(--border)", + borderRadius: "var(--r-lg)", + padding: "16px", + ...style, + }} > {children}
); } -export function CardHeader({ children, className = "" }: CardHeaderProps): ReactElement { - return
{children}
; -} - -export function CardContent({ children, className = "" }: CardContentProps): ReactElement { - return
{children}
; -} - -export function CardFooter({ children, className = "" }: CardFooterProps): ReactElement { +export function CardHeader({ children, className = "", style }: CardHeaderProps): ReactElement { return ( -
+
+ {children} +
+ ); +} + +export function CardContent({ children, className = "", style }: CardContentProps): ReactElement { + return ( +
+ {children} +
+ ); +} + +export function CardFooter({ children, className = "", style }: CardFooterProps): ReactElement { + return ( +
{children}
); 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/Dot.tsx b/packages/ui/src/components/Dot.tsx new file mode 100644 index 0000000..df959a4 --- /dev/null +++ b/packages/ui/src/components/Dot.tsx @@ -0,0 +1,39 @@ +import type { ReactElement } from "react"; + +export type DotVariant = "teal" | "blue" | "amber" | "red" | "muted"; + +export interface DotProps { + variant?: DotVariant; + className?: string; +} + +interface DotColorDef { + bg: string; + shadow: string; +} + +export function Dot({ variant = "muted", className = "" }: DotProps): ReactElement { + const colors: Record = { + teal: { bg: "var(--success)", shadow: "0 0 5px var(--success)" }, + blue: { bg: "var(--primary)", shadow: "0 0 5px var(--primary)" }, + amber: { bg: "var(--warn)", shadow: "0 0 5px var(--warn)" }, + red: { bg: "var(--danger)", shadow: "0 0 5px var(--danger)" }, + muted: { bg: "var(--muted)", shadow: "none" }, + }; + + const { bg, shadow } = colors[variant]; + + return ( +