S3.9: the org chart draws everyone as their office pixel person, with one shared CEO rule

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ZacharyZhang-NYandClaude Opus 5.5 committed 2026-10-09 13:56:49 -04:00
1 parent ccd698602e
commit e5a4e87300
7 files changed
+60 -7

No files matched your search

+2 -3
View File
@@ -12,6 +12,7 @@ import { dayPart, season } from "../shared/palette-clock";
import { pixelScale } from "../shared/pixel-scale";
import type { Camera, OfficeAgentInput } from "./office/office-types";
import { officeStatus } from "./office/agent-state";
import { officeRoster } from "./office/roster";
import { t } from "../i18n/i18n";
const FPS_INTERVAL = 1000 / 24;
@@ -42,9 +43,7 @@ export function OfficePage({ snapshot }: PageProps) {
if (simRef.current === null) simRef.current = new OfficeSimulation();
const inputs = useMemo<OfficeAgentInput[]>(() => {
const orgAgents = snapshot.agents
.filter((agent) => agent.orgId === orgId && agent.lifecycle === "active")
.sort((a, b) => a.createdAt.localeCompare(b.createdAt) || a.id.localeCompare(b.id));
const orgAgents = officeRoster(snapshot.agents.filter((agent) => agent.orgId === orgId));
const allTasks = snapshot.tasks.filter((task) => task.orgId === orgId);
const orgTasks = allTasks.filter(isBusinessTask);
return orgAgents.map((agent) => ({
@@ -0,0 +1,24 @@
import { useEffect, useRef } from "react";
import { appearanceFor, drawPerson } from "./sprites";
const W = 24;
const H = 24;
/** Each sprite pixel shows as a 2×2 block. */
const ZOOM = 2;
/** The person as the office draws them, from the shoulders up, so a face on the org chart is the same face at the desk. */
export function PixelAvatar({ id, isCeo }: { id: string; isCeo: boolean }) {
const ref = useRef<HTMLCanvasElement>(null);
useEffect(() => {
const canvas = ref.current!;
const scale = ZOOM * Math.max(1, Math.ceil(window.devicePixelRatio));
canvas.width = W * scale;
canvas.height = H * scale;
const ctx = canvas.getContext("2d");
if (!ctx) throw new Error("The avatar canvas has no 2D context.");
ctx.imageSmoothingEnabled = false;
ctx.setTransform(scale, 0, 0, scale, 0, 0);
drawPerson(ctx, W / 2, H + 10, appearanceFor(id, isCeo), { facing: "south", walkPhase: 0, working: false, carrying: null, timeMs: 0, animSeed: 0 }, isCeo);
}, [id, isCeo]);
return <canvas ref={ref} className="pixel-avatar" data-person={id} aria-hidden="true" style={{ width: W * ZOOM, height: H * ZOOM }} />;
}
+11
View File
@@ -0,0 +1,11 @@
/** Agents who come to the office, in hire order: the office and the org chart read the same list. */
export function officeRoster<T extends { id: string; lifecycle: string; createdAt: string }>(agents: T[]): T[] {
return agents
.filter((agent) => agent.lifecycle === "active")
.sort((a, b) => a.createdAt.localeCompare(b.createdAt) || a.id.localeCompare(b.id));
}
/** The first c-suite agent on the roster holds the CEO office and wears the CEO's look. */
export function ceoIdOf(roster: Array<{ id: string; roleLevel: string }>): string | null {
return roster.find((agent) => agent.roleLevel === "c_suite")?.id ?? null;
}
+2 -1
View File
@@ -1,6 +1,7 @@
import { buildFloorPlan, findPath } from "./floor-plan";
import type { AmenitySpot, Camera, FloorPlan, OfficeAgentInput, Rect, Vec } from "./office-types";
import { appearanceFor, type Carrying, type Facing, type SpriteAppearance } from "./sprites";
import { ceoIdOf } from "./roster";
export type AgentBehavior =
| { kind: "seated" }
@@ -278,7 +279,7 @@ export class OfficeSimulation {
* else takes open-floor desks in order.
*/
sync(inputs: OfficeAgentInput[]): void {
const ceoId = inputs.find((input) => input.roleLevel === "c_suite")?.id ?? null;
const ceoId = ceoIdOf(inputs);
const openCount = inputs.filter((input) => input.id !== ceoId).length;
const planRebuilt = openCount !== this.openDeskDemand;
if (planRebuilt) {
+5 -3
View File
@@ -1,9 +1,10 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Avatar } from "../../shared/ui";
import { PixelAvatar } from "../office/PixelAvatar";
import { ceoIdOf, officeRoster } from "../office/roster";
import type { AgentRecord, EmployeeRecord } from "../../../../shared/domain";
import { t } from "../../i18n/i18n";
const NODE_W = 208;
const NODE_W = 232;
const NODE_H = 64;
const H_GAP = 24;
const V_GAP = 44;
@@ -47,6 +48,7 @@ export function OrgChartTree({
onSelectAgent: (agentId: string) => void;
}) {
const layout = useMemo(() => buildLayout(boardChair, agents), [boardChair, agents]);
const ceoId = useMemo(() => ceoIdOf(officeRoster(agents)), [agents]);
const scrollRef = useRef<HTMLDivElement>(null);
const scaleRef = useRef(1);
const pendingCenter = useRef<{ cx: number; cy: number } | null>(null);
@@ -126,7 +128,7 @@ export function OrgChartTree({
onClick={() => { if (node.agentId) onSelectAgent(node.agentId); }}
disabled={!node.agentId}
>
<Avatar name={node.name} bot={!node.human} size="sm" />
{node.id !== "__org__" && <PixelAvatar id={node.id} isCeo={node.id === ceoId} />}
<span className="org-node-text">
<span className="org-node-name">{node.name}</span>
<span className="org-node-role">{node.role}</span>
+1
View File
@@ -635,6 +635,7 @@ a { color: inherit; }
}
.org-node-card:hover:not(:disabled) { border-color: var(--border-strong); box-shadow: var(--shadow-md); }
.org-node-card:disabled { cursor: default; }
.pixel-avatar { flex-shrink: 0; image-rendering: pixelated; }
.org-node-card.human { border-color: var(--amber); background: var(--amber-soft); }
.org-node-text { min-width: 0; display: flex; flex-direction: column; gap: 2px; }
.org-node-name {
+15
View File
@@ -113,6 +113,21 @@ test("the office floor opens agent details on click", async () => {
// Hired agents take open-floor desks in hire order and are clickable too.
await hireAgent(page, "Ava Patel", "Designer", "Design");
await hireAgent(page, "Ken Ito", "Engineer", "Engineering");
// The org chart draws everyone as the office's pixel people: the chair, the CEO and both hires.
await page.getByRole("button", { name: "Team", exact: true }).click();
const avatars = page.locator(".org-node-card canvas.pixel-avatar");
await expect(avatars).toHaveCount(4);
const painted = await avatars.evaluateAll((canvases) => canvases.map((canvas) => {
const { data } = (canvas as HTMLCanvasElement).getContext("2d")!.getImageData(0, 0, (canvas as HTMLCanvasElement).width, (canvas as HTMLCanvasElement).height);
let opaque = 0;
for (let i = 3; i < data.length; i += 4) if (data[i] === 255) opaque++;
return opaque;
}));
for (const count of painted) expect(count).toBeGreaterThan(100);
// Different people look different.
const looks = await avatars.evaluateAll((canvases) => canvases.map((canvas) => (canvas as HTMLCanvasElement).toDataURL()));
expect(new Set(looks).size).toBe(4);
await page.getByRole("button", { name: "Office", exact: true }).click();
await expect(canvas).toBeVisible();
await page.waitForTimeout(300);