CUB-23: implement AgentCard component with status indicators, progress bar, and M3 styling

- AgentCard component with inputs: status, task, progress, sessionKey, channel, lastActivity
- Status badge colors: Active (#38BDF8), Idle (#2DD4BF), Thinking (#A78BFA), Error (#F87171)
- 4px left-border accent matching status color via CSS custom properties
- Pulse animations: Active (2s), Error (0.8s), Thinking (3s) using global styles.scss keyframes
- Task progress bar with percentage display
- Quick-Jump button using M3 FilledTonalIconButton style with state layer overlay
- Hover/focus states with M3 state layer overlay (8% primary)
- Accessibility: aria-labels for status, role=article on cards, focus-visible outlines
- Reduced-motion media query support
- Updated HubPageComponent with demo data grid layout
This commit is contained in:
cubecraft-agents[bot]
2026-04-26 01:36:55 +00:00
parent f490098af6
commit 7eaf736fc4
5 changed files with 542 additions and 11 deletions

View File

@@ -0,0 +1,71 @@
<article
class="agent-card"
[class]="'agent-card--' + status()"
[attr.aria-label]="'Agent card: ' + statusLabel() + ' status'"
[style.--agent-status-color]="statusColorVar()"
[style.--agent-status-bg]="statusBgVar()"
>
<!-- Left border accent (4px, matching status color) -->
<div class="agent-card__accent"></div>
<!-- Card body -->
<div class="agent-card__body">
<!-- Header row: status dot + status label + channel + last activity -->
<div class="agent-card__header">
<div class="agent-card__status-row">
<span
class="status-dot agent-card__dot"
[ngClass]="[statusDotClass()]"
[attr.aria-label]="statusLabel() + ' status indicator'"
role="status"
></span>
<span class="agent-card__status-label" [style.color]="statusColorVar()">
{{ statusLabel() }}
</span>
</div>
<div class="agent-card__meta">
@if (channel()) {
<span class="agent-card__channel text-mono">
<mat-icon class="agent-card__channel-icon" fontIcon="forum" [inline]="true"></mat-icon>
{{ channel() }}
</span>
}
<span class="agent-card__last-activity text-mono">{{ lastActivityText() }}</span>
</div>
</div>
<!-- Task description -->
@if (task()) {
<p class="agent-card__task">{{ task() }}</p>
}
<!-- Progress bar -->
@if (showProgress()) {
<div class="agent-card__progress">
<mat-progress-bar
mode="determinate"
[value]="progress()"
[attr.aria-label]="'Task progress: ' + progress() + '%'"
></mat-progress-bar>
<div class="agent-card__progress-info">
<span class="text-mono">{{ progress() }}%</span>
</div>
</div>
}
<!-- Footer: session key (truncated) + jump button -->
<div class="agent-card__footer">
<span class="agent-card__session text-mono" [matTooltip]="sessionKey()">
{{ sessionKey().length > 28 ? sessionKey().substring(0, 28) + '…' : sessionKey() }}
</span>
<button
mat-icon-button
class="agent-card__jump"
[attr.aria-label]="'Jump to agent session'"
matTooltip="Jump to session"
>
<mat-icon>arrow_forward</mat-icon>
</button>
</div>
</div>
</article>

View File

@@ -0,0 +1,277 @@
// ============================================================================
// Agent Card Styles — M3 Tactical Dark
// Per spec Section 7.3: Agent Card Component
// Section 7.5: Animation Specs
// ============================================================================
// ---------------------------------------------------------------------------
// 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)
// ---------------------------------------------------------------------------
.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__dot {
flex-shrink: 0;
}
.agent-card__status-label {
font-size: 14px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.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
// ---------------------------------------------------------------------------
.agent-card__progress {
display: flex;
flex-direction: column;
gap: 4px;
}
.agent-card__progress-info {
display: flex;
justify-content: flex-end;
font-size: 12px;
color: var(--cc-on-surface-variant);
}
// Override M3 progress bar track to use status color
.agent-card__progress .mat-mdc-progress-bar {
--mdc-linear-progress-active-indicator-color: var(--agent-status-color);
--mdc-linear-progress-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;
}
// ---------------------------------------------------------------------------
// Quick-Jump Button — M3 FilledTonalIconButton style
// Per spec: → Jump button using M3 FilledTonalIconButton
// ---------------------------------------------------------------------------
.agent-card__jump {
// M3 FilledTonal style: secondary container color
--mdc-icon-button-icon-color: var(--cc-on-surface);
background-color: var(--cc-surface-container-high);
border-radius: 50%;
width: 40px;
height: 40px;
flex-shrink: 0;
// State layer overlay on hover (8% primary)
&::before {
content: '';
position: absolute;
inset: 0;
border-radius: 50%;
background-color: rgba(255, 255, 255, 0.08);
opacity: 0;
transition: opacity 150ms ease;
pointer-events: none;
}
&:hover::before {
opacity: 1;
}
// Focus-visible ring
&:focus-visible {
outline: 3px solid var(--status-active);
outline-offset: 2px;
}
// Active state
&:active {
transform: scale(0.95);
}
.mat-icon {
font-size: 20px;
width: 20px;
height: 20px;
}
}
// ---------------------------------------------------------------------------
// 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
// ---------------------------------------------------------------------------
// Touch-First Targets
// ---------------------------------------------------------------------------
// Minimum touch target: 48px (M3 standard)
.agent-card__jump {
min-width: 48px;
min-height: 48px;
}
// ---------------------------------------------------------------------------
// Accessibility: Reduced Motion
// ---------------------------------------------------------------------------
@media (prefers-reduced-motion: reduce) {
.agent-card {
transition: none;
}
.agent-card__jump {
transition: none;
&:active {
transform: none;
}
}
}
// ---------------------------------------------------------------------------
// Responsive: Mobile adjustments
// ---------------------------------------------------------------------------
@media (max-width: 599px) {
.agent-card {
min-width: unset;
}
.agent-card__body {
padding: 16px;
gap: 8px;
}
}

View File

@@ -0,0 +1,120 @@
// ============================================================================
// Agent Card Component
// Per spec Section 7.3: Agent Card Component Interface
// Section 7.5: Animation Specs
// ============================================================================
import { ChangeDetectionStrategy, Component, input, computed } from '@angular/core';
import { CommonModule } from '@angular/common';
import { MatIconModule } from '@angular/material/icon';
import { MatButtonModule } from '@angular/material/button';
import { MatProgressBarModule } from '@angular/material/progress-bar';
import { MatTooltipModule } from '@angular/material/tooltip';
import { AgentStatus } from '../../models/agent.model';
/**
* AgentCard displays a single agent's status in the Command Hub grid.
*
* Inputs:
* - status: AgentStatus — current agent status
* - task: string — current task description
* - progress: number — task progress percentage (0100)
* - sessionKey: string — full session key
* - 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 and interactive elements
* - focus-visible outlines for keyboard navigation
*/
@Component({
selector: 'app-agent-card',
standalone: true,
imports: [
CommonModule,
MatIconModule,
MatButtonModule,
MatProgressBarModule,
MatTooltipModule,
],
templateUrl: './agent-card.component.html',
styleUrl: './agent-card.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class AgentCardComponent {
/** Current agent status */
readonly status = input.required<AgentStatus>();
/** Current task description */
readonly task = input<string>('');
/** Task progress percentage (0100) */
readonly progress = input<number>(0);
/** Full session key */
readonly sessionKey = input.required<string>();
/** Communication channel (e.g., "telegram") */
readonly channel = input<string>('');
/** Timestamp of last activity */
readonly lastActivity = input<Date>(new Date());
/** Map status to CSS custom property name for dynamic color binding */
readonly statusColorVar = computed(() => {
const map: Record<AgentStatus, string> = {
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<AgentStatus, string> = {
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()];
});
/** Human-readable status label */
readonly statusLabel = computed(() => {
const map: Record<AgentStatus, string> = {
active: 'Active',
idle: 'Idle',
thinking: 'Thinking',
error: 'Error',
offline: 'Offline',
};
return map[this.status()];
});
/** CSS class for status dot animation */
readonly statusDotClass = computed(() => `status-dot--${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 progress bar */
readonly showProgress = computed(() => {
const s = this.status();
return (s === 'active' || s === 'thinking') && this.progress() > 0;
});
}

View File

@@ -0,0 +1 @@
export { AgentCardComponent } from './agent-card.component';

View File

@@ -1,26 +1,88 @@
import { ChangeDetectionStrategy, Component } from '@angular/core';
import { ChangeDetectionStrategy, Component, signal } from '@angular/core';
import { AgentCardComponent } from '../../components/agent-card/agent-card.component';
import { AgentCardData } from '../../models/agent.model';
@Component({
selector: 'app-hub-page',
standalone: true,
imports: [],
imports: [AgentCardComponent],
template: `
<div class="hub-page">
<p class="hub-page__placeholder">Command Hub — Fleet status grid will render here</p>
<div class="hub-page__grid">
@for (agent of agents(); track agent.id) {
<app-agent-card
[status]="agent.status"
[task]="agent.currentTask ?? ''"
[progress]="agent.taskProgress ?? 0"
[sessionKey]="agent.sessionKey"
[channel]="agent.channel"
[lastActivity]="agent.lastActivity"
/>
}
</div>
</div>
`,
styles: [`
.hub-page {
display: flex;
align-items: center;
justify-content: center;
min-height: 400px;
padding: var(--cc-section-padding, 24px);
}
.hub-page__placeholder {
color: var(--cc-on-surface-variant);
font-size: 16px;
.hub-page__grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(var(--cc-card-min-width, 320px), 1fr));
gap: var(--cc-card-gap, 16px);
}
`],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class HubPageComponent {}
export class HubPageComponent {
/** Demo agents for development — will be replaced by SignalR data */
protected readonly agents = signal<AgentCardData[]>([
{
id: 'otto',
displayName: 'Otto',
role: 'Orchestrator',
status: 'active',
currentTask: 'Reviewing PR #42',
taskProgress: 65,
taskElapsed: '04m 12s',
sessionKey: 'agent:otto:telegram:direct:8787451565',
channel: 'telegram',
lastActivity: new Date(),
},
{
id: 'rex',
displayName: 'Rex',
role: 'Frontend Specialist',
status: 'thinking',
currentTask: 'Building AgentCard component',
taskProgress: 30,
taskElapsed: '02m 45s',
sessionKey: 'agent:rex:subagent:0cdbf600',
channel: 'telegram',
lastActivity: new Date(Date.now() - 120000),
},
{
id: 'dex',
displayName: 'Dex',
role: 'Backend Engineer',
status: 'idle',
sessionKey: 'agent:dex:slack:channel:C01234567',
channel: 'slack',
lastActivity: new Date(Date.now() - 300000),
},
{
id: 'hex',
displayName: 'Hex',
role: 'Database Architect',
status: 'error',
currentTask: 'Migration failed — rollback',
taskProgress: 0,
taskElapsed: '00m 00s',
sessionKey: 'agent:hex:slack:channel:C01234568',
channel: 'slack',
lastActivity: new Date(Date.now() - 1800000),
errorMessage: 'Connection timeout',
},
]);
}