diff --git a/.claude/skills/queen-hive-visuals/SKILL.md b/.claude/skills/queen-hive-visuals/SKILL.md new file mode 100644 index 0000000000..d2dc1c8501 --- /dev/null +++ b/.claude/skills/queen-hive-visuals/SKILL.md @@ -0,0 +1,35 @@ +--- +name: queen-hive-visuals +description: Maintain QUEEN's floating hive, original point-down TRINITY logo, sharp issue displays and matching camera interactions. +--- + +# QUEEN hive + +- Original TRINITY logo: flat top, apex DOWN, exact27 petals/135 edges, inside + the hub hexagon. Never substitute a generic triangle or mirror the mark. + The header SVG and central scene mark must agree on screen orientation. +- The field faces the viewer vertically. Its180-degree turn uses the shared + `queenHiveOrientation.ts` transform: local(x,height,z)→world(-x,z,height). + Camera stays at+Z. Changing camera alpha by pi is a back-side view, not a + half-turn; it reverses horizontal handedness. Do not revive that old patch. +- Match homepage Outfit for prose and JetBrains Mono for technical readouts, + using existing assets/tokens. Keep cells translucent and the viewport fully + filled after resize. Catalog stars require source, epoch, units and license; + the HYG4.1/J2000 projection is not live sky ephemerides or random decoration. +- All input uses the same coordinate convention: inverse ray-plane picking + including wall drift, pan, cursor zoom, Inspect and bounds. Cell lift still + comes toward the viewer. Native issue/epic text and HUD remain upright. +- Preserve original logo geometry in `QueenComb.tsx`, the same repo+issue + identity across polls, exact event joins and canonical GitHub links. Historical + GitHub titles are quoted source; only their individual nodes may be language + exempt, never controls or whole cards. +- Honey is hover/focus feedback. T27-yellow needs real coverage provenance; + red needs evidence of manual code. Missing issue→module proof is UNKNOWN, + not completed/generated code. Do not invent events or worker throughput. + +Before changing orientation, add a failing projection/interaction regression. +Run `npm run check:queen-displays` in `apps/website` (includes Babylon projection +and roundtrip tests), the affected regressions and build. Inspect the actual +logo and cards on desktop/mobile, reduced-motion, and test tap, pan and zoom. +Do not treat a data attribute or old screenshot as proof. Production publication +requires explicit user authority and the existing site release checks. diff --git a/apps/website/package.json b/apps/website/package.json index 6fdb623d83..bbc8ff1282 100644 --- a/apps/website/package.json +++ b/apps/website/package.json @@ -30,6 +30,8 @@ "check:queen-review": "node qa/queen-review-lifecycle-contract.mjs", "check:queen-viewport": "node qa/queen-viewport-contract.mjs", "check:queen-honesty": "node --experimental-strip-types qa/queen-honesty-contract.mjs", + "check:queen-coverage": "node --experimental-strip-types qa/queen-coverage-contract.mjs", + "check:queen-displays": "node --experimental-strip-types qa/queen-hive-display-contract.mjs", "check:queen-dead-api": "node qa/queen-viewport-contract.mjs --dead-api", "check:queen-touch": "node qa/queen-touch-contract.mjs", "check:queen-round": "node qa/queen-round-contract.mjs", diff --git a/apps/website/qa/queen-coverage-contract.mjs b/apps/website/qa/queen-coverage-contract.mjs new file mode 100644 index 0000000000..3184142010 --- /dev/null +++ b/apps/website/qa/queen-coverage-contract.mjs @@ -0,0 +1,69 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import * as hud from "../src/components/queenHud.ts"; + +let checks = 0; +const failures = []; +function check(label, run) { + checks++; + try { run(); } catch (error) { failures.push(`${label}: ${error.message}`); } +} +const cover = hud.hiveCoverOf; +for (const path of ["rings/t27", "rings/t27-compiler", "specs", "specs/auth", "tests/t27/parser"]) { + check(`no yellow inferred from ${path}`, () => assert.notEqual(cover(path, new Set()), "t27")); +} +check("basename is not a claim on another path", () => assert.notEqual(cover("other/auth", new Set(["auth"])), "t27")); +check("punctuation is identity", () => assert.notEqual(cover("a/b-c", new Set(["abc"])), "t27")); +check("exact path is a claim", () => assert.equal(cover("agent-server/auth", new Set(["agent-server/auth"])), "t27")); +check("unknown corpus is not manual code", () => assert.equal(cover("rings/t27", null), "unknown")); +check("empty cell awaits a boundary", () => assert.equal(cover(null, null), "awaiting")); +check("known corpus without a claim is migration debt", () => assert.equal(cover("manual", new Set()), "manual")); + +const manifest = { + coverageSchemaVersion: 1, + repos: [{ repo: "trios", commit: "abcdef1" }, { repo: "trinity", commit: "abcdef2" }], + specs: [ + { repo: "trios", module: "DisplayLabel", modulePath: "agent-server/auth", name: "OtherModule", path: "trios/specs/auth.t27" }, + { repo: "trinity", modulePath: "foreign", path: "trinity/specs/foreign.t27" }, + ], +}; +const build = hud.hiveCoverageFromManifest; +check("manifest parsing is a testable production function", () => assert.equal(typeof build, "function")); +if (build) { + check("only exact module claims from the displayed repository", () => assert.deepEqual([...build(manifest, "gHashTag/trios")], ["agent-server/auth"])); + check("repository changes do not retain old claims", () => assert.deepEqual([...build(manifest, "gHashTag/trinity")], ["foreign"])); + for (const repo of [null, 17, "gHashTag/missing", "another-owner/trios"]) { + check(`unknown repository ${repo}`, () => assert.equal(build(manifest, repo), null)); + } + for (const bad of [null, {}, { repos: manifest.repos }, { repos: manifest.repos, specs: [null] }]) { + check(`invalid manifest ${JSON.stringify(bad)}`, () => assert.equal(build(bad, "gHashTag/trios"), null)); + } + check("known empty corpus is distinct from unknown", () => assert.equal(build({ ...manifest, specs: [] }, "gHashTag/trios").size, 0)); + check("legacy module labels are not explicit path claims", () => { + assert.equal(build({ repos: manifest.repos, specs: [{ repo: "trios", module: "auth", path: "trios/specs/unrelated.t27" }] }, "gHashTag/trios"), null); + assert.equal(cover("DisplayLabel", build(manifest, "gHashTag/trios")), "manual"); + }); + check("primary t27 corpus has unprefixed source paths", () => { + const primary = { coverageSchemaVersion: 1, repos: [{ repo: "t27", commit: "abcdef1" }], specs: [{ repo: "t27", modulePath: "compiler/auth", path: "specs/auth.t27" }] }; + assert.deepEqual([...build(primary, "gHashTag/t27")], ["compiler/auth"]); + }); + check("versioned claims cannot omit the path mapping", () => assert.equal(build({ ...manifest, specs: [{ repo: "trios", module: "auth", path: "trios/specs/auth.t27" }] }, "gHashTag/trios"), null)); + for (const path of ["trios/../trinity/specs/foreign.t27", "trios//auth.t27", "trinity/specs/auth.t27", "trios/notes.md"]) { + check(`invalid claim source ${path} is unknown`, () => assert.equal(build({ ...manifest, specs: [{ repo: "trios", modulePath: "auth", path }] }, "gHashTag/trios"), null)); + } + check("unversioned corpus remains unknown", () => assert.equal(build({ ...manifest, repos: [{ repo: "trios" }] }, "gHashTag/trios"), null)); + check("case and traversal cannot alias another module", () => { + const paths = ["../auth", "agent//auth", "/agent/auth", "agent/./auth", "agent/../auth", "agent\\auth"]; + for (const modulePath of paths) assert.equal(build({ ...manifest, specs: [{ repo: "trios", modulePath, path: "trios/specs/x.t27" }] }, "gHashTag/trios"), null); + assert.equal(cover("Agent-Server/auth", build(manifest, "gHashTag/trios")), "manual"); + }); + check("display name does not claim a module", () => assert.equal(cover("OtherModule", build(manifest, "gHashTag/trios")), "manual")); + const actual = JSON.parse(readFileSync(new URL("../public/t27/manifest.json", import.meta.url), "utf8")); + const modules = JSON.parse(readFileSync(new URL("../public/queen/modules.json", import.meta.url), "utf8")); + check("current snapshot has no corpus and cannot claim yellow", () => { + assert.equal(build(actual, modules.repo), null); + assert.ok(modules.modules.every(m => cover(m.path, build(actual, modules.repo)) === "unknown")); + }); +} +if (failures.length) { console.error(failures.join("\n")); console.error(`Hive coverage: FAIL (${failures.length}/${checks})`); process.exit(1); } +console.log(`Hive coverage: PASS (${checks} checks)`); diff --git a/apps/website/qa/queen-hive-display-contract.mjs b/apps/website/qa/queen-hive-display-contract.mjs new file mode 100644 index 0000000000..1e1d280de2 --- /dev/null +++ b/apps/website/qa/queen-hive-display-contract.mjs @@ -0,0 +1,58 @@ +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +await import('./queen-hive-orientation.mjs'); +await import('./queen-starfield-contract.mjs'); +let h; +try { h = await import('../src/components/queenHiveDisplay.ts'); } +catch { console.error('RED: issue display model does not exist; close-up is still capped at8x and uses256px cards'); process.exit(1); } +const repo='gHashTag/trios'; +const old={repo,closedIssues:[{number:7,title:'old title',closedAt:'2026-01-01T00:00:00Z',labels:[]}],epics:[{number:8,title:'Epic',state:'open',closedAt:null,labels:[],children:[{number:7,title:'Child',state:'closed',closedAt:'2026-01-01T00:00:00Z'}]}]}; +const board=[{number:7,title:'Reopened',column:'running'},{number:9,title:'Live issue',column:'review'}]; +const rows=h.hiveDisplayRecords(repo,board,old); +assert.equal(rows.length,3); +assert.equal(rows.find(r=>r.number===7).state,'running'); +assert.equal(rows.find(r=>r.number===7).title,'Reopened'); +assert.equal(rows.find(r=>r.number===7).closedAt,null); +assert.equal(rows.find(r=>r.number===8).kind,'epic'); +assert.ok(rows.every(r=>r.coverage==='unknown'), 'issue closure/position never proves T27 coverage'); +assert.equal(h.hiveSameRepositorySnapshot(repo,old),old); +assert.equal(h.hiveSameRepositorySnapshot(repo,{...old,repo:'gHashTag/trinity'}),null); +assert.equal(h.hiveSameRepositorySnapshot(repo,{...old,repo:undefined}),null); +assert.equal(h.hiveSameRepositorySnapshot(null,old),null); +assert.equal(h.hiveDisplayRecords(repo,[],{...old,repo:'gHashTag/trinity'}).length,0); +assert.equal(h.hiveDisplayRecords('javascript:evil',board,old).length,0); +const p=h.placeHiveDisplays(new Map(),rows,37); +const p2=h.placeHiveDisplays(p.ledger,[{...rows[0],number:999,key:`${repo}#999`},...rows].reverse(),37); +assert.equal(p.placed[0],null); +for(const r of rows) assert.equal(p2.ledger.get(r.key),p.ledger.get(r.key)); +const e=(issue,id,at='2026-09-07T00:00:00Z')=>({issue,id,at,kind:'review',title:'same unrelated path',state:'wait'}); +const events=[e(7,'a'),e(9,'b'),e(7,'a'),e(7,'bad','invalid')]; +assert.deepEqual(h.hiveDisplayEvents(rows.find(r=>r.number===7),events).map(e=>e.id),['a']); +assert.equal(h.hiveDisplayEvents(rows.find(r=>r.number===8),events)[0].issue,7); +assert.equal(h.hiveDisplayEvents(rows.find(r=>r.number===9),[]).length,0); +assert.deepEqual(h.hiveDisplayEvents(rows[0],[e(7,'z-finished'),e(7,'a-dispatched')]).map(e=>e.id),['z-finished','a-dispatched'],'same-second ties preserve wire order, not alphabetical id order'); +assert.deepEqual(h.hiveEpicProgress(rows.find(r=>r.number===8),rows),{done:0,total:1}); +assert.equal(h.hiveDisplayLod(20),'overview'); +assert.equal(h.hiveDisplayLod(75),'badge'); +assert.equal(h.hiveDisplayLod(170),'title'); +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); +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'); +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'); +assert.match(cards, /]*data-lang-exempt="github-title"[^>]*>[^<]*\{row.title\}/, 'quoted option titles retain the existing source-language boundary'); +assert.match(cards, /

]*>\{row.title\}<\/h3>/, 'close-up source title is not mistranslated'); +assert.doesNotMatch(cards, /<(?:article|select|button|div)[^>]*data-lang-exempt/, 'UI controls and card chrome remain audited'); +assert.match(cards, /title=\{c.sourceTitle\}/, 'source-language explanation is localized'); +assert.match(scene,/displaysRef.current\[i\]\?\.coverage \?\? 'awaiting'/,'issue coverage cannot use colocated module'); +assert.match(scene,/\(displays \|\| t27Coverage === null\) && assert.ok(Math.abs(a-b)<1e-5, `${a} != ${b}`); +const rotation = Matrix.RotationYawPitchRoll(HIVE_WALL_ROTATION.y,HIVE_WALL_ROTATION.x,HIVE_WALL_ROTATION.z); +const view = Matrix.LookAtLH(new Vector3(0,0,4000),Vector3.Zero(),Vector3.Up()); +const screen = (x,z,height=0) => { + const p=Vector3.TransformCoordinates(new Vector3(x,height,z),rotation.multiply(view)); + return {x:p.x,y:-p.y,depth:p.z}; +}; +// Screen axes retain the source handedness after the true half-turn. +assert.ok(screen(100,0).x>screen(0,0).x, 'local right must project right, not mirrored'); +assert.ok(screen(0,100).ybaseLeft.y && apex.y>baseRight.y, 'original logo apex points DOWN'); +assert.ok(baseLeft.x= 2 && /const fieldNeed = Math\.max\(moduleCards\.length \+ 1, closedCount \+ 1, \(foundationState\.data\?\.rings\.length \?\? 0\) > 0 \? hexCellCount\(CASTLE_RING\) \+ 1 : 0\)/.test(src), "the field is as large as the honey and the castle need (the hub plus modules or closed issues, at least ring 7 when rings exist), the modules keep their inner cells (H-C2, K-2)"); +check(/\$\{QUEEN_API\}\/queen\/public-foundation/.test(src) && /"\.\/queen\/foundation\.json"/.test(src) && /data-foundation=\{hiveFoundation \?/.test(src), "GitHub facts use the server then the dated fallback, repository-scoped and named on the viewport (H-C1)"); +check((src.match(/hexField\(fieldNeed\)/g) || []).length >= 2 && /const fieldNeed = Math\.max\(moduleCards\.length \+ 1, closedCount \+ 1, hiveRecords\.length \+ 1, \(hiveFoundation\?\.rings\.length \?\? 0\) > 0 \? hexCellCount\(CASTLE_RING\) \+ 1 : 0\)/.test(src), "field reserves hub and space for modules, closed issues, live issue displays and same-repository castle"); check(/data-pick-kind=\{livePick\?\.kind/.test(src) && /data-pick-issue=\{livePick\?\.kind === "issue"/.test(src) && /if \(pick\.kind === "issue" && pick\.issue\)/.test(src), "a pick is (kind, number): an issue pick follows its number through the snapshot, the viewport names the kind and the issue (H-E)"); // the castle of the rings (K-1): places on spiral ring 7, epics to rings, towers by stage const ksRings = ["SR-00", "RUST-13", "T27-00", "RUST-04"]; @@ -175,7 +175,7 @@ check(ksSummary.epics === 1 && ksSummary.closed === 3 && ksSummary.total === 5 & check(wallBetween([ksEpic({ state: "closed", closedAt: "x", children: ksKids(2, 0) })], [ksEpic({ state: "closed", closedAt: "x", children: ksKids(1, 0) })]) === true && wallBetween([ksEpic({ children: ksKids(3, 2) })], [ksEpic({ state: "closed", closedAt: "x", children: ksKids(1, 0) })]) === false && wallBetween([], [ksEpic({ state: "closed", closedAt: "x", children: ksKids(1, 0) })]) === false, "a wall rises only between two rings whose every epic is a keep"); check(ringOfModulePath("rings/SR-00") === "SR-00" && ringOfModulePath("rings/RUST-13/clade-meshd/src") === "RUST-13" && ringOfModulePath("rings") === null && ringOfModulePath("apps/rings/SR-00") === null && ringOfModulePath(".") === null, "ringOfModulePath: rings/ and anything beneath it; nothing else"); check(epicOfIssue(101, [ksEpic({ number: 9001, children: ksKids(3, 2) }), ksEpic({ number: 9002, children: [] })])?.number === 9001 && epicOfIssue(1, [ksEpic({ children: ksKids(3, 2) })]) === null, "epicOfIssue: the first epic listing the issue among its children, else null"); -check(/data-working-age/.test(combSrc) && /kind === "finished" \|\| kind === "error"/.test(combSrc) && !/WORK_WINDOW_MS/.test(combSrc) && /const i = cellOfPath\(title\);/.test(combSrc), "work is a state, not an age: the cell is worked while the issue's LAST wire event is not terminal, and the age rides beside the count (measured 2026-09-06: the wire burst-delivers, newest row 18 min old while the swarm reported working)"); +check(/data-working-age/.test(combSrc) && /kind === "finished" \|\| kind === "error"/.test(combSrc) && !/WORK_WINDOW_MS/.test(combSrc) && /const i = displaysRef.current \? indexByNumber.get\(issueNumber\) \?\? -1 : cellOfPath\(title\);/.test(combSrc), "work follows the issue's LAST wire state, not age; issue displays join its exact number and only legacy module cells use paths"); check(/if \(hover >= 0 && hover !== p && cells\[hover\]\) placeDashed/.test(combSrc), "every cell under the pointer lights up, not only the ones carrying a card"); check(!/\.glb/.test(combSrc) && !/LoadAssetContainerAsync/.test(combSrc) && /data-look", "hive"/.test(combSrc) && /CreateLineSystem\("queen-mark"/.test(combSrc), "the hive draws no model at all: no .glb is named, no asset container is loaded, and the Queen at the centre is the mark itself (the user, 2026-09-06)"); const SPACE_KIT = ["platform_small", "machine_generatorLarge", "satelliteDish_large", "hangar_smallA", "hangar_roundA", "structure_closed", "gate_complex", "crater", "hangar_largeA", "rock_crystalsLargeA", "astronautA", "astronautB", "rover", "alien"]; @@ -184,14 +184,15 @@ check(/host\.setAttribute\("data-foundation-shape", "outline"\)/.test(combSrc) & check(/host\.setAttribute\("data-hover-issue"/.test(combSrc) && /queen27-hover-card/.test(combSrc), "a hovered cell names its GitHub issue on the host and in the card"); check(/host\.setAttribute\("data-castle-source", fdNow \? fdNow\.source : "none"\)/.test(combSrc) && /data-castle-stages/.test(combSrc) && /data-castle-unassigned/.test(combSrc), "the castle's testimony on the host comes from the snapshot's source, never a guess; stages and unassigned epics are named (K-2)"); check(HIVE_TONES.t27 === "#FFD45A" && HIVE_TONES.awaiting === "#64DCFF" && HIVE_TONES.manual === "#FF4D5E" && HIVE_TONES.hover === "#FFC24D", "the hive's colour law is exact: yellow T27, neon blue awaiting, red manual, honey only for the hand"); -const claimed = new Set([hiveKey("Access Control")]); +const claimed = new Set(["agent-server/apps/access-control"]); check(hiveCoverOf("agent-server/apps/access-control", claimed) === "t27" && hiveCoverOf("agent-server/apps/manual", claimed) === "manual" && hiveCoverOf(null, claimed) === "awaiting" && hiveToneOf("manual")[0] > hiveToneOf("manual")[2], "cover is a claim against the module: T27, manual, or awaiting; no decoration chooses it"); -check(/fieldRoot\.rotation\.x = Math\.PI \/ 2/.test(combSrc) && /new ArcRotateCamera\("cam", Math\.PI \/ 2, Math\.PI \/ 2/.test(combSrc) && /data-orientation", "facing"/.test(combSrc), "the comb is a wall facing the player, not a horizontal board (the user, 2026-09-06)"); +check(/fieldRoot.rotation.copyFromFloats\(HIVE_WALL_ROTATION.x, HIVE_WALL_ROTATION.y, HIVE_WALL_ROTATION.z\)/.test(combSrc) && /new ArcRotateCamera\("cam", Math\.PI \/ 2, Math\.PI \/ 2/.test(combSrc) && /data-orientation", "facing"/.test(combSrc), "the comb is a wall facing the player, with the shared point-down orientation"); check(/HIVE_TONES\.hover/.test(combSrc) && /hiveToneOf\(covers\[i\]\)/.test(combSrc) && /queen27-hive-law/.test(combSrc), "hover is honey while cell claims follow the law, and the legend names that law"); -check(/fieldRoot\.position\.y = Math\.sin\(nowMs \/ 6400\) \* 7/.test(combSrc), "the wall floats; the drift is small enough not to steal the cell from under the pointer"); -check(/camera\.target\.y \+= \(-anchor\.z - camera\.target\.y\) \* k/.test(combSrc) && /const p = planeAt\(x, y\)/.test(combSrc), "zoom and picking use the same vertical wall-plane coordinates"); -check(/host\.setAttribute\("data-event-cards", String\(latest\.length\)\)/.test(combSrc) && /const EVENT_CARD_POOL = 12/.test(combSrc) && /new DynamicTexture\(`event-card-\$\{k\}`, 256/.test(combSrc), "the latest wire events become bounded display cards on their cells, capped at a readable twelve"); -check(/function useT27Coverage\(\)/.test(src) && /fetch\("t27\/manifest\.json"/.test(src) && /spec\.repo !== "trinity"/.test(src) && /hiveKey\(value\)/.test(src), "yellow is claimed only by trinity rows read from the real T27 manifest, not by an issue label or a guess"); +check(/fieldRoot\.position\.y = motionPreference\.matches \|\| selectedDisplayRef\.current !== null \? 0 : Math\.sin\(nowMs \/ 6400\) \* 7/.test(combSrc), "wall drift stops for close-up reading and reduced motion"); +check(/hiveWallToWorld\(anchor.x, anchor.z\)/.test(combSrc) && /hiveWorldToWall\(wx, wy, fieldRoot.position.y\)/.test(combSrc) && /const p = planeAt\(x, y\)/.test(combSrc), "zoom and picking use the same rotated wall-plane coordinates"); +check(/QueenHiveDisplays/.test(combSrc) && /projected\.slice\(0,32\)/.test(combSrc) && !/new DynamicTexture\(`event-card-/.test(combSrc), "native displays replace blurry256px textures and cull to32 visible cells"); +check(/function useT27Coverage\(repository: string \| null\)/.test(src) && /fetch\("t27\/manifest\.json"/.test(src) && /hiveCoverageFromManifest\(manifest, repository\)/.test(src) && /useT27Coverage\(modulesState\.data\?\.repo \?\? null\)/.test(src), "yellow is scoped to the displayed module snapshot repository, never hard-coded trinity"); +check(/\[\.\.\.t27Coverage\]\.sort\(\)/.test(combSrc), "same-size coverage changes invalidate the scene's signature"); check(/hiveLawT27:\s*"T27 covered"/.test(src) && /hiveLawManual:\s*"manual code"/.test(src) && /hiveLawAwaiting:\s*"awaiting T27"/.test(src) && /hiveLawBees:\s*"bees"/.test(src), "the English colour-law words exist"); check(/hiveLawT27:\s*"покрыто T27"/.test(src) && /hiveLawManual:\s*"ручной код"/.test(src) && /hiveLawAwaiting:\s*"ждёт T27"/.test(src) && /hiveLawBees:\s*"пчёлы"/.test(src), "the Russian colour-law words exist"); check(decisionDetail({ allowed: false, refusal: null }, 0, 1, L) !== "0 executing now", "self-test"); diff --git a/apps/website/qa/queen-starfield-contract.mjs b/apps/website/qa/queen-starfield-contract.mjs new file mode 100644 index 0000000000..a16b951787 --- /dev/null +++ b/apps/website/qa/queen-starfield-contract.mjs @@ -0,0 +1,31 @@ +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { selectHygStars } from '../scripts/import-hyg.mjs'; +import { projectCatalogStar, catalogStarRows } from '../src/components/queenStarProjection.ts'; +const csv='id,proper,x,y,z,dist,mag,ci\n0,Sol,0,0,0,0,-26,0.65\n1,"Alpha, test",3,4,0,5,2,0.4\n2,Bad,0,0,1,100000,1,0\n3,Dim,3,4,0,5,9,0\n4,Broken,NaN,4,0,5,1,0\n5,Negative,3,4,0,-5,1,0\n'; +assert.deepEqual(selectHygStars(csv),[[1,'Alpha, test',3,4,0,5,2,.4]]); +const star=[1,'test',10,0,0,10,2,.4]; +assert.deepEqual(catalogStarRows([star,[1,'bad',NaN,0,0,1,1,0],[]]),[star]); +const catalog=JSON.parse(readFileSync(new URL('../src/data/hyg-v41-bright.json',import.meta.url),'utf8')); +assert.equal(catalog.stars.length,4965); +assert.equal(catalogStarRows(catalog.stars).length,4965); +assert.equal(catalog.sourceBlob,'ba2dec4eb0f6768914c7fc1051258100214ddf84'); +assert.equal(catalog.license,'CC BY-SA4.0'); +const front=projectCatalogStar(star,800,600,{yaw:0,pitch:0,offset:0}); +assert.equal(front.x,400); assert.equal(front.y,300); assert.equal(front.depth,10); +assert.equal(projectCatalogStar([2,'back',-10,0,0,10,2,0],800,600,{yaw:0,pitch:0,offset:0}),null); +const near=projectCatalogStar(star,800,600,{yaw:0,pitch:0,offset:.1}); +const far=projectCatalogStar([3,'far',100,0,0,100,2,0],800,600,{yaw:0,pitch:0,offset:.1}); +assert.ok(Math.abs(near.x-400)>Math.abs(far.x-400), 'real distance affects observer parallax'); +const css=readFileSync(new URL('../src/pages/Queen.css',import.meta.url),'utf8'); +assert.match(css,/\.queen27-page.is-shell \.queen27-hud-viewport\s*\{[^}]*grid-template-columns: minmax\(0, 1fr\)/, 'outer viewport must stretch the grid track, not only its child'); +assert.match(css,/\.queen27-page.is-shell \.queen27-hud-vp-body\s*\{[^}]*grid-template-columns: minmax\(0, 1fr\)/); +assert.match(css,/\.queen-hive-display\s*\{[^}]*var\(--font\)/); +assert.match(css,/\.queen-hive-display\s*\{[^}]*rgba\(3, 14, 18, 0\.4[0-9]\)/); +const scene=readFileSync(new URL('../src/components/QueenCombBabylon.tsx',import.meta.url),'utf8'); +assert.match(scene,/scene.clearColor = new Color4\(0, 0, 0, 0\)/); +const renderer=readFileSync(new URL('../src/components/QueenStarfield.tsx',import.meta.url),'utf8'); +assert.doesNotMatch(renderer.slice(renderer.indexOf('const draw='),renderer.indexOf('const schedule=')),/document\.hidden/, 'structural redraw must work in background tabs'); +assert.match(renderer,/if\(canvas.width!==width\) canvas.width=width/, 'do not reallocate an unchanged high-DPI bitmap'); +assert.match(renderer,/new ResizeObserver\(redraw\)/); +console.log('Starfield contract: PASS (catalog filtering, 3D projection/parallax, full-width and branded glass)'); diff --git a/apps/website/scripts/import-hyg.mjs b/apps/website/scripts/import-hyg.mjs new file mode 100644 index 0000000000..9342724922 --- /dev/null +++ b/apps/website/scripts/import-hyg.mjs @@ -0,0 +1,44 @@ +import { readFileSync, writeFileSync } from 'node:fs'; +import { createHash } from 'node:crypto'; +import { pathToFileURL } from 'node:url'; + +const SOURCE_BLOB='ba2dec4eb0f6768914c7fc1051258100214ddf84'; +// CSV quoted fields can contain commas, escaped quotes and line breaks. +export function selectHygStars(text) { + const rows=[]; let row=[],field='',quoted=false; + for(let i=0;i!columns.includes(k))) throw new Error('Missing HYG columns'); + const at=Object.fromEntries(required.map(k=>[k,columns.indexOf(k)])); + const number=(r,k)=>r[at[k]]?.trim() ? Number(r[at[k]]) : NaN; + return rows.flatMap(r=>{ + const id=number(r,'id'),x=number(r,'x'),y=number(r,'y'),z=number(r,'z'),d=number(r,'dist'),m=number(r,'mag'),ci=number(r,'ci'); + if(![id,x,y,z,d,m].every(Number.isFinite) || id<=0 || d<=0 || d>=100000 || m>6 || Math.abs(Math.hypot(x,y,z)-d)>Math.max(.02,d*.001)) return []; + return [[id,r[at.proper],x,y,z,d,m,Number.isFinite(ci)?ci:null]]; + }).sort((a,b)=>a[0]-b[0]); +} + +if(process.argv[1] && import.meta.url===pathToFileURL(process.argv[1]).href) { + const input=process.argv[2]; if(!input) throw new Error('Usage: node scripts/import-hyg.mjs /path/to/hygdata_v41.csv'); + const bytes=readFileSync(input); + const blob=createHash('sha1').update(`blob ${bytes.length}\0`).update(bytes).digest('hex'); + if(blob!==SOURCE_BLOB) throw new Error('Source differs from reviewed HYG4.1 snapshot'); + const stars=selectHygStars(bytes.toString('utf8')); + const catalog={catalog:'HYG4.1',epoch:'J2000',units:'parsec',credit:'David Nash / Astronexus',license:'CC BY-SA4.0', + source:'https://github.com/astronexus/HYG-Database/blob/c7f7f883fe678cc7680169a50ccd7dcc49b060ce/hyg/CURRENT/hygdata_v41.csv', + sourceBlob:blob,sha256:createHash('sha256').update(bytes).digest('hex'), + filter:'id>0; 0 void; cards: (SpikeCard | null)[]; workers: SpikeWorkers | null; onPick?: (pick: HudPick | null) => void; @@ -61,7 +66,7 @@ interface QueenCombBabylonProps { events?: HudEvent[]; /** The code modules by card id (M-2): the building is generated from the signature. */ modules?: ReadonlyMap; - /** Per issue in progress: the cell of the module its title names, or null (the hub). */ + /** Per issue in progress: its exact issue display cell, or null (unmapped). */ beeTargets?: ReadonlyArray; /** The honeycomb foundation: the loop's snapshot of closed GitHub issues, one honey hex each. */ foundation?: { issues: FoundationIssue[]; generatedAt: string; source: "wire" | "file"; rings?: string[]; epics?: EpicRecord[]; releases?: Array<{ tag: string; name: string; publishedAt: string | null; prerelease: boolean }> } | null; @@ -69,10 +74,10 @@ interface QueenCombBabylonProps { layers?: Record; /** The toolbar's zoom and fit, the same handle the canvas comb exposes. */ handleRef?: Ref; - /** hiveKey'd names the T27 corpus claims; a cell against this set is yellow, without it red (manual), no module blue. */ + /** Exact paths claimed by the displayed repository's T27 corpus; null is unknown. */ t27Coverage?: ReadonlySet | null; /** The colour law's words, in the page's language, for the legend above the field. */ - law?: { t27: string; manual: string; awaiting: string; bees: string }; + law?: { t27: string; manual: string; awaiting: string; unknown: string; bees: string }; } type Territory = "held" | "neutral" | "fog"; @@ -85,10 +90,20 @@ const RING_POOL = 24; const ALL_LAYERS: Record = { foundation: true, castle: true, code: true }; -export function QueenCombBabylon({ cards, workers, onPick, pickIndex = null, fitInset = 0, events = EMPTY_EVENTS, modules, beeTargets, foundation = null, layers = ALL_LAYERS, handleRef, t27Coverage = null, law }: QueenCombBabylonProps) { +export function QueenCombBabylon({ cards, workers, onPick, pickIndex = null, fitInset = 0, events = EMPTY_EVENTS, modules, beeTargets, foundation = null, layers = ALL_LAYERS, handleRef, t27Coverage = null, law, displays, lang = 'en', onInspect }: QueenCombBabylonProps) { const hostRef = useRef(null); const canvasRef = useRef(null); const cardRef = useRef(null); + const [projections, setProjections] = useState([]); + const [selectedDisplayKey, setSelectedDisplayKey] = useState(null); + const displayIndex = displays?.findIndex(row => row?.key === selectedDisplayKey) ?? -1; + const selectedDisplay = displayIndex >= 0 ? displayIndex : null; + const displaysRef = useRef(displays); + const selectedDisplayRef = useRef(selectedDisplay); + const inspectRef = useRef(onInspect); + const displayControllerRef = useRef(null); + const savedViewRef = useRef<{ zoom: number; x: number; y: number; focusKey: string | null } | null>(null); + useEffect(() => { displaysRef.current = displays; selectedDisplayRef.current = selectedDisplay; inspectRef.current = onInspect; }, [displays, selectedDisplay, onInspect]); const onPickRef = useRef(onPick); const pickRef = useRef(pickIndex); const insetRef = useRef(fitInset); @@ -107,9 +122,10 @@ export function QueenCombBabylon({ cards, workers, onPick, pickIndex = null, fit workers?.slots.map((s) => [s.slot, s.state]) ?? null, // a new snapshot of the foundation rebuilds the honey like a modules change rebuilds the city foundation ? [foundation.generatedAt, foundation.issues.length] : null, + displays?.map(row => row?.key ?? null) ?? null, // a new claim from the spec corpus re-colours the law; the words re-word the legend - t27Coverage ? t27Coverage.size : null, - law ? [law.t27, law.manual, law.awaiting, law.bees] : null, + t27Coverage ? [...t27Coverage].sort() : null, + law ? [law.t27, law.manual, law.awaiting, law.unknown, law.bees] : null, ]); const cardsRef = useRef(cards); const workersRef = useRef(workers); @@ -160,7 +176,8 @@ export function QueenCombBabylon({ cards, workers, onPick, pickIndex = null, fit // gates rather than assumed. engine.setHardwareScalingLevel(1 / Math.min(2, Math.max(1, window.devicePixelRatio || 1))); const scene = new Scene(engine); - scene.clearColor = new Color4(2 / 255, 8 / 255, 6 / 255, 1); + const motionPreference = window.matchMedia('(prefers-reduced-motion: reduce)'); + scene.clearColor = new Color4(0, 0, 0, 0); scene.skipPointerMovePicking = true; // the rendered look: tone mapping with a little contrast and a vignette, // depth fog into the void, and a glow on every emissive part (windows, @@ -194,15 +211,19 @@ export function QueenCombBabylon({ cards, workers, onPick, pickIndex = null, fit // THE WALL (the user, 2026-09-06: "поле не по горизонтали лежащим, а прямо // перед лицом парящее"). The comb keeps its own flat coordinates (x, z per // cell, y as height toward the board); one root turns that plane into the - // wall the player faces — local (x, y, z) becomes world (x, -z, y). The + // wall the player faces. After the user's180-degree field turn, local + // (x, height, z) becomes world (-x, z, height). The original SVG mark now + // projects point-down, without changing its geometry or mirroring it. The // camera sits dead in front at +Z, so the honeycomb hangs in the air at eye // height and a cell's lift (its local y) comes TOWARD the hand instead of // away from it. Every position below stays in comb coordinates; only the // pick and the pan convert world to comb. const fieldRoot = new TransformNode("field-root", scene); - fieldRoot.rotation.x = Math.PI / 2; - // the wall's world centre: the root maps comb (x, z) to world (x, -z) - const centreWorld = new Vector3(centre.x, -centre.z, 0); + fieldRoot.rotation.copyFromFloats(HIVE_WALL_ROTATION.x, HIVE_WALL_ROTATION.y, HIVE_WALL_ROTATION.z); + host.setAttribute('data-map-turn', '180'); + host.setAttribute('data-logo-orientation', 'point-down'); + const centrePoint = hiveWallToWorld(centre.x, centre.z); + const centreWorld = new Vector3(centrePoint.x, centrePoint.y, 0); const camera = new ArcRotateCamera("cam", Math.PI / 2, Math.PI / 2, 4000, centreWorld.clone(), scene); // the wall is a plane, not a mesh: a screen point becomes a comb point by // solving the picking ray against z = 0, the plane the comb is drawn on @@ -215,7 +236,7 @@ export function QueenCombBabylon({ cards, workers, onPick, pickIndex = null, fit if (t < 0) return null; const wx = pickRay.origin.x + pickRay.direction.x * t; const wy = pickRay.origin.y + pickRay.direction.y * t; - return { x: wx, z: -wy }; + return hiveWorldToWall(wx, wy, fieldRoot.position.y); }; const depth = Math.max(maxX - minX, maxZ - minZ); scene.fogStart = 4000 - depth * 0.1; @@ -224,8 +245,11 @@ export function QueenCombBabylon({ cards, workers, onPick, pickIndex = null, fit camera.lowerBetaLimit = Math.PI / 2; camera.upperBetaLimit = Math.PI / 2; camera.lowerAlphaLimit = camera.alpha; camera.upperAlphaLimit = camera.alpha; camera.panningSensibility = 0; - let zoom = 1; - let zoomGoal = 1; + let zoom = savedViewRef.current?.zoom ?? 1; + let zoomGoal = zoom; + if (savedViewRef.current) { camera.target.x = savedViewRef.current.x; camera.target.y = savedViewRef.current.y; } + const restoredFocus = savedViewRef.current?.focusKey ? displaysRef.current?.findIndex(row => row?.key === savedViewRef.current?.focusKey) ?? -1 : -1; + let focusIndex: number | null = restoredFocus >= 0 ? restoredFocus : null; let anchor: { x: number; z: number } | null = null; let appliedInset = -1; // the castle's couplings to the code and the honey (K-5): filled when the snapshot lands @@ -247,6 +271,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; } + if (focusIndex !== null && cells[focusIndex]) { + zoom = zoomGoal = hiveFocusZoom(S_CELL * w / (halfW * 2), w, band); + const focusedPoint = hiveWallToWorld(cells[focusIndex].x, cells[focusIndex].y); + camera.target.x = focusedPoint.x; + camera.target.y = focusedPoint.y; + } halfW /= zoom; halfH /= zoom; const unitsPerPx = (halfH * 2) / band; const shift = (h - band) * unitsPerPx; @@ -254,6 +284,7 @@ export function QueenCombBabylon({ cards, workers, onPick, pickIndex = null, fit camera.orthoTop = halfH; camera.orthoBottom = -halfH - shift; appliedInset = inset; host.setAttribute("data-zoom", zoom.toFixed(2)); + savedViewRef.current = {zoom,x:camera.target.x,y:camera.target.y,focusKey:focusIndex !== null ? displaysRef.current?.[focusIndex]?.key ?? null : null}; }; fit(); // the wall answers the hand (the user, 2026-09-06). Both the drag and the @@ -261,14 +292,15 @@ export function QueenCombBabylon({ cards, workers, onPick, pickIndex = null, fit // the cursor is the point that follows it; pan runs in the wall's own axes. const ROAM = Math.max(maxX - minX, maxZ - minZ) * 0.55; const clampTarget = () => { - camera.target.x = Math.min(Math.max(camera.target.x, centre.x - ROAM), centre.x + ROAM); - camera.target.y = Math.min(Math.max(camera.target.y, -centre.z - ROAM), -centre.z + ROAM); + camera.target.x = Math.min(Math.max(camera.target.x, centreWorld.x - ROAM), centreWorld.x + ROAM); + camera.target.y = Math.min(Math.max(camera.target.y, centreWorld.y - ROAM), centreWorld.y + ROAM); }; const onWheel = (e: WheelEvent) => { e.preventDefault(); const r = canvas.getBoundingClientRect(); + focusIndex = null; anchor = planeAt(e.clientX - r.left, e.clientY - r.top); - zoomGoal = Math.min(8, Math.max(0.5, zoomGoal * Math.exp(-e.deltaY * 0.0016))); + zoomGoal = Math.min(128, Math.max(0.5, zoomGoal * Math.exp(-e.deltaY * 0.0016))); host.setAttribute("data-zoom-goal", zoomGoal.toFixed(2)); }; canvas.addEventListener("wheel", onWheel, { passive: false }); @@ -276,9 +308,14 @@ export function QueenCombBabylon({ cards, workers, onPick, pickIndex = null, fit const onDown = (e: PointerEvent) => { if (e.button !== 0) return; grab = { x: e.clientX, y: e.clientY }; canvas.setPointerCapture(e.pointerId); }; const onMove = (e: PointerEvent) => { if (!grab) return; + focusIndex = null; const r = canvas.getBoundingClientRect(); const a = planeAt(grab.x - r.left, grab.y - r.top), b = planeAt(e.clientX - r.left, e.clientY - r.top); - if (a && b) { camera.target.x += a.x - b.x; camera.target.y += b.z - a.z; clampTarget(); fit(); } + if (a && b) { + const from = hiveWallToWorld(a.x,a.z), to = hiveWallToWorld(b.x,b.z); + camera.target.x += from.x - to.x; camera.target.y += from.y - to.y; + clampTarget(); fit(); + } grab = { x: e.clientX, y: e.clientY }; }; const onUp = (e: PointerEvent) => { grab = null; if (canvas.hasPointerCapture(e.pointerId)) canvas.releasePointerCapture(e.pointerId); }; @@ -288,11 +325,18 @@ export function QueenCombBabylon({ cards, workers, onPick, pickIndex = null, fit canvas.addEventListener("pointercancel", onUp); // the toolbar's FIT VIEW / - / + reach the scene through the same handle the canvas comb exposes cameraRef.current = { - zoomIn: () => { anchor = null; zoom = zoomGoal = Math.min(8, zoom * 1.25); fit(); }, - zoomOut: () => { anchor = null; zoom = zoomGoal = Math.max(0.5, zoom / 1.25); fit(); }, + zoomIn: () => { focusIndex = null; anchor = null; zoom = zoomGoal = Math.min(128, zoom * 1.25); fit(); }, + zoomOut: () => { focusIndex = null; anchor = null; zoom = zoomGoal = Math.max(0.5, zoom / 1.25); fit(); }, // FIT VIEW is the way home: it undoes the roam as well as the zoom - fit: () => { anchor = null; zoom = zoomGoal = 1; camera.target.copyFrom(centreWorld); fit(); }, + fit: () => { focusIndex = null; selectedDisplayRef.current = null; setSelectedDisplayKey(null); host.removeAttribute('data-display-selected'); anchor = null; zoom = zoomGoal = 1; camera.target.copyFrom(centreWorld); fit(); }, + }; + const inspectDisplay = (index: number) => { + const row = displaysRef.current?.[index]; if (!row) return; + selectedDisplayRef.current = index; setSelectedDisplayKey(row.key); + focusIndex = index; anchor = null; inspectRef.current?.(); fit(); + host.setAttribute('data-display-selected', row.key); }; + displayControllerRef.current = { inspect: inspectDisplay, overview: () => cameraRef.current?.fit(), hover: index => { hover = index ?? -1; } }; // ---- light: a sun from the upper left and a soft sky, shadows on ----- const sky = new HemisphericLight("sky", new Vector3(0.2, 1, 0.1), scene); @@ -316,14 +360,16 @@ export function QueenCombBabylon({ cards, workers, onPick, pickIndex = null, fit // with the GLBs; what remains is pure comb geometry. // the colour law's cover for a cell: T27 if the corpus claims the module, // manual if there is hand-written code and no claim, awaiting otherwise - const covered = coverageRef.current ?? new Set(); - const coverOf = (i: number): HiveCover => hiveCoverOf(cards[i]?.title ?? null, covered); + const covered = coverageRef.current; + const coverOf = (i: number): HiveCover => displaysRef.current + ? displaysRef.current[i]?.coverage ?? 'awaiting' + : hiveCoverOf(cards[i]?.title ?? null, covered); const covers: HiveCover[] = cells.map((_, i) => coverOf(i)); // ---- rings: picked (gold), hover (dashed, honey) -------------------- const ring7 = () => [Array.from({ length: 7 }, () => new Vector3(0, 0, 0))]; const picked = CreateLineSystem("picked", { lines: ring7(), updatable: true }, scene); - picked.color = Color3.FromHexString(HIVE_TONES.t27); picked.isPickable = false; picked.parent = fieldRoot; + picked.color = Color3.FromHexString(HIVE_TONES.hover); picked.isPickable = false; picked.parent = fieldRoot; // a cell's outline is its hexagon, inset a little so the ring reads as the cell's, not the neighbour's const hexOf = (i: number): [number, number][] => hexCornersAt(cells[i].x, cells[i].y, HEX_R, 4).map((c) => [c.x, c.y]); const placeRing = (mesh: LinesMesh, i: number, y: number) => { @@ -339,12 +385,20 @@ export function QueenCombBabylon({ cards, workers, onPick, pickIndex = null, fit // the hover card (the user, 2026-09-06): the hovered cell's issue, named beside the pointer let hoverIssue = -1; let pointerXY: [number, number] = [0, 0]; + const clearHoverCard = () => { + hoverIssue = -1; + cardRef.current?.setAttribute("aria-hidden", "true"); + cardRef.current?.replaceChildren(); + host.removeAttribute("data-hover-issue"); + }; + // DOM survives a scene rebuild; the previous repo's coverage must not. + clearHoverCard(); const showHoverCard = (i: number) => { const card = cardRef.current; if (!card) return; const issue = i >= 0 ? fCells?.[i] ?? null : null; if (!issue) { - if (hoverIssue !== -1) { hoverIssue = -1; card.setAttribute("aria-hidden", "true"); card.replaceChildren(); host.removeAttribute("data-hover-issue"); } + if (hoverIssue !== -1) clearHoverCard(); return; } if (issue.number !== hoverIssue) { @@ -352,8 +406,11 @@ export function QueenCombBabylon({ cards, workers, onPick, pickIndex = null, fit const head = document.createElement("b"); head.textContent = `#${issue.number} ${issue.title}`; const meta = document.createElement("span"); const cover = coverOf(i); - const coverWord = cover === "t27" ? "T27" : cover === "manual" ? "MANUAL CODE" : "AWAITING T27"; - meta.textContent = [issue.closedAt.slice(0, 16).replace("T", " "), ...issue.labels, coverWord].join(" \u00b7 "); + const coverWord = lawRef.current?.[cover] ?? (cover === "unknown" ? "T27 coverage unknown" : cover); + // A closed issue and a module share a cell position, not an identity. + // Attribute the claim to its module explicitly, never to that issue. + const modulePath = cards[i]?.title; + meta.textContent = [issue.closedAt.slice(0, 16).replace("T", " "), ...issue.labels, ...(modulePath ? [`${modulePath}: ${coverWord}`] : [])].join(" \u00b7 "); meta.dataset.cover = cover; card.replaceChildren(head, meta); card.setAttribute("aria-hidden", "false"); @@ -383,7 +440,8 @@ export function QueenCombBabylon({ cards, workers, onPick, pickIndex = null, fit const running = cells.map((c, i) => ({ c, i })).filter(({ i }) => cards[i]?.column === "running").map(({ i }) => i); const indexByNumber = new Map(); - cells.forEach((c, i) => { if (c.cardNumber !== null) indexByNumber.set(c.cardNumber, i); }); + if (displaysRef.current) displaysRef.current.forEach((row,i) => { if(row) indexByNumber.set(row.number,i); }); + else cells.forEach((c, i) => { if (c.cardNumber !== null) indexByNumber.set(c.cardNumber, i); }); // The swarm is anonymous by the server's decision, so every bee is the same // mote: one glowing hex, bright while it works. Three sprite costumes used // to imply a distinction the wire does not carry; cut 2026-09-06. @@ -405,7 +463,13 @@ export function QueenCombBabylon({ cards, workers, onPick, pickIndex = null, fit for (const b of bees) { b.busy = slots.get(b.slot) === "busy"; let target = home; - if (b.busy) { const byIssue = issueTargets ? issueTargets[busyRank] : undefined; target = byIssue ?? running[busyRank] ?? home; b.work = (byIssue !== undefined && byIssue !== null) || running[busyRank] !== undefined; busyRank += 1; } + if (b.busy) { + const byIssue = issueTargets ? issueTargets[busyRank] : undefined; + const fallback = displaysRef.current ? undefined : running[busyRank]; + target = byIssue ?? fallback ?? home; + b.work = (byIssue !== undefined && byIssue !== null) || fallback !== undefined; + busyRank += 1; + } else { b.work = false; target = ringCell(idleRank); idleRank += 1; } if (b.to !== target) { b.from = b.t < 0.5 ? b.from : b.to; b.to = target; b.t = 0; b.speed = b.busy ? 0.55 : 0.35; } } @@ -429,6 +493,7 @@ export function QueenCombBabylon({ cards, workers, onPick, pickIndex = null, fit capMat.diffuseColor = Color3.Black(); capMat.specularColor = Color3.Black(); capMat.emissiveColor = new Color3(0.055, 0.075, 0.072); + capMat.alpha = 0.08; const caps = CreateCylinder("caps", { tessellation: 6, diameter: 2 * HEX_R * 0.92, height: 2 }, scene); const cb = caps.getBoundingInfo().boundingBox; if (cb.maximum.x - cb.minimum.x > cb.maximum.z - cb.minimum.z) { caps.rotation.y = Math.PI / 6; caps.bakeCurrentTransformIntoVertices(); } @@ -439,6 +504,7 @@ export function QueenCombBabylon({ cards, workers, onPick, pickIndex = null, fit const liftMat = new StandardMaterial("lift", scene); liftMat.diffuseColor = Color3.Black(); liftMat.specularColor = Color3.Black(); liftMat.emissiveColor = new Color3(0.1, 0.14, 0.135); + liftMat.alpha = 0.14; const lift = CreateCylinder("lift", { tessellation: 6, diameter: 2 * HEX_R * 0.92, height: 3 }, scene); const lb = lift.getBoundingInfo().boundingBox; if (lb.maximum.x - lb.minimum.x > lb.maximum.z - lb.minimum.z) { lift.rotation.y = Math.PI / 6; lift.bakeCurrentTransformIntoVertices(); } @@ -453,7 +519,7 @@ export function QueenCombBabylon({ cards, workers, onPick, pickIndex = null, fit for (let i = 1; i < cells.length; i += 1) { const corners = hexCornersAt(cells[i].x, cells[i].y, HEX_R, 4); lines.push([...corners, corners[0]].map((c) => new Vector3(c.x, 1.5, c.y))); - const issue = fCells?.[i] ?? null; + const issue = displaysRef.current ? displaysRef.current[i] : fCells?.[i] ?? null; // the empty comb is the reference's dim teal wax; a cell with an // issue burns in the colour LAW: yellow T27, neon blue awaiting, // red manual code (the user, 2026-09-06) @@ -477,6 +543,8 @@ export function QueenCombBabylon({ cards, workers, onPick, pickIndex = null, fit host.setAttribute("data-cover-t27", String(law.t27)); host.setAttribute("data-cover-manual", String(law.manual)); host.setAttribute("data-cover-awaiting", String(law.awaiting)); + host.setAttribute("data-cover-unknown", String(covers.filter((v) => v === "unknown").length)); + host.setAttribute("data-coverage-status", displaysRef.current || covered === null ? "unknown" : "source-claim"); if (first) host.setAttribute("data-foundation-first", first); if (last) host.setAttribute("data-foundation-last", last); } @@ -640,9 +708,9 @@ export function QueenCombBabylon({ cards, workers, onPick, pickIndex = null, fit if (Number.isFinite(at)) last.set(e.issue, { at, kind: e.kind, title: e.title }); } const ageOf = new Map(); - for (const { at, kind, title } of last.values()) { + for (const [issueNumber, { at, kind, title }] of last) { if (kind === "finished" || kind === "error") continue; - const i = cellOfPath(title); + const i = displaysRef.current ? indexByNumber.get(issueNumber) ?? -1 : cellOfPath(title); if (i < 0) continue; const age = nowWallMs - at; const seen = ageOf.get(i); @@ -672,89 +740,6 @@ export function QueenCombBabylon({ cards, workers, onPick, pickIndex = null, fit } }; - // ---- the latest wire events as cards inside their cells ----------------- - // The comb is a display, not only a map (the user, 2026-09-06). The newest - // event per module becomes a texture card on that module's cell: issue, - // kind, module and age. Twelve is the honest limit because a hex cannot - // carry an unreadable poster; older work stays in the intel feed. - const EVENT_CARD_POOL = 12; - interface EventCard { mesh: Mesh; material: StandardMaterial; surface: DynamicTexture; index: number; issue: number | null; kind: string; ageMinute: number } - const eventCards: EventCard[] = Array.from({ length: EVENT_CARD_POOL }, (_, k) => { - const surface = new DynamicTexture(`event-card-${k}`, 256, scene, true); - surface.hasAlpha = true; - const material = new StandardMaterial(`event-card-material-${k}`, scene); - material.diffuseTexture = surface; - material.emissiveTexture = surface; - material.opacityTexture = surface; - material.emissiveColor = new Color3(1, 1, 1); - material.specularColor = Color3.Black(); - material.disableLighting = true; - const mesh = CreateGround(`event-card-${k}`, { width: S * 0.72, height: S * 0.72 }, scene); - mesh.material = material; mesh.isPickable = false; mesh.isVisible = false; mesh.parent = fieldRoot; - return { mesh, material, surface, index: -1, issue: null, kind: "", ageMinute: -1 }; - }); - const eventCardAge = (ageMs: number): string => { - if (ageMs < 60_000) return `${Math.max(0, Math.round(ageMs / 1000))}s`; - if (ageMs < 3_600_000) return `${Math.round(ageMs / 60_000)}m`; - if (ageMs < 86_400_000) return `${Math.round(ageMs / 3_600_000)}h`; - return `${Math.round(ageMs / 86_400_000)}d`; - }; - const drawEventCard = (card: EventCard, event: HudEvent, ageMs: number) => { - const ctx = card.surface.getContext() as CanvasRenderingContext2D; - const tone = TONE_HEX[eventTone(event.kind)]; - const path = cards[card.index]?.title ?? ""; - const shortPath = path.split("/").filter(Boolean).slice(-2).join("/") || "—"; - ctx.clearRect(0, 0, 256, 256); - ctx.fillStyle = "rgba(3, 9, 11, 0.88)"; - ctx.fillRect(10, 10, 236, 236); - ctx.strokeStyle = tone; - ctx.lineWidth = 6; - ctx.strokeRect(13, 13, 230, 230); - ctx.textAlign = "left"; - ctx.textBaseline = "middle"; - ctx.fillStyle = tone; - ctx.font = "700 36px ui-monospace, Menlo, monospace"; - ctx.fillText(`#${event.issue ?? "—"}`, 30, 58, 196); - ctx.fillStyle = "rgba(230, 240, 236, 0.88)"; - ctx.font = "600 29px ui-monospace, Menlo, monospace"; - ctx.fillText(event.kind.toUpperCase(), 30, 108, 196); - ctx.fillStyle = "rgba(230, 240, 236, 0.68)"; - ctx.fillText(shortPath, 30, 154, 196); - ctx.fillStyle = tone; - ctx.fillText(eventCardAge(ageMs), 30, 202, 196); - card.surface.update(); - }; - let eventCardSeen: readonly HudEvent[] | null = null; - let eventCardMinute = -1; - const refreshEventCards = (nowWallMs: number) => { - const list = eventsRef.current; - const minute = Math.floor(nowWallMs / 60_000); - if (list === eventCardSeen && minute === eventCardMinute) return; - eventCardSeen = list; - eventCardMinute = minute; - const newest = new Map(); - for (const event of list) { - const atMs = Date.parse(event.at); - if (!Number.isFinite(atMs)) continue; - const index = cellOfPath(event.title); - if (index < 0) continue; - const prior = newest.get(index); - if (!prior || atMs >= prior.atMs) newest.set(index, { ...event, atMs }); - } - const latest = [...newest.values()].sort((a, b) => b.atMs - a.atMs).slice(0, EVENT_CARD_POOL); - eventCards.forEach((card, k) => { - const event = latest[k]; - if (!event) { card.mesh.isVisible = false; card.index = -1; card.issue = null; card.kind = ""; card.ageMinute = -1; return; } - card.index = cellOfPath(event.title); - card.issue = event.issue; - card.kind = event.kind; - card.ageMinute = Math.floor(event.atMs / 60_000); - drawEventCard(card, event, Math.max(0, nowWallMs - event.atMs)); - card.mesh.position.set(cells[card.index].x, 4, cells[card.index].y); - card.mesh.isVisible = layersRef.current.foundation || layersRef.current.code; - }); - host.setAttribute("data-event-cards", String(latest.length)); - }; // the roots (K-5): a picked closed issue that an epic lists draws lines from the epic's tower (the keep when unassigned) to every closed child on the field const drawRoots = (number: number | null) => { @@ -781,11 +766,16 @@ export function QueenCombBabylon({ cards, workers, onPick, pickIndex = null, fit } if (info.type === PointerEventTypes.POINTERUP) downAt = null; }); - canvas.addEventListener("click", (e) => { + const onCellClick = (e: MouseEvent) => { if (travelled > 6) { travelled = 0; return; } const rect = canvas.getBoundingClientRect(); const index = cellUnder(e.clientX - rect.left, e.clientY - rect.top); if (index < 0) { host.setAttribute("data-hit", "off"); return; } + if (displaysRef.current?.[index]) { inspectDisplay(index); host.setAttribute('data-hit','display'); return; } + // An empty issue cell cannot inherit a colocated legacy module/issue. + if (displaysRef.current && index !== home) { + host.setAttribute('data-hit','void'); onPickRef.current?.(null); return; + } const card = layersRef.current.code ? cards[index] : null; // the top visible layer wins (H-E): the hub, then a module with CODE on, // then a honey cell with FOUNDATION on; anything else is empty ground @@ -804,8 +794,47 @@ export function QueenCombBabylon({ cards, workers, onPick, pickIndex = null, fit host.setAttribute("data-hit", index === home ? "queen" : "module"); const b = beeAt(index); onPickRef.current?.({ index, isQueen: index === home, territory: cells[index].own, card: card ?? null, bee: b ? { slot: b.slot, line: b.line, busy: b.busy } : null, kind: index === home ? "queen" : "module" } as HudPick); - }); - canvas.addEventListener("pointerleave", () => { hover = -1; showHoverCard(-1); }); + }; + canvas.addEventListener('click', onCellClick); + const onCellLeave = () => { hover = -1; showHoverCard(-1); }; + canvas.addEventListener('pointerleave', onCellLeave); + + // Project a bounded number of visible issue displays into CSS pixels. + // Text is browser-native, never a256px texture enlarged by the camera. + let displayTick = -Infinity; + let displayEventSource: readonly HudEvent[] | null = null; + let eventIssueNumbers = new Set(); + const projectDisplays = (nowMs: number) => { + if (nowMs - displayTick < 100) return; + displayTick = nowMs; + const width = host.clientWidth, height = host.clientHeight; + const viewport = camera.viewport.toGlobal(width,height); + const matrix = scene.getTransformMatrix(); + const world = fieldRoot.computeWorldMatrix(true); + const project = (x: number,z: number) => Vector3.Project(new Vector3(x,0,z),world,matrix,viewport); + const a = project(0,0), b = project(S_CELL,0), cellWidth = Math.abs(b.x-a.x); + const projected: HiveDisplayProjection[] = []; + if (hiveDisplayLod(cellWidth) !== 'overview') displaysRef.current?.forEach((row,index) => { + const cell = cells[index]; if (!row || !cell) return; + const p = project(cell.x,cell.y), h = cellWidth * 2 / Math.sqrt(3); + if (p.x + cellWidth/2 < 0 || p.x-cellWidth/2 > width || p.y+h/2 < 0 || p.y-h/2 > height) return; + projected.push({index,x:p.x,y:p.y,width:cellWidth,height:h}); + }); + projected.sort((a,b) => Number(b.index===selectedDisplayRef.current)-Number(a.index===selectedDisplayRef.current) || Math.hypot(a.x-width/2,a.y-height/2)-Math.hypot(b.x-width/2,b.y-height/2)); + const visible = projected.slice(0,32); + setProjections(previous => previous.length === visible.length && previous.every((p,i) => { + const next = visible[i]; + return p.index === next.index && Math.abs(p.x-next.x)<.1 && Math.abs(p.y-next.y)<.1 && Math.abs(p.width-next.width)<.1; + }) ? previous : visible); + host.setAttribute('data-display-visible',String(visible.length)); + host.setAttribute('data-display-cell-width',cellWidth.toFixed(1)); + host.setAttribute('data-display-lod',hiveDisplayLod(cellWidth)); + if(displayEventSource !== eventsRef.current) { + displayEventSource = eventsRef.current; + eventIssueNumbers = new Set(eventsRef.current.flatMap(e=>e.issue !== null ? [e.issue] : [])); + } + host.setAttribute('data-event-cards',String(visible.filter(p=>eventIssueNumbers.has(displaysRef.current?.[p.index]?.number ?? -1)).length)); + }; // ---- frame loop --------------------------------------------------------- const t0 = performance.now(); @@ -816,22 +845,23 @@ export function QueenCombBabylon({ cards, workers, onPick, pickIndex = null, fit const dt = Math.min(nowMs - lastMs, 32); lastMs = nowMs; // the wall hangs in the air; the drift is small enough not to move a // cell out from under the pointer while the player is reading it - fieldRoot.position.y = Math.sin(nowMs / 6400) * 7; + fieldRoot.position.y = motionPreference.matches || selectedDisplayRef.current !== null ? 0 : Math.sin(nowMs / 6400) * 7; + host.setAttribute('data-reduced-motion',String(motionPreference.matches)); // the working cells breathe so a still frame still says which are live refreshWorking(Date.now()); - refreshEventCards(Date.now()); if (workingRing) workingRing.alpha = 0.45 + 0.45 * Math.abs(Math.sin(nowMs / 620)); // the zoom glides instead of snapping, and it glides toward the cursor: // an orthographic frustum is a uniform scale about the target, so moving // the target by (1 - 1/f) toward the anchor keeps that point still if (zoom !== zoomGoal) { const prev = zoom; - zoom = Math.abs(zoomGoal - zoom) < 1e-4 ? zoomGoal : zoom + (zoomGoal - zoom) * (1 - Math.pow(2, -dt / 60)); + zoom = motionPreference.matches || Math.abs(zoomGoal - zoom) < 1e-4 ? zoomGoal : zoom + (zoomGoal - zoom) * (1 - Math.pow(2, -dt / 60)); const f = zoom / prev; if (anchor && f !== 1) { const k = 1 - 1 / f; - camera.target.x += (anchor.x - camera.target.x) * k; - camera.target.y += (-anchor.z - camera.target.y) * k; + const worldAnchor = hiveWallToWorld(anchor.x, anchor.z); + camera.target.x += (worldAnchor.x - camera.target.x) * k; + camera.target.y += (worldAnchor.y + fieldRoot.position.y - camera.target.y) * k; clampTarget(); } fit(); @@ -840,7 +870,8 @@ export function QueenCombBabylon({ cards, workers, onPick, pickIndex = null, fit // a pane resized before it is shown); a ResizeObserver alone missed // that and left the field clipped to the old canvas. Check every frame. const cw = canvas.clientWidth, ch = canvas.clientHeight; - if ((cw > 0 && ch > 0) && (canvas.width !== cw || canvas.height !== ch)) { engine.resize(); fit(); } + const dpr = Math.min(2,Math.max(1,window.devicePixelRatio || 1)); + if ((cw > 0 && ch > 0) && (Math.abs(canvas.width-cw*dpr)>1 || Math.abs(canvas.height-ch*dpr)>1)) { engine.setHardwareScalingLevel(1/dpr); engine.resize(); fit(); } if (insetRef.current !== appliedInset) fit(); // the layers: applied on change, no rebuild; the host mirrors the applied state const want = layersRef.current; @@ -881,11 +912,11 @@ export function QueenCombBabylon({ cards, workers, onPick, pickIndex = null, fit ring.alpha = 0.9 * (1 - u2); ring.isVisible = true; } - const p = pickRef.current; + const p = selectedDisplayRef.current ?? pickRef.current; if (p !== null && cells[p]) placeRing(picked, p, 1.4); else picked.isVisible = false; if (p !== flaredPick) { flaredPick = p; if (p !== null && cells[p] && effects.length < RING_POOL) effects.push({ index: p, tone: "muted", start: nowMs, flip: false, flare: ringTone(cells[p].own as Territory) }); } // a hovered plinth (K-5) draws the link from the ring's stone to the module cells it owns; nothing is drawn for a ring that owns no placed module - const linkRing = hover >= 0 && layersRef.current.castle && castleLinks ? castleLinks.ringOfCell.get(hover) ?? null : null; + const linkRing = !displaysRef.current && hover >= 0 && layersRef.current.castle && castleLinks ? castleLinks.ringOfCell.get(hover) ?? null : null; if (linkRing !== linkFor) { if (linkLines) { linkLines.dispose(); linkLines = null; } linkFor = linkRing; @@ -908,8 +939,10 @@ export function QueenCombBabylon({ cards, workers, onPick, pickIndex = null, fit liftY += (goalY - liftY) * (1 - Math.pow(2, -dt / 55)); if (liftIndex >= 0) { lift.isVisible = true; lift.position.set(cells[liftIndex].x, liftY, cells[liftIndex].y); } else if (liftY < 0.4) lift.isVisible = false; else lift.position.y = liftY; - showHoverCard(layersRef.current.foundation && hover >= 0 ? hover : -1); + showHoverCard(!displaysRef.current && layersRef.current.foundation && hover >= 0 ? hover : -1); scene.render(); + projectDisplays(nowMs); + savedViewRef.current = {zoom,x:camera.target.x,y:camera.target.y,focusKey:focusIndex !== null ? displaysRef.current?.[focusIndex]?.key ?? null : null}; frames += 1; if (frames === 1) host.setAttribute("data-first-frame-ms", String(Math.round(nowMs - t0))); host.setAttribute("data-frames", String(frames)); @@ -920,6 +953,10 @@ export function QueenCombBabylon({ cards, workers, onPick, pickIndex = null, fit document.addEventListener("visibilitychange", onVisible); document.addEventListener("fullscreenchange", onVisible); return () => { + clearHoverCard(); + canvas.removeEventListener('click',onCellClick); + canvas.removeEventListener('pointerleave',onCellLeave); + displayControllerRef.current = null; document.removeEventListener("visibilitychange", onVisible); document.removeEventListener("fullscreenchange", onVisible); canvas.removeEventListener("wheel", onWheel); @@ -934,10 +971,13 @@ export function QueenCombBabylon({ cards, workers, onPick, pickIndex = null, fit return (
- + +