From c8b92f91d428f22fde080a0e33c4d39644cff09c Mon Sep 17 00:00:00 2001 From: "autojack-bot[bot]" <266347038+autojack-bot[bot]@users.noreply.github.com> Date: Wed, 23 Sep 2026 01:53:20 +0200 Subject: [PATCH 1/3] feat(town): add stable fullscreen and compact map overview --- AGENTS.md | 8 + README.md | 24 ++ package.json | 2 + scripts/browser-packing-smoke.mjs | 97 ++++++++ scripts/browser-smoke.mjs | 14 +- scripts/browser-viewport-smoke.mjs | 184 ++++++++++++++ src/fullscreen.mjs | 29 +-- src/map-display.mjs | 108 +++++++++ src/observatory.mjs | 61 +++-- src/town-layout.mjs | 188 ++++++++++++++ src/town.html | 69 ++++-- src/town.mjs | 378 +++++++++++------------------ src/viewport.mjs | 129 ++++++++++ src/world.mjs | 53 +--- test/fullscreen.test.mjs | 28 +-- test/town-layout.test.mjs | 95 ++++++++ test/viewport.test.mjs | 124 ++++++++++ 17 files changed, 1208 insertions(+), 383 deletions(-) create mode 100644 scripts/browser-packing-smoke.mjs create mode 100644 scripts/browser-viewport-smoke.mjs create mode 100644 src/map-display.mjs create mode 100644 src/town-layout.mjs create mode 100644 src/viewport.mjs create mode 100644 test/town-layout.test.mjs create mode 100644 test/viewport.test.mjs diff --git a/AGENTS.md b/AGENTS.md index 8dab051..a2a8afa 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 @@ -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 | @@ -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. diff --git a/README.md b/README.md index 879e49e..5b09437 100644 --- a/README.md +++ b/README.md @@ -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 ``` @@ -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. diff --git a/package.json b/package.json index b8a490b..8a5a1c1 100644 --- a/package.json +++ b/package.json @@ -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" }, diff --git a/scripts/browser-packing-smoke.mjs b/scripts/browser-packing-smoke.mjs new file mode 100644 index 0000000..e46e587 --- /dev/null +++ b/scripts/browser-packing-smoke.mjs @@ -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()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)); +} diff --git a/scripts/browser-smoke.mjs b/scripts/browser-smoke.mjs index 89cfc9e..e348c90 100644 --- a/scripts/browser-smoke.mjs +++ b/scripts/browser-smoke.mjs @@ -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'}]}; @@ -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}, @@ -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'); @@ -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"]'); @@ -277,7 +287,9 @@ try{ assert.equal(await evaluate('window.cottageObservatory.state.player.y