From a02ab6b75f5d7c2c7978c4bc3d2032fecd743a43 Mon Sep 17 00:00:00 2001
From: "cubecraft-agents[bot]"
<3458173+cubecraft-agents[bot]@users.noreply.github.com>
Date: Sun, 26 Apr 2026 03:44:56 +0000
Subject: [PATCH] CUB-60: implement minion state machine and animation system
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- Add MinionState type and MinionData model with four states (idle, walking, working, returning)
- Add MinionStateService with full state machine: spawn → walking → working → returning → idle
- Create MinionComponent with 16-bit pixel art CSS animations:
- idle: banana-eating loop animation with gentle float
- walking: 2s translate-right walk cycle with arm/leg swing
- working: typing keyframe animation with laptop and progress bar
- returning: 2s translate-left walk-back animation
- Add MinionState transition map with event-driven dispatch
- Create BreakroomPage demo with spawn/complete/error/reset controls
- Add /breakroom route for testing state transitions
- Touch-optimized: 48px min targets, responsive scaling
- Full reduced-motion accessibility support
- TypeScript strict: no any, all inputs typed
---
frontend/src/app/app.routes.ts | 2 +
.../components/minion/minion.component.html | 120 +++
.../components/minion/minion.component.scss | 747 ++++++++++++++++++
.../app/components/minion/minion.component.ts | 182 +++++
frontend/src/app/models/index.ts | 1 +
frontend/src/app/models/minion.model.ts | 81 ++
.../breakroom/breakroom-page.component.html | 136 ++++
.../breakroom/breakroom-page.component.scss | 209 +++++
.../breakroom/breakroom-page.component.ts | 102 +++
.../src/app/services/minion-state.service.ts | 213 +++++
10 files changed, 1793 insertions(+)
create mode 100644 frontend/src/app/components/minion/minion.component.html
create mode 100644 frontend/src/app/components/minion/minion.component.scss
create mode 100644 frontend/src/app/components/minion/minion.component.ts
create mode 100644 frontend/src/app/models/minion.model.ts
create mode 100644 frontend/src/app/pages/breakroom/breakroom-page.component.html
create mode 100644 frontend/src/app/pages/breakroom/breakroom-page.component.scss
create mode 100644 frontend/src/app/pages/breakroom/breakroom-page.component.ts
create mode 100644 frontend/src/app/services/minion-state.service.ts
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 @@
+
+
+
+
+ @if (showProgress()) {
+
+
+
+ }
+
+
+
{{ displayLabel() }}
+
+
+
+
+ @if (currentState() === 'idle') {
+
+ }
+
+
+ @if (currentState() === 'walking') {
+
+ }
+
+
+ @if (currentState() === 'working') {
+
+ }
+
+
+ @if (currentState() === 'returning') {
+
+ }
+
+
+
+ @if (currentState() === 'working' && currentTaskLabel()) {
+
{{ currentTaskLabel() }}
+ }
+
\ 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') {
+
+ }
+ }
+
+
+
+
+
+
+
+
+
+
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
+
+
+
+
\ 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