S3.7: isometric office assets: animated monitors, coffee machine with steam, toilets, sinks, real-time wall clock, decorative window weather; explanatory on-screen notes and the hint bubble removed

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:34:12 -04:00
1 parent 23c7d55b1d
commit 202418f81c
17 files changed
+265 -94

No files matched your search

-3
View File
@@ -343,7 +343,6 @@ export const zh: Record<string, string> = {
"That file could not be read as an image.": "无法将该文件读取为图片。",
"Conversation messages": "会话消息",
"Office tools": "办公室工具",
"Agents with work stay at their desks. Only idle agents walk to coffee, snacks or the meeting table.": "有工作的智能体会一直待在工位;只有空闲的智能体才会去咖啡机、零食区或会议桌。",
"Avatar": "头像",
"Avatar emoji": "头像表情",
"Avatar color": "头像颜色",
@@ -709,8 +708,6 @@ export const zh: Record<string, string> = {
"Waiting for the {name} sign-in in your browser (check {check} of {checks}).": "正在等待你在浏览器中完成 {name} 登录(第 {check}/{checks} 次检查)。",
"Stop waiting": "停止等待",
"People to mention": "可提及的人",
"Only a task runs: an agent picks it up and does the work. Goals and projects group tasks; documents and knowledge are what agents read and write along the way.": "只有任务会被执行:智能体接手任务并完成工作。目标和项目用来归组任务;文档和知识是智能体在工作中读写的内容。",
"Agents read and write these while working on tasks. Nothing here starts work; a task does.": "智能体在执行任务时读写这些内容。这里的内容不会自行启动工作,只有任务会。",
"{title}: use the arrow keys to read each value": "{title}:用方向键逐项读取数值",
"{rate}% of judged work accepted": "已评判的工作中 {rate}% 被接受",
"No work judged yet": "还没有评判过的工作",
-2
View File
@@ -1,4 +1,3 @@
import { HintBubble } from "../shared/HintBubble";
import { oneOf, usePersistentState } from "../shared/view-state";
import { useMemo } from "react";
import { DocumentsPanel } from "./library/DocumentsPanel";
@@ -50,7 +49,6 @@ export function LibraryPage({ snapshot, actions, focus }: PageProps) {
<div className="content">
<div className="page">
<h1 className="page-h1">{tab === "documents" ? t("Documents") : t("Knowledge")}</h1>
<HintBubble>{t("Agents read and write these while working on tasks. Nothing here starts work; a task does.")}</HintBubble>
{tab === "documents"
? <DocumentsPanel focusId={focus?.kind === "document" ? focus.id : null} orgId={orgId} employeeId={employee.id} documents={documents} tasks={tasks} actions={actions} />
: <KnowledgePanel focusId={focus?.kind === "knowledge" ? focus.id : null} orgId={orgId} employeeId={employee.id} entries={entries} scopes={scopes} actions={actions} />}
+1 -2
View File
@@ -259,12 +259,11 @@ export function OfficePage({ snapshot }: PageProps) {
aria-label={t("Show a person")}
value={selectedId ?? ""}
onChange={(event) => setSelectedId(event.target.value || null)}
style={{ width: 200, height: 30 }}
style={{ width: 200 }}
>
<option value="">{t("Show a person…")}</option>
{inputs.map((input) => <option key={input.id} value={input.id}>{t("{name}, {activity}", { name: input.name, activity: officeActivityLabel(input.activity) })}</option>)}
</select>
<span className="office-legend">{t("Agents with work stay at their desks. Only idle agents walk to coffee, snacks or the meeting table.")}</span>
</div>
</div>
);
-2
View File
@@ -1,4 +1,3 @@
import { HintBubble } from "../shared/HintBubble";
import { oneOf, usePersistentState } from "../shared/view-state";
import { useMemo, useState } from "react";
import { Avatar, Button, Icon, IconButton, Pill, Segmented } from "../shared/ui";
@@ -114,7 +113,6 @@ export function WorkPage({ snapshot, actions, navigate, focus }: PageProps) {
<Button tone="primary" onClick={() => setNewTaskOpen(true)}><Icon name="plus" /> {t("New task")}</Button>
</TopbarActions>
<h1 className="page-h1">{t("Work")}</h1>
<HintBubble>{t("Only a task runs: an agent picks it up and does the work. Goals and projects group tasks; documents and knowledge are what agents read and write along the way.")}</HintBubble>
{scope && (
<div className="row" style={{ gap: 8, marginBottom: 8 }}>
<Pill>
+3 -8
View File
@@ -36,14 +36,9 @@ export function setGroundTransform(ctx: CanvasRenderingContext2D, frame: IsoFram
ctx.transform(K, K / 2, -K, K / 2, frame.originX, SKY_MARGIN);
}
/** Draws face-space art onto the south face of the back wall at plan y = `wallY`: x runs along the wall, y down from its top. */
export function setNorthWallTransform(ctx: CanvasRenderingContext2D, frame: IsoFrame, wallY: number): void {
ctx.transform(K, K / 2, 0, 1, frame.originX - wallY * K, SKY_MARGIN + (wallY * K) / 2 - BACK_WALL_H);
}
/** Draws face-space art onto the east face of the back wall at plan x = `wallX`: x runs south along the wall, y down from its top. */
export function setWestWallTransform(ctx: CanvasRenderingContext2D, frame: IsoFrame, wallX: number): void {
ctx.transform(-K, K / 2, 0, 1, frame.originX + wallX * K, SKY_MARGIN + (wallX * K) / 2 - BACK_WALL_H);
/** Draws face-space art onto a south-facing face at plan y = `planeY` whose top is at height `topZ`: x runs along the face, y down from its top. */
export function setSouthFaceTransform(ctx: CanvasRenderingContext2D, frame: IsoFrame, planeY: number, topZ: number): void {
ctx.transform(K, K / 2, 0, 1, frame.originX - planeY * K, SKY_MARGIN + (planeY * K) / 2 - topZ);
}
export interface BoxColors {
@@ -1,7 +1,7 @@
import type { AgentActivity, Camera, FloorPlan, Rect } from "./office-types";
import { drawBackWalls, drawGround, sceneDrawables, type Drawable } from "./scene-renderer";
import { depthOf, toIso, type IsoFrame } from "./iso";
import { skyScene } from "./sky";
import { skyScene, weatherFor } from "./sky";
import { drawPerson, type Carrying, type Facing, type SpriteAppearance } from "./sprites";
import type { DayPart, Season } from "../../shared/palette-clock";
@@ -77,9 +77,9 @@ export function renderOffice(ctx: CanvasRenderingContext2D, scene: HTMLCanvasEle
sctx.translate(-Math.round(input.camera.x), -Math.round(input.camera.y));
drawGround(sctx, input.frame, input.plan);
drawBackWalls(sctx, input.frame, input.plan, skyScene(input.clock));
drawBackWalls(sctx, input.frame, input.plan, { sky: skyScene(input.clock), weather: weatherFor(input.clock), clock: input.clock, timeMs: input.timeMs });
const lit = new Set(input.people.filter((person) => person.desk && (person.activity === "active" || person.activity === "thinking")).map((person) => person.desk!));
const items: Drawable[] = [...sceneDrawables(input.frame, input.plan, input.season, lit), ...input.people.map((person) => personDrawable(input, person))];
const items: Drawable[] = [...sceneDrawables(input.frame, input.plan, input.season, lit, input.timeMs), ...input.people.map((person) => personDrawable(input, person))];
items.sort((a, b) => a.depth - b.depth);
for (const item of items) item.draw(sctx);
+87
View File
@@ -0,0 +1,87 @@
import type { Rect } from "./office-types";
import { drawBox, setSouthFaceTransform, toIso, type IsoFrame } from "./iso";
/** Hand angles in radians, clockwise from twelve, for a local time. */
export function clockHands(date: Date): { hour: number; minute: number } {
const minutes = date.getMinutes();
return {
hour: (((date.getHours() % 12) + minutes / 60) / 12) * 2 * Math.PI,
minute: (minutes / 60) * 2 * Math.PI,
};
}
/** A 16-pixel wall clock in wall-face coordinates (x along the wall, y down), showing the real time. */
export function drawWallClock(ctx: CanvasRenderingContext2D, x: number, y: number, date: Date): void {
ctx.fillStyle = "#3f3831";
ctx.fillRect(x + 2, y, 12, 16);
ctx.fillRect(x, y + 2, 16, 12);
ctx.fillStyle = "#f2efe8";
ctx.fillRect(x + 2, y + 2, 12, 12);
ctx.fillStyle = "#8a8378";
for (const [tx, ty] of [[7, 2], [12, 7], [7, 12], [2, 7]] as const) ctx.fillRect(x + tx, y + ty, 2, 1);
const hands = clockHands(date);
hand(ctx, x + 8, y + 8, hands.minute, 5, "#2b2622");
hand(ctx, x + 8, y + 8, hands.hour, 3, "#b3543f");
}
function hand(ctx: CanvasRenderingContext2D, cx: number, cy: number, angle: number, length: number, color: string): void {
ctx.fillStyle = color;
for (let step = 0; step <= length; step++) {
ctx.fillRect(Math.round(cx + Math.sin(angle) * step - 0.5), Math.round(cy - Math.cos(angle) * step - 0.5), 1, 1);
}
}
/** A desk monitor's screen on its south face: scrolling code and a blinking cursor while its agent works, a standby light otherwise. */
export function drawMonitorScreen(ctx: CanvasRenderingContext2D, frame: IsoFrame, monitor: Rect, z: number, height: number, on: boolean, timeMs: number, seed: number): void {
ctx.save();
setSouthFaceTransform(ctx, frame, monitor.y + monitor.h, z + height);
const glass = { x: monitor.x + 2, y: 2, w: monitor.w - 4, h: height - 4 };
ctx.fillStyle = on ? "#0f2a1c" : "#20242c";
ctx.fillRect(glass.x, glass.y, glass.w, glass.h);
if (on) {
const scroll = Math.floor(timeMs / 350);
ctx.fillStyle = "#37d38a";
for (let row = 0; row < 4; row++) {
const width = 4 + ((seed * 7 + (scroll + row) * 13) % (glass.w - 6));
ctx.fillRect(glass.x + 1 + (row % 2) * 2, glass.y + 1 + row * 2, width, 1);
}
if (Math.floor(timeMs / 500) % 2 === 0) ctx.fillRect(glass.x + glass.w - 3, glass.y + glass.h - 2, 2, 1);
} else {
ctx.fillStyle = "#e0a23a";
ctx.fillRect(glass.x + glass.w - 2, glass.y + glass.h - 1, 1, 1);
}
ctx.restore();
}
/** The snack bar's coffee machine: body, cap, a lit button and a cup, with steam curling up from the cup. */
export function drawCoffeeMachine(ctx: CanvasRenderingContext2D, frame: IsoFrame, foot: Rect, z: number, timeMs: number): void {
drawBox(ctx, frame, foot, z, 14, { top: "#454b52", south: "#2e3238", east: "#23262b" });
drawBox(ctx, frame, { x: foot.x + 1, y: foot.y + 1, w: foot.w - 2, h: 5 }, z + 14, 3, { top: "#5a616a", south: "#3c4249", east: "#30353b" });
ctx.save();
setSouthFaceTransform(ctx, frame, foot.y + foot.h, z + 14);
ctx.fillStyle = Math.floor(timeMs / 700) % 2 === 0 ? "#e2553b" : "#8f3426";
ctx.fillRect(foot.x + 2, 2, 2, 2);
ctx.fillStyle = "#d8d3c8";
ctx.fillRect(foot.x + foot.w / 2 - 2, 9, 4, 4);
ctx.restore();
const cup = toIso(frame, { x: foot.x + foot.w / 2, y: foot.y + foot.h }, z + 6);
for (let puff = 0; puff < 3; puff++) {
const rise = ((timeMs / 120 + puff * 5) % 15);
ctx.fillStyle = `rgba(242,242,245,${0.75 * (1 - rise / 15)})`;
ctx.fillRect(Math.round(cup.x + Math.sin((rise + puff) / 2) * 1.5), Math.round(cup.y - 4 - rise), 1, 1);
}
}
/** A toilet against the back wall: tank and bowl in porcelain white, the bowl's opening shaded. */
export function drawToilet(ctx: CanvasRenderingContext2D, frame: IsoFrame, cx: number, backY: number): void {
const porcelain = { top: "#f4f6f8", south: "#d9dee4", east: "#c3c9d0" };
drawBox(ctx, frame, { x: cx - 6, y: backY, w: 12, h: 5 }, 0, 16, porcelain);
drawBox(ctx, frame, { x: cx - 5, y: backY + 5, w: 10, h: 11 }, 0, 8, porcelain);
drawBox(ctx, frame, { x: cx - 3, y: backY + 7, w: 6, h: 7 }, 8, 0, { top: "#9fb2c4", south: "#9fb2c4", east: "#9fb2c4" });
}
/** A wash basin set into a counter top at height `z`: a pale rim around a shaded bowl. */
export function drawBasin(ctx: CanvasRenderingContext2D, frame: IsoFrame, basin: Rect, z: number): void {
drawBox(ctx, frame, basin, z, 0, { top: "#e4ebf2", south: "#e4ebf2", east: "#e4ebf2" });
drawBox(ctx, frame, { x: basin.x + 4, y: basin.y + 3, w: basin.w - 8, h: basin.h - 6 }, z, 0, { top: "#8fa3b7", south: "#8fa3b7", east: "#8fa3b7" });
}
+87 -39
View File
@@ -1,7 +1,8 @@
import type { FloorPlan, Rect, RoomSpec } from "./office-types";
import { WALL } from "./floor-plan";
import { drawWindowSky, type SkyScene } from "./sky";
import { BACK_WALL_H, depthOf, drawBox, setGroundTransform, setNorthWallTransform, type BoxColors, type IsoFrame } from "./iso";
import { drawWeather, drawWindowSky, type SkyScene, type Weather } from "./sky";
import { BACK_WALL_H, depthOf, drawBox, setGroundTransform, setSouthFaceTransform, type BoxColors, type IsoFrame } from "./iso";
import { drawBasin, drawCoffeeMachine, drawMonitorScreen, drawToilet, drawWallClock } from "./props";
import type { Season } from "../../shared/palette-clock";
/** One thing in the scene that people can stand in front of or behind; drawn in depth order with them. */
@@ -10,9 +11,14 @@ export interface Drawable {
draw: (ctx: CanvasRenderingContext2D) => void;
}
/** A piece of the scene and the plan footprint it sorts by. */
export interface SceneDrawable extends Drawable {
foot: Rect;
}
/** Interior walls are cut away to this height so rooms stay visible. */
const INNER_WALL_H = 14;
/** Longest stretch of a block that sorts as one piece. */
/** Longest side of a block that sorts as one piece. */
const PIECE = 32;
const PALETTE = {
@@ -33,10 +39,9 @@ const PALETTE = {
desk: "#8a6d4c",
deskExec: "#6a5238",
monitor: "#4b5058",
screenOff: "#20242c",
screenOn: "#37d38a",
chair: "#3d4148",
frame: "#3f3831",
panelSeam: "#5d5246",
} as const;
const LEAVES: Record<Season, { leaf: string; light: string }> = {
@@ -88,27 +93,42 @@ function drawRoomFloor(ctx: CanvasRenderingContext2D, room: RoomSpec): void {
}
}
/** The tall north and west walls, which everything stands in front of, with windows and wall art on the north face. */
export function drawBackWalls(ctx: CanvasRenderingContext2D, frame: IsoFrame, plan: FloorPlan, sky: SkyScene): void {
/** What the back walls show: the sky and weather outside, and the time for the wall clock. */
export interface Outside {
sky: SkyScene;
weather: Weather;
clock: Date;
timeMs: number;
}
/** The tall north and west walls, which everything stands in front of, with panels, windows, a clock and wall art on the north face. */
export function drawBackWalls(ctx: CanvasRenderingContext2D, frame: IsoFrame, plan: FloorPlan, outside: Outside): void {
const { w, h } = plan.world;
const wall = faces(PALETTE.wall);
drawBox(ctx, frame, { x: 0, y: WALL, w: WALL, h: 280 }, 0, BACK_WALL_H, wall);
drawBox(ctx, frame, { x: 0, y: 344, w: WALL, h: h - 344 - WALL }, 0, BACK_WALL_H, wall);
drawBox(ctx, frame, { x: 0, y: 0, w, h: WALL }, 0, BACK_WALL_H, { ...wall, top: PALETTE.wallTop });
ctx.save();
setNorthWallTransform(ctx, frame, WALL);
setSouthFaceTransform(ctx, frame, WALL, BACK_WALL_H);
ctx.fillStyle = PALETTE.wallTop;
ctx.fillRect(0, 0, w, 2);
ctx.fillStyle = PALETTE.panelSeam;
for (let x = 32; x < w; x += 32) ctx.fillRect(x, 2, 1, BACK_WALL_H - 5);
ctx.fillStyle = PALETTE.baseboard;
ctx.fillRect(0, BACK_WALL_H - 3, w, 3);
for (const room of plan.rooms) {
windowRects(room).forEach((rect, index) => {
const seed = Math.round(room.bounds.x) + index * 53;
ctx.fillStyle = PALETTE.frame;
ctx.fillRect(rect.x - 2, rect.y - 2, rect.w + 4, rect.h + 4);
drawWindowSky(ctx, rect, sky, Math.round(room.bounds.x) + index * 53);
drawWindowSky(ctx, rect, outside.sky, seed);
drawWeather(ctx, rect, outside.weather, outside.timeMs, seed);
ctx.fillStyle = PALETTE.frame;
ctx.fillRect(rect.x + rect.w / 2 - 1, rect.y, 2, rect.h);
});
}
drawWallArt(ctx);
drawWallClock(ctx, 664, 10, outside.clock);
ctx.restore();
}
@@ -134,23 +154,34 @@ function drawWallArt(ctx: CanvasRenderingContext2D): void {
ctx.fillRect(432, 19, 40, 3);
ctx.fillStyle = "#6d9c6b";
ctx.fillRect(432, 26, 33, 3);
// Restroom mirror over the sinks.
ctx.fillStyle = PALETTE.frame;
ctx.fillRect(934, 8, 84, 22);
ctx.fillStyle = "#b9d3e6";
ctx.fillRect(936, 10, 80, 18);
ctx.fillStyle = "#e3f0f8";
ctx.fillRect(944, 12, 2, 12);
ctx.fillRect(948, 12, 1, 8);
}
/** A block sorted by its footprint centre; one resting on `support` sorts just after every piece of it underneath, so no part of that surface paints over it. */
export function blockDrawable(frame: IsoFrame, foot: Rect, height: number, colors: BoxColors, z = 0, support?: Rect): Drawable {
const centerDepth = (rect: Rect) => depthOf({ x: rect.x + rect.w / 2, y: rect.y + rect.h / 2 });
if (!support) return { depth: centerDepth(foot), draw: (ctx) => drawBox(ctx, frame, foot, z, height, colors) };
/** A block sorted by its footprint centre; one resting on `support` touches exactly one piece of it (overhanging its outer edge is fine) and sorts just after that piece. */
export function blockDrawable(frame: IsoFrame, foot: Rect, height: number, colors: BoxColors, z = 0, support?: Rect): SceneDrawable {
return { foot, depth: support ? restingDepth(foot, support) : centerDepth(foot), draw: (ctx) => drawBox(ctx, frame, foot, z, height, colors) };
}
function restingDepth(foot: Rect, support: Rect): number {
const under = split(support).filter((piece) => overlaps(piece, foot));
if (under.length === 0) throw new Error("A resting block must sit over its support.");
return {
depth: Math.max(...under.map(centerDepth)) + 0.25,
draw: (ctx) => drawBox(ctx, frame, foot, z, height, colors),
};
if (under.length !== 1) throw new Error("A resting block must touch exactly one piece of its support.");
return centerDepth(under[0]!) + 0.25;
}
/** Interior walls, furniture and desks as blocks, each sorted by depth against the people; `lit` desks have their screen on. */
export function sceneDrawables(frame: IsoFrame, plan: FloorPlan, season: Season, lit: Set<Rect>): Drawable[] {
const items: Drawable[] = [];
function centerDepth(rect: Rect): number {
return depthOf({ x: rect.x + rect.w / 2, y: rect.y + rect.h / 2 });
}
/** Interior walls, furniture and desks as blocks, each sorted by depth against the people; `lit` desks have their screen on, and `timeMs` animates screens and steam. */
export function sceneDrawables(frame: IsoFrame, plan: FloorPlan, season: Season, lit: Set<Rect>, timeMs: number): SceneDrawable[] {
const items: SceneDrawable[] = [];
// Long blocks go in pieces so each sorts against nearby people on its own.
const block = (foot: Rect, height: number, colors: BoxColors, z = 0, support?: Rect) => {
for (const piece of support ? [foot] : split(foot)) items.push(blockDrawable(frame, piece, height, colors, z, support));
@@ -166,7 +197,7 @@ export function sceneDrawables(frame: IsoFrame, plan: FloorPlan, season: Season,
// CEO office: rug, executive desk, sofa, plant.
block({ x: 32, y: 96, w: 24, h: 72 }, 10, faces("#7a4a41"));
plant(280, 40);
deskBlocks(plan.ceoDesk, true, lit.has(plan.ceoDesk)).forEach((item) => items.push(item(frame)));
deskBlocks(plan.ceoDesk, true, lit.has(plan.ceoDesk), timeMs).forEach((item) => items.push(item(frame)));
// Meeting room: table and chairs.
block({ x: 392, y: 120, w: 160, h: 64 }, 12, faces("#75593b"));
for (let i = 0; i < 3; i++) {
@@ -177,33 +208,51 @@ export function sceneDrawables(frame: IsoFrame, plan: FloorPlan, season: Season,
const counter = { x: 656, y: 40, w: 224, h: 28 };
block(counter, 16, faces("#8f8578"));
block({ x: 700, y: 20, w: 24, h: 20 }, 22, faces("#d5dade"));
block({ x: 744, y: 44, w: 14, h: 14 }, 12, faces("#2e3238"), 16, counter);
block({ x: 806, y: 46, w: 12, h: 10 }, 8, faces("#c9a86a"), 16, counter);
const machine = { x: 726, y: 44, w: 14, h: 14 };
items.push({ foot: machine, depth: restingDepth(machine, counter), draw: (ctx) => drawCoffeeMachine(ctx, frame, machine, 16, timeMs) });
block({ x: 792, y: 46, w: 12, h: 10 }, 8, faces("#c9a86a"), 16, counter);
block({ x: 826, y: 46, w: 12, h: 10 }, 10, faces("#b3543f"), 16, counter);
block({ x: 748, y: 170, w: 40, h: 40 }, 12, faces("#94897b"));
block({ x: 728, y: 182, w: 12, h: 12 }, 7, faces("#6d5334"));
block({ x: 796, y: 182, w: 12, h: 12 }, 7, faces("#6d5334"));
// Restroom: three stalls and a sink counter.
for (let i = 0; i < 3; i++) block({ x: 1064 + i * 40, y: WALL, w: 38, h: 72 }, 30, faces("#7d8a99"));
block({ x: 928, y: 32, w: 96, h: 32 }, 14, faces("#c6d2de"));
// Restroom: three open stalls with toilets between partitions, and a sink counter.
for (let i = 0; i < 3; i++) {
const cx = 1084 + i * 40;
const toilet = { x: cx - 6, y: WALL + 2, w: 12, h: 16 };
items.push({ foot: toilet, depth: centerDepth(toilet), draw: (ctx) => drawToilet(ctx, frame, cx, WALL + 2) });
}
for (let i = 0; i <= 3; i++) block({ x: 1063 + i * 40, y: WALL, w: 2, h: 48 }, INNER_WALL_H, faces("#7d8a99"));
const sinks = { x: 928, y: 32, w: 96, h: 32 };
block(sinks, 14, { top: "#c6d2de", south: "#a7b4c2", east: "#909eae" });
for (let i = 0; i < 2; i++) {
const basin = { x: sinks.x + 2 + i * 64, y: sinks.y + 8, w: 28, h: 16 };
items.push({ foot: basin, depth: restingDepth(basin, sinks), draw: (ctx) => drawBasin(ctx, frame, basin, 14) });
}
for (const [cx, cy] of [[312, 300], [632, 300], [904, 300]] as const) plant(cx, cy);
for (const desk of plan.desks) deskBlocks(desk.bounds, false, lit.has(desk.bounds)).forEach((item) => items.push(item(frame)));
for (const desk of plan.desks) deskBlocks(desk.bounds, false, lit.has(desk.bounds), timeMs).forEach((item) => items.push(item(frame)));
return items;
}
/** A desk, its monitor and its chair, each a separate block so a seated person sorts between them. */
function deskBlocks(desk: Rect, executive: boolean, screenOn: boolean): Array<(frame: IsoFrame) => Drawable> {
function deskBlocks(desk: Rect, executive: boolean, screenOn: boolean, timeMs: number): Array<(frame: IsoFrame) => SceneDrawable> {
const monitor = deskMonitor(desk);
return [
...split(desk).map((piece) => (frame: IsoFrame) => blockDrawable(frame, piece, 14, faces(executive ? PALETTE.deskExec : PALETTE.desk))),
(frame) => blockDrawable(frame, monitor, 12, { top: PALETTE.monitor, south: screenOn ? PALETTE.screenOn : PALETTE.screenOff, east: shade(PALETTE.monitor, 0.7) }, 14, desk),
(frame) => {
const shell = blockDrawable(frame, monitor, 12, { top: PALETTE.monitor, south: PALETTE.monitor, east: shade(PALETTE.monitor, 0.7) }, 14, desk);
return { foot: monitor, depth: shell.depth, draw: (ctx) => {
shell.draw(ctx);
drawMonitorScreen(ctx, frame, monitor, 14, 12, screenOn, timeMs, Math.round(desk.x + desk.y));
} };
},
(frame) => blockDrawable(frame, { x: desk.x + desk.w / 2 - 8, y: desk.y + desk.h + 6, w: 16, h: 12 }, 9, faces(PALETTE.chair)),
];
}
/** Footprint of a desk's monitor, at the back of the desktop with its screen facing south. */
export function deskMonitor(desk: Rect): Rect {
return { x: desk.x + desk.w / 2 - 14, y: desk.y + 4, w: 28, h: 6 };
/** Footprint of a desk's monitor, at the back of the desktop with its screen facing south, inside the desk piece that holds the desk's centre. */
function deskMonitor(desk: Rect): Rect {
const piece = Math.floor(desk.w / 2 / PIECE) * PIECE;
return { x: desk.x + piece + 3, y: desk.y + 4, w: 26, h: 6 };
}
/** Partitions, room fronts (with door gaps) and the low outer east and south walls. */
@@ -225,13 +274,12 @@ function innerWalls(plan: FloorPlan): Rect[] {
return walls;
}
function split(wall: Rect): Rect[] {
const horizontal = wall.w >= wall.h;
const length = horizontal ? wall.w : wall.h;
function split(block: Rect): Rect[] {
const pieces: Rect[] = [];
for (let start = 0; start < length; start += PIECE) {
const size = Math.min(PIECE, length - start);
pieces.push(horizontal ? { x: wall.x + start, y: wall.y, w: size, h: wall.h } : { x: wall.x, y: wall.y + start, w: wall.w, h: size });
for (let dy = 0; dy < block.h; dy += PIECE) {
for (let dx = 0; dx < block.w; dx += PIECE) {
pieces.push({ x: block.x + dx, y: block.y + dy, w: Math.min(PIECE, block.w - dx), h: Math.min(PIECE, block.h - dy) });
}
}
return pieces;
}
+29
View File
@@ -1,4 +1,5 @@
import type { Rect } from "./office-types";
import { season } from "../../shared/palette-clock";
/**
* The world outside the office windows, driven by real local time: night
@@ -155,6 +156,34 @@ function drawSkyline(ctx: CanvasRenderingContext2D, glass: Rect, scene: SkyScene
}
}
export type Weather = "clear" | "rain" | "snow" | "leaves";
/** Decoration for the view outside, from the season and a per-day roll; never a forecast. */
export function weatherFor(date: Date): Weather {
const dayKey = date.getFullYear() * 400 + date.getMonth() * 32 + date.getDate();
const roll = Math.abs(Math.imul(dayKey ^ 0x5bd1e995, 2654435761) >> 7) % 100;
switch (season(date)) {
case "winter": return roll < 60 ? "snow" : "clear";
case "spring": return roll < 40 ? "rain" : "clear";
case "summer": return roll < 15 ? "rain" : "clear";
case "autumn": return roll < 45 ? "leaves" : roll < 65 ? "rain" : "clear";
}
}
/** Falling rain, snow or leaves inside a window, moving with time; each pane gets its own scatter. */
export function drawWeather(ctx: CanvasRenderingContext2D, glass: Rect, weather: Weather, timeMs: number, seed: number): void {
if (weather === "clear") return;
const style = { rain: { color: "rgba(200,220,255,0.75)", w: 1, h: 3, speed: 0.06, drift: 0.01 }, snow: { color: "#f4f6fb", w: 1, h: 1, speed: 0.008, drift: 0.004 }, leaves: { color: "#d9822b", w: 2, h: 1, speed: 0.012, drift: 0.01 } }[weather];
ctx.fillStyle = style.color;
const count = Math.max(3, Math.floor(glass.w / 6));
for (let i = 0; i < count; i++) {
const offset = (seed * 37 + i * 53) % 1000;
const fall = (offset + timeMs * style.speed) % glass.h;
const sway = (offset * 7 + timeMs * style.drift) % glass.w;
ctx.fillRect(Math.floor(glass.x + sway), Math.floor(glass.y + fall), style.w, Math.min(style.h, glass.y + glass.h - Math.floor(glass.y + fall)));
}
}
function rgb(r: number, g: number, b: number): Rgb {
return { r, g, b };
}
-12
View File
@@ -1,12 +0,0 @@
import type { ReactNode } from "react";
import { Icon } from "./Icon";
/** A hint spoken by the office guide: a pixel portrait and a speech bubble, so help reads like a character talking. */
export function HintBubble({ children }: { children: ReactNode }) {
return (
<div className="hint-bubble" role="note">
<span className="hint-speaker" aria-hidden="true"><Icon name="bot" /></span>
<p className="hint-text">{children}</p>
</div>
);
}
+4 -4
View File
@@ -254,13 +254,13 @@ textarea.input { min-height: 84px; resize: vertical; line-height: 1.5; }
transform: translateX(-50%);
display: flex;
gap: 6px;
width: max-content;
max-width: calc(100% - 32px);
padding: 6px;
background: var(--panel);
border: 1px solid var(--border-strong);
border-radius: 0;
box-shadow: 0 8px 24px rgba(16, 16, 24, 0.18);
border: 2px solid var(--border-strong);
box-shadow: var(--shadow-md);
}
.office-legend { align-self: center; padding: 0 6px; font-size: var(--fs-body); color: var(--text-2); }
/* Floating "jump to latest" pill above the chat composer */
.jump-latest {
-5
View File
@@ -69,11 +69,6 @@
transition: width var(--motion-slow) var(--ease-out);
}
/* Hint bubble: the guide's portrait and a speech box with a stepped tail. */
.hint-bubble { display: flex; align-items: flex-start; gap: 10px; margin: 0 0 var(--sp-4); }
.hint-speaker { display: grid; place-items: center; width: calc(24px * var(--px)); height: calc(24px * var(--px)); background: var(--accent); color: var(--btn-primary-fg); box-shadow: 2px 2px 0 0 var(--border-strong); flex-shrink: 0; }
.hint-text { position: relative; margin: 0; padding: 6px 10px; background: var(--panel); border: 2px solid var(--border-strong); color: var(--text-2); }
.hint-text::before { content: ""; position: absolute; left: -8px; top: 6px; width: 6px; height: 8px; background: var(--border-strong); clip-path: polygon(100% 0, 100% 100%, 67% 100%, 67% 75%, 34% 75%, 34% 50%, 0 50%, 0 25%, 34% 25%, 34% 0); }
/* Game over: a framed panel with a blinking banner and Continue. */
.game-over { display: grid; gap: 12px; max-width: 520px; text-align: center; justify-items: center; }
-4
View File
@@ -48,8 +48,6 @@ test("the library manages documents with versions and knowledge with history", a
expect(orgId).toBeTruthy();
await page.getByRole("button", { name: "Library", exact: true }).click();
// Each page says which object drives work: only a task runs.
await expect(page.getByText("Nothing here starts work; a task does.", { exact: false })).toBeVisible();
await page.getByRole("listitem").filter({ hasText: "Pricing memo" }).click();
await page.getByRole("button", { name: "Edit", exact: true }).click();
await page.getByLabel("Content").fill("Price is 12.");
@@ -60,9 +58,7 @@ test("the library manages documents with versions and knowledge with history", a
await expect(page.getByText("Price is 10.")).toBeVisible();
await page.getByRole("button", { name: "Knowledge", exact: true }).click();
await expect(page.getByText("Nothing here starts work; a task does.", { exact: false })).toBeVisible();
await page.getByRole("button", { name: "Work", exact: true }).click();
await expect(page.getByText("Only a task runs: an agent picks it up and does the work.", { exact: false })).toBeVisible();
await page.getByRole("button", { name: "Library", exact: true }).click();
await page.getByRole("button", { name: "Knowledge", exact: true }).click();
await page.getByRole("button", { name: "New", exact: true }).click();
+1 -2
View File
@@ -91,9 +91,8 @@ test("the office floor opens agent details on click", async () => {
}
await expect(canvas).toBeVisible();
// The office offers no pretend controls over work, and says what a position means.
// The office offers no pretend controls over work.
await expect(page.getByRole("button", { name: "Whip" })).toHaveCount(0);
await expect(page.getByText("Agents with work stay at their desks. Only idle agents walk to coffee, snacks or the meeting table.")).toBeVisible();
// Hired agents take open-floor desks in hire order and are clickable too.
await hireAgent(page, "Ava Patel", "Designer", "Design");
+1 -3
View File
@@ -60,15 +60,13 @@ test("text uses the bundled pixel font, and the palette follows the day part, se
await page.keyboard.press("Escape");
await expect(dialog).toHaveCount(0);
// Progress is an XP bar announced as a value; hints are spoken by the guide in a bubble.
// Progress is an XP bar announced as a value.
await page.getByRole("button", { name: "Home" }).click();
await expect(page.getByRole("progressbar", { name: "Goal progress" })).toHaveAttribute("aria-valuenow", "0");
// On the roster the only agent holds the largest budget, and the bar says it compares budgets, not spend.
await page.getByRole("button", { name: "Team", exact: true }).click();
await expect(page.getByRole("progressbar", { name: "Budget compared with the largest agent budget" })).toHaveAttribute("aria-valuenow", "100");
await page.getByRole("button", { name: "Work", exact: true }).click();
const hint = page.getByRole("note").filter({ hasText: "Only a task runs" });
await expect(hint.locator(".hint-speaker svg.pixel-icon")).toHaveCount(1);
// Tabs are raised pixel tabs; fields are sunken pixel boxes.
const views = page.getByRole("radiogroup", { name: "View" });
expect(await views.locator("[aria-checked=true]").evaluate((tab) => getComputedStyle(tab).borderBottomColor === getComputedStyle(tab).backgroundColor)).toBe(true);
+21 -5
View File
@@ -1,6 +1,7 @@
import { describe, expect, test } from "vitest";
import { depthOf, fromIso, isoFrame, isoPixelSize, pickAt, toIso } from "../../src/renderer/src/pages/office/iso";
import { blockDrawable } from "../../src/renderer/src/pages/office/scene-renderer";
import { blockDrawable, sceneDrawables } from "../../src/renderer/src/pages/office/scene-renderer";
import { buildFloorPlan } from "../../src/renderer/src/pages/office/floor-plan";
const frame = isoFrame({ w: 1216, h: 600 });
@@ -70,11 +71,26 @@ describe("office depth order", () => {
const resting = blockDrawable(frame, cup, 12, colors, 16, counter);
expect(loose.depth).toBeLessThan(underneath.depth);
expect(resting.depth).toBeGreaterThan(underneath.depth);
expect(() => blockDrawable(frame, { x: 10, y: 10, w: 4, h: 4 }, 4, colors, 16, counter)).toThrow("A resting block must sit over its support.");
// The coffee machine straddles the pieces at 720..752 and 752..784: it sorts after both.
const machine = blockDrawable(frame, { x: 744, y: 44, w: 14, h: 14 }, 12, colors, 16, counter);
for (const x of [720, 752]) expect(machine.depth).toBeGreaterThan(blockDrawable(frame, { x, y: 40, w: 32, h: 28 }, 16, colors).depth);
// Off the support, or straddling two of its pieces, a resting block has no single surface to sort after: refused.
expect(() => blockDrawable(frame, { x: 10, y: 10, w: 4, h: 4 }, 4, colors, 16, counter)).toThrow("A resting block must touch exactly one piece of its support.");
expect(() => blockDrawable(frame, { x: 744, y: 44, w: 14, h: 14 }, 12, colors, 16, counter)).toThrow("A resting block must touch exactly one piece of its support.");
// Overhanging the support's outer edge, like a plant's leaves past its pot, still sorts after that one piece.
const overhang = blockDrawable(frame, { x: 726, y: 64, w: 14, h: 14 }, 12, colors, 16, counter);
expect(overhang.depth).toBeGreaterThan(underneath.depth);
// A person standing in front of the counter still draws over what rests on it.
expect(depthOf({ x: 704, y: 92 })).toBeGreaterThan(resting.depth);
});
});
test("every piece of the scene sorts by a footprint no longer than 32 on either side, so a person beside a long block is never painted over", () => {
const plan = buildFloorPlan(12);
const items = sceneDrawables(isoFrame(plan.world), plan, "winter", new Set(), 0);
const oversized = items.filter((item) => item.foot.w > 32 || item.foot.h > 32).map((item) => item.foot);
expect(oversized).toEqual([]);
// A person at the left basin sorts after both the counter piece and the basin in front of them.
const atBasin = depthOf({ x: 944, y: 72 });
for (const foot of [{ x: 928, y: 32 }, { x: 930, y: 40 }]) {
const item = items.find((candidate) => candidate.foot.x === foot.x && candidate.foot.y === foot.y)!;
expect(atBasin).toBeGreaterThan(item.depth);
}
});
+28
View File
@@ -0,0 +1,28 @@
import { describe, expect, test } from "vitest";
import { weatherFor } from "../../src/renderer/src/pages/office/sky";
import { clockHands } from "../../src/renderer/src/pages/office/props";
const day = (month: number, date: number, hour = 12) => new Date(2026, month - 1, date, hour, 0, 0);
describe("office decor", () => {
test("the view outside keeps one weather all day and fits the season", () => {
expect(weatherFor(day(1, 10, 8))).toBe(weatherFor(day(1, 10, 22)));
const seen = (month: number) => new Set(Array.from({ length: 28 }, (_, index) => weatherFor(day(month, index + 1))));
expect([...seen(1)].sort()).toEqual(["clear", "snow"]);
expect([...seen(4)].sort()).toEqual(["clear", "rain"]);
expect(seen(7).has("snow")).toBe(false);
expect(seen(7).has("leaves")).toBe(false);
expect(seen(10).has("leaves")).toBe(true);
expect(seen(10).has("snow")).toBe(false);
});
test("the wall clock shows the local time: hands measured clockwise from twelve", () => {
const at = (hour: number, minute: number) => clockHands(new Date(2026, 0, 1, hour, minute));
expect(at(0, 0)).toEqual({ hour: 0, minute: 0 });
expect(at(3, 0).hour).toBeCloseTo(Math.PI / 2);
expect(at(15, 0).hour).toBeCloseTo(Math.PI / 2);
expect(at(6, 30).hour).toBeCloseTo((6.5 / 12) * 2 * Math.PI);
expect(at(6, 30).minute).toBeCloseTo(Math.PI);
expect(at(9, 45).minute).toBeCloseTo(1.5 * Math.PI);
});
});