Compare commits

..

1 Commits

Author SHA1 Message Date
Alexander Whitestone
d2706b18c2 feat: session power meter — 3D balance visualizer
Adds the session power meter as both a 3D in-scene object and a HUD overlay.

3D scene object (positioned at -9, 0, 4 — visible from spawn):
- Vertical glass cylinder with GLSL energy-fill shader that clips
  fragments above the current power level
- Teal-to-purple gradient fill; turns red when power < 20%
- Animated scan lines, bright band at fill edge, pulse glow
- Floating orb that tracks fill height with gentle bob animation
- Dynamic point light attached to orb, color-shifts with power state
- 3 decorative spinning rings that dim above the fill level
- Canvas label: SESSION POWER / Fund once · Ask many

HUD panel (top-right corner):
- Live percentage + gradient bar with glowing tip indicator
- Credits counter (out of 10,000 CR)
- SOVEREIGN model tier badge
- Low-power warning (< 20%) with flashing icon
- "Fund once · Ask many models" tagline

Session power drains slowly over time (~16 min full drain) as a live
demo. 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:23:20 -04:00
3 changed files with 289 additions and 319 deletions

307
app.js
View File

@@ -34,8 +34,11 @@ let debugOverlay;
let frameCount = 0, lastFPSTime = 0, fps = 0;
let chatOpen = true;
let loadProgress = 0;
let sessionPower = 1.0; // 0.01.0
let sessionPowerMeter = null; // { group, fillMat, orbMat, ringMats, pointLight }
// Session power state
let sessionPower = 1.0; // 0.0 → 1.0
const SESSION_CREDITS_MAX = 10000;
let sessionPowerMeter = null; // { fillMat, glowMat, orbMesh, orb, lightRef }
// ═══ INIT ═══
function init() {
@@ -790,53 +793,54 @@ function createAmbientStructures() {
scene.add(pedestal);
}
// ═══ SESSION POWER METER (3D) ═══
// ═══ SESSION POWER METER ═══
function createSessionPowerMeter() {
const group = new THREE.Group();
group.position.set(-9, 0, 5);
group.name = 'session-power-meter';
// Place to the left, slightly in front — visible from spawn at (0,2,12)
group.position.set(-9, 0, 4);
group.name = 'power-meter-group';
const METER_HEIGHT = 4;
const METER_RADIUS = 0.35;
const METER_H = 4.0;
const METER_R = 0.35;
// Outer glass housing
const glassGeo = new THREE.CylinderGeometry(METER_RADIUS + 0.06, METER_RADIUS + 0.06, METER_HEIGHT, 32, 1, true);
// --- Outer glass shell ---
const glassGeo = new THREE.CylinderGeometry(METER_R + 0.08, METER_R + 0.08, METER_H, 32, 1, true);
const glassMat = new THREE.MeshPhysicalMaterial({
color: 0x88ddff,
color: 0x88ccff,
transparent: true,
opacity: 0.18,
opacity: 0.12,
roughness: 0,
metalness: 0,
side: THREE.DoubleSide,
depthWrite: false,
});
const glass = new THREE.Mesh(glassGeo, glassMat);
glass.position.y = METER_HEIGHT / 2;
group.add(glass);
group.add(new THREE.Mesh(glassGeo, glassMat));
// 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);
// Glass rim rings (top + bottom)
for (const yOff of [METER_H / 2, -METER_H / 2]) {
const rimGeo = new THREE.TorusGeometry(METER_R + 0.1, 0.04, 8, 32);
const rimMat = new THREE.MeshStandardMaterial({
color: NEXUS.colors.primary,
emissive: NEXUS.colors.primary,
emissiveIntensity: 1.2,
roughness: 0.2,
metalness: 0.8,
});
const rim = new THREE.Mesh(rimGeo, rimMat);
rim.rotation.x = Math.PI / 2;
rim.position.y = yOff;
group.add(rim);
}
// Animated energy fill — clips by UV.y against uFill
const fillGeo = new THREE.CylinderGeometry(METER_RADIUS, METER_RADIUS, METER_HEIGHT, 32, 64, false);
// --- Energy fill (shader-driven, clips at uPower) ---
const fillGeo = new THREE.CylinderGeometry(METER_R - 0.02, METER_R - 0.02, METER_H, 32, 64, true);
const fillMat = new THREE.ShaderMaterial({
transparent: true,
depthWrite: false,
blending: THREE.AdditiveBlending,
side: THREE.DoubleSide,
uniforms: {
uTime: { value: 0 },
uFill: { value: 1.0 },
uColorA: { value: new THREE.Color(0x4af0c0) },
uColorB: { value: new THREE.Color(0x7b5cff) },
uTime: { value: 0 },
uPower: { value: 1.0 },
},
vertexShader: `
varying vec2 vUv;
@@ -847,104 +851,141 @@ function createSessionPowerMeter() {
`,
fragmentShader: `
uniform float uTime;
uniform float uFill;
uniform vec3 uColorA;
uniform vec3 uColorB;
uniform float uPower;
varying vec2 vUv;
void main() {
if (vUv.y > uFill) discard;
// vUv.y: 0 = bottom, 1 = top
if (vUv.y > uPower) discard;
// Gradient from bottom (colorA) to top (colorB)
vec3 col = mix(uColorA, uColorB, vUv.y / max(uFill, 0.01));
float t = (uPower > 0.001) ? (vUv.y / uPower) : 0.0;
// Scan lines rippling upward
float scan = sin(vUv.y * 40.0 - uTime * 3.0) * 0.5 + 0.5;
col += col * scan * 0.25;
// Teal → purple gradient based on remaining power color
vec3 colLow = vec3(1.0, 0.27, 0.40); // red danger
vec3 colMid = vec3(0.29, 0.94, 0.75); // teal
vec3 colHigh = vec3(0.48, 0.36, 1.00); // purple
// 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;
// Blend between danger and normal based on power level
float powerBlend = smoothstep(0.0, 0.25, uPower);
vec3 baseBot = mix(colLow, colMid, powerBlend);
vec3 baseTop = mix(colLow, colHigh, powerBlend);
vec3 col = mix(baseBot, baseTop, t);
// Pulse
float pulse = 0.75 + 0.25 * sin(uTime * 2.5);
float alpha = (0.55 + 0.2 * scan) * pulse;
// Scan lines
float scan = sin(vUv.y * 120.0 - uTime * 3.0) * 0.5 + 0.5;
col += col * scan * 0.08;
gl_FragColor = vec4(col, alpha);
// Bright band at fill edge
float edge = smoothstep(0.0, 0.04, uPower - vUv.y);
col += vec3(1.0) * (1.0 - edge) * 0.6;
// Pulse glow
float pulse = sin(uTime * 2.5) * 0.5 + 0.5;
col += col * pulse * 0.15;
float alpha = mix(0.55, 0.85, t) * edge;
gl_FragColor = vec4(col, alpha + 0.1);
}
`,
});
const fill = new THREE.Mesh(fillGeo, fillMat);
fill.position.y = METER_HEIGHT / 2;
group.add(fill);
const fillMesh = new THREE.Mesh(fillGeo, fillMat);
group.add(fillMesh);
// 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.8,
});
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 = 'spm-ring-' + i;
group.add(ring);
ringMats.push({ mat: ringMat, baseY: METER_HEIGHT * (0.25 + i * 0.25) });
}
// Floating indicator orb at fill level
// --- Floating orb that tracks fill height ---
const orbGeo = new THREE.SphereGeometry(0.12, 16, 16);
const orbMat = new THREE.MeshStandardMaterial({
color: 0x4af0c0,
emissive: 0x4af0c0,
color: NEXUS.colors.primary,
emissive: NEXUS.colors.primary,
emissiveIntensity: 3,
roughness: 0,
metalness: 1,
});
const orb = new THREE.Mesh(orbGeo, orbMat);
orb.name = 'spm-orb';
orb.position.y = METER_HEIGHT; // starts at top, updated in loop
orb.position.set(0, METER_H / 2 - METER_H * (1.0 - sessionPower) - METER_H / 2, 0);
group.add(orb);
// 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';
// --- Dynamic point light at orb position ---
const powerLight = new THREE.PointLight(NEXUS.colors.primary, 2.5, 8, 2);
powerLight.position.copy(orb.position);
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';
// --- 3 decorative rings (spin, dim above fill) ---
const rings = [];
for (let i = 0; i < 3; i++) {
const rGeo = new THREE.TorusGeometry(METER_R + 0.25 + i * 0.12, 0.025, 6, 32);
const rMat = new THREE.MeshBasicMaterial({
color: NEXUS.colors.secondary,
transparent: true,
opacity: 0.7,
});
const ring = new THREE.Mesh(rGeo, rMat);
ring.position.y = -METER_H / 2 + (i + 1) * (METER_H / 4);
ring.rotation.x = Math.PI / 2;
group.add(ring);
rings.push({ mesh: ring, baseY: ring.position.y, index: i });
}
// --- Canvas label ---
const lc = document.createElement('canvas');
lc.width = 256; lc.height = 80;
const lctx = lc.getContext('2d');
lctx.font = 'bold 20px "Orbitron", sans-serif';
lctx.fillStyle = '#4af0c0';
lctx.textAlign = 'center';
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);
// 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);
lctx.fillText('SESSION POWER', 128, 28);
lctx.font = '13px "JetBrains Mono", monospace';
lctx.fillStyle = '#7b5cff';
lctx.fillText('Fund once · Ask many', 128, 56);
const lTex = new THREE.CanvasTexture(lc);
const lMat = new THREE.MeshBasicMaterial({ map: lTex, transparent: true, side: THREE.DoubleSide, depthWrite: false });
const label = new THREE.Mesh(new THREE.PlaneGeometry(2.2, 0.7), lMat);
label.position.y = METER_H / 2 + 0.65;
group.add(label);
scene.add(group);
sessionPowerMeter = { group, fillMat, orbMat, ringMats, powerLight, orb, METER_HEIGHT };
sessionPowerMeter = { fillMat, orb, powerLight, rings, group, METER_H };
}
function updateSessionPowerMeter(elapsed) {
if (!sessionPowerMeter) return;
const { fillMat, orb, powerLight, rings, METER_H } = sessionPowerMeter;
fillMat.uniforms.uTime.value = elapsed;
fillMat.uniforms.uPower.value = sessionPower;
// Orb tracks the fill top edge
const fillTopY = -METER_H / 2 + sessionPower * METER_H;
orb.position.y = fillTopY + Math.sin(elapsed * 3.0) * 0.06;
orb.material.emissive.setHex(sessionPower < 0.2 ? NEXUS.colors.danger : NEXUS.colors.primary);
powerLight.position.y = orb.position.y;
powerLight.color.setHex(sessionPower < 0.2 ? NEXUS.colors.danger : NEXUS.colors.primary);
powerLight.intensity = 1.5 + Math.sin(elapsed * 2.5) * 0.5;
// Rings: dim those above current fill level, spin at different rates
rings.forEach(({ mesh, baseY, index }) => {
const inFill = baseY < fillTopY;
mesh.material.opacity = inFill ? 0.7 : 0.15;
mesh.rotation.z = elapsed * (0.4 + index * 0.25);
});
// HUD update
const pct = Math.round(sessionPower * 100);
const credits = Math.round(sessionPower * SESSION_CREDITS_MAX).toLocaleString();
const pmPct = document.getElementById('pm-pct');
const pmBar = document.getElementById('pm-bar');
const pmCredits = document.getElementById('pm-credits');
const pmWarn = document.getElementById('pm-warn');
if (pmPct) pmPct.textContent = pct + '%';
if (pmCredits) pmCredits.textContent = credits + ' CR';
if (pmBar) {
pmBar.style.width = pct + '%';
const isLow = sessionPower < 0.2;
pmBar.style.background = isLow
? 'linear-gradient(90deg, #ff2244, #ff6644)'
: 'linear-gradient(90deg, #4af0c0, #7b5cff)';
}
if (pmWarn) pmWarn.style.display = sessionPower < 0.2 ? 'flex' : 'none';
}
// ═══ CONTROLS ═══
@@ -1101,6 +1142,10 @@ function gameLoop() {
}
}
// Drain session power slowly (full drain in ~16 min for demo)
sessionPower = Math.max(0, sessionPower - delta * 0.001);
updateSessionPowerMeter(elapsed);
// Animate nexus core
const core = scene.getObjectByName('nexus-core');
if (core) {
@@ -1110,64 +1155,6 @@ 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

@@ -95,28 +95,25 @@
</div>
</div>
<!-- Session Power Meter HUD -->
<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>
<!-- Session Power Meter HUD (top-right) -->
<div id="power-meter-hud" class="power-meter-hud">
<div class="pm-header">
<span class="pm-icon"></span>
<span class="pm-title">SESSION POWER</span>
<span class="pm-badge">SOVEREIGN</span>
</div>
<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 class="pm-bar-track">
<div class="pm-bar" id="pm-bar"></div>
</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 class="pm-stats">
<span id="pm-pct" class="pm-pct">100%</span>
<span id="pm-credits" class="pm-credits">10,000 CR</span>
</div>
<div class="spm-tier-row">
<span id="spm-tier" class="spm-tier-badge">SOVEREIGN</span>
<div id="pm-warn" class="pm-warn" style="display:none;">
<span class="pm-warn-icon"></span>
<span>Low power — top up session</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 class="pm-tagline">Fund once · Ask many models</div>
</div>
<!-- Minimap / Controls hint -->

268
style.css
View File

@@ -330,6 +330,133 @@ canvas#nexus-canvas {
background: rgba(74, 240, 192, 0.1);
}
/* === SESSION POWER METER HUD === */
.power-meter-hud {
position: absolute;
top: var(--space-3);
right: var(--space-4);
width: 200px;
background: var(--color-surface);
backdrop-filter: blur(var(--panel-blur));
border: 1px solid var(--color-border);
border-radius: var(--panel-radius);
padding: var(--space-3) var(--space-4);
pointer-events: auto;
}
.pm-header {
display: flex;
align-items: center;
gap: var(--space-1);
margin-bottom: var(--space-2);
}
.pm-icon {
font-size: 13px;
color: var(--color-primary);
filter: drop-shadow(0 0 4px var(--color-primary));
}
.pm-title {
font-family: var(--font-display);
font-size: 9px;
letter-spacing: 0.1em;
font-weight: 600;
color: var(--color-text-bright);
flex: 1;
}
.pm-badge {
font-family: var(--font-display);
font-size: 8px;
letter-spacing: 0.08em;
color: var(--color-secondary);
border: 1px solid rgba(123, 92, 255, 0.4);
padding: 1px 4px;
border-radius: 3px;
}
.pm-bar-track {
height: 6px;
background: rgba(255, 255, 255, 0.06);
border-radius: 3px;
overflow: hidden;
margin-bottom: var(--space-2);
position: relative;
}
.pm-bar {
height: 100%;
width: 100%;
border-radius: 3px;
background: linear-gradient(90deg, var(--color-primary), var(--color-secondary));
transition: width 0.5s ease, background 0.5s ease;
position: relative;
}
.pm-bar::after {
content: '';
position: absolute;
right: 0;
top: 50%;
transform: translateY(-50%);
width: 4px;
height: 10px;
border-radius: 2px;
background: #fff;
box-shadow: 0 0 6px var(--color-primary), 0 0 12px var(--color-primary);
opacity: 0.9;
}
.pm-stats {
display: flex;
justify-content: space-between;
align-items: baseline;
margin-bottom: var(--space-2);
}
.pm-pct {
font-family: var(--font-display);
font-size: var(--text-lg);
font-weight: 700;
color: var(--color-primary);
text-shadow: 0 0 10px rgba(74, 240, 192, 0.4);
font-variant-numeric: tabular-nums lining-nums;
}
.pm-credits {
font-size: var(--text-xs);
color: var(--color-text-muted);
font-variant-numeric: tabular-nums;
}
.pm-warn {
display: flex;
align-items: center;
gap: var(--space-1);
font-size: var(--text-xs);
color: var(--color-danger);
margin-bottom: var(--space-2);
animation: warn-flash 1s ease-in-out infinite;
}
.pm-warn-icon {
font-size: 12px;
}
@keyframes warn-flash {
0%, 100% { opacity: 0.5; }
50% { opacity: 1; }
}
.pm-tagline {
font-size: 9px;
color: var(--color-text-muted);
letter-spacing: 0.05em;
text-align: center;
opacity: 0.7;
}
/* === FOOTER === */
.nexus-footer {
position: fixed;
@@ -348,147 +475,6 @@ canvas#nexus-canvas {
color: var(--color-primary);
}
/* === 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 var(--color-border);
border-radius: var(--panel-radius);
padding: var(--space-3) var(--space-4);
pointer-events: none;
transition: border-color var(--transition-ui);
}
.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: var(--text-xs);
font-weight: 600;
letter-spacing: 0.12em;
color: var(--color-primary);
}
.spm-bar-wrap {
display: flex;
align-items: center;
gap: var(--space-2);
margin-bottom: var(--space-2);
}
.spm-bar {
flex: 1;
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;
}
.spm-fill {
height: 100%;
width: 100%;
background: linear-gradient(90deg, var(--color-primary), var(--color-secondary));
border-radius: 4px;
transition: width 0.4s ease, background 0.4s ease;
}
.session-power-hud.low-power .spm-fill {
background: linear-gradient(90deg, var(--color-danger), #ff8844);
}
.spm-tip {
position: absolute;
right: 0;
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;
}
.session-power-hud.low-power .spm-tip {
background: var(--color-danger);
box-shadow: 0 0 8px var(--color-danger);
}
.spm-pct {
font-family: var(--font-display);
font-size: var(--text-xs);
font-weight: 700;
color: var(--color-primary);
min-width: 36px;
text-align: right;
transition: color 0.4s ease;
}
.session-power-hud.low-power .spm-pct {
color: var(--color-danger);
}
.spm-credits-row {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: var(--space-1);
}
.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; }
}
/* Mobile adjustments */
@media (max-width: 480px) {
.chat-panel {