From 4de915e02b9b0c7cca0b2355d619c0ad74770644 Mon Sep 17 00:00:00 2001 From: Dmitrii Fedorov Date: Wed, 23 Sep 2026 12:48:48 -0300 Subject: [PATCH 1/3] fix(queen): the close-up zoom ceiling follows the size of the map Zoom on the Queen map is relative to the whole field (1 shows everything), and every zoom path clamped it at a fixed 128. As the catalogue grows, a fixed ceiling is worth fewer pixels. Since 2026-09-21 the nightly scan finds eleven repositories; the smallest cell then needs zoom 130 at 390x300 to reach its readable size, check:queen-continuous fails with "Close-up stays readable at the actual viewport", and t27-world-scan stops in its gates (runs of 2026-09-21, 22 and 23). The ceiling is now the zoom at which the smallest cell reaches the largest readable width a close-up asks for (520 px), never below the old 128 (hiveMaxZoom, catalogMaxZoom). The cell and region close-ups, the connection view, the wheel, the pinch and the + button share it, and fit() takes an unfocused zoom down when a narrower host lowers the ceiling, so the next drag or wheel does not jump. Tests: qa/queen-continuous-map.mjs checks every close-up on a field eight times wider (fails on the old code with today's data) and catalogMaxZoom itself; qa/queen-hive-display-contract.mjs pins hiveMaxZoom, the extended hiveFocusZoom and, in the scene source, that no zoom path keeps 128. Co-Authored-By: Claude Opus 5.5 --- apps/website/qa/queen-continuous-map.mjs | 6 +++++ .../qa/queen-hive-display-contract.mjs | 9 ++++++++ .../src/components/QueenCombBabylon.tsx | 19 +++++++++------ .../src/components/queenCatalogData.ts | 20 +++++++++++----- .../src/components/queenHiveDisplay.ts | 23 ++++++++++++++++--- 5 files changed, 61 insertions(+), 16 deletions(-) diff --git a/apps/website/qa/queen-continuous-map.mjs b/apps/website/qa/queen-continuous-map.mjs index 98d1c59405..46338c8fb0 100644 --- a/apps/website/qa/queen-continuous-map.mjs +++ b/apps/website/qa/queen-continuous-map.mjs @@ -21,6 +21,7 @@ assert.equal(data.catalogCellAt(world.map,1e8,1e8),-1); const positions=world.map.positions; const fieldWidth=Math.max(...positions.map(p=>p.x+HEX_R*p.scale))-Math.min(...positions.map(p=>p.x-HEX_R*p.scale)); const fieldHeight=Math.max(...positions.map(p=>p.y+HEX_R*p.scale))-Math.min(...positions.map(p=>p.y-HEX_R*p.scale)); +{const scale=Math.min(...positions.map(p=>p.scale));for(const [width,halfWidth] of [[390,1000],[390,1e6],[1440,5e5]])assert.equal(data.catalogMaxZoom(world.map,halfWidth,width),Math.max(128,520/(S_CELL*scale*width/(halfWidth*2))),'the zoom ceiling is the one that brings the smallest cell to 520 px');} for(const [width,height] of [[1440,800],[390,600],[390,300]]){ const halfWidth=Math.max(fieldWidth,fieldHeight*width/height)/2*1.2,halfHeight=halfWidth*height/width; for(const region of world.map.regions){ @@ -30,6 +31,11 @@ for(const [width,height] of [[1440,800],[390,600],[390,300]]){ // bound is an equality and floating point may land a few 1e-14 px under it (it did at // 390x300 once the field held eight repositories). A pixel fraction is not a regression. for(const i of region.indices){const view=data.catalogFocusView(world.map,i,halfWidth,halfHeight,width,height),p=positions[i];assert.equal(view.x,p.x);assert.equal(view.y,p.y);const projected=S_CELL*p.scale*width/(halfWidth*2)*view.zoom;assert(projected>=Math.min(260,width*.84,height*.74)-1e-6,'Close-up stays readable at the actual viewport');} + // A catalogue that grows makes zoom 1 cover more world per pixel. The nightly + // scans of 2026-09-21..23 hit exactly that (11 repositories, the old fixed + // ceiling of 128 fell short), so the same bound must hold on a field eight + // times wider; catalogMaxZoom is what lets it. + for(const i of region.indices){const grown=halfWidth*8,view=data.catalogFocusView(world.map,i,grown,grown*height/width,width,height),p=positions[i];const projected=S_CELL*p.scale*width/(grown*2)*view.zoom;assert(projected>=Math.min(260,width*.84,height*.74)-1e-6,'Close-up stays readable when the catalogue grows');} } } for(const region of world.map.regions){ diff --git a/apps/website/qa/queen-hive-display-contract.mjs b/apps/website/qa/queen-hive-display-contract.mjs index 7d4a3fa7f6..39e2e5d766 100644 --- a/apps/website/qa/queen-hive-display-contract.mjs +++ b/apps/website/qa/queen-hive-display-contract.mjs @@ -41,9 +41,18 @@ assert.equal(h.hiveDisplayLod(340),'detail'); assert.ok(h.hiveFocusZoom(10,390,650)>8); assert.ok(h.hiveFocusZoom(10,390,650)<=128); assert.equal(h.hiveFocusZoom(0,390,650),1); +// The ceiling follows the field: a cell 1 px wide at zoom 1 still reaches its readable size. +assert.equal(h.hiveMaxZoom(10),128,'small fields keep the old ceiling'); +assert.equal(h.hiveMaxZoom(1),h.HIVE_FOCUS_MAX_PX); +assert.equal(h.hiveMaxZoom(0),128); +assert.equal(h.hiveFocusZoom(1,390,650),128,'without a ceiling the old clamp applies'); +assert.ok(Math.abs(h.hiveFocusZoom(1,390,650,h.hiveMaxZoom(1))-390*.84)<1e-9,'with the field ceiling the close-up is exactly readable'); assert.equal(h.hiveIssueUrl(repo,7),'https://github.com/gHashTag/trios/issues/7'); assert.equal(h.hiveIssueUrl('evil.example/a',NaN),null); const scene=readFileSync(new URL('../src/components/QueenCombBabylon.tsx',import.meta.url),'utf8'); +assert.ok(!/Math\.min\(\s*128\b/.test(scene),'no zoom path of the map keeps the fixed ceiling of 128'); +assert.equal((scene.match(/Math\.min\(maxZoom\b/g)??[]).length,3,'the wheel, the pinch and the + button clamp to the ceiling fit() computes'); +assert.match(scene,/if \(focusIndex === null && zoom > maxZoom\) zoom = zoomGoal = maxZoom;/,'fit() takes an unfocused zoom down when the ceiling drops'); const page=readFileSync(new URL('../src/pages/Queen.tsx',import.meta.url),'utf8'); const cards=readFileSync(new URL('../src/components/QueenHiveDisplays.tsx',import.meta.url),'utf8'); const css=readFileSync(new URL('../src/pages/Queen.css',import.meta.url),'utf8'); diff --git a/apps/website/src/components/QueenCombBabylon.tsx b/apps/website/src/components/QueenCombBabylon.tsx index 19a012fbf0..5150f8cf48 100644 --- a/apps/website/src/components/QueenCombBabylon.tsx +++ b/apps/website/src/components/QueenCombBabylon.tsx @@ -10,8 +10,8 @@ import { ImageProcessingConfiguration } from "@babylonjs/core/Materials/imagePro import { useEffect, useRef, useState, useImperativeHandle } from "react"; import { QueenHiveDisplays, QueenHiveTaskLegend, type HiveDisplayController } from './QueenHiveDisplays'; import { QueenStarfield } from './QueenStarfield'; -import {catalogCellAt,catalogFocusView,catalogSpecSelection,catalogConnectionView,type CatalogHive,type CatalogController} from './queenCatalogData'; -import { HIVE_TASK_PALETTE, hiveSignalDelivery, hiveSignalRing, hiveRunningIssueNumbers, hiveTaskPaint, hiveTaskCounts, hiveDisplayPaintKey, hiveFocusZoom, hiveDisplayLod, type HiveDisplay, type HiveDisplayProjection, type HiveTaskTone, type HiveSignalHealth, type HiveSignalCursor, type HiveEventRing } from './queenHiveDisplay'; +import {catalogCellAt,catalogFocusView,catalogMaxZoom,catalogSpecSelection,catalogConnectionView,type CatalogHive,type CatalogController} from './queenCatalogData'; +import { HIVE_TASK_PALETTE, hiveSignalDelivery, hiveSignalRing, hiveRunningIssueNumbers, hiveTaskPaint, hiveTaskCounts, hiveDisplayPaintKey, hiveFocusZoom, hiveMaxZoom, hiveDisplayLod, type HiveDisplay, type HiveDisplayProjection, type HiveTaskTone, type HiveSignalHealth, type HiveSignalCursor, type HiveEventRing } from './queenHiveDisplay'; import { HIVE_WALL_ROTATION, hiveWallToWorld, hiveWorldToWall } from './queenHiveOrientation'; import type { Ref } from "react"; import { TransformNode } from "@babylonjs/core/Meshes/transformNode"; @@ -357,6 +357,8 @@ export function QueenCombBabylon({ cards, workers, onPick, pickIndex = null, fit camera.panningSensibility = 0; let zoom = savedViewRef.current?.zoom ?? 1; let zoomGoal = zoom; + // recomputed by fit(): the ceiling follows the field and the viewport (hiveMaxZoom) + let maxZoom = 128; if (savedViewRef.current) { camera.target.x = savedViewRef.current.x; camera.target.y = savedViewRef.current.y; } const restoredFocus = savedViewRef.current?.focusKey ? cells.findIndex((_,index)=>viewKey(index)===savedViewRef.current?.focusKey) : -1; let focusIndex: number | null = restoredFocus >= 0 ? restoredFocus : null; @@ -394,9 +396,12 @@ export function QueenCombBabylon({ cards, workers, onPick, pickIndex = null, fit const aspect = w / band; let halfW = fieldW / 2, halfH = halfW / aspect; if (halfH < fieldH / 2) { halfH = fieldH / 2; halfW = halfH * aspect; } + maxZoom = catalogRef.current ? catalogMaxZoom(catalogRef.current, halfW, w) : hiveMaxZoom(S_CELL * w / (halfW * 2)); + // a narrower host lowers the ceiling; take the zoom down with it here, not at the next drag or wheel + if (focusIndex === null && zoom > maxZoom) zoom = zoomGoal = maxZoom; if (focusIndex !== null && cells[focusIndex]) { - const view=catalogRef.current?(focusConnection?catalogConnectionView(catalogRef.current,focusIndex,halfW,halfH,specInset()):catalogFocusView(catalogRef.current,focusIndex,halfW,halfH,w,band,catalogRef.current.cells[focusIndex]?.kind==='spec'?specInset():undefined)):null; - zoom = zoomGoal = view?.zoom??hiveFocusZoom(S_CELL * cellScale(focusIndex) * w / (halfW * 2), w, band); + const view=catalogRef.current?(focusConnection?catalogConnectionView(catalogRef.current,focusIndex,halfW,halfH,specInset(),maxZoom):catalogFocusView(catalogRef.current,focusIndex,halfW,halfH,w,band,catalogRef.current.cells[focusIndex]?.kind==='spec'?specInset():undefined)):null; + zoom = zoomGoal = view?.zoom??hiveFocusZoom(S_CELL * cellScale(focusIndex) * w / (halfW * 2), w, band, maxZoom); const focusedPoint = hiveWallToWorld(view?.x??cells[focusIndex].x, view?.y??cells[focusIndex].y); camera.target.x = focusedPoint.x; camera.target.y = focusedPoint.y; @@ -427,7 +432,7 @@ export function QueenCombBabylon({ cards, workers, onPick, pickIndex = null, fit const r = canvas.getBoundingClientRect(); focusIndex = null; anchor = planeAt(e.clientX - r.left, e.clientY - r.top); - zoomGoal = Math.min(128, Math.max(0.05, zoomGoal * Math.exp(-e.deltaY * 0.0016))); + zoomGoal = Math.min(maxZoom, Math.max(0.05, zoomGoal * Math.exp(-e.deltaY * 0.0016))); host.setAttribute("data-zoom-goal", zoomGoal.toFixed(2)); }; // Listen above the native spec labels too: their text must not form dead @@ -442,7 +447,7 @@ export function QueenCombBabylon({ cards, workers, onPick, pickIndex = null, fit anchor=null; const r = canvas.getBoundingClientRect(); const a=planeAt(movement.from.x-r.left,movement.from.y-r.top); - zoom=zoomGoal=Math.min(128,Math.max(.05,zoom*movement.scale));fit(); + zoom=zoomGoal=Math.min(maxZoom,Math.max(.05,zoom*movement.scale));fit(); const b=planeAt(movement.to.x-r.left,movement.to.y-r.top); if (a && b) { const from = hiveWallToWorld(a.x,a.z), to = hiveWallToWorld(b.x,b.z); @@ -461,7 +466,7 @@ export function QueenCombBabylon({ cards, workers, onPick, pickIndex = null, fit window.addEventListener('blur',onBlur); // the toolbar's FIT VIEW / - / + reach the scene through the same handle the canvas comb exposes cameraRef.current = { - zoomIn: () => { focusIndex = null; anchor = null; zoom = zoomGoal = Math.min(128, zoom * 1.25); fit(); }, + zoomIn: () => { focusIndex = null; anchor = null; zoom = zoomGoal = Math.min(maxZoom, zoom * 1.25); fit(); }, zoomOut: () => { focusIndex = null; anchor = null; zoom = zoomGoal = Math.max(0.05, zoom / 1.25); fit(); }, // FIT VIEW is the way home: it undoes the roam as well as the zoom fit: () => { focusIndex = null; selectedDisplayRef.current = null; setSelectedDisplayKey(null); host.removeAttribute('data-display-selected'); anchor = null; zoom = zoomGoal = 1; camera.target.copyFrom(centreWorld); fit(); }, diff --git a/apps/website/src/components/queenCatalogData.ts b/apps/website/src/components/queenCatalogData.ts index 41aa8a2d0d..a7a19f98d0 100644 --- a/apps/website/src/components/queenCatalogData.ts +++ b/apps/website/src/components/queenCatalogData.ts @@ -1,7 +1,7 @@ import {HEX_R,S_CELL,hexCellCount,hexRingStart,hexRingsFor,hexToWorld,spiralAxial,hexIndexAt} from './queenHud.ts'; import {keyWorldAtlas,type UniverseAtlas} from '../lib/queenUniverseAtlas.ts'; import type {WorldIssue} from './queenRepositoryWorld.ts'; -import {hiveFocusZoom} from './queenHiveDisplay.ts'; +import {HIVE_ZOOM_CEILING,hiveFocusZoom,hiveMaxZoom} from './queenHiveDisplay.ts'; export type CatalogCell = {kind:'spec';key:string;specId:string;placement:'core'|'source';sourceRepo?:string;title:string;sources:string[];count:1} | {kind:'repo';key:string;title:string;repo:string;count:number;open:number|null}; @@ -110,22 +110,30 @@ export function catalogSpecSelection(map:CatalogHive,index:number|null):{indices return {indices,links:(map.specLinks??[]).filter(([a,b])=>selected.has(a)&&selected.has(b))}; } -export function catalogConnectionView(map:CatalogHive,index:number,halfWidth:number,halfHeight:number,inset={right:0,bottom:0}) { +const smallestScale=new WeakMap(); +/** The zoom ceiling of this map when zoom 1 spans 2*halfWidth world units across `width` pixels (see hiveMaxZoom). */ +export function catalogMaxZoom(map:CatalogHive,halfWidth:number,width:number):number { + let scale=smallestScale.get(map); + if(scale===undefined){scale=Infinity;for(const p of map.positions??[])if(p.scalemap.positions?.[i]?[map.positions[i]]:[]); if(!points.length)return null; const minX=Math.min(...points.map(p=>p.x-HEX_R*p.scale)),maxX=Math.max(...points.map(p=>p.x+HEX_R*p.scale)); const minY=Math.min(...points.map(p=>p.y-HEX_R*p.scale)),maxY=Math.max(...points.map(p=>p.y+HEX_R*p.scale)); - const zoom=Math.min(128,Math.max(.05,Math.min(halfWidth*2*(1-inset.right)/(maxX-minX+S_CELL),halfHeight*2*(1-inset.bottom)/(maxY-minY+S_CELL))*.78)); + const zoom=Math.min(maxZoom,Math.max(.05,Math.min(halfWidth*2*(1-inset.right)/(maxX-minX+S_CELL),halfHeight*2*(1-inset.bottom)/(maxY-minY+S_CELL))*.78)); return {x:(minX+maxX)/2+inset.right*halfWidth/zoom,y:(minY+maxY)/2-inset.bottom*halfHeight/zoom,zoom}; } /** Region and cell close-ups share the exact camera sizing contract on desktop and mobile. */ export function catalogFocusView(map:CatalogHive,index:number,halfWidth:number,halfHeight:number,width:number,height:number,inset={right:0,bottom:0}) { const position=map.positions?.[index];if(!position)return null; - const region=map.regions?.find(r=>r.portalIndex===index); + const region=map.regions?.find(r=>r.portalIndex===index),maxZoom=catalogMaxZoom(map,halfWidth,width); const zoom=region? - Math.min(128,Math.max(.5,Math.min(halfWidth,halfHeight)/(region.focusRadius*1.12))): - hiveFocusZoom(S_CELL*position.scale*width/(halfWidth*2),width*(1-inset.right),height*(1-inset.bottom)); + Math.min(maxZoom,Math.max(.5,Math.min(halfWidth,halfHeight)/(region.focusRadius*1.12))): + hiveFocusZoom(S_CELL*position.scale*width/(halfWidth*2),width*(1-inset.right),height*(1-inset.bottom),maxZoom); return {x:(region?.x??position.x)+(region?0:inset.right*halfWidth/zoom),y:(region?.y??position.y)-(region?0:inset.bottom*halfHeight/zoom),zoom}; } diff --git a/apps/website/src/components/queenHiveDisplay.ts b/apps/website/src/components/queenHiveDisplay.ts index 80a923ad24..84f07f7371 100644 --- a/apps/website/src/components/queenHiveDisplay.ts +++ b/apps/website/src/components/queenHiveDisplay.ts @@ -165,10 +165,27 @@ export function hiveDisplayLod(width: number): 'overview' | 'badge' | 'title' | return width < 64 ? 'overview' : width < 140 ? 'badge' : width < 260 ? 'title' : 'detail'; } -export function hiveFocusZoom(baseCellWidth: number, viewportWidth: number, viewportHeight: number): number { +/** Largest on-screen width, in CSS pixels, a close-up ever asks a cell to reach. */ +export const HIVE_FOCUS_MAX_PX = 520; +/** The zoom ceiling the map had when it held five repositories. */ +export const HIVE_ZOOM_CEILING = 128; + +/** + * Zoom is relative to the whole field (1 shows everything), so a fixed ceiling + * shrinks in pixels as the catalogue grows: with eleven repositories the + * smallest cell needed more than 128 to reach its readable size, the close-up + * contract failed, and the nightly catalogue refresh stopped. The ceiling is the + * zoom at which the smallest cell, `smallestCellWidth` pixels wide at zoom 1, + * reaches HIVE_FOCUS_MAX_PX -- never below the old 128. + */ +export function hiveMaxZoom(smallestCellWidth: number): number { + return smallestCellWidth > 0 ? Math.max(HIVE_ZOOM_CEILING, HIVE_FOCUS_MAX_PX / smallestCellWidth) : HIVE_ZOOM_CEILING; +} + +export function hiveFocusZoom(baseCellWidth: number, viewportWidth: number, viewportHeight: number, maxZoom = HIVE_ZOOM_CEILING): number { if (!(baseCellWidth > 0)) return 1; - const target = Math.max(64, Math.min(520, viewportWidth * .84, viewportHeight * .74)); - return Math.min(128, Math.max(.5, target / baseCellWidth)); + const target = Math.max(64, Math.min(HIVE_FOCUS_MAX_PX, viewportWidth * .84, viewportHeight * .74)); + return Math.min(maxZoom, Math.max(.5, target / baseCellWidth)); } export interface HiveDisplayProjection { index: number; x: number; y: number; width: number; height: number } From e7c272618bf5f6c0a16c054fcfc09152d1d2424e Mon Sep 17 00:00:00 2001 From: Dmitrii Fedorov Date: Wed, 23 Sep 2026 12:48:48 -0300 Subject: [PATCH 2/3] ci(t27-world-scan): lease the bot branch against its real head The checkout fetches main alone at depth 1, so the bare --force-with-lease of the push step had no remote-tracking ref for bot/t27-world-scan and expected the branch not to exist. The run of 2026-09-16 created it (PR #1039); from 2026-09-17 to 2026-09-20 every push was refused as "! [rejected] HEAD -> bot/t27-world-scan (stale info)" after all gates had passed, so no refresh reached the site even before the zoom ceiling broke. The expected value is now read with ls-remote and named explicitly: the push goes through whether the branch exists or not, and is still refused if someone moves the branch between the read and the push (checked against a local bare remote with a depth-1, main-only checkout). Co-Authored-By: Claude Opus 5.5 --- .github/workflows/t27-world-scan.yml | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/.github/workflows/t27-world-scan.yml b/.github/workflows/t27-world-scan.yml index 76fe27dc66..281ca8e752 100644 --- a/.github/workflows/t27-world-scan.yml +++ b/.github/workflows/t27-world-scan.yml @@ -116,8 +116,14 @@ jobs: git commit -q -m "t27 worlds: refresh the catalog ($N)" BRANCH=bot/t27-world-scan # The bot's own branch, and only ever this job writes it -- so the - # lease is against the copy this run started from, not against a human. - git push -q --force-with-lease origin "HEAD:refs/heads/$BRANCH" + # lease is against the branch as this run found it, not against a human. + # The checkout fetched main alone, so a bare --force-with-lease has no + # tracking ref to compare with and expects the branch to be absent: once + # the first refresh had created it, every push was refused as "stale + # info" (the nights of 2026-09-17..20). Name the expected value + # explicitly; an empty one still means "must not exist yet". + EXPECT=$(git ls-remote --heads origin "refs/heads/$BRANCH" | cut -f1) + git push -q --force-with-lease="refs/heads/$BRANCH:$EXPECT" origin "HEAD:refs/heads/$BRANCH" SHA=$(git rev-parse HEAD) node scripts/catalog-work-report.mjs --head-sha "$SHA" --out "$RUNNER_TEMP/catalog-report.md" PR=$(gh pr list --head "$BRANCH" --base main --state open --json number --jq '.[0].number // empty') From e90d427a7a3bab2794ec5dbdd1eee7916c2d8c57 Mon Sep 17 00:00:00 2001 From: Dmitrii Fedorov Date: Wed, 23 Sep 2026 12:48:48 -0300 Subject: [PATCH 3/3] ci(website-checks): run the map's close-up contracts on pull requests check:queen-continuous and check:queen-displays ran only in the nightly t27-world-scan, so the fixed zoom ceiling surfaced there, three nights after the catalogue outgrew it, instead of on a pull request. Both are pure node scripts over the committed atlas. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/website-checks.yml | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/.github/workflows/website-checks.yml b/.github/workflows/website-checks.yml index b5331fdb59..70db6989d2 100644 --- a/.github/workflows/website-checks.yml +++ b/.github/workflows/website-checks.yml @@ -219,6 +219,14 @@ jobs: # stale round called stale. Pure, no browser. - name: Queen idle reason run: npm run check:queen-idle + # The map's camera: a close-up of every cell stays readable at three + # viewports, and also on a field eight times wider, because zoom is + # relative to the whole field and the catalogue keeps growing. A fixed + # ceiling of 128 broke this once the scan found an eleventh repository, + # and the nightly t27 world scan -- the only place these ran -- then + # failed for three nights. Pure, no browser. + - name: Queen map close-ups stay readable + run: npm run check:queen-displays && npm run check:queen-continuous # The PASSPORT argues a result is worthless without the record of how it # was taken, so its own figures must not drift from its own prose: every # plotted quantity carries the literal the text prints for it, in both