diff --git a/frontend/src/app/app.routes.ts b/frontend/src/app/app.routes.ts index b3066bd..52e85b0 100644 --- a/frontend/src/app/app.routes.ts +++ b/frontend/src/app/app.routes.ts @@ -5,6 +5,7 @@ import { ProjectsPageComponent } from './pages/projects/projects-page.component' import { SessionsPageComponent } from './pages/sessions/sessions-page.component'; import { LogsPageComponent } from './pages/logs/logs-page.component'; import { SettingsPageComponent } from './pages/settings/settings-page.component'; +import { BreakroomPageComponent } from './pages/breakroom/breakroom-page.component'; export const routes: Routes = [ { @@ -17,6 +18,7 @@ export const routes: Routes = [ { path: 'sessions', component: SessionsPageComponent }, { path: 'logs', component: LogsPageComponent }, { path: 'settings', component: SettingsPageComponent }, + { path: 'breakroom', component: BreakroomPageComponent }, ], }, ]; \ No newline at end of file diff --git a/frontend/src/app/components/minion/minion.component.html b/frontend/src/app/components/minion/minion.component.html new file mode 100644 index 0000000..4743017 --- /dev/null +++ b/frontend/src/app/components/minion/minion.component.html @@ -0,0 +1,120 @@ + + + \ No newline at end of file diff --git a/frontend/src/app/components/minion/minion.component.scss b/frontend/src/app/components/minion/minion.component.scss new file mode 100644 index 0000000..cb39a8b --- /dev/null +++ b/frontend/src/app/components/minion/minion.component.scss @@ -0,0 +1,747 @@ +// ============================================================================ +// Minion Component Styles — 16-bit Breakroom Animation System +// Per CUB-60: Minion State & Animation System +// ============================================================================ + +// --------------------------------------------------------------------------- +// Pixel Art Scale & Dimensions +// --------------------------------------------------------------------------- +$pixel: 4px; // Base pixel unit for 16-bit aesthetic +$minion-width: 48px; +$minion-height: 64px; +$head-size: 24px; +$body-width: 32px; +$body-height: 24px; +$leg-width: 8px; +$leg-height: 16px; +$arm-width: 6px; +$arm-height: 16px; + +// --------------------------------------------------------------------------- +// Colors — Retro Palette +// --------------------------------------------------------------------------- +$minion-skin: #FFD93D; +$minion-skin-shadow: #E6B800; +$minion-overalls: #4169E1; +$minion-overalls-shadow: #2E4FA0; +$minion-goggle: #C0C0C0; +$minion-goggle-strap: #333333; +$minion-eye: #FFFFFF; +$minion-pupil: #1A1A1A; +$minion-mouth: #8B0000; +$minion-banana: #FFE135; +$minion-banana-shadow: #DAA520; +$minion-laptop: #2D2D2D; +$minion-laptop-screen: #0A1628; +$minion-laptop-code: #38BDF8; + +$walk-duration: 2s; +$type-duration: 0.4s; +$eat-duration: 1.2s; +$return-duration: 2s; + +// --------------------------------------------------------------------------- +// Keyframe Animations +// --------------------------------------------------------------------------- + +// Walking animation — bounce + translate right +@keyframes minionWalkRight { + 0% { + transform: translateX(0) translateY(0); + } + 12.5% { + transform: translateX(12.5%) translateY(-3px); + } + 25% { + transform: translateX(25%) translateY(0); + } + 37.5% { + transform: translateX(37.5%) translateY(-3px); + } + 50% { + transform: translateX(50%) translateY(0); + } + 62.5% { + transform: translateX(62.5%) translateY(-3px); + } + 75% { + transform: translateX(75%) translateY(0); + } + 87.5% { + transform: translateX(87.5%) translateY(-3px); + } + 100% { + transform: translateX(100%) translateY(0); + } +} + +// Returning animation — bounce + translate left +@keyframes minionWalkLeft { + 0% { + transform: translateX(0) translateY(0); + } + 12.5% { + transform: translateX(-12.5%) translateY(-3px); + } + 25% { + transform: translateX(-25%) translateY(0); + } + 37.5% { + transform: translateX(-37.5%) translateY(-3px); + } + 50% { + transform: translateX(-50%) translateY(0); + } + 62.5% { + transform: translateX(-62.5%) translateY(-3px); + } + 75% { + transform: translateX(-75%) translateY(0); + } + 87.5% { + transform: translateX(-87.5%) translateY(-3px); + } + 100% { + transform: translateX(-100%) translateY(0); + } +} + +// Typing animation — alternate arm bob +@keyframes minionType { + 0%, 100% { + transform: translateY(0); + } + 50% { + transform: translateY(-2px); + } +} + +// Arm swing during walking +@keyframes armSwing { + 0%, 100% { + transform: rotate(0deg); + } + 25% { + transform: rotate(25deg); + } + 75% { + transform: rotate(-25deg); + } +} + +// Alternate arm swing (opposite phase) +@keyframes armSwingAlt { + 0%, 100% { + transform: rotate(0deg); + } + 25% { + transform: rotate(-25deg); + } + 75% { + transform: rotate(25deg); + } +} + +// Leg walk cycle +@keyframes legWalk { + 0%, 100% { + transform: translateY(0); + } + 50% { + transform: translateY(-4px); + } +} + +// Alternate leg walk cycle +@keyframes legWalkAlt { + 0%, 100% { + transform: translateY(-4px); + } + 50% { + transform: translateY(0); + } +} + +// Banana eating — arm to mouth bob +@keyframes bananaEat { + 0%, 100% { + transform: rotate(-15deg) translateY(0); + } + 50% { + transform: rotate(-15deg) translateY(-4px); + } +} + +// Mouth chewing +@keyframes mouthChew { + 0%, 40%, 100% { + transform: scaleY(1); + } + 20% { + transform: scaleY(0.6); + } +} + +// Happy mouth (returning) +@keyframes mouthHappy { + 0%, 100% { + transform: scaleY(1); + } + 50% { + transform: scaleY(1.15); + } +} + +// Laptop code blink +@keyframes codeBlink { + 0%, 40%, 100% { + opacity: 1; + } + 50% { + opacity: 0.3; + } +} + +// Determined face blink +@keyframes eyeBlink { + 0%, 90%, 100% { + transform: scaleY(1); + } + 95% { + transform: scaleY(0.1); + } +} + +// Idle float (subtle bounce) +@keyframes idleFloat { + 0%, 100% { + transform: translateY(0); + } + 50% { + transform: translateY(-2px); + } +} + +// --------------------------------------------------------------------------- +// Minion Root Container +// --------------------------------------------------------------------------- +.minion { + display: flex; + flex-direction: column; + align-items: center; + position: relative; + width: 80px; + image-rendering: pixelated; +} + +// --------------------------------------------------------------------------- +// Progress Bar (above head) +// --------------------------------------------------------------------------- +.minion__progress-bar { + width: 60px; + margin-bottom: 4px; + + .mat-mdc-progress-bar { + --mdc-linear-progress-active-indicator-color: var(--status-active); + --mdc-linear-progress-track-color: var(--cc-surface-container-high); + height: 4px; + border-radius: 2px; + } +} + +// --------------------------------------------------------------------------- +// Name Label +// --------------------------------------------------------------------------- +.minion__label { + font-size: 11px; + font-weight: 600; + color: var(--cc-on-surface); + text-transform: uppercase; + letter-spacing: 0.05em; + margin-bottom: 2px; + white-space: nowrap; + text-overflow: ellipsis; + overflow: hidden; + max-width: 80px; + text-align: center; +} + +// --------------------------------------------------------------------------- +// Sprite Container +// --------------------------------------------------------------------------- +.minion__sprite { + position: relative; + width: $minion-width; + height: $minion-height; + overflow: visible; +} + +// --------------------------------------------------------------------------- +// Character Base +// --------------------------------------------------------------------------- +.minion__character { + position: absolute; + width: $minion-width; + height: $minion-height; +} + +// --------------------------------------------------------------------------- +// Idle State — Banana eating, gentle bounce +// --------------------------------------------------------------------------- +.minion__character--idle { + animation: idleFloat 3s ease-in-out infinite; + + .minion__body { + animation: idleFloat 3s ease-in-out infinite; + } +} + +// --------------------------------------------------------------------------- +// Walking State — Move right to desk +// --------------------------------------------------------------------------- +.minion__character--walking { + animation: minionWalkRight $walk-duration ease-in-out forwards; +} + +// --------------------------------------------------------------------------- +// Working State — At desk, typing +// --------------------------------------------------------------------------- +.minion__character--working { + // No movement animation — static at desk +} + +// --------------------------------------------------------------------------- +// Returning State — Move left back to breakroom +// --------------------------------------------------------------------------- +.minion__character--returning { + animation: minionWalkLeft $return-duration ease-in-out forwards; +} + +// --------------------------------------------------------------------------- +// Body Parts — 16-bit Pixel Art +// --------------------------------------------------------------------------- +.minion__body { + position: relative; + width: $minion-width; + height: $minion-height; +} + +// Head — yellow capsule with goggles +.minion__head { + position: absolute; + top: 0; + left: 50%; + transform: translateX(-50%); + width: $head-size; + height: $head-size; + background-color: $minion-skin; + border-radius: 50% 50% 40% 40%; + border: 2px solid $minion-skin-shadow; + overflow: hidden; + z-index: 2; + + // Goggle strap + &::before { + content: ''; + position: absolute; + top: 30%; + left: -2px; + right: -2px; + height: 10px; + background-color: $minion-goggle-strap; + border-radius: 2px; + } +} + +// Eyes (inside goggle area) +.minion__eye { + position: absolute; + top: 35%; + width: 8px; + height: 8px; + background-color: $minion-eye; + border-radius: 50%; + border: 1px solid #666; + z-index: 3; + + &::after { + content: ''; + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + width: 4px; + height: 4px; + background-color: $minion-pupil; + border-radius: 50%; + } + + &--left { + left: 3px; + } + + &--right { + right: 3px; + } + + // Focused eyes (squint) for working state + &--focused { + height: 6px; + animation: eyeBlink 4s step-end infinite; + } +} + +// Mouth expressions +.minion__mouth { + position: absolute; + bottom: 4px; + left: 50%; + transform: translateX(-50%); + width: 8px; + height: 4px; + border-radius: 0 0 4px 4px; + z-index: 3; + + &--smile { + background-color: $minion-mouth; + border-radius: 0 0 50% 50%; + animation: mouthChew $eat-duration ease-in-out infinite; + } + + &--determined { + background-color: $minion-mouth; + width: 10px; + height: 3px; + border-radius: 0; + } + + &--focused { + background-color: $minion-mouth; + width: 6px; + height: 3px; + border-radius: 0; + } + + &--happy { + background-color: $minion-mouth; + width: 10px; + height: 5px; + border-radius: 0 0 50% 50%; + animation: mouthHappy 1.5s ease-in-out infinite; + } +} + +// Torso / Overalls +.minion__torso { + position: absolute; + top: $head-size - 4px; + left: 50%; + transform: translateX(-50%); + width: $body-width; + height: $body-height; + background-color: $minion-overalls; + border: 2px solid $minion-overalls-shadow; + border-radius: 4px 4px 2px 2px; + z-index: 1; + + // Overall strap left + &::before { + content: ''; + position: absolute; + top: -4px; + left: 6px; + width: 3px; + height: 8px; + background-color: $minion-overalls; + border-radius: 1px; + } + + // Overall strap right + &::after { + content: ''; + position: absolute; + top: -4px; + right: 6px; + width: 3px; + height: 8px; + background-color: $minion-overalls; + border-radius: 1px; + } +} + +// Arms +.minion__arm { + position: absolute; + top: $head-size; + width: $arm-width; + height: $arm-height; + background-color: $minion-skin; + border: 1px solid $minion-skin-shadow; + border-radius: 3px; + z-index: 0; + transform-origin: top center; + + &--left { + left: 2px; + } + + &--right { + right: 2px; + } + + // Arm swing during walking + &--swing { + animation: armSwing $walk-duration * 0.5 ease-in-out infinite; + } + + &--swing-alt { + animation: armSwingAlt $walk-duration * 0.5 ease-in-out infinite; + } + + // Arm raised for eating banana + &--eating { + transform: rotate(-15deg); + animation: bananaEat $eat-duration ease-in-out infinite; + } + + // Arms typing on laptop + &--typing { + transform: rotate(10deg) translateY(2px); + + &.minion__arm--typing-alt { + transform: rotate(-10deg) translateY(2px); + animation: minionType $type-duration ease-in-out infinite; + } + } +} + +// Legs +.minion__leg { + position: absolute; + bottom: 0; + width: $leg-width; + height: $leg-height; + background-color: $minion-overalls; + border: 1px solid $minion-overalls-shadow; + border-radius: 2px 2px 3px 3px; + z-index: 0; + + &--left { + left: calc(50% - #{$leg-width} - 2px); + } + + &--right { + right: calc(50% - #{$leg-width} - 2px); + } + + // Walking cycle + &--walk { + animation: legWalk $walk-duration * 0.25 ease-in-out infinite; + + &.minion__leg--walk-alt { + animation: legWalkAlt $walk-duration * 0.25 ease-in-out infinite; + } + } +} + +// --------------------------------------------------------------------------- +// Banana +// --------------------------------------------------------------------------- +.minion__banana { + position: absolute; + width: 8px; + height: 5px; + z-index: 4; + + &::before { + content: ''; + position: absolute; + width: 8px; + height: 5px; + background-color: $minion-banana; + border: 1px solid $minion-banana-shadow; + border-radius: 50% 50% 20% 20%; + } + + // Banana in hand during idle + &--idle { + top: $head-size - 2px; + right: 0; + } + + // Banana carried while returning + &--carried { + top: 8px; + left: -2px; + transform: rotate(20deg); + } +} + +// --------------------------------------------------------------------------- +// Laptop (working state) +// --------------------------------------------------------------------------- +.minion__laptop { + position: absolute; + bottom: 0; + left: 50%; + transform: translateX(-50%); + width: 40px; + z-index: 2; +} + +.minion__laptop-screen { + width: 36px; + height: 20px; + background-color: $minion-laptop-screen; + border: 2px solid $minion-laptop; + border-radius: 3px 3px 0 0; + margin: 0 auto; + overflow: hidden; + display: flex; + flex-direction: column; + justify-content: center; + gap: 2px; + padding: 3px; +} + +.minion__laptop-code { + height: 2px; + background-color: $minion-laptop-code; + border-radius: 1px; + opacity: 0.8; + + &--1 { + width: 80%; + animation: codeBlink 2s ease-in-out infinite; + } + + &--2 { + width: 60%; + animation: codeBlink 2s ease-in-out infinite 0.3s; + } + + &--3 { + width: 70%; + animation: codeBlink 2s ease-in-out infinite 0.6s; + } +} + +.minion__laptop-base { + width: 40px; + height: 3px; + background-color: $minion-laptop; + border-radius: 0 0 2px 2px; + margin: 0 auto; +} + +// --------------------------------------------------------------------------- +// Task Label (below minion) +// --------------------------------------------------------------------------- +.minion__task-label { + font-size: 9px; + color: var(--cc-on-surface-variant); + text-align: center; + max-width: 80px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + margin-top: 2px; + font-family: var(--cc-font-mono); +} + +// --------------------------------------------------------------------------- +// Side-specific adjustments +// --------------------------------------------------------------------------- +// Dev minions face right (walk right to desk) +.minion--dev .minion__character--walking { + animation-name: minionWalkRight; +} + +// Business minions face left (walk left to desk) +.minion--business .minion__character--walking { + animation-name: minionWalkRight; + transform: scaleX(-1); +} + +// Returning always walks back toward breakroom +.minion--dev .minion__character--returning { + animation-name: minionWalkLeft; +} + +.minion--business .minion__character--returning { + animation-name: minionWalkLeft; + transform: scaleX(-1); +} + +// --------------------------------------------------------------------------- +// Walking sprite shows walking animation +// --------------------------------------------------------------------------- +.minion--walking .minion__sprite { + animation: minionWalkRight $walk-duration ease-in-out forwards; +} + +.minion--returning .minion__sprite { + animation: minionWalkLeft $return-duration ease-in-out forwards; +} + +// --------------------------------------------------------------------------- +// Accessibility: Reduced Motion +// --------------------------------------------------------------------------- +@media (prefers-reduced-motion: reduce) { + .minion__character--idle, + .minion__character--walking, + .minion__character--returning, + .minion--walking .minion__sprite, + .minion--returning .minion__sprite { + animation: none; + } + + .minion__arm--swing, + .minion__arm--swing-alt, + .minion__arm--eating, + .minion__arm--typing.minion__arm--typing-alt, + .minion__leg--walk, + .minion__leg--walk-alt, + .minion__mouth--smile, + .minion__mouth--happy, + .minion__eye--focused, + .minion__laptop-code--1, + .minion__laptop-code--2, + .minion__laptop-code--3 { + animation: none; + } +} + +// --------------------------------------------------------------------------- +// Touch-friendly sizing +// --------------------------------------------------------------------------- +.minion { + min-width: 80px; + min-height: 100px; + + // Ensure touch targets are at least 48px + .minion__sprite { + min-width: 48px; + min-height: 48px; + } +} + +// --------------------------------------------------------------------------- +// Responsive: Smaller on mobile +// --------------------------------------------------------------------------- +@media (max-width: 599px) { + $mobile-scale: 0.85; + + .minion { + width: 68px; + } + + .minion__sprite { + transform: scale($mobile-scale); + transform-origin: top center; + } + + .minion__label { + font-size: 10px; + } +} \ No newline at end of file diff --git a/frontend/src/app/components/minion/minion.component.ts b/frontend/src/app/components/minion/minion.component.ts new file mode 100644 index 0000000..7f948d5 --- /dev/null +++ b/frontend/src/app/components/minion/minion.component.ts @@ -0,0 +1,182 @@ +// ============================================================================ +// Minion Component — 16-bit Breakroom Minion with State Animations +// Per CUB-60: Minion State & Animation System +// ============================================================================ +import { + ChangeDetectionStrategy, + Component, + OnDestroy, + OnInit, + input, + output, + signal, + computed, + effect, + inject, +} from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { MatIconModule } from '@angular/material/icon'; +import { MatProgressBarModule } from '@angular/material/progress-bar'; +import { MatTooltipModule } from '@angular/material/tooltip'; +import { MinionState, MinionSide } from '../../models/minion.model'; +import { MinionStateService } from '../../services/minion-state.service'; + +/** + * MinionComponent renders a single 16-bit minion character with four visual states: + * - idle: In breakroom — eating bananas, watching TV + * - walking: Moving from breakroom to desk (2s animation, then auto → working) + * - working: At desk — typing on laptop, progress bar overhead + * - returning: Walking back to breakroom (2s animation, then auto → idle) + * + * The component reads its state from MinionStateService and emits events + * when animations complete so the service can advance the state machine. + */ +@Component({ + selector: 'app-minion', + standalone: true, + imports: [ + CommonModule, + MatIconModule, + MatProgressBarModule, + MatTooltipModule, + ], + templateUrl: './minion.component.html', + styleUrl: './minion.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class MinionComponent implements OnInit, OnDestroy { + /** Agent name — used to look up state from MinionStateService */ + readonly agentName = input.required(); + + /** Which side of the office: dev or business */ + readonly side = input('dev'); + + /** Desk index on the assigned side (0-based) */ + readonly deskIndex = input(0); + + /** Override state (for standalone demo use, otherwise read from service) */ + readonly state = input('idle'); + + /** Override progress (for standalone demo use) */ + readonly progress = input(0); + + /** Override display name (for standalone demo use) */ + readonly displayName = input(''); + + /** Override task description (for standalone demo use) */ + readonly currentTask = input(''); + + /** Emits when the walking animation completes */ + readonly walkingComplete = output(); + + /** Emits when the returning animation completes */ + readonly returningComplete = output(); + + private readonly minionStateService = inject(MinionStateService); + + /** Track whether to use service data or input data */ + private readonly useServiceData = signal(false); + + /** Animation state — tracks which animation is active */ + readonly animationState = signal('none'); + + /** Computed: current state (from service if available, otherwise input) */ + readonly currentState = computed(() => { + if (this.useServiceData()) { + const minion = this.minionStateService.getMinion(this.agentName()); + return minion?.state ?? this.state(); + } + return this.state(); + }); + + /** Computed: current progress */ + readonly currentProgress = computed(() => { + if (this.useServiceData()) { + const minion = this.minionStateService.getMinion(this.agentName()); + return minion?.progress ?? this.progress(); + } + return this.progress(); + }); + + /** Computed: display name */ + readonly displayLabel = computed(() => { + if (this.useServiceData()) { + const minion = this.minionStateService.getMinion(this.agentName()); + return minion?.displayName ?? this.displayName() ?? this.agentName(); + } + return this.displayName() || this.agentName(); + }); + + /** Computed: current task */ + readonly currentTaskLabel = computed(() => { + if (this.useServiceData()) { + const minion = this.minionStateService.getMinion(this.agentName()); + return minion?.currentTask ?? this.currentTask() ?? ''; + } + return this.currentTask(); + }); + + /** Computed: CSS class based on current state */ + readonly stateClass = computed(() => `minion--${this.currentState()}`); + + /** Computed: whether to show the progress bar */ + readonly showProgress = computed(() => this.currentState() === 'working' && this.currentProgress() > 0); + + /** Computed: side class */ + readonly sideClass = computed(() => `minion--${this.side()}`); + + /** Computed: position style for desk assignment */ + readonly deskPosition = computed(() => this.deskIndex()); + + /** Computed: human-readable state label */ + readonly stateLabel = computed(() => { + const labels: Record = { + idle: 'On Break', + walking: 'Heading to Desk', + working: 'Working', + returning: 'Returning to Breakroom', + }; + return labels[this.currentState()]; + }); + + ngOnInit(): void { + // If the service has data for this agent, use it + const minion = this.minionStateService.getMinion(this.agentName()); + if (minion) { + this.useServiceData.set(true); + } + + // Sync animation state with current state + this.animationState.set(this.currentState()); + } + + /** + * Handle animation end events from CSS animations. + * Only fires for walking/returning which have finite durations. + */ + onAnimationEnd(event: AnimationEvent): void { + const state = this.currentState(); + + if (state === 'walking' && event.animationName === 'minionWalkRight') { + this.walkingComplete.emit(this.agentName()); + // Auto-transition walking → working via service + if (this.useServiceData()) { + this.minionStateService.onWalkingComplete(this.agentName()); + } + this.animationState.set('working'); + } + + if (state === 'returning' && event.animationName === 'minionWalkLeft') { + this.returningComplete.emit(this.agentName()); + // Auto-transition returning → idle via service + if (this.useServiceData()) { + this.minionStateService.onReturningComplete(this.agentName()); + } + this.animationState.set('idle'); + } + } + + ngOnDestroy(): void { + // Cleanup handled by service if needed + } +} \ No newline at end of file diff --git a/frontend/src/app/models/index.ts b/frontend/src/app/models/index.ts index da487dc..8a371c6 100644 --- a/frontend/src/app/models/index.ts +++ b/frontend/src/app/models/index.ts @@ -1,2 +1,3 @@ export * from './agent.model'; +export * from './minion.model'; export * from './nav.model'; \ No newline at end of file diff --git a/frontend/src/app/models/minion.model.ts b/frontend/src/app/models/minion.model.ts new file mode 100644 index 0000000..171b083 --- /dev/null +++ b/frontend/src/app/models/minion.model.ts @@ -0,0 +1,81 @@ +// ============================================================================ +// Minion State & Animation Types +// Per CUB-60: Minion State & Animation System +// ============================================================================ + +/** + * The four minion states in the breakroom UI state machine. + * + * State transitions: + * idle → walking → working → returning → idle + * + * idle: In breakroom — eating bananas, watching TV + * walking: Moving from breakroom to desk + * working: At desk — typing on laptop, progress bar overhead + * returning: Walking back to breakroom after task completion + */ +export type MinionState = 'idle' | 'walking' | 'working' | 'returning'; + +/** + * Which side of the office the minion belongs to. + * Dev minions walk to dev desks, Business minions to business desks. + */ +export type MinionSide = 'dev' | 'business'; + +/** + * Event types that trigger state transitions in the MinionStateService. + */ +export type MinionEvent = 'spawn' | 'task_complete' | 'task_error' | 'reset'; + +/** + * Represents the full state of a single minion in the breakroom. + */ +export interface MinionData { + /** Unique agent name, e.g., "otto", "rex" */ + agentName: string; + + /** Current minion state */ + state: MinionState; + + /** Task progress percentage (0–100), only meaningful when state === 'working' */ + progress: number; + + /** Which side of the office: dev or business */ + side: MinionSide; + + /** Desk index on the assigned side (0-based) */ + deskIndex: number; + + /** Display name for the minion, e.g., "Otto" */ + displayName: string; + + /** Current task description */ + currentTask?: string; + + /** Timestamp of last state transition */ + lastTransition: Date; +} + +/** + * State machine transition map. + * Defines valid transitions: current state → event → next state. + */ +export const MINION_TRANSITIONS: Record>> = { + idle: { + spawn: 'walking', + }, + walking: { + // walking → working happens automatically via animation end + task_error: 'idle', + reset: 'idle', + }, + working: { + task_complete: 'returning', + task_error: 'returning', + reset: 'idle', + }, + returning: { + // returning → idle happens automatically via animation end + reset: 'idle', + }, +}; \ No newline at end of file diff --git a/frontend/src/app/pages/breakroom/breakroom-page.component.html b/frontend/src/app/pages/breakroom/breakroom-page.component.html new file mode 100644 index 0000000..7d4485a --- /dev/null +++ b/frontend/src/app/pages/breakroom/breakroom-page.component.html @@ -0,0 +1,136 @@ + +
+ +
+

+ weekend + Breakroom +

+
+ @for (minion of minions(); track minion.agentName) { + @if (minion.state === 'idle') { + + } + } +
+
+ + +
+
+

+ computer + Office +

+
+ + +
+

Dev Desks

+
+ @for (minion of minions(); track minion.agentName) { + @if (minion.side === 'dev' && (minion.state === 'working' || minion.state === 'walking')) { +
+ +
+ } + } +
+
+ + +
+

Business Desks

+
+ @for (minion of minions(); track minion.agentName) { + @if (minion.side === 'business' && (minion.state === 'working' || minion.state === 'walking')) { +
+ +
+ } + } +
+
+
+ + +
+

+ gamepad + Controls +

+

Spawn minions and control their state transitions

+ +
+ @for (demo of demoMinions; track demo.agentName) { +
+ {{ demo.displayName }} + + @if (!isSpawned(demo.agentName)) { + + } @else { + {{ getStateLabel(demo.agentName) }} + + + + + + + + + + + } +
+ } +
+
+
\ No newline at end of file diff --git a/frontend/src/app/pages/breakroom/breakroom-page.component.scss b/frontend/src/app/pages/breakroom/breakroom-page.component.scss new file mode 100644 index 0000000..f9b14bb --- /dev/null +++ b/frontend/src/app/pages/breakroom/breakroom-page.component.scss @@ -0,0 +1,209 @@ +// ============================================================================ +// Breakroom Page Styles +// Per CUB-60: Demo/test page for minion state & animation +// ============================================================================ + +.breakroom { + display: flex; + flex-direction: column; + gap: 24px; + padding: var(--cc-section-padding, 24px); + min-height: 100%; +} + +// --------------------------------------------------------------------------- +// Zone (breakroom or office area) +// --------------------------------------------------------------------------- +.breakroom__zone { + background-color: var(--cc-surface-container); + border-radius: var(--cc-card-border-radius, 16px); + padding: var(--cc-card-padding, 20px); +} + +.breakroom__zone-title { + display: flex; + align-items: center; + gap: 8px; + margin: 0 0 16px; + font-size: 18px; + font-weight: 600; + color: var(--cc-on-surface); + + .mat-icon { + font-size: 20px; + width: 20px; + height: 20px; + } +} + +// --------------------------------------------------------------------------- +// Breakroom zone (idle minions) +// --------------------------------------------------------------------------- +.breakroom__zone--breakroom { + background: linear-gradient( + 135deg, + var(--cc-surface-container) 0%, + rgba(45, 212, 191, 0.06) 100% + ); + min-height: 120px; +} + +.breakroom__minions--idle { + display: flex; + flex-wrap: wrap; + gap: 16px; + justify-content: center; + min-height: 80px; + align-items: flex-end; + + &:empty::after { + content: 'No minions on break 🍌'; + color: var(--cc-on-surface-variant); + font-size: 14px; + text-align: center; + padding: 20px; + display: block; + } +} + +// --------------------------------------------------------------------------- +// Office zone (working minions) +// --------------------------------------------------------------------------- +.breakroom__zone--office { + background: linear-gradient( + 135deg, + var(--cc-surface-container) 0%, + rgba(56, 189, 248, 0.06) 100% + ); +} + +.breakroom__desk-row { + margin-bottom: 16px; +} + +.breakroom__desk-label { + font-size: 13px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.08em; + color: var(--cc-on-surface-variant); + margin: 0 0 8px; +} + +.breakroom__desks { + display: flex; + flex-wrap: wrap; + gap: 20px; + min-height: 100px; + padding: 12px; + border: 2px dashed var(--cc-outline); + border-radius: 12px; + background-color: rgba(0, 0, 0, 0.1); + align-items: flex-end; + + &:empty::after { + content: 'No minions at their desks 💻'; + color: var(--cc-on-surface-variant); + font-size: 14px; + text-align: center; + padding: 20px; + display: block; + width: 100%; + } +} + +.breakroom__desk { + display: flex; + flex-direction: column; + align-items: center; + justify-content: flex-end; + min-width: 90px; + min-height: 110px; + background-color: var(--cc-surface-container-high); + border-radius: 8px; + padding: 8px 4px 4px; + border: 1px solid var(--cc-outline); +} + +// --------------------------------------------------------------------------- +// Controls section +// --------------------------------------------------------------------------- +.breakroom__controls { + background-color: var(--cc-surface-container); + border-radius: var(--cc-card-border-radius, 16px); + padding: var(--cc-card-padding, 20px); +} + +.breakroom__controls-hint { + font-size: 13px; + color: var(--cc-on-surface-variant); + margin: 0 0 16px; +} + +.breakroom__button-grid { + display: flex; + flex-direction: column; + gap: 12px; +} + +.breakroom__control-row { + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; + padding: 8px 12px; + background-color: var(--cc-surface); + border-radius: 8px; + min-height: 48px; +} + +.breakroom__agent-name { + font-weight: 600; + font-size: 14px; + min-width: 60px; + color: var(--cc-on-surface); +} + +.breakroom__state-chip { + font-size: 12px; + font-family: var(--cc-font-mono); + padding: 4px 10px; + border-radius: 12px; + background-color: var(--cc-surface-container-high); + color: var(--cc-on-surface-variant); + border: 1px solid var(--cc-outline); +} + +// Button sizing for touch +.breakroom__control-row .mat-mdc-raised-button, +.breakroom__control-row .mat-mdc-outlined-button { + min-height: 40px; + min-width: 48px; + font-size: 13px; + + .mat-icon { + font-size: 18px; + width: 18px; + height: 18px; + margin-right: 4px; + } +} + +// --------------------------------------------------------------------------- +// Responsive +// --------------------------------------------------------------------------- +@media (max-width: 599px) { + .breakroom { + padding: 16px; + gap: 16px; + } + + .breakroom__control-row { + flex-wrap: wrap; + gap: 6px; + } + + .breakroom__agent-name { + min-width: 50px; + } +} \ No newline at end of file diff --git a/frontend/src/app/pages/breakroom/breakroom-page.component.ts b/frontend/src/app/pages/breakroom/breakroom-page.component.ts new file mode 100644 index 0000000..1cc8e5e --- /dev/null +++ b/frontend/src/app/pages/breakroom/breakroom-page.component.ts @@ -0,0 +1,102 @@ +// ============================================================================ +// Breakroom Page — Demo/Test Page for Minion State & Animation +// Per CUB-60: Validation page with spawn/transition/reset controls +// ============================================================================ +import { ChangeDetectionStrategy, Component, inject } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { MatButtonModule } from '@angular/material/button'; +import { MatIconModule } from '@angular/material/icon'; +import { MatChipsModule } from '@angular/material/chips'; +import { MinionComponent } from '../../components/minion/minion.component'; +import { MinionStateService } from '../../services/minion-state.service'; +import { MinionData } from '../../models/minion.model'; + +interface DemoMinion { + agentName: string; + displayName: string; + side: 'dev' | 'business'; + deskIndex: number; +} + +@Component({ + selector: 'app-breakroom-page', + standalone: true, + imports: [ + CommonModule, + MatButtonModule, + MatIconModule, + MatChipsModule, + MinionComponent, + ], + templateUrl: './breakroom-page.component.html', + styleUrl: './breakroom-page.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class BreakroomPageComponent { + protected readonly minionStateService = inject(MinionStateService); + + /** Predefined demo minions */ + readonly demoMinions: DemoMinion[] = [ + { agentName: 'otto', displayName: 'Otto', side: 'dev', deskIndex: 0 }, + { agentName: 'rex', displayName: 'Rex', side: 'dev', deskIndex: 1 }, + { agentName: 'dex', displayName: 'Dex', side: 'dev', deskIndex: 2 }, + { agentName: 'hex', displayName: 'Hex', side: 'dev', deskIndex: 3 }, + { agentName: 'pip', displayName: 'Pip', side: 'business', deskIndex: 0 }, + { agentName: 'nano', displayName: 'Nano', side: 'business', deskIndex: 1 }, + { agentName: 'flip', displayName: 'Flip', side: 'business', deskIndex: 2 }, + ]; + + /** All minions currently in the state service */ + readonly minions = this.minionStateService.minionList; + + /** Spawn a demo minion (idle → walking → working) */ + spawnMinion(demo: DemoMinion): void { + this.minionStateService.spawn(demo.agentName, demo.displayName, demo.side, demo.deskIndex); + } + + /** Complete a minion's task (working → returning → idle) */ + completeTask(agentName: string): void { + this.minionStateService.completeTask(agentName); + } + + /** Simulate a task error (working → returning → idle) */ + taskError(agentName: string): void { + this.minionStateService.taskError(agentName); + } + + /** Reset a minion to idle */ + resetMinion(agentName: string): void { + this.minionStateService.resetMinion(agentName); + } + + /** Remove a minion entirely */ + removeMinion(agentName: string): void { + this.minionStateService.removeMinion(agentName); + } + + /** Update progress (simulate for testing) */ + bumpProgress(agentName: string): void { + const minion = this.minionStateService.getMinion(agentName); + if (minion) { + this.minionStateService.updateProgress(agentName, Math.min(100, minion.progress + 15)); + } + } + + /** Get state label for a minion */ + getStateLabel(agentName: string): string { + const minion = this.minionStateService.getMinion(agentName); + if (!minion) return '—'; + const labels: Record = { + idle: '🍌 Idle', + walking: '🚶 Walking', + working: '💻 Working', + returning: '🔙 Returning', + }; + return labels[minion.state] ?? minion.state; + } + + /** Check if a minion is spawned */ + isSpawned(agentName: string): boolean { + return !!this.minionStateService.getMinion(agentName); + } +} \ No newline at end of file diff --git a/frontend/src/app/services/minion-state.service.ts b/frontend/src/app/services/minion-state.service.ts new file mode 100644 index 0000000..8fca36a --- /dev/null +++ b/frontend/src/app/services/minion-state.service.ts @@ -0,0 +1,213 @@ +// ============================================================================ +// Minion State Service +// Per CUB-60: State machine managing minion transitions +// idle → walking → working → returning → idle +// ============================================================================ +import { Injectable, signal, computed } from '@angular/core'; +import { + MinionData, + MinionEvent, + MinionState, + MINION_TRANSITIONS, +} from '../models/minion.model'; + +/** + * Manages the state machine for all minions in the breakroom. + * + * Each minion follows: idle → walking → working → returning → idle + * Transitions are triggered by events (spawn, task_complete, task_error, reset) + * or automatically when walking/returning animations complete. + * + * Usage: + * service.spawn('otto', 'Otto', 'dev', 0); // idle → walking → working + * service.completeTask('otto'); // working → returning → idle + * service.dispatch('otto', 'reset'); // any → idle + */ +@Injectable({ providedIn: 'root' }) +export class MinionStateService { + /** Internal map of agent name → minion data */ + private readonly _minions = signal>(new Map()); + + /** Readonly map of all minions */ + readonly minions = this._minions.asReadonly(); + + /** Computed array of minions sorted by desk position */ + readonly minionList = computed(() => { + const map = this._minions(); + return Array.from(map.values()).sort((a, b) => { + if (a.side !== b.side) return a.side.localeCompare(b.side); + return a.deskIndex - b.deskIndex; + }); + }); + + /** Get a single minion by agent name */ + getMinion(agentName: string): MinionData | undefined { + return this._minions().get(agentName); + } + + /** + * Spawn a minion: creates entry in 'idle' state, then immediately + * transitions to 'walking' (which auto-transitions to 'working'). + */ + spawn(agentName: string, displayName: string, side: 'dev' | 'business', deskIndex: number): void { + const minion: MinionData = { + agentName, + displayName, + state: 'idle', + progress: 0, + side, + deskIndex, + lastTransition: new Date(), + }; + this._minions.update(map => { + const next = new Map(map); + next.set(agentName, minion); + return next; + }); + // Immediately transition idle → walking + this.dispatch(agentName, 'spawn'); + } + + /** + * Dispatch an event to transition a minion's state. + * Follows the MINION_TRANSITIONS map. Invalid transitions are ignored. + */ + dispatch(agentName: string, event: MinionEvent): void { + this._minions.update(map => { + const minion = map.get(agentName); + if (!minion) return map; + + const validTransitions = MINION_TRANSITIONS[minion.state]; + const nextState = validTransitions[event]; + if (!nextState) return map; // Invalid transition — ignore + + const updated: MinionData = { + ...minion, + state: nextState, + lastTransition: new Date(), + }; + + const next = new Map(map); + next.set(agentName, updated); + return next; + }); + } + + /** + * Complete a task: transitions working → returning. + * After returning animation, the minion auto-transitions to idle. + */ + completeTask(agentName: string): void { + this.dispatch(agentName, 'task_complete'); + } + + /** + * Report a task error: transitions working → returning (same visual, different reason). + * After returning animation, the minion auto-transitions to idle. + */ + taskError(agentName: string): void { + this.dispatch(agentName, 'task_error'); + } + + /** + * Called by the MinionComponent when the walking animation completes. + * Transitions walking → working and sets initial progress to 0. + */ + onWalkingComplete(agentName: string): void { + this._minions.update(map => { + const minion = map.get(agentName); + if (!minion || minion.state !== 'walking') return map; + + const updated: MinionData = { + ...minion, + state: 'working', + progress: 0, + lastTransition: new Date(), + }; + + const next = new Map(map); + next.set(agentName, updated); + return next; + }); + } + + /** + * Called by the MinionComponent when the returning animation completes. + * Transitions returning → idle. + */ + onReturningComplete(agentName: string): void { + this._minions.update(map => { + const minion = map.get(agentName); + if (!minion || minion.state !== 'returning') return map; + + const updated: MinionData = { + ...minion, + state: 'idle', + progress: 0, + currentTask: undefined, + lastTransition: new Date(), + }; + + const next = new Map(map); + next.set(agentName, updated); + return next; + }); + } + + /** + * Update task progress for a working minion. + */ + updateProgress(agentName: string, progress: number): void { + this._minions.update(map => { + const minion = map.get(agentName); + if (!minion || minion.state !== 'working') return map; + + const updated: MinionData = { + ...minion, + progress: Math.min(100, Math.max(0, progress)), + lastTransition: new Date(), + }; + + const next = new Map(map); + next.set(agentName, updated); + return next; + }); + } + + /** + * Update the current task description for a minion. + */ + updateTask(agentName: string, taskDescription: string): void { + this._minions.update(map => { + const minion = map.get(agentName); + if (!minion) return map; + + const updated: MinionData = { + ...minion, + currentTask: taskDescription, + }; + + const next = new Map(map); + next.set(agentName, updated); + return next; + }); + } + + /** + * Remove a minion entirely from the state map. + */ + removeMinion(agentName: string): void { + this._minions.update(map => { + const next = new Map(map); + next.delete(agentName); + return next; + }); + } + + /** + * Reset a minion back to idle state. + */ + resetMinion(agentName: string): void { + this.dispatch(agentName, 'reset'); + } +} \ No newline at end of file