From 08dac3606d4d3fdfd914b278948dba7509c7f89b Mon Sep 17 00:00:00 2001 From: William Thorsen Date: Sun, 1 Mar 2026 05:45:22 -0800 Subject: [PATCH 1/2] factory|feat: Move station labels to platform with role names Reposition station labels from inside the station rectangle to the platform bar below, centered horizontally using `TextAlign.Center`. Display the role name (e.g., "architect") instead of the phase name (e.g., "architecture") for readability. Add PHASE_ROLE constant mapping phases to display role names, add role field to StationConfig, and update `StationActor` to accept role as its first parameter with the new label offset `vec(50, 25)`. --- .../src/client/game/actors/StationActor.ts | 11 +++-- .../actors/__tests__/StationActor.test.ts | 47 +++++++++++++++---- .../mappers/__tests__/run-to-scene.test.ts | 15 +++++- .../src/client/game/mappers/run-to-scene.ts | 4 +- .../src/client/game/scenes/FactoryScene.ts | 2 +- .../scenes/__tests__/FactoryScene.test.ts | 22 ++++----- .../src/shared/constants/role-types.ts | 10 ++++ 7 files changed, 82 insertions(+), 29 deletions(-) diff --git a/packages/factory/src/client/game/actors/StationActor.ts b/packages/factory/src/client/game/actors/StationActor.ts index 3be0af18..3c1b3954 100644 --- a/packages/factory/src/client/game/actors/StationActor.ts +++ b/packages/factory/src/client/game/actors/StationActor.ts @@ -1,12 +1,13 @@ -import { Actor, Color, Font, GraphicsGroup, Rectangle, Text, vec, type Vector } from 'excalibur'; +import { Actor, Color, Font, GraphicsGroup, Rectangle, Text, TextAlign, vec, type Vector } from 'excalibur'; import { PALETTE } from '../../../shared/constants/palette.js'; const STATION_WIDTH = 100; const STATION_HEIGHT = 40; +const PLATFORM_HEIGHT = 20; export class StationActor extends Actor { - constructor(phase: string, active: boolean, position: Vector) { + constructor(role: string, active: boolean, position: Vector) { const color = active ? PALETTE.lightGray : PALETTE.darkGray; super({ pos: position, @@ -25,15 +26,15 @@ export class StationActor extends Actor { const labelColor = Color.fromHex(PALETTE.white); labelColor.a = 0.6; const label = new Text({ - text: phase, + text: role, color: labelColor, - font: new Font({ size: 10 }), + font: new Font({ size: 10, textAlign: TextAlign.Center }), }); const group = new GraphicsGroup({ members: [ { graphic: rect, offset: vec(0, 0) }, - { graphic: label, offset: vec(10, STATION_HEIGHT / 2 - 5), useBounds: false }, + { graphic: label, offset: vec(STATION_WIDTH / 2, STATION_HEIGHT / 2 + PLATFORM_HEIGHT / 2 - 5), useBounds: false }, ], }); diff --git a/packages/factory/src/client/game/actors/__tests__/StationActor.test.ts b/packages/factory/src/client/game/actors/__tests__/StationActor.test.ts index fce646b2..03c3321b 100644 --- a/packages/factory/src/client/game/actors/__tests__/StationActor.test.ts +++ b/packages/factory/src/client/game/actors/__tests__/StationActor.test.ts @@ -36,10 +36,14 @@ vi.mock('excalibur', () => { } } + const MockTextAlign = { Center: 'center' }; + class MockFont { size: number; - constructor(opts: { size: number }) { + textAlign: string | undefined; + constructor(opts: { size: number; textAlign?: string }) { this.size = opts.size; + this.textAlign = opts.textAlign; } } @@ -72,6 +76,7 @@ vi.mock('excalibur', () => { Color: MockColor, Font: MockFont, Text: MockText, + TextAlign: MockTextAlign, Rectangle: MockRectangle, GraphicsGroup: MockGraphicsGroup, vec: (x: number, y: number) => ({ x, y }), @@ -84,7 +89,7 @@ const { PALETTE } = await import('../../../../shared/constants/palette.js'); describe('StationActor', () => { it('creates a Rectangle with semi-transparent lightGray when active', () => { - new StationActor('architecture', true, vec(100, 200)); + new StationActor('architect', true, vec(100, 200)); const expectedColor = Color.fromHex(PALETTE.lightGray); expectedColor.a = 0.15; @@ -98,7 +103,7 @@ describe('StationActor', () => { }); it('creates a Rectangle with semi-transparent darkGray when inactive', () => { - new StationActor('architecture', false, vec(100, 200)); + new StationActor('architect', false, vec(100, 200)); const expectedColor = Color.fromHex(PALETTE.darkGray); expectedColor.a = 0.15; @@ -109,21 +114,21 @@ describe('StationActor', () => { ); }); - it('creates a Text label with the phase name at reduced opacity', () => { - new StationActor('planning', true, vec(0, 0)); + it('creates a Text label with the role name at reduced opacity', () => { + new StationActor('planner', true, vec(0, 0)); const expectedColor = Color.fromHex(PALETTE.white); expectedColor.a = 0.6; expect(mockTextConstructor).toHaveBeenCalledWith( expect.objectContaining({ - text: 'planning', + text: 'planner', color: expectedColor, }), ); }); it('creates a GraphicsGroup composing rectangle and text', () => { - new StationActor('implementation', true, vec(0, 0)); + new StationActor('coder', true, vec(0, 0)); expect(mockGraphicsGroupConstructor).toHaveBeenCalledWith( expect.objectContaining({ @@ -136,13 +141,13 @@ describe('StationActor', () => { }); it('uses the GraphicsGroup via graphics.use()', () => { - new StationActor('review', true, vec(0, 0)); + new StationActor('reviewer', true, vec(0, 0)); expect(mockGraphicsUse).toHaveBeenCalled(); }); it('sets correct dimensions on the Actor', () => { - new StationActor('architecture', true, vec(50, 75)); + new StationActor('architect', true, vec(50, 75)); expect(mockActorConstructor).toHaveBeenCalledWith( expect.objectContaining({ @@ -154,7 +159,7 @@ describe('StationActor', () => { it('passes position to Actor constructor', () => { const pos = vec(300, 400); - new StationActor('holistic', false, pos); + new StationActor('holistic-reviewer', false, pos); expect(mockActorConstructor).toHaveBeenCalledWith( expect.objectContaining({ @@ -162,4 +167,26 @@ describe('StationActor', () => { }), ); }); + + it('positions label centered on platform below station', () => { + new StationActor('architect', true, vec(0, 0)); + + expect(mockGraphicsGroupConstructor).toHaveBeenCalledWith( + expect.objectContaining({ + members: expect.arrayContaining([ + expect.objectContaining({ offset: { x: 50, y: 25 }, useBounds: false }), + ]), + }), + ); + }); + + it('uses center-aligned text on the Font', () => { + new StationActor('coder', true, vec(0, 0)); + + expect(mockTextConstructor).toHaveBeenCalledWith( + expect.objectContaining({ + font: expect.objectContaining({ textAlign: 'center' }), + }), + ); + }); }); diff --git a/packages/factory/src/client/game/mappers/__tests__/run-to-scene.test.ts b/packages/factory/src/client/game/mappers/__tests__/run-to-scene.test.ts index 96fce34f..bba34258 100644 --- a/packages/factory/src/client/game/mappers/__tests__/run-to-scene.test.ts +++ b/packages/factory/src/client/game/mappers/__tests__/run-to-scene.test.ts @@ -6,7 +6,7 @@ import { createMockRunStatus, emptyPhases, } from '../../../../__test-helpers__/fixtures.js'; -import { PHASE_ROLE_TYPE } from '../../../../shared/constants/role-types.js'; +import { PHASE_ROLE, PHASE_ROLE_TYPE } from '../../../../shared/constants/role-types.js'; import { REVIEW_STATION_INDEX as LAYOUT_REVIEW_STATION_INDEX } from '../../layout/platform-layout.js'; import { createSceneConfig, PHASE_NAMES, REVIEW_STATION_INDEX } from '../run-to-scene.js'; @@ -892,6 +892,19 @@ describe('createSceneConfig', () => { expect(station.phase).toBe(PHASE_NAMES[i]); }); }); + + it('assigns station role from PHASE_ROLE for each phase', () => { + const status = createMockRunStatus(); + + const config = createSceneConfig(status); + + config.stations.forEach((station, i) => { + const phase = PHASE_NAMES[i]; + if (phase !== undefined) { + expect(station.role).toBe(PHASE_ROLE[phase]); + } + }); + }); }); describe('constant synchronization', () => { diff --git a/packages/factory/src/client/game/mappers/run-to-scene.ts b/packages/factory/src/client/game/mappers/run-to-scene.ts index 31b2b5af..7e4f9157 100644 --- a/packages/factory/src/client/game/mappers/run-to-scene.ts +++ b/packages/factory/src/client/game/mappers/run-to-scene.ts @@ -1,10 +1,11 @@ import type { PhaseName, RoleType } from '../../../shared/constants/role-types.js'; -import { PHASE_NAMES, PHASE_ROLE_TYPE } from '../../../shared/constants/role-types.js'; +import { PHASE_NAMES, PHASE_ROLE, PHASE_ROLE_TYPE } from '../../../shared/constants/role-types.js'; import { findCurrentPhase, isPhasePresentInData } from '../../../shared/phase-inference.js'; import type { CanonicalRunStatus, Phases } from '../../../shared/types/canonical.js'; export interface StationConfig { phase: string; + role: string; active: boolean; } @@ -52,6 +53,7 @@ function isPhaseActive(phase: PhaseName, phases: Phases, runStatus: string, curr function buildStations(status: CanonicalRunStatus, currentPhase?: PhaseName): StationConfig[] { return PHASE_NAMES.map((phase) => ({ phase, + role: PHASE_ROLE[phase], active: isPhaseActive(phase, status.phases, status.status, currentPhase), })); } diff --git a/packages/factory/src/client/game/scenes/FactoryScene.ts b/packages/factory/src/client/game/scenes/FactoryScene.ts index 167f6596..1ce22776 100644 --- a/packages/factory/src/client/game/scenes/FactoryScene.ts +++ b/packages/factory/src/client/game/scenes/FactoryScene.ts @@ -95,7 +95,7 @@ export class FactoryScene extends Scene { const station = config.stations[i]; const pos = layout.stationPositions[i]; if (station === undefined || pos === undefined) continue; - this.add(new StationActor(station.phase, station.active, vec(pos.x, pos.y))); + this.add(new StationActor(station.role, station.active, vec(pos.x, pos.y))); } } diff --git a/packages/factory/src/client/game/scenes/__tests__/FactoryScene.test.ts b/packages/factory/src/client/game/scenes/__tests__/FactoryScene.test.ts index c3b0a84b..9b1e3959 100644 --- a/packages/factory/src/client/game/scenes/__tests__/FactoryScene.test.ts +++ b/packages/factory/src/client/game/scenes/__tests__/FactoryScene.test.ts @@ -70,7 +70,7 @@ vi.mock('../../../game/actors/StationActor.js', () => ({ StationActor: class StationActor { kind = 'station'; constructor( - public phase: string, + public role: string, public active: boolean, public position: unknown, ) {} @@ -155,7 +155,7 @@ const { FactoryScene } = await import('../FactoryScene.js'); interface MockActorWithKind { kind?: string; agentKey?: string; - phase?: string; + role?: string; roleType?: string; position?: { x: number; y: number }; } @@ -314,7 +314,7 @@ describe('FactoryScene', () => { expect(mockSceneAdd).toHaveBeenCalledTimes(27); }); - it('adds station actors with correct phase names', () => { + it('adds station actors with correct role names', () => { const status = createMockRunStatus(); const scene = new FactoryScene(status); scene.onInitialize(); @@ -324,15 +324,15 @@ describe('FactoryScene', () => { ); expect(stationCalls).toHaveLength(7); - const phaseNames = stationCalls.map((call: unknown[]) => getActorFromCall(call).phase); - expect(phaseNames).toEqual([ - 'architecture', - 'planning', - 'implementation', - 'review', + const roleNames = stationCalls.map((call: unknown[]) => getActorFromCall(call).role); + expect(roleNames).toEqual([ + 'architect', + 'planner', + 'coder', + 'reviewer', 'simplifier', - 'holistic', - 'summary', + 'holistic-reviewer', + 'orchestrator', ]); }); diff --git a/packages/factory/src/shared/constants/role-types.ts b/packages/factory/src/shared/constants/role-types.ts index 32a332db..ba783984 100644 --- a/packages/factory/src/shared/constants/role-types.ts +++ b/packages/factory/src/shared/constants/role-types.ts @@ -14,6 +14,16 @@ export const PHASE_NAMES = [ ] as const; export type PhaseName = (typeof PHASE_NAMES)[number]; +export const PHASE_ROLE: Record = { + architecture: 'architect', + planning: 'planner', + implementation: 'coder', + review: 'reviewer', + simplifier: 'simplifier', + holistic: 'holistic-reviewer', + summary: 'orchestrator', +}; + export const PHASE_ROLE_TYPE: Record = { architecture: 'analyst', planning: 'planner', From 0904da40f13062fa7e17368e41c901329753d0ce Mon Sep 17 00:00:00 2001 From: William Thorsen Date: Sun, 1 Mar 2026 06:18:05 -0800 Subject: [PATCH 2/2] factory|fix: Center station labels in main platform bar Fix label Y offset from 25 to 45 so text centers vertically within the main platform rather than inside the station rectangle. The GraphicsGroup offset origin is the group's top-left, so reaching the platform center requires STATION_HEIGHT + PLATFORM_HEIGHT/2 - 5 (40 + 10 - 5 = 45), not STATION_HEIGHT/2 + PLATFORM_HEIGHT/2 - 5. Also use spaces in display names ("holistic reviewer") instead of hyphens for readability. --- packages/factory/src/client/game/actors/StationActor.ts | 2 +- .../src/client/game/actors/__tests__/StationActor.test.ts | 6 ++---- .../src/client/game/scenes/__tests__/FactoryScene.test.ts | 2 +- packages/factory/src/shared/constants/role-types.ts | 2 +- 4 files changed, 5 insertions(+), 7 deletions(-) diff --git a/packages/factory/src/client/game/actors/StationActor.ts b/packages/factory/src/client/game/actors/StationActor.ts index 3c1b3954..b885b696 100644 --- a/packages/factory/src/client/game/actors/StationActor.ts +++ b/packages/factory/src/client/game/actors/StationActor.ts @@ -34,7 +34,7 @@ export class StationActor extends Actor { const group = new GraphicsGroup({ members: [ { graphic: rect, offset: vec(0, 0) }, - { graphic: label, offset: vec(STATION_WIDTH / 2, STATION_HEIGHT / 2 + PLATFORM_HEIGHT / 2 - 5), useBounds: false }, + { graphic: label, offset: vec(STATION_WIDTH / 2, STATION_HEIGHT + PLATFORM_HEIGHT / 2 - 5), useBounds: false }, ], }); diff --git a/packages/factory/src/client/game/actors/__tests__/StationActor.test.ts b/packages/factory/src/client/game/actors/__tests__/StationActor.test.ts index 03c3321b..88025418 100644 --- a/packages/factory/src/client/game/actors/__tests__/StationActor.test.ts +++ b/packages/factory/src/client/game/actors/__tests__/StationActor.test.ts @@ -159,7 +159,7 @@ describe('StationActor', () => { it('passes position to Actor constructor', () => { const pos = vec(300, 400); - new StationActor('holistic-reviewer', false, pos); + new StationActor('holistic reviewer', false, pos); expect(mockActorConstructor).toHaveBeenCalledWith( expect.objectContaining({ @@ -173,9 +173,7 @@ describe('StationActor', () => { expect(mockGraphicsGroupConstructor).toHaveBeenCalledWith( expect.objectContaining({ - members: expect.arrayContaining([ - expect.objectContaining({ offset: { x: 50, y: 25 }, useBounds: false }), - ]), + members: expect.arrayContaining([expect.objectContaining({ offset: { x: 50, y: 45 }, useBounds: false })]), }), ); }); diff --git a/packages/factory/src/client/game/scenes/__tests__/FactoryScene.test.ts b/packages/factory/src/client/game/scenes/__tests__/FactoryScene.test.ts index 9b1e3959..791b8645 100644 --- a/packages/factory/src/client/game/scenes/__tests__/FactoryScene.test.ts +++ b/packages/factory/src/client/game/scenes/__tests__/FactoryScene.test.ts @@ -331,7 +331,7 @@ describe('FactoryScene', () => { 'coder', 'reviewer', 'simplifier', - 'holistic-reviewer', + 'holistic reviewer', 'orchestrator', ]); }); diff --git a/packages/factory/src/shared/constants/role-types.ts b/packages/factory/src/shared/constants/role-types.ts index ba783984..9d39eb04 100644 --- a/packages/factory/src/shared/constants/role-types.ts +++ b/packages/factory/src/shared/constants/role-types.ts @@ -20,7 +20,7 @@ export const PHASE_ROLE: Record = { implementation: 'coder', review: 'reviewer', simplifier: 'simplifier', - holistic: 'holistic-reviewer', + holistic: 'holistic reviewer', summary: 'orchestrator', };