Compare commits

...

9 Commits

Author SHA1 Message Date
Timmy
a21afda8b9 fix: add GPU screenshot tests, improve render wait times
- 3 visual screenshot tests: default view, overview mode, mouse look
- Screenshots saved to test-screenshots/ for human review
- GPU project config for headed real-render tests
- Pixel stats logging for automated content verification
- Increased render wait to 6s for full scene initialization

Refs #445
2026-03-25 09:48:28 -04:00
Timmy
7ef1c55bc0 feat: add headless smoke tests for Nexus rendering and interaction
- Playwright-based, zero LLM dependency
- Tests: world renders, canvas exists, WebGL healthy, HUD elements,
  mouse interaction, Tab overview toggle, animation loop, data files
- run-smoke.sh for local execution
- Configurable NEXUS_URL for CI or local

Refs #445
2026-03-25 09:18:13 -04:00
d09b31825b [claude] Re-implement dual-brain panel (#481) (#499) 2026-03-25 03:08:02 +00:00
475df10944 [claude] Commit heatmap on Nexus floor (#469) (#493)
Co-authored-by: Claude (Opus 4.6) <claude@hermes.local>
Co-committed-by: Claude (Opus 4.6) <claude@hermes.local>
2026-03-25 03:07:10 +00:00
b4afcd40ce [claude] Glass floor sections showing void below (#483) (#497)
Co-authored-by: Claude (Opus 4.6) <claude@hermes.local>
Co-committed-by: Claude (Opus 4.6) <claude@hermes.local>
2026-03-25 03:07:00 +00:00
d71628e087 [gemini] Re-implement Rune Ring (Portal-Tethered) (#476) (#496)
Co-authored-by: Google AI Agent <gemini@hermes.local>
Co-committed-by: Google AI Agent <gemini@hermes.local>
2026-03-25 03:06:52 +00:00
6ae5e40cc7 [claude] Re-implement Bitcoin block height counter (#480) (#495) 2026-03-25 03:06:48 +00:00
518717f820 [gemini] Feat: Re-implement Service Worker and PWA Manifest (#485) (#491) 2026-03-25 03:02:46 +00:00
309f07166c [gemini] Re-implement glass floor sections (#483) (#492) 2026-03-25 03:02:37 +00:00
13 changed files with 1178 additions and 27 deletions

3
.gitignore vendored Normal file
View File

@@ -0,0 +1,3 @@
node_modules/
test-results/
test-screenshots/

573
app.js
View File

@@ -39,12 +39,31 @@ let thoughtStreamMesh;
let harnessPulseMesh;
let powerMeterBars = [];
let particles, dustParticles;
let dualBrainGroup, dualBrainScanCtx, dualBrainScanTexture;
let cloudOrb, localOrb, cloudOrbLight, localOrbLight, dualBrainLight;
let debugOverlay;
let glassEdgeMaterials = []; // Glass tile edge materials for animation
let voidLight = null; // Point light below glass floor
let frameCount = 0, lastFPSTime = 0, fps = 0;
let chatOpen = true;
let loadProgress = 0;
let performanceTier = 'high';
// ═══ COMMIT HEATMAP ═══
let heatmapMesh = null;
let heatmapMat = null;
let heatmapTexture = null;
const _heatmapCanvas = document.createElement('canvas');
_heatmapCanvas.width = 512;
_heatmapCanvas.height = 512;
const HEATMAP_ZONES = [
{ name: 'Claude', color: [255, 100, 60], authorMatch: /^claude$/i, angleDeg: 0 },
{ name: 'Timmy', color: [ 60, 160, 255], authorMatch: /^timmy/i, angleDeg: 90 },
{ name: 'Kimi', color: [ 60, 255, 140], authorMatch: /^kimi/i, angleDeg: 180 },
{ name: 'Perplexity', color: [200, 60, 255], authorMatch: /^perplexity/i, angleDeg: 270 },
];
const _heatZoneIntensity = Object.fromEntries(HEATMAP_ZONES.map(z => [z.name, 0]));
// ═══ NAVIGATION SYSTEM ═══
const NAV_MODES = ['walk', 'orbit', 'fly'];
let navModeIdx = 0;
@@ -92,6 +111,7 @@ async function init() {
createLighting();
updateLoad(40);
createFloor();
createCommitHeatmap();
updateLoad(50);
createBatcaveTerminal();
updateLoad(60);
@@ -124,6 +144,7 @@ async function init() {
createThoughtStream();
createHarnessPulse();
createSessionPowerMeter();
createDualBrainPanel();
updateLoad(90);
composer = new EffectComposer(renderer);
@@ -303,10 +324,13 @@ function createLighting() {
// ═══ FLOOR ═══
function createFloor() {
const platGeo = new THREE.CylinderGeometry(25, 25, 0.3, 6);
const platMat = new THREE.MeshStandardMaterial({
color: 0x0a0f1a,
roughness: 0.8,
metalness: 0.3,
const platMat = new THREE.MeshPhysicalMaterial({
color: NEXUS.colors.bg,
transparent: true,
opacity: 0.2,
transmission: 0.9,
roughness: 0.1,
metalness: 0.2,
});
const platform = new THREE.Mesh(platGeo, platMat);
platform.position.y = -0.15;
@@ -330,6 +354,238 @@ function createFloor() {
ring.rotation.x = Math.PI / 2;
ring.position.y = 0.05;
scene.add(ring);
// ─── Glass floor sections showing void below ───
_buildGlassFloor();
}
function _buildGlassFloor() {
const GLASS_TILE_SIZE = 0.85;
const GLASS_TILE_GAP = 0.14;
const GLASS_TILE_STEP = GLASS_TILE_SIZE + GLASS_TILE_GAP;
const GLASS_RADIUS = 4.55;
const glassPlatformGroup = new THREE.Group();
// Solid dark frame ring around the glass section
const frameMat = new THREE.MeshStandardMaterial({
color: 0x0a1828,
metalness: 0.9,
roughness: 0.1,
emissive: new THREE.Color(NEXUS.colors.primary).multiplyScalar(0.06),
});
const rimGeo = new THREE.RingGeometry(4.7, 5.3, 64);
const rim = new THREE.Mesh(rimGeo, frameMat);
rim.rotation.x = -Math.PI / 2;
rim.position.y = 0.01;
glassPlatformGroup.add(rim);
const borderTorusGeo = new THREE.TorusGeometry(5.0, 0.1, 6, 64);
const borderTorus = new THREE.Mesh(borderTorusGeo, frameMat);
borderTorus.rotation.x = Math.PI / 2;
borderTorus.position.y = 0.01;
glassPlatformGroup.add(borderTorus);
// Semi-transparent glass tile material (transmission lets void show through)
const glassTileMat = new THREE.MeshPhysicalMaterial({
color: new THREE.Color(NEXUS.colors.primary),
transparent: true,
opacity: 0.09,
roughness: 0.0,
metalness: 0.0,
transmission: 0.92,
thickness: 0.06,
side: THREE.DoubleSide,
depthWrite: false,
});
// Collect tile positions within the glass radius
const tileSlots = [];
for (let row = -5; row <= 5; row++) {
for (let col = -5; col <= 5; col++) {
const x = col * GLASS_TILE_STEP;
const z = row * GLASS_TILE_STEP;
const distFromCenter = Math.sqrt(x * x + z * z);
if (distFromCenter > GLASS_RADIUS) continue;
tileSlots.push({ x, z, distFromCenter });
}
}
// InstancedMesh for all tiles (single draw call)
const tileGeo = new THREE.PlaneGeometry(GLASS_TILE_SIZE, GLASS_TILE_SIZE);
const tileMesh = new THREE.InstancedMesh(tileGeo, glassTileMat, tileSlots.length);
tileMesh.instanceMatrix.setUsage(THREE.StaticDrawUsage);
const dummy = new THREE.Object3D();
dummy.rotation.x = -Math.PI / 2;
for (let i = 0; i < tileSlots.length; i++) {
dummy.position.set(tileSlots[i].x, 0.005, tileSlots[i].z);
dummy.updateMatrix();
tileMesh.setMatrixAt(i, dummy.matrix);
}
tileMesh.instanceMatrix.needsUpdate = true;
glassPlatformGroup.add(tileMesh);
// Merge all tile edge lines into a single LineSegments draw call
const HS = GLASS_TILE_SIZE / 2;
const edgeVerts = new Float32Array(tileSlots.length * 8 * 3);
let evi = 0;
for (const { x, z } of tileSlots) {
const y = 0.008;
edgeVerts[evi++]=x-HS; edgeVerts[evi++]=y; edgeVerts[evi++]=z-HS;
edgeVerts[evi++]=x+HS; edgeVerts[evi++]=y; edgeVerts[evi++]=z-HS;
edgeVerts[evi++]=x+HS; edgeVerts[evi++]=y; edgeVerts[evi++]=z-HS;
edgeVerts[evi++]=x+HS; edgeVerts[evi++]=y; edgeVerts[evi++]=z+HS;
edgeVerts[evi++]=x+HS; edgeVerts[evi++]=y; edgeVerts[evi++]=z+HS;
edgeVerts[evi++]=x-HS; edgeVerts[evi++]=y; edgeVerts[evi++]=z+HS;
edgeVerts[evi++]=x-HS; edgeVerts[evi++]=y; edgeVerts[evi++]=z+HS;
edgeVerts[evi++]=x-HS; edgeVerts[evi++]=y; edgeVerts[evi++]=z-HS;
}
const mergedEdgeGeo = new THREE.BufferGeometry();
mergedEdgeGeo.setAttribute('position', new THREE.BufferAttribute(edgeVerts, 3));
const edgeMat = new THREE.LineBasicMaterial({
color: NEXUS.colors.primary,
transparent: true,
opacity: 0.55,
});
glassPlatformGroup.add(new THREE.LineSegments(mergedEdgeGeo, edgeMat));
// Register per-tile edge entries for the animation loop
// (we animate the single merged material, grouped by distance bands)
const BAND_COUNT = 6;
const bandMats = [];
for (let b = 0; b < BAND_COUNT; b++) {
const mat = new THREE.LineBasicMaterial({
color: NEXUS.colors.primary,
transparent: true,
opacity: 0.55,
});
// distFromCenter goes 0 → GLASS_RADIUS; spread across bands
const distFromCenter = (b / (BAND_COUNT - 1)) * GLASS_RADIUS;
glassEdgeMaterials.push({ mat, distFromCenter });
bandMats.push(mat);
}
// Rebuild edge geometry per band so each band has its own material
// (more draw calls but proper animated glow rings)
mergedEdgeGeo.dispose(); // dispose the merged one we won't use
for (let b = 0; b < BAND_COUNT; b++) {
const bandMin = (b / BAND_COUNT) * GLASS_RADIUS;
const bandMax = ((b + 1) / BAND_COUNT) * GLASS_RADIUS;
const bandSlots = tileSlots.filter(s => s.distFromCenter >= bandMin && s.distFromCenter < bandMax);
if (bandSlots.length === 0) continue;
const bVerts = new Float32Array(bandSlots.length * 8 * 3);
let bvi = 0;
for (const { x, z } of bandSlots) {
const y = 0.008;
bVerts[bvi++]=x-HS; bVerts[bvi++]=y; bVerts[bvi++]=z-HS;
bVerts[bvi++]=x+HS; bVerts[bvi++]=y; bVerts[bvi++]=z-HS;
bVerts[bvi++]=x+HS; bVerts[bvi++]=y; bVerts[bvi++]=z-HS;
bVerts[bvi++]=x+HS; bVerts[bvi++]=y; bVerts[bvi++]=z+HS;
bVerts[bvi++]=x+HS; bVerts[bvi++]=y; bVerts[bvi++]=z+HS;
bVerts[bvi++]=x-HS; bVerts[bvi++]=y; bVerts[bvi++]=z+HS;
bVerts[bvi++]=x-HS; bVerts[bvi++]=y; bVerts[bvi++]=z+HS;
bVerts[bvi++]=x-HS; bVerts[bvi++]=y; bVerts[bvi++]=z-HS;
}
const bGeo = new THREE.BufferGeometry();
bGeo.setAttribute('position', new THREE.BufferAttribute(bVerts, 3));
glassPlatformGroup.add(new THREE.LineSegments(bGeo, bandMats[b]));
}
// Void light pulses below the glass to illuminate the emptiness underneath
voidLight = new THREE.PointLight(NEXUS.colors.primary, 0.5, 14);
voidLight.position.set(0, -3.5, 0);
glassPlatformGroup.add(voidLight);
scene.add(glassPlatformGroup);
}
// ═══ COMMIT HEATMAP FUNCTIONS ═══
function createCommitHeatmap() {
heatmapTexture = new THREE.CanvasTexture(_heatmapCanvas);
heatmapMat = new THREE.MeshBasicMaterial({
map: heatmapTexture,
transparent: true,
opacity: 0.9,
depthWrite: false,
blending: THREE.AdditiveBlending,
side: THREE.DoubleSide,
});
heatmapMesh = new THREE.Mesh(new THREE.CircleGeometry(24, 64), heatmapMat);
heatmapMesh.rotation.x = -Math.PI / 2;
heatmapMesh.position.y = 0.005;
scene.add(heatmapMesh);
// Kick off first fetch; subsequent updates every 5 min
updateHeatmap();
setInterval(updateHeatmap, 5 * 60 * 1000);
}
function drawHeatmap() {
const ctx = _heatmapCanvas.getContext('2d');
const cx = 256, cy = 256, r = 246;
const SPAN = Math.PI / 2;
ctx.clearRect(0, 0, 512, 512);
ctx.save();
ctx.beginPath();
ctx.arc(cx, cy, r, 0, Math.PI * 2);
ctx.clip();
for (const zone of HEATMAP_ZONES) {
const intensity = _heatZoneIntensity[zone.name] || 0;
if (intensity < 0.01) continue;
const [rr, gg, bb] = zone.color;
const baseRad = zone.angleDeg * (Math.PI / 180);
const gx = cx + Math.cos(baseRad) * r * 0.55;
const gy = cy + Math.sin(baseRad) * r * 0.55;
const grad = ctx.createRadialGradient(gx, gy, 0, gx, gy, r * 0.75);
grad.addColorStop(0, `rgba(${rr},${gg},${bb},${0.65 * intensity})`);
grad.addColorStop(0.45, `rgba(${rr},${gg},${bb},${0.25 * intensity})`);
grad.addColorStop(1, `rgba(${rr},${gg},${bb},0)`);
ctx.beginPath();
ctx.moveTo(cx, cy);
ctx.arc(cx, cy, r, baseRad - SPAN / 2, baseRad + SPAN / 2);
ctx.closePath();
ctx.fillStyle = grad;
ctx.fill();
if (intensity > 0.05) {
ctx.font = `bold ${Math.round(13 * intensity + 7)}px "Courier New", monospace`;
ctx.fillStyle = `rgba(${rr},${gg},${bb},${Math.min(intensity * 1.2, 0.9)})`;
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(zone.name, cx + Math.cos(baseRad) * r * 0.62, cy + Math.sin(baseRad) * r * 0.62);
}
}
ctx.restore();
if (heatmapTexture) heatmapTexture.needsUpdate = true;
}
async function updateHeatmap() {
let commits = [];
try {
const res = await fetch(
'http://143.198.27.163:3000/api/v1/repos/Timmy_Foundation/the-nexus/commits?limit=50',
{ headers: { 'Authorization': 'token dc0517a965226b7a0c5ffdd961b1ba26521ac592' } }
);
if (res.ok) commits = await res.json();
} catch { /* network error — use zero baseline */ }
const DECAY_MS = 24 * 60 * 60 * 1000;
const now = Date.now();
const raw = Object.fromEntries(HEATMAP_ZONES.map(z => [z.name, 0]));
for (const commit of commits) {
const author = commit.commit?.author?.name || commit.author?.login || '';
const ts = new Date(commit.commit?.author?.date || 0).getTime();
const age = now - ts;
if (age > DECAY_MS) continue;
const weight = 1 - age / DECAY_MS;
for (const zone of HEATMAP_ZONES) {
if (zone.authorMatch.test(author)) { raw[zone.name] += weight; break; }
}
}
const MAX_W = 8;
for (const zone of HEATMAP_ZONES) {
_heatZoneIntensity[zone.name] = Math.min(raw[zone.name] / MAX_W, 1.0);
}
drawHeatmap();
}
// ═══ BATCAVE TERMINAL ═══
@@ -677,6 +933,183 @@ function createSessionPowerMeter() {
scene.add(group);
}
// ═══ DUAL-BRAIN PANEL ═══
function createDualBrainTexture() {
const W = 512, H = 512;
const canvas = document.createElement('canvas');
canvas.width = W;
canvas.height = H;
const ctx = canvas.getContext('2d');
ctx.fillStyle = 'rgba(0, 6, 20, 0.90)';
ctx.fillRect(0, 0, W, H);
ctx.strokeStyle = '#4488ff';
ctx.lineWidth = 2;
ctx.strokeRect(1, 1, W - 2, H - 2);
ctx.strokeStyle = '#223366';
ctx.lineWidth = 1;
ctx.strokeRect(5, 5, W - 10, H - 10);
ctx.font = 'bold 22px "Courier New", monospace';
ctx.fillStyle = '#88ccff';
ctx.textAlign = 'center';
ctx.fillText('\u25C8 DUAL-BRAIN STATUS', W / 2, 40);
ctx.strokeStyle = '#1a3a6a';
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(20, 52);
ctx.lineTo(W - 20, 52);
ctx.stroke();
ctx.font = '11px "Courier New", monospace';
ctx.fillStyle = '#556688';
ctx.textAlign = 'left';
ctx.fillText('BRAIN GAP SCORECARD', 20, 74);
const categories = [
{ name: 'Triage' },
{ name: 'Tool Use' },
{ name: 'Code Gen' },
{ name: 'Planning' },
{ name: 'Communication' },
{ name: 'Reasoning' },
];
const barX = 20;
const barW = W - 130;
const barH = 20;
let y = 90;
for (const cat of categories) {
ctx.font = '13px "Courier New", monospace';
ctx.fillStyle = '#445566';
ctx.textAlign = 'left';
ctx.fillText(cat.name, barX, y + 14);
ctx.font = 'bold 13px "Courier New", monospace';
ctx.fillStyle = '#334466';
ctx.textAlign = 'right';
ctx.fillText('\u2014', W - 20, y + 14);
y += 22;
ctx.fillStyle = 'rgba(255, 255, 255, 0.06)';
ctx.fillRect(barX, y, barW, barH);
y += barH + 12;
}
ctx.strokeStyle = '#1a3a6a';
ctx.beginPath();
ctx.moveTo(20, y + 4);
ctx.lineTo(W - 20, y + 4);
ctx.stroke();
y += 22;
ctx.font = 'bold 18px "Courier New", monospace';
ctx.fillStyle = '#334466';
ctx.textAlign = 'center';
ctx.fillText('AWAITING DEPLOYMENT', W / 2, y + 10);
ctx.font = '11px "Courier New", monospace';
ctx.fillStyle = '#223344';
ctx.fillText('Dual-brain system not yet connected', W / 2, y + 32);
y += 52;
ctx.beginPath();
ctx.arc(W / 2 - 60, y + 8, 6, 0, Math.PI * 2);
ctx.fillStyle = '#334466';
ctx.fill();
ctx.font = '11px "Courier New", monospace';
ctx.fillStyle = '#334466';
ctx.textAlign = 'left';
ctx.fillText('CLOUD', W / 2 - 48, y + 12);
ctx.beginPath();
ctx.arc(W / 2 + 30, y + 8, 6, 0, Math.PI * 2);
ctx.fillStyle = '#334466';
ctx.fill();
ctx.fillStyle = '#334466';
ctx.fillText('LOCAL', W / 2 + 42, y + 12);
return new THREE.CanvasTexture(canvas);
}
function createDualBrainPanel() {
dualBrainGroup = new THREE.Group();
dualBrainGroup.position.set(10, 3, -8);
dualBrainGroup.lookAt(0, 3, 0);
scene.add(dualBrainGroup);
// Main panel sprite
const panelTexture = createDualBrainTexture();
const panelMat = new THREE.SpriteMaterial({
map: panelTexture, transparent: true, opacity: 0.92, depthWrite: false,
});
const panelSprite = new THREE.Sprite(panelMat);
panelSprite.scale.set(5.0, 5.0, 1);
panelSprite.position.set(0, 0, 0);
panelSprite.userData = { baseY: 0, floatPhase: 0, floatSpeed: 0.22, zoomLabel: 'Dual-Brain Status' };
dualBrainGroup.add(panelSprite);
// Panel glow light
dualBrainLight = new THREE.PointLight(0x4488ff, 0.6, 10);
dualBrainLight.position.set(0, 0.5, 1);
dualBrainGroup.add(dualBrainLight);
// Cloud Brain Orb
const cloudOrbGeo = new THREE.SphereGeometry(0.35, 32, 32);
const cloudOrbMat = new THREE.MeshStandardMaterial({
color: 0x334466,
emissive: new THREE.Color(0x334466),
emissiveIntensity: 0.1, metalness: 0.3, roughness: 0.2,
transparent: true, opacity: 0.85,
});
cloudOrb = new THREE.Mesh(cloudOrbGeo, cloudOrbMat);
cloudOrb.position.set(-2.0, 3.0, 0);
cloudOrb.userData.zoomLabel = 'Cloud Brain';
dualBrainGroup.add(cloudOrb);
cloudOrbLight = new THREE.PointLight(0x334466, 0.15, 5);
cloudOrbLight.position.copy(cloudOrb.position);
dualBrainGroup.add(cloudOrbLight);
// Local Brain Orb
const localOrbGeo = new THREE.SphereGeometry(0.35, 32, 32);
const localOrbMat = new THREE.MeshStandardMaterial({
color: 0x334466,
emissive: new THREE.Color(0x334466),
emissiveIntensity: 0.1, metalness: 0.3, roughness: 0.2,
transparent: true, opacity: 0.85,
});
localOrb = new THREE.Mesh(localOrbGeo, localOrbMat);
localOrb.position.set(2.0, 3.0, 0);
localOrb.userData.zoomLabel = 'Local Brain';
dualBrainGroup.add(localOrb);
localOrbLight = new THREE.PointLight(0x334466, 0.15, 5);
localOrbLight.position.copy(localOrb.position);
dualBrainGroup.add(localOrbLight);
// Scan line overlay
const scanCanvas = document.createElement('canvas');
scanCanvas.width = 512;
scanCanvas.height = 512;
dualBrainScanCtx = scanCanvas.getContext('2d');
dualBrainScanTexture = new THREE.CanvasTexture(scanCanvas);
const scanMat = new THREE.SpriteMaterial({
map: dualBrainScanTexture, transparent: true, opacity: 0.18, depthWrite: false,
});
const scanSprite = new THREE.Sprite(scanMat);
scanSprite.scale.set(5.0, 5.0, 1);
scanSprite.position.set(0, 0, 0.01);
dualBrainGroup.add(scanSprite);
}
// ═══ VISION SYSTEM ═══
function createVisionPoints(data) {
data.forEach(config => {
@@ -821,6 +1254,33 @@ function createPortal(config) {
light.position.set(0, 3.5, 1);
group.add(light);
// Rune Ring (Portal-tethered)
const runeCount = 8;
const runeRingRadius = 4.5;
const runes = [];
for (let i = 0; i < runeCount; i++) {
const angle = (i / runeCount) * Math.PI * 2;
const runeGeo = new THREE.BoxGeometry(0.3, 0.8, 0.1);
const runeMat = new THREE.MeshStandardMaterial({
color: portalColor,
emissive: portalColor,
emissiveIntensity: 0.8,
transparent: true,
opacity: 0.7,
roughness: 0.2,
metalness: 0.5,
});
const rune = new THREE.Mesh(runeGeo, runeMat);
rune.position.set(
Math.cos(angle) * runeRingRadius,
4,
Math.sin(angle) * runeRingRadius
);
rune.rotation.y = angle + Math.PI / 2;
group.add(rune);
runes.push(rune);
}
// Label
const labelCanvas = document.createElement('canvas');
labelCanvas.width = 512;
@@ -860,7 +1320,8 @@ function createPortal(config) {
ring,
swirl,
pSystem,
light
light,
runes
};
}
@@ -986,20 +1447,7 @@ function createAmbientStructures() {
scene.add(crystal);
});
for (let i = 0; i < 5; i++) {
const angle = (i / 5) * Math.PI * 2;
const r = 10;
const geo = new THREE.OctahedronGeometry(0.4, 0);
const mat = new THREE.MeshStandardMaterial({
color: NEXUS.colors.primary,
emissive: NEXUS.colors.primary,
emissiveIntensity: 0.5,
});
const stone = new THREE.Mesh(geo, mat);
stone.position.set(Math.cos(angle) * r, 5 + Math.sin(i * 1.3) * 1.5, Math.sin(angle) * r);
stone.name = 'runestone_' + i;
scene.add(stone);
}
const coreGeo = new THREE.IcosahedronGeometry(0.6, 2);
const coreMat = new THREE.MeshPhysicalMaterial({
@@ -1408,6 +1856,9 @@ function gameLoop() {
const sky = scene.getObjectByName('skybox');
if (sky) sky.material.uniforms.uTime.value = elapsed;
// Pulse heatmap opacity
if (heatmapMat) heatmapMat.opacity = 0.75 + Math.sin(elapsed * 0.6) * 0.2;
batcaveTerminals.forEach(t => {
if (t.scanMat?.uniforms) t.scanMat.uniforms.uTime.value = elapsed;
});
@@ -1427,6 +1878,12 @@ function gameLoop() {
positions[i * 3 + 1] += Math.sin(elapsed + i) * 0.002;
}
portal.pSystem.geometry.attributes.position.needsUpdate = true;
// Animate runes
portal.runes.forEach((rune, i) => {
rune.position.y = 4 + Math.sin(elapsed * 2 + i * 0.5) * 0.2;
rune.rotation.z = elapsed * 0.8 + i;
});
});
// Animate Vision Points
@@ -1442,6 +1899,34 @@ function gameLoop() {
// Animate Agents
updateAgents(elapsed, delta);
// Animate Dual-Brain Panel
if (dualBrainGroup) {
dualBrainGroup.position.y = 3 + Math.sin(elapsed * 0.22) * 0.15;
if (cloudOrb) {
cloudOrb.position.y = 3 + Math.sin(elapsed * 1.3) * 0.15;
cloudOrb.rotation.y = elapsed * 0.4;
}
if (localOrb) {
localOrb.position.y = 3 + Math.sin(elapsed * 1.3 + Math.PI) * 0.15;
localOrb.rotation.y = -elapsed * 0.4;
}
if (dualBrainLight) {
dualBrainLight.intensity = 0.4 + Math.sin(elapsed * 1.5) * 0.2;
}
if (dualBrainScanCtx && dualBrainScanTexture) {
const W = 512, H = 512;
dualBrainScanCtx.clearRect(0, 0, W, H);
const scanY = ((elapsed * 80) % H);
const grad = dualBrainScanCtx.createLinearGradient(0, scanY - 20, 0, scanY + 20);
grad.addColorStop(0, 'rgba(68, 136, 255, 0)');
grad.addColorStop(0.5, 'rgba(68, 136, 255, 0.6)');
grad.addColorStop(1, 'rgba(68, 136, 255, 0)');
dualBrainScanCtx.fillStyle = grad;
dualBrainScanCtx.fillRect(0, scanY - 20, W, 40);
dualBrainScanTexture.needsUpdate = true;
}
}
// Animate Power Meter
powerMeterBars.forEach((bar, i) => {
const level = (Math.sin(elapsed * 2 + i * 0.5) * 0.5 + 0.5);
@@ -1451,6 +1936,15 @@ function gameLoop() {
bar.scale.x = active ? 1.2 : 1.0;
});
// Animate glass floor edge glow (ripple outward from center)
for (const { mat, distFromCenter } of glassEdgeMaterials) {
const phase = elapsed * 1.1 - distFromCenter * 0.18;
mat.opacity = 0.25 + Math.sin(phase) * 0.22;
}
if (voidLight) {
voidLight.intensity = 0.35 + Math.sin(elapsed * 1.4) * 0.2;
}
if (thoughtStreamMesh) {
thoughtStreamMesh.material.uniforms.uTime.value = elapsed;
thoughtStreamMesh.rotation.y = elapsed * 0.05;
@@ -1463,14 +1957,7 @@ function gameLoop() {
dustParticles.rotation.y = elapsed * 0.01;
}
for (let i = 0; i < 5; i++) {
const stone = scene.getObjectByName('runestone_' + i);
if (stone) {
stone.position.y = 5 + Math.sin(elapsed * 0.8 + i * 1.3) * 0.8;
stone.rotation.y = elapsed * 0.5 + i;
stone.rotation.x = elapsed * 0.3 + i * 0.7;
}
}
const core = scene.getObjectByName('nexus-core');
if (core) {
@@ -1692,4 +2179,36 @@ function triggerHarnessPulse() {
}
}
// === BITCOIN BLOCK HEIGHT ===
(function initBitcoin() {
const blockHeightDisplay = document.getElementById('block-height-display');
const blockHeightValue = document.getElementById('block-height-value');
if (!blockHeightDisplay || !blockHeightValue) return;
let lastKnownBlockHeight = null;
async function fetchBlockHeight() {
try {
const res = await fetch('https://blockstream.info/api/blocks/tip/height');
if (!res.ok) return;
const height = parseInt(await res.text(), 10);
if (isNaN(height)) return;
if (lastKnownBlockHeight !== null && height !== lastKnownBlockHeight) {
blockHeightDisplay.classList.remove('fresh');
void blockHeightDisplay.offsetWidth;
blockHeightDisplay.classList.add('fresh');
}
lastKnownBlockHeight = height;
blockHeightValue.textContent = height.toLocaleString();
} catch (_) {
// Network unavailable
}
}
fetchBlockHeight();
setInterval(fetchBlockHeight, 60000);
})();
init();

1
icons/icon-192x192.png Normal file
View File

@@ -0,0 +1 @@
placeholder 192x192

1
icons/icon-512x512.png Normal file
View File

@@ -0,0 +1 @@
placeholder 512x512

View File

@@ -23,6 +23,7 @@
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@300;400;500;600;700&family=Orbitron:wght@400;500;600;700;800;900&display=swap" rel="stylesheet">
<link rel="stylesheet" href="./style.css">
<link rel="manifest" href="manifest.json">
<script type="importmap">
{
"imports": {
@@ -154,6 +155,12 @@
</div>
</div>
<!-- Bitcoin Block Height -->
<div id="block-height-display">
<span class="block-height-label">⛏ BLOCK</span>
<span id="block-height-value"></span>
</div>
<!-- Click to Enter -->
<div id="enter-prompt" style="display:none;">
<div class="enter-content">
@@ -172,6 +179,20 @@
<script type="module" src="./app.js"></script>
<script>
if ('serviceWorker' in navigator) {
window.addEventListener('load', () => {
navigator.serviceWorker.register('/service-worker.js')
.then(registration => {
console.log('Service Worker registered: ', registration);
})
.catch(error => {
console.log('Service Worker registration failed: ', error);
});
});
}
</script>
<!-- Live Refresh: polls Gitea for new commits on main, reloads when SHA changes -->
<div id="live-refresh-banner" style="
display:none; position:fixed; top:0; left:0; right:0; z-index:9999;

21
manifest.json Normal file
View File

@@ -0,0 +1,21 @@
{
"name": "The Nexus",
"short_name": "Nexus",
"start_url": ".",
"display": "standalone",
"background_color": "#000000",
"theme_color": "#000000",
"description": "Timmy's Sovereign Home - A Three.js environment.",
"icons": [
{
"src": "icons/icon-192x192.png",
"sizes": "192x192",
"type": "image/png"
},
{
"src": "icons/icon-512x512.png",
"sizes": "512x512",
"type": "image/png"
}
]
}

75
package-lock.json generated Normal file
View File

@@ -0,0 +1,75 @@
{
"name": "nexus-check",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"devDependencies": {
"@playwright/test": "^1.58.2"
}
},
"node_modules/@playwright/test": {
"version": "1.58.2",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.58.2.tgz",
"integrity": "sha512-akea+6bHYBBfA9uQqSYmlJXn61cTa+jbO87xVLCWbTqbWadRVmhxlXATaOjOgcBaWU4ePo0wB41KMFv3o35IXA==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright": "1.58.2"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=18"
}
},
"node_modules/fsevents": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/playwright": {
"version": "1.58.2",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.58.2.tgz",
"integrity": "sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright-core": "1.58.2"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"fsevents": "2.3.2"
}
},
"node_modules/playwright-core": {
"version": "1.58.2",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.58.2.tgz",
"integrity": "sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"playwright-core": "cli.js"
},
"engines": {
"node": ">=18"
}
}
}
}

5
package.json Normal file
View File

@@ -0,0 +1,5 @@
{
"devDependencies": {
"@playwright/test": "^1.58.2"
}
}

45
service-worker.js Normal file
View File

@@ -0,0 +1,45 @@
const CACHE_NAME = 'nexus-cache-v1';
const urlsToCache = [
'.',
'index.html',
'style.css',
'app.js',
'manifest.json'
];
self.addEventListener('install', (event) => {
event.waitUntil(
caches.open(CACHE_NAME)
.then((cache) => {
console.log('Opened cache');
return cache.addAll(urlsToCache);
})
);
});
self.addEventListener('fetch', (event) => {
event.respondWith(
caches.match(event.request)
.then((response) => {
if (response) {
return response;
}
return fetch(event.request);
})
);
});
self.addEventListener('activate', (event) => {
const cacheWhitelist = [CACHE_NAME];
event.waitUntil(
caches.keys().then((cacheNames) => {
return Promise.all(
cacheNames.map((cacheName) => {
if (cacheWhitelist.indexOf(cacheName) === -1) {
return caches.delete(cacheName);
}
})
);
})
);
});

View File

@@ -625,6 +625,42 @@ canvas#nexus-canvas {
color: var(--color-primary);
}
/* === BITCOIN BLOCK HEIGHT === */
#block-height-display {
position: fixed;
bottom: 12px;
right: 12px;
z-index: 20;
font-family: var(--font-body);
font-size: 11px;
letter-spacing: 0.15em;
color: var(--color-primary);
background: rgba(0, 0, 8, 0.7);
border: 1px solid var(--color-secondary);
padding: 4px 10px;
pointer-events: none;
white-space: nowrap;
}
.block-height-label {
color: var(--color-text-muted);
margin-right: 6px;
font-size: 10px;
}
#block-height-value {
color: var(--color-primary);
}
#block-height-display.fresh #block-height-value {
animation: block-flash 0.6s ease-out;
}
@keyframes block-flash {
0% { color: #ffffff; text-shadow: 0 0 8px #4488ff; }
100% { color: var(--color-primary); text-shadow: none; }
}
/* Mobile adjustments */
@media (max-width: 480px) {
.chat-panel {

View File

@@ -0,0 +1,43 @@
// @ts-check
const { defineConfig } = require('@playwright/test');
module.exports = defineConfig({
testDir: '.',
timeout: 30000,
retries: 1,
use: {
headless: true,
viewport: { width: 1280, height: 720 },
launchOptions: {
args: [
'--use-gl=angle',
'--use-angle=swiftshader',
'--enable-webgl',
],
},
},
projects: [
// Headless — fast, for CI. Software WebGL (limited shader support).
{ name: 'chromium', use: { browserName: 'chromium' } },
// Headed — real GPU render. Use for visual screenshot tests.
// Run with: --project=gpu
{
name: 'gpu',
use: {
browserName: 'chromium',
headless: false,
viewport: { width: 1280, height: 720 },
launchOptions: {
args: ['--enable-webgl', '--enable-gpu'],
},
},
},
],
// Local server
webServer: {
command: 'python3 -m http.server 8888',
port: 8888,
cwd: '..',
reuseExistingServer: true,
},
});

43
tests/run-smoke.sh Executable file
View File

@@ -0,0 +1,43 @@
#!/usr/bin/env bash
# run-smoke.sh — Run Nexus smoke tests locally. No LLM. No cloud.
#
# Usage:
# ./tests/run-smoke.sh # Run all smoke tests
# ./tests/run-smoke.sh --headed # Run with visible browser (debug)
# ./tests/run-smoke.sh --grep "HUD" # Run specific test group
#
# Requirements: playwright installed (npm i -D @playwright/test)
# First run: npx playwright install chromium
set -euo pipefail
cd "$(dirname "$0")/.."
# Ensure playwright is available
if ! command -v npx &>/dev/null; then
echo "ERROR: npx not found. Install Node.js."
exit 1
fi
# Install playwright test if needed
if [ ! -d node_modules/@playwright ]; then
echo "Installing playwright test runner..."
npm install --save-dev @playwright/test 2>&1 | tail -3
fi
# Ensure browser is installed
npx playwright install chromium --with-deps 2>/dev/null || true
# Run tests
echo ""
echo "=== NEXUS SMOKE TESTS ==="
echo ""
npx playwright test tests/smoke.spec.js -c tests/playwright.config.js "$@"
EXIT=$?
echo ""
if [ $EXIT -eq 0 ]; then
echo "✅ ALL SMOKE TESTS PASSED"
else
echo "❌ SOME TESTS FAILED (exit $EXIT)"
fi
exit $EXIT

338
tests/smoke.spec.js Normal file
View File

@@ -0,0 +1,338 @@
// @ts-check
/**
* Nexus Smoke Tests — Zero LLM, pure headless browser.
*
* Tests that the 3D world renders, modules load, and basic interaction works.
* Run: npx playwright test tests/smoke.spec.js
* Requires: a local server serving the nexus (e.g., python3 -m http.server 8888)
*/
const { test, expect } = require('@playwright/test');
const path = require('path');
const fs = require('fs');
const BASE_URL = process.env.NEXUS_URL || 'http://localhost:8888';
const SCREENSHOT_DIR = path.join(__dirname, '..', 'test-screenshots');
// Ensure screenshot directory exists
if (!fs.existsSync(SCREENSHOT_DIR)) {
fs.mkdirSync(SCREENSHOT_DIR, { recursive: true });
}
// --- RENDERING TESTS ---
test.describe('World Renders', () => {
test('index.html loads without errors', async ({ page }) => {
const errors = [];
page.on('pageerror', err => errors.push(err.message));
const response = await page.goto(BASE_URL, { waitUntil: 'domcontentloaded' });
expect(response.status()).toBe(200);
// Give Three.js a moment to initialize
await page.waitForTimeout(2000);
// No fatal JS errors
const fatalErrors = errors.filter(e =>
!e.includes('ambient.mp3') && // missing audio file is OK
!e.includes('favicon') &&
!e.includes('serviceWorker')
);
expect(fatalErrors).toEqual([]);
});
test('canvas element exists (Three.js rendered)', async ({ page }) => {
await page.goto(BASE_URL, { waitUntil: 'domcontentloaded' });
await page.waitForTimeout(3000);
// Three.js creates a <canvas> element
const canvas = await page.locator('canvas');
await expect(canvas).toBeVisible();
// Canvas should have non-zero dimensions
const box = await canvas.boundingBox();
expect(box.width).toBeGreaterThan(100);
expect(box.height).toBeGreaterThan(100);
});
test('full render screenshot — default view', async ({ page }) => {
await page.goto(BASE_URL, { waitUntil: 'domcontentloaded' });
// Wait for scene to fully render — crystals, portals, sigil, earth
await page.waitForTimeout(6000);
const screenshotPath = path.join(SCREENSHOT_DIR, 'render-default.png');
await page.screenshot({ path: screenshotPath, fullPage: false });
console.log(`Screenshot saved: ${screenshotPath}`);
// Verify the screenshot has actual content (not blank)
const stats = fs.statSync(screenshotPath);
expect(stats.size).toBeGreaterThan(10000); // A real render is >10KB
});
test('full render screenshot — overview mode', async ({ page }) => {
await page.goto(BASE_URL, { waitUntil: 'domcontentloaded' });
await page.waitForTimeout(4000);
// Toggle overview mode
await page.keyboard.press('Tab');
await page.waitForTimeout(2000);
const screenshotPath = path.join(SCREENSHOT_DIR, 'render-overview.png');
await page.screenshot({ path: screenshotPath, fullPage: false });
console.log(`Screenshot saved: ${screenshotPath}`);
const stats = fs.statSync(screenshotPath);
expect(stats.size).toBeGreaterThan(10000);
});
test('full render screenshot — after mouse look', async ({ page }) => {
await page.goto(BASE_URL, { waitUntil: 'domcontentloaded' });
await page.waitForTimeout(4000);
// Move the camera by moving mouse to corner
const viewport = page.viewportSize();
await page.mouse.move(viewport.width * 0.9, viewport.height * 0.2);
await page.waitForTimeout(2000);
const screenshotPath = path.join(SCREENSHOT_DIR, 'render-mouse-look.png');
await page.screenshot({ path: screenshotPath, fullPage: false });
console.log(`Screenshot saved: ${screenshotPath}`);
const stats = fs.statSync(screenshotPath);
expect(stats.size).toBeGreaterThan(10000);
});
test('canvas is not all black (scene has content)', async ({ page }) => {
await page.goto(BASE_URL, { waitUntil: 'domcontentloaded' });
await page.waitForTimeout(4000);
// Sample pixels from the canvas
const pixelStats = await page.evaluate(() => {
const canvas = document.querySelector('canvas');
if (!canvas) return { error: 'no canvas' };
const ctx = canvas.getContext('webgl2') || canvas.getContext('webgl');
if (!ctx) return { error: 'no webgl context' };
// Read a block of pixels from center
const w = canvas.width;
const h = canvas.height;
const pixels = new Uint8Array(4 * 100);
ctx.readPixels(
Math.floor(w / 2) - 5, Math.floor(h / 2) - 5,
10, 10, ctx.RGBA, ctx.UNSIGNED_BYTE, pixels
);
// Count non-black pixels and compute average brightness
let nonBlack = 0;
let totalBrightness = 0;
for (let i = 0; i < pixels.length; i += 4) {
const brightness = (pixels[i] + pixels[i + 1] + pixels[i + 2]) / 3;
totalBrightness += brightness;
if (brightness > 5) nonBlack++;
}
return {
nonBlackPixels: nonBlack,
totalSampled: 25,
avgBrightness: Math.round(totalBrightness / 25),
};
});
console.log(`Pixel stats: ${JSON.stringify(pixelStats)}`);
expect(pixelStats.error).toBeUndefined();
expect(pixelStats.nonBlackPixels).toBeGreaterThan(3);
});
test('WebGL context is healthy', async ({ page }) => {
await page.goto(BASE_URL, { waitUntil: 'domcontentloaded' });
await page.waitForTimeout(2000);
const glInfo = await page.evaluate(() => {
const canvas = document.querySelector('canvas');
if (!canvas) return { error: 'no canvas' };
const gl = canvas.getContext('webgl2') || canvas.getContext('webgl');
if (!gl) return { error: 'no webgl context' };
return {
renderer: gl.getParameter(gl.RENDERER),
vendor: gl.getParameter(gl.VENDOR),
version: gl.getParameter(gl.VERSION),
isLost: gl.isContextLost(),
};
});
console.log(`WebGL: ${glInfo.renderer} (${glInfo.vendor}) ${glInfo.version}`);
expect(glInfo.error).toBeUndefined();
expect(glInfo.isLost).toBe(false);
});
});
// --- MODULE LOADING TESTS ---
test.describe('Modules Load', () => {
test('all ES modules resolve (no import errors)', async ({ page }) => {
const moduleErrors = [];
page.on('pageerror', err => {
if (err.message.includes('import') || err.message.includes('module') ||
err.message.includes('export') || err.message.includes('Cannot find')) {
moduleErrors.push(err.message);
}
});
await page.goto(BASE_URL, { waitUntil: 'domcontentloaded' });
await page.waitForTimeout(3000);
expect(moduleErrors).toEqual([]);
});
test('Three.js loaded from CDN', async ({ page }) => {
await page.goto(BASE_URL, { waitUntil: 'domcontentloaded' });
await page.waitForTimeout(2000);
const hasThree = await page.evaluate(() => {
// Check if THREE is accessible (it's imported as ES module, so check via scene)
const canvas = document.querySelector('canvas');
return !!canvas;
});
expect(hasThree).toBe(true);
});
});
// --- HUD ELEMENTS ---
test.describe('HUD Elements', () => {
test('block height display exists', async ({ page }) => {
await page.goto(BASE_URL, { waitUntil: 'domcontentloaded' });
const blockDisplay = page.locator('#block-height-display');
await expect(blockDisplay).toBeAttached();
});
test('weather HUD exists', async ({ page }) => {
await page.goto(BASE_URL, { waitUntil: 'domcontentloaded' });
const weather = page.locator('#weather-hud');
await expect(weather).toBeAttached();
});
test('audio toggle exists', async ({ page }) => {
await page.goto(BASE_URL, { waitUntil: 'domcontentloaded' });
const btn = page.locator('#audio-toggle');
await expect(btn).toBeAttached();
});
test('sovereignty message exists', async ({ page }) => {
await page.goto(BASE_URL, { waitUntil: 'domcontentloaded' });
const msg = page.locator('#sovereignty-msg');
await expect(msg).toBeAttached();
});
test('oath overlay exists', async ({ page }) => {
await page.goto(BASE_URL, { waitUntil: 'domcontentloaded' });
const oath = page.locator('#oath-overlay');
await expect(oath).toBeAttached();
});
});
// --- INTERACTION TESTS ---
test.describe('Interactions', () => {
test('mouse movement updates camera (parallax)', async ({ page }) => {
await page.goto(BASE_URL, { waitUntil: 'domcontentloaded' });
await page.waitForTimeout(3000);
// Get initial canvas snapshot
const canvas = page.locator('canvas');
const box = await canvas.boundingBox();
// Take screenshot before mouse move
const before = await page.screenshot({ clip: { x: box.x, y: box.y, width: 100, height: 100 } });
// Move mouse significantly
await page.mouse.move(box.x + box.width * 0.8, box.y + box.height * 0.2);
await page.waitForTimeout(1500);
// Take screenshot after
const after = await page.screenshot({ clip: { x: box.x, y: box.y, width: 100, height: 100 } });
// Screenshots should differ (camera moved)
expect(Buffer.compare(before, after)).not.toBe(0);
});
test('Tab key toggles overview mode', async ({ page }) => {
await page.goto(BASE_URL, { waitUntil: 'domcontentloaded' });
await page.waitForTimeout(3000);
// Press Tab for overview
await page.keyboard.press('Tab');
await page.waitForTimeout(1000);
// Overview indicator should be visible
const indicator = page.locator('#overview-indicator');
// It should have some visibility (either display or opacity)
const isVisible = await indicator.evaluate(el => {
const style = window.getComputedStyle(el);
return style.display !== 'none' && parseFloat(style.opacity) > 0;
});
// Press Tab again to exit
await page.keyboard.press('Tab');
expect(isVisible).toBe(true);
});
test('animation loop is running (requestAnimationFrame)', async ({ page }) => {
await page.goto(BASE_URL, { waitUntil: 'domcontentloaded' });
await page.waitForTimeout(2000);
// Check that frames are being rendered by watching a timestamp
const frameCount = await page.evaluate(() => {
return new Promise(resolve => {
let count = 0;
const start = performance.now();
function tick() {
count++;
if (performance.now() - start > 500) {
resolve(count);
} else {
requestAnimationFrame(tick);
}
}
requestAnimationFrame(tick);
});
});
// Should get at least 10 frames in 500ms (20+ FPS)
expect(frameCount).toBeGreaterThan(10);
});
});
// --- DATA / API TESTS ---
test.describe('Data Loading', () => {
test('portals.json loads', async ({ page }) => {
const response = await page.goto(`${BASE_URL}/portals.json`);
expect(response.status()).toBe(200);
const data = await response.json();
expect(Array.isArray(data) || typeof data === 'object').toBe(true);
});
test('sovereignty-status.json loads', async ({ page }) => {
const response = await page.goto(`${BASE_URL}/sovereignty-status.json`);
expect(response.status()).toBe(200);
});
test('style.css loads', async ({ page }) => {
const response = await page.goto(`${BASE_URL}/style.css`);
expect(response.status()).toBe(200);
});
test('manifest.json is valid', async ({ page }) => {
const response = await page.goto(`${BASE_URL}/manifest.json`);
expect(response.status()).toBe(200);
const data = await response.json();
expect(data.name || data.short_name).toBeTruthy();
});
});