Compare commits
2 Commits
reference/
...
feat/issue
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
708212fa8a | ||
|
|
3c961beefd |
16
index.html
16
index.html
@@ -47,9 +47,25 @@
|
||||
<button id="timelapse-btn" class="chat-toggle-btn" aria-label="Start time-lapse replay" title="Time-lapse: replay today's activity in 30s [L]">
|
||||
⏩
|
||||
</button>
|
||||
<button id="portal-atlas-toggle" class="chat-toggle-btn" aria-label="Open portal atlas" title="Open portal atlas [G]">
|
||||
🜂
|
||||
</button>
|
||||
<audio id="ambient-sound" src="ambient.mp3" loop></audio>
|
||||
</div>
|
||||
|
||||
<div id="portal-atlas-overlay" aria-live="polite" aria-label="Portal atlas">
|
||||
<div id="portal-atlas-inner">
|
||||
<div id="portal-atlas-header">
|
||||
<div>
|
||||
<div id="portal-atlas-title">PORTAL ATLAS</div>
|
||||
<div id="portal-atlas-subtitle">Every place Timmy can drop into</div>
|
||||
</div>
|
||||
<button id="portal-atlas-close" aria-label="Close portal atlas">✕</button>
|
||||
</div>
|
||||
<div id="portal-atlas-list"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="overview-indicator">
|
||||
<span>MAP VIEW</span>
|
||||
<span class="overview-hint">[Tab] to exit</span>
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import * as THREE from 'three';
|
||||
import { NEXUS } from './constants.js';
|
||||
import { scene } from './scene-setup.js';
|
||||
import { fetchNexusCommits, fetchMergedPRs } from './data/gitea.js';
|
||||
|
||||
// === AGENT STATUS PANELS (declared early) ===
|
||||
export const agentPanelSprites = [];
|
||||
@@ -37,29 +38,16 @@ function createCommitTexture(hash, message) {
|
||||
}
|
||||
|
||||
export async function initCommitBanners() {
|
||||
let commits;
|
||||
try {
|
||||
const res = await fetch(
|
||||
'http://143.198.27.163:3000/api/v1/repos/Timmy_Foundation/the-nexus/commits?limit=5',
|
||||
{ headers: { 'Authorization': 'token dc0517a965226b7a0c5ffdd961b1ba26521ac592' } }
|
||||
);
|
||||
if (!res.ok) throw new Error('fetch failed');
|
||||
const data = await res.json();
|
||||
commits = data.map(c => ({
|
||||
hash: c.sha.slice(0, 7),
|
||||
message: c.commit.message.split('\n')[0],
|
||||
}));
|
||||
} catch {
|
||||
commits = [
|
||||
{ hash: 'a1b2c3d', message: 'feat: depth of field effect on distant objects' },
|
||||
{ hash: 'e4f5g6h', message: 'feat: photo mode with orbit controls' },
|
||||
{ hash: 'i7j8k9l', message: 'feat: sovereignty easter egg animation' },
|
||||
{ hash: 'm0n1o2p', message: 'feat: overview mode bird\'s-eye view' },
|
||||
{ hash: 'q3r4s5t', message: 'feat: star field and constellation lines' },
|
||||
];
|
||||
|
||||
initCommitBanners();
|
||||
}
|
||||
const raw = await fetchNexusCommits(5);
|
||||
const commits = raw.length > 0
|
||||
? raw.map(c => ({ hash: c.sha.slice(0, 7), message: c.commit.message.split('\n')[0] }))
|
||||
: [
|
||||
{ hash: 'a1b2c3d', message: 'feat: depth of field effect on distant objects' },
|
||||
{ hash: 'e4f5g6h', message: 'feat: photo mode with orbit controls' },
|
||||
{ hash: 'i7j8k9l', message: 'feat: sovereignty easter egg animation' },
|
||||
{ hash: 'm0n1o2p', message: 'feat: overview mode bird\'s-eye view' },
|
||||
{ hash: 'q3r4s5t', message: 'feat: star field and constellation lines' },
|
||||
];
|
||||
|
||||
const spreadX = [-7, -3.5, 0, 3.5, 7];
|
||||
const spreadY = [1.0, -1.5, 2.2, -0.8, 1.6];
|
||||
@@ -208,23 +196,8 @@ function buildBookshelf(books, position, rotationY) {
|
||||
}
|
||||
|
||||
export async function initBookshelves() {
|
||||
let prs = [];
|
||||
try {
|
||||
const res = await fetch(
|
||||
'http://143.198.27.163:3000/api/v1/repos/Timmy_Foundation/the-nexus/pulls?state=closed&limit=20',
|
||||
{ headers: { 'Authorization': 'token dc0517a965226b7a0c5ffdd961b1ba26521ac592' } }
|
||||
);
|
||||
if (!res.ok) throw new Error('fetch failed');
|
||||
const data = await res.json();
|
||||
prs = data
|
||||
.filter(p => p.merged)
|
||||
.map(p => ({
|
||||
prNum: p.number,
|
||||
title: p.title
|
||||
.replace(/^\[[\w\s]+\]\s*/i, '')
|
||||
.replace(/\s*\(#\d+\)\s*$/, ''),
|
||||
}));
|
||||
} catch {
|
||||
let prs = await fetchMergedPRs(20);
|
||||
if (prs.length === 0) {
|
||||
prs = [
|
||||
{ prNum: 324, title: 'Model training status — LoRA adapters' },
|
||||
{ prNum: 323, title: 'The Oath — interactive SOUL.md reading' },
|
||||
|
||||
@@ -139,4 +139,25 @@ export async function refreshAgentData() {
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchMergedPRs(limit = 20) {
|
||||
try {
|
||||
const res = await fetch(
|
||||
`${GITEA_BASE}/repos/Timmy_Foundation/the-nexus/pulls?state=closed&limit=${limit}`,
|
||||
{ headers: { 'Authorization': `token ${GITEA_TOKEN}` } }
|
||||
);
|
||||
if (!res.ok) return [];
|
||||
const data = await res.json();
|
||||
return data
|
||||
.filter(p => p.merged)
|
||||
.map(p => ({
|
||||
prNum: p.number,
|
||||
title: p.title
|
||||
.replace(/^\[[\w\s]+\]\s*/i, '')
|
||||
.replace(/\s*\(#\d+\)\s*$/, ''),
|
||||
}));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export { GITEA_BASE, GITEA_TOKEN, GITEA_REPOS, AGENT_NAMES, CACHE_MS as AGENT_STATUS_CACHE_MS };
|
||||
|
||||
@@ -11,6 +11,60 @@ scene.add(portalGroup);
|
||||
|
||||
export let portals = [];
|
||||
|
||||
const atlasOverlay = document.getElementById('portal-atlas-overlay');
|
||||
const atlasList = document.getElementById('portal-atlas-list');
|
||||
const atlasToggle = document.getElementById('portal-atlas-toggle');
|
||||
const atlasClose = document.getElementById('portal-atlas-close');
|
||||
|
||||
function renderPortalAtlas() {
|
||||
if (!atlasList) return;
|
||||
atlasList.innerHTML = portals.map((portal) => {
|
||||
const env = portal.environment || 'unknown';
|
||||
const readiness = portal.readiness_state || portal.status || 'unknown';
|
||||
const access = portal.access_mode || 'unknown';
|
||||
const actionLabel = portal.destination?.action_label || 'Inspect';
|
||||
const url = portal.destination?.url || '#';
|
||||
return `
|
||||
<article class="portal-atlas-card" data-portal-id="${portal.id}">
|
||||
<div class="portal-atlas-meta">
|
||||
<span class="portal-atlas-name">${portal.name}</span>
|
||||
<span class="portal-atlas-status status-${portal.status}">${portal.status}</span>
|
||||
</div>
|
||||
<p class="portal-atlas-description">${portal.description}</p>
|
||||
<div class="portal-atlas-tags">
|
||||
<span>${portal.portal_type || 'portal'}</span>
|
||||
<span>${portal.world_category || 'world'}</span>
|
||||
<span>${env}</span>
|
||||
<span>${access}</span>
|
||||
<span>${readiness}</span>
|
||||
</div>
|
||||
<div class="portal-atlas-footer">
|
||||
<span class="portal-atlas-owner">Owner: ${portal.owner || 'unknown'}</span>
|
||||
<a class="portal-atlas-link" href="${url}">${actionLabel}</a>
|
||||
</div>
|
||||
</article>
|
||||
`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function setAtlasOpen(open) {
|
||||
if (!atlasOverlay) return;
|
||||
atlasOverlay.classList.toggle('visible', open);
|
||||
}
|
||||
|
||||
function initPortalAtlas() {
|
||||
atlasToggle?.addEventListener('click', () => setAtlasOpen(!atlasOverlay?.classList.contains('visible')));
|
||||
atlasClose?.addEventListener('click', () => setAtlasOpen(false));
|
||||
document.addEventListener('keydown', (event) => {
|
||||
if (event.key === 'g' || event.key === 'G') setAtlasOpen(!atlasOverlay?.classList.contains('visible'));
|
||||
if (event.key === 'Escape') setAtlasOpen(false);
|
||||
});
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
if (params.get('atlas') === '1') setAtlasOpen(true); // atlas=1
|
||||
}
|
||||
|
||||
initPortalAtlas();
|
||||
|
||||
function createPortals() {
|
||||
const portalGeo = new THREE.TorusGeometry(3.0, 0.2, 16, 100);
|
||||
|
||||
@@ -54,6 +108,7 @@ export async function loadPortals() {
|
||||
setPortalsRef(portals);
|
||||
setPortalsRefAudio(portals);
|
||||
createPortals();
|
||||
renderPortalAtlas();
|
||||
rebuildRuneRing();
|
||||
if (_rebuildGravityZonesFn) _rebuildGravityZonesFn();
|
||||
startPortalHums();
|
||||
|
||||
@@ -4,6 +4,7 @@ import { scene, ambientLight } from './scene-setup.js';
|
||||
import { cloudMaterial } from './platform.js';
|
||||
import { rebuildRuneRing } from './effects.js';
|
||||
import { S } from './state.js';
|
||||
import { fetchWeatherData } from './data/weather.js';
|
||||
|
||||
// === PORTAL HEALTH CHECKS ===
|
||||
const PORTAL_HEALTH_CHECK_MS = 5 * 60 * 1000;
|
||||
@@ -162,20 +163,13 @@ function updateWeatherHUD(wx) {
|
||||
|
||||
export async function fetchWeather() {
|
||||
try {
|
||||
const url = `https://api.open-meteo.com/v1/forecast?latitude=${WEATHER_LAT}&longitude=${WEATHER_LON}¤t=temperature_2m,weather_code,wind_speed_10m,cloud_cover&temperature_unit=fahrenheit&wind_speed_unit=mph&forecast_days=1`;
|
||||
const res = await fetch(url);
|
||||
if (!res.ok) throw new Error('weather fetch failed');
|
||||
const data = await res.json();
|
||||
const cur = data.current;
|
||||
const code = cur.weather_code;
|
||||
const { condition, icon } = weatherCodeToLabel(code);
|
||||
const cloudcover = typeof cur.cloud_cover === 'number' ? cur.cloud_cover : 50;
|
||||
weatherState = { code, temp: cur.temperature_2m, wind: cur.wind_speed_10m, condition, icon, cloudcover };
|
||||
applyWeatherToScene(weatherState);
|
||||
const cloudOpacity = 0.05 + (cloudcover / 100) * 0.55;
|
||||
cloudMaterial.uniforms.uDensity.value = 0.3 + (cloudcover / 100) * 0.7;
|
||||
const wx = await fetchWeatherData();
|
||||
weatherState = wx;
|
||||
applyWeatherToScene(wx);
|
||||
const cloudOpacity = 0.05 + (wx.cloudcover / 100) * 0.55;
|
||||
cloudMaterial.uniforms.uDensity.value = 0.3 + (wx.cloudcover / 100) * 0.7;
|
||||
cloudMaterial.opacity = cloudOpacity;
|
||||
updateWeatherHUD(weatherState);
|
||||
updateWeatherHUD(wx);
|
||||
} catch {
|
||||
const descEl = document.getElementById('weather-desc');
|
||||
if (descEl) descEl.textContent = 'Lempster NH';
|
||||
|
||||
30
portals.json
30
portals.json
@@ -3,13 +3,21 @@
|
||||
"id": "morrowind",
|
||||
"name": "Morrowind",
|
||||
"description": "The Vvardenfell harness. Ash storms and ancient mysteries.",
|
||||
"status": "offline",
|
||||
"status": "online",
|
||||
"portal_type": "game-world",
|
||||
"world_category": "rpg",
|
||||
"environment": "production",
|
||||
"access_mode": "public",
|
||||
"readiness_state": "playable",
|
||||
"telemetry_source": "hermes-harness:morrowind-mcp",
|
||||
"owner": "Timmy",
|
||||
"color": "#ff6600",
|
||||
"position": { "x": 15, "y": 0, "z": -10 },
|
||||
"rotation": { "y": -0.5 },
|
||||
"destination": {
|
||||
"url": "https://morrowind.timmy.foundation",
|
||||
"type": "harness",
|
||||
"action_label": "Enter Vvardenfell",
|
||||
"params": { "world": "vvardenfell" }
|
||||
}
|
||||
},
|
||||
@@ -17,13 +25,21 @@
|
||||
"id": "bannerlord",
|
||||
"name": "Bannerlord",
|
||||
"description": "Calradia battle harness. Massive armies, tactical command.",
|
||||
"status": "offline",
|
||||
"status": "rebuilding",
|
||||
"portal_type": "game-world",
|
||||
"world_category": "strategy-rpg",
|
||||
"environment": "staging",
|
||||
"access_mode": "operator",
|
||||
"readiness_state": "prototype",
|
||||
"telemetry_source": "hermes-harness:bannerlord-bridge",
|
||||
"owner": "Timmy",
|
||||
"color": "#ffd700",
|
||||
"position": { "x": -15, "y": 0, "z": -10 },
|
||||
"rotation": { "y": 0.5 },
|
||||
"destination": {
|
||||
"url": "https://bannerlord.timmy.foundation",
|
||||
"type": "harness",
|
||||
"action_label": "Enter Calradia",
|
||||
"params": { "world": "calradia" }
|
||||
}
|
||||
},
|
||||
@@ -31,13 +47,21 @@
|
||||
"id": "workshop",
|
||||
"name": "Workshop",
|
||||
"description": "The creative harness. Build, script, and manifest.",
|
||||
"status": "offline",
|
||||
"status": "online",
|
||||
"portal_type": "operator-room",
|
||||
"world_category": "workspace",
|
||||
"environment": "local",
|
||||
"access_mode": "local-only",
|
||||
"readiness_state": "active",
|
||||
"telemetry_source": "hermes-harness:workshop-state",
|
||||
"owner": "Alexander",
|
||||
"color": "#4af0c0",
|
||||
"position": { "x": 0, "y": 0, "z": -20 },
|
||||
"rotation": { "y": 0 },
|
||||
"destination": {
|
||||
"url": "https://workshop.timmy.foundation",
|
||||
"type": "harness",
|
||||
"action_label": "Enter Workshop",
|
||||
"params": { "mode": "creative" }
|
||||
}
|
||||
}
|
||||
|
||||
121
style.css
121
style.css
@@ -155,6 +155,127 @@ canvas {
|
||||
color: var(--color-bg);
|
||||
}
|
||||
|
||||
#portal-atlas-toggle {
|
||||
margin-left: 8px;
|
||||
}
|
||||
|
||||
#portal-atlas-overlay {
|
||||
display: none;
|
||||
position: fixed;
|
||||
top: 56px;
|
||||
right: 16px;
|
||||
width: min(420px, calc(100vw - 32px));
|
||||
max-height: calc(100vh - 88px);
|
||||
overflow: auto;
|
||||
background: rgba(5, 10, 24, 0.92);
|
||||
border: 1px solid rgba(68, 136, 255, 0.45);
|
||||
box-shadow: 0 0 24px rgba(68, 136, 255, 0.2);
|
||||
z-index: 30;
|
||||
border-radius: 8px;
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
#portal-atlas-overlay.visible {
|
||||
display: block;
|
||||
}
|
||||
|
||||
#portal-atlas-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
#portal-atlas-title {
|
||||
font-size: 14px;
|
||||
letter-spacing: 0.18em;
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
#portal-atlas-subtitle {
|
||||
font-size: 11px;
|
||||
color: var(--color-text-muted);
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
#portal-atlas-close {
|
||||
border: 1px solid rgba(68, 136, 255, 0.4);
|
||||
background: rgba(68, 136, 255, 0.12);
|
||||
color: var(--color-text);
|
||||
border-radius: 4px;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
#portal-atlas-list {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.portal-atlas-card {
|
||||
border: 1px solid rgba(68, 136, 255, 0.18);
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
border-radius: 8px;
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.portal-atlas-meta,
|
||||
.portal-atlas-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.portal-atlas-name {
|
||||
font-size: 13px;
|
||||
color: var(--color-text);
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.portal-atlas-status {
|
||||
font-size: 10px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.12em;
|
||||
padding: 2px 6px;
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.status-online { background: rgba(42, 201, 122, 0.18); color: #7dffb2; }
|
||||
.status-rebuilding { background: rgba(255, 170, 34, 0.18); color: #ffd37a; }
|
||||
.status-offline { background: rgba(255, 68, 102, 0.18); color: #ff93aa; }
|
||||
|
||||
.portal-atlas-description {
|
||||
margin: 8px 0;
|
||||
color: var(--color-text);
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.portal-atlas-tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.portal-atlas-tags span,
|
||||
.portal-atlas-owner {
|
||||
font-size: 10px;
|
||||
color: var(--color-text-muted);
|
||||
border: 1px solid rgba(68, 136, 255, 0.14);
|
||||
padding: 2px 6px;
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.portal-atlas-link {
|
||||
color: var(--color-primary);
|
||||
text-decoration: none;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.collision-box {
|
||||
outline: 2px solid red;
|
||||
outline-offset: 2px;
|
||||
|
||||
30
tests/test_portal_atlas_ui.py
Normal file
30
tests/test_portal_atlas_ui.py
Normal file
@@ -0,0 +1,30 @@
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def test_index_exposes_portal_atlas_ui_shell() -> None:
|
||||
html = Path('index.html').read_text()
|
||||
|
||||
for token in [
|
||||
'portal-atlas-toggle',
|
||||
'portal-atlas-overlay',
|
||||
'portal-atlas-list',
|
||||
'PORTAL ATLAS',
|
||||
]:
|
||||
assert token in html
|
||||
|
||||
|
||||
def test_portals_module_supports_atlas_render_and_query_open() -> None:
|
||||
js = Path('modules/portals.js').read_text()
|
||||
|
||||
for token in [
|
||||
'renderPortalAtlas',
|
||||
'portal-atlas-list',
|
||||
'portal-atlas-toggle',
|
||||
'URLSearchParams',
|
||||
'atlas=1',
|
||||
'action_label',
|
||||
'access_mode',
|
||||
'readiness_state',
|
||||
'environment',
|
||||
]:
|
||||
assert token in js
|
||||
Reference in New Issue
Block a user