diff --git a/index.html b/index.html index 88753a5..f989583 100644 --- a/index.html +++ b/index.html @@ -47,9 +47,25 @@ + +
+
+
+
+
PORTAL ATLAS
+
Every place Timmy can drop into
+
+ +
+
+
+
+
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