Files
FarmHouse-Holdings/engine/static/office.html
T
fez5587andClaude Fable 5 b141900ad7 Office: company switcher tabs + active objective line
Tabs render only when more than one active company exists; objective line
shows the newest in-progress objective above the canvas.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-04 13:33:48 -05:00

249 lines
12 KiB
HTML

<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>FarmHouse Office</title>
<style>
body { background:#101418; color:#d8dee6; font:14px/1.5 system-ui, sans-serif;
margin:0; padding:1rem; text-align:center; }
h1 { font-size:1.1rem; } a { color:#5c9de0; }
canvas { image-rendering:pixelated; border:1px solid #2a323c; border-radius:8px;
max-width:100%; }
#ticker { max-width:960px; margin:.5rem auto; color:#8a94a0; font-size:.8rem;
white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
#legend { color:#8a94a0; font-size:.75rem; }
</style>
</head>
<body>
<h1>🏠 FarmHouse Office <a href="/">← dashboard</a></h1>
<div id="tabs"></div>
<div id="objline" style="color:#8a94a0; font-size:.8rem; margin-bottom:.3rem;">…</div>
<canvas id="c" width="480" height="320" style="width:960px"></canvas>
<div id="legend">at desk = building · research room = researching · conference room = blocked, waiting on your answer · water cooler = idle</div>
<div id="ticker">…</div>
<script>
// Top-down pixel office. Every position/animation derives from real engine
// state (PRD 12: no fake activity): working -> walk to own desk and type,
// blocked -> walk to conference room and wait with a "?" bubble,
// idle -> stand at the break area.
const ctx = document.getElementById('c').getContext('2d');
const T = 16, COLS = 30, ROWS = 20;
// # wall . carpet , conference wood floor ; research floor + door (passable)
const MAP = [
"##############################",
"#.................#,,,,,,,,,,#",
"#.................#,,,,,,,,,,#",
"#.................#,,,,,,,,,,#",
"#.................+,,,,,,,,,,#",
"#.................#,,,,,,,,,,#",
"#.................#,,,,,,,,,,#",
"#.................#,,,,,,,,,,#",
"#.................############",
"#............................#",
"#............................#",
"#............................#",
"#...................####+#####",
"#...................#;;;;;;;;#",
"#...................#;;;;;;;;#",
"#...................#;;;;;;;;#",
"#...................#;;;;;;;;#",
"#...................#;;;;;;;;#",
"#...................#;;;;;;;;#",
"##############################",
];
const DESKS = [[3,10],[8,10],[13,10],[3,14],[8,14],[13,14]]; // desk tile; worker sits above it
const CONF_SEATS = [[21,2],[23,2],[25,2],[21,6],[23,6],[25,6]]; // around the table
const CONF_TABLE = {x:20, y:3, w:7, h:3};
const LAB_SEATS = [[22,15],[24,15],[26,15]]; // reading table, north side
const BREAK_SPOTS = [[15,16],[17,16],[14,17],[16,17],[18,17],[15,15]];
// rooms with a single door: test = inside?, W = waypoint outside door, D = inside doorway
const ROOMS = {
conf: { test:(x,y)=> x>18*T && y<8*T, W:[17*T+8, 4*T+8], D:[19*T+8, 4*T+8] },
lab: { test:(x,y)=> x>20*T && y>12*T, W:[24*T+8, 11*T+8], D:[24*T+8, 13*T+8] },
};
const ROLE_SHIRT = { manager:'#e0b354', engineer:'#5c9de0', qa:'#b48ce0' };
const HAIR = ['#3a2a1c','#151412','#c9a24b','#8a3b22','#4a4e57'];
const px = (x,y,w,h,c) => { ctx.fillStyle=c; ctx.fillRect(x|0,y|0,w,h); };
const hash = s => [...s].reduce((a,ch)=>(a*31+ch.charCodeAt(0))>>>0, 7);
const center = ([tx,ty]) => [tx*T+T/2, ty*T+T/2];
let tick = 0, actors = {}, companyName = '…';
// ---------------------------------------------------------------- tiles
function drawMap() {
for (let r=0;r<ROWS;r++) for (let c=0;c<COLS;c++) {
const t = MAP[r][c], x=c*T, y=r*T;
if (t==='#') { px(x,y,T,T,'#35465c'); px(x,y,T,4,'#465b76'); px(x,y+T-2,T,2,'#22303f'); }
else if (t===',') { px(x,y,T,T,(r+c)%2?'#6b4a33':'#64452f'); px(x,y,T,1,'#77543b'); }
else if (t===';') { px(x,y,T,T,(r+c)%2?'#3d4a63':'#38455e'); px(x,y,T,1,'#46536e'); }
else if (t==='+') { px(x,y,T,T,'#64452f'); px(x,y,2,T,'#2b2620'); px(x+T-2,y,2,T,'#2b2620'); }
else { px(x,y,T,T,(r+c)%2?'#3f5d56':'#3a5850'); if((r*7+c*3)%11===0) px(x+7,y+7,2,2,'#456660'); }
}
// conference table
const {x,y,w,h} = CONF_TABLE;
px(x*T+4, y*T+4, w*T-8, h*T-8, '#7a4f34'); px(x*T+4, y*T+4, w*T-8, 3, '#8f5f40');
px(x*T+6, y*T+7, w*T-12, h*T-14, '#6b4229');
// desks: wood top + monitor + chair above
for (const [dc,dr] of DESKS) {
const dx=dc*T, dy=dr*T;
px(dx-4, dy, T+8, 12, '#8a5c3b'); px(dx-4, dy, T+8, 3, '#9c6b46'); px(dx-4, dy+10, T+8, 2, '#5e3d26');
px(dx+3, dy-1, 10, 7, '#20262e'); // monitor (glow drawn per-actor)
}
// break area: rug, water cooler, plant
px(14*T, 15*T, 5*T, 3*T, '#4c5a68'); px(14*T, 15*T, 5*T, 2, '#5a6a7a');
px(18*T+6, 14*T+2, 8, 14, '#bcd8e8'); px(18*T+7, 14*T+4, 6, 6, '#8fc4e0'); px(18*T+6, 14*T+14, 8, 4, '#6a7684');
px(19*T+6, 16*T, 8, 6, '#a25636'); px(19*T+4, 16*T-8, 4, 8, '#3e7d46'); px(19*T+10, 16*T-8, 4, 8, '#356e3e'); px(19*T+7, 16*T-12, 4, 8, '#478a4f');
// research room: bookshelves along the top wall, reading table
for (let c2=21;c2<=27;c2++) {
const bx=c2*T, by=13*T;
px(bx, by-4, T, 14, '#5e3d26'); px(bx, by-4, T, 2, '#77543b');
for (let i=0;i<4;i++) { px(bx+1+i*4, by-1, 3, 5, ['#a34d3f','#3e7d46','#4a6da8','#c9a24b'][(c2+i)%4]);
px(bx+1+i*4, by+5, 3, 4, ['#4a6da8','#c9a24b','#a34d3f','#3e7d46'][(c2*3+i)%4]); }
px(bx, by+10, T, 2, '#3a2a1c');
}
px(21*T+8, 15*T+10, 6*T-16, 22, '#7a4f34'); px(21*T+8, 15*T+10, 6*T-16, 3, '#8f5f40');
}
// ---------------------------------------------------------------- sprite
// 12x18 character, feet anchored at (x,y). dir: 0 down, 1 up, 2 left, 3 right.
function drawChar(x, y, a) {
const L=x-6, Tp=y-18, step = a.moving ? (tick>>3)%2 : 0;
const bob = a.anim==='type' && (tick>>4)%2 ? 1 : 0;
const skin='#e8c39e', pants='#2e3440', shoe='#1c2026';
// legs
if (a.moving) { px(L+2,Tp+13,3,4-step,pants); px(L+7,Tp+13,3,3+step,pants);
px(L+2,Tp+16-step,3,2,shoe); px(L+7,Tp+16+ (step?0:-1),3,2,shoe); }
else { px(L+2,Tp+13,3,4,pants); px(L+7,Tp+13,3,4,pants); px(L+2,Tp+16,3,2,shoe); px(L+7,Tp+16,3,2,shoe); }
// torso + arms
px(L+1,Tp+7+bob,10,6,a.shirt);
if (a.dir===2) px(L,Tp+8+bob,2,5,a.shirt); else if (a.dir===3) px(L+10,Tp+8+bob,2,5,a.shirt);
else { px(L-1,Tp+8+bob,2,5,a.shirt); px(L+11,Tp+8+bob,2,5,a.shirt); }
// head
px(L+2,Tp+1+bob,8,7,skin);
// hair by facing
if (a.dir===1) px(L+1,Tp+bob,10,6,a.hair);
else { px(L+1,Tp+bob,10,3,a.hair); px(L+1,Tp+bob,2,5,a.hair); px(L+9,Tp+bob,2,5,a.hair); }
// eyes
if (a.dir===0) { px(L+3,Tp+4+bob,2,2,'#20262e'); px(L+7,Tp+4+bob,2,2,'#20262e'); }
else if (a.dir===2) px(L+3,Tp+4+bob,2,2,'#20262e');
else if (a.dir===3) px(L+7,Tp+4+bob,2,2,'#20262e');
}
function drawLabel(x, y, text, color) {
ctx.font='7px monospace';
const w = ctx.measureText(text).width;
px(x-w/2-2, y, w+4, 9, 'rgba(16,20,24,.75)');
ctx.fillStyle = color; ctx.fillText(text, x-w/2, y+7);
}
// ---------------------------------------------------------------- actors
function roomOf(x, y) { for (const k in ROOMS) if (ROOMS[k].test(x, y)) return k; return null; }
function route(a, target) {
const from = roomOf(a.x, a.y), to = roomOf(target[0], target[1]);
if (from === to) { a.path = [target]; return; }
const path = [];
if (from) path.push(ROOMS[from].D, ROOMS[from].W); // step to the doorway, then outside
if (to) path.push(ROOMS[to].W, ROOMS[to].D); // outside the door, then through it
path.push(target);
a.path = path;
}
function isResearch(t) { return t === 'research' || t === 'experiment'; }
function targetFor(a, i) {
if (a.status === 'working') {
if (isResearch(a.taskType)) { const [c]=LAB_SEATS[i%LAB_SEATS.length]; return [c*T+T/2, 15*T+4]; }
const [c,r]=DESKS[i%DESKS.length]; return [c*T+T/2, r*T-2];
}
if (a.status === 'blocked') return center(CONF_SEATS[i%CONF_SEATS.length]);
return center(BREAK_SPOTS[i%BREAK_SPOTS.length]);
}
function step(a) {
if (!a.path?.length) { a.moving=false; return; }
const [tx,ty] = a.path[0], SPD=0.8;
a.moving = true;
if (Math.abs(tx-a.x) > SPD) { a.dir = tx>a.x?3:2; a.x += tx>a.x?SPD:-SPD; }
else if (Math.abs(ty-a.y) > SPD) { a.x=tx; a.dir = ty>a.y?0:1; a.y += ty>a.y?SPD:-SPD; }
else { a.x=tx; a.y=ty; a.path.shift();
if (!a.path.length) { a.moving=false; a.dir = 0; } }
}
async function poll() {
try {
const companies = (await (await fetch('/companies')).json()).filter(c=>c.lifecycle_state==='active');
const want = new URLSearchParams(location.search).get('company');
const co = companies.find(c=>c.slug===want) ?? companies[0];
if (!co) return;
companyName = co.name;
document.getElementById('tabs').innerHTML = companies.length > 1
? companies.map(c => `<a href="?company=${c.slug}" style="margin:0 .4rem; ${c.slug===co.slug?'font-weight:bold;':''}">${c.name}</a>`).join('')
: '';
const [detail, items, events] = await Promise.all([
fetch(`/companies/${co.slug}`).then(r=>r.json()),
fetch(`/companies/${co.slug}/work-items`).then(r=>r.json()),
fetch(`/companies/${co.slug}/events?limit=1`).then(r=>r.json())]);
const working = Object.fromEntries(items.filter(w=>w.status==='in_progress'&&w.owner_name)
.map(w=>[w.owner_name, w]));
detail.employees.forEach((e,i) => {
let a = actors[e.id];
if (!a) { const [bx,by]=center(BREAK_SPOTS[i%BREAK_SPOTS.length]);
a = actors[e.id] = { x:bx, y:by, dir:0, path:[], status:'', shirt:ROLE_SHIRT[e.role]??'#8a94a0',
hair:HAIR[hash(e.name)%HAIR.length], name:e.name, i }; }
const w = working[e.name];
a.task = w?.title;
const taskType = w?.type;
if (e.status !== a.status || taskType !== a.taskType) {
a.status = e.status; a.taskType = taskType; route(a, targetFor(a, i));
}
});
const activeObj = items.find(w => w.type === 'objective' && w.status === 'in_progress');
document.getElementById('objline').textContent =
activeObj ? `Objective: ${activeObj.title.slice(0,110)}` : 'No active objective';
const ev = events[0];
if (ev) document.getElementById('ticker').textContent =
`${new Date(ev.created_at).toLocaleTimeString()} — ${ev.event_type}: ${JSON.stringify(ev.payload??{}).slice(0,120)}`;
} catch (e) { document.getElementById('ticker').textContent = 'engine unreachable'; }
}
// ---------------------------------------------------------------- frame
function draw() {
tick++;
drawMap();
const list = Object.values(actors).sort((a,b)=>a.y-b.y); // painter's order
for (const a of list) {
step(a);
a.anim = !a.moving && a.status==='working' ? 'type' : '';
if (a.anim==='type') {
if (isResearch(a.taskType)) { // open book, page flip
const flip = (tick>>4)%2 ? 1 : 0;
px(a.x-5, a.y+13, 10, 6, '#f5efdc'); px(a.x-1, a.y+13, 2, 6, '#c9c2ae');
px(a.x-4+flip, a.y+14, 3, 1, '#8a94a0'); px(a.x+2-flip, a.y+16, 3, 1, '#8a94a0');
} else { // lit monitor while typing
const [c,r] = DESKS[a.i%DESKS.length];
px(c*T+4, r*T, 8, 5, (tick>>4)%2 ? '#9fd0ff' : '#7db8ec');
}
}
drawChar(a.x, a.y, a);
if (!a.moving && a.status==='blocked') { // waiting on shareholder
const by = a.y - 26 - ((tick>>4)%2);
px(a.x+4, by, 9, 9, '#f5efdc'); px(a.x+6, by+9, 2, 2, '#f5efdc');
ctx.fillStyle='#a3402e'; ctx.font='bold 8px monospace'; ctx.fillText('?', a.x+7, by+8);
}
drawLabel(a.x, a.y+2, a.name, '#d8dee6');
}
ctx.fillStyle='#d8dee6'; ctx.font='8px monospace';
ctx.fillText(companyName, 8, 12);
ctx.fillStyle='#8a94a0'; ctx.fillText('CONFERENCE', 20*T, 1.5*T);
ctx.fillText('RESEARCH', 21*T+4, 18*T+10);
requestAnimationFrame(draw);
}
poll(); setInterval(poll, 5000); draw();
</script>
</body>
</html>