Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ renders pixel cottages; a tiny local HTTP process serves the page and
npm start # http://127.0.0.1:8787
npm test # Node test runner, no install step
npm run test:browser # Real Chrome journey; requires Browser Hand
npm run test:viewport # Fullscreen, zoom, floating details and responsive camera
npm run test:layout # Compact towns, Overview, branch grouping and stable arrivals
npm run test:ambience # Music, light, and postcards in real Chrome
npm run test:night # Night pixels, bedtime and talking beside a bed
node src/feed.mjs --once # print one /agents snapshot as JSON
Expand All @@ -31,6 +33,8 @@ duck. Check direct inspector controls, the connect box, and stale-feed recovery.
|---|---|
| `src/town.html` | Page structure and styles |
| `src/town.mjs` | Exterior pixel renderer, demo simulator, feed polling |
| `src/viewport.mjs` / `src/map-display.mjs` | Stable map scale, scrolling, zoom, fullscreen and floating inspector chrome |
| `src/town-layout.mjs` | Compact stable districts, branch grouping, street routes, resident counts and Overview bounds |
| `src/observatory.mjs` | Walking, interiors, inspector, journals, filters, wildlife interactions |
| `src/interiors.mjs` / `src/world.mjs` | Seeded rooms/residents, walkability, stable plots, explicit paths/handoffs |
| `src/feed-client.mjs` | Browser feed normalization and incremental activity merging |
Expand Down Expand Up @@ -70,6 +74,10 @@ handoffs create paths and couriers. Sound starts off. Scrapbook replay is limite
to locally observed history. Walk into doors to enter/leave; E talks to a nearby
resident or inspects an object. Direct buttons and Escape remain available.

Fullscreen fills the browser window with CSS; never request native fullscreen
or switch macOS spaces. Keep map scale and stable cottage positions independent
of viewport size. Overview is an explicit camera action, not a layout reshuffle.

Music is independent of sound effects and starts off on every load. The village
clock uses device-local time; musical location follows Jack, not the selected
inspector. Generation is a manual authoring step, never a browser/build request.
Expand Down
24 changes: 24 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,8 @@ Browser Hand CLI and its Chrome extension:

```bash
npm run test:browser # walking, rooms, PR desk, activity, input, and recovery
npm run test:viewport # in-window fullscreen, map zoom, floating details, and small screens
npm run test:layout # compact town packing, Overview, stable arrivals, and branch grouping
npm run test:ambience # gramophone, light, and locally rendered postcards
npm run test:night # night palette, bedtime scenery, and visiting a host
```
Expand All @@ -51,12 +53,34 @@ Click the map to give it keyboard focus.
| Walk into a doorway | Enter the cottage or walk back outside; no interaction key needed |
| E or Enter | Talk to a nearby agent, or use a bench, noticeboard, gramophone, or room object |
| Escape | Leave the cottage or bench while the map has focus |
| F / Fullscreen | Fill the browser window at the current map scale; use Exit fullscreen or Escape to return |
| + / − or zoom buttons | Zoom the Townmap between 50% and 200% |
| 0 / percentage button | Reset to the initial map scale (100%) |
| Overview | Frame working and blocked cottages, including active roommates and shed residents |
| Details / Close | Show or hide the floating inspector in fullscreen |
| Click a cottage or its roster button | Open its inspector without walking there |
| Enter cottage / Leave cottage | Visit or exit using buttons |
| Click a resident / Talk to agent | Open their conversation and recorded activity |
| Click a room object or inspector tab | Read the request, clock, journal, to-do list, PR desk, or shelves |
| Click a parcel | Open its PR when a URL is available |

Fullscreen fills the browser window and shows more map without enlarging cottages.
Browser tabs stay visible; it never requests native fullscreen or a macOS space.
Details float on
the right, or along the bottom on small screens; inspecting a cottage reopens
them. Zoom persists across fullscreen and cottage visits until reload. Interiors
continue to fit the room, and Ctrl/Cmd browser zoom shortcuts remain unchanged.

Towns use compact courtyards sized for their cottage households, packed across
the window's available width on load. Known branch names group neighboring
cottages; shared worktrees still share one cottage. Signs count working and
blocked residents, including roommates and children. Existing plots stay fixed
through feed updates, fullscreen and resizing; arrivals fill spare lots or add
an annex. Overview changes the camera, with the Details panel taken into account,
and falls back to all visible cottages when none are active. It respects the
current filters and the 50% zoom floor, so very large fleets still need scrolling.
Branch labels express grouping, not inferred Git ancestry.

Rooms and residents are generated from cottage and task identity. Return visits keep the furniture in place. A new explicit task gets a new home. HubTown sets the cozy base with switchboards and pigeonholes. AppTown fills its desks with computers, phones and charging leads. MemTown lines its walls with bookshelves. VaultTown is a clock-and-lock repair shop, with pendulums, key racks and scattered repair tools. Unknown projects get neutral homes.

Walk near a duck and it'll head for the pond. Cats and a goose roam too. **Sound starts off.** Enable it for footsteps, doors, a little wordless murmur when you talk to a resident, and status alerts, with separate ambience and alert switches. Reduced-motion preferences are respected.
Expand Down
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,8 @@
"deploy:demo": "node scripts/deploy-demo.mjs",
"test": "node --test",
"test:browser": "node scripts/browser-smoke.mjs",
"test:viewport": "node scripts/browser-viewport-smoke.mjs",
"test:layout": "node scripts/browser-packing-smoke.mjs",
"test:ambience": "node scripts/browser-ambience-smoke.mjs",
"test:night": "node scripts/browser-night-smoke.mjs"
},
Expand Down
97 changes: 97 additions & 0 deletions scripts/browser-packing-smoke.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
#!/usr/bin/env node
/** Compact districts, Overview and stable arrivals in real Chrome; fixture data only. */
import assert from 'node:assert/strict';
import {execFile} from 'node:child_process';
import {promisify} from 'node:util';
import {createFeedServer} from '../src/feed.mjs';

const run=promisify(execFile),page='cottagecode-packing-smoke',now=Date.now();
const cottage=(id,town,status,extra={})=>({id,taskId:id,name:id,town,status,occupancy:'live',inventoryScope:'dashboard',
task:'Verify compact town placement',updatedAt:now,taskStartedAt:now-60000,...extra});
let agents=[
...Array.from({length:7},(_,i)=>cottage('host-'+i,'HubTown',['idle','working','blocked','working','done','done','offline'][i],
{branch:i===0?'main':i===1||i===3?'feat/auth':i===2?'fix/cache':'',defaultBranch:'main',worktreePath:'/fixture/hub/'+i})),
cottage('roommate','HubTown','working',{worktreePath:'/fixture/hub/0',branch:'main'}),
cottage('child-a','HubTown','blocked',{parent:'host-0'}),cottage('child-b','HubTown','working',{parent:'host-0'}),
cottage('app-a','AppTown','working',{branch:'feat/mobile'}),cottage('app-b','AppTown','working',{branch:'feat/mobile'}),
cottage('mem','MemTown','blocked'),cottage('fusion','FusionTown','offline'),
cottage('vault-a','VaultTown','done'),cottage('vault-b','VaultTown','offline'),
];
const server=createFeedServer({snapshot:()=>({source:'packing-fixture',agents,relationships:[{from:'HubTown',to:'MemTown'}]})});
await new Promise(resolve=>server.listen(0,'127.0.0.1',resolve));
const url='http://127.0.0.1:'+server.address().port;
async function browser(operation,args=[]){
const {stdout}=await run('browser-hand',[operation,'--page-name',page,...args],{timeout:35000,maxBuffer:2e6});
const result=JSON.parse(stdout);assert.equal(result.success,true,JSON.stringify(result));return result;
}
const evaluate=async code=>(await browser('evaluate',['--code',code])).result;
const click=selector=>browser('click',['--selector',selector]);
async function until(code,message){
const end=Date.now()+10000;
while(Date.now()<end){if(await evaluate(code))return;await new Promise(r=>setTimeout(r,150));}
throw new Error(message);
}
const pass=message=>process.stdout.write('PASS '+message+'\n');
const positions=()=>evaluate('window.cottageState().plots.map(p=>[p.id,p.x,p.y]).sort()');

try{
await browser('open',['--url',url]);
await until('window.cottageState?.().agents.length===16','Fixture failed to load');
await browser('snapshot');
const initial=await positions();
assert.deepEqual(await evaluate('window.cottageState().townCounts.HubTown'),{working:4,blocked:2,total:10,active:6});
assert.equal(await evaluate('window.cottageState().plots.length'),13);
const scale=await evaluate('window.cottageState().viewport.scale');
await click('#fullscreen-toggle');
assert.equal(await evaluate('window.cottageState().viewport.scale'),scale);
await click('#map-overview');
assert.equal(await evaluate('document.getElementById("overview-status").textContent.includes("outside")'),false);
assert.deepEqual(await positions(),initial);
await evaluate('window.cottageObservatory.focusCottage("host-0")');
await click('[data-action="follow"]');await click('#map-overview');
assert.equal(await evaluate('window.cottageState().scene.followId'),null);
await click('[data-action="enter"]');
assert.equal(await evaluate('document.getElementById("map-zoom").hidden'),true);
await click('#leave-cottage');await click('#map-overview');
pass('Overview frames active households, stops following, and leaves room controls intact');

const before=await evaluate('({zoom:window.cottageState().viewport.zoom,center:window.cottageState().viewport.center})');
agents=agents.map(a=>a.id==='host-3'?{...a,status:'blocked',branch:'renamed'}:a).reverse();
await until('window.cottageState().agents.find(a=>a.id==="host-3").branch==="renamed"','Feed update did not arrive');
assert.deepEqual(await positions(),initial);
assert.deepEqual(await evaluate('({zoom:window.cottageState().viewport.zoom,center:window.cottageState().viewport.center})'),before);
agents.push(...Array.from({length:8},(_,i)=>cottage('new-'+i,i<3?'HubTown':'NewTown','working')));
await until('window.cottageState().agents.length===24','Arrivals did not appear');
const arrived=await positions();
for(const p of initial)assert.deepEqual(arrived.find(q=>q[0]===p[0]),p);
pass('reordered feeds, status and branch changes, spare plots and annexes preserve existing locations');

for(const [width,height] of [[2077,1100],[900,450],[390,844]]){
const result=await evaluate(`(async()=>{
const frame=document.createElement('iframe');frame.style='position:fixed;inset:0;width:${width}px;height:${height}px;z-index:9999;border:0';
frame.src=location.href;document.body.append(frame);await new Promise(r=>frame.onload=r);
const w=frame.contentWindow,d=w.document,tick=()=>new Promise(r=>w.requestAnimationFrame(()=>w.requestAnimationFrame(r)));
for(let i=0;i<120&&!w.cottageState?.().plots.length;i++)await new Promise(r=>setTimeout(r,50));
const positions=()=>JSON.stringify(w.cottageState().plots.map(p=>[p.id,p.x,p.y]).sort()),before=positions(),scale=w.cottageState().viewport.scale;
d.getElementById('fullscreen-toggle').click();await tick();
const sameScale=scale===w.cottageState().viewport.scale;
d.getElementById('map-overview').click();await tick();
const s=w.cottageState(),panel=d.getElementById('details-shell').getBoundingClientRect();
const count=s.districts.filter(t=>t.y===30).length;
const result={width:s.width,topTowns:count,sameScale,samePositions:before===positions(),zoom:s.viewport.zoom,
overflow:d.documentElement.scrollWidth>w.innerWidth,toolbarFits:d.getElementById('map-zoom').getBoundingClientRect().right<=w.innerWidth,
panelVisible:panel.bottom<=w.innerHeight&&panel.right<=w.innerWidth,status:d.getElementById('overview-status').textContent};
d.getElementById('details-close').click();await tick();result.samePositions&&=before===positions();
d.getElementById('fullscreen-toggle').click();await tick();result.samePositions&&=before===positions();
frame.remove();return result;
})()`);
assert.equal(result.sameScale,true);assert.equal(result.samePositions,true);
assert.equal(result.overflow,false);assert.equal(result.toolbarFits,true);assert.equal(result.panelVisible,true);
assert.ok([50,75,100].includes(result.zoom));
if(width>2000){assert.ok(result.width>768);assert.ok(result.topTowns>2);}
}
pass('wide, short and 390px layouts keep scale, positions and reachable controls');
}finally{
await browser('goto',['--url','about:blank']).catch(()=>{});
await new Promise(resolve=>server.close(resolve));
}
14 changes: 13 additions & 1 deletion scripts/browser-smoke.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ import {readFile} from 'node:fs/promises';
import {createFeedServer} from '../src/feed.mjs';
import {pageActivity} from '../src/activity.mjs';
import {inputRequestVersion} from '../src/input-request.mjs';
import {createTownLayout} from '../src/town-layout.mjs';
import {plotAgentsForLayout} from '../src/roommates.mjs';

const run=promisify(execFile),page='cottagecode-browser-smoke';
const stamp=Date.now(),pr={number:123,url:'https://github.com/example/observatory/pull/123',state:'open',labels:['feature','babysit:ready'],headSha:'head-one',source:'github',checkedAt:stamp,statusCheckRollup:[{name:'smoke',status:'COMPLETED',conclusion:'SUCCESS',detailsUrl:'https://github.com/example/observatory/actions/runs/123'}]};
Expand Down Expand Up @@ -230,6 +232,8 @@ try{
await until('window.cottageObservatory.state.replayIndex>'+reducedReplay,'Reduced motion incorrectly disabled replay');
await key('Escape');
pass('emulated reduced-motion preference preserves walking and recorded replay');
// This next scenario needs fresh evidence even when the preceding real-browser journey took several minutes.
pr.checkedAt=Date.now();
const historical=Array.from({length:500},(_,i)=>({id:'archived-'+i,name:'Old task '+i,town:'HubTown',status:'offline',occupancy:'settled',inventoryScope:'history',worktreePath:i===0?'/fixture/shared':'/fixture/history/'+i,pr:{...pr,number:i+1000,url:'https://github.com/example/history/pull/'+(i+1000),labels:['babysit:blocked']}}));
agents=[...historical,
{id:'current',name:'Current work',town:'HubTown',status:'working',occupancy:'live',inventoryScope:'dashboard',worktreePath:'/fixture/shared',task:'Current task',pr},
Expand All @@ -244,6 +248,11 @@ try{
assert.equal(await evaluate('document.querySelector(\'[data-pr="ready"] span\').textContent'),'1');
const compact=await evaluate('({height:window.cottageState().height,plot:window.cottageState().plots.find(p=>p.id===\'current\')})');
assert.ok(compact.height<1000);assert.deepEqual(compact.plot.roommates,[]);
// A live-only control follows the same arrivals without ever visiting history.
// Compact courtyards may legitimately need an annex before the eighth cottage.
const layoutControl=createTownLayout({maxWidth:await evaluate('window.cottageState().packingWidth')});
const liveControl=async()=>layoutControl.update(plotAgentsForLayout(await evaluate('window.cottageState().agents.filter(a=>a.inventoryScope!=="history")')),{trimEmptyBlocks:true});
await liveControl();
await click('#settled');
await until('document.querySelectorAll(\'#cottage-list button\').length===502','Settled toggle did not expose canonical history');
assert.deepEqual(await evaluate('window.cottageState().plots.find(p=>p.id===\'current\').roommates'),['archived-0'],'The current task remains the shared-worktree host while history is visible');
Expand All @@ -268,6 +277,7 @@ try{
await until('window.cottageState().plots.length===3','New dashboard arrival did not get a plot');
assert.deepEqual(await evaluate('window.cottageState().plots.find(p=>p.id===\'arrival\')'),expandedArrival,'A live cottage arriving while history is visible keeps its canonical position');
assert.equal(await evaluate('window.cottageState().height'),compact.height,'Historical plots must not push new live cottages into distant annexes');
await liveControl();
await click('#settled');
await click('.cottage-directory summary');
await click('[data-cottage="archived-400"]');
Expand All @@ -277,7 +287,9 @@ try{
assert.equal(await evaluate('window.cottageObservatory.state.player.y<window.cottageState().height'),true,'Leaving a hidden historical cottage must return Jack inside the compact village');
agents.push(...Array.from({length:6},(_,i)=>({...agents.find(a=>a.id==='current'),id:'later-'+i,worktreePath:'/fixture/later/'+i})));
await until('window.cottageState().plots.length===9','Later dashboard arrivals did not get plots after the historical visit');
assert.equal(await evaluate('window.cottageState().height'),compact.height,'Visiting history must not consume the eight live HubTown slots');
const expected=await liveControl();
const actual=await evaluate('({height:window.cottageState().height,width:window.cottageState().width,plots:window.cottageState().plots.map(p=>[p.id,p.x,p.y]).sort()})');
assert.deepEqual(actual,{height:expected.height+112,width:expected.width,plots:expected.plots.map(p=>[p.agent.id,p.x,p.y]).sort()},'Historical visits must not consume live plots or change where later cottages are built');
pass('canonical live inventory keeps history out of plots, selection and PR counts; settled toggle restores stable homes');
await browser('screenshot');
process.stdout.write('Browser journey passed. Fixture: '+url+'\n');
Expand Down
Loading
Loading