Compare commits

..

1 Commits

Author SHA1 Message Date
Alexander Whitestone
22e22f7b0d feat: add 3D memory graph & sovereignty loop visualization
Implements issue #18 — unified persistent memory & spatial agentic
loop visualization:

- **Memory Graph** (`createMemoryGraph`): 9 typed nodes (user/feedback/
  project/reference) as glowing pulsing spheres with labeled edges,
  floating in the left wing of the Nexus at (-14, 2.5, -8). Nodes bob
  and breathe independently; graph slowly sways on Y.

- **Sovereignty Loop** (`createSovereigntyLoop`): EXPORT → COMPRESS →
  TRAIN → EVAL ring with stage nodes, halos, and a traveling pulse
  sphere that changes color per stage. Central soul icosahedron pulses
  at the core. Group slowly rotates.

- **HUD Legend**: top-right color key panel for memory node types and
  sovereignty loop stages.

Fixes #18

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-23 21:25:54 -04:00
3 changed files with 218 additions and 211 deletions

378
app.js
View File

@@ -34,8 +34,28 @@ let debugOverlay;
let frameCount = 0, lastFPSTime = 0, fps = 0;
let chatOpen = true;
let loadProgress = 0;
let memoryNodes = [];
let sovereigntyGroup, sovereigntyPulse, sovereigntyPulseT = 0;
// Memory & Sovereignty loop state
let memoryNodeMeshes = [];
let sovereigntyGroup;
let loopPulseSphere;
let loopPulseT = 0;
// Memory node type colors
const MEMORY_COLORS = {
user: 0x4af0c0,
feedback: 0xffd700,
project: 0x7b5cff,
reference: 0x4488ff,
};
// Sovereignty loop stages
const LOOP_STAGES = [
{ label: 'EXPORT', color: 0x4af0c0, angle: Math.PI / 2 },
{ label: 'COMPRESS', color: 0xffd700, angle: 0 },
{ label: 'TRAIN', color: 0x7b5cff, angle: -Math.PI / 2 },
{ label: 'EVAL', color: 0xff4466, angle: Math.PI },
];
// ═══ INIT ═══
function init() {
@@ -79,6 +99,7 @@ function init() {
createDustParticles();
updateLoad(85);
createAmbientStructures();
updateLoad(87);
createMemoryGraph();
createSovereigntyLoop();
updateLoad(90);
@@ -794,209 +815,197 @@ function createAmbientStructures() {
// ═══ MEMORY GRAPH ═══
function createMemoryGraph() {
const group = new THREE.Group();
group.position.set(-15, 0, -10);
group.position.set(-14, 2.5, -8);
group.name = 'memory-graph';
scene.add(group);
// Memory type color map
const typeColors = {
user: 0x4af0c0,
feedback: 0xff8844,
project: 0x7b5cff,
reference: 0xffd700,
};
// Node definitions: label, type, local position
const nodeDefs = [
{ label: 'role', type: 'user', pos: [-2.5, 4, 0.5] },
{ label: 'prefs', type: 'user', pos: [-1, 5.5, -1 ] },
{ label: 'expertise', type: 'user', pos: [-3, 3, -1.5] },
{ label: 'corrections', type: 'feedback', pos: [ 1.5, 4, -0.5] },
{ label: 'confirmed', type: 'feedback', pos: [ 2.5, 5.5, 1 ] },
{ label: 'milestone', type: 'project', pos: [ 0.5, 2.5, -2 ] },
{ label: 'decisions', type: 'project', pos: [-1, 2, 1 ] },
{ label: 'gitea', type: 'reference', pos: [ 2, 2.8, 2 ] },
{ label: 'grafana', type: 'reference', pos: [-2, 5, 2 ] },
const NODES = [
{ type: 'user', label: 'Role: Sovereign Dev', pos: [ 0, 2.5, 0 ] },
{ type: 'user', label: 'Prefs: Terse replies', pos: [-1.8, 3.8, -0.8 ] },
{ type: 'feedback', label: 'No trailing summaries', pos: [ 1.8, 3.5, 0.5 ] },
{ type: 'feedback', label: 'Real DB in tests', pos: [ 2.2, 1.2, -1.2 ] },
{ type: 'project', label: 'Nexus v1 build', pos: [-2.2, 1.5, 1.2 ] },
{ type: 'project', label: 'Portal system', pos: [ 0.5, -0.2, 2.0 ] },
{ type: 'reference', label: 'Gitea API', pos: [-1.2, -0.2, -1.8 ] },
{ type: 'reference', label: 'Issue tracker', pos: [ 1.0, -1.2, -0.5 ] },
{ type: 'project', label: 'Sovereignty loop', pos: [-0.5, -1.5, 1.0 ] },
];
// Edges (index pairs)
const edges = [
[0, 1], [0, 2], [1, 2],
[3, 4],
[5, 6],
[7, 8],
[0, 3], [2, 6], [4, 5], [6, 7],
const EDGES = [
[0, 1], [0, 2], [0, 4], [0, 6],
[1, 2], [2, 3], [3, 7], [4, 5],
[5, 8], [6, 7], [7, 8], [4, 8],
];
// Create node spheres
nodeDefs.forEach((def, i) => {
const color = typeColors[def.type];
const geo = new THREE.SphereGeometry(0.22, 16, 16);
// Build node meshes
NODES.forEach((n, i) => {
const color = MEMORY_COLORS[n.type];
const geo = new THREE.SphereGeometry(0.22, 12, 12);
const mat = new THREE.MeshStandardMaterial({
color,
emissive: color,
emissiveIntensity: 1.2,
roughness: 0.2,
metalness: 0.5,
metalness: 0.6,
});
const sphere = new THREE.Mesh(geo, mat);
sphere.position.set(...def.pos);
sphere.userData = { baseY: def.pos[1], phase: i * 0.7 };
sphere.name = 'memnode_' + i;
group.add(sphere);
const mesh = new THREE.Mesh(geo, mat);
mesh.position.set(...n.pos);
mesh.name = `mem-node-${i}`;
group.add(mesh);
memoryNodeMeshes.push(mesh);
// Halo ring
const haloGeo = new THREE.TorusGeometry(0.32, 0.02, 8, 32);
const haloMat = new THREE.MeshBasicMaterial({ color, transparent: true, opacity: 0.5 });
const halo = new THREE.Mesh(haloGeo, haloMat);
halo.rotation.x = Math.PI / 2;
sphere.add(halo);
// Outer glow ring
const ringGeo = new THREE.TorusGeometry(0.35, 0.025, 6, 24);
const ringMat = new THREE.MeshBasicMaterial({ color, transparent: true, opacity: 0.45 });
const ring = new THREE.Mesh(ringGeo, ringMat);
ring.position.set(...n.pos);
ring.rotation.x = Math.PI / 2;
group.add(ring);
// Canvas label
// Label sprite
const lc = document.createElement('canvas');
lc.width = 256; lc.height = 48;
lc.width = 320; lc.height = 48;
const lctx = lc.getContext('2d');
lctx.font = 'bold 22px "JetBrains Mono", monospace';
lctx.font = '18px "JetBrains Mono", monospace';
lctx.fillStyle = '#' + new THREE.Color(color).getHexString();
lctx.textAlign = 'center';
lctx.fillText(def.label, 128, 32);
lctx.fillText(n.label, 6, 32);
const ltex = new THREE.CanvasTexture(lc);
const lmat = new THREE.MeshBasicMaterial({ map: ltex, transparent: true, side: THREE.DoubleSide, depthWrite: false });
const lmesh = new THREE.Mesh(new THREE.PlaneGeometry(1.0, 0.2), lmat);
lmesh.position.y = 0.42;
sphere.add(lmesh);
memoryNodes.push(sphere);
ltex.minFilter = THREE.LinearFilter;
const lmat = new THREE.MeshBasicMaterial({ map: ltex, transparent: true, depthWrite: false, side: THREE.DoubleSide });
const lmesh = new THREE.Mesh(new THREE.PlaneGeometry(1.4, 0.22), lmat);
lmesh.position.set(n.pos[0] + 0.85, n.pos[1], n.pos[2]);
group.add(lmesh);
});
// Create edge lines
edges.forEach(([a, b]) => {
const posA = new THREE.Vector3(...nodeDefs[a].pos);
const posB = new THREE.Vector3(...nodeDefs[b].pos);
const points = [posA, posB];
// Build edge lines
EDGES.forEach(([a, b]) => {
const pa = new THREE.Vector3(...NODES[a].pos);
const pb = new THREE.Vector3(...NODES[b].pos);
const points = [pa, pb];
const lineGeo = new THREE.BufferGeometry().setFromPoints(points);
const lineMat = new THREE.LineBasicMaterial({
color: 0x334466,
color: 0x2a3a5a,
transparent: true,
opacity: 0.4,
opacity: 0.5,
});
group.add(new THREE.Line(lineGeo, lineMat));
const line = new THREE.Line(lineGeo, lineMat);
group.add(line);
});
// Section label
const lc = document.createElement('canvas');
lc.width = 512; lc.height = 56;
const lctx = lc.getContext('2d');
lctx.font = 'bold 28px "Orbitron", sans-serif';
lctx.fillStyle = '#4af0c0';
lctx.textAlign = 'center';
lctx.fillText('◈ MEMORY BANKS', 256, 38);
const ltex = new THREE.CanvasTexture(lc);
const lmat = new THREE.MeshBasicMaterial({ map: ltex, transparent: true, side: THREE.DoubleSide });
const lmesh = new THREE.Mesh(new THREE.PlaneGeometry(4, 0.45), lmat);
lmesh.position.set(0, 7, 0);
group.add(lmesh);
// Section title label
const tc = document.createElement('canvas');
tc.width = 512; tc.height = 56;
const tctx = tc.getContext('2d');
tctx.font = 'bold 26px "Orbitron", sans-serif';
tctx.fillStyle = '#4af0c0';
tctx.textAlign = 'center';
tctx.fillText('◈ MEMORY GRAPH', 256, 38);
const ttex = new THREE.CanvasTexture(tc);
ttex.minFilter = THREE.LinearFilter;
const tmat = new THREE.MeshBasicMaterial({ map: ttex, transparent: true, depthWrite: false, side: THREE.DoubleSide });
const tmesh = new THREE.Mesh(new THREE.PlaneGeometry(4, 0.44), tmat);
tmesh.position.set(0, 5.5, 0);
group.add(tmesh);
scene.add(group);
}
// ═══ SOVEREIGNTY LOOP ═══
function createSovereigntyLoop() {
sovereigntyGroup = new THREE.Group();
sovereigntyGroup.position.set(0, 8, -22);
sovereigntyGroup.name = 'sovereignty-loop';
scene.add(sovereigntyGroup);
const group = new THREE.Group();
group.position.set(-14, 2.5, 5);
group.name = 'sovereignty-loop';
sovereigntyGroup = group;
const stages = [
{ label: 'EXPORT', color: 0x4af0c0, angle: 0 },
{ label: 'COMPRESS', color: 0x7b5cff, angle: Math.PI / 2 },
{ label: 'TRAIN', color: 0xff8844, angle: Math.PI },
{ label: 'EVAL', color: 0xffd700, angle: Math.PI * 3 / 2 },
];
const RADIUS = 2.5;
const ringRadius = 3.5;
// Orbit ring
const orbitGeo = new THREE.TorusGeometry(RADIUS, 0.04, 8, 80);
const orbitMat = new THREE.MeshBasicMaterial({ color: 0x1a2a4a, transparent: true, opacity: 0.6 });
group.add(new THREE.Mesh(orbitGeo, orbitMat));
stages.forEach((s, i) => {
const x = Math.cos(s.angle) * ringRadius;
const z = Math.sin(s.angle) * ringRadius;
// Stage nodes
LOOP_STAGES.forEach((stage) => {
const x = Math.cos(stage.angle) * RADIUS;
const z = Math.sin(stage.angle) * RADIUS;
// Stage sphere
const geo = new THREE.SphereGeometry(0.38, 20, 20);
const geo = new THREE.SphereGeometry(0.35, 14, 14);
const mat = new THREE.MeshStandardMaterial({
color: s.color,
emissive: s.color,
emissiveIntensity: 1.5,
roughness: 0.1,
color: stage.color,
emissive: stage.color,
emissiveIntensity: 1.0,
roughness: 0.15,
metalness: 0.7,
});
const sphere = new THREE.Mesh(geo, mat);
sphere.position.set(x, 0, z);
sphere.name = 'stage_' + i;
sovereigntyGroup.add(sphere);
const mesh = new THREE.Mesh(geo, mat);
mesh.position.set(x, 0, z);
group.add(mesh);
// Halo
const hgeo = new THREE.TorusGeometry(0.5, 0.03, 6, 20);
const hmat = new THREE.MeshBasicMaterial({ color: stage.color, transparent: true, opacity: 0.4 });
const halo = new THREE.Mesh(hgeo, hmat);
halo.position.set(x, 0, z);
group.add(halo);
// Stage label
const lc = document.createElement('canvas');
lc.width = 256; lc.height = 48;
lc.width = 320; lc.height = 56;
const lctx = lc.getContext('2d');
lctx.font = 'bold 24px "Orbitron", sans-serif';
lctx.fillStyle = '#' + new THREE.Color(s.color).getHexString();
lctx.font = 'bold 28px "Orbitron", sans-serif';
lctx.fillStyle = '#' + new THREE.Color(stage.color).getHexString();
lctx.textAlign = 'center';
lctx.fillText(s.label, 128, 32);
lctx.fillText(stage.label, 160, 38);
const ltex = new THREE.CanvasTexture(lc);
const lmat = new THREE.MeshBasicMaterial({ map: ltex, transparent: true, side: THREE.DoubleSide, depthWrite: false });
const lmesh = new THREE.Mesh(new THREE.PlaneGeometry(1.4, 0.26), lmat);
lmesh.position.set(x, 0.7, z);
sovereigntyGroup.add(lmesh);
// Arc segment to next stage
const nextAngle = stages[(i + 1) % stages.length].angle;
const pts = [];
const steps = 24;
for (let k = 0; k <= steps; k++) {
const a = s.angle + (nextAngle - s.angle + (i === 3 ? Math.PI * 2 : 0)) * (k / steps);
pts.push(new THREE.Vector3(Math.cos(a) * ringRadius, 0, Math.sin(a) * ringRadius));
}
const arcGeo = new THREE.BufferGeometry().setFromPoints(pts);
const arcMat = new THREE.LineBasicMaterial({ color: s.color, transparent: true, opacity: 0.35 });
sovereigntyGroup.add(new THREE.Line(arcGeo, arcMat));
ltex.minFilter = THREE.LinearFilter;
const lmat = new THREE.MeshBasicMaterial({ map: ltex, transparent: true, depthWrite: false, side: THREE.DoubleSide });
const lmesh = new THREE.Mesh(new THREE.PlaneGeometry(1.6, 0.28), lmat);
lmesh.position.set(x * 1.5, 0.7, z * 1.5);
group.add(lmesh);
});
// Central soul sphere
const soulGeo = new THREE.IcosahedronGeometry(0.55, 2);
const soulGeo = new THREE.IcosahedronGeometry(0.45, 2);
const soulMat = new THREE.MeshPhysicalMaterial({
color: 0xffffff,
emissive: 0x4af0c0,
emissiveIntensity: 2,
emissive: 0x8877ff,
emissiveIntensity: 2.5,
roughness: 0,
metalness: 1,
transmission: 0.4,
transmission: 0.3,
});
const soul = new THREE.Mesh(soulGeo, soulMat);
soul.name = 'soul-core';
sovereigntyGroup.add(soul);
soul.name = 'soul-sphere';
group.add(soul);
// Traveling pulse sphere
const pulseGeo = new THREE.SphereGeometry(0.15, 12, 12);
const pulseMat = new THREE.MeshStandardMaterial({
color: 0x4af0c0,
emissive: 0x4af0c0,
const pGeo = new THREE.SphereGeometry(0.15, 8, 8);
const pMat = new THREE.MeshStandardMaterial({
color: 0xffffff,
emissive: 0xffffff,
emissiveIntensity: 3,
roughness: 0,
metalness: 1,
});
sovereigntyPulse = new THREE.Mesh(pulseGeo, pulseMat);
sovereigntyGroup.add(sovereigntyPulse);
loopPulseSphere = new THREE.Mesh(pGeo, pMat);
loopPulseSphere.name = 'loop-pulse';
group.add(loopPulseSphere);
// Section label
const lc = document.createElement('canvas');
lc.width = 640; lc.height = 56;
const lctx = lc.getContext('2d');
lctx.font = 'bold 28px "Orbitron", sans-serif';
lctx.fillStyle = '#ffd700';
lctx.textAlign = 'center';
lctx.fillText('◈ SOVEREIGNTY LOOP', 320, 38);
const ltex = new THREE.CanvasTexture(lc);
const lmat = new THREE.MeshBasicMaterial({ map: ltex, transparent: true, side: THREE.DoubleSide });
const lmesh = new THREE.Mesh(new THREE.PlaneGeometry(5, 0.45), lmat);
lmesh.position.set(0, 2.2, 0);
sovereigntyGroup.add(lmesh);
// Section title
const tc = document.createElement('canvas');
tc.width = 512; tc.height = 56;
const tctx = tc.getContext('2d');
tctx.font = 'bold 24px "Orbitron", sans-serif';
tctx.fillStyle = '#7b5cff';
tctx.textAlign = 'center';
tctx.fillText('◈ SOVEREIGNTY LOOP', 256, 38);
const ttex = new THREE.CanvasTexture(tc);
ttex.minFilter = THREE.LinearFilter;
const tmat = new THREE.MeshBasicMaterial({ map: ttex, transparent: true, depthWrite: false, side: THREE.DoubleSide });
const tmesh = new THREE.Mesh(new THREE.PlaneGeometry(4, 0.44), tmat);
tmesh.position.set(0, 4, 0);
group.add(tmesh);
scene.add(group);
}
// ═══ CONTROLS ═══
@@ -1153,47 +1162,6 @@ function gameLoop() {
}
}
// Animate memory nodes (float & pulse)
memoryNodes.forEach((node, i) => {
node.position.y = node.userData.baseY + Math.sin(elapsed * 0.9 + node.userData.phase) * 0.25;
node.material.emissiveIntensity = 1.0 + Math.sin(elapsed * 1.5 + node.userData.phase) * 0.4;
});
// Animate sovereignty loop
if (sovereigntyGroup) {
sovereigntyGroup.rotation.y = elapsed * 0.12;
// Soul core pulse
const soul = scene.getObjectByName('soul-core');
if (soul) {
soul.rotation.y = elapsed * 0.8;
soul.rotation.x = elapsed * 0.4;
soul.material.emissiveIntensity = 1.5 + Math.sin(elapsed * 2.5) * 0.5;
}
// Traveling pulse around the ring
if (sovereigntyPulse) {
sovereigntyPulseT = (elapsed * 0.4) % 1; // full lap period ~2.5s
const stageAngles = [0, Math.PI / 2, Math.PI, Math.PI * 3 / 2];
const stageColors = [0x4af0c0, 0x7b5cff, 0xff8844, 0xffd700];
const seg = sovereigntyPulseT * 4;
const segIdx = Math.floor(seg) % 4;
const segT = seg - Math.floor(seg);
const aFrom = stageAngles[segIdx];
const aTo = stageAngles[(segIdx + 1) % 4];
// handle wrap-around from EVAL (270°) back to EXPORT (0°/360°)
let diff = aTo - aFrom;
if (diff < 0) diff += Math.PI * 2;
const angle = aFrom + diff * segT;
const ringRadius = 3.5;
sovereigntyPulse.position.set(Math.cos(angle) * ringRadius, 0, Math.sin(angle) * ringRadius);
const col = new THREE.Color(stageColors[segIdx]);
sovereigntyPulse.material.color.copy(col);
sovereigntyPulse.material.emissive.copy(col);
sovereigntyPulse.material.emissiveIntensity = 2.5 + Math.sin(elapsed * 8) * 0.5;
}
}
// Animate nexus core
const core = scene.getObjectByName('nexus-core');
if (core) {
@@ -1203,6 +1171,40 @@ function gameLoop() {
core.material.emissiveIntensity = 1.5 + Math.sin(elapsed * 2) * 0.5;
}
// Animate memory graph — pulse nodes and slow float
memoryNodeMeshes.forEach((mesh, i) => {
mesh.material.emissiveIntensity = 0.8 + 0.6 * Math.sin(elapsed * 1.5 + i * 0.8);
mesh.position.y += Math.sin(elapsed * 0.9 + i * 1.1) * 0.003;
mesh.rotation.y = elapsed * 0.4 + i;
});
const memGraph = scene.getObjectByName('memory-graph');
if (memGraph) {
memGraph.rotation.y = Math.sin(elapsed * 0.15) * 0.2;
}
// Animate sovereignty loop
const soul = scene.getObjectByName('soul-sphere');
if (soul) {
soul.rotation.y = elapsed * 0.8;
soul.rotation.x = elapsed * 0.4;
soul.material.emissiveIntensity = 2 + Math.sin(elapsed * 2.5) * 0.8;
}
if (sovereigntyGroup) {
sovereigntyGroup.rotation.y = elapsed * 0.12;
}
// Traveling pulse
if (loopPulseSphere) {
loopPulseT = (elapsed * 0.4) % 1;
const angle = loopPulseT * Math.PI * 2;
const RADIUS = 2.5;
loopPulseSphere.position.x = Math.cos(angle) * RADIUS;
loopPulseSphere.position.z = Math.sin(angle) * RADIUS;
// Color matches current stage
const stageIdx = Math.floor(loopPulseT * 4) % 4;
loopPulseSphere.material.emissive.setHex(LOOP_STAGES[stageIdx].color);
loopPulseSphere.material.color.setHex(LOOP_STAGES[stageIdx].color);
}
// Render
composer.render();

View File

@@ -95,18 +95,18 @@
</div>
</div>
<!-- Memory legend -->
<!-- Memory type legend (top-right) -->
<div class="hud-memory-legend">
<div class="legend-title">MEMORY BANKS</div>
<div class="legend-row"><span class="legend-dot" style="background:#4af0c0"></span>USER</div>
<div class="legend-row"><span class="legend-dot" style="background:#ff8844"></span>FEEDBACK</div>
<div class="legend-row"><span class="legend-dot" style="background:#7b5cff"></span>PROJECT</div>
<div class="legend-row"><span class="legend-dot" style="background:#ffd700"></span>REFERENCE</div>
<div class="legend-title" style="margin-top:8px">SOVEREIGNTY LOOP</div>
<div class="legend-row"><span class="legend-dot" style="background:#4af0c0"></span>EXPORT</div>
<div class="legend-row"><span class="legend-dot" style="background:#7b5cff"></span>COMPRESS</div>
<div class="legend-row"><span class="legend-dot" style="background:#ff8844"></span>TRAIN</div>
<div class="legend-row"><span class="legend-dot" style="background:#ffd700"></span>EVAL</div>
<div class="legend-title">MEMORY NODES</div>
<div class="legend-item"><span class="legend-dot" style="background:#4af0c0;"></span> User</div>
<div class="legend-item"><span class="legend-dot" style="background:#ffd700;"></span> Feedback</div>
<div class="legend-item"><span class="legend-dot" style="background:#7b5cff;"></span> Project</div>
<div class="legend-item"><span class="legend-dot" style="background:#4488ff;"></span> Reference</div>
<div class="legend-title" style="margin-top:8px;">SOVEREIGNTY LOOP</div>
<div class="legend-item"><span class="legend-dot" style="background:#4af0c0;"></span> EXPORT</div>
<div class="legend-item"><span class="legend-dot" style="background:#ffd700;"></span> COMPRESS</div>
<div class="legend-item"><span class="legend-dot" style="background:#7b5cff;"></span> TRAIN</div>
<div class="legend-item"><span class="legend-dot" style="background:#ff4466;"></span> EVAL</div>
</div>
<!-- Minimap / Controls hint -->

View File

@@ -363,19 +363,22 @@ canvas#nexus-canvas {
/* === MEMORY LEGEND === */
.hud-memory-legend {
position: fixed;
top: 80px;
top: var(--space-4);
right: var(--space-4);
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--panel-radius);
padding: var(--space-3);
backdrop-filter: blur(var(--panel-blur));
min-width: 140px;
font-size: var(--text-xs);
padding: var(--space-3) var(--space-4);
font-family: var(--font-body);
font-size: var(--text-xs);
color: var(--color-text-muted);
backdrop-filter: blur(var(--panel-blur));
pointer-events: none;
z-index: 100;
min-width: 140px;
}
.legend-title {
.hud-memory-legend .legend-title {
font-family: var(--font-display);
font-size: 9px;
letter-spacing: 0.1em;
@@ -383,18 +386,20 @@ canvas#nexus-canvas {
margin-bottom: var(--space-1);
text-transform: uppercase;
}
.legend-row {
.hud-memory-legend .legend-item {
display: flex;
align-items: center;
gap: var(--space-2);
margin: 3px 0;
font-size: var(--text-xs);
letter-spacing: 0.05em;
line-height: 1.8;
color: var(--color-text);
}
.legend-dot {
.hud-memory-legend .legend-dot {
display: inline-block;
width: 8px;
height: 8px;
border-radius: 50%;
flex-shrink: 0;
box-shadow: 0 0 6px currentColor;
box-shadow: 0 0 4px currentColor;
}