diff --git a/docs/changelog.md b/docs/changelog.md
index 1ffaa7b..43ffec0 100644
--- a/docs/changelog.md
+++ b/docs/changelog.md
@@ -9,6 +9,15 @@
### Added
+- **A command center and a floor that lives.** On the Live floor the harness's
+ agents wait inside a glass command center instead of crowding a table; whoever
+ is on walks out through its sliding door onto a pad, speaks, and walks back.
+ Everyone else has a mood — watching the worker, a coffee break, a stretch, a
+ walk round the table, a nap after a long wait — shown as a pose, a badge and a
+ line in their dossier. A table whose tickets are all done celebrates (beers, a
+ kick-about, a dance), and the whole floor joins in when the run ships. The
+ flat map shows the same moods; after a run the 3D floor keeps living at an
+ ambient 30 fps for a few minutes, then settles.
- **Dynamic or Strict teams, on the run bar.** Dynamic is the default and says
so: the dispatcher picks the teams, how they work and who manages them, and
names its pick for the request being typed. Picking teams switches to Strict —
diff --git a/docs/squads.md b/docs/squads.md
index cf671a6..0a26f50 100644
--- a/docs/squads.md
+++ b/docs/squads.md
@@ -78,8 +78,9 @@ screen:
Picking a team switches to Strict; the ✕ on the button, or *Dynamic* in its
menu, goes back. The choice governs one run: the server restores the saved pins
-when it ends. The dispatcher says what it decided on the run log, and sits at
-the harness table on the floor:
+when it ends. The dispatcher says what it decided on the run log, and waits in
+the floor's command center (the harness's own room) with the phase agents —
+stepping out onto its pad while it speaks:
```text
compose dispatcher dynamic — picked from the request and the workspace: 2 teams build in parallel …
diff --git a/docs/studio.md b/docs/studio.md
index 9bb8b96..518062c 100644
--- a/docs/studio.md
+++ b/docs/studio.md
@@ -65,7 +65,7 @@ the binary. `make web-check` runs the SPA's lint, typecheck, tests and build.
| Route | Page | What it does |
|---|---|---|
-| `/` | **Live** | The run as a place: the **team floor** (each team at its table with its manager, members, monitors and tickets; contract conduits between tables; a spark when a manager moves a ticket), the **phase journey** above it, a ticker of what is happening right now, and one **activity** column beside it — the log by default, with tasks, fixes, files and the result as filters |
+| `/` | **Live** | The run as a place: the **team floor** (each team at its table with its manager, members, monitors and tickets; contract conduits between tables; a spark when a manager moves a ticket; the harness in a **command center** whose crew walks out onto a pad when it is their turn; idle people who watch, take coffee breaks, stroll and nap, and a done table that celebrates with beers, football and a dance), the **phase journey** above it, a ticker of what is happening right now, and one **activity** column beside it — the log by default, with tasks, fixes, files and the result as filters |
| `/board` | **Board** | Kanban — add, edit, delete, move, delegate, drag mid-run |
| `/review` | **Review** | Pending changes from `permission: review`, as diffs, with per-file apply/reject |
| `/runs` | **Runs** | Run history, and a per-run **trace** with per-phase wall time and token/cost attribution |
diff --git a/web/src/components/Live/floor/FloorDossier.tsx b/web/src/components/Live/floor/FloorDossier.tsx
index 19341a3..e3773e3 100644
--- a/web/src/components/Live/floor/FloorDossier.tsx
+++ b/web/src/components/Live/floor/FloorDossier.tsx
@@ -6,6 +6,7 @@ import { teamColor } from '@/components/Board/teamColor';
import { isTypingTarget } from '@/hooks/useKeyboard';
import { agentTrail, workTables, DISPATCHER_ID, type FloorAgent, type FloorModel, type FloorStageSeat, type FloorTeam, type FloorTicket } from './floorModel';
import { TICKET_HEX, TICKET_LABEL, ago, clock, findAgent, findTicket, glyphFor, seatTitle, type FloorSelection } from './floorShared';
+import { MOOD_GLYPH, MOOD_LABEL, floorShipped, moodFor, seedOf, tableParties } from './floorLife';
// ── The dossier: who is this, what are they doing ────────────────────────
//
@@ -173,6 +174,7 @@ function AgentDossier({
)}
{held && held.title &&
{held.title}
}
+ {!active && }
{/* Management */}
@@ -347,6 +349,16 @@ function TicketDossier({
}
/** A phase agent on the pipeline's stage: what phase, whether speaking, what was said. */
+/** What someone idle is up to meanwhile — the same mood the stage draws. */
+function MoodLine({ id, since, running, partying, now }: { id: string; since?: number; running: boolean; partying: boolean; now: number }) {
+ const mood = moodFor({ active: false, running, partying, since: since ?? now, now, seed: seedOf(id) });
+ return (
+
+ meanwhile: {MOOD_GLYPH[mood]} {MOOD_LABEL[mood]}
+
+ );
+}
+
function StageDossier({ seat, floor, events, running, now, onSelect }: { seat: FloorStageSeat; floor: FloorModel; events: RunEvent[]; running: boolean; now: number; onSelect: (sel: FloorSelection) => void }) {
const trail = useMemo(() => agentTrail(events, seat.id, 8), [events, seat.id]);
const active = seat.active && running;
@@ -371,6 +383,7 @@ function StageDossier({ seat, floor, events, running, now, onSelect }: { seat: F
{seat.lastMessage && (
{seat.lastMessage}
)}
+ {!active && }
{seat.id === DISPATCHER_ID
diff --git a/web/src/components/Live/floor/RigBody.tsx b/web/src/components/Live/floor/RigBody.tsx
new file mode 100644
index 0000000..1612f4a
--- /dev/null
+++ b/web/src/components/Live/floor/RigBody.tsx
@@ -0,0 +1,100 @@
+import type { ReactNode } from 'react';
+import type { ThreeEvent } from '@react-three/fiber';
+import { HIP_Y, HEAD_Y, SHOULDER_Y, type Rig, type RigLook } from './floorRig';
+
+// The figure's meshes; floorRig poses them. See floorRig for the local space.
+
+type Pick = {
+ onClick: (e: ThreeEvent) => void;
+ onPointerOver: (e: ThreeEvent) => void;
+ onPointerOut: () => void;
+};
+
+/** The figure's meshes. Everything moves through the refs in `rig`. */
+export default function RigBody({ rig, look, pick, children }: { rig: Rig; look: RigLook; pick: Pick; children?: ReactNode }) {
+ const opacity = look.ghost ? 0.72 : 1;
+ const transparent = !!look.ghost;
+ const pants = '#334155';
+ return (
+
+ {/* Legs, on hip pivots. */}
+ {([-1, 1] as const).map((side) => (
+
+
+
+
+
+
+
+
+
+
+ ))}
+
+
+
+
+
+ {/* Arms, on shoulder pivots; the right hand holds the cup or the beer. */}
+ {([-1, 1] as const).map((side) => (
+
+
+
+
+
+
+
+
+
+ {side > 0 && (
+ <>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ >
+ )}
+
+ ))}
+
+
+
+
+
+ {/* Eyes, so a turned head reads as a glance and a nap as a nap. */}
+
+
+
+
+
+
+
+
+ {look.hair && (
+
+
+
+
+ )}
+ {look.crown && (
+
+
+
+
+ )}
+
+
+ {children}
+
+ );
+}
diff --git a/web/src/components/Live/floor/TeamFloor.test.tsx b/web/src/components/Live/floor/TeamFloor.test.tsx
index ed63342..4c0fede 100644
--- a/web/src/components/Live/floor/TeamFloor.test.tsx
+++ b/web/src/components/Live/floor/TeamFloor.test.tsx
@@ -206,6 +206,48 @@ describe('TeamFloor', () => {
expect(within(dossier).getByTestId('dossier-trail')).toHaveTextContent('three tickets, two teams');
});
+ it('keeps the command center’s crew inside as chips, and sends whoever is on out to the pad', () => {
+ const log: RunEvent[] = [{ phase: 'plan', kind: 'agent_start', agent: 'planner', message: 'reading the request', time: at(1) }];
+ const floor = buildFloor({
+ squads: chart,
+ tasks,
+ events: log,
+ composition: { summary: 'x', phases: [{ id: 'plan', agent: 'planner', enabled: true, when: 'auto' }, { id: 'split', agent: 'splitter', enabled: true, when: 'auto' }] },
+ running: true,
+ now: T0 + 2000,
+ });
+ render( );
+ const harness = screen.getByTestId('island-harness');
+ expect(within(harness).getByText(/Command center/)).toBeInTheDocument();
+ // Out on the pad: named, with a bubble. Inside: a chip, no name tag.
+ expect(within(within(harness).getByTestId('agent-planner')).getByText('planner')).toBeInTheDocument();
+ expect(within(within(harness).getByTestId('agent-splitter')).queryByText('splitter')).toBeNull();
+ });
+
+ it('throws a party at a table whose every ticket is done', () => {
+ const done: SquadsView = {
+ ...chart,
+ squads: [{ ...chart.squads![0], done: 2, in_flight: 0, complete: true }, chart.squads![1]],
+ };
+ const doneTasks = [task('T1', { status: 'done', column: 'done' }), task('T2', { status: 'done', column: 'done' }), tasks[2]];
+ const floor = buildFloor({ squads: done, tasks: doneTasks, events: [], composition: null, running: true, now: T0 });
+ render( );
+ expect(screen.getByTestId('party-backend-go')).toBeInTheDocument();
+ expect(screen.queryByTestId('party-frontend-react')).toBeNull();
+ const mood = within(screen.getByTestId('island-backend-go')).getByTestId('agent-go-worker').getAttribute('data-mood');
+ expect(['cheers', 'football', 'dance']).toContain(mood);
+ });
+
+ it('lets people nap once the run has stopped, and says so in the dossier', () => {
+ const floor = buildFloor({ squads: chart, tasks, events, composition: null, running: false, now: T0 + 5000 });
+ render( );
+ const worker = within(screen.getByTestId('island-backend-go')).getByTestId('agent-go-worker');
+ expect(worker).toHaveAttribute('data-mood', 'nap');
+ expect(within(worker).getByTestId('mood-go-worker')).toHaveTextContent('💤');
+ fireEvent.click(worker);
+ expect(within(screen.getByTestId('floor-dossier')).getByTestId('dossier-mood')).toHaveTextContent('having a nap');
+ });
+
it('explains an empty floor', () => {
const floor = buildFloor({ squads: null, tasks: [], events: [], composition: null, running: false, now: T0 });
render( );
diff --git a/web/src/components/Live/floor/TeamFloor3D.tsx b/web/src/components/Live/floor/TeamFloor3D.tsx
index 6caeea0..03e21e2 100644
--- a/web/src/components/Live/floor/TeamFloor3D.tsx
+++ b/web/src/components/Live/floor/TeamFloor3D.tsx
@@ -8,6 +8,9 @@ import { STAGE_GLYPHS } from '@/components/shared/labels';
import { HARNESS_ID, PULSE_TTL_MS, workTables, type FloorAgent, type FloorHandoff, type FloorModel, type FloorPhase, type FloorPulse, type FloorStageSeat, type FloorTeam, type FloorTicket, type TicketState } from './floorModel';
import { LEGEND_STATES, TICKET_HEX, TICKET_LABEL, glyphFor, seatTitle, type FloorSelection } from './floorShared';
import { floorStore, rememberCamera, rememberCameraPrefs } from './floorStore';
+import { MOOD_GLYPH, MOOD_LABEL, floorShipped, isFootballRound, isParty, moodFor, moodShows, seedOf, tableParties, type LifeInputs, type Mood } from './floorLife';
+import { lookOf, makeWalk, poseRig, stillPose, turnToward, useRig, walkAt, type Walk } from './floorRig';
+import RigBody from './RigBody';
// ── The team floor, in three dimensions ──────────────────────────────────
//
@@ -23,14 +26,28 @@ import { floorStore, rememberCamera, rememberCameraPrefs } from './floorStore';
// seat; a reassignment is a spark across the table; a ticket finishing or
// failing bursts. Gates paint the table's rim green or red.
//
+// The harness — the dispatcher and the phase agents who plan, split and
+// compose between the tables' typing — lives in the command center: a glass
+// room with a mission screen on its back wall. The crew waits inside; whoever
+// the run hands the microphone to walks out through the door onto the pad in
+// front, says their piece, and walks back in. A room of ten figures stays a
+// room, not a crowd of name tags.
+//
+// Nobody is a statue. Everyone who is not working has a mood (floorLife):
+// they watch the worker, sip a coffee, stretch, take a walk round the table,
+// and doze off at the desk when nothing comes their way. A table whose every
+// ticket is done stands up for a beer, a kick-about and a dance; when the
+// whole run ships, the whole floor — command center included — joins in.
+//
// Everything is clickable: a person or a ticket opens its dossier (owned by
// the wrapper), a table focuses the camera, empty floor clears. The camera is
// the user's: drag to orbit, wheel to zoom, right-drag to pan, "follow" keeps
// whoever is working in the middle. Everything animates on the GPU per frame
// while there is something to animate — a run, live tickets, fresh pulses,
-// the camera gliding, follow or spin — and the loop drops to on-demand when
-// there is not, and stops when the tab is hidden. Under
-// prefers-reduced-motion nothing moves at all.
+// the camera gliding, follow or spin. After a run the floor keeps its life at
+// an ambient 30 fps for a few minutes (the party, the naps) and then settles
+// to on-demand; a hidden tab renders nothing. Under prefers-reduced-motion
+// nothing moves: everyone holds a still pose of their mood.
//
// Per-frame work allocates nothing: the lerps, colours and curve samples go
// through scratch objects made once, and the wall clock is read once per
@@ -80,9 +97,16 @@ const BOARD_W = 3.4;
const BOARD_H = 1.9;
const BOARD_BACK = SEAT_R + 2.4;
const BOARD_BOTTOM = 1.9;
-/** The pipeline's stage: a platform at the back of the hall, behind the boards. */
-const STAGE_R = 2.7;
-const STAGE_H = 0.22;
+/** The command center: the harness's glass room. */
+const HQ_HEX = '#8b5cf6';
+const HQ_WALL_H = 2.7;
+const HQ_GLASS_H = 1.05;
+const HQ_DOOR_W = 0.95;
+const HQ_DOOR_H = 2.05;
+/** How long the floor keeps living (ambient frames) after it last changed. */
+const AMBIENT_MS = 4 * 60_000;
+/** When this page first saw the floor: the idle clock for anyone the log has not heard from. */
+const FLOOR_BORN = Date.now();
const BURST_S = 1.7;
const DISPATCH_S = 5;
const FLASH_S = 0.9;
@@ -101,6 +125,50 @@ interface Placed {
seats: Map;
/** Ticket card centres in table space, for threads and bursts. */
slots: Map;
+ /** The command center's floor plan, for the harness. */
+ hq?: HQ;
+}
+
+/** The command center's floor plan, in its own space (the room's centre at the origin). */
+interface HQ {
+ w: number;
+ d: number;
+ /** Where each of the crew waits inside, on a stool. */
+ spots: Map;
+ /** Where whoever is on stands, on the pad outside the door. */
+ pads: Map;
+ doorIn: THREE.Vector3;
+ doorOut: THREE.Vector3;
+ padAt: THREE.Vector3;
+}
+
+/**
+ * commandLayout plans the room: stools in rows facing the front glass, the
+ * console along the back wall, the door in the middle of the front, and the
+ * pad outside it — one place on the pad per agent who is on right now.
+ */
+function commandLayout(agents: FloorAgent[], running: boolean): HQ {
+ const n = Math.max(agents.length, 1);
+ const cols = Math.max(3, Math.min(5, Math.ceil(Math.sqrt(n * 2))));
+ const rows = Math.ceil(n / cols);
+ const cellW = 0.95;
+ const cellD = 1.0;
+ const w = Math.max(4.8, cols * cellW + 1.5);
+ const d = Math.max(3.2, rows * cellD + 2.0);
+ const spots = new Map();
+ agents.forEach((a, i) => {
+ const row = Math.floor(i / cols);
+ const inRow = Math.min(cols, agents.length - row * cols);
+ const col = i % cols;
+ // Stagger alternate rows, so the back row is seen between the heads of the front.
+ const x = -((inRow - 1) * cellW) / 2 + col * cellW + (row % 2 ? cellW * 0.25 : 0);
+ spots.set(a.id, new THREE.Vector3(x, 0, -d / 2 + 1.45 + row * cellD));
+ });
+ const on = running ? agents.filter((a) => a.active) : [];
+ const padAt = new THREE.Vector3(0, 0, d / 2 + 1.75);
+ const pads = new Map();
+ on.forEach((a, k) => pads.set(a.id, padAt.clone().setX((k - (on.length - 1) / 2) * 1.1)));
+ return { w, d, spots, pads, doorIn: new THREE.Vector3(0, 0, d / 2 - 0.5), doorOut: new THREE.Vector3(0, 0, d / 2 + 0.55), padAt };
}
const ORDER: TicketState[] = ['working', 'review', 'blocked', 'failed', 'queued', 'done'];
@@ -128,7 +196,7 @@ function ticketSlots(team: FloorTeam): { shown: FloorTicket[]; more: number; slo
}
/** Tables on a shallow arc facing the camera; seats around each table. */
-function layout(teams: FloorTeam[]): Placed[] {
+function layout(teams: FloorTeam[], running: boolean): Placed[] {
const n = teams.length;
return teams.map((team, i) => {
const x = (i - (n - 1) / 2) * TABLE_GAP;
@@ -137,11 +205,13 @@ function layout(teams: FloorTeam[]): Placed[] {
const hex = team.internal ? '#8b5cf6' : HEX[teamColor(team.crew ? '' : team.id).name] ?? HEX.gray;
const seats = new Map();
if (team.internal) {
- // The harness stands on its platform (Stage), at lecterns in an arc.
- for (const [id, p] of stageLayout(team.agents, new THREE.Vector3())) {
- seats.set(id, { pos: p, angle: Math.PI / 2, screen: p.clone().setY(1.0) });
+ // The harness waits in the command center; whoever is on is out on the pad.
+ const hq = commandLayout(team.agents, running);
+ for (const a of team.agents) {
+ const p = hq.pads.get(a.id) ?? hq.spots.get(a.id)!;
+ seats.set(a.id, { pos: p, angle: Math.PI / 2, screen: p.clone().setY(1.0) });
}
- return { team, pos, hex, seats, slots: new Map() };
+ return { team, pos, hex, seats, slots: new Map(), hq };
}
const manager = team.agents.find((a) => a.seat === 'manager');
const others = team.agents.filter((a) => a.seat !== 'manager');
@@ -178,6 +248,47 @@ function FrameClock() {
return null;
}
+/**
+ * Asks for ~30 frames a second while `on`, for AMBIENT_MS after `resetKey`
+ * last changed — so a finished floor keeps partying (or napping) for a while
+ * without holding the GPU at full rate forever in a forgotten tab.
+ */
+function AmbientPump({ on, resetKey }: { on: boolean; resetKey: unknown }) {
+ const invalidate = useThree((s) => s.invalidate);
+ useEffect(() => {
+ if (!on) return undefined;
+ const until = Date.now() + AMBIENT_MS;
+ const id = window.setInterval(() => {
+ if (Date.now() > until) {
+ window.clearInterval(id);
+ return;
+ }
+ invalidate();
+ }, 33);
+ return () => window.clearInterval(id);
+ }, [on, resetKey, invalidate]);
+ return null;
+}
+
+/**
+ * useMood is what a person is doing, re-read every frame and set as state
+ * only when it changes (a few times a minute), so the labels follow without
+ * the scene re-rendering per frame.
+ */
+function useMood(inputs: Omit): Mood {
+ const [mood, setMood] = useState(() => moodFor({ ...inputs, now: Date.now() }));
+ const { active, running, partying, since, seed } = inputs;
+ // Inputs changed (the log moved): re-read at once, even with no frame coming.
+ useEffect(() => {
+ setMood(moodFor({ active, running, partying, since, seed, now: Date.now() }));
+ }, [active, running, partying, since, seed]);
+ useFrame(() => {
+ const next = moodFor({ active, running, partying, since, seed, now: frame.now * 1000 });
+ if (next !== mood) setMood(next);
+ });
+ return mood;
+}
+
/** Scratch objects for per-frame maths — allocated once, never per frame. */
const _scale = new THREE.Vector3();
const _dir = new THREE.Vector3();
@@ -198,7 +309,7 @@ function useDocumentVisible(): boolean {
}
export default function TeamFloor3D({ floor, running, dark, reducedMotion, selection, onSelect, pulses, onTicket, onContextLost }: TeamFloor3DProps) {
- const placed = useMemo(() => layout(floor.teams), [floor.teams]);
+ const placed = useMemo(() => layout(floor.teams, running), [floor.teams, running]);
const byID = useMemo(() => new Map(placed.map((p) => [p.team.id, p])), [placed]);
const [focus, setFocus] = useState(null);
const [focusDistance, setFocusDistance] = useState(null);
@@ -229,13 +340,12 @@ export default function TeamFloor3D({ floor, running, dark, reducedMotion, selec
[floor.teams],
);
const freshPulse = pulses.length > 0 && Date.now() - pulses[pulses.length - 1].at < PULSE_TTL_MS;
- const frameloop: 'always' | 'demand' | 'never' = !visible
- ? 'never'
- : reducedMotion
- ? 'demand'
- : running || autoRotate || follow || anyLive || freshPulse
- ? 'always'
- : 'demand';
+ const lively = running || autoRotate || follow || anyLive || freshPulse;
+ const frameloop: 'always' | 'demand' | 'never' = !visible ? 'never' : reducedMotion ? 'demand' : lively ? 'always' : 'demand';
+ // Between runs the floor still lives — naps, the party — at an ambient
+ // frame rate, for a while after it last changed.
+ const ambient = visible && !reducedMotion && !lively;
+ const shipped = useMemo(() => floorShipped(floor, running), [floor, running]);
// A lost context (GPU reset, VRAM eviction) is reported up; the wrapper
// swaps in the flat map and says so. The listener is removed on unmount.
@@ -351,6 +461,7 @@ export default function TeamFloor3D({ floor, running, dark, reducedMotion, selec
>
+
{floor.links.map((link) => {
@@ -360,16 +471,18 @@ export default function TeamFloor3D({ floor, running, dark, reducedMotion, selec
return ;
})}
{placed.map((p) =>
- p.team.internal ? (
- {
@@ -388,6 +501,7 @@ export default function TeamFloor3D({ floor, running, dark, reducedMotion, selec
onSelect={onSelect}
pulses={pulses}
onTicket={onTicket}
+ partying={tableParties(p.team, shipped)}
onFocus={() => {
setFocus(p.pos.clone().setY(0.8));
setFocusDistance(13);
@@ -504,6 +618,7 @@ function Table({
onSelect,
pulses,
onTicket,
+ partying,
onFocus,
}: {
placed: Placed;
@@ -514,6 +629,8 @@ function Table({
onSelect: (sel: FloorSelection) => void;
pulses: FloorPulse[];
onTicket?: (id: string) => void;
+ /** Every ticket here is done: beers, a kick-about, a dance. */
+ partying: boolean;
onFocus: () => void;
}) {
const { team, pos, hex, seats, slots } = placed;
@@ -548,11 +665,29 @@ function Table({
const boardDir = useMemo(() => (managerPos ? managerPos.pos.clone().setY(0).normalize() : new THREE.Vector3(0, 0, -1)), [managerPos]);
const signAt = useMemo(() => boardDir.clone().multiplyScalar(BOARD_BACK).setY(BOARD_BOTTOM + BOARD_H + 0.75), [boardDir]);
const tableCentre = useMemo(() => new THREE.Vector3(0, TABLE_Y + 0.3, 0), []);
+ // Where the players stand for the kick-about: behind their chairs, in seat order round the table.
+ const pitch = useMemo(
+ () =>
+ team.agents
+ .filter((a) => !(a.active && running))
+ .map((a) => seats.get(a.id))
+ .filter((x): x is Seat => !!x)
+ .sort((a, b) => a.angle - b.angle)
+ .map((x) => x.pos.clone().setY(0).multiplyScalar((SEAT_R + 0.8) / SEAT_R)),
+ [team.agents, seats, running],
+ );
return (
{activeAgent && animate && }
+ {partying && animate && (
+ <>
+
+
+ >
+ )}
+ {partying && pitch.length >= 2 && }
{/* Pedestal + top. */}
@@ -582,6 +717,7 @@ function Table({
{team.name}
+ {partying ? '🎉 all done · ' : ''}
{team.total > 0 ? `${team.done}/${team.total} · ${pct}%` : running ? 'no tickets yet' : 'idle'}
{team.blocked > 0 ? ` · ${team.blocked} blocked` : ''}
{team.gate === 'green' ? ' · proved' : team.gate === 'red' ? ' · RED' : team.gate === 'unverified' ? ' · unverified' : ''}
@@ -622,6 +758,7 @@ function Table({
selected={selected}
tall={a.seat !== 'manager' && i % 2 === 1}
lookAt={a.active ? undefined : lookAt}
+ partying={partying}
onSelect={() => onSelect(selected ? null : { kind: 'agent', id: a.id, team: team.id })}
/>
);
@@ -801,6 +938,44 @@ function TicketCard({
}
const UP = new THREE.Vector3(0, 1, 0);
+const _walkPos = new THREE.Vector3();
+
+/** Where a seated figure is, in its seat's space: on the chair. */
+const SEATED_AT = new THREE.Vector3(0, 0, 0.1);
+/** Where it stands when it gets up: behind the chair, facing the table. */
+const STANDING_AT = new THREE.Vector3(0, 0, 0.85);
+
+/** The stroll: up, round behind the chairs, and back to sit down. */
+const STROLL = [STANDING_AT, new THREE.Vector3(0.85, 0, 1.25), new THREE.Vector3(-0.85, 0, 1.25), STANDING_AT, SEATED_AT];
+
+function hoverPick(onSelect: () => void, setHover: (v: boolean) => void) {
+ return {
+ onClick: (e: ThreeEvent) => {
+ e.stopPropagation();
+ onSelect();
+ },
+ onPointerOver: (e: ThreeEvent) => {
+ e.stopPropagation();
+ setHover(true);
+ document.body.style.cursor = 'pointer';
+ },
+ onPointerOut: () => {
+ setHover(false);
+ document.body.style.cursor = '';
+ },
+ };
+}
+
+/** Floating z's over a sleeper. */
+function Zzz() {
+ return (
+
+ z
+ z
+ Z
+
+ );
+}
function Person({
agent,
@@ -813,6 +988,7 @@ function Person({
selected,
tall,
lookAt,
+ partying,
onSelect,
}: {
agent: FloorAgent;
@@ -827,21 +1003,34 @@ function Person({
tall?: boolean;
/** Table-space point this person glances at when idle (the one working). */
lookAt?: THREE.Vector3;
+ /** The table is done: this person is at the party. */
+ partying: boolean;
onSelect: () => void;
}) {
const isManager = agent.seat === 'manager';
const active = agent.active && running;
const screen = useRef(null);
- const body = useRef(null);
- const head = useRef(null);
const flash = useRef(null);
const halo = useRef(null);
const pulse = useRef(null);
const code = useRef([]);
+ const mug = useRef(null);
+ const lift = useRef(null);
const flashStart = useRef(-1);
const wasActive = useRef(false);
const [hover, setHover] = useState(false);
const invalidate = useThree((s) => s.invalidate);
+ const rig = useRig();
+ const seed = useMemo(() => seedOf(agent.id), [agent.id]);
+ const look = useMemo(() => lookOf(seed), [seed]);
+ const phase = (seed % 628) / 100;
+ const mood = useMood({ active, running, partying, since: agent.lastAt ?? FLOOR_BORN, seed });
+ const standing = isParty(mood);
+ // The motion a mood carries: when it began, the stroll's path, how seated.
+ const moodStart = useRef<{ mood: Mood; at: number }>({ mood, at: -1 });
+ const walk = useRef(null);
+ const sit = useRef(standing ? 0 : 1);
+ const prev = useRef(new THREE.Vector3().copy(standing ? STANDING_AT : SEATED_AT));
// A flash ring when this person starts working: the "message received"
// cue, once per activation.
@@ -850,11 +1039,11 @@ function Person({
wasActive.current = active;
invalidate();
}, [active, invalidate]);
- // Hover and selection ease the scale over several frames; on-demand
+ // Hover, selection and a new mood ease over several frames; on-demand
// rendering has to be asked for each of them.
useEffect(() => {
invalidate();
- }, [hover, selected, invalidate]);
+ }, [hover, selected, mood, invalidate]);
// Seat faces the table centre: rotate the whole person so its screen is
// between them and the table.
@@ -864,34 +1053,50 @@ function Person({
const local = lookAt.clone().sub(pos).applyAxisAngle(UP, -yaw);
return THREE.MathUtils.clamp(Math.atan2(-local.x, -local.z), -1.1, 1.1);
}, [lookAt, pos, yaw]);
+ const glance = mood === 'watch' || mood === 'coffee' ? headYaw : 0;
- useFrame(({ clock }) => {
+ useFrame(({ clock }, delta) => {
const t = clock.getElapsedTime();
if (screen.current) {
- screen.current.emissiveIntensity = active ? (animate ? 1.2 + Math.sin(t * 6) * 0.5 : 1.4) : 0.12;
+ screen.current.emissiveIntensity = active ? (animate ? 1.2 + Math.sin(t * 6) * 0.5 : 1.4) : mood === 'nap' ? 0.04 : 0.12;
}
- if (body.current) {
- if (animate) {
- // Typing: a small nod while active; a slow breath otherwise.
- body.current.position.y = active ? Math.abs(Math.sin(t * 9)) * 0.03 : Math.sin(t * 1.2 + angle) * 0.01;
- body.current.rotation.x = active ? Math.sin(t * 9) * 0.04 : 0;
+ const root = rig.root.current;
+ if (root) {
+ if (moodStart.current.mood !== mood || moodStart.current.at < 0) {
+ moodStart.current = { mood, at: t };
+ walk.current = mood === 'stroll' && animate ? makeWalk([root.position.clone(), ...STROLL], t, 0.85) : null;
}
- const target = hover || selected ? 1.08 : 1;
- if (Math.abs(body.current.scale.x - target) > 0.002) {
- body.current.scale.lerp(_scale.setScalar(target), 0.2);
- invalidate();
+ if (!animate) {
+ root.position.copy(standing ? STANDING_AT : SEATED_AT);
+ root.rotation.y = 0;
+ sit.current = standing ? 0 : 1;
+ stillPose(rig, mood, sit.current, glance);
+ } else {
+ let facing = 0;
+ if (walk.current) {
+ const dir = walkAt(walk.current, t, _walkPos);
+ if (dir === null) walk.current = null;
+ else facing = dir;
+ root.position.copy(_walkPos);
+ } else {
+ root.position.lerp(standing ? STANDING_AT : SEATED_AT, 0.07);
+ }
+ const speed = delta > 0 ? root.position.distanceTo(prev.current) / delta : 0;
+ prev.current.copy(root.position);
+ root.rotation.y = turnToward(root.rotation.y, walk.current && speed > 0.05 ? facing : 0, 0.15);
+ // Seated only once back at the chair.
+ const atChair = root.position.distanceToSquared(SEATED_AT) < 0.02;
+ sit.current = THREE.MathUtils.lerp(sit.current, !standing && atChair && !walk.current ? 1 : 0, 0.14);
+ poseRig(rig, { mood, t, since: t - moodStart.current.at, phase, sit: sit.current, speed, glance });
}
- }
- if (head.current) {
- // Idle people glance at whoever is working; the worker watches the screen.
- const want = active ? 0 : headYaw;
- if (animate && Math.abs(head.current.rotation.y - want) > 0.002) {
- head.current.rotation.y = THREE.MathUtils.lerp(head.current.rotation.y, want, 0.06);
+ const target = hover || selected ? 1.08 : 1;
+ if (Math.abs(root.scale.x - target) > 0.002) {
+ root.scale.lerp(_scale.setScalar(target), 0.2);
invalidate();
- } else if (!animate) {
- head.current.rotation.y = want;
}
+ if (lift.current) lift.current.position.y = (1 - sit.current) * 0.28;
}
+ if (mug.current) mug.current.visible = !active && mood !== 'coffee';
if (flash.current) {
const age = flashStart.current < 0 ? Infinity : (performance.now() - flashStart.current) / 1000;
const visible = age < 1.2 && animate;
@@ -935,22 +1140,8 @@ function Person({
const label = agent.id.length > 18 ? agent.id.slice(0, 17) + '…' : agent.id;
const saying = agent.lastMessage && agent.lastMessage.length > 54 ? agent.lastMessage.slice(0, 53) + '…' : agent.lastMessage;
- const skin = isManager ? hex : '#e5e7eb';
- const pick = {
- onClick: (e: ThreeEvent) => {
- e.stopPropagation();
- onSelect();
- },
- onPointerOver: (e: ThreeEvent) => {
- e.stopPropagation();
- setHover(true);
- document.body.style.cursor = 'pointer';
- },
- onPointerOut: () => {
- setHover(false);
- document.body.style.cursor = '';
- },
- };
+ const pick = hoverPick(onSelect, setHover);
+ const quiet = !active && !selected && !hover && !isManager;
return (
@@ -981,33 +1172,38 @@ function Person({
- {/* Person */}
-
-
-
-
-
-
-
-
-
-
- {/* Eyes, so a turned head reads as a glance. */}
-
-
-
-
-
-
-
-
- {isManager && (
-
-
-
-
- )}
-
+ {/* The person: gets up, walks about, sits back down. */}
+
+
+
+ {/* Name + the pop-out when working, riding with the person. */}
+
+
+ {active && (
+
+
+ {agent.task ? `on ${agent.task}` : 'working'}
+
+
+
+
+ {saying && {saying} }
+
+ )}
+ {mood === 'nap' &&
}
+
+ {moodShows(mood) && {MOOD_GLYPH[mood]} }
+ {glyphFor(agent)} {label}
+ {isManager && {team.managerDefault ? ' · manager (default)' : ' · manager'} }
+ {agent.borrowed && {` · ${agent.seat} from the ${agent.borrowed}`} }
+
+
+
+
+
{/* Desk + monitor, toward the table (−z after the yaw). */}
@@ -1022,153 +1218,322 @@ function Person({
- {/* A mug on the idle desk; it goes when the typing starts. */}
- {!active && (
-
-
-
-
- )}
+ {/* The mug waits on the desk — in the hand on a coffee break, gone while typing. */}
+
+
+
+
{/* Activation flash. */}
- {/* Name + the pop-out when working. */}
-
-
- {active && (
-
-
- {agent.task ? `on ${agent.task}` : 'working'}
-
-
-
-
- {saying && {saying} }
-
- )}
-
- {glyphFor(agent)} {label}
- {isManager && {team.managerDefault ? ' · manager (default)' : ' · manager'} }
- {agent.borrowed && {` · ${agent.seat} from the ${agent.borrowed}`} }
-
-
-
);
}
-/** Standing places on the stage: an arc facing the camera, the newest speaker in the middle. */
-function stageLayout(seats: { id: string }[], at: THREE.Vector3): Map {
- const out = new Map();
- const n = seats.length;
- if (n === 0) return out;
- const span = Math.min(Math.PI * 0.95, 0.9 * Math.max(n - 1, 0) + 0.001);
- seats.forEach((s, k) => {
- const f = n === 1 ? 0.5 : k / (n - 1);
- const angle = Math.PI / 2 - span / 2 + f * span;
- out.set(s.id, new THREE.Vector3(at.x + Math.cos(angle) * (STAGE_R - 0.5), STAGE_H, at.z + Math.sin(angle) * (STAGE_R - 0.5) * 0.55 - 0.5));
+/**
+ * The kick-about at a done table: a ball passed round the players behind
+ * their chairs and back again, lobbed over the table when it has to be.
+ * Only on the party's football rounds; between them it rests out of sight.
+ */
+function Football({ spots, animate }: { spots: THREE.Vector3[]; animate: boolean }) {
+ const ball = useRef(null);
+ const shadow = useRef(null);
+ const PASS_S = 1.3;
+ useFrame(() => {
+ const m = ball.current;
+ if (!m) return;
+ const on = isFootballRound(frame.now * 1000);
+ m.visible = on;
+ if (shadow.current) shadow.current.visible = on;
+ if (!on) return;
+ const n = spots.length;
+ const legs = 2 * (n - 1);
+ const k = animate ? frame.now / PASS_S : 0.5;
+ const leg = Math.floor(k) % legs;
+ const u = k - Math.floor(k);
+ const from = leg < n - 1 ? leg : legs - leg;
+ const to = leg < n - 1 ? leg + 1 : legs - leg - 1;
+ const a = spots[from];
+ const b = spots[to];
+ const h = Math.sin(u * Math.PI) * (0.3 + a.distanceTo(b) * 0.32);
+ m.position.lerpVectors(a, b, u);
+ m.position.y = 0.13 + h;
+ if (animate) {
+ m.rotation.x += 0.18;
+ m.rotation.z += 0.07;
+ }
+ if (shadow.current) {
+ shadow.current.position.set(m.position.x, 0.012, m.position.z);
+ const s = 1 / (1 + h * 0.8);
+ shadow.current.scale.set(s, s, s);
+ }
});
- return out;
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
}
/**
- * The pipeline's stage: where the run's thinking happens between the tables'
- * typing. A platform with a screen naming the phase the run is in and what is
- * being said, and the phase agents — planner, splitter, architect… — standing
- * at lecterns; whoever is speaking is lit, the rest wait in the wings.
+ * The command center: the harness's glass room. A mission screen on the back
+ * wall names the phase; the crew — dispatcher, planner, splitter, composer… —
+ * wait inside on stools; whoever the run hands the microphone to walks out
+ * through the sliding door onto the pad in front, says their piece and walks
+ * back in. The room's name and phase hang above it.
*/
-function Stage({
+function CommandCenter({
+ team,
seats,
phase,
at,
- positions,
+ hq,
running,
animate,
dark,
+ partying,
selection,
onSelect,
onFocus,
}: {
+ team: FloorTeam;
seats: FloorStageSeat[];
phase: FloorPhase | null;
at: THREE.Vector3;
- /** Each figure's place, relative to the platform (the harness table's seats). */
- positions: Map;
+ hq: HQ;
running: boolean;
animate: boolean;
dark: boolean;
+ /** The run shipped: the crew parties too. */
+ partying: boolean;
selection: FloorSelection;
onSelect: (sel: FloorSelection) => void;
onFocus: () => void;
}) {
+ const { w, d } = hq;
+ const out = seats.filter((s) => s.active && running && hq.pads.has(s.id));
const live = running && !!phase;
- const glow = useRef(null);
+ const padGlow = useRef(null);
+ const screenGlow = useRef(null);
+ const lamp = useRef(null);
+ const doorL = useRef(null);
+ const doorR = useRef(null);
+ /** Scene seconds until which the door stays open: walkers keep pushing it. */
+ const door = useRef({ until: 0 });
+ const invalidate = useThree((s) => s.invalidate);
+ const busy = out.length > 0;
useFrame(({ clock }) => {
- if (!glow.current) return;
const t = clock.getElapsedTime();
- glow.current.emissiveIntensity = live ? (animate ? 0.55 + Math.sin(t * 2.2) * 0.3 : 0.7) : 0.12;
+ if (padGlow.current) padGlow.current.emissiveIntensity = busy ? (animate ? 0.8 + Math.sin(t * 3) * 0.35 : 1) : 0.15;
+ if (screenGlow.current) screenGlow.current.emissiveIntensity = live ? (animate ? 0.7 + Math.sin(t * 1.7) * 0.15 : 0.8) : 0.2;
+ if (lamp.current) lamp.current.emissiveIntensity = busy ? (animate ? 1.2 + Math.sin(t * 8) * 0.8 : 1.5) : 0.2;
+ // The door slides open for anyone passing, and shuts behind them.
+ const open = t < door.current.until;
+ const goal = open ? HQ_DOOR_W * 0.75 : HQ_DOOR_W / 4;
+ for (const [ref, side] of [[doorL, -1], [doorR, 1]] as const) {
+ const m = ref.current;
+ if (!m) continue;
+ const want = side * goal;
+ if (!animate) m.position.x = want;
+ else if (Math.abs(m.position.x - want) > 0.002) {
+ m.position.x = THREE.MathUtils.lerp(m.position.x, want, 0.14);
+ invalidate();
+ }
+ }
});
+
+ const glass = ;
+ const frameMat = ;
const message = phase?.message && phase.message.length > 90 ? phase.message.slice(0, 89) + '…' : phase?.message;
+ const sideW = (w - HQ_DOOR_W) / 2;
+ const inside = seats.length - out.length;
+ const pathLen = hq.padAt.z - hq.doorOut.z;
return (
- {/* Platform */}
- { e.stopPropagation(); onFocus(); }}>
-
-
+ {/* The room's floor: click it to go in. */}
+ {
+ e.stopPropagation();
+ onFocus();
+ }}
+ >
+
+
-
-
-
+ {/* Back wall, its console and the mission screen. */}
+
+
+
- {/* The screen at the back of the stage. */}
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
-
-
-
+
+
+
-
+
- phase
- {phase ? phase.id : running ? 'starting' : 'idle'}
+ mission · phase
+ {phase ? phase.id : partying ? 'shipped 🎉' : running ? 'starting' : 'standing by'}
{phase?.agent && {phase.agent} }
{message && {message} }
-
-
-
- Harness · internal
- {seats.length === 0 ? 'no phase agents this run' : `${seats.filter((x) => x.spoke).length}/${seats.length} have spoken`}
+
+
+
+
+ {Array.from({ length: Math.max(2, Math.floor((w - 1) / 0.9)) }).map((_, i, all) => (
+
+
+
+
+ ))}
+ {/* Glass: the sides, and the front either side of the door. */}
+ {([-1, 1] as const).map((side) => (
+
+
+
+ {glass}
+
+
+
+ {glass}
+
+
+
+ {frameMat}
+
+
+
+ {frameMat}
+
+ {/* The door frame. */}
+
+
+ {frameMat}
+
+
+ ))}
+
+
+ {frameMat}
+
+ {/* The on-air lamp over the door: lit while someone is out on the pad. */}
+
+
+
+
+ {/* The sliding door. */}
+ {([-1, 1] as const).map((side) => (
+
+
+
+
+ ))}
+ {/* A plant, and the coffee machine the coffee breaks come from. */}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {/* Stools. */}
+ {team.agents.map((a) => {
+ const s = hq.spots.get(a.id);
+ if (!s) return null;
+ return (
+
+
+
+
+ );
+ })}
+ {/* The walkway and the pad. */}
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {busy && }
+
+ {partying && animate && }
+
+
+
+ Command center
+
+ {seats.length === 0
+ ? 'harness · no crew this run'
+ : out.length > 0
+ ? `${out.map((s) => s.id).join(', ')} on the pad · ${inside} inside`
+ : `harness · ${seats.length} crew · ${seats.filter((x) => x.spoke).length} have spoken`}
+
- {seats.map((seat, i) => {
- const p = positions.get(seat.id);
- if (!p) return null;
+ {seats.map((seat) => {
+ const spot = hq.spots.get(seat.id);
+ if (!spot) return null;
const selected = selection?.kind === 'agent' && selection.id === seat.id;
return (
- onSelect(selected ? null : { kind: 'agent', id: seat.id, team: HARNESS_ID })}
/>
);
@@ -1177,100 +1542,152 @@ function Stage({
);
}
-/** One person standing at a lectern on the stage. */
-function StageFigure({ seat, pos, tall, running, animate, selected, onSelect }: { seat: FloorStageSeat; pos: THREE.Vector3; tall: boolean; running: boolean; animate: boolean; selected: boolean; onSelect: () => void }) {
- const active = seat.active && running;
- const body = useRef(null);
- const ring = useRef(null);
+/** One of the crew: on a stool inside, or out on the pad when it is their turn. */
+function CrewFigure({
+ seat,
+ spot,
+ pad,
+ hq,
+ running,
+ animate,
+ partying,
+ selected,
+ door,
+ onSelect,
+}: {
+ seat: FloorStageSeat;
+ spot: THREE.Vector3;
+ pad?: THREE.Vector3;
+ hq: HQ;
+ running: boolean;
+ animate: boolean;
+ partying: boolean;
+ selected: boolean;
+ door: { current: { until: number } };
+ onSelect: () => void;
+}) {
+ const active = seat.active && running && !!pad;
+ const rig = useRig();
+ const lift = useRef(null);
const [hover, setHover] = useState(false);
const invalidate = useThree((s) => s.invalidate);
- const hex = '#8b5cf6';
- const glyph = STAGE_GLYPHS[seat.id] ?? STAGE_GLYPHS[seat.phase ?? ''] ?? '🎓';
+ const clock = useThree((s) => s.clock);
+ const seed = useMemo(() => seedOf(seat.id), [seat.id]);
+ const look = useMemo(() => lookOf(seed), [seed]);
+ const phase = (seed % 628) / 100;
+ const raw = useMood({ active, running, partying, since: seat.lastAt ?? FLOOR_BORN, seed });
+ // No pitch in here: the crew dances instead.
+ const mood: Mood = raw === 'football' ? 'dance' : raw;
+ const dest = active ? pad! : spot;
+ const destKey = `${dest.x.toFixed(2)},${dest.z.toFixed(2)}`;
+ const pos = useRef(dest.clone());
+ const prev = useRef(dest.clone());
+ const walk = useRef(null);
+ const planned = useRef(destKey);
+ const moodStart = useRef<{ mood: Mood; at: number }>({ mood, at: -1 });
+ const sit = useRef(active || isParty(mood) ? 0 : 1);
+
+ // A new place to be: walk there — through the door when it is on the other side of it.
useEffect(() => {
+ if (planned.current === destKey) return;
+ planned.current = destKey;
+ const inside = (v: THREE.Vector3) => v.z < hq.d / 2 - 0.1;
+ const from = pos.current.clone();
+ const pts = [from];
+ if (inside(from) && !inside(dest)) pts.push(hq.doorIn, hq.doorOut);
+ else if (!inside(from) && inside(dest)) pts.push(hq.doorOut, hq.doorIn);
+ pts.push(dest.clone());
+ walk.current = animate ? makeWalk(pts, clock.getElapsedTime(), 1.7) : null;
+ if (!animate) pos.current.copy(dest);
invalidate();
- }, [hover, selected, active, invalidate]);
- useFrame(({ clock }) => {
- const t = clock.getElapsedTime();
- if (body.current) {
- if (animate) {
- // Speaking: a lively sway; waiting: a slow breath; dim before they have spoken.
- body.current.position.y = active ? Math.abs(Math.sin(t * 5)) * 0.04 : Math.sin(t * 1.1 + pos.x) * 0.01;
- body.current.rotation.z = active ? Math.sin(t * 2.5) * 0.05 : 0;
+ }, [destKey, dest, hq, animate, clock, invalidate]);
+ useEffect(() => {
+ invalidate();
+ }, [hover, selected, mood, invalidate]);
+
+ useFrame(({ clock: c }, delta) => {
+ const root = rig.root.current;
+ if (!root) return;
+ const t = c.getElapsedTime();
+ const inside = pos.current.z < hq.d / 2 - 0.1;
+ if (moodStart.current.mood !== mood || moodStart.current.at < 0) {
+ moodStart.current = { mood, at: t };
+ if (mood === 'stroll' && animate && inside && !walk.current) {
+ walk.current = makeWalk([pos.current.clone(), spot.clone().add(new THREE.Vector3(0.38, 0, 0.42)), spot.clone().add(new THREE.Vector3(-0.38, 0, 0.42)), spot.clone()], t, 0.6);
}
- const target = hover || selected ? 1.08 : 1;
- if (Math.abs(body.current.scale.x - target) > 0.002) {
- body.current.scale.lerp(_scale.setScalar(target), 0.2);
+ }
+ let facing = Math.PI;
+ let walking = false;
+ if (walk.current && animate) {
+ const dir = walkAt(walk.current, t, pos.current);
+ if (dir === null) walk.current = null;
+ else {
+ facing = dir;
+ walking = true;
+ if (pos.current.distanceTo(hq.doorIn) < 1.1 || pos.current.distanceTo(hq.doorOut) < 1.1) door.current.until = t + 0.4;
invalidate();
}
+ } else {
+ walk.current = null;
+ pos.current.copy(dest);
}
- if (ring.current) {
- ring.current.visible = active || selected;
- if (animate) {
- const k = (t * 0.9) % 1;
- const s = active ? 0.8 + k * 0.9 : 1;
- ring.current.scale.set(s, s, s);
- (ring.current.material as THREE.MeshBasicMaterial).opacity = active ? 0.55 * (1 - k) : 0.9;
- }
+ root.position.copy(pos.current);
+ const standing = active || isParty(mood) || walking || !inside;
+ if (!animate) {
+ root.rotation.y = Math.PI;
+ sit.current = standing ? 0 : 1;
+ stillPose(rig, mood, sit.current, 0, active);
+ } else {
+ const speed = delta > 0 ? pos.current.distanceTo(prev.current) / delta : 0;
+ root.rotation.y = turnToward(root.rotation.y, facing, 0.15);
+ sit.current = THREE.MathUtils.lerp(sit.current, standing ? 0 : 1, 0.14);
+ poseRig(rig, { mood, t, since: t - moodStart.current.at, phase, sit: sit.current, speed: walking ? speed : 0, glance: 0, talk: active });
}
+ prev.current.copy(pos.current);
+ const target = hover || selected ? 1.1 : 1;
+ if (Math.abs(root.scale.x - target) > 0.002) {
+ root.scale.lerp(_scale.setScalar(target), 0.2);
+ invalidate();
+ }
+ if (lift.current) lift.current.position.y = (1 - sit.current) * 0.28;
});
- const pick = {
- onClick: (e: ThreeEvent) => {
- e.stopPropagation();
- onSelect();
- },
- onPointerOver: (e: ThreeEvent) => {
- e.stopPropagation();
- setHover(true);
- document.body.style.cursor = 'pointer';
- },
- onPointerOut: () => {
- setHover(false);
- document.body.style.cursor = '';
- },
- };
+
+ const pick = hoverPick(onSelect, setHover);
+ const glyph = STAGE_GLYPHS[seat.id] ?? STAGE_GLYPHS[seat.phase ?? ''] ?? '🎓';
const saying = seat.lastMessage && seat.lastMessage.length > 54 ? seat.lastMessage.slice(0, 53) + '…' : seat.lastMessage;
- const tint = seat.spoke ? '#ddd6fe' : '#e5e7eb';
+ const named = active || hover || selected;
+ const title = `${seat.id}${seat.phase ? ` · ${seat.phase}` : ''} · ${active ? 'on the pad' : MOOD_LABEL[mood]} · ${seat.spoke ? 'has spoken' : 'has not spoken yet'} · click for the dossier`;
return (
-
-
-
-
-
- {active && }
- {/* Lectern */}
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- {active && (
-
-
{seat.phase ? `${seat.phase} · speaking` : 'speaking'}
-
- {saying &&
{saying} }
+
+
+
+
+
+ {active && (
+
+ {seat.phase ? `${seat.phase} · on air` : 'on air'}
+
+ {saying && {saying} }
+
+ )}
+ {mood === 'nap' && !active &&
}
+ {named ? (
+
+ {moodShows(mood) && !active && {MOOD_GLYPH[mood]} }
+ {glyph} {seat.id}
+ {seat.phase && {` · ${seat.phase}`} }
+
+ ) : (
+ // Inside, a chip, not a name tag: the room stays a room.
+
+ {glyph}
+ {moodShows(mood) && {MOOD_GLYPH[mood]} }
+
+ )}
- )}
-
- {glyph} {seat.id}
- {seat.phase && {` · ${seat.phase}`} }
-
-
-
+
+
+
);
}
diff --git a/web/src/components/Live/floor/TeamFloorFlat.tsx b/web/src/components/Live/floor/TeamFloorFlat.tsx
index 2a8eb40..d8d45f7 100644
--- a/web/src/components/Live/floor/TeamFloorFlat.tsx
+++ b/web/src/components/Live/floor/TeamFloorFlat.tsx
@@ -3,6 +3,7 @@ import clsx from 'clsx';
import { teamColor } from '@/components/Board/teamColor';
import { workTables, type FloorAgent, type FloorHandoff, type FloorModel, type FloorPhase, type FloorPulse, type FloorTeam, type FloorTicket, type TicketState } from './floorModel';
import { LEGEND_STATES, TICKET_LABEL, ago, glyphFor, type FloorSelection } from './floorShared';
+import { MOOD_GLYPH, MOOD_LABEL, floorShipped, isParty, moodFor, moodShows, seedOf, tableParties, type Mood } from './floorLife';
// ── The team floor ───────────────────────────────────────────────────────
//
@@ -19,6 +20,11 @@ import { LEGEND_STATES, TICKET_LABEL, ago, glyphFor, type FloorSelection } from
// minutes at a time, and a thing that breathes reads as alive where a table
// reads as stuck. Every motion is CSS, so `prefers-reduced-motion` (honored
// globally) freezes it to a still picture with nothing lost.
+//
+// The people have moods (floorLife) here too — a badge and a little motion:
+// a doze, a stroll, a party bounce at a table that is done — and the harness
+// is a command center: the crew waits inside as small chips, and whoever is
+// on steps out through the door onto the pad with their name and bubble.
export interface TeamFloorFlatProps {
floor: FloorModel;
@@ -69,8 +75,14 @@ interface Placed {
agentPos: Map
;
}
+/** The command center's box, centred on its island. */
+const HQ_W = 300;
+const HQ_H = 112;
+const HQ_DOOR = 36;
+const HQ_COLS = 6;
+
/** Islands are laid out on a shallow arc, so three read as a floor and not a row. */
-function layout(teams: FloorTeam[]): Placed[] {
+function layout(teams: FloorTeam[], running: boolean): Placed[] {
const n = teams.length;
return teams.map((team, i) => {
const t = n === 1 ? 0.5 : i / (n - 1);
@@ -79,6 +91,20 @@ function layout(teams: FloorTeam[]): Placed[] {
const cy = n <= 2 ? H / 2 + 10 : 200 + (i % 2) * 190;
const hex = team.internal ? HEX.slate : HEX[teamColor(team.crew ? '' : team.id).name] ?? HEX.gray;
const agentPos = new Map();
+ if (team.internal) {
+ // The crew in rows inside the room; whoever is on, out on the pad.
+ const on = running ? team.agents.filter((a) => a.active) : [];
+ const inside = team.agents.filter((a) => !on.includes(a));
+ const rows = Math.max(1, Math.ceil(inside.length / HQ_COLS));
+ inside.forEach((a, i) => {
+ const row = Math.floor(i / HQ_COLS);
+ const inRow = Math.min(HQ_COLS, inside.length - row * HQ_COLS);
+ const col = i % HQ_COLS;
+ agentPos.set(a.id, { x: cx - ((inRow - 1) * 40) / 2 + col * 40, y: cy + 12 + (row - (rows - 1) / 2) * 28 });
+ });
+ on.forEach((a, k) => agentPos.set(a.id, { x: cx + (k - (on.length - 1) / 2) * 70, y: cy + HQ_H / 2 + 26 }));
+ return { team, cx, cy, hex, agentPos };
+ }
// Manager at the head; everyone else spaced along the front edge of the
// island, where the tickets are within reach.
const others = team.agents.filter((a) => a.seat !== 'manager');
@@ -94,7 +120,9 @@ function layout(teams: FloorTeam[]): Placed[] {
}
export default function TeamFloorFlat({ floor, running, now, onTicket, selection = null, onSelect, pulses }: TeamFloorFlatProps) {
- const placed = useMemo(() => layout(floor.teams), [floor.teams]);
+ const placed = useMemo(() => layout(floor.teams, running), [floor.teams, running]);
+ const shipped = floorShipped(floor, running);
+ const anyParty = shipped || floor.teams.some((t) => tableParties(t, false));
const working = workTables(floor).length;
const byID = useMemo(() => new Map(placed.map((p) => [p.team.id, p])), [placed]);
@@ -105,10 +133,11 @@ export default function TeamFloorFlat({ floor, running, now, onTicket, selection
setTick(now);
return undefined;
}
- if (!running) return undefined;
+ // The party keeps its clock after the run: the rounds change, people dance.
+ if (!running && !anyParty) return undefined;
const id = window.setInterval(() => setTick(Date.now()), 1000);
return () => window.clearInterval(id);
- }, [running, now]);
+ }, [running, now, anyParty]);
// Tickets with a pulse still on them flash, whichever kind it was.
const flashing = useMemo(() => new Set((pulses ?? []).filter((p) => p.ticket && tick - p.at < 4000).map((p) => p.ticket!)), [pulses, tick]);
@@ -158,7 +187,7 @@ export default function TeamFloorFlat({ floor, running, now, onTicket, selection
})}
{placed.map((p) => (
-
+
))}
{floor.handoffs.map((h) => (
@@ -187,6 +216,7 @@ function Island({
onSelect,
flashing,
phase,
+ partying,
}: {
placed: Placed;
running: boolean;
@@ -196,9 +226,10 @@ function Island({
onSelect?: (sel: FloorSelection) => void;
flashing: Set;
phase: FloorPhase | null;
+ partying: boolean;
}) {
const { team, cx, cy, hex, agentPos } = placed;
- if (team.internal) return ;
+ if (team.internal) return ;
const pct = team.total > 0 ? Math.round((team.done / team.total) * 100) : 0;
const rim =
team.gate === 'green' ? '#10b981' : team.gate === 'red' ? '#ef4444' : team.waitingOn.length > 0 ? '#f59e0b' : hex;
@@ -218,7 +249,14 @@ function Island({
{team.name}
+ {partying && (
+
+ Every ticket here is done — the table is celebrating
+ 🎉
+
+ )}
+ {partying ? '🍻 ' : ''}
{team.total > 0 ? `${team.done}/${team.total} done` : running ? 'no tickets yet' : 'idle'}
{team.blocked > 0 ? ` · ${team.blocked} blocked` : ''}
{team.gate === 'green' ? ' · proved' : team.gate === 'red' ? ' · RED' : team.gate === 'unverified' ? ' · unverified' : ''}
@@ -232,7 +270,7 @@ function Island({
{team.agents.map((a) => {
const pos = agentPos.get(a.id);
if (!pos) return null;
- return ;
+ return ;
})}
{team.waitingOn.length > 0 && (
@@ -245,31 +283,46 @@ function Island({
}
/**
- * The harness's own table: drawn quieter than a team's — dashed rim, no desk
- * of tickets, no progress — with the phase the run is in where a team shows
- * its progress, so it reads as the machinery beside the work, not a team.
+ * The harness's command center: a room, not a team's island — no tickets, no
+ * progress. The crew waits inside as small chips; whoever is on steps out
+ * through the door onto the pad below with their name and bubble; the phase
+ * the run is in is written on the wall where a team shows its progress.
*/
-function HarnessIsland({ placed, running, tick, selection, onSelect, phase }: { placed: Placed; running: boolean; tick: number; selection: FloorSelection; onSelect?: (sel: FloorSelection) => void; phase: FloorPhase | null }) {
+function HarnessIsland({ placed, running, tick, selection, onSelect, phase, partying }: { placed: Placed; running: boolean; tick: number; selection: FloorSelection; onSelect?: (sel: FloorSelection) => void; phase: FloorPhase | null; partying: boolean }) {
const { team, cx, cy, hex, agentPos } = placed;
- const active = team.agents.some((a) => a.active);
+ const out = running ? team.agents.filter((a) => a.active) : [];
+ const busy = out.length > 0;
+ const left = cx - HQ_W / 2;
+ const top = cy - HQ_H / 2;
+ const bottom = cy + HQ_H / 2;
+ // The outline, open at the door.
+ const walls = `M ${cx - HQ_DOOR / 2} ${bottom} H ${left + 14} Q ${left} ${bottom} ${left} ${bottom - 14} V ${top + 14} Q ${left} ${top} ${left + 14} ${top} H ${left + HQ_W - 14} Q ${left + HQ_W} ${top} ${left + HQ_W} ${top + 14} V ${bottom - 14} Q ${left + HQ_W} ${bottom} ${left + HQ_W - 14} ${bottom} H ${cx + HQ_DOOR / 2}`;
return (
-
+
{team.charter}
-
-
-
- {team.name}
-
-
- {phase ? `phase · ${phase.id}` : running ? 'starting' : 'standing by'}
+
+
+
+ {/* The sliding door: open while someone is out on the pad. */}
+
+
+
+ {busy ? 'on air: someone is out on the pad' : 'nobody on air'}
+
+ {/* The pad and the path to it. */}
+
+
+
+ {partying ? '🎉 ' : ''}Command center
-
- not a team · no tickets
+
+ {phase ? `phase · ${phase.id}` : partying ? 'shipped' : running ? 'starting' : 'standing by'}
{team.agents.map((a) => {
const pos = agentPos.get(a.id);
if (!pos) return null;
- return ;
+ const onPad = out.includes(a);
+ return ;
})}
);
@@ -391,6 +444,8 @@ function Avatar({
team,
selected,
onSelect,
+ partying = false,
+ compact = false,
}: {
agent: FloorAgent;
x: number;
@@ -401,15 +456,28 @@ function Avatar({
team: FloorTeam;
selected: boolean;
onSelect?: (sel: FloorSelection) => void;
+ /** The table is done: the party is on. */
+ partying?: boolean;
+ /** A chip, not a name tag: the command center's crew waiting inside. */
+ compact?: boolean;
}) {
- const r = agent.seat === 'manager' ? 17 : 15;
+ const r = compact ? 10 : agent.seat === 'manager' ? 17 : 15;
const pick = onSelect ? () => onSelect({ kind: 'agent', id: agent.id, team: team.id }) : undefined;
const label = agent.id.length > 16 ? agent.id.slice(0, 15) + '…' : agent.id;
const isManager = agent.seat === 'manager';
const glowing = agent.active && running;
+ const mood: Mood = moodFor({ active: glowing, running, partying, since: agent.lastAt ?? tick, now: tick, seed: seedOf(agent.id) });
return (
{`${agent.id} · ${isManager ? (team.managerDefault ? 'project manager (run default)' : 'project manager') : team.internal ? 'harness · internal' : agent.seat}` +
(agent.borrowed ? ` (the team names none — lent by the ${agent.borrowed})` : '') +
- (agent.touched ? ` · ${agent.touched} ticket${agent.touched === 1 ? '' : 's'} touched` : '')}
+ (agent.touched ? ` · ${agent.touched} ticket${agent.touched === 1 ? '' : 's'} touched` : '') +
+ ` · ${MOOD_LABEL[mood]}`}
- {glowing && }
- {glowing && }
-
-
- {glyphFor(agent)}
-
-
- {label}
-
+
+ {glowing && }
+ {glowing && }
+
+
+ {glyphFor(agent)}
+
+ {moodShows(mood) && (
+
+ {MOOD_GLYPH[mood]}
+
+ )}
+
+ {!compact && (
+
+ {label}
+
+ )}
{isManager && (
{team.managerDefault ? 'manager · run default' : 'manager'}
diff --git a/web/src/components/Live/floor/floorLife.test.ts b/web/src/components/Live/floor/floorLife.test.ts
new file mode 100644
index 0000000..2c24d48
--- /dev/null
+++ b/web/src/components/Live/floor/floorLife.test.ts
@@ -0,0 +1,86 @@
+import { describe, expect, it } from 'vitest';
+import { MOOD_SLOT_MS, PARTY_SLOT_MS, SLEEPY_MS, WATCH_MS, floorShipped, isFootballRound, isParty, moodFor, moodShows, seedOf, tableParties, type LifeInputs, type Mood } from './floorLife';
+import type { FloorTeam } from './floorModel';
+
+const T0 = Date.parse('2026-09-10T10:00:00Z');
+
+function life(over: Partial = {}): LifeInputs {
+ return { active: false, running: true, partying: false, since: T0, now: T0, seed: seedOf('go-worker'), ...over };
+}
+
+function team(over: Partial = {}): FloorTeam {
+ return { id: 'backend', name: 'Backend', manager: 'triage', managerDefault: false, agents: [], tickets: [], total: 2, done: 2, blocked: 0, inFlight: 0, complete: true, gate: '', waitingOn: [], ...over };
+}
+
+describe('moodFor', () => {
+ it('works while working, whatever else is true', () => {
+ expect(moodFor(life({ active: true, partying: true, since: T0 - SLEEPY_MS * 5 }))).toBe('work');
+ });
+
+ it('watches the worker when just idle', () => {
+ expect(moodFor(life({ now: T0 + WATCH_MS - 1 }))).toBe('watch');
+ });
+
+ it('is deterministic: the same inputs, the same mood', () => {
+ const at = life({ now: T0 + 40_000 });
+ expect(moodFor(at)).toBe(moodFor({ ...at }));
+ });
+
+ it('takes breaks while idle in a live run — coffee, stretches, walks', () => {
+ const seen = new Set();
+ for (let k = 0; k < 60; k++) seen.add(moodFor(life({ now: T0 + WATCH_MS + k * MOOD_SLOT_MS })));
+ expect(seen.has('coffee')).toBe(true);
+ expect(seen.has('stroll')).toBe(true);
+ expect(seen.has('work')).toBe(false);
+ });
+
+ it('never has the whole room change mood at once', () => {
+ const now = T0 + 30_000;
+ const moods = new Set(Array.from({ length: 20 }, (_, i) => moodFor(life({ seed: seedOf(`agent-${i}`), now }))));
+ expect(moods.size).toBeGreaterThan(1);
+ });
+
+ it('dozes off after a long wait, and sleeps after a very long one', () => {
+ let naps = 0;
+ for (let k = 0; k < 40; k++) if (moodFor(life({ now: T0 + SLEEPY_MS + 1 + k * MOOD_SLOT_MS })) === 'nap') naps++;
+ expect(naps).toBeGreaterThan(10);
+ expect(moodFor(life({ now: T0 + SLEEPY_MS * 2 + 1 }))).toBe('nap');
+ });
+
+ it('naps at the desk once the run has stopped', () => {
+ expect(moodFor(life({ running: false, now: T0 + 60_000 }))).toBe('nap');
+ });
+
+ it('parties at a done table: beers, a dance, and football every third round', () => {
+ const football = T0 - (T0 % (PARTY_SLOT_MS * 3)) + PARTY_SLOT_MS + 1;
+ expect(isFootballRound(football)).toBe(true);
+ expect(moodFor(life({ partying: true, now: football }))).toBe('football');
+ const beers = football + PARTY_SLOT_MS;
+ expect(['cheers', 'dance']).toContain(moodFor(life({ partying: true, now: beers })));
+ expect(isParty(moodFor(life({ partying: true, now: beers })))).toBe(true);
+ });
+
+ it('badges every mood but working and watching', () => {
+ expect(moodShows('work')).toBe(false);
+ expect(moodShows('watch')).toBe(false);
+ expect(moodShows('nap')).toBe(true);
+ expect(moodShows('cheers')).toBe(true);
+ });
+});
+
+describe('parties', () => {
+ it('a done table parties; an unfinished one or the harness does not, until the run ships', () => {
+ expect(tableParties(team(), false)).toBe(true);
+ expect(tableParties(team({ complete: false, done: 1 }), false)).toBe(false);
+ expect(tableParties(team({ id: 'harness', internal: true, total: 0, complete: false }), false)).toBe(false);
+ expect(tableParties(team({ id: 'harness', internal: true, total: 0, complete: false }), true)).toBe(true);
+ });
+
+ it('the floor ships when the run is over and every work table is done', () => {
+ const harness = team({ id: 'harness', internal: true, total: 0, complete: false });
+ expect(floorShipped({ teams: [harness, team()] }, false)).toBe(true);
+ expect(floorShipped({ teams: [harness, team()] }, true)).toBe(false);
+ expect(floorShipped({ teams: [harness, team(), team({ id: 'web', complete: false, done: 0 })] }, false)).toBe(false);
+ expect(floorShipped({ teams: [harness] }, false)).toBe(false);
+ });
+});
diff --git a/web/src/components/Live/floor/floorLife.ts b/web/src/components/Live/floor/floorLife.ts
new file mode 100644
index 0000000..779974c
--- /dev/null
+++ b/web/src/components/Live/floor/floorLife.ts
@@ -0,0 +1,156 @@
+import { workTables, type FloorModel, type FloorTeam } from './floorModel';
+
+// ── Life on the floor ────────────────────────────────────────────────────
+//
+// A run is mostly waiting: one agent types while the rest of the table has
+// nothing to do. Drawn as statues, that reads as a stuck page; drawn as
+// people, it reads as an office. So everyone who is not working has a mood —
+// they watch whoever is, fetch a coffee, stretch, take a walk round the room,
+// and doze off when nothing has come their way for a while. A table whose
+// every ticket is done throws a party: beers, a kick-about, a dance. When the
+// whole run is over and everything shipped, the whole floor joins in.
+//
+// It is pure and deterministic — a person's mood is a function of the time,
+// how long they have been idle and a seed from their name — so both stages
+// (and the dossier) agree on what someone is doing without sharing state, a
+// re-render never reshuffles anybody, and it is testable.
+
+export type Mood = 'work' | 'watch' | 'coffee' | 'stretch' | 'stroll' | 'nap' | 'cheers' | 'football' | 'dance';
+
+export const MOOD_GLYPH: Record = {
+ work: '⌨️',
+ watch: '👀',
+ coffee: '☕',
+ stretch: '🙆',
+ stroll: '🚶',
+ nap: '💤',
+ cheers: '🍻',
+ football: '⚽',
+ dance: '🕺',
+};
+
+export const MOOD_LABEL: Record = {
+ work: 'working',
+ watch: 'watching the others work',
+ coffee: 'on a coffee break',
+ stretch: 'stretching their legs',
+ stroll: 'taking a walk round the room',
+ nap: 'having a nap',
+ cheers: 'having a beer — the table is done',
+ football: 'playing football — the table is done',
+ dance: 'dancing — the table is done',
+};
+
+/** Moods worth a badge: watching is the default, and working has its own bubble. */
+export function moodShows(mood: Mood): boolean {
+ return mood !== 'work' && mood !== 'watch';
+}
+
+/** A party mood: the table is done. */
+export function isParty(mood: Mood): boolean {
+ return mood === 'cheers' || mood === 'football' || mood === 'dance';
+}
+
+/** Idle for less than this, a person just watches whoever is working. */
+export const WATCH_MS = 12_000;
+/** How long one idle mood lasts before the next is drawn. */
+export const MOOD_SLOT_MS = 9_000;
+/** Idle past this, a nap becomes likely… */
+export const DROWSY_MS = 60_000;
+/** …and past this, the likeliest thing of all. */
+export const SLEEPY_MS = 150_000;
+/** One round of the party: beers, then a kick-about, then beers again. */
+export const PARTY_SLOT_MS = 14_000;
+
+export interface LifeInputs {
+ /** Working right now, in a live run. */
+ active: boolean;
+ running: boolean;
+ /** Every ticket at this person's table is done (or the whole run shipped). */
+ partying: boolean;
+ /** When this person last did anything, in ms — or when the floor was first seen. */
+ since: number;
+ now: number;
+ /** From seedOf(id): who they are decides what they like doing. */
+ seed: number;
+}
+
+/** A stable 32-bit seed from a name (FNV-1a). */
+export function seedOf(id: string): number {
+ let h = 0x811c9dc5;
+ for (let i = 0; i < id.length; i++) {
+ h ^= id.charCodeAt(i);
+ h = Math.imul(h, 0x01000193);
+ }
+ return h >>> 0;
+}
+
+/** Mixes a seed with a slot number into a fresh roll, 0..2^32. */
+function roll(seed: number, slot: number): number {
+ let h = (seed ^ Math.imul(slot + 0x9e3779b9, 0x85ebca6b)) >>> 0;
+ h = Math.imul(h ^ (h >>> 16), 0x7feb352d);
+ h = Math.imul(h ^ (h >>> 15), 0x846ca68b);
+ return (h ^ (h >>> 16)) >>> 0;
+}
+
+/** Which party round it is — shared by the whole table, so they play together. */
+export function partyRound(now: number): number {
+ return Math.floor(now / PARTY_SLOT_MS);
+}
+
+/** Every third party round is a kick-about. */
+export function isFootballRound(now: number): boolean {
+ return partyRound(now) % 3 === 1;
+}
+
+/**
+ * moodFor is what a person is doing right now.
+ *
+ * Working beats everything. A done table parties — everyone at it together,
+ * the round decides whether it is beers or football, and a few dance. A live
+ * run's idle people watch at first, then draw a new mood every MOOD_SLOT_MS
+ * (each on their own offset, so the room never changes all at once), with
+ * naps growing likelier the longer nothing comes their way. A run that is not
+ * live and did not ship is a room of people napping at their desks.
+ */
+export function moodFor(i: LifeInputs): Mood {
+ if (i.active && i.running) return 'work';
+ if (i.partying) {
+ const round = partyRound(i.now);
+ if (round % 3 === 1) return 'football';
+ return roll(i.seed, round) % 4 === 0 ? 'dance' : 'cheers';
+ }
+ const idle = Math.max(0, i.now - i.since);
+ if (!i.running) {
+ // The run stopped: people drift off one by one, not in unison.
+ return idle > 4_000 + (i.seed % 20_000) ? 'nap' : 'watch';
+ }
+ if (idle < WATCH_MS) return 'watch';
+ if (idle > SLEEPY_MS * 2) return 'nap';
+ const offset = i.seed % MOOD_SLOT_MS;
+ const slot = Math.floor((i.now + offset) / MOOD_SLOT_MS);
+ const r = roll(i.seed, slot) % 100;
+ const napChance = idle > SLEEPY_MS ? 60 : idle > DROWSY_MS ? 22 : 0;
+ if (r < napChance) return 'nap';
+ const rest = (r - napChance) / (100 - napChance);
+ if (rest < 0.34) return 'watch';
+ if (rest < 0.58) return 'coffee';
+ if (rest < 0.74) return 'stretch';
+ return 'stroll';
+}
+
+/**
+ * floorShipped: the run is over and every work table finished — the whole
+ * floor, harness included, can put its feet up.
+ */
+export function floorShipped(floor: Pick, running: boolean): boolean {
+ if (running) return false;
+ const tables = workTables(floor);
+ return tables.length > 0 && tables.every((t) => t.total > 0 && t.complete);
+}
+
+/** A table parties when its own tickets are all done, or when the whole run shipped. */
+export function tableParties(team: FloorTeam, shipped: boolean): boolean {
+ if (shipped) return true;
+ return !team.internal && team.total > 0 && team.complete;
+}
diff --git a/web/src/components/Live/floor/floorRig.ts b/web/src/components/Live/floor/floorRig.ts
new file mode 100644
index 0000000..733f8bf
--- /dev/null
+++ b/web/src/components/Live/floor/floorRig.ts
@@ -0,0 +1,312 @@
+import { useRef, type RefObject } from 'react';
+import * as THREE from 'three';
+import type { Mood } from './floorLife';
+
+// ── A person, with limbs ─────────────────────────────────────────────────
+//
+// Everyone on the 3D floor is the same little figure: legs, a torso, arms on
+// shoulder pivots and a head. The pose is set per frame by poseRig from the
+// person's mood — typing, sipping a coffee, stretching, walking, dozing with
+// their head on the desk, raising a beer, dancing — so a scene of twenty
+// people costs twenty sets of transforms and no re-renders.
+//
+// Local space: the figure faces −z, its feet are at y = 0. Seated, it drops
+// onto a chair and its legs swing forward under the desk.
+
+export interface Rig {
+ /** Moves and turns the whole figure: walks, standing up. */
+ root: RefObject;
+ /** Bobs and leans the upper body. */
+ body: RefObject;
+ head: RefObject;
+ armL: RefObject;
+ armR: RefObject;
+ legL: RefObject;
+ legR: RefObject;
+ beer: RefObject;
+ cup: RefObject;
+}
+
+export function useRig(): Rig {
+ return {
+ root: useRef(null),
+ body: useRef(null),
+ head: useRef(null),
+ armL: useRef(null),
+ armR: useRef(null),
+ legL: useRef(null),
+ legR: useRef(null),
+ beer: useRef(null),
+ cup: useRef(null),
+ };
+}
+
+/** How far the body drops when seated. */
+export const SIT_DROP = 0.2;
+export const HIP_Y = 0.42;
+export const SHOULDER_Y = 1.1;
+export const HEAD_Y = 1.38;
+
+export interface RigLook {
+ shirt: string;
+ skin: string;
+ hair?: string;
+ /** Half-transparent: a seat the pipeline lent, or someone who has not spoken yet. */
+ ghost?: boolean;
+ crown?: boolean;
+}
+
+export interface PoseInputs {
+ mood: Mood;
+ /** Elapsed seconds (the scene clock). */
+ t: number;
+ /** Seconds since this mood began. */
+ since: number;
+ /** A per-person phase, so a table does not move in lockstep. */
+ phase: number;
+ /** 0 = standing, 1 = seated; eased by the caller. */
+ sit: number;
+ /** Walking speed right now, units per second (0 when still). */
+ speed: number;
+ /** The head's glance, when watching. */
+ glance: number;
+ /** Working by talking (on the command center's pad), not by typing. */
+ talk?: boolean;
+}
+
+const lerp = THREE.MathUtils.lerp;
+
+/** Eases a rotation toward a goal: poses blend rather than snap. */
+function ease(obj: THREE.Object3D | null, axis: 'x' | 'y' | 'z', goal: number, k = 0.18) {
+ if (!obj) return;
+ obj.rotation[axis] = lerp(obj.rotation[axis], goal, k);
+}
+
+/**
+ * poseRig sets every joint for this frame. Rotations about x swing a limb
+ * forward (toward −z) for positive angles: an arm at 1.3 types, at π points
+ * straight up.
+ */
+export function poseRig(rig: Rig, p: PoseInputs): void {
+ const { mood, t, since, phase, sit, speed } = p;
+ const walking = speed > 0.05;
+ const stride = walking ? Math.sin(t * 9 + phase) * 0.55 : 0;
+ let armL = 0.25;
+ let armR = 0.25;
+ let armLz = 0;
+ let armRz = 0;
+ let headX = 0;
+ let headY = p.glance;
+ let bodyX = 0;
+ let bodyY = 0;
+ let bodyZ = 0;
+ let bob = 0;
+ let kick = 0;
+ let beer = false;
+ let cup = false;
+
+ switch (mood) {
+ case 'work': {
+ if (p.talk) {
+ // Holding forth: hands that talk, a nod on the point.
+ armL = 0.7 + Math.sin(t * 2.6 + phase) * 0.45;
+ armR = 1.3 + Math.sin(t * 3.4 + phase + 1) * 0.55;
+ armLz = -0.25;
+ armRz = 0.3;
+ headX = Math.sin(t * 4) * 0.08;
+ bob = Math.abs(Math.sin(t * 2.6)) * 0.03;
+ headY = Math.sin(t * 0.9) * 0.3;
+ break;
+ }
+ // Typing: both hands on the keys, a nod in time.
+ armL = 1.25 + Math.sin(t * 16 + phase) * 0.1;
+ armR = 1.25 + Math.sin(t * 16 + phase + 1.7) * 0.1;
+ bodyX = -0.06 + Math.sin(t * 9) * 0.03;
+ bob = Math.abs(Math.sin(t * 9)) * 0.02;
+ headY = 0;
+ break;
+ }
+ case 'watch': {
+ armL = sit > 0.5 ? 0.9 : 0.2;
+ armR = sit > 0.5 ? 0.9 : 0.2;
+ bob = Math.sin(t * 1.2 + phase) * 0.008;
+ break;
+ }
+ case 'coffee': {
+ // A sip every four seconds, the cup back down between.
+ cup = true;
+ const k = ((since + phase) % 4) / 4;
+ const sip = k > 0.55 && k < 0.85 ? Math.sin(((k - 0.55) / 0.3) * Math.PI) : 0;
+ armR = 0.95 + sip * 1.35;
+ armRz = sip * 0.45;
+ headX = sip * 0.25;
+ armL = sit > 0.5 ? 0.9 : 0.25;
+ bob = Math.sin(t * 1.2 + phase) * 0.008;
+ break;
+ }
+ case 'stretch': {
+ // Arms up, a lean one way then the other, a yawn of a head tilt.
+ const s = Math.sin(since * 1.4);
+ armL = Math.PI - 0.15;
+ armR = Math.PI - 0.15;
+ armLz = -0.25 - s * 0.15;
+ armRz = 0.25 - s * 0.15;
+ bodyZ = s * 0.12;
+ headX = 0.25;
+ bob = 0.02;
+ break;
+ }
+ case 'stroll': {
+ armL = 0.1;
+ armR = 0.1;
+ headY = walking ? 0 : Math.sin(t * 0.8 + phase) * 0.6;
+ break;
+ }
+ case 'nap': {
+ // Head down on folded arms, a slow breath.
+ armL = sit > 0.5 ? 1.35 : 0.35;
+ armR = sit > 0.5 ? 1.35 : 0.35;
+ armLz = sit > 0.5 ? 0.5 : 0;
+ armRz = sit > 0.5 ? -0.5 : 0;
+ bodyX = sit > 0.5 ? -0.42 : -0.1;
+ headX = -0.55;
+ headY = 0.35;
+ bob = Math.sin(t * 1.1 + phase) * 0.012;
+ break;
+ }
+ case 'cheers': {
+ // Beer up, a clink toward the middle every few seconds.
+ beer = true;
+ const clink = Math.max(0, Math.sin(t * 2.2 + phase)) ** 6;
+ armR = 2.3 + clink * 0.5;
+ armRz = 0.2;
+ armL = 0.3 + Math.sin(t * 3 + phase) * 0.15;
+ bob = Math.abs(Math.sin(t * 3 + phase)) * 0.04;
+ headX = 0.2;
+ break;
+ }
+ case 'football': {
+ // On the toes, arms out for balance, the odd kick.
+ armL = 0.5;
+ armR = 0.5;
+ armLz = -0.5;
+ armRz = 0.5;
+ bob = Math.abs(Math.sin(t * 6 + phase)) * 0.06;
+ kick = Math.max(0, Math.sin(t * 1.6 + phase)) ** 8 * 1.1;
+ break;
+ }
+ case 'dance': {
+ const beat = t * 5 + phase;
+ armL = Math.PI * 0.75 + Math.sin(beat) * 0.6;
+ armR = Math.PI * 0.75 - Math.sin(beat) * 0.6;
+ armLz = -0.4;
+ armRz = 0.4;
+ bodyY = Math.sin(beat * 0.5) * 0.5;
+ bodyZ = Math.sin(beat) * 0.12;
+ bob = Math.abs(Math.sin(beat)) * 0.09;
+ break;
+ }
+ }
+
+ if (walking) {
+ // Arms swing against the legs; nothing else carries on mid-stride.
+ armL = 0.1 - stride * 0.8;
+ armR = 0.1 + stride * 0.8;
+ armLz = 0;
+ armRz = 0;
+ bob = Math.abs(Math.sin(t * 9 + phase)) * 0.05;
+ bodyX = 0.05;
+ bodyY = 0;
+ beer = mood === 'cheers';
+ }
+
+ if (rig.body.current) {
+ rig.body.current.position.y = bob - sit * SIT_DROP;
+ }
+ ease(rig.body.current, 'x', bodyX);
+ ease(rig.body.current, 'y', bodyY);
+ ease(rig.body.current, 'z', bodyZ);
+ ease(rig.head.current, 'x', headX, 0.1);
+ ease(rig.head.current, 'y', headY, 0.06);
+ ease(rig.armL.current, 'x', armL, 0.22);
+ ease(rig.armR.current, 'x', armR, 0.22);
+ ease(rig.armL.current, 'z', armLz, 0.22);
+ ease(rig.armR.current, 'z', armRz, 0.22);
+ // Seated, the thighs swing forward under the desk; standing, they walk.
+ const legSit = sit * 1.45;
+ if (rig.legL.current) {
+ rig.legL.current.rotation.x = lerp(rig.legL.current.rotation.x, legSit + stride, 0.3);
+ rig.legL.current.position.y = HIP_Y - sit * SIT_DROP;
+ }
+ if (rig.legR.current) {
+ rig.legR.current.rotation.x = lerp(rig.legR.current.rotation.x, legSit - stride + kick, 0.3);
+ rig.legR.current.position.y = HIP_Y - sit * SIT_DROP;
+ }
+ if (rig.beer.current) rig.beer.current.visible = beer;
+ if (rig.cup.current) rig.cup.current.visible = cup && !walking;
+}
+
+/**
+ * Pose a rig without animation (prefers-reduced-motion): a still picture of
+ * the same mood.
+ */
+export function stillPose(rig: Rig, mood: Mood, sit: number, glance: number, talk = false): void {
+ for (let i = 0; i < 30; i++) poseRig(rig, { mood, t: 0.3, since: 2.5, phase: 0, sit, speed: 0, glance, talk });
+}
+
+const SKINS = ['#f1c27d', '#e0ac69', '#c68642', '#8d5524', '#ffdbac', '#f5d0b5'];
+const HAIRS = ['#2b1d14', '#4a3222', '#a0522d', '#d6b370', '#111827', '#9ca3af', '#7c2d12'];
+const SHIRTS = ['#c7d2fe', '#bae6fd', '#bbf7d0', '#fde68a', '#fecdd3', '#e9d5ff', '#fed7aa', '#99f6e4'];
+
+/** A person's own look, from their seed: skin, hair and a shirt nobody else at the table shares often. */
+export function lookOf(seed: number): { skin: string; hair: string; shirt: string } {
+ return { skin: SKINS[seed % SKINS.length], hair: HAIRS[(seed >>> 4) % HAIRS.length], shirt: SHIRTS[(seed >>> 9) % SHIRTS.length] };
+}
+
+// ── Walking ──────────────────────────────────────────────────────────────
+
+export interface Walk {
+ points: THREE.Vector3[];
+ /** Cumulative length at each point. */
+ at: number[];
+ total: number;
+ /** Scene-clock seconds when the walk began. */
+ start: number;
+ speed: number;
+}
+
+export function makeWalk(points: THREE.Vector3[], start: number, speed = 1.6): Walk | null {
+ const pts = points.filter((p, i) => i === 0 || p.distanceToSquared(points[i - 1]) > 1e-6);
+ if (pts.length < 2) return null;
+ const at = [0];
+ for (let i = 1; i < pts.length; i++) at.push(at[i - 1] + pts[i].distanceTo(pts[i - 1]));
+ return { points: pts, at, total: at[at.length - 1], start, speed };
+}
+
+/**
+ * walkAt writes the position `t` seconds into a walk to `out`, and returns
+ * the direction of travel's yaw (for a figure facing −z) or null when the
+ * walk is over.
+ */
+export function walkAt(w: Walk, t: number, out: THREE.Vector3): number | null {
+ const d = Math.max(0, (t - w.start) * w.speed);
+ if (d >= w.total) {
+ out.copy(w.points[w.points.length - 1]);
+ return null;
+ }
+ let i = 1;
+ while (i < w.points.length - 1 && w.at[i] < d) i++;
+ const a = w.points[i - 1];
+ const b = w.points[i];
+ const seg = w.at[i] - w.at[i - 1] || 1;
+ out.copy(a).lerp(b, (d - w.at[i - 1]) / seg);
+ return Math.atan2(-(b.x - a.x), -(b.z - a.z));
+}
+
+/** Turns an angle toward a goal the short way round. */
+export function turnToward(current: number, goal: number, k: number): number {
+ let diff = goal - current;
+ while (diff > Math.PI) diff -= Math.PI * 2;
+ while (diff < -Math.PI) diff += Math.PI * 2;
+ return current + diff * k;
+}
diff --git a/web/src/styles/globals.css b/web/src/styles/globals.css
index 86bdd79..f636b44 100644
--- a/web/src/styles/globals.css
+++ b/web/src/styles/globals.css
@@ -517,3 +517,41 @@
.floor3d-screen-say { font-size: 9.5px; line-height: 1.3; opacity: 0.8; max-height: 3.9em; overflow: hidden; }
@media (prefers-reduced-motion: reduce) { .floor3d-screen-phase { text-shadow: none; } }
.floor-stage-live { animation: floorSelected 1.4s ease-in-out infinite; }
+
+/* ── Life on the floor: moods, naps, the command center's crew ── */
+.floor3d-mood { display: inline-block; margin-right: 3px; animation: floorMoodBob 2.4s ease-in-out infinite; }
+@keyframes floorMoodBob { 0%, 100% { transform: translateY(0) rotate(0); } 50% { transform: translateY(-2px) rotate(-8deg); } }
+.floor3d-name-quiet { font-size: 9.5px; font-weight: 500; padding: 1px 6px; opacity: 0.82; }
+.floor3d-chip {
+ display: inline-flex;
+ gap: 2px;
+ padding: 1px 5px;
+ border-radius: 9999px;
+ background: rgba(255, 255, 255, 0.78);
+ font-size: 10px;
+ box-shadow: 0 2px 6px rgba(17, 24, 39, 0.12);
+ backdrop-filter: blur(3px);
+}
+.dark .floor3d-chip { background: rgba(17, 24, 39, 0.78); }
+.floor3d-zzz { display: inline-flex; gap: 1px; align-self: flex-end; margin-right: -14px; color: #6366f1; font-weight: 800; font-style: italic; pointer-events: none; }
+.dark .floor3d-zzz { color: #a5b4fc; }
+.floor3d-zzz i { font-style: italic; opacity: 0; animation: floorZzz 2.7s ease-out infinite; }
+.floor3d-zzz i:nth-child(1) { font-size: 9px; }
+.floor3d-zzz i:nth-child(2) { font-size: 11px; animation-delay: 0.9s; }
+.floor3d-zzz i:nth-child(3) { font-size: 14px; animation-delay: 1.8s; }
+@keyframes floorZzz { 0% { opacity: 0; transform: translate(0, 4px); } 30% { opacity: 1; } 100% { opacity: 0; transform: translate(8px, -14px); } }
+
+/* The flat map's moods: badges over the avatars, a doze, a party bounce. */
+.floor-mood { animation: floorMoodBob 2.4s ease-in-out infinite; transform-box: fill-box; transform-origin: center; }
+.floor-avatar-nap .floor-avatar-inner { opacity: 0.75; animation: floorDoze 4s ease-in-out infinite; transform-box: fill-box; transform-origin: bottom center; }
+@keyframes floorDoze { 0%, 100% { transform: rotate(0); } 50% { transform: rotate(-9deg) translateY(1px); } }
+.floor-avatar-party .floor-avatar-inner { animation: floorParty 0.7s ease-in-out infinite alternate; transform-box: fill-box; transform-origin: bottom center; }
+@keyframes floorParty { from { transform: translateY(0) rotate(-4deg); } to { transform: translateY(-5px) rotate(4deg); } }
+.floor-avatar-stroll .floor-avatar-inner { animation: floorStroll 6s ease-in-out infinite; }
+@keyframes floorStroll { 0%, 100% { transform: translateX(0); } 30% { transform: translateX(10px); } 70% { transform: translateX(-10px); } }
+.floor-hq-door { transition: transform 0.4s ease; transform-box: fill-box; }
+.floor-party-badge { animation: floorMoodBob 1.6s ease-in-out infinite; transform-box: fill-box; transform-origin: center; }
+@media (prefers-reduced-motion: reduce) {
+ .floor3d-mood, .floor3d-zzz i, .floor-mood, .floor-avatar-nap .floor-avatar-inner, .floor-avatar-party .floor-avatar-inner, .floor-avatar-stroll .floor-avatar-inner, .floor-party-badge { animation: none !important; }
+ .floor3d-zzz i { opacity: 1; }
+}