Merge branch 'dev' into agent/rex/CUB-51-quick-jump-drawer

This commit is contained in:
Joshua King
2026-04-27 17:54:48 -04:00
33 changed files with 2277 additions and 3 deletions
@@ -0,0 +1,112 @@
<!-- ======================================================================== -->
<!-- Adaptive Navigation — Desktop sidebar / Mobile header -->
<!-- Desktop (≥768px): 72px sidebar with full navigation items -->
<!-- Mobile (<768px): 56px compact header with hamburger menu -->
<!-- ======================================================================== -->
<!-- ======================= DESKTOP SIDEBAR (≥768px) ======================= -->
<aside class="adaptive-nav__sidebar" aria-label="Navigation sidebar">
<!-- Brand / Toggle header -->
<div class="adaptive-nav__sidebar-header">
<span class="adaptive-nav__brand">OC</span>
</div>
<!-- Navigation destinations -->
<nav class="adaptive-nav__sidebar-nav">
@for (dest of destinations; track dest.route) {
<a
class="adaptive-nav__sidebar-item"
[routerLink]="dest.route"
routerLinkActive="adaptive-nav__sidebar-item--active"
[attr.aria-label]="dest.label"
>
<mat-icon
[matBadge]="dest.badge ?? 0"
[matBadgeHidden]="!dest.badge"
matBadgePosition="above after"
matBadgeSize="small"
>
{{ dest.icon }}
</mat-icon>
<span class="adaptive-nav__sidebar-label">{{ dest.label }}</span>
</a>
}
</nav>
<!-- Sidebar footer: LIVE indicator + action buttons -->
<div class="adaptive-nav__sidebar-footer">
<div class="adaptive-nav__live" [class.adaptive-nav__live--connected]="isConnected()">
<span
class="adaptive-nav__live-dot"
[class.adaptive-nav__live-dot--connected]="isConnected()"
></span>
<mat-chip
class="adaptive-nav__live-chip"
[highlighted]="isConnected()"
[disabled]="!isConnected()"
>
{{ isConnected() ? 'LIVE' : 'OFFLINE' }}
</mat-chip>
</div>
<!-- Action buttons (placeholder) -->
<div class="adaptive-nav__sidebar-actions">
<button mat-icon-button aria-label="Notifications">
<mat-icon>notifications</mat-icon>
</button>
<button mat-icon-button aria-label="Settings">
<mat-icon>settings</mat-icon>
</button>
</div>
</div>
</aside>
<!-- ======================= MOBILE HEADER (<768px) ======================== -->
<header class="adaptive-nav__mobile-header" role="banner">
<!-- Hamburger menu button -->
<button
class="adaptive-nav__hamburger"
mat-icon-button
[attr.aria-label]="mobileMenuOpen() ? 'Close menu' : 'Open menu'"
[attr.aria-expanded]="mobileMenuOpen()"
(click)="toggleMobileMenu()"
>
<mat-icon>{{ mobileMenuOpen() ? 'close' : 'menu' }}</mat-icon>
</button>
<!-- Title -->
<h1 class="adaptive-nav__mobile-title">Command Hub</h1>
<!-- LIVE indicator (always visible on mobile) -->
<div class="adaptive-nav__live adaptive-nav__live--mobile" [class.adaptive-nav__live--connected]="isConnected()">
<span
class="adaptive-nav__live-dot"
[class.adaptive-nav__live-dot--connected]="isConnected()"
></span>
<span class="adaptive-nav__live-text">{{ isConnected() ? 'LIVE' : 'OFFLINE' }}</span>
</div>
<!-- Mobile action buttons (placeholder) -->
<button class="adaptive-nav__mobile-action" mat-icon-button aria-label="Notifications">
<mat-icon>notifications</mat-icon>
</button>
</header>
<!-- ======================= MOBILE DRAWER OVERLAY ========================= -->
@if (mobileMenuOpen()) {
<div class="adaptive-nav__overlay" (click)="closeMobileMenu()" role="presentation"></div>
<nav class="adaptive-nav__mobile-drawer" aria-label="Mobile navigation menu">
@for (dest of destinations; track dest.route) {
<a
class="adaptive-nav__drawer-item"
[routerLink]="dest.route"
routerLinkActive="adaptive-nav__drawer-item--active"
[attr.aria-label]="dest.label"
(click)="closeMobileMenu()"
>
<mat-icon>{{ dest.icon }}</mat-icon>
<span class="adaptive-nav__drawer-label">{{ dest.label }}</span>
</a>
}
</nav>
}
@@ -0,0 +1,316 @@
// ============================================================================
// Adaptive Navigation — Desktop sidebar / Mobile header
// Desktop (≥768px): 72px sidebar with full navigation items
// Mobile (<768px): 56px compact header with hamburger menu
// ============================================================================
// ---------------------------------------------------------------------------
// Desktop Sidebar (visible ≥768px)
// ---------------------------------------------------------------------------
.adaptive-nav__sidebar {
display: flex;
flex-direction: column;
width: var(--cc-nav-rail-collapsed-width, 72px);
min-height: 100vh;
background-color: var(--cc-surface-container-high);
border-right: 1px solid var(--cc-outline);
z-index: 10;
}
.adaptive-nav__sidebar-header {
display: flex;
align-items: center;
justify-content: center;
height: 64px;
border-bottom: 1px solid var(--cc-outline);
}
.adaptive-nav__brand {
font-size: 18px;
font-weight: 700;
color: var(--status-active);
letter-spacing: 0.04em;
}
.adaptive-nav__sidebar-nav {
flex: 1;
padding-top: 8px;
}
.adaptive-nav__sidebar-item {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 4px;
min-height: 56px;
padding: 8px 0;
margin: 2px 8px;
border-radius: 28px;
color: var(--cc-on-surface-variant);
text-decoration: none;
transition: background-color 150ms ease, color 150ms ease;
&:hover {
background-color: rgba(255, 255, 255, 0.08);
color: var(--cc-on-surface);
}
&--active {
background-color: var(--status-active-bg);
color: var(--status-active);
.adaptive-nav__sidebar-label {
font-weight: 500;
}
}
}
.adaptive-nav__sidebar-label {
font-size: 11px;
font-weight: 400;
letter-spacing: 0.02em;
white-space: nowrap;
}
// ---------------------------------------------------------------------------
// Sidebar Footer — LIVE indicator + action buttons
// ---------------------------------------------------------------------------
.adaptive-nav__sidebar-footer {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
padding: 12px 0 20px;
border-top: 1px solid var(--cc-outline);
}
.adaptive-nav__sidebar-actions {
display: flex;
gap: 4px;
.mat-mdc-icon-button {
color: var(--cc-on-surface-variant) !important;
--mdc-icon-button-icon-size: 20px;
&:hover {
color: var(--cc-on-surface) !important;
}
}
}
// ---------------------------------------------------------------------------
// LIVE Status Indicator
// ---------------------------------------------------------------------------
.adaptive-nav__live {
display: flex;
align-items: center;
gap: 6px;
padding: 4px 8px;
border-radius: 16px;
transition: background-color 200ms ease;
&--connected {
background-color: var(--status-active-bg);
}
}
.adaptive-nav__live-dot {
display: inline-block;
width: 8px;
height: 8px;
min-width: 8px;
border-radius: 50%;
background-color: var(--status-error);
transition: background-color 200ms ease;
&--connected {
background-color: var(--status-active);
animation: pulse-active 2s ease-in-out infinite;
}
}
.adaptive-nav__live-chip {
font-size: 11px !important;
font-weight: 600 !important;
letter-spacing: 0.06em;
height: 24px !important;
min-height: 24px !important;
padding: 0 8px !important;
color: var(--status-active) !important;
--mdc-chip-elevated-container-color: transparent;
background: transparent !important;
border: none !important;
box-shadow: none !important;
}
.adaptive-nav__live-text {
font-size: 12px;
font-weight: 600;
letter-spacing: 0.06em;
color: var(--status-active);
}
// ---------------------------------------------------------------------------
// Mobile Header (visible <768px)
// ---------------------------------------------------------------------------
.adaptive-nav__mobile-header {
display: none; // Hidden on desktop, shown on mobile via media query
align-items: center;
height: 56px;
padding: 0 12px;
background-color: var(--cc-surface-container-high);
border-bottom: 1px solid var(--cc-outline);
z-index: 20;
gap: 8px;
}
.adaptive-nav__hamburger {
color: var(--cc-on-surface-variant) !important;
&:hover {
color: var(--cc-on-surface) !important;
}
}
.adaptive-nav__mobile-title {
flex: 1;
font-size: 20px;
font-weight: 500;
color: var(--cc-on-surface);
margin: 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.adaptive-nav__live--mobile {
padding: 4px 10px;
border-radius: 16px;
.adaptive-nav__live-text {
font-size: 11px;
font-weight: 700;
}
}
.adaptive-nav__mobile-action {
color: var(--cc-on-surface-variant) !important;
&:hover {
color: var(--cc-on-surface) !important;
}
}
// ---------------------------------------------------------------------------
// Mobile Drawer
// ---------------------------------------------------------------------------
.adaptive-nav__overlay {
position: fixed;
inset: 0;
background-color: rgba(0, 0, 0, 0.5);
z-index: 40;
}
.adaptive-nav__mobile-drawer {
position: fixed;
top: 56px; // Below header
left: 0;
bottom: 0;
width: 280px;
max-width: 80vw;
background-color: var(--cc-surface-container);
border-right: 1px solid var(--cc-outline);
z-index: 50;
padding: 12px 0;
overflow-y: auto;
animation: slide-in-left 200ms cubic-bezier(0.4, 0, 0.2, 1);
}
.adaptive-nav__drawer-item {
display: flex;
align-items: center;
gap: 16px;
min-height: 48px;
padding: 0 20px;
color: var(--cc-on-surface-variant);
text-decoration: none;
transition: background-color 150ms ease, color 150ms ease;
&:hover {
background-color: rgba(255, 255, 255, 0.08);
color: var(--cc-on-surface);
}
&--active {
background-color: var(--status-active-bg);
color: var(--status-active);
.adaptive-nav__drawer-label {
font-weight: 500;
}
}
}
.adaptive-nav__drawer-label {
font-size: 14px;
font-weight: 400;
white-space: nowrap;
}
// ---------------------------------------------------------------------------
// Drawer slide-in animation
// ---------------------------------------------------------------------------
@keyframes slide-in-left {
from {
transform: translateX(-100%);
}
to {
transform: translateX(0);
}
}
// ---------------------------------------------------------------------------
// Media Queries — Layout Switch
// ---------------------------------------------------------------------------
// Desktop (≥768px): Show sidebar, hide mobile header
// Mobile (<768px): Hide sidebar, show compact header
// ---------------------------------------------------------------------------
@media (min-width: 768px) {
.adaptive-nav__sidebar {
display: flex;
}
.adaptive-nav__mobile-header {
display: none;
}
// Hide mobile drawer and overlay on desktop
.adaptive-nav__overlay,
.adaptive-nav__mobile-drawer {
display: none;
}
}
@media (max-width: 767px) {
.adaptive-nav__sidebar {
display: none;
}
.adaptive-nav__mobile-header {
display: flex;
}
}
// ---------------------------------------------------------------------------
// Accessibility: Reduced Motion
// ---------------------------------------------------------------------------
@media (prefers-reduced-motion: reduce) {
.adaptive-nav__live-dot--connected {
animation: none;
}
.adaptive-nav__mobile-drawer {
animation: none;
}
}
@@ -0,0 +1,53 @@
import { ChangeDetectionStrategy, Component, signal } from '@angular/core';
import { RouterLink, RouterLinkActive } from '@angular/router';
import { MatIconModule } from '@angular/material/icon';
import { MatButtonModule } from '@angular/material/button';
import { MatChipsModule } from '@angular/material/chips';
import { MatBadgeModule } from '@angular/material/badge';
import { NAV_DESTINATIONS } from '../../models/nav.model';
/**
* Adaptive Navigation Component — switches between desktop sidebar
* and mobile header layouts using CSS media queries.
*
* Desktop (≥768px): 72px sidebar with full navigation items.
* Mobile (<768px): 56px compact header with hamburger menu.
*
* The LIVE status indicator is visible in both layouts.
* Per spec Section 3.1 (kiosk) and 3.2 (mobile).
*/
@Component({
selector: 'app-adaptive-navigation',
standalone: true,
imports: [
RouterLink,
RouterLinkActive,
MatIconModule,
MatButtonModule,
MatChipsModule,
MatBadgeModule,
],
templateUrl: './adaptive-navigation.component.html',
styleUrl: './adaptive-navigation.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class AdaptiveNavigationComponent {
/** Navigation destinations shared with other nav components */
protected readonly destinations = NAV_DESTINATIONS;
/** Whether the mobile drawer is open */
protected readonly mobileMenuOpen = signal(false);
/** Live connection status */
protected readonly isConnected = signal(true);
/** Toggle mobile menu */
toggleMobileMenu(): void {
this.mobileMenuOpen.update((v) => !v);
}
/** Close mobile menu (e.g. on nav) */
closeMobileMenu(): void {
this.mobileMenuOpen.set(false);
}
}
@@ -0,0 +1 @@
export * from './adaptive-navigation.component';
@@ -0,0 +1,8 @@
<span class="badge"
[class]="statusClass"
[class.badge--pulse]="hasPulse"
[attr.aria-label]="'Agent status: ' + displayLabel"
role="status">
<span class="badge__dot"></span>
<span class="badge__label">{{ displayLabel }}</span>
</span>
@@ -0,0 +1,146 @@
// ============================================================================
// Agent Status Badge — per spec Section 7.3
// Colored pill with dot indicator and optional pulse animation.
// ============================================================================
$badge-height: 24px;
$dot-size: 8px;
$border-radius: 12px;
$font-size: 12px;
$font-weight: 500;
$padding-x: 8px;
$gap: 6px;
@use 'sass:color';
// Status color palette
$color-active: #22c55e; // green-500
$color-idle: #9ca3af; // gray-400
$color-thinking: #3b82f6; // blue-500
$color-error: #ef4444; // red-500
$color-offline: #9ca3af; // gray-400
// Background tints (12% opacity for soft pill background)
$bg-active: rgba($color-active, 0.12);
$bg-idle: rgba($color-idle, 0.12);
$bg-thinking: rgba($color-thinking, 0.12);
$bg-error: rgba($color-error, 0.12);
$bg-offline: rgba($color-offline, 0.12);
// ---------------------------------------------------------------------------
// Base pill
// ---------------------------------------------------------------------------
.badge {
display: inline-flex;
align-items: center;
height: $badge-height;
padding: 0 $padding-x;
border-radius: $border-radius;
gap: $gap;
font-size: $font-size;
font-weight: $font-weight;
line-height: 1;
white-space: nowrap;
user-select: none;
}
// ---------------------------------------------------------------------------
// Dot indicator
// ---------------------------------------------------------------------------
.badge__dot {
width: $dot-size;
height: $dot-size;
border-radius: 50%;
flex-shrink: 0;
}
// ---------------------------------------------------------------------------
// Label text
// ---------------------------------------------------------------------------
.badge__label {
line-height: 1;
}
// ---------------------------------------------------------------------------
// Status color variants
// ---------------------------------------------------------------------------
.badge--active {
background: $bg-active;
color: color.adjust($color-active, $lightness: -10%);
.badge__dot {
background: $color-active;
}
}
.badge--idle {
background: $bg-idle;
color: color.adjust($color-idle, $lightness: -15%);
.badge__dot {
background: $color-idle;
}
}
.badge--thinking {
background: $bg-thinking;
color: color.adjust($color-thinking, $lightness: -10%);
.badge__dot {
background: $color-thinking;
}
}
.badge--error {
background: $bg-error;
color: color.adjust($color-error, $lightness: -10%);
.badge__dot {
background: $color-error;
}
}
.badge--offline {
background: $bg-offline;
color: color.adjust($color-offline, $lightness: -15%);
.badge__dot {
background: $color-offline;
}
}
// ---------------------------------------------------------------------------
// Pulse animation — applied when status is active, thinking, or error
// ---------------------------------------------------------------------------
.badge--pulse {
.badge__dot {
animation: pulse-dot 2s ease-in-out infinite;
}
}
// Active: 2s pulse
.badge--active.badge--pulse .badge__dot {
animation-duration: 2s;
}
// Thinking: 3s pulse
.badge--thinking.badge--pulse .badge__dot {
animation-duration: 3s;
}
// Error: 0.8s pulse (fast, urgent)
.badge--error.badge--pulse .badge__dot {
animation-duration: 0.8s;
}
@keyframes pulse-dot {
0%,
100% {
opacity: 1;
transform: scale(1);
}
50% {
opacity: 0.4;
transform: scale(1.5);
}
}
@@ -0,0 +1,54 @@
import { ChangeDetectionStrategy, Component, input } from '@angular/core';
import { AgentStatus } from '../../models/agent.model';
/**
* Agent Status Badge component.
* Displays a colored pill with a pulse animation indicating the agent's current status.
* Per spec Section 7.3: Agent Card Component Interface — status indicator.
*
* Color mapping:
* - Active → green
* - Idle → gray
* - Thinking → blue
* - Error → red
* - Offline → gray (no pulse)
*
* Pulse animations:
* - Active → 2s
* - Error → 0.8s
* - Thinking → 3s
* - Idle / Offline → no pulse
*/
@Component({
selector: 'app-agent-status-badge',
standalone: true,
imports: [],
templateUrl: './agent-status-badge.component.html',
styleUrl: './agent-status-badge.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class AgentStatusBadgeComponent {
/** Current agent status — binds to the AgentStatus type from the model. */
readonly status = input.required<AgentStatus>();
/** Label text shown inside the badge. Defaults to title-cased status. */
readonly label = input<string>();
get displayLabel(): string {
return this.label() ?? this.titleCase(this.status());
}
/** CSS class driven by the current status value. */
get statusClass(): string {
return `badge--${this.status()}`;
}
/** Whether the pulse animation should be active for the current status. */
get hasPulse(): boolean {
return this.status() === 'active' || this.status() === 'thinking' || this.status() === 'error';
}
private titleCase(value: string): string {
return value.charAt(0).toUpperCase() + value.slice(1);
}
}
@@ -0,0 +1 @@
export { AgentStatusBadgeComponent } from './agent-status-badge.component';
@@ -0,0 +1,31 @@
<!-- Backdrop overlay — click to dismiss -->
<div class="global-action-modal__backdrop" #backdrop (click)="onBackdropClick()"></div>
<!-- Modal panel -->
<div class="global-action-modal__panel" (click)="onModalClick($event)" role="dialog" aria-modal="true" aria-label="Global Actions">
<!-- Header -->
<div class="global-action-modal__header">
<h2 class="global-action-modal__title">Global Actions</h2>
<button matIconButton
class="global-action-modal__close"
aria-label="Close modal"
(click)="onClose()">
<mat-icon>close</mat-icon>
</button>
</div>
<!-- Action grid -->
<div class="global-action-modal__actions">
@for (action of actions; track action.key) {
<button class="global-action-modal__action-btn global-action-modal__action-btn--{{ action.color }}"
(click)="onAction(action)">
<div class="global-action-modal__action-icon">
<mat-icon>{{ action.icon }}</mat-icon>
</div>
<span class="global-action-modal__action-label">{{ action.label }}</span>
<span class="global-action-modal__action-desc">{{ action.description }}</span>
</button>
}
</div>
</div>
@@ -0,0 +1,198 @@
// ============================================================================
// Global Action Modal — Tactical Dark Mode Styling
// Uses Control Center design tokens from styles.scss
// ============================================================================
// ---------------------------------------------------------------------------
// Backdrop
// ---------------------------------------------------------------------------
:host {
display: block;
position: fixed;
inset: 0;
z-index: 1000;
}
.global-action-modal__backdrop {
position: absolute;
inset: 0;
background: rgba(0, 0, 0, 0.6);
backdrop-filter: blur(4px);
}
// ---------------------------------------------------------------------------
// Modal Panel
// ---------------------------------------------------------------------------
.global-action-modal__panel {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: min(560px, calc(100vw - 48px));
background: var(--cc-surface-container);
border: 1px solid var(--cc-outline);
border-radius: var(--cc-card-border-radius);
box-shadow: 0 24px 48px rgba(0, 0, 0, 0.5);
overflow: hidden;
}
// ---------------------------------------------------------------------------
// Header
// ---------------------------------------------------------------------------
.global-action-modal__header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 20px 24px 12px;
}
.global-action-modal__title {
margin: 0;
font-size: 20px;
font-weight: 600;
color: var(--cc-on-surface);
letter-spacing: 0.01em;
}
.global-action-modal__close {
--mat-icon-button-state-layer-color: transparent;
color: var(--cc-on-surface-variant);
&:hover {
color: var(--cc-on-surface);
}
}
// ---------------------------------------------------------------------------
// Action Grid
// ---------------------------------------------------------------------------
.global-action-modal__actions {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
padding: 12px 24px 24px;
}
// ---------------------------------------------------------------------------
// Action Button
// ---------------------------------------------------------------------------
.global-action-modal__action-btn {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
padding: 20px 16px;
border: 1px solid var(--cc-outline);
border-radius: 12px;
background: var(--cc-surface);
color: var(--cc-on-surface);
cursor: pointer;
transition: background 150ms ease, border-color 150ms ease, transform 100ms ease;
font-family: inherit;
text-align: center;
&:hover {
background: var(--cc-surface-container-high);
border-color: var(--cc-on-surface-variant);
transform: translateY(-1px);
}
&:active {
transform: translateY(0);
}
&:focus-visible {
outline: 2px solid var(--mat-sys-primary, #38BDF8);
outline-offset: 2px;
}
}
// Action icon wrapper
.global-action-modal__action-icon {
width: 48px;
height: 48px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 12px;
.mat-icon {
width: 28px;
height: 28px;
font-size: 28px;
}
}
// Action label
.global-action-modal__action-label {
font-size: 15px;
font-weight: 600;
letter-spacing: 0.01em;
}
// Action description
.global-action-modal__action-desc {
font-size: 12px;
color: var(--cc-on-surface-variant);
line-height: 1.4;
}
// ---------------------------------------------------------------------------
// Color Variants — per-action accent colors
// ---------------------------------------------------------------------------
.global-action-modal__action-btn--deploy {
.global-action-modal__action-icon {
background: var(--status-active-bg);
color: var(--status-active);
}
&:hover {
border-color: var(--status-active);
}
}
.global-action-modal__action-btn--pause {
.global-action-modal__action-icon {
background: var(--status-idle-bg);
color: var(--status-idle);
}
&:hover {
border-color: var(--status-idle);
}
}
.global-action-modal__action-btn--emergency {
.global-action-modal__action-icon {
background: var(--status-error-bg);
color: var(--status-error);
}
&:hover {
border-color: var(--status-error);
}
.global-action-modal__action-label {
color: var(--status-error);
}
}
.global-action-modal__action-btn--add {
.global-action-modal__action-icon {
background: var(--status-thinking-bg);
color: var(--status-thinking);
}
&:hover {
border-color: var(--status-thinking);
}
}
// ---------------------------------------------------------------------------
// Responsive — stack single column on narrow viewports
// ---------------------------------------------------------------------------
@media (max-width: 400px) {
.global-action-modal__actions {
grid-template-columns: 1fr;
}
}
@@ -0,0 +1,87 @@
import { ChangeDetectionStrategy, Component, ElementRef, EventEmitter, Output, ViewChild } from '@angular/core';
import { MatIconModule } from '@angular/material/icon';
import { MatButtonModule } from '@angular/material/button';
/**
* Global Action Modal — overlay for fleet-wide commands.
*
* Four main actions: Deploy All, Pause All, Emergency Stop, Add Agent.
* Tactical Dark Mode styling using Control Center design tokens.
* Dismisses on backdrop click or close button.
*/
@Component({
selector: 'app-global-action-modal',
standalone: true,
imports: [MatIconModule, MatButtonModule],
templateUrl: './global-action-modal.component.html',
styleUrl: './global-action-modal.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class GlobalActionModalComponent {
/** Emitted when any action button is clicked. Payload is the action key. */
@Output() readonly actionSelected = new EventEmitter<GlobalAction>();
/** Emitted when the modal is dismissed (backdrop click or close button). */
@Output() readonly dismissed = new EventEmitter<void>();
@ViewChild('backdrop') backdropEl!: ElementRef<HTMLElement>;
/** All available global actions. */
readonly actions: GlobalActionDef[] = [
{
key: 'deploy-all',
label: 'Deploy All',
description: 'Deploy all agents in the fleet',
icon: 'rocket_launch',
color: 'deploy',
},
{
key: 'pause-all',
label: 'Pause All',
description: 'Pause all running agents',
icon: 'pause_circle',
color: 'pause',
},
{
key: 'emergency-stop',
label: 'Emergency Stop',
description: 'Immediately halt all agents',
icon: 'emergency',
color: 'emergency',
},
{
key: 'add-agent',
label: 'Add Agent',
description: 'Register a new agent to the fleet',
icon: 'person_add',
color: 'add',
},
];
onBackdropClick(): void {
this.dismissed.emit();
}
onModalClick(event: Event): void {
// Prevent clicks inside the modal panel from closing it
event.stopPropagation();
}
onClose(): void {
this.dismissed.emit();
}
onAction(action: GlobalActionDef): void {
this.actionSelected.emit(action.key);
}
}
export type GlobalAction = 'deploy-all' | 'pause-all' | 'emergency-stop' | 'add-agent';
export interface GlobalActionDef {
key: GlobalAction;
label: string;
description: string;
icon: string;
color: 'deploy' | 'pause' | 'emergency' | 'add';
}
+2
View File
@@ -1 +1,3 @@
export * from './quick-jump-button/quick-jump-button.component';
export { AgentStatusBadgeComponent } from './agent-status-badge/agent-status-badge.component';
export { QuickJumpDrawerComponent } from './quick-jump-drawer/index';
@@ -0,0 +1,8 @@
<button
mat-icon-button
class="quick-jump-button"
[attr.aria-label]="'Jump to agent session'"
(click)="onJumpClick()"
>
<mat-icon>arrow_forward</mat-icon>
</button>
@@ -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);
}
}
@@ -0,0 +1,32 @@
import { ChangeDetectionStrategy, Component, EventEmitter, 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<string>();
/** The session key to navigate to. Set by the parent agent card. */
sessionKey = '';
onJumpClick(): void {
this.jumpClick.emit(this.sessionKey);
}
}
@@ -0,0 +1,6 @@
// ============================================================================
// Task Progress Bar — Barrel Export
// CUB-44
// ============================================================================
export { TaskProgressBarComponent } from './task-progress-bar.component';
@@ -0,0 +1,18 @@
<!-- Task Progress Bar: determinate progress with optional elapsed time -->
<div class="task-progress-bar">
<!-- Info row: percentage + optional elapsed -->
<div class="task-progress-bar__info">
<span class="task-progress-bar__percent">{{ clampedProgress }}%</span>
<span *ngIf="showElapsed" class="task-progress-bar__elapsed">
{{ elapsedText }}
</span>
</div>
<!-- Angular Material determinate progress bar -->
<mat-progress-bar
class="task-progress-bar__bar"
mode="determinate"
[value]="clampedProgress"
aria-label="Task progress"
></mat-progress-bar>
</div>
@@ -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;
}
@@ -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
* <app-task-progress-bar [progress]="65" />
* <app-task-progress-bar [progress]="42" [showElapsed]="true" />
* ```
*/
@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 (0100). 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<typeof setInterval> | 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 0100 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`;
}
}
}