diff --git a/index.html b/index.html
index 88753a5..f989583 100644
--- a/index.html
+++ b/index.html
@@ -47,9 +47,25 @@
+
+
MAP VIEW
[Tab] to exit
diff --git a/modules/portals.js b/modules/portals.js
index abe7453..2f8fc17 100644
--- a/modules/portals.js
+++ b/modules/portals.js
@@ -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 `
+
+
+ ${portal.name}
+ ${portal.status}
+
+ ${portal.description}
+
+ ${portal.portal_type || 'portal'}
+ ${portal.world_category || 'world'}
+ ${env}
+ ${access}
+ ${readiness}
+
+
+
+ `;
+ }).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();
diff --git a/portals.json b/portals.json
index 5bfbdc8..c57e201 100644
--- a/portals.json
+++ b/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" }
}
}
diff --git a/style.css b/style.css
index a70e718..c2c3570 100644
--- a/style.css
+++ b/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;
diff --git a/tests/test_portal_atlas_ui.py b/tests/test_portal_atlas_ui.py
new file mode 100644
index 0000000..ff49b2d
--- /dev/null
+++ b/tests/test_portal_atlas_ui.py
@@ -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