Compare commits

..

1 Commits

Author SHA1 Message Date
Alexander Whitestone
2a7dbef0c9 feat: session power meter — 3D balance visualizer
Implements issue #16: visual 3D representation of session
resources/balance tied to subscription credits.

**3D scene object** placed at (-9, 0, 5) left of center:
- Glass cylinder housing (MeshPhysicalMaterial, transparent)
- Animated energy fill via custom GLSL shader — clips fragments
  by UV.y against uFill, teal→purple gradient, scan-line ripple,
  bright edge band at fill level
- Switches to red/orange palette when power drops below 20%
- Floating spinning orb that tracks fill height with subtle bob
- 3 accent rings that dim when above the fill level
- Dynamic PointLight tracks orb position and intensity
- Canvas label: SESSION POWER / Fund once · Ask many

**HUD panel** (top-right corner):
- Live percentage + gradient bar with glowing tip marker
- Credits counter (out of 10,000)
- SOVEREIGN tier badge
- Fund once · Ask many models tagline
- Low-power warning state (<20%) with red bar, pulsing border, and
  flashing warning text

Session power drains slowly over time (~1% per 5 seconds) to demo
the live visualization. Ready to wire to a real credits/subscription API.

Fixes #16

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

344
app.js
View File

@@ -34,14 +34,8 @@ let debugOverlay;
let frameCount = 0, lastFPSTime = 0, fps = 0;
let chatOpen = true;
let loadProgress = 0;
// Session power meter
let sessionPower = 0.72; // 0.0 1.0
const SESSION_MAX_CREDITS = 10000;
let powerMeterGroup = null;
let powerFillMat = null;
let powerOrbMesh = null;
let powerRingMats = [];
let sessionPower = 1.0; // 0.01.0
let sessionPowerMeter = null; // { group, fillMat, orbMat, ringMats, pointLight }
// ═══ INIT ═══
function init() {
@@ -85,8 +79,7 @@ function init() {
createDustParticles();
updateLoad(85);
createAmbientStructures();
updateLoad(88);
createPowerMeter();
createSessionPowerMeter();
updateLoad(90);
// Post-processing
@@ -798,55 +791,52 @@ function createAmbientStructures() {
}
// ═══ SESSION POWER METER (3D) ═══
function createPowerMeter() {
const CONTAINER_H = 5.0;
const CONTAINER_Y = 0.5; // bottom of container above floor
function createSessionPowerMeter() {
const group = new THREE.Group();
group.position.set(-9, 0, 5);
group.name = 'session-power-meter';
powerMeterGroup = new THREE.Group();
powerMeterGroup.position.set(-9, 0, 5);
powerMeterGroup.rotation.y = 0.4;
powerMeterGroup.name = 'power-meter';
const METER_HEIGHT = 4;
const METER_RADIUS = 0.35;
// Base pedestal
const baseGeo = new THREE.CylinderGeometry(0.9, 1.1, 0.5, 8);
const baseMat = new THREE.MeshStandardMaterial({
color: 0x0a0f1a,
roughness: 0.4,
metalness: 0.9,
emissive: 0x1a2a4a,
emissiveIntensity: 0.3,
});
const baseMesh = new THREE.Mesh(baseGeo, baseMat);
baseMesh.position.y = 0.25;
baseMesh.castShadow = true;
powerMeterGroup.add(baseMesh);
// Glass outer tube (open-ended cylinder)
const glassGeo = new THREE.CylinderGeometry(0.62, 0.62, CONTAINER_H, 32, 1, true);
// Outer glass housing
const glassGeo = new THREE.CylinderGeometry(METER_RADIUS + 0.06, METER_RADIUS + 0.06, METER_HEIGHT, 32, 1, true);
const glassMat = new THREE.MeshPhysicalMaterial({
color: NEXUS.colors.primary,
color: 0x88ddff,
transparent: true,
opacity: 0.07,
opacity: 0.18,
roughness: 0,
metalness: 0,
side: THREE.DoubleSide,
depthWrite: false,
});
const glassMesh = new THREE.Mesh(glassGeo, glassMat);
glassMesh.position.y = CONTAINER_Y + CONTAINER_H / 2;
powerMeterGroup.add(glassMesh);
const glass = new THREE.Mesh(glassGeo, glassMat);
glass.position.y = METER_HEIGHT / 2;
group.add(glass);
// Energy fill (full cylinder; shader clips to power level by UV.y)
const fillGeo = new THREE.CylinderGeometry(0.48, 0.48, CONTAINER_H, 32, 32, false);
powerFillMat = new THREE.ShaderMaterial({
// Glass cap top
const capGeo = new THREE.CircleGeometry(METER_RADIUS + 0.06, 32);
const capMat = new THREE.MeshPhysicalMaterial({ color: 0x88ddff, transparent: true, opacity: 0.22, roughness: 0 });
const capTop = new THREE.Mesh(capGeo, capMat);
capTop.rotation.x = -Math.PI / 2;
capTop.position.y = METER_HEIGHT;
group.add(capTop);
const capBot = new THREE.Mesh(capGeo, capMat.clone());
capBot.rotation.x = Math.PI / 2;
capBot.position.y = 0;
group.add(capBot);
// Animated energy fill — clips by UV.y against uFill
const fillGeo = new THREE.CylinderGeometry(METER_RADIUS, METER_RADIUS, METER_HEIGHT, 32, 64, false);
const fillMat = new THREE.ShaderMaterial({
transparent: true,
depthWrite: false,
blending: THREE.AdditiveBlending,
side: THREE.DoubleSide,
uniforms: {
uTime: { value: 0 },
uPower: { value: sessionPower },
uColorA: { value: new THREE.Color(NEXUS.colors.primary) },
uColorB: { value: new THREE.Color(NEXUS.colors.secondary) },
uFill: { value: 1.0 },
uColorA: { value: new THREE.Color(0x4af0c0) },
uColorB: { value: new THREE.Color(0x7b5cff) },
},
vertexShader: `
varying vec2 vUv;
@@ -857,113 +847,104 @@ function createPowerMeter() {
`,
fragmentShader: `
uniform float uTime;
uniform float uPower;
uniform float uFill;
uniform vec3 uColorA;
uniform vec3 uColorB;
varying vec2 vUv;
void main() {
// Discard fragments above the current power level
if (vUv.y > uPower) discard;
if (vUv.y > uFill) discard;
// Color: teal at bottom, purple at top of fill
float t = (uPower > 0.001) ? vUv.y / uPower : 0.0;
vec3 col = mix(uColorA, uColorB, t);
// Gradient from bottom (colorA) to top (colorB)
vec3 col = mix(uColorA, uColorB, vUv.y / max(uFill, 0.01));
// Scan lines
float scan = pow(sin(vUv.y * 60.0 - uTime * 4.0) * 0.5 + 0.5, 8.0);
// Scan lines rippling upward
float scan = sin(vUv.y * 40.0 - uTime * 3.0) * 0.5 + 0.5;
col += col * scan * 0.25;
// Pulse brightness
float pulse = 0.65 + 0.35 * sin(uTime * 2.5);
// Bright band at the fill edge
float edge = smoothstep(0.0, 0.04, uFill - vUv.y);
float glow = (1.0 - edge) * 2.0;
col += col * glow;
// Bright band at top of fill
float topBand = smoothstep(0.06, 0.0, uPower - vUv.y);
// Pulse
float pulse = 0.75 + 0.25 * sin(uTime * 2.5);
float alpha = (0.55 + 0.2 * scan) * pulse;
float alpha = (0.45 + scan * 0.25 + topBand * 0.5) * pulse;
gl_FragColor = vec4(col + topBand * 0.4, alpha);
gl_FragColor = vec4(col, alpha);
}
`,
});
const fillMesh = new THREE.Mesh(fillGeo, powerFillMat);
fillMesh.position.y = CONTAINER_Y + CONTAINER_H / 2;
fillMesh.name = 'power-fill';
powerMeterGroup.add(fillMesh);
const fill = new THREE.Mesh(fillGeo, fillMat);
fill.position.y = METER_HEIGHT / 2;
group.add(fill);
// Bottom cap ring
const capRingGeo = new THREE.TorusGeometry(0.65, 0.04, 8, 32);
const capRingMat = new THREE.MeshStandardMaterial({
color: NEXUS.colors.primary,
emissive: NEXUS.colors.primary,
emissiveIntensity: 0.8,
roughness: 0.2,
metalness: 0.8,
});
const botRing = new THREE.Mesh(capRingGeo, capRingMat);
botRing.position.y = CONTAINER_Y;
botRing.rotation.x = Math.PI / 2;
powerMeterGroup.add(botRing);
// Top cap ring
const topRing = new THREE.Mesh(capRingGeo.clone(), capRingMat.clone());
topRing.position.y = CONTAINER_Y + CONTAINER_H;
topRing.rotation.x = Math.PI / 2;
powerMeterGroup.add(topRing);
// Three spinning accent rings at varying heights
const ringHeights = [0.25, 0.5, 0.75];
ringHeights.forEach((frac, i) => {
const rGeo = new THREE.TorusGeometry(0.75, 0.02, 6, 24);
const rMat = new THREE.MeshBasicMaterial({
color: NEXUS.colors.secondary,
// Accent rings
const ringMats = [];
for (let i = 0; i < 3; i++) {
const ringGeo = new THREE.TorusGeometry(METER_RADIUS + 0.09, 0.025, 8, 32);
const ringMat = new THREE.MeshBasicMaterial({
color: i === 0 ? 0x4af0c0 : i === 1 ? 0x7b5cff : 0x4af0c0,
transparent: true,
opacity: 0.5,
opacity: 0.8,
});
powerRingMats.push(rMat);
const ring = new THREE.Mesh(rGeo, rMat);
ring.position.y = CONTAINER_Y + CONTAINER_H * frac;
const ring = new THREE.Mesh(ringGeo, ringMat);
ring.position.y = METER_HEIGHT * (0.25 + i * 0.25);
ring.rotation.x = Math.PI / 2;
ring.name = `power-ring-${i}`;
powerMeterGroup.add(ring);
});
ring.name = 'spm-ring-' + i;
group.add(ring);
ringMats.push({ mat: ringMat, baseY: METER_HEIGHT * (0.25 + i * 0.25) });
}
// Floating orb at top of fill level
const orbGeo = new THREE.IcosahedronGeometry(0.22, 2);
const orbMat = new THREE.MeshPhysicalMaterial({
color: NEXUS.colors.primary,
emissive: NEXUS.colors.primary,
// Floating indicator orb at fill level
const orbGeo = new THREE.SphereGeometry(0.12, 16, 16);
const orbMat = new THREE.MeshStandardMaterial({
color: 0x4af0c0,
emissive: 0x4af0c0,
emissiveIntensity: 3,
roughness: 0,
metalness: 1,
});
powerOrbMesh = new THREE.Mesh(orbGeo, orbMat);
powerOrbMesh.position.y = CONTAINER_Y + CONTAINER_H * sessionPower;
powerOrbMesh.name = 'power-orb';
powerMeterGroup.add(powerOrbMesh);
const orb = new THREE.Mesh(orbGeo, orbMat);
orb.name = 'spm-orb';
orb.position.y = METER_HEIGHT; // starts at top, updated in loop
group.add(orb);
// Floating label
const lblCanvas = document.createElement('canvas');
lblCanvas.width = 256;
lblCanvas.height = 80;
const lctx = lblCanvas.getContext('2d');
lctx.font = 'bold 20px "Orbitron", sans-serif';
// Dynamic point light that tracks the orb
const powerLight = new THREE.PointLight(0x4af0c0, 2.5, 8, 1.8);
powerLight.position.set(0, METER_HEIGHT, 0);
powerLight.name = 'spm-light';
group.add(powerLight);
// Canvas label
const labelCanvas = document.createElement('canvas');
labelCanvas.width = 512;
labelCanvas.height = 80;
const lctx = labelCanvas.getContext('2d');
lctx.font = 'bold 28px "Orbitron", sans-serif';
lctx.fillStyle = '#4af0c0';
lctx.textAlign = 'center';
lctx.fillText('SESSION POWER', 128, 26);
lctx.font = '14px "JetBrains Mono", monospace';
lctx.fillStyle = '#7b5cff';
lctx.fillText('Fund once · Ask many', 128, 52);
const lblTex = new THREE.CanvasTexture(lblCanvas);
const lblMat = new THREE.MeshBasicMaterial({ map: lblTex, transparent: true, side: THREE.DoubleSide, depthWrite: false });
const lblMesh = new THREE.Mesh(new THREE.PlaneGeometry(2.2, 0.7), lblMat);
lblMesh.position.y = CONTAINER_Y + CONTAINER_H + 1.1;
powerMeterGroup.add(lblMesh);
lctx.fillText('SESSION POWER', 256, 32);
lctx.font = '18px "JetBrains Mono", monospace';
lctx.fillStyle = '#5a8888';
lctx.fillText('Fund once · Ask many', 256, 60);
const labelTex = new THREE.CanvasTexture(labelCanvas);
const labelMat = new THREE.MeshBasicMaterial({ map: labelTex, transparent: true, side: THREE.DoubleSide, depthWrite: false });
const labelMesh = new THREE.Mesh(new THREE.PlaneGeometry(3, 0.5), labelMat);
labelMesh.position.y = METER_HEIGHT + 0.9;
group.add(labelMesh);
// Point light that reflects power intensity
const powerLight = new THREE.PointLight(NEXUS.colors.primary, 1.5, 12, 1.8);
powerLight.position.y = CONTAINER_Y + CONTAINER_H / 2;
powerLight.name = 'power-light';
powerMeterGroup.add(powerLight);
// Base pedestal
const baseGeo = new THREE.CylinderGeometry(0.55, 0.7, 0.25, 8);
const baseMat = new THREE.MeshStandardMaterial({ color: 0x0a0f1a, roughness: 0.4, metalness: 0.9, emissive: 0x0a1a2a, emissiveIntensity: 0.4 });
const base = new THREE.Mesh(baseGeo, baseMat);
base.position.y = -0.12;
base.castShadow = true;
group.add(base);
scene.add(powerMeterGroup);
scene.add(group);
sessionPowerMeter = { group, fillMat, orbMat, ringMats, powerLight, orb, METER_HEIGHT };
}
// ═══ CONTROLS ═══
@@ -1120,59 +1101,6 @@ function gameLoop() {
}
}
// Animate session power meter
if (powerMeterGroup) {
// Very slow drain — simulates session usage (0.5% per minute ≈ 0.000008/frame at 60fps)
sessionPower = Math.max(0, sessionPower - 0.000008 * delta * 60);
// Update fill shader
if (powerFillMat?.uniforms) {
powerFillMat.uniforms.uTime.value = elapsed;
powerFillMat.uniforms.uPower.value = sessionPower;
}
// Float orb at top of fill
if (powerOrbMesh) {
const CONTAINER_Y = 0.5, CONTAINER_H = 5.0;
powerOrbMesh.position.y = CONTAINER_Y + CONTAINER_H * sessionPower + Math.sin(elapsed * 3.0) * 0.08;
powerOrbMesh.rotation.y = elapsed * 1.5;
powerOrbMesh.material.emissiveIntensity = 2.5 + Math.sin(elapsed * 2.5) * 0.8;
}
// Spin accent rings
for (let i = 0; i < 3; i++) {
const ring = powerMeterGroup.getObjectByName(`power-ring-${i}`);
if (ring) {
ring.rotation.y = elapsed * (0.4 + i * 0.15) * (i % 2 === 0 ? 1 : -1);
// Fade rings that are above current power level
const CONTAINER_Y = 0.5, CONTAINER_H = 5.0;
const ringFrac = [0.25, 0.5, 0.75][i];
const aboveFill = ringFrac > sessionPower;
if (powerRingMats[i]) {
powerRingMats[i].opacity = aboveFill ? 0.12 : 0.5;
}
}
}
// Power light tracks intensity
const pLight = powerMeterGroup.getObjectByName('power-light');
if (pLight) {
pLight.intensity = (0.8 + Math.sin(elapsed * 2.0) * 0.3) * Math.max(0.1, sessionPower);
}
// Update HUD
const pct = Math.round(sessionPower * 100);
const credits = Math.round(sessionPower * SESSION_MAX_CREDITS).toLocaleString();
const pctEl = document.getElementById('power-pct');
const fillEl = document.getElementById('power-bar-fill');
const credEl = document.getElementById('power-credits');
const panel = document.getElementById('power-panel');
if (pctEl) pctEl.textContent = pct + '%';
if (fillEl) fillEl.style.width = pct + '%';
if (credEl) credEl.textContent = credits;
if (panel) panel.classList.toggle('low-power', sessionPower < 0.2);
}
// Animate nexus core
const core = scene.getObjectByName('nexus-core');
if (core) {
@@ -1182,6 +1110,64 @@ function gameLoop() {
core.material.emissiveIntensity = 1.5 + Math.sin(elapsed * 2) * 0.5;
}
// Animate session power meter
if (sessionPowerMeter) {
const { fillMat, orbMat, ringMats, powerLight, orb, METER_HEIGHT } = sessionPowerMeter;
// Slowly drain power over time (demo — ~1% per 5 seconds)
sessionPower = Math.max(0, sessionPower - delta * 0.002);
// Update 3D fill shader
if (fillMat.uniforms) {
fillMat.uniforms.uTime.value = elapsed;
fillMat.uniforms.uFill.value = sessionPower;
// Switch to danger colors at low power
if (sessionPower < 0.2) {
fillMat.uniforms.uColorA.value.set(0xff4466);
fillMat.uniforms.uColorB.value.set(0xff8844);
} else {
fillMat.uniforms.uColorA.value.set(0x4af0c0);
fillMat.uniforms.uColorB.value.set(0x7b5cff);
}
}
// Orb tracks fill height + subtle bob
const orbY = sessionPower * METER_HEIGHT + Math.sin(elapsed * 3) * 0.06;
orb.position.y = orbY;
orb.rotation.y = elapsed * 2;
powerLight.position.y = orbY;
powerLight.intensity = 1.5 + sessionPower + Math.sin(elapsed * 3) * 0.4;
powerLight.color.set(sessionPower < 0.2 ? 0xff4466 : 0x4af0c0);
// Dim rings above fill level
ringMats.forEach(({ mat, baseY }) => {
const ringNorm = baseY / METER_HEIGHT;
mat.opacity = ringNorm <= sessionPower ? 0.8 : 0.15;
});
// Update HUD
const hudEl = document.getElementById('session-power-hud');
const fill = document.getElementById('spm-fill');
const tip = document.getElementById('spm-tip');
const pct = document.getElementById('spm-pct');
const cred = document.getElementById('spm-credits');
const warn = document.getElementById('spm-warn');
if (hudEl && fill && pct) {
const pctVal = Math.round(sessionPower * 100);
fill.style.width = pctVal + '%';
pct.textContent = pctVal + '%';
if (cred) {
const credVal = Math.round(sessionPower * 10000).toLocaleString();
cred.textContent = credVal + ' / 10,000';
}
const lowPower = sessionPower < 0.2;
hudEl.classList.toggle('low-power', lowPower);
if (warn) warn.style.display = lowPower ? 'block' : 'none';
// Tip position tracks fill percentage
if (tip) tip.style.right = (100 - pctVal) + '%';
}
}
// Render
composer.render();

View File

@@ -96,26 +96,27 @@
</div>
<!-- Session Power Meter HUD -->
<div id="power-panel" class="power-panel">
<div class="power-panel-header">
<span class="power-icon"></span>
<span class="power-title">SESSION POWER</span>
<span id="power-pct" class="power-pct">72%</span>
<div id="session-power-hud" class="session-power-hud">
<div class="spm-header">
<span class="spm-icon"></span>
<span class="spm-title">SESSION POWER</span>
</div>
<div class="power-bar-track">
<div id="power-bar-fill" class="power-bar-fill"></div>
</div>
<div class="power-stats">
<div class="power-stat">
<span class="power-stat-label">CREDITS</span>
<span id="power-credits" class="power-stat-value">7,200</span>
</div>
<div class="power-stat">
<span class="power-stat-label">TIER</span>
<span class="power-stat-value power-tier-badge">SOVEREIGN</span>
<div class="spm-bar-wrap">
<div id="spm-bar" class="spm-bar">
<div id="spm-fill" class="spm-fill"></div>
<div id="spm-tip" class="spm-tip"></div>
</div>
<span id="spm-pct" class="spm-pct">100%</span>
</div>
<div class="power-tagline">Fund once · Ask many models</div>
<div class="spm-credits-row">
<span class="spm-label">Credits</span>
<span id="spm-credits" class="spm-credits-val">10,000 / 10,000</span>
</div>
<div class="spm-tier-row">
<span id="spm-tier" class="spm-tier-badge">SOVEREIGN</span>
</div>
<div class="spm-tagline">Fund once · Ask many models</div>
<div id="spm-warn" class="spm-warn" style="display:none;">⚠ LOW POWER — refill to continue</div>
</div>
<!-- Minimap / Controls hint -->

193
style.css
View File

@@ -348,129 +348,144 @@ canvas#nexus-canvas {
color: var(--color-primary);
}
/* === SESSION POWER PANEL === */
.power-panel {
/* === SESSION POWER METER HUD === */
.session-power-hud {
position: absolute;
top: var(--space-4);
right: var(--space-4);
width: 220px;
background: var(--color-surface);
backdrop-filter: blur(var(--panel-blur));
border: 1px solid rgba(74, 240, 192, 0.25);
border: 1px solid var(--color-border);
border-radius: var(--panel-radius);
padding: var(--space-3) var(--space-4);
pointer-events: none;
display: flex;
flex-direction: column;
gap: var(--space-2);
transition: border-color var(--transition-ui);
}
.power-panel-header {
.session-power-hud.low-power {
border-color: rgba(255, 68, 102, 0.5);
animation: spm-warn-pulse 1.5s ease-in-out infinite;
}
@keyframes spm-warn-pulse {
0%, 100% { box-shadow: 0 0 0 0 rgba(255, 68, 102, 0); }
50% { box-shadow: 0 0 12px 2px rgba(255, 68, 102, 0.25); }
}
.spm-header {
display: flex;
align-items: center;
gap: var(--space-2);
margin-bottom: var(--space-2);
}
.spm-icon {
font-size: 14px;
animation: spm-icon-pulse 2s ease-in-out infinite;
}
@keyframes spm-icon-pulse {
0%, 100% { opacity: 0.6; }
50% { opacity: 1; }
}
.spm-title {
font-family: var(--font-display);
font-size: 10px;
letter-spacing: 0.12em;
font-size: var(--text-xs);
font-weight: 600;
letter-spacing: 0.12em;
color: var(--color-primary);
}
.power-icon {
font-size: 12px;
animation: power-pulse 2s ease-in-out infinite;
.spm-bar-wrap {
display: flex;
align-items: center;
gap: var(--space-2);
margin-bottom: var(--space-2);
}
@keyframes power-pulse {
0%, 100% { opacity: 0.7; text-shadow: 0 0 6px var(--color-primary); }
50% { opacity: 1; text-shadow: 0 0 14px var(--color-primary), 0 0 24px var(--color-primary); }
}
.power-title {
.spm-bar {
flex: 1;
color: var(--color-text-bright);
}
.power-pct {
font-size: 11px;
color: var(--color-primary);
font-weight: 700;
font-variant-numeric: tabular-nums;
}
.power-bar-track {
height: 4px;
background: rgba(74, 240, 192, 0.1);
border-radius: 2px;
overflow: hidden;
height: 8px;
background: rgba(74, 240, 192, 0.08);
border: 1px solid rgba(74, 240, 192, 0.2);
border-radius: 4px;
overflow: visible;
position: relative;
}
.power-bar-fill {
.spm-fill {
height: 100%;
width: 72%;
border-radius: 2px;
width: 100%;
background: linear-gradient(90deg, var(--color-primary), var(--color-secondary));
box-shadow: 0 0 8px rgba(74, 240, 192, 0.6);
transition: width 0.5s ease, background 0.5s ease;
position: relative;
border-radius: 4px;
transition: width 0.4s ease, background 0.4s ease;
}
.power-bar-fill::after {
content: '';
.session-power-hud.low-power .spm-fill {
background: linear-gradient(90deg, var(--color-danger), #ff8844);
}
.spm-tip {
position: absolute;
right: 0;
top: 0;
width: 4px;
height: 100%;
background: white;
opacity: 0.5;
border-radius: 2px;
animation: fill-tip 1.5s ease-in-out infinite;
top: 50%;
transform: translate(50%, -50%);
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--color-primary);
box-shadow: 0 0 8px var(--color-primary);
transition: background 0.4s ease, box-shadow 0.4s ease;
}
@keyframes fill-tip {
0%, 100% { opacity: 0.3; }
50% { opacity: 0.8; }
.session-power-hud.low-power .spm-tip {
background: var(--color-danger);
box-shadow: 0 0 8px var(--color-danger);
}
.power-stats {
display: flex;
gap: var(--space-2);
justify-content: space-between;
}
.power-stat {
display: flex;
flex-direction: column;
gap: 2px;
}
.power-stat-label {
font-size: 9px;
letter-spacing: 0.1em;
color: var(--color-text-muted);
.spm-pct {
font-family: var(--font-display);
}
.power-stat-value {
font-size: var(--text-xs);
color: var(--color-text-bright);
font-weight: 600;
font-variant-numeric: tabular-nums;
font-weight: 700;
color: var(--color-primary);
min-width: 36px;
text-align: right;
transition: color 0.4s ease;
}
.power-tier-badge {
color: var(--color-gold) !important;
text-shadow: 0 0 8px rgba(255, 215, 0, 0.4);
}
.power-tagline {
font-size: 9px;
color: var(--color-text-muted);
letter-spacing: 0.05em;
text-align: center;
border-top: 1px solid rgba(74, 240, 192, 0.1);
padding-top: var(--space-1);
}
/* Low power warning state */
.power-panel.low-power .power-bar-fill {
background: linear-gradient(90deg, var(--color-danger), #ff8844);
box-shadow: 0 0 8px rgba(255, 68, 102, 0.6);
}
.power-panel.low-power .power-pct {
.session-power-hud.low-power .spm-pct {
color: var(--color-danger);
}
.power-panel.low-power .power-icon {
animation: power-warn 0.8s ease-in-out infinite;
.spm-credits-row {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: var(--space-1);
}
@keyframes power-warn {
0%, 100% { opacity: 0.4; }
.spm-label {
font-size: var(--text-xs);
color: var(--color-text-muted);
}
.spm-credits-val {
font-size: var(--text-xs);
color: var(--color-text);
font-variant-numeric: tabular-nums;
}
.spm-tier-row {
margin-bottom: var(--space-2);
}
.spm-tier-badge {
font-family: var(--font-display);
font-size: 9px;
letter-spacing: 0.15em;
font-weight: 700;
color: var(--color-gold);
background: rgba(255, 215, 0, 0.08);
border: 1px solid rgba(255, 215, 0, 0.25);
border-radius: 3px;
padding: 2px 6px;
}
.spm-tagline {
font-size: var(--text-xs);
color: var(--color-text-muted);
letter-spacing: 0.05em;
}
.spm-warn {
margin-top: var(--space-2);
font-size: var(--text-xs);
color: var(--color-danger);
animation: spm-warn-text 1.2s ease-in-out infinite;
}
@keyframes spm-warn-text {
0%, 100% { opacity: 0.5; }
50% { opacity: 1; }
}