diff --git a/frontend/src/app/components/agent-card/agent-card.component.html b/frontend/src/app/components/agent-card/agent-card.component.html new file mode 100644 index 0000000..45d4d76 --- /dev/null +++ b/frontend/src/app/components/agent-card/agent-card.component.html @@ -0,0 +1,99 @@ + + +
+ +
+ + +
+ + +
+
+ + +
+
+ @if (channel()) { + + + {{ channel() }} + + } + + {{ lastActivityText() }} + +
+
+ + + @if (task()) { +

+ {{ task() }} +

+ } + + + @if (showProgress()) { +
+ +
+ } + + + + +
+
\ No newline at end of file diff --git a/frontend/src/app/components/agent-card/agent-card.component.scss b/frontend/src/app/components/agent-card/agent-card.component.scss new file mode 100644 index 0000000..8611c30 --- /dev/null +++ b/frontend/src/app/components/agent-card/agent-card.component.scss @@ -0,0 +1,196 @@ +// ============================================================================ +// Agent Card Styles — M3 Tactical Dark (CUB-45 Final Integration) +// Assembles: AgentStatusBadge, TaskProgressBar, QuickJumpButton +// Uses CUB-47 dark mode CSS variables. +// ============================================================================ + +// --------------------------------------------------------------------------- +// Card Shell +// --------------------------------------------------------------------------- +.agent-card { + display: flex; + position: relative; + min-width: var(--cc-card-min-width, 320px); + border-radius: var(--cc-card-border-radius, 16px); + background-color: var(--cc-surface-container); + overflow: hidden; + transition: background-color 150ms ease, box-shadow 150ms ease; + + // M3 state layer overlay on hover (8% primary) + &::after { + content: ''; + position: absolute; + inset: 0; + border-radius: inherit; + background-color: rgba(255, 255, 255, 0.08); + opacity: 0; + transition: opacity 150ms ease; + pointer-events: none; + } + + &:hover::after { + opacity: 1; + } + + // Focus-visible outline for keyboard navigation + &:focus-visible { + outline: 3px solid var(--status-active); + outline-offset: 2px; + } +} + +// --------------------------------------------------------------------------- +// Left-border accent (4px, matching status color) +// Uses --agent-status-color set via [style.--agent-status-color] binding +// in the template. Color map: +// Active → --status-active (#38BDF8) +// Idle → --status-idle (#2DD4BF) +// Thinking → --status-thinking (#A78BFA) +// Error → --status-error (#F87171) +// --------------------------------------------------------------------------- +.agent-card__accent { + flex-shrink: 0; + width: 4px; + background-color: var(--agent-status-color); + border-radius: 4px 0 0 4px; +} + +// --------------------------------------------------------------------------- +// Card Body +// --------------------------------------------------------------------------- +.agent-card__body { + flex: 1; + display: flex; + flex-direction: column; + gap: 12px; + padding: var(--cc-card-padding, 20px); + min-width: 0; // Prevent flex blowout for long text +} + +// --------------------------------------------------------------------------- +// Header Row +// --------------------------------------------------------------------------- +.agent-card__header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + flex-wrap: wrap; +} + +.agent-card__status-row { + display: flex; + align-items: center; + gap: 8px; +} + +.agent-card__meta { + display: flex; + align-items: center; + gap: 12px; + color: var(--cc-on-surface-variant); + font-size: 12px; +} + +.agent-card__channel { + display: inline-flex; + align-items: center; + gap: 4px; +} + +.agent-card__channel-icon { + font-size: 14px; + width: 14px; + height: 14px; +} + +.agent-card__last-activity { + opacity: 0.7; +} + +// --------------------------------------------------------------------------- +// Task Description +// --------------------------------------------------------------------------- +.agent-card__task { + margin: 0; + font-size: 14px; + line-height: 1.5; + color: var(--cc-on-surface); + overflow: hidden; + text-overflow: ellipsis; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; +} + +// --------------------------------------------------------------------------- +// Progress Bar Container +// Overrides the sub-component's bar to use the agent's status color +// --------------------------------------------------------------------------- +.agent-card__progress { + // Override TaskProgressBar's active indicator to match agent status color + --mat-progress-bar-active-indicator-color: var(--agent-status-color); + --mat-progress-bar-track-color: var(--agent-status-bg); +} + +// --------------------------------------------------------------------------- +// Footer Row +// --------------------------------------------------------------------------- +.agent-card__footer { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + margin-top: auto; // Push footer to bottom +} + +.agent-card__session { + font-size: 11px; + color: var(--cc-on-surface-variant); + opacity: 0.6; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + flex: 1; + min-width: 0; +} + +// --------------------------------------------------------------------------- +// Status-specific card backgrounds (subtle tint) +// --------------------------------------------------------------------------- +.agent-card--active { + background-color: var(--status-active-bg); +} + +.agent-card--thinking { + background-color: var(--status-thinking-bg); +} + +.agent-card--error { + background-color: var(--status-error-bg); +} + +// Idle and offline use default surface-container + +// --------------------------------------------------------------------------- +// Accessibility: Reduced Motion +// --------------------------------------------------------------------------- +@media (prefers-reduced-motion: reduce) { + .agent-card { + transition: none; + } +} + +// --------------------------------------------------------------------------- +// Responsive: Mobile adjustments +// --------------------------------------------------------------------------- +@media (max-width: 599px) { + .agent-card { + min-width: unset; + } + + .agent-card__body { + padding: 16px; + gap: 8px; + } +} \ No newline at end of file diff --git a/frontend/src/app/components/agent-card/agent-card.component.ts b/frontend/src/app/components/agent-card/agent-card.component.ts new file mode 100644 index 0000000..a7a8c4d --- /dev/null +++ b/frontend/src/app/components/agent-card/agent-card.component.ts @@ -0,0 +1,160 @@ +// ============================================================================ +// Agent Card Component — Final Integration (CUB-45) +// Assembles: AgentStatusBadge, TaskProgressBar, QuickJumpButton +// Layout: left-border accent, aria-labels, role="article" +// Uses tactical dark mode CSS variables from CUB-47. +// ============================================================================ + +import { + ChangeDetectionStrategy, + Component, + EventEmitter, + Output, + computed, + input, +} from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { MatIconModule } from '@angular/material/icon'; +import { MatTooltipModule } from '@angular/material/tooltip'; + +import { AgentStatus } from '../../models'; +import { AgentStatusBadgeComponent } from '../agent-status-badge/agent-status-badge.component'; +import { TaskProgressBarComponent } from '../task-progress-bar/task-progress-bar.component'; +import { QuickJumpButtonComponent } from '../quick-jump-button/quick-jump-button.component'; + +/** + * AgentCard displays a single agent's status in the Command Hub grid. + * + * Composes three sub-components: + * - AgentStatusBadge — colored pill with pulse animation + * - TaskProgressBar — determinate progress with optional elapsed time + * - QuickJumpButton — M3 FilledTonalIconButton for session navigation + * + * Inputs: + * - status: AgentStatus — current agent status (required) + * - task: string — current task description + * - progress: number — task progress percentage (0–100) + * - sessionKey: string — full session key (required) + * - channel: string — communication channel (e.g., "telegram") + * - lastActivity: Date — timestamp of last activity + * + * Accessibility: + * - role="article" on the card element + * - aria-labels for status badge, progress bar, and jump button + * - focus-visible outlines for keyboard navigation + */ +@Component({ + selector: 'app-agent-card', + standalone: true, + imports: [ + CommonModule, + MatIconModule, + MatTooltipModule, + AgentStatusBadgeComponent, + TaskProgressBarComponent, + QuickJumpButtonComponent, + ], + templateUrl: './agent-card.component.html', + styleUrl: './agent-card.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class AgentCardComponent { + // --------------------------------------------------------------------------- + // Required Inputs + // --------------------------------------------------------------------------- + + /** Current agent status — drives left-border accent color and badge. */ + readonly status = input.required(); + + /** Full session key — displayed subtly and passed to QuickJumpButton. */ + readonly sessionKey = input.required(); + + // --------------------------------------------------------------------------- + // Optional Inputs + // --------------------------------------------------------------------------- + + /** Current task description, e.g. "Reviewing PR #42". */ + readonly task = input(''); + + /** Task progress percentage (0–100). Shown when status is active/thinking. */ + readonly progress = input(0); + + /** Communication channel, e.g. "telegram". */ + readonly channel = input(''); + + /** Timestamp of last activity. Formatted as relative time. */ + readonly lastActivity = input(new Date()); + + // --------------------------------------------------------------------------- + // Outputs + // --------------------------------------------------------------------------- + + /** Emitted when the Quick-Jump button is clicked with the session key. */ + @Output() + readonly jumpClick = new EventEmitter(); + + // --------------------------------------------------------------------------- + // Computed Values + // --------------------------------------------------------------------------- + + /** Map status to CSS custom property for dynamic left-border accent color. */ + readonly statusColorVar = computed(() => { + const map: Record = { + active: 'var(--status-active)', + idle: 'var(--status-idle)', + thinking: 'var(--status-thinking)', + error: 'var(--status-error)', + offline: 'var(--status-offline)', + }; + return map[this.status()]; + }); + + /** Map status to background tint CSS variable. */ + readonly statusBgVar = computed(() => { + const map: Record = { + active: 'var(--status-active-bg)', + idle: 'var(--status-idle-bg)', + thinking: 'var(--status-thinking-bg)', + error: 'var(--status-error-bg)', + offline: 'var(--cc-surface-container)', + }; + return map[this.status()]; + }); + + /** Format last activity as relative time string. */ + readonly lastActivityText = computed(() => { + const now = Date.now(); + const then = this.lastActivity().getTime(); + const diffMs = now - then; + const diffMin = Math.floor(diffMs / 60000); + if (diffMin < 1) return 'Just now'; + if (diffMin < 60) return `${diffMin}m ago`; + const diffHr = Math.floor(diffMin / 60); + if (diffHr < 24) return `${diffHr}h ago`; + return `${Math.floor(diffHr / 24)}d ago`; + }); + + /** Whether to show the progress bar — visible for active/thinking with progress > 0. */ + readonly showProgress = computed(() => { + const s = this.status(); + return (s === 'active' || s === 'thinking') && this.progress() > 0; + }); + + /** Whether to show the elapsed time on the progress bar. */ + readonly showElapsed = computed(() => this.showProgress()); + + /** Truncate session key for display. */ + readonly truncatedSessionKey = computed(() => { + const key = this.sessionKey(); + return key.length > 28 ? key.substring(0, 28) + '…' : key; + }); + + // --------------------------------------------------------------------------- + // Event Handlers + // --------------------------------------------------------------------------- + + /** Forward QuickJumpButton's jump event with the session key. */ + onJumpClick(sessionKey: string): void { + this.jumpClick.emit(sessionKey); + } +} \ No newline at end of file diff --git a/frontend/src/app/components/agent-card/index.ts b/frontend/src/app/components/agent-card/index.ts new file mode 100644 index 0000000..e3ac9fe --- /dev/null +++ b/frontend/src/app/components/agent-card/index.ts @@ -0,0 +1,4 @@ +// ============================================================================ +// Agent Card — Barrel Export (CUB-45) +// ============================================================================ +export { AgentCardComponent } from './agent-card.component'; \ No newline at end of file diff --git a/frontend/src/app/components/agent-status-badge/agent-status-badge.component.html b/frontend/src/app/components/agent-status-badge/agent-status-badge.component.html new file mode 100644 index 0000000..91d4544 --- /dev/null +++ b/frontend/src/app/components/agent-status-badge/agent-status-badge.component.html @@ -0,0 +1,12 @@ + + + + + {{ statusLabels[status] }} + + \ No newline at end of file diff --git a/frontend/src/app/components/agent-status-badge/agent-status-badge.component.scss b/frontend/src/app/components/agent-status-badge/agent-status-badge.component.scss new file mode 100644 index 0000000..d8e8935 --- /dev/null +++ b/frontend/src/app/components/agent-status-badge/agent-status-badge.component.scss @@ -0,0 +1,143 @@ +// ============================================================================ +// Agent Status Badge — Pill Style with Pulse Animation +// Per CUB-48: Color mapping + animation durations: +// Active → --color-primary (#38BDF8) 2s pulse +// Idle → --color-secondary (#2DD4BF) no animation +// Thinking → --color-accent (#A78BFA) 3s pulse +// Error → --color-danger (#F87171) 0.8s pulse +// ============================================================================ + +// --------------------------------------------------------------------------- +// Badge Container +// --------------------------------------------------------------------------- +.status-badge { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 4px 12px; + border-radius: 999px; + font-size: 12px; + font-weight: 500; + letter-spacing: 0.02em; + line-height: 1; + white-space: nowrap; + user-select: none; + + // --- Status-specific backgrounds & text --- + &--active { + background-color: var(--status-active-bg); + color: var(--status-active); + } + + &--idle { + background-color: var(--status-idle-bg); + color: var(--status-idle); + } + + &--thinking { + background-color: var(--status-thinking-bg); + color: var(--status-thinking); + } + + &--error { + background-color: var(--status-error-bg); + color: var(--status-error); + } + + &--offline { + background-color: rgba(100, 116, 139, 0.12); + color: var(--status-offline); + } +} + +// --------------------------------------------------------------------------- +// Status Dot (inner indicator circle) +// --------------------------------------------------------------------------- +.status-badge__dot { + display: inline-block; + width: 8px; + height: 8px; + border-radius: 50%; + flex-shrink: 0; + + .status-badge--active & { + background-color: var(--status-active); + animation: badge-pulse-active 2s ease-in-out infinite; + } + + .status-badge--idle & { + background-color: var(--status-idle); + // Idle: no animation — steady dot + } + + .status-badge--thinking & { + background-color: var(--status-thinking); + animation: badge-pulse-thinking 3s ease-in-out infinite; + } + + .status-badge--error & { + background-color: var(--status-error); + animation: badge-pulse-error 0.8s ease-in-out infinite; + } + + .status-badge--offline & { + background-color: var(--status-offline); + // Offline: no animation + } +} + +// --------------------------------------------------------------------------- +// Label Text +// --------------------------------------------------------------------------- +.status-badge__label { + color: inherit; +} + +// --------------------------------------------------------------------------- +// Badge Pulse Keyframes +// --------------------------------------------------------------------------- +// These are scoped to the badge component rather than reusing the global +// .status-dot animations, because the badge pulse is subtler (scale + opacity +// blend for a pill context vs. standalone dot context). +// --------------------------------------------------------------------------- + +@keyframes badge-pulse-active { + 0%, + 100% { + opacity: 1; + transform: scale(1); + } + 50% { + opacity: 0.7; + transform: scale(1.3); + } +} + +@keyframes badge-pulse-thinking { + 0%, + 100% { + opacity: 0.8; + } + 50% { + opacity: 0.4; + } +} + +@keyframes badge-pulse-error { + 0%, + 100% { + opacity: 1; + } + 50% { + opacity: 0.4; + } +} + +// --------------------------------------------------------------------------- +// Accessibility: Reduced Motion +// --------------------------------------------------------------------------- +@media (prefers-reduced-motion: reduce) { + .status-badge__dot { + animation: none !important; + } +} \ No newline at end of file diff --git a/frontend/src/app/components/agent-status-badge/agent-status-badge.component.ts b/frontend/src/app/components/agent-status-badge/agent-status-badge.component.ts new file mode 100644 index 0000000..0a7519d --- /dev/null +++ b/frontend/src/app/components/agent-status-badge/agent-status-badge.component.ts @@ -0,0 +1,51 @@ +// ============================================================================ +// Agent Status Badge Component +// Per CUB-48: Colored pill badge with pulse animation for agent statuses. +// Displays Active, Idle, Thinking, or Error with correct color mapping. +// ============================================================================ + +import { ChangeDetectionStrategy, Component, Input } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { AgentStatus } from '../../models'; + +/** + * Reusable status badge that renders a colored pill with label text + * and a subtle pulse animation for Active, Thinking, and Error states. + * + * Usage: + * ```html + * + * + * ``` + */ +@Component({ + selector: 'app-agent-status-badge', + standalone: true, + imports: [CommonModule], + templateUrl: './agent-status-badge.component.html', + styleUrl: './agent-status-badge.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class AgentStatusBadgeComponent { + /** Agent status to display. Maps to a color and animation. */ + @Input({ required: true }) + status!: AgentStatus; + + /** Whether to show the status label text alongside the dot. Defaults to true. */ + @Input() + showLabel = true; + + /** Mapping from status to human-readable label. */ + readonly statusLabels: Record = { + active: 'Active', + idle: 'Idle', + thinking: 'Thinking', + error: 'Error', + offline: 'Offline', + }; + + /** CSS class string for the badge container based on current status. */ + get statusClass(): string { + return `status-badge--${this.status}`; + } +} \ No newline at end of file diff --git a/frontend/src/app/components/agent-status-badge/index.ts b/frontend/src/app/components/agent-status-badge/index.ts new file mode 100644 index 0000000..bd3aec6 --- /dev/null +++ b/frontend/src/app/components/agent-status-badge/index.ts @@ -0,0 +1,6 @@ +// ============================================================================ +// Agent Status Badge — Barrel Export +// CUB-48 +// ============================================================================ + +export { AgentStatusBadgeComponent } from './agent-status-badge.component'; \ No newline at end of file diff --git a/frontend/src/app/components/index.ts b/frontend/src/app/components/index.ts new file mode 100644 index 0000000..21be0fd --- /dev/null +++ b/frontend/src/app/components/index.ts @@ -0,0 +1,7 @@ +// ============================================================================ +// Component Barrel Exports +// ============================================================================ +export * from './agent-card/index'; +export * from './agent-status-badge/index'; +export * from './quick-jump-button/quick-jump-button.component'; +export * from './task-progress-bar/index'; \ No newline at end of file diff --git a/frontend/src/app/components/quick-jump-button/quick-jump-button.component.html b/frontend/src/app/components/quick-jump-button/quick-jump-button.component.html new file mode 100644 index 0000000..3aa3c3c --- /dev/null +++ b/frontend/src/app/components/quick-jump-button/quick-jump-button.component.html @@ -0,0 +1,8 @@ + \ No newline at end of file diff --git a/frontend/src/app/components/quick-jump-button/quick-jump-button.component.scss b/frontend/src/app/components/quick-jump-button/quick-jump-button.component.scss new file mode 100644 index 0000000..718b441 --- /dev/null +++ b/frontend/src/app/components/quick-jump-button/quick-jump-button.component.scss @@ -0,0 +1,68 @@ +// ============================================================================ +// Quick-Jump Button — M3 FilledTonalIconButton +// Per spec Section 7.3: Agent Card Quick-Jump action +// M3 spec: FilledTonalIconButton uses secondary container color +// with 8% state layer overlay for hover/focus. +// ============================================================================ + +.quick-jump-button { + // M3 FilledTonalIconButton: secondary-container background + // Angular Material mat-icon-button sets up the base shape (40x40, round). + // We override the color tokens to match FilledTonal style. + --mdc-icon-button-icon-color: var(--mat-sys-on-secondary-container); + background-color: var(--mat-sys-secondary-container); + border-radius: 50%; + transition: background-color 150ms cubic-bezier(0.4, 0, 0.2, 1); + + // M3 State Layer: 8% overlay on hover + &:hover { + background-color: var(--mat-sys-secondary-container); + // State layer overlay using a pseudo-element for precise 8% opacity + &::after { + content: ''; + position: absolute; + inset: 0; + border-radius: 50%; + background-color: var(--mat-sys-on-secondary-container); + opacity: 0.08; + pointer-events: none; + } + } + + // M3 State Layer: 12% overlay on focus-visible (slightly stronger for accessibility) + &:focus-visible { + background-color: var(--mat-sys-secondary-container); + outline: 3px solid var(--status-active); + outline-offset: 2px; + + &::after { + content: ''; + position: absolute; + inset: 0; + border-radius: 50%; + background-color: var(--mat-sys-on-secondary-container); + opacity: 0.12; + pointer-events: none; + } + } + + // M3 State Layer: 12% overlay on active/pressed + &:active { + background-color: var(--mat-sys-secondary-container); + + &::after { + content: ''; + position: absolute; + inset: 0; + border-radius: 50%; + background-color: var(--mat-sys-on-secondary-container); + opacity: 0.12; + pointer-events: none; + } + } + + // Icon color stays on-secondary-container across all states + .mat-icon { + color: var(--mat-sys-on-secondary-container); + } +} \ No newline at end of file diff --git a/frontend/src/app/components/quick-jump-button/quick-jump-button.component.ts b/frontend/src/app/components/quick-jump-button/quick-jump-button.component.ts new file mode 100644 index 0000000..a4927cd --- /dev/null +++ b/frontend/src/app/components/quick-jump-button/quick-jump-button.component.ts @@ -0,0 +1,33 @@ +import { ChangeDetectionStrategy, Component, EventEmitter, Input, Output } from '@angular/core'; +import { MatIconButton } from '@angular/material/button'; +import { MatIcon } from '@angular/material/icon'; + +/** + * Quick-Jump Button — M3 FilledTonalIconButton + * + * An icon button that emits a navigation event for jumping to an agent session. + * Uses the Material Design 3 FilledTonalIconButton style with 8% state layer + * overlay on hover and focus. + * + * Per spec Section 7.3: Agent Card Component Interface + */ +@Component({ + selector: 'app-quick-jump-button', + standalone: true, + imports: [MatIconButton, MatIcon], + templateUrl: './quick-jump-button.component.html', + styleUrl: './quick-jump-button.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class QuickJumpButtonComponent { + /** Emitted when the button is clicked, carrying the session key for navigation. */ + @Output() jumpClick = new EventEmitter(); + + /** The session key to navigate to. Set by the parent agent card. */ + @Input() + sessionKey = ''; + + onJumpClick(): void { + this.jumpClick.emit(this.sessionKey); + } +} \ No newline at end of file diff --git a/frontend/src/app/components/task-progress-bar/index.ts b/frontend/src/app/components/task-progress-bar/index.ts new file mode 100644 index 0000000..67414db --- /dev/null +++ b/frontend/src/app/components/task-progress-bar/index.ts @@ -0,0 +1,6 @@ +// ============================================================================ +// Task Progress Bar — Barrel Export +// CUB-44 +// ============================================================================ + +export { TaskProgressBarComponent } from './task-progress-bar.component'; \ No newline at end of file diff --git a/frontend/src/app/components/task-progress-bar/task-progress-bar.component.html b/frontend/src/app/components/task-progress-bar/task-progress-bar.component.html new file mode 100644 index 0000000..f8d8a7d --- /dev/null +++ b/frontend/src/app/components/task-progress-bar/task-progress-bar.component.html @@ -0,0 +1,18 @@ + +
+ +
+ {{ clampedProgress }}% + + {{ elapsedText }} + +
+ + + +
\ No newline at end of file diff --git a/frontend/src/app/components/task-progress-bar/task-progress-bar.component.scss b/frontend/src/app/components/task-progress-bar/task-progress-bar.component.scss new file mode 100644 index 0000000..bb467a2 --- /dev/null +++ b/frontend/src/app/components/task-progress-bar/task-progress-bar.component.scss @@ -0,0 +1,77 @@ +// ============================================================================ +// Task Progress Bar — Tactical Dark Theme Styling +// Per CUB-44: Uses --color-primary for bar fill and --color-surface-light +// for track background, mapped to the Control Center's M3 dark tokens. +// ============================================================================ + +// --------------------------------------------------------------------------- +// Container +// --------------------------------------------------------------------------- +.task-progress-bar { + display: flex; + flex-direction: column; + gap: 6px; + width: 100%; +} + +// --------------------------------------------------------------------------- +// Info row: percentage label + elapsed time +// --------------------------------------------------------------------------- +.task-progress-bar__info { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 8px; +} + +.task-progress-bar__percent { + font-family: var(--cc-font-mono, 'Roboto Mono', monospace); + font-size: 14px; + font-weight: 600; + color: var(--cc-on-surface, #E2E8F0); + letter-spacing: 0.02em; +} + +.task-progress-bar__elapsed { + font-family: var(--cc-font-mono, 'Roboto Mono', monospace); + font-size: 12px; + font-weight: 400; + color: var(--cc-on-surface-variant, #8A9BB0); + letter-spacing: 0.01em; +} + +// --------------------------------------------------------------------------- +// Material Progress Bar Overrides +// --------------------------------------------------------------------------- +// Map the spec's --color-primary and --color-surface-light to the Control +// Center's actual theme tokens. This ensures the bar uses the tactical dark +// palette while respecting the spec's variable naming. +// --------------------------------------------------------------------------- + +.task-progress-bar__bar { + // Override the track (background) to use the surface container + --mat-progress-bar-track-height: 6px; + --mat-progress-bar-active-indicator-height: 6px; + + // Bar fill color: primary (cyan/sky blue per tactical dark theme) + --mat-progress-bar-active-indicator-color: var(--color-primary, var(--mat-sys-primary, #38BDF8)); + + // Track background: surface container (dark slate) + --mat-progress-bar-track-color: var(--color-surface-light, var(--cc-surface-container, #1C2027)); + + // Border radius for a softer bar + border-radius: 3px; + + // Smooth transition on value changes + transition: none; +} + +// Rounded ends on the progress bar fill +:host ::ng-deep .mdc-linear-progress__bar-inner { + border-radius: 3px; +} + +// Rounded track background +:host ::ng-deep .mdc-linear-progress__track { + border-radius: 3px; +} \ No newline at end of file diff --git a/frontend/src/app/components/task-progress-bar/task-progress-bar.component.ts b/frontend/src/app/components/task-progress-bar/task-progress-bar.component.ts new file mode 100644 index 0000000..a380566 --- /dev/null +++ b/frontend/src/app/components/task-progress-bar/task-progress-bar.component.ts @@ -0,0 +1,109 @@ +// ============================================================================ +// Task Progress Bar Component +// Per CUB-44: Determinate progress bar with optional elapsed time display. +// Uses Angular Material mat-progress-bar in determinate mode with tactical +// dark theme styling via CSS custom properties. +// ============================================================================ + +import { + ChangeDetectionStrategy, + ChangeDetectorRef, + Component, + Input, + OnDestroy, + OnInit, +} from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { MatProgressBarModule } from '@angular/material/progress-bar'; + +/** + * Displays a determinate progress bar with an optional elapsed time indicator. + * + * Usage: + * ```html + * + * + * ``` + */ +@Component({ + selector: 'app-task-progress-bar', + standalone: true, + imports: [CommonModule, MatProgressBarModule], + templateUrl: './task-progress-bar.component.html', + styleUrl: './task-progress-bar.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class TaskProgressBarComponent implements OnInit, OnDestroy { + // --------------------------------------------------------------------------- + // Inputs + // --------------------------------------------------------------------------- + + /** Current progress percentage (0–100). Required. */ + @Input({ required: true }) + progress!: number; + + /** Whether to show elapsed time next to the percentage. Defaults to false. */ + @Input() + showElapsed = false; + + // --------------------------------------------------------------------------- + // Internal state + // --------------------------------------------------------------------------- + + /** Timestamp when the component initialized — used for elapsed calculation. */ + startTime = Date.now(); + + /** Formatted elapsed time string, e.g. "2m 15s ago". */ + elapsedText = ''; + + /** Interval timer for updating the elapsed display. */ + private timer: ReturnType | null = null; + + constructor(private cdr: ChangeDetectorRef) {} + + // --------------------------------------------------------------------------- + // Lifecycle + // --------------------------------------------------------------------------- + + ngOnInit(): void { + this.updateElapsed(); + + if (this.showElapsed) { + // Update elapsed time every second + this.timer = setInterval(() => { + this.updateElapsed(); + this.cdr.markForCheck(); + }, 1000); + } + } + + ngOnDestroy(): void { + if (this.timer) { + clearInterval(this.timer); + this.timer = null; + } + } + + // --------------------------------------------------------------------------- + // Helpers + // --------------------------------------------------------------------------- + + /** Clamp progress to 0–100 for safety. */ + get clampedProgress(): number { + return Math.max(0, Math.min(100, this.progress ?? 0)); + } + + /** Recalculate the elapsed time string. */ + private updateElapsed(): void { + const elapsedMs = Date.now() - this.startTime; + const totalSeconds = Math.floor(elapsedMs / 1000); + const minutes = Math.floor(totalSeconds / 60); + const seconds = totalSeconds % 60; + + if (minutes > 0) { + this.elapsedText = `${minutes}m ${seconds}s ago`; + } else { + this.elapsedText = `${seconds}s ago`; + } + } +} \ No newline at end of file diff --git a/frontend/src/styles.scss b/frontend/src/styles.scss index af13a84..a581a28 100644 --- a/frontend/src/styles.scss +++ b/frontend/src/styles.scss @@ -48,6 +48,17 @@ html { // These are NOT part of the M3 tonal palette; they are semantic overrides. // --------------------------------------------------------------------------- :root { + // --- Tactical Dark Mode color palette (CUB-47) --- + --color-surface: #0F172A; + --color-surface-light: #1E293B; + --color-primary: #38BDF8; + --color-secondary: #2DD4BF; + --color-accent: #A78BFA; + --color-danger: #F87171; + --color-text-primary: #FFFFFF; + --color-text-secondary: #94A3B8; + --color-border: #334155; + // --- Status colors --- --status-active: #38BDF8; --status-idle: #2DD4BF; @@ -90,7 +101,7 @@ html { // Global Body Styles // --------------------------------------------------------------------------- body { - background-color: var(--cc-background); + background-color: var(--color-surface); color: var(--cc-on-surface); font-family: 'Inter', 'Roboto', sans-serif; margin: 0;