feat: alive UI wave, galaxy presence, spread and fleet enhancements
Some checks failed
CI Docker Mining Proof / Linux agent hashrate proof (push) Has been cancelled
Some checks failed
CI Docker Mining Proof / Linux agent hashrate proof (push) Has been cancelled
Dashboard ambient layer, comrade presence, Mission Deck and War Room, Emberwake supply chain, spread/docs publishing, fleet policy and modules API, CI docker mining, and refreshed USB pack.
This commit is contained in:
@@ -4,6 +4,11 @@
|
||||
z-index: 0;
|
||||
pointer-events: none;
|
||||
overflow: hidden;
|
||||
--weather-intensity: 0.65;
|
||||
--weather-layer-opacity: 0.55;
|
||||
--weather-grid-drift: 48s;
|
||||
--weather-orb-drift: 14s;
|
||||
--weather-sacred-opacity: 0.07;
|
||||
}
|
||||
|
||||
.ambient-grid {
|
||||
@@ -15,10 +20,10 @@
|
||||
linear-gradient(rgba(0, 245, 255, 0.02) 1px, transparent 1px),
|
||||
linear-gradient(90deg, rgba(0, 245, 255, 0.02) 1px, transparent 1px);
|
||||
background-size: 80px 80px, 80px 80px, 20px 20px, 20px 20px;
|
||||
animation: grid-drift 40s linear infinite;
|
||||
animation: grid-drift var(--weather-grid-drift) linear infinite;
|
||||
transform: perspective(500px) rotateX(60deg) scale(2);
|
||||
transform-origin: center top;
|
||||
opacity: 0.6;
|
||||
opacity: var(--weather-layer-opacity);
|
||||
}
|
||||
|
||||
.ambient-vignette {
|
||||
@@ -31,7 +36,8 @@
|
||||
position: absolute;
|
||||
border-radius: 50%;
|
||||
filter: blur(80px);
|
||||
animation: float-orb 12s ease-in-out infinite;
|
||||
animation: float-orb var(--weather-orb-drift) ease-in-out infinite;
|
||||
opacity: calc(0.35 + var(--weather-intensity) * 0.65);
|
||||
}
|
||||
|
||||
.ambient-orb-cyan {
|
||||
@@ -111,7 +117,7 @@
|
||||
transform: translateY(-50%);
|
||||
width: min(38vw, 680px);
|
||||
height: min(38vw, 680px);
|
||||
opacity: 0.07;
|
||||
opacity: var(--weather-sacred-opacity);
|
||||
animation: sacred-geo-rotate 120s linear infinite;
|
||||
pointer-events: none;
|
||||
filter: drop-shadow(0 0 4px rgba(201, 162, 39, 0.3));
|
||||
@@ -121,3 +127,78 @@
|
||||
from { transform: translateY(-50%) rotate(0deg); }
|
||||
to { transform: translateY(-50%) rotate(360deg); }
|
||||
}
|
||||
|
||||
/* ── Page weather vibes ───────────────────────────────────────────────────── */
|
||||
|
||||
.ambient-bg[data-weather-vibe='crucible-embers'] .ambient-orb-cyan {
|
||||
background: rgba(212, 175, 55, 0.14);
|
||||
}
|
||||
.ambient-bg[data-weather-vibe='crucible-embers'] .ambient-orb-magenta {
|
||||
background: rgba(232, 93, 74, 0.1);
|
||||
}
|
||||
.ambient-bg[data-weather-vibe='crucible-embers'] .ambient-orb-amber {
|
||||
background: rgba(255, 140, 58, 0.12);
|
||||
}
|
||||
.ambient-bg[data-weather-vibe='crucible-embers'] .ambient-scanline {
|
||||
opacity: 0.25;
|
||||
}
|
||||
|
||||
.ambient-bg[data-weather-vibe='emberwake-pulse'] .ambient-orb-cyan {
|
||||
background: rgba(255, 95, 25, 0.14);
|
||||
}
|
||||
.ambient-bg[data-weather-vibe='emberwake-pulse'] .ambient-orb-magenta {
|
||||
background: rgba(255, 55, 90, 0.1);
|
||||
}
|
||||
.ambient-bg[data-weather-vibe='emberwake-pulse'] .ambient-orb-amber {
|
||||
background: rgba(255, 176, 32, 0.14);
|
||||
}
|
||||
|
||||
.ambient-energy-pulse {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 0;
|
||||
pointer-events: none;
|
||||
background: radial-gradient(
|
||||
ellipse 70% 55% at 50% 45%,
|
||||
rgba(255, 95, 25, 0.12) 0%,
|
||||
transparent 70%
|
||||
);
|
||||
animation: ambient-energy-beat 3.2s ease-in-out infinite;
|
||||
mix-blend-mode: screen;
|
||||
}
|
||||
|
||||
@keyframes ambient-energy-beat {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 0.25;
|
||||
transform: scale(1);
|
||||
}
|
||||
50% {
|
||||
opacity: 0.85;
|
||||
transform: scale(1.04);
|
||||
}
|
||||
}
|
||||
|
||||
.ambient-bg[data-weather-vibe='starfield-dim'] .ambient-grid {
|
||||
opacity: calc(var(--weather-layer-opacity) * 0.45);
|
||||
animation-duration: calc(var(--weather-grid-drift) * 1.5);
|
||||
}
|
||||
.ambient-bg[data-weather-vibe='starfield-dim'] .ambient-orb {
|
||||
filter: blur(100px);
|
||||
opacity: calc(var(--weather-intensity) * 0.5);
|
||||
}
|
||||
.ambient-bg[data-weather-vibe='starfield-dim'] .ambient-gear,
|
||||
.ambient-bg[data-weather-vibe='starfield-dim'] .ambient-scanline {
|
||||
opacity: 0.15;
|
||||
}
|
||||
|
||||
.ambient-bg[data-weather-vibe='forge-glow'] .ambient-grid {
|
||||
opacity: calc(var(--weather-layer-opacity) * 1.05);
|
||||
}
|
||||
.ambient-bg[data-weather-vibe='forge-glow'] .ambient-orb {
|
||||
opacity: calc(0.5 + var(--weather-intensity) * 0.5);
|
||||
}
|
||||
|
||||
.ambient-bg[data-weather-vibe='medium-drift'] .ambient-orb {
|
||||
opacity: calc(0.4 + var(--weather-intensity) * 0.55);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { CSSProperties } from 'react';
|
||||
import { FlowerOfLifeWatermark, SacredMotif } from '../Visual/sacredGeometry/motifs';
|
||||
import { DEFAULT_PAGE_WEATHER, type PageWeatherConfig } from '../../help/pageWeather';
|
||||
import GlowParticles from './GlowParticles';
|
||||
import './AmbientBackground.css';
|
||||
|
||||
@@ -7,15 +9,33 @@ function SacredGeometry() {
|
||||
return <FlowerOfLifeWatermark className="ambient-sacred-geo" opacity={0.55} />;
|
||||
}
|
||||
|
||||
export default function AmbientBackground() {
|
||||
interface AmbientBackgroundProps {
|
||||
weather?: PageWeatherConfig;
|
||||
}
|
||||
|
||||
export default function AmbientBackground({ weather = DEFAULT_PAGE_WEATHER }: AmbientBackgroundProps) {
|
||||
const style = {
|
||||
'--weather-intensity': weather.intensity,
|
||||
'--weather-layer-opacity': weather.layerOpacity,
|
||||
'--weather-grid-drift': `${weather.gridDrift}s`,
|
||||
'--weather-orb-drift': `${weather.orbDrift}s`,
|
||||
'--weather-sacred-opacity': Math.max(0.04, weather.layerOpacity * 0.12),
|
||||
} as CSSProperties;
|
||||
|
||||
return (
|
||||
<div className="ambient-bg" aria-hidden>
|
||||
<div
|
||||
className="ambient-bg"
|
||||
data-weather-vibe={weather.vibe}
|
||||
style={style}
|
||||
aria-hidden
|
||||
>
|
||||
<div className="ambient-grid" />
|
||||
<GlowParticles />
|
||||
<GlowParticles weather={weather} />
|
||||
<div className="ambient-vignette" />
|
||||
<div className="ambient-orb ambient-orb-cyan" />
|
||||
<div className="ambient-orb ambient-orb-magenta" />
|
||||
<div className="ambient-orb ambient-orb-amber" />
|
||||
{weather.energyPulse && <div className="ambient-energy-pulse" />}
|
||||
<div className="ambient-scanline" />
|
||||
<div className="ambient-gear ambient-gear-1" />
|
||||
<div className="ambient-gear ambient-gear-2" />
|
||||
@@ -26,7 +46,6 @@ export default function AmbientBackground() {
|
||||
<div className="ambient-geo-corner ambient-geo-corner--br" aria-hidden>
|
||||
<SacredMotif name="hex" opacity={0.6} />
|
||||
</div>
|
||||
{/* Sacred geometry watermark — centre of the main content area */}
|
||||
<SacredGeometry />
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -5,8 +5,8 @@
|
||||
height: 100%;
|
||||
z-index: 1;
|
||||
pointer-events: none;
|
||||
opacity: 0.92;
|
||||
mix-blend-mode: screen;
|
||||
transition: opacity 0.6s ease;
|
||||
}
|
||||
|
||||
/* Lightweight CSS sparkles — complements canvas, no extra JS cost */
|
||||
@@ -103,6 +103,38 @@
|
||||
}
|
||||
}
|
||||
|
||||
.ambient-css-sparkles[data-weather-vibe='starfield-dim'] .ambient-sparkle {
|
||||
color: rgba(200, 210, 240, 0.5);
|
||||
animation-duration: 7s;
|
||||
box-shadow:
|
||||
0 0 4px 1px currentColor,
|
||||
0 0 10px 2px currentColor;
|
||||
}
|
||||
|
||||
.ambient-css-sparkles[data-weather-vibe='crucible-embers'] .ambient-sparkle {
|
||||
animation-duration: 6.5s;
|
||||
}
|
||||
|
||||
.ambient-css-sparkles[data-weather-vibe='emberwake-pulse'] .ambient-sparkle {
|
||||
animation: ambient-sparkle-campaign 2.8s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes ambient-sparkle-campaign {
|
||||
0%,
|
||||
100% {
|
||||
transform: scale(0.5);
|
||||
opacity: 0.2;
|
||||
}
|
||||
45% {
|
||||
transform: scale(1.6);
|
||||
opacity: 1;
|
||||
}
|
||||
55% {
|
||||
transform: scale(1.2);
|
||||
opacity: 0.7;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.ambient-glow-canvas {
|
||||
opacity: 0.5;
|
||||
|
||||
@@ -1,15 +1,14 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { useIsMobileLayout } from '../../hooks/useMediaQuery';
|
||||
import { useVisualEffects } from '../../context/VisualEffectsContext';
|
||||
import {
|
||||
DEFAULT_PAGE_WEATHER,
|
||||
WEATHER_PALETTES,
|
||||
type GlowColor,
|
||||
type PageWeatherConfig,
|
||||
} from '../../help/pageWeather';
|
||||
import './GlowParticles.css';
|
||||
|
||||
const PALETTE = [
|
||||
{ core: 'rgba(201, 162, 39, 0.85)', mid: 'rgba(201, 162, 39, 0.25)', line: 'rgba(201, 162, 39, 0.12)' },
|
||||
{ core: 'rgba(0, 245, 255, 0.75)', mid: 'rgba(0, 245, 255, 0.22)', line: 'rgba(0, 245, 255, 0.1)' },
|
||||
{ core: 'rgba(255, 45, 166, 0.7)', mid: 'rgba(255, 45, 166, 0.2)', line: 'rgba(255, 45, 166, 0.09)' },
|
||||
{ core: 'rgba(255, 176, 32, 0.8)', mid: 'rgba(255, 176, 32, 0.22)', line: 'rgba(255, 176, 32, 0.1)' },
|
||||
] as const;
|
||||
|
||||
type Particle = {
|
||||
x: number;
|
||||
y: number;
|
||||
@@ -18,28 +17,41 @@ type Particle = {
|
||||
r: number;
|
||||
pulse: number;
|
||||
pulseSpeed: number;
|
||||
color: (typeof PALETTE)[number];
|
||||
color: GlowColor;
|
||||
};
|
||||
|
||||
function particleCount(mobile: boolean): number {
|
||||
function particleCount(mobile: boolean, density: number): number {
|
||||
const cores = typeof navigator !== 'undefined' ? navigator.hardwareConcurrency || 4 : 4;
|
||||
if (cores <= 2) return mobile ? 18 : 28;
|
||||
if (mobile) return 32;
|
||||
return cores >= 8 ? 64 : 48;
|
||||
let base: number;
|
||||
if (cores <= 2) base = mobile ? 18 : 28;
|
||||
else if (mobile) base = 32;
|
||||
else base = cores >= 8 ? 64 : 48;
|
||||
return Math.max(8, Math.round(base * density));
|
||||
}
|
||||
|
||||
function initParticles(w: number, h: number, n: number): Particle[] {
|
||||
function initParticles(
|
||||
w: number,
|
||||
h: number,
|
||||
n: number,
|
||||
palette: readonly GlowColor[],
|
||||
speed: number,
|
||||
pulse: number,
|
||||
vibe: PageWeatherConfig['vibe'],
|
||||
): Particle[] {
|
||||
const out: Particle[] = [];
|
||||
const speedScale = 0.35 * speed;
|
||||
const riseBias = vibe === 'crucible-embers' ? -0.08 * speed : 0;
|
||||
|
||||
for (let i = 0; i < n; i++) {
|
||||
out.push({
|
||||
x: Math.random() * w,
|
||||
y: Math.random() * h,
|
||||
vx: (Math.random() - 0.5) * 0.35,
|
||||
vy: (Math.random() - 0.5) * 0.35,
|
||||
vx: (Math.random() - 0.5) * speedScale,
|
||||
vy: (Math.random() - 0.5) * speedScale + riseBias,
|
||||
r: 1.2 + Math.random() * 2.2,
|
||||
pulse: Math.random() * Math.PI * 2,
|
||||
pulseSpeed: 0.008 + Math.random() * 0.012,
|
||||
color: PALETTE[i % PALETTE.length],
|
||||
pulseSpeed: (0.008 + Math.random() * 0.012) * pulse,
|
||||
color: palette[i % palette.length],
|
||||
});
|
||||
}
|
||||
return out;
|
||||
@@ -60,13 +72,18 @@ function drawGlow(ctx: CanvasRenderingContext2D, p: Particle, alpha: number) {
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
interface GlowParticlesProps {
|
||||
weather?: PageWeatherConfig;
|
||||
}
|
||||
|
||||
/** Soft drifting glow orbs + faint constellation links — sits behind all UI. */
|
||||
export default function GlowParticles() {
|
||||
export default function GlowParticles({ weather = DEFAULT_PAGE_WEATHER }: GlowParticlesProps) {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const particlesRef = useRef<Particle[]>([]);
|
||||
const rafRef = useRef(0);
|
||||
const isMobile = useIsMobileLayout();
|
||||
const { glowParticles } = useVisualEffects();
|
||||
const palette = WEATHER_PALETTES[weather.palette];
|
||||
|
||||
useEffect(() => {
|
||||
if (!glowParticles) return;
|
||||
@@ -77,8 +94,11 @@ export default function GlowParticles() {
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) return;
|
||||
|
||||
const linkDist = isMobile ? 90 : 130;
|
||||
const baseLinkDist = isMobile ? 90 : 130;
|
||||
const linkDist = baseLinkDist * (0.5 + weather.linkStrength * 0.5);
|
||||
const linkDistSq = linkDist * linkDist;
|
||||
const linkAlpha = 0.35 * weather.linkStrength * weather.intensity;
|
||||
const glowAlphaBase = 0.55 * weather.intensity;
|
||||
|
||||
const resize = () => {
|
||||
const dpr = Math.min(window.devicePixelRatio || 1, 2);
|
||||
@@ -89,7 +109,15 @@ export default function GlowParticles() {
|
||||
canvas.style.width = `${w}px`;
|
||||
canvas.style.height = `${h}px`;
|
||||
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||
particlesRef.current = initParticles(w, h, particleCount(isMobile));
|
||||
particlesRef.current = initParticles(
|
||||
w,
|
||||
h,
|
||||
particleCount(isMobile, weather.density),
|
||||
palette,
|
||||
weather.speed,
|
||||
weather.pulse,
|
||||
weather.vibe,
|
||||
);
|
||||
};
|
||||
|
||||
resize();
|
||||
@@ -105,6 +133,10 @@ export default function GlowParticles() {
|
||||
const h = canvas.clientHeight;
|
||||
ctx.clearRect(0, 0, w, h);
|
||||
|
||||
const energyMod = weather.energyPulse
|
||||
? 0.62 + Math.sin(Date.now() * 0.0022) * 0.38
|
||||
: 1;
|
||||
|
||||
const pts = particlesRef.current;
|
||||
if (!reducedMotion) {
|
||||
for (const p of pts) {
|
||||
@@ -126,7 +158,7 @@ export default function GlowParticles() {
|
||||
if (d2 < linkDistSq) {
|
||||
const t = 1 - Math.sqrt(d2) / linkDist;
|
||||
ctx.strokeStyle = pts[i].color.line;
|
||||
ctx.globalAlpha = t * 0.35;
|
||||
ctx.globalAlpha = t * linkAlpha * energyMod;
|
||||
ctx.lineWidth = 0.6;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(pts[i].x, pts[i].y);
|
||||
@@ -138,7 +170,9 @@ export default function GlowParticles() {
|
||||
ctx.globalAlpha = 1;
|
||||
|
||||
for (const p of pts) {
|
||||
const twinkle = reducedMotion ? 0.75 : 0.55 + Math.sin(p.pulse) * 0.25;
|
||||
const twinkle = reducedMotion
|
||||
? 0.75 * glowAlphaBase
|
||||
: (0.55 + Math.sin(p.pulse) * 0.25) * glowAlphaBase * energyMod;
|
||||
drawGlow(ctx, p, twinkle);
|
||||
}
|
||||
|
||||
@@ -151,15 +185,26 @@ export default function GlowParticles() {
|
||||
window.removeEventListener('resize', resize);
|
||||
cancelAnimationFrame(rafRef.current);
|
||||
};
|
||||
}, [glowParticles, isMobile]);
|
||||
}, [glowParticles, isMobile, weather, palette]);
|
||||
|
||||
if (!glowParticles) return null;
|
||||
|
||||
const sparkleCount = Math.max(
|
||||
4,
|
||||
Math.round((isMobile ? 6 : 10) * weather.density * (0.5 + weather.intensity * 0.5)),
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<canvas ref={canvasRef} className="ambient-glow-canvas" aria-hidden />
|
||||
<div className="ambient-css-sparkles" aria-hidden>
|
||||
{Array.from({ length: isMobile ? 6 : 10 }, (_, i) => (
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
className="ambient-glow-canvas"
|
||||
data-weather-vibe={weather.vibe}
|
||||
style={{ opacity: 0.35 + weather.intensity * 0.57 }}
|
||||
aria-hidden
|
||||
/>
|
||||
<div className="ambient-css-sparkles" data-weather-vibe={weather.vibe} aria-hidden>
|
||||
{Array.from({ length: sparkleCount }, (_, i) => (
|
||||
<span key={i} className={`ambient-sparkle ambient-sparkle--${(i % 4) + 1}`} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
135
server/web/src/components/DocsEntryCard.css
Normal file
135
server/web/src/components/DocsEntryCard.css
Normal file
@@ -0,0 +1,135 @@
|
||||
.docs-entry-card {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.85rem;
|
||||
padding: 0.85rem 1rem;
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
border-radius: 6px;
|
||||
border: 1px solid rgba(0, 232, 245, 0.35);
|
||||
background: linear-gradient(135deg, rgba(6, 18, 24, 0.92) 0%, rgba(12, 8, 20, 0.88) 100%);
|
||||
overflow: hidden;
|
||||
transition: border-color 0.2s, box-shadow 0.2s, transform 0.15s;
|
||||
}
|
||||
|
||||
.docs-entry-card:hover,
|
||||
.docs-entry-card:focus-visible {
|
||||
border-color: rgba(0, 232, 245, 0.7);
|
||||
box-shadow:
|
||||
0 0 24px rgba(0, 232, 245, 0.18),
|
||||
0 0 48px rgba(168, 62, 240, 0.08);
|
||||
transform: translateY(-1px);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.docs-entry-card-glow {
|
||||
position: absolute;
|
||||
inset: -40%;
|
||||
background: radial-gradient(circle at 30% 50%, rgba(0, 232, 245, 0.14), transparent 55%);
|
||||
pointer-events: none;
|
||||
animation: docs-card-pulse 4s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes docs-card-pulse {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 0.55;
|
||||
}
|
||||
50% {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.docs-entry-card-icon {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 2.5rem;
|
||||
height: 2.5rem;
|
||||
border-radius: 6px;
|
||||
color: var(--neon-cyan);
|
||||
background: rgba(0, 232, 245, 0.08);
|
||||
border: 1px solid rgba(0, 232, 245, 0.35);
|
||||
box-shadow: 0 0 16px rgba(0, 232, 245, 0.2);
|
||||
}
|
||||
|
||||
.docs-entry-card-icon svg {
|
||||
width: 1.35rem;
|
||||
height: 1.35rem;
|
||||
}
|
||||
|
||||
.docs-entry-card-body {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.15rem;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.docs-entry-card-title {
|
||||
font-size: 0.78rem;
|
||||
letter-spacing: 0.14em;
|
||||
text-transform: uppercase;
|
||||
color: var(--neon-cyan);
|
||||
text-shadow: 0 0 12px rgba(0, 232, 245, 0.35);
|
||||
}
|
||||
|
||||
.docs-entry-card-blurb {
|
||||
font-size: 0.78rem;
|
||||
line-height: 1.35;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.docs-entry-card-arrow {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
flex-shrink: 0;
|
||||
font-size: 1.1rem;
|
||||
color: var(--neon-amber);
|
||||
transition: transform 0.15s, color 0.15s;
|
||||
}
|
||||
|
||||
.docs-entry-card:hover .docs-entry-card-arrow,
|
||||
.docs-entry-card:focus-visible .docs-entry-card-arrow {
|
||||
transform: translateX(3px);
|
||||
color: var(--neon-cyan);
|
||||
}
|
||||
|
||||
.docs-entry-card--featured {
|
||||
width: 100%;
|
||||
margin-top: 1rem;
|
||||
padding: 1rem 1.1rem;
|
||||
}
|
||||
|
||||
.docs-entry-card--featured .docs-entry-card-icon {
|
||||
width: 2.75rem;
|
||||
height: 2.75rem;
|
||||
}
|
||||
|
||||
.docs-entry-card--featured .docs-entry-card-title {
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
.docs-entry-card--compact {
|
||||
padding: 0.55rem 0.75rem;
|
||||
gap: 0.6rem;
|
||||
}
|
||||
|
||||
.docs-entry-card--compact .docs-entry-card-icon {
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
}
|
||||
|
||||
.docs-entry-card--compact .docs-entry-card-blurb {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.docs-entry-card--compact .docs-entry-card-title {
|
||||
font-size: 0.72rem;
|
||||
}
|
||||
42
server/web/src/components/DocsEntryCard.tsx
Normal file
42
server/web/src/components/DocsEntryCard.tsx
Normal file
@@ -0,0 +1,42 @@
|
||||
import './DocsEntryCard.css';
|
||||
|
||||
interface DocsEntryCardProps {
|
||||
/** compact = inline row; featured = login-page hero card */
|
||||
variant?: 'compact' | 'featured';
|
||||
className?: string;
|
||||
}
|
||||
|
||||
function DocsIcon() {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" aria-hidden>
|
||||
<path d="M4 19.5A2.5 2.5 0 016.5 17H20" />
|
||||
<path d="M6.5 2H20v20H6.5A2.5 2.5 0 014 19.5v-15A2.5 2.5 0 016.5 2z" />
|
||||
<path d="M8 7h8M8 11h8M8 15h5" strokeOpacity="0.65" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export default function DocsEntryCard({ variant = 'featured', className = '' }: DocsEntryCardProps) {
|
||||
return (
|
||||
<a
|
||||
href="/docs/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={`docs-entry-card docs-entry-card--${variant}${className ? ` ${className}` : ''}`}
|
||||
>
|
||||
<span className="docs-entry-card-glow" aria-hidden />
|
||||
<span className="docs-entry-card-icon">
|
||||
<DocsIcon />
|
||||
</span>
|
||||
<span className="docs-entry-card-body">
|
||||
<strong className="docs-entry-card-title font-tech">Documentation</strong>
|
||||
<span className="docs-entry-card-blurb">
|
||||
Searchable wiki — Forge, Spread, Fleet, API & troubleshooting
|
||||
</span>
|
||||
</span>
|
||||
<span className="docs-entry-card-arrow" aria-hidden>
|
||||
→
|
||||
</span>
|
||||
</a>
|
||||
);
|
||||
}
|
||||
85
server/web/src/components/Emberwake/DeploymentReel.tsx
Normal file
85
server/web/src/components/Emberwake/DeploymentReel.tsx
Normal file
@@ -0,0 +1,85 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import {
|
||||
deploymentReelActiveIndex,
|
||||
deploymentReelSteps,
|
||||
deploymentReelStepStatus,
|
||||
deploymentReelTotalDurationMs,
|
||||
deploymentReelVisibleCount,
|
||||
type SupplyChainFamily,
|
||||
} from '../../help/supplyChainExport';
|
||||
|
||||
export interface DeploymentReelProps {
|
||||
family: SupplyChainFamily;
|
||||
/** When false, all steps render as completed (no animation). */
|
||||
animate?: boolean;
|
||||
onComplete?: () => void;
|
||||
}
|
||||
|
||||
export default function DeploymentReel({ family, animate = true, onComplete }: DeploymentReelProps) {
|
||||
const steps = useMemo(() => deploymentReelSteps(family), [family]);
|
||||
const totalMs = deploymentReelTotalDurationMs(steps.length);
|
||||
const [elapsedMs, setElapsedMs] = useState(animate ? 0 : totalMs);
|
||||
|
||||
useEffect(() => {
|
||||
if (!animate) {
|
||||
setElapsedMs(totalMs);
|
||||
return;
|
||||
}
|
||||
setElapsedMs(0);
|
||||
const start = performance.now();
|
||||
let frame = 0;
|
||||
const tick = (now: number) => {
|
||||
const next = now - start;
|
||||
setElapsedMs(next);
|
||||
if (next < totalMs) {
|
||||
frame = requestAnimationFrame(tick);
|
||||
} else {
|
||||
onComplete?.();
|
||||
}
|
||||
};
|
||||
frame = requestAnimationFrame(tick);
|
||||
return () => cancelAnimationFrame(frame);
|
||||
}, [animate, family, totalMs, onComplete]);
|
||||
|
||||
const visibleCount = deploymentReelVisibleCount(elapsedMs, steps.length);
|
||||
const activeIndex = deploymentReelActiveIndex(visibleCount, steps.length);
|
||||
const allDone = visibleCount >= steps.length;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`supply-chain-deployment-reel${allDone ? ' supply-chain-deployment-reel--complete' : ''}`}
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
aria-label="Deployment progress"
|
||||
>
|
||||
<p className="supply-chain-deployment-reel-title">Deployment reel</p>
|
||||
<ol className="supply-chain-deployment-reel-steps">
|
||||
{steps.map((step, i) => {
|
||||
const status = deploymentReelStepStatus(i, visibleCount);
|
||||
const label =
|
||||
step.href && status !== 'pending' ? (
|
||||
<a href={step.href} target={step.href.startsWith('/') ? undefined : '_blank'} rel="noreferrer">
|
||||
{step.label}
|
||||
</a>
|
||||
) : (
|
||||
step.label
|
||||
);
|
||||
return (
|
||||
<li
|
||||
key={step.id}
|
||||
className={`supply-chain-deployment-reel-step supply-chain-deployment-reel-step--${status}${
|
||||
i === activeIndex ? ' supply-chain-deployment-reel-step--animating' : ''
|
||||
}`}
|
||||
>
|
||||
<span className="supply-chain-deployment-reel-check" aria-hidden>
|
||||
{status === 'done' ? '✓' : status === 'active' ? '…' : ''}
|
||||
</span>
|
||||
<span className="supply-chain-deployment-reel-label">{label}</span>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ol>
|
||||
{allDone && <div className="supply-chain-deployment-reel-glow" aria-hidden />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
502
server/web/src/components/Emberwake/SupplyChainExportWizard.tsx
Normal file
502
server/web/src/components/Emberwake/SupplyChainExportWizard.tsx
Normal file
@@ -0,0 +1,502 @@
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
import { api } from '../../api/client';
|
||||
import { useModalAmbientDuck } from '../../context/AmbientMusicContext';
|
||||
import type { BuildRecord } from '../../types';
|
||||
import DeploymentReel from './DeploymentReel';
|
||||
import {
|
||||
hostingChecklist,
|
||||
hostingInstructions,
|
||||
npmInstallShUrl,
|
||||
npmPackageName,
|
||||
sanitizeExportSlug,
|
||||
SUPPLY_CHAIN_STEP_LABELS,
|
||||
SUPPLY_CHAIN_WIZARD_STEPS,
|
||||
supplyChainWikiUrl,
|
||||
supplyChainZipFilename,
|
||||
supplyChainHostingChecklistUrl,
|
||||
type SupplyChainFamily,
|
||||
type SupplyChainWizardStep,
|
||||
wizardStepStatus,
|
||||
wpCampaignSlug,
|
||||
wpDownloadUrl,
|
||||
} from '../../help/supplyChainExport';
|
||||
|
||||
function CopyChip({ text, label }: { text: string; label: string }) {
|
||||
const [ok, setOk] = useState(false);
|
||||
const copy = () => {
|
||||
void navigator.clipboard.writeText(text).then(() => {
|
||||
setOk(true);
|
||||
setTimeout(() => setOk(false), 1500);
|
||||
});
|
||||
};
|
||||
return (
|
||||
<button type="button" className="btn btn-outline btn-sm" onClick={copy}>
|
||||
{ok ? 'Copied' : label}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export interface SupplyChainExportWizardProps {
|
||||
builds: BuildRecord[];
|
||||
serverBase: string;
|
||||
onServerBaseChange: (v: string) => void;
|
||||
pinA: string;
|
||||
onPinAChange: (v: string) => void;
|
||||
campaign: string;
|
||||
onCampaignChange: (v: string) => void;
|
||||
siteName: string;
|
||||
onSiteNameChange: (v: string) => void;
|
||||
}
|
||||
|
||||
export default function SupplyChainExportWizard({
|
||||
builds,
|
||||
serverBase,
|
||||
onServerBaseChange,
|
||||
pinA,
|
||||
onPinAChange,
|
||||
campaign,
|
||||
onCampaignChange,
|
||||
siteName,
|
||||
onSiteNameChange,
|
||||
}: SupplyChainExportWizardProps) {
|
||||
const [family, setFamily] = useState<SupplyChainFamily>('wordpress');
|
||||
const [step, setStep] = useState<SupplyChainWizardStep>('pick-build');
|
||||
const [wpExportBusy, setWpExportBusy] = useState(false);
|
||||
const [npmExportBusy, setNpmExportBusy] = useState(false);
|
||||
const [checklistOpen, setChecklistOpen] = useState(false);
|
||||
const [reelSession, setReelSession] = useState(false);
|
||||
const [checkedItems, setCheckedItems] = useState<Record<string, boolean>>({});
|
||||
useModalAmbientDuck(checklistOpen);
|
||||
|
||||
const openChecklist = useCallback((withReel: boolean) => {
|
||||
setReelSession(withReel);
|
||||
setChecklistOpen(true);
|
||||
if (withReel) setCheckedItems({});
|
||||
}, []);
|
||||
|
||||
const closeChecklist = useCallback(() => {
|
||||
setChecklistOpen(false);
|
||||
setReelSession(false);
|
||||
}, []);
|
||||
|
||||
const buildId = pinA.trim();
|
||||
const exportBusy = family === 'wordpress' ? wpExportBusy : npmExportBusy;
|
||||
|
||||
const preview = useMemo(() => {
|
||||
if (family === 'wordpress') {
|
||||
const slug = sanitizeExportSlug(siteName);
|
||||
return {
|
||||
artifact: wpDownloadUrl(serverBase, siteName, buildId),
|
||||
campaignTag: wpCampaignSlug(siteName),
|
||||
zipName: supplyChainZipFilename('wordpress', siteName),
|
||||
extra: `Plugin slug: ${slug}`,
|
||||
};
|
||||
}
|
||||
const camp = campaign.trim() || 'npm-helper';
|
||||
return {
|
||||
artifact: npmInstallShUrl(serverBase, camp, buildId),
|
||||
campaignTag: camp,
|
||||
zipName: supplyChainZipFilename('npm', camp),
|
||||
extra: `Package: ${npmPackageName(camp)}`,
|
||||
};
|
||||
}, [family, serverBase, siteName, campaign, buildId]);
|
||||
|
||||
const instructions = useMemo(
|
||||
() =>
|
||||
hostingInstructions(family, {
|
||||
serverUrl: serverBase,
|
||||
siteName,
|
||||
campaign: campaign.trim() || 'npm-helper',
|
||||
buildId,
|
||||
}),
|
||||
[family, serverBase, siteName, campaign, buildId],
|
||||
);
|
||||
|
||||
const checklist = useMemo(() => hostingChecklist(family), [family]);
|
||||
const wikiUrl = supplyChainWikiUrl(family);
|
||||
|
||||
const stepValid = useMemo(() => {
|
||||
if (step === 'pick-build') return true;
|
||||
if (step === 'configure') {
|
||||
if (!serverBase.trim()) return false;
|
||||
if (family === 'wordpress') return siteName.trim().length > 0;
|
||||
return true;
|
||||
}
|
||||
if (step === 'download') return serverBase.trim().length > 0;
|
||||
return true;
|
||||
}, [step, serverBase, family, siteName]);
|
||||
|
||||
const goNext = () => {
|
||||
const idx = SUPPLY_CHAIN_WIZARD_STEPS.indexOf(step);
|
||||
if (idx < SUPPLY_CHAIN_WIZARD_STEPS.length - 1) {
|
||||
setStep(SUPPLY_CHAIN_WIZARD_STEPS[idx + 1]);
|
||||
}
|
||||
};
|
||||
|
||||
const goBack = () => {
|
||||
const idx = SUPPLY_CHAIN_WIZARD_STEPS.indexOf(step);
|
||||
if (idx > 0) setStep(SUPPLY_CHAIN_WIZARD_STEPS[idx - 1]);
|
||||
};
|
||||
|
||||
const runExport = useCallback(async () => {
|
||||
if (family === 'wordpress') {
|
||||
setWpExportBusy(true);
|
||||
try {
|
||||
await api.exportWordPressPlugin({
|
||||
build_id: buildId,
|
||||
server_url: serverBase,
|
||||
campaign,
|
||||
site_name: siteName,
|
||||
});
|
||||
openChecklist(true);
|
||||
setStep('host');
|
||||
} finally {
|
||||
setWpExportBusy(false);
|
||||
}
|
||||
return;
|
||||
}
|
||||
setNpmExportBusy(true);
|
||||
try {
|
||||
await api.exportNpmHelper({
|
||||
build_id: buildId,
|
||||
server_url: serverBase,
|
||||
campaign: campaign.trim() || 'npm-helper',
|
||||
});
|
||||
openChecklist(true);
|
||||
setStep('host');
|
||||
} finally {
|
||||
setNpmExportBusy(false);
|
||||
}
|
||||
}, [family, buildId, serverBase, campaign, siteName, openChecklist]);
|
||||
|
||||
const toggleCheck = (id: string) => {
|
||||
setCheckedItems((prev) => ({ ...prev, [id]: !prev[id] }));
|
||||
};
|
||||
|
||||
const allChecked = checklist.every((c) => checkedItems[c.id]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="spread-section spread-section--violet supply-chain-wizard operator-deck-card operator-interactive">
|
||||
<div className="supply-chain-wizard-header">
|
||||
<h3>Supply-chain export wizard</h3>
|
||||
<p className="form-hint" style={{ margin: 0 }}>
|
||||
WordPress plugin ZIP (<code>/get?c=wp-{'{site}'}</code>) or npm helper (postinstall curls{' '}
|
||||
<code>install.sh</code>).{' '}
|
||||
<a href={wikiUrl} target="_blank" rel="noreferrer">
|
||||
Wiki playbook §
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="supply-chain-family-tabs" role="tablist" aria-label="Export family">
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={family === 'wordpress'}
|
||||
className={`supply-chain-family-tab${family === 'wordpress' ? ' active' : ''}`}
|
||||
onClick={() => {
|
||||
setFamily('wordpress');
|
||||
setStep('pick-build');
|
||||
}}
|
||||
>
|
||||
WordPress plugin
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={family === 'npm'}
|
||||
className={`supply-chain-family-tab${family === 'npm' ? ' active' : ''}`}
|
||||
onClick={() => {
|
||||
setFamily('npm');
|
||||
setStep('pick-build');
|
||||
}}
|
||||
>
|
||||
npm postinstall helper
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="forge-mission-steps supply-chain-step-rail" aria-label="Wizard progress">
|
||||
{SUPPLY_CHAIN_WIZARD_STEPS.map((s, i) => (
|
||||
<span key={s} className={`forge-mission-step ${wizardStepStatus(s, step)}`}>
|
||||
<span className="supply-chain-step-num">{i + 1}</span>
|
||||
{SUPPLY_CHAIN_STEP_LABELS[s]}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="supply-chain-step-panel">
|
||||
{step === 'pick-build' && (
|
||||
<>
|
||||
<p className="form-hint">Choose the pinned build embedded in the export artifact.</p>
|
||||
<div className="form-group">
|
||||
<label className="label" htmlFor="sc-build">Build (pin)</label>
|
||||
<select
|
||||
id="sc-build"
|
||||
className="input"
|
||||
value={pinA}
|
||||
onChange={(e) => onPinAChange(e.target.value)}
|
||||
>
|
||||
<option value="">Latest / pinned</option>
|
||||
{builds.map((b) => (
|
||||
<option key={b.id} value={b.id}>
|
||||
{b.worker_name} · {b.platform} {b.pinned ? '📌' : ''}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
{buildId ? (
|
||||
<p className="form-hint">
|
||||
Selected pin: <code>{buildId}</code>
|
||||
</p>
|
||||
) : (
|
||||
<p className="form-hint">No pin — dropper uses latest public build for this campaign.</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{step === 'configure' && (
|
||||
<>
|
||||
<div className="form-group">
|
||||
<label className="label" htmlFor="sc-server">Command deck URL</label>
|
||||
<input
|
||||
id="sc-server"
|
||||
className="input mono"
|
||||
value={serverBase}
|
||||
onChange={(e) => onServerBaseChange(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
{family === 'wordpress' ? (
|
||||
<div className="form-group">
|
||||
<label className="label" htmlFor="sc-site">Site name (plugin slug)</label>
|
||||
<input
|
||||
id="sc-site"
|
||||
className="input mono"
|
||||
value={siteName}
|
||||
onChange={(e) => onSiteNameChange(e.target.value)}
|
||||
placeholder="my-blog"
|
||||
/>
|
||||
<p className="form-hint">
|
||||
Campaign auto-tag: <code>{wpCampaignSlug(siteName || 'my-blog')}</code>
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="form-group">
|
||||
<label className="label" htmlFor="sc-campaign">Campaign slug (?c=)</label>
|
||||
<input
|
||||
id="sc-campaign"
|
||||
className="input mono"
|
||||
value={campaign}
|
||||
onChange={(e) => onCampaignChange(e.target.value)}
|
||||
placeholder="ci-bootstrap"
|
||||
/>
|
||||
<p className="form-hint">
|
||||
Package name: <code>{npmPackageName(campaign || 'npm-helper')}</code>
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
<div className="supply-chain-preview">
|
||||
<p className="form-hint" style={{ marginTop: 0 }}>
|
||||
Live preview — {family === 'wordpress' ? 'plugin download URL' : 'postinstall target'}
|
||||
</p>
|
||||
<code className="mono supply-chain-preview-url">{preview.artifact}</code>
|
||||
<CopyChip text={preview.artifact} label="Copy URL" />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{step === 'download' && (
|
||||
<>
|
||||
<p className="form-hint">
|
||||
Downloads a customized ZIP from{' '}
|
||||
<code>templates/{family === 'wordpress' ? 'wordpress-plugin' : 'npm-helper-package'}/</code>
|
||||
with your server URL and campaign baked in.
|
||||
</p>
|
||||
<ul className="supply-chain-download-meta">
|
||||
<li>
|
||||
<strong>ZIP:</strong> <code>{preview.zipName}</code>
|
||||
</li>
|
||||
<li>
|
||||
<strong>Campaign:</strong> <code>{preview.campaignTag}</code>
|
||||
</li>
|
||||
<li>
|
||||
<strong>{family === 'wordpress' ? 'Get URL' : 'install.sh'}:</strong>{' '}
|
||||
<code className="mono" style={{ wordBreak: 'break-all' }}>
|
||||
{preview.artifact}
|
||||
</code>
|
||||
</li>
|
||||
<li>
|
||||
<strong>Detail:</strong> {preview.extra}
|
||||
</li>
|
||||
</ul>
|
||||
<div className="supply-chain-export-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary"
|
||||
disabled={exportBusy || !serverBase.trim() || (family === 'wordpress' && !siteName.trim())}
|
||||
onClick={() => void runExport()}
|
||||
>
|
||||
{exportBusy
|
||||
? 'Zipping…'
|
||||
: family === 'wordpress'
|
||||
? 'Export WordPress Plugin ZIP'
|
||||
: 'Export npm package template ZIP'}
|
||||
</button>
|
||||
<a className="btn btn-outline btn-sm" href={wikiUrl} target="_blank" rel="noreferrer">
|
||||
Read wiki §
|
||||
</a>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{step === 'host' && (
|
||||
<>
|
||||
<p className="form-hint">
|
||||
Copy hosting steps below. Full playbook:{' '}
|
||||
<a href={wikiUrl} target="_blank" rel="noreferrer">
|
||||
{family === 'wordpress' ? 'WordPress plugin supply chain' : 'npm postinstall helper'}
|
||||
</a>
|
||||
</p>
|
||||
{instructions.map((block) => (
|
||||
<div key={block.title} className="forge-mission-link-block">
|
||||
<p>{block.title}</p>
|
||||
<code>{block.body}</code>
|
||||
<CopyChip text={block.body} label={`Copy ${block.title}`} />
|
||||
</div>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-outline btn-sm"
|
||||
onClick={() => openChecklist(false)}
|
||||
>
|
||||
Open post-export checklist
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="supply-chain-wizard-nav">
|
||||
<button type="button" className="btn btn-outline btn-sm" disabled={step === 'pick-build'} onClick={goBack}>
|
||||
← Back
|
||||
</button>
|
||||
{step !== 'host' && step !== 'download' && (
|
||||
<button type="button" className="btn btn-primary btn-sm" disabled={!stepValid} onClick={goNext}>
|
||||
Next →
|
||||
</button>
|
||||
)}
|
||||
{step === 'download' && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-outline btn-sm"
|
||||
onClick={() => setStep('host')}
|
||||
>
|
||||
Skip to hosting instructions
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="supply-chain-quick-export">
|
||||
<p className="form-hint" style={{ marginBottom: '0.35rem' }}>
|
||||
Quick export (same APIs — no wizard steps):
|
||||
</p>
|
||||
<div className="emberwake-ab-row">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-outline btn-sm"
|
||||
disabled={wpExportBusy || !serverBase || !siteName.trim()}
|
||||
onClick={() => void (async () => {
|
||||
setWpExportBusy(true);
|
||||
try {
|
||||
await api.exportWordPressPlugin({
|
||||
build_id: buildId,
|
||||
server_url: serverBase,
|
||||
campaign,
|
||||
site_name: siteName,
|
||||
});
|
||||
setFamily('wordpress');
|
||||
openChecklist(true);
|
||||
} finally {
|
||||
setWpExportBusy(false);
|
||||
}
|
||||
})()}
|
||||
>
|
||||
{wpExportBusy ? 'Zipping…' : 'Export WordPress Plugin ZIP'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-outline btn-sm"
|
||||
disabled={npmExportBusy || !serverBase}
|
||||
onClick={() => void (async () => {
|
||||
setNpmExportBusy(true);
|
||||
try {
|
||||
await api.exportNpmHelper({
|
||||
build_id: buildId,
|
||||
server_url: serverBase,
|
||||
campaign: campaign.trim() || 'npm-helper',
|
||||
});
|
||||
setFamily('npm');
|
||||
openChecklist(true);
|
||||
} finally {
|
||||
setNpmExportBusy(false);
|
||||
}
|
||||
})()}
|
||||
>
|
||||
{npmExportBusy ? 'Zipping…' : 'Export npm package template ZIP'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{checklistOpen && (
|
||||
<div
|
||||
className="forge-mission-modal-backdrop"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="sc-checklist-title"
|
||||
onClick={closeChecklist}
|
||||
>
|
||||
<div className="forge-mission-modal supply-chain-checklist-modal" onClick={(e) => e.stopPropagation()}>
|
||||
{reelSession && <DeploymentReel family={family} animate />}
|
||||
<h3 id="sc-checklist-title">
|
||||
Post-export hosting checklist — {family === 'wordpress' ? 'WordPress' : 'npm'}
|
||||
</h3>
|
||||
<p className="form-hint">
|
||||
Complete these steps on infrastructure you operate.{' '}
|
||||
<a href={supplyChainHostingChecklistUrl(family)} target="_blank" rel="noreferrer">
|
||||
Wiki § hosting checklist
|
||||
</a>
|
||||
</p>
|
||||
<ul className="supply-chain-checklist">
|
||||
{checklist.map((item) => (
|
||||
<li key={item.id}>
|
||||
<label>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={!!checkedItems[item.id]}
|
||||
onChange={() => toggleCheck(item.id)}
|
||||
/>
|
||||
{item.label}
|
||||
</label>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
{allChecked && (
|
||||
<p className="supply-chain-checklist-done" role="status">
|
||||
✓ All steps marked — campaign should appear in War Room after first hit.
|
||||
</p>
|
||||
)}
|
||||
<div className="supply-chain-checklist-actions">
|
||||
<CopyChip
|
||||
text={checklist.map((c) => `[ ] ${c.label}`).join('\n')}
|
||||
label="Copy checklist"
|
||||
/>
|
||||
<button type="button" className="btn btn-primary btn-sm" onClick={closeChecklist}>
|
||||
Done
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -90,6 +90,11 @@ export default function AgentListItem({
|
||||
{agent.platform}{agent.arch ? `/${agent.arch}` : ''}
|
||||
</span>
|
||||
)}
|
||||
{agent.campaign && (
|
||||
<span className="agent-tag-chip" title="Spread campaign (?c=)">
|
||||
c:{agent.campaign}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.4rem' }}>
|
||||
<span className={`status-badge ${agent.status}`}>{agent.status}</span>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useModalAmbientDuck } from '../../context/AmbientMusicContext';
|
||||
import { FLEET_GROUP_COLORS, normalizeGroupColor } from '../../help/fleetGroups';
|
||||
import './CreateGroupModal.css';
|
||||
|
||||
@@ -10,6 +11,7 @@ interface Props {
|
||||
}
|
||||
|
||||
export default function CreateGroupModal({ open, agentCount, onClose, onCreate }: Props) {
|
||||
useModalAmbientDuck(open);
|
||||
const [name, setName] = useState('');
|
||||
const [color, setColor] = useState<string>(FLEET_GROUP_COLORS[0]);
|
||||
|
||||
|
||||
158
server/web/src/components/Fleet/FleetHeatMiniMap.css
Normal file
158
server/web/src/components/Fleet/FleetHeatMiniMap.css
Normal file
@@ -0,0 +1,158 @@
|
||||
/* ── Fleet heat mini-map (Crucible sidebar) ─────────────────────────────── */
|
||||
|
||||
.fleet-heat-minimap {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.fleet-heat-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.45rem;
|
||||
font-size: 0.72rem;
|
||||
letter-spacing: 0.1em;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.fleet-heat-count {
|
||||
margin-left: auto;
|
||||
color: var(--neon-cyan);
|
||||
font-size: 0.68rem;
|
||||
}
|
||||
|
||||
.fleet-heat-canvas {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
aspect-ratio: 1;
|
||||
min-height: 160px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid rgba(0, 245, 255, 0.18);
|
||||
background:
|
||||
radial-gradient(ellipse at 50% 45%, rgba(0, 245, 255, 0.06) 0%, transparent 65%),
|
||||
rgba(0, 0, 0, 0.45);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.fleet-heat-grid {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background-image:
|
||||
linear-gradient(rgba(0, 245, 255, 0.04) 1px, transparent 1px),
|
||||
linear-gradient(90deg, rgba(0, 245, 255, 0.04) 1px, transparent 1px);
|
||||
background-size: 20% 20%;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.fleet-heat-dot {
|
||||
position: absolute;
|
||||
transform: translate(-50%, -50%);
|
||||
border: none;
|
||||
padding: 0;
|
||||
cursor: default;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.fleet-heat-dot--agent {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
background: var(--dot-color, var(--neon-cyan));
|
||||
box-shadow: 0 0 6px var(--dot-color, var(--neon-cyan));
|
||||
cursor: pointer;
|
||||
transition: transform 0.12s, box-shadow 0.12s, opacity 0.12s;
|
||||
}
|
||||
|
||||
.fleet-heat-dot--agent:hover {
|
||||
transform: translate(-50%, -50%) scale(1.35);
|
||||
box-shadow: 0 0 12px var(--dot-color, var(--neon-cyan));
|
||||
}
|
||||
|
||||
.fleet-heat-dot--agent.offline {
|
||||
opacity: 0.35;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.fleet-heat-dot--agent.selected {
|
||||
transform: translate(-50%, -50%) scale(1.45);
|
||||
box-shadow:
|
||||
0 0 0 2px rgba(255, 255, 255, 0.35),
|
||||
0 0 14px var(--dot-color, var(--neon-cyan));
|
||||
}
|
||||
|
||||
.fleet-heat-dot--agent.spike-pulse {
|
||||
animation: fleet-heat-spike 1.1s ease-out;
|
||||
}
|
||||
|
||||
@keyframes fleet-heat-spike {
|
||||
0% {
|
||||
transform: translate(-50%, -50%) scale(1);
|
||||
box-shadow: 0 0 4px var(--dot-color, var(--neon-cyan));
|
||||
}
|
||||
35% {
|
||||
transform: translate(-50%, -50%) scale(2.2);
|
||||
box-shadow:
|
||||
0 0 0 3px rgba(255, 255, 255, 0.25),
|
||||
0 0 22px var(--dot-color, var(--neon-cyan)),
|
||||
0 0 36px rgba(0, 245, 255, 0.55);
|
||||
}
|
||||
100% {
|
||||
transform: translate(-50%, -50%) scale(1);
|
||||
box-shadow: 0 0 6px var(--dot-color, var(--neon-cyan));
|
||||
}
|
||||
}
|
||||
|
||||
.fleet-heat-dot--comrade {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: var(--dot-color, #ffb020);
|
||||
box-shadow: 0 0 8px rgba(255, 176, 32, 0.85);
|
||||
border: 1px solid rgba(255, 220, 120, 0.7);
|
||||
z-index: 3;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.fleet-heat-legend {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem 0.75rem;
|
||||
font-size: 0.62rem;
|
||||
color: var(--text-muted);
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.fleet-heat-legend > span {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.3rem;
|
||||
}
|
||||
|
||||
.fleet-heat-legend-dot {
|
||||
display: inline-block;
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.fleet-heat-legend-dot.agent {
|
||||
background: var(--neon-cyan);
|
||||
box-shadow: 0 0 4px var(--neon-cyan);
|
||||
}
|
||||
|
||||
.fleet-heat-legend-dot.comrade {
|
||||
background: #ffb020;
|
||||
box-shadow: 0 0 4px #ffb020;
|
||||
}
|
||||
|
||||
.fleet-heat-legend-dot.pulse {
|
||||
background: var(--neon-cyan);
|
||||
animation: fleet-heat-spike 1.1s ease-out infinite;
|
||||
}
|
||||
|
||||
.fleet-heat-empty {
|
||||
margin: 0;
|
||||
font-size: 0.72rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
137
server/web/src/components/Fleet/FleetHeatMiniMap.tsx
Normal file
137
server/web/src/components/Fleet/FleetHeatMiniMap.tsx
Normal file
@@ -0,0 +1,137 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import type { Agent } from '../../types';
|
||||
import type { FleetGroup } from '../../help/fleetGroups';
|
||||
import { formatHashrate } from '../../help/fleetFilters';
|
||||
import { usePresence } from '../../context/PresenceContext';
|
||||
import {
|
||||
agentAccentColor,
|
||||
COMRADE_DOT_COLOR,
|
||||
hashrateSpiked,
|
||||
layoutAgentPoints,
|
||||
layoutComradePoints,
|
||||
} from '../../help/fleetHeatMap';
|
||||
import './FleetHeatMiniMap.css';
|
||||
|
||||
interface FleetHeatMiniMapProps {
|
||||
agents: Agent[];
|
||||
groups: FleetGroup[];
|
||||
allIds: string[];
|
||||
selectedIds: Set<string>;
|
||||
onSelectAgent: (id: string) => void;
|
||||
}
|
||||
|
||||
export default function FleetHeatMiniMap({
|
||||
agents,
|
||||
groups,
|
||||
allIds,
|
||||
selectedIds,
|
||||
onSelectAgent,
|
||||
}: FleetHeatMiniMapProps) {
|
||||
const { comrades } = usePresence();
|
||||
const prevHashrateRef = useRef<Record<string, number>>({});
|
||||
const [spikingIds, setSpikingIds] = useState<Set<string>>(() => new Set());
|
||||
|
||||
useEffect(() => {
|
||||
const spikes = new Set<string>();
|
||||
for (const agent of agents) {
|
||||
const prev = prevHashrateRef.current[agent.id];
|
||||
const current = agent.hashrate_15s ?? 0;
|
||||
if (hashrateSpiked(prev, current)) spikes.add(agent.id);
|
||||
prevHashrateRef.current[agent.id] = current;
|
||||
}
|
||||
if (spikes.size === 0) return;
|
||||
setSpikingIds(spikes);
|
||||
const t = setTimeout(() => setSpikingIds(new Set()), 1200);
|
||||
return () => clearTimeout(t);
|
||||
}, [agents]);
|
||||
|
||||
const agentPoints = useMemo(() => layoutAgentPoints(agents, groups), [agents, groups]);
|
||||
const comradePoints = useMemo(
|
||||
() => layoutComradePoints(comrades.map((c) => c.user)),
|
||||
[comrades],
|
||||
);
|
||||
|
||||
const onlineCount = agents.filter((a) => a.status === 'online').length;
|
||||
const agentById = useMemo(() => new Map(agents.map((a) => [a.id, a])), [agents]);
|
||||
|
||||
return (
|
||||
<div className="fleet-heat-minimap">
|
||||
<div className="fleet-heat-header font-tech">
|
||||
<span className="section-ornament">◆</span>
|
||||
FLEET HEAT
|
||||
<span className="fleet-heat-count">
|
||||
{onlineCount}/{agents.length}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{agents.length === 0 ? (
|
||||
<p className="fleet-heat-empty">No nodes yet — deploy a build to see the map.</p>
|
||||
) : (
|
||||
<div
|
||||
className="fleet-heat-canvas"
|
||||
role="img"
|
||||
aria-label={`Fleet heat map with ${agents.length} agents and ${comrades.length} online comrades`}
|
||||
>
|
||||
<div className="fleet-heat-grid" aria-hidden />
|
||||
|
||||
{agentPoints.map((pt) => {
|
||||
const agent = agentById.get(pt.id);
|
||||
if (!agent) return null;
|
||||
const selected = selectedIds.has(pt.id);
|
||||
const color = agentAccentColor(pt.id, allIds, pt.color);
|
||||
const pulsing = spikingIds.has(pt.id);
|
||||
const hr = agent.hashrate_15s ?? 0;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={pt.id}
|
||||
type="button"
|
||||
className={[
|
||||
'fleet-heat-dot',
|
||||
'fleet-heat-dot--agent',
|
||||
pt.online ? '' : 'offline',
|
||||
selected ? 'selected' : '',
|
||||
pulsing ? 'spike-pulse' : '',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ')}
|
||||
style={{ left: `${pt.x}%`, top: `${pt.y}%`, '--dot-color': color } as React.CSSProperties}
|
||||
title={`${pt.label} · ${agent.status}${hr > 0 ? ` · ${formatHashrate(hr)}` : ''}`}
|
||||
aria-label={`Select ${pt.label}`}
|
||||
aria-pressed={selected}
|
||||
onClick={() => onSelectAgent(pt.id)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
{comradePoints.map((pt) => (
|
||||
<span
|
||||
key={pt.id}
|
||||
className="fleet-heat-dot fleet-heat-dot--comrade"
|
||||
style={
|
||||
{ left: `${pt.x}%`, top: `${pt.y}%`, '--dot-color': COMRADE_DOT_COLOR } as React.CSSProperties
|
||||
}
|
||||
title={`Operator ${pt.label} online`}
|
||||
aria-label={`Comrade ${pt.label}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="fleet-heat-legend font-tech">
|
||||
<span>
|
||||
<i className="fleet-heat-legend-dot agent" aria-hidden />
|
||||
Agents
|
||||
</span>
|
||||
<span>
|
||||
<i className="fleet-heat-legend-dot comrade" aria-hidden />
|
||||
Comrades
|
||||
</span>
|
||||
<span>
|
||||
<i className="fleet-heat-legend-dot pulse" aria-hidden />
|
||||
Hash spike
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -185,7 +185,7 @@ export function FleetHealthCard({ health }: { health: FleetHealth }) {
|
||||
: '#ff3c50';
|
||||
|
||||
return (
|
||||
<NeonCard accent={accent as any} className="fleet-health-card" hud>
|
||||
<NeonCard accent={accent as any} className="fleet-health-card operator-deck-card operator-interactive" hud>
|
||||
<div className="fh-header">
|
||||
<div>
|
||||
<span className="fh-label font-tech">FLEET HEALTH</span>
|
||||
|
||||
73
server/web/src/components/Fleet/FleetRuntimePanel.css
Normal file
73
server/web/src/components/Fleet/FleetRuntimePanel.css
Normal file
@@ -0,0 +1,73 @@
|
||||
.fleet-policy-deck {
|
||||
border: 1px solid rgba(74, 222, 128, 0.25);
|
||||
box-shadow: 0 0 24px rgba(74, 222, 128, 0.08);
|
||||
}
|
||||
|
||||
.fleet-policy-eyebrow {
|
||||
margin: 0 0 0.35rem;
|
||||
font-size: 0.72rem;
|
||||
letter-spacing: 0.12em;
|
||||
color: rgba(74, 222, 128, 0.85);
|
||||
}
|
||||
|
||||
.fleet-policy-steps {
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.fleet-policy-step-panel {
|
||||
margin-top: 1rem;
|
||||
padding-top: 0.75rem;
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
.fleet-policy-step-title {
|
||||
margin: 0 0 0.75rem;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.fleet-policy-step-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.fleet-policy-review {
|
||||
padding: 0.75rem 1rem;
|
||||
border-radius: 8px;
|
||||
background: rgba(0, 0, 0, 0.25);
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
font-size: 0.92rem;
|
||||
}
|
||||
|
||||
.fleet-policy-review p {
|
||||
margin: 0.35rem 0;
|
||||
}
|
||||
|
||||
.fleet-policy-ack-banner {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 0.75rem;
|
||||
padding: 1rem 1.25rem;
|
||||
margin: 0.75rem 0;
|
||||
border-radius: 10px;
|
||||
background: rgba(74, 222, 128, 0.08);
|
||||
border: 1px solid rgba(74, 222, 128, 0.35);
|
||||
}
|
||||
|
||||
.fleet-policy-ack-count {
|
||||
font-size: 2.5rem;
|
||||
line-height: 1;
|
||||
color: var(--neon-green, #6f6);
|
||||
text-shadow: 0 0 16px rgba(74, 222, 128, 0.35);
|
||||
}
|
||||
|
||||
.fleet-policy-ack-label {
|
||||
font-size: 0.95rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.fleet-module-deck {
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
444
server/web/src/components/Fleet/FleetRuntimePanel.tsx
Normal file
444
server/web/src/components/Fleet/FleetRuntimePanel.tsx
Normal file
@@ -0,0 +1,444 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { api } from '../../api/client';
|
||||
import { useModalAmbientDuck } from '../../context/AmbientMusicContext';
|
||||
import { useWebSocket } from '../../hooks/useWebSocket';
|
||||
import { useFleetGroups } from '../../hooks/useFleetGroups';
|
||||
import type { FleetModuleManifest } from '../../types';
|
||||
import NeonCard from '../NeonCard/NeonCard';
|
||||
import './FleetRuntimePanel.css';
|
||||
|
||||
type TargetMode = 'all' | 'group';
|
||||
type WizardStep = 1 | 2 | 3 | 4;
|
||||
|
||||
const POLICY_STEPS: { step: WizardStep; label: string }[] = [
|
||||
{ step: 1, label: 'Pick target' },
|
||||
{ step: 2, label: 'Set policy' },
|
||||
{ step: 3, label: 'Confirm push' },
|
||||
{ step: 4, label: 'Live acks' },
|
||||
];
|
||||
|
||||
function stepStatus(current: WizardStep, step: WizardStep): 'done' | 'active' | 'pending' {
|
||||
if (step < current) return 'done';
|
||||
if (step === current) return 'active';
|
||||
return 'pending';
|
||||
}
|
||||
|
||||
export default function FleetRuntimePanel() {
|
||||
const { agents, policyAcks } = useWebSocket();
|
||||
const { groups } = useFleetGroups();
|
||||
const [modules, setModules] = useState<FleetModuleManifest[]>([]);
|
||||
const [wizardStep, setWizardStep] = useState<WizardStep>(1);
|
||||
const [targetMode, setTargetMode] = useState<TargetMode>('all');
|
||||
const [groupId, setGroupId] = useState('');
|
||||
const [selectedModule, setSelectedModule] = useState('crucible_ops');
|
||||
const [miningMode, setMiningMode] = useState('scheduled');
|
||||
const [scheduleStart, setScheduleStart] = useState('22:00');
|
||||
const [scheduleEnd, setScheduleEnd] = useState('06:00');
|
||||
const [maxCpu, setMaxCpu] = useState(75);
|
||||
const [poolHost, setPoolHost] = useState('');
|
||||
const [poolPort, setPoolPort] = useState(0);
|
||||
const [policyMsg, setPolicyMsg] = useState('');
|
||||
const [moduleMsg, setModuleMsg] = useState('');
|
||||
const [pushingPolicy, setPushingPolicy] = useState(false);
|
||||
const [pushingModule, setPushingModule] = useState(false);
|
||||
const [pushId, setPushId] = useState<string | null>(null);
|
||||
const [expectedSent, setExpectedSent] = useState(0);
|
||||
useModalAmbientDuck(wizardStep === 3);
|
||||
|
||||
useEffect(() => {
|
||||
api.listFleetModules().then(setModules).catch(() => setModules([]));
|
||||
}, []);
|
||||
|
||||
const onlineCount = useMemo(() => agents.filter((a) => a.status === 'online').length, [agents]);
|
||||
|
||||
const targetLabel = useMemo(() => {
|
||||
if (targetMode === 'all') return `All online (${onlineCount})`;
|
||||
const g = groups.find((x) => x.id === groupId);
|
||||
return g ? `${g.name} (${g.agentIds.length} agents)` : 'No group selected';
|
||||
}, [targetMode, groupId, groups, onlineCount]);
|
||||
|
||||
const ackCount = useMemo(() => {
|
||||
if (!pushId) return 0;
|
||||
const ids = new Set<string>();
|
||||
for (const ack of policyAcks) {
|
||||
if (ack.push_id === pushId && ack.agent_id) ids.add(ack.agent_id);
|
||||
}
|
||||
return ids.size;
|
||||
}, [policyAcks, pushId]);
|
||||
|
||||
const resolveAgentIds = (): string[] => {
|
||||
if (targetMode === 'all') return ['all'];
|
||||
const g = groups.find((x) => x.id === groupId);
|
||||
if (!g || g.agentIds.length === 0) return [];
|
||||
return g.agentIds;
|
||||
};
|
||||
|
||||
const targetReady = targetMode === 'all' || (groupId !== '' && resolveAgentIds().length > 0);
|
||||
|
||||
const handlePushPolicy = async () => {
|
||||
const agent_ids = resolveAgentIds();
|
||||
if (agent_ids.length === 0) {
|
||||
setPolicyMsg('Select a group with agents or use All online.');
|
||||
return;
|
||||
}
|
||||
setPushingPolicy(true);
|
||||
setPolicyMsg('');
|
||||
try {
|
||||
const policy: Record<string, unknown> = {
|
||||
mining_mode: miningMode,
|
||||
max_cpu_usage_pct: maxCpu,
|
||||
};
|
||||
if (miningMode === 'scheduled') {
|
||||
policy.schedule_start = scheduleStart;
|
||||
policy.schedule_end = scheduleEnd;
|
||||
}
|
||||
if (poolHost.trim()) {
|
||||
policy.pool_host = poolHost.trim();
|
||||
if (poolPort > 0) policy.pool_port = poolPort;
|
||||
}
|
||||
const res = await api.pushFleetPolicy({ agent_ids, policy });
|
||||
if (res.success) {
|
||||
setPushId(res.push_id ?? null);
|
||||
setExpectedSent(res.sent ?? 0);
|
||||
setWizardStep(4);
|
||||
setPolicyMsg(
|
||||
`Policy dispatched to ${res.sent} agent(s)${res.failed ? ` (${res.failed} delivery failures)` : ''}. Waiting for live acks…`,
|
||||
);
|
||||
} else {
|
||||
setPolicyMsg(res.error || 'No agents received the policy.');
|
||||
}
|
||||
} catch (e) {
|
||||
setPolicyMsg(e instanceof Error ? e.message : 'Push failed');
|
||||
} finally {
|
||||
setPushingPolicy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePushModule = async () => {
|
||||
const agent_ids = resolveAgentIds();
|
||||
if (agent_ids.length === 0) {
|
||||
setModuleMsg('Select a group with agents or use All online.');
|
||||
return;
|
||||
}
|
||||
setPushingModule(true);
|
||||
setModuleMsg('');
|
||||
try {
|
||||
const res = await api.pushFleetModule({ agent_ids, module: selectedModule });
|
||||
setModuleMsg(
|
||||
res.success
|
||||
? `Module "${res.module}" queued for ${res.sent} agent(s).`
|
||||
: res.error || 'No agents received the module push.',
|
||||
);
|
||||
} catch (e) {
|
||||
setModuleMsg(e instanceof Error ? e.message : 'Push failed');
|
||||
} finally {
|
||||
setPushingModule(false);
|
||||
}
|
||||
};
|
||||
|
||||
const resetWizard = () => {
|
||||
setWizardStep(1);
|
||||
setPushId(null);
|
||||
setExpectedSent(0);
|
||||
setPolicyMsg('');
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<NeonCard accent="green" className="settings-section fleet-policy-deck operator-deck-card operator-interactive" hud>
|
||||
<p className="fleet-policy-eyebrow font-tech">RUNTIME · NO RE-FORGE</p>
|
||||
<h2 className="font-display">Live Fleet Policy</h2>
|
||||
<p className="section-desc">
|
||||
Push live mining rules to connected workers — schedule, CPU cap, and optional pool overrides apply in memory
|
||||
via <code className="mono-sm">policy_update</code>. Identity and baked forge options stay on the binary; this
|
||||
panel never replaces the Forge builder.
|
||||
</p>
|
||||
|
||||
<div className="forge-mission-steps fleet-policy-steps" aria-label="Policy push steps">
|
||||
{POLICY_STEPS.map(({ step, label }) => {
|
||||
const status = stepStatus(wizardStep, step);
|
||||
return (
|
||||
<span key={step} className={`forge-mission-step ${status}`}>
|
||||
{status === 'done' ? '✓' : status === 'active' ? '●' : '○'} {step}. {label}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{wizardStep === 1 && (
|
||||
<div className="fleet-policy-step-panel operator-interactive">
|
||||
<h3 className="fleet-policy-step-title">Step 1 — Pick target</h3>
|
||||
<div className="form-row">
|
||||
<div className="form-group">
|
||||
<label className="label" htmlFor="fleet-target-mode">
|
||||
Target fleet
|
||||
</label>
|
||||
<select
|
||||
id="fleet-target-mode"
|
||||
className="input"
|
||||
value={targetMode}
|
||||
onChange={(e) => setTargetMode(e.target.value as TargetMode)}
|
||||
>
|
||||
<option value="all">All online ({onlineCount})</option>
|
||||
<option value="group">Fleet group</option>
|
||||
</select>
|
||||
</div>
|
||||
{targetMode === 'group' && (
|
||||
<div className="form-group">
|
||||
<label className="label" htmlFor="fleet-group">
|
||||
Group
|
||||
</label>
|
||||
<select
|
||||
id="fleet-group"
|
||||
className="input"
|
||||
value={groupId}
|
||||
onChange={(e) => setGroupId(e.target.value)}
|
||||
>
|
||||
<option value="">Select group…</option>
|
||||
{groups.map((g) => (
|
||||
<option key={g.id} value={g.id}>
|
||||
{g.name} ({g.agentIds.length})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="fleet-policy-step-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary"
|
||||
disabled={!targetReady}
|
||||
onClick={() => setWizardStep(2)}
|
||||
>
|
||||
Next — Set policy
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{wizardStep === 2 && (
|
||||
<div className="fleet-policy-step-panel operator-interactive">
|
||||
<h3 className="fleet-policy-step-title">Step 2 — Set policy</h3>
|
||||
<p className="form-hint">Target: {targetLabel}</p>
|
||||
<div className="form-row">
|
||||
<div className="form-group">
|
||||
<label className="label" htmlFor="fleet-mining-mode">
|
||||
Mining mode
|
||||
</label>
|
||||
<select
|
||||
id="fleet-mining-mode"
|
||||
className="input"
|
||||
value={miningMode}
|
||||
onChange={(e) => setMiningMode(e.target.value)}
|
||||
>
|
||||
<option value="always">Always</option>
|
||||
<option value="idle">Idle</option>
|
||||
<option value="scheduled">Scheduled</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="label" htmlFor="fleet-max-cpu">
|
||||
Max CPU %
|
||||
</label>
|
||||
<input
|
||||
id="fleet-max-cpu"
|
||||
type="number"
|
||||
className="input"
|
||||
min={10}
|
||||
max={100}
|
||||
value={maxCpu}
|
||||
onChange={(e) => setMaxCpu(parseInt(e.target.value, 10) || 75)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{miningMode === 'scheduled' && (
|
||||
<div className="form-row">
|
||||
<div className="form-group">
|
||||
<label className="label" htmlFor="fleet-sched-start">
|
||||
Mine from
|
||||
</label>
|
||||
<input
|
||||
id="fleet-sched-start"
|
||||
type="time"
|
||||
className="input"
|
||||
value={scheduleStart}
|
||||
onChange={(e) => setScheduleStart(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="label" htmlFor="fleet-sched-end">
|
||||
Mine until
|
||||
</label>
|
||||
<input
|
||||
id="fleet-sched-end"
|
||||
type="time"
|
||||
className="input"
|
||||
value={scheduleEnd}
|
||||
onChange={(e) => setScheduleEnd(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="form-row">
|
||||
<div className="form-group">
|
||||
<label className="label" htmlFor="fleet-pool-host">
|
||||
Pool host (optional)
|
||||
</label>
|
||||
<input
|
||||
id="fleet-pool-host"
|
||||
type="text"
|
||||
className="input mono"
|
||||
placeholder="leave blank to keep baked pool"
|
||||
value={poolHost}
|
||||
onChange={(e) => setPoolHost(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="label" htmlFor="fleet-pool-port">
|
||||
Pool port
|
||||
</label>
|
||||
<input
|
||||
id="fleet-pool-port"
|
||||
type="number"
|
||||
className="input"
|
||||
min={0}
|
||||
value={poolPort || ''}
|
||||
onChange={(e) => setPoolPort(parseInt(e.target.value, 10) || 0)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="fleet-policy-step-actions">
|
||||
<button type="button" className="btn btn-outline" onClick={() => setWizardStep(1)}>
|
||||
Back
|
||||
</button>
|
||||
<button type="button" className="btn btn-primary" onClick={() => setWizardStep(3)}>
|
||||
Next — Review
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{wizardStep === 3 && (
|
||||
<div className="fleet-policy-step-panel operator-interactive">
|
||||
<h3 className="fleet-policy-step-title">Step 3 — Confirm push</h3>
|
||||
<div className="fleet-policy-review">
|
||||
<p>
|
||||
<strong>Target:</strong> {targetLabel}
|
||||
</p>
|
||||
<p>
|
||||
<strong>Mining:</strong> {miningMode}
|
||||
{miningMode === 'scheduled' ? ` · ${scheduleStart} → ${scheduleEnd}` : ''}
|
||||
</p>
|
||||
<p>
|
||||
<strong>CPU cap:</strong> {maxCpu}%
|
||||
</p>
|
||||
<p>
|
||||
<strong>Pool override:</strong>{' '}
|
||||
{poolHost.trim() ? `${poolHost.trim()}${poolPort > 0 ? `:${poolPort}` : ''}` : 'none (keep baked)'}
|
||||
</p>
|
||||
</div>
|
||||
<div className="fleet-policy-step-actions">
|
||||
<button type="button" className="btn btn-outline" onClick={() => setWizardStep(2)}>
|
||||
Back
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary"
|
||||
onClick={() => void handlePushPolicy()}
|
||||
disabled={pushingPolicy || !targetReady}
|
||||
>
|
||||
{pushingPolicy ? 'Pushing…' : 'Confirm & push policy'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{wizardStep === 4 && (
|
||||
<div className="fleet-policy-step-panel operator-interactive" aria-live="polite">
|
||||
<h3 className="fleet-policy-step-title">Step 4 — Live acknowledgements</h3>
|
||||
<div className="fleet-policy-ack-banner">
|
||||
<span className="fleet-policy-ack-count font-display">{ackCount}</span>
|
||||
<span className="fleet-policy-ack-label">
|
||||
agent{ackCount === 1 ? '' : 's'} acknowledged
|
||||
{expectedSent > 0 ? ` · ${expectedSent} dispatched` : ''}
|
||||
</span>
|
||||
</div>
|
||||
{pushId && <p className="form-hint mono-sm">push_id: {pushId}</p>}
|
||||
{policyMsg && <p className="form-hint">{policyMsg}</p>}
|
||||
<div className="fleet-policy-step-actions">
|
||||
<button type="button" className="btn btn-outline" onClick={resetWizard}>
|
||||
Push another policy
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</NeonCard>
|
||||
|
||||
<NeonCard accent="magenta" className="settings-section fleet-module-deck operator-deck-card operator-interactive">
|
||||
<h2 className="font-display">Push Module to Fleet</h2>
|
||||
<p className="section-desc">
|
||||
Stage signed feature packs from <code className="mono-sm">data/modules/</code> — agents fetch via{' '}
|
||||
<code className="mono-sm">GET /api/v1/agent/module/{name}</code> and enable flags without a full
|
||||
re-forge. Uses the same target picker as Live Fleet Policy above.
|
||||
</p>
|
||||
<div className="form-row">
|
||||
<div className="form-group">
|
||||
<label className="label" htmlFor="fleet-target-mode-module">
|
||||
Target
|
||||
</label>
|
||||
<select
|
||||
id="fleet-target-mode-module"
|
||||
className="input"
|
||||
value={targetMode}
|
||||
onChange={(e) => setTargetMode(e.target.value as TargetMode)}
|
||||
>
|
||||
<option value="all">All online ({onlineCount})</option>
|
||||
<option value="group">Fleet group</option>
|
||||
</select>
|
||||
</div>
|
||||
{targetMode === 'group' && (
|
||||
<div className="form-group">
|
||||
<label className="label" htmlFor="fleet-group-module">
|
||||
Group
|
||||
</label>
|
||||
<select id="fleet-group-module" className="input" value={groupId} onChange={(e) => setGroupId(e.target.value)}>
|
||||
<option value="">Select group…</option>
|
||||
{groups.map((g) => (
|
||||
<option key={g.id} value={g.id}>
|
||||
{g.name} ({g.agentIds.length})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="form-row">
|
||||
<div className="form-group">
|
||||
<label className="label" htmlFor="fleet-module">
|
||||
Module pack
|
||||
</label>
|
||||
<select
|
||||
id="fleet-module"
|
||||
className="input"
|
||||
value={selectedModule}
|
||||
onChange={(e) => setSelectedModule(e.target.value)}
|
||||
>
|
||||
{(modules.length ? modules : [{ name: 'crucible_ops', description: 'Remote aggressive ops' }]).map(
|
||||
(m) => (
|
||||
<option key={m.name} value={m.name}>
|
||||
{m.name} — {m.description || ('version' in m ? m.version : '')}
|
||||
</option>
|
||||
),
|
||||
)}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<button type="button" className="btn btn-outline" onClick={handlePushModule} disabled={pushingModule}>
|
||||
{pushingModule ? 'Pushing…' : 'Push module'}
|
||||
</button>
|
||||
{moduleMsg && <p className="form-hint" style={{ marginTop: '0.5rem' }}>{moduleMsg}</p>}
|
||||
</NeonCard>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useEffect } from 'react';
|
||||
import type { BuildResponse } from '../../types';
|
||||
import { useModalAmbientDuck } from '../../context/AmbientMusicContext';
|
||||
import { useSound } from '../../context/SoundContext';
|
||||
import DownloadButton from '../DownloadButton';
|
||||
import './ForgeDispenseReveal.css';
|
||||
@@ -20,6 +21,7 @@ function dnaBars(fingerprint?: string): number[] {
|
||||
}
|
||||
|
||||
export default function ForgeDispenseReveal({ result, onClose }: Props) {
|
||||
useModalAmbientDuck(true);
|
||||
const { play } = useSound();
|
||||
const score = result.stealth_score ?? 0;
|
||||
const bars = dnaBars(result.binary_fingerprint);
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
0 4px 18px rgba(0, 0, 0, 0.55),
|
||||
0 0 1px rgba(0, 245, 255, 0.15);
|
||||
pointer-events: auto;
|
||||
opacity: 0.72;
|
||||
opacity: var(--deck-ambient-ui-opacity, 0.72);
|
||||
transition: opacity 0.25s ease, border-color 0.25s ease, box-shadow 0.25s ease;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,13 +1,24 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import { useAmbientMusic } from '../context/AmbientMusicContext';
|
||||
import { resolvePageAmbientIntensity } from '../audio/ambientMusic';
|
||||
import './GlobalMusicPlayer.css';
|
||||
|
||||
export default function GlobalMusicPlayer() {
|
||||
const { enabled, playing, volume, setVolume, togglePlay } = useAmbientMusic();
|
||||
const { enabled, playing, volume, setVolume, togglePlay, setPageIntensity, pageIntensity } = useAmbientMusic();
|
||||
const location = useLocation();
|
||||
const routeIntensity = resolvePageAmbientIntensity(location.pathname);
|
||||
const isDim = routeIntensity < 0.5;
|
||||
|
||||
useEffect(() => {
|
||||
setPageIntensity(routeIntensity);
|
||||
}, [routeIntensity, setPageIntensity]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="global-music-player"
|
||||
className={`global-music-player${isDim ? ' global-music-player--ambient-dim' : ''}`}
|
||||
data-sfx="off"
|
||||
data-ambient-intensity={pageIntensity.toFixed(2)}
|
||||
role="region"
|
||||
aria-label="Background music controls"
|
||||
>
|
||||
|
||||
@@ -96,3 +96,22 @@
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.help-tip-doc-link {
|
||||
display: inline-block;
|
||||
margin-top: 0.45rem;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 600;
|
||||
color: var(--neon-amber);
|
||||
text-decoration: none;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
.help-tip-doc-link:hover {
|
||||
color: var(--neon-cyan);
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.help-tip-popup .help-tip-doc-link {
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { docAnchorForField } from '../help/docAnchors';
|
||||
import { FIELD_HELP } from '../help/settingHelp';
|
||||
import './HelpTip.css';
|
||||
|
||||
@@ -8,8 +9,23 @@ interface HelpTipProps {
|
||||
label?: string;
|
||||
}
|
||||
|
||||
function DocReadMoreLink({ href }: { href: string }) {
|
||||
return (
|
||||
<a
|
||||
href={href}
|
||||
className="help-tip-doc-link"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
Read more →
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
export function HelpTip({ field, label }: HelpTipProps) {
|
||||
const text = FIELD_HELP[field];
|
||||
const docAnchor = docAnchorForField(field);
|
||||
const triggerRef = useRef<HTMLButtonElement>(null);
|
||||
const popupRef = useRef<HTMLDivElement>(null);
|
||||
const [open, setOpen] = useState(false);
|
||||
@@ -107,6 +123,7 @@ export function HelpTip({ field, label }: HelpTipProps) {
|
||||
onMouseLeave={hide}
|
||||
>
|
||||
{text}
|
||||
{docAnchor && <DocReadMoreLink href={docAnchor} />}
|
||||
</div>,
|
||||
document.body,
|
||||
)}
|
||||
@@ -114,7 +131,9 @@ export function HelpTip({ field, label }: HelpTipProps) {
|
||||
);
|
||||
}
|
||||
|
||||
/** @deprecated Use HelpTip on the label instead — hints are shown on ? hover/click only. */
|
||||
export function FieldHint(_props: { field: string }) {
|
||||
return null;
|
||||
/** Inline doc link below a field when a wiki anchor exists. */
|
||||
export function FieldHint({ field }: { field: string }) {
|
||||
const docAnchor = docAnchorForField(field);
|
||||
if (!docAnchor) return null;
|
||||
return <DocReadMoreLink href={docAnchor} />;
|
||||
}
|
||||
|
||||
@@ -137,6 +137,24 @@
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.nav-item--docs {
|
||||
margin-top: 0.35rem;
|
||||
border: 1px solid rgba(0, 232, 245, 0.22);
|
||||
background: linear-gradient(90deg, rgba(0, 232, 245, 0.06), transparent);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.nav-item--docs:hover {
|
||||
background: linear-gradient(90deg, rgba(0, 232, 245, 0.14), rgba(168, 62, 240, 0.06));
|
||||
border-color: rgba(0, 232, 245, 0.45);
|
||||
box-shadow: 0 0 16px rgba(0, 232, 245, 0.12);
|
||||
}
|
||||
|
||||
.mobile-more-link--docs {
|
||||
border: 1px solid rgba(0, 232, 245, 0.25);
|
||||
background: rgba(0, 232, 245, 0.06);
|
||||
}
|
||||
|
||||
.nav-item.active {
|
||||
background: linear-gradient(90deg, rgba(0, 245, 255, 0.12), transparent);
|
||||
color: var(--neon-cyan);
|
||||
@@ -169,6 +187,23 @@
|
||||
border-radius: 0 2px 2px 0;
|
||||
}
|
||||
|
||||
.nav-item--mission.active {
|
||||
background: linear-gradient(90deg, rgba(255, 140, 58, 0.18), transparent);
|
||||
color: #ff8c3a;
|
||||
border-color: rgba(255, 140, 58, 0.45);
|
||||
box-shadow: inset 0 0 24px rgba(255, 140, 58, 0.12);
|
||||
}
|
||||
|
||||
.nav-item--mission:hover {
|
||||
color: #ffb366;
|
||||
border-color: rgba(255, 140, 58, 0.35);
|
||||
}
|
||||
|
||||
.nav-glow--mission {
|
||||
background: #ff8c3a;
|
||||
box-shadow: 0 0 14px #ff8c3a, 0 0 28px rgba(255, 140, 58, 0.45);
|
||||
}
|
||||
|
||||
/* ── Matrix rain ──────────────────────────────── */
|
||||
.matrix-rain-wrap {
|
||||
/* Flex-grow to fill all space between nav and footer */
|
||||
|
||||
@@ -11,30 +11,50 @@ import SacredGeometryLayer from '../Visual/sacredGeometry/SacredGeometryLayer';
|
||||
import { SacredMotif } from '../Visual/sacredGeometry/motifs';
|
||||
import SetupBanner from '../SetupBanner';
|
||||
import { getSetupStatus } from '../../help/setupStatus';
|
||||
import { resolvePageWeather } from '../../help/pageWeather';
|
||||
import { api } from '../../api/client';
|
||||
import { usePresence } from '../../context/PresenceContext';
|
||||
import ComradeAvatar from '../Presence/ComradeAvatar';
|
||||
import type { ServerConfig } from '../../types';
|
||||
import '../Presence/Presence.css';
|
||||
import './Layout.css';
|
||||
import './MobileNav.css';
|
||||
import '../../styles/operatorDeck.css';
|
||||
|
||||
interface LayoutProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
function operatorDeckId(pathname: string): string {
|
||||
const path = pathname.split('?')[0].replace(/\/$/, '') || '/';
|
||||
if (path.startsWith('/mission-deck')) return 'mission-deck';
|
||||
if (path.startsWith('/forge') || path.startsWith('/builder')) return 'forge';
|
||||
if (path.startsWith('/crucible')) return 'crucible';
|
||||
if (path.startsWith('/emberwake') || path.startsWith('/spread')) return 'emberwake';
|
||||
if (path.startsWith('/agents')) return 'fleet';
|
||||
if (path.startsWith('/builds')) return 'builds';
|
||||
if (path.startsWith('/settings')) return 'settings';
|
||||
if (path.startsWith('/pathtracer')) return 'pathtracer';
|
||||
return 'dashboard';
|
||||
}
|
||||
|
||||
const NAV = [
|
||||
{ to: '/dashboard', label: 'Command Deck', icon: 'deck' },
|
||||
{ to: '/agents', label: 'Fleet Roster', icon: 'fleet' },
|
||||
{ to: '/crucible', label: 'Crucible', icon: 'crucible' },
|
||||
{ to: '/forge', label: 'Forge', icon: 'forge' },
|
||||
{ to: '/mission-deck', label: 'Mission Deck', icon: 'mission', glow: true },
|
||||
{ to: '/builds', label: 'Builds', icon: 'builds' },
|
||||
{ to: '/emberwake', label: 'Emberwake', icon: 'ember' },
|
||||
{ to: '/guide', label: 'Field Guide', icon: 'guide' },
|
||||
{ to: '/settings', label: 'Calibrate', icon: 'gear' },
|
||||
{ to: '/pathtracer', label: 'Path Tracer', icon: 'trace' },
|
||||
] as const;
|
||||
|
||||
const DOCS_HREF = '/docs/';
|
||||
|
||||
/** Primary tabs on iPhone bottom bar */
|
||||
const MOBILE_PRIMARY = NAV.slice(0, 4);
|
||||
/** Builds, Guide, Calibrate, Path Tracer — “More” sheet */
|
||||
/** Builds, Calibrate, Path Tracer — “More” sheet */
|
||||
const MOBILE_MORE = NAV.slice(4);
|
||||
|
||||
function NavIcon({ type }: { type: string }) {
|
||||
@@ -60,6 +80,12 @@ function NavIcon({ type }: { type: string }) {
|
||||
<path d="M8 16l-2 4 4-2" />
|
||||
</svg>
|
||||
);
|
||||
case 'mission':
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
|
||||
<path d="M13 2L3 14h8l-1 8 10-12h-8l1-8z" />
|
||||
</svg>
|
||||
);
|
||||
case 'builds':
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
|
||||
@@ -69,14 +95,6 @@ function NavIcon({ type }: { type: string }) {
|
||||
<path d="M7 4.5h10M7 10.5h10M7 16.5h10" strokeOpacity="0.35" />
|
||||
</svg>
|
||||
);
|
||||
case 'guide':
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
|
||||
<path d="M4 19.5A2.5 2.5 0 016.5 17H20" />
|
||||
<path d="M6.5 2H20v20H6.5A2.5 2.5 0 014 19.5v-15A2.5 2.5 0 016.5 2z" />
|
||||
<path d="M8 7h8M8 11h6" />
|
||||
</svg>
|
||||
);
|
||||
case 'crucible':
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
|
||||
@@ -103,6 +121,14 @@ function NavIcon({ type }: { type: string }) {
|
||||
<path d="M12 8v4" />
|
||||
</svg>
|
||||
);
|
||||
case 'docs':
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
|
||||
<path d="M4 19.5A2.5 2.5 0 016.5 17H20" />
|
||||
<path d="M6.5 2H20v20H6.5A2.5 2.5 0 014 19.5v-15A2.5 2.5 0 016.5 2z" />
|
||||
<path d="M8 7h8M8 11h6" strokeOpacity="0.55" />
|
||||
</svg>
|
||||
);
|
||||
default:
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
|
||||
@@ -185,6 +211,7 @@ function MobileTopStats() {
|
||||
export default function Layout({ children }: LayoutProps) {
|
||||
const location = useLocation();
|
||||
const isMobile = useIsMobileLayout();
|
||||
const { othersOnline, comrades } = usePresence();
|
||||
const [serverConfig, setServerConfig] = useState<ServerConfig | null>(null);
|
||||
const [moreOpen, setMoreOpen] = useState(false);
|
||||
|
||||
@@ -206,6 +233,7 @@ export default function Layout({ children }: LayoutProps) {
|
||||
}, [moreOpen]);
|
||||
|
||||
const setupStatus = getSetupStatus(serverConfig);
|
||||
const pageWeather = resolvePageWeather(location.pathname);
|
||||
const moreActive = MOBILE_MORE.some((item) => location.pathname === item.to);
|
||||
const mobileShortLabel: Record<string, string> = {
|
||||
'/dashboard': 'Deck',
|
||||
@@ -215,9 +243,12 @@ export default function Layout({ children }: LayoutProps) {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`layout${isMobile ? ' layout--mobile' : ''}`}>
|
||||
<div
|
||||
className={`layout${isMobile ? ' layout--mobile' : ''}${othersOnline ? ' layout--comrades-online' : ''}`}
|
||||
data-operator-deck={operatorDeckId(location.pathname)}
|
||||
>
|
||||
{!isMobile && <CursorFire />}
|
||||
<AmbientBackground />
|
||||
<AmbientBackground weather={pageWeather} />
|
||||
<SacredGeometryLayer />
|
||||
<nav className="sidebar sidebar--desktop desktop-only">
|
||||
<div className="sidebar-header">
|
||||
@@ -238,15 +269,30 @@ export default function Layout({ children }: LayoutProps) {
|
||||
<NavLink
|
||||
key={item.to}
|
||||
to={item.to}
|
||||
className={({ isActive }) => `nav-item ${isActive ? 'active' : ''}`}
|
||||
className={({ isActive }) =>
|
||||
`nav-item${'glow' in item && item.glow ? ' nav-item--mission' : ''} ${isActive ? 'active' : ''}`
|
||||
}
|
||||
>
|
||||
<span className="nav-icon">
|
||||
<NavIcon type={item.icon} />
|
||||
</span>
|
||||
<span className="nav-label">{item.label}</span>
|
||||
{location.pathname === item.to && <span className="nav-glow" />}
|
||||
{location.pathname === item.to && (
|
||||
<span className={`nav-glow${'glow' in item && item.glow ? ' nav-glow--mission' : ''}`} />
|
||||
)}
|
||||
</NavLink>
|
||||
))}
|
||||
<a
|
||||
href={DOCS_HREF}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="nav-item nav-item--docs"
|
||||
>
|
||||
<span className="nav-icon">
|
||||
<NavIcon type="docs" />
|
||||
</span>
|
||||
<span className="nav-label">Documentation</span>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div className="sidebar-sacred-sigil" aria-hidden>
|
||||
@@ -258,6 +304,18 @@ export default function Layout({ children }: LayoutProps) {
|
||||
|
||||
<div className="sidebar-footer">
|
||||
<FleetReadout />
|
||||
{othersOnline && (
|
||||
<div className="sidebar-comrades">
|
||||
<div className="sidebar-comrades-avatars">
|
||||
{comrades.slice(0, 4).map((c) => (
|
||||
<ComradeAvatar key={c.user} user={c.user} size="sm" />
|
||||
))}
|
||||
</div>
|
||||
<span className="sidebar-comrades-label">
|
||||
{comrades.length} comrade{comrades.length === 1 ? '' : 's'} online
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="sidebar-sig font-tech">
|
||||
<span className="sig-love">made with <span className="sig-heart">♥</span> drjones</span>
|
||||
<span className="sig-ver">v0.0.1</span>
|
||||
@@ -300,6 +358,16 @@ export default function Layout({ children }: LayoutProps) {
|
||||
{item.label}
|
||||
</NavLink>
|
||||
))}
|
||||
<a
|
||||
href={DOCS_HREF}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="mobile-more-link mobile-more-link--docs"
|
||||
onClick={() => setMoreOpen(false)}
|
||||
>
|
||||
<NavIcon type="docs" />
|
||||
Documentation
|
||||
</a>
|
||||
</div>
|
||||
<nav className="mobile-bottom-nav" aria-label="Main navigation">
|
||||
{MOBILE_PRIMARY.map((item) => (
|
||||
|
||||
48
server/web/src/components/Presence/AlsoHere.tsx
Normal file
48
server/web/src/components/Presence/AlsoHere.tsx
Normal file
@@ -0,0 +1,48 @@
|
||||
import { usePresence } from '../../context/PresenceContext';
|
||||
import { presenceActivityLine, presencePageLabel } from '../../help/presencePages';
|
||||
import ComradeAvatar from './ComradeAvatar';
|
||||
import './Presence.css';
|
||||
|
||||
interface AlsoHereProps {
|
||||
page: string;
|
||||
}
|
||||
|
||||
export default function AlsoHere({ page }: AlsoHereProps) {
|
||||
const { comradesHere } = usePresence();
|
||||
const here = comradesHere(page);
|
||||
if (here.length === 0) return null;
|
||||
|
||||
const label = presencePageLabel(page);
|
||||
const count = here.length;
|
||||
|
||||
return (
|
||||
<div className="also-here-banner" role="status">
|
||||
<div className="also-here-beacon" aria-hidden>
|
||||
<span className="also-here-pulse-ring" />
|
||||
<span className="also-here-dot" />
|
||||
</div>
|
||||
<div className="also-here-body">
|
||||
<div className="also-here-avatars" aria-label={`${count} comrade${count === 1 ? '' : 's'} in ${label}`}>
|
||||
{here.map((c) => (
|
||||
<ComradeAvatar
|
||||
key={c.user}
|
||||
user={c.user}
|
||||
size="sm"
|
||||
title={presenceActivityLine(c.user, c.page)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<div className="also-here-text">
|
||||
<span className="also-here-headline">
|
||||
{count} comrade{count === 1 ? '' : 's'} in the war room
|
||||
</span>
|
||||
<span className="also-here-detail">
|
||||
Also here in <strong className="also-here-zone">{label}</strong>
|
||||
{': '}
|
||||
<span className="also-here-names">{here.map((c) => c.user).join(', ')}</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
24
server/web/src/components/Presence/ComradeAvatar.tsx
Normal file
24
server/web/src/components/Presence/ComradeAvatar.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
import './Presence.css';
|
||||
|
||||
export function comradeAvatarInitial(user: string): string {
|
||||
const ch = user.trim().charAt(0);
|
||||
return ch ? ch.toUpperCase() : '?';
|
||||
}
|
||||
|
||||
interface ComradeAvatarProps {
|
||||
user: string;
|
||||
size?: 'sm' | 'md';
|
||||
title?: string;
|
||||
}
|
||||
|
||||
export default function ComradeAvatar({ user, size = 'md', title }: ComradeAvatarProps) {
|
||||
return (
|
||||
<span
|
||||
className={`comrade-avatar comrade-avatar--${size}`}
|
||||
title={title}
|
||||
aria-label={title ?? user}
|
||||
>
|
||||
{comradeAvatarInitial(user)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
38
server/web/src/components/Presence/ComradeIndicators.tsx
Normal file
38
server/web/src/components/Presence/ComradeIndicators.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
import { usePresence } from '../../context/PresenceContext';
|
||||
import { presenceActivityLine, presencePageLabel } from '../../help/presencePages';
|
||||
import ComradeAvatar from './ComradeAvatar';
|
||||
import './Presence.css';
|
||||
|
||||
export default function ComradeIndicators() {
|
||||
const { comrades } = usePresence();
|
||||
if (comrades.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="comrade-presence" aria-label="Online comrades">
|
||||
<span className="comrade-presence-beacon" aria-hidden>
|
||||
<span className="comrade-presence-ring" />
|
||||
<span className="comrade-presence-core" />
|
||||
</span>
|
||||
<span className="comrade-presence-label">COMRADES</span>
|
||||
<div className="comrade-avatar-list">
|
||||
{comrades.map((c) => (
|
||||
<ComradeAvatar
|
||||
key={c.user}
|
||||
user={c.user}
|
||||
title={presenceActivityLine(c.user, c.page)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<span className="comrade-activity-text">
|
||||
{comrades.map((c, i) => (
|
||||
<span key={c.user} className="comrade-activity-line">
|
||||
{i > 0 && <span className="comrade-activity-sep"> · </span>}
|
||||
<strong className="comrade-activity-user">{c.user}</strong>
|
||||
<span className="comrade-activity-verb"> is in </span>
|
||||
<span className="comrade-activity-page">{presencePageLabel(c.page)}</span>
|
||||
</span>
|
||||
))}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
396
server/web/src/components/Presence/Presence.css
Normal file
396
server/web/src/components/Presence/Presence.css
Normal file
@@ -0,0 +1,396 @@
|
||||
/* ── Status bar: soft pulse when comrades online ── */
|
||||
@keyframes comrade-status-pulse {
|
||||
0%,
|
||||
100% {
|
||||
box-shadow:
|
||||
0 4px 24px rgba(0, 0, 0, 0.25),
|
||||
0 0 12px rgba(61, 214, 198, 0.08),
|
||||
inset 0 -1px 0 rgba(61, 214, 198, 0.15);
|
||||
border-bottom-color: rgba(61, 214, 198, 0.18);
|
||||
}
|
||||
50% {
|
||||
box-shadow:
|
||||
0 4px 28px rgba(0, 0, 0, 0.3),
|
||||
0 0 28px rgba(61, 214, 198, 0.22),
|
||||
inset 0 -1px 0 rgba(61, 214, 198, 0.35);
|
||||
border-bottom-color: rgba(61, 214, 198, 0.38);
|
||||
}
|
||||
}
|
||||
|
||||
.system-status-bar--comrades-online {
|
||||
animation: comrade-status-pulse 3.5s ease-in-out infinite;
|
||||
border-bottom: 1px solid rgba(61, 214, 198, 0.18);
|
||||
}
|
||||
|
||||
/* ── Comrade indicators (status bar) ── */
|
||||
.comrade-presence {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-left: auto;
|
||||
padding: 0.15rem 0.5rem;
|
||||
border-radius: 999px;
|
||||
border: 1px solid rgba(61, 214, 198, 0.2);
|
||||
background: linear-gradient(135deg, rgba(61, 214, 198, 0.06), rgba(8, 6, 4, 0.5));
|
||||
}
|
||||
|
||||
.comrade-presence-beacon {
|
||||
position: relative;
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.comrade-presence-core {
|
||||
position: absolute;
|
||||
inset: 2px;
|
||||
border-radius: 50%;
|
||||
background: var(--neon-green);
|
||||
box-shadow: 0 0 8px var(--neon-green);
|
||||
}
|
||||
|
||||
.comrade-presence-ring {
|
||||
position: absolute;
|
||||
inset: -2px;
|
||||
border-radius: 50%;
|
||||
border: 1px solid rgba(61, 214, 198, 0.5);
|
||||
animation: comrade-beacon-ring 2.4s ease-out infinite;
|
||||
}
|
||||
|
||||
@keyframes comrade-beacon-ring {
|
||||
0% {
|
||||
transform: scale(0.85);
|
||||
opacity: 0.9;
|
||||
}
|
||||
70%,
|
||||
100% {
|
||||
transform: scale(1.6);
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.comrade-presence-label {
|
||||
color: var(--text-muted);
|
||||
font-size: 0.68rem;
|
||||
letter-spacing: 0.08em;
|
||||
}
|
||||
|
||||
.comrade-activity-text {
|
||||
color: var(--neon-cyan);
|
||||
font-size: 0.68rem;
|
||||
opacity: 0.95;
|
||||
max-width: 28rem;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.comrade-activity-user {
|
||||
color: var(--text-primary);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.comrade-activity-verb {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.comrade-activity-page {
|
||||
color: var(--neon-cyan);
|
||||
font-family: var(--font-tech);
|
||||
letter-spacing: 0.03em;
|
||||
}
|
||||
|
||||
.comrade-activity-sep {
|
||||
color: rgba(61, 214, 198, 0.45);
|
||||
}
|
||||
|
||||
.comrade-avatar-list {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.comrade-avatar {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 1.5rem;
|
||||
height: 1.5rem;
|
||||
padding: 0 0.35rem;
|
||||
border-radius: 999px;
|
||||
border: 1px solid rgba(61, 214, 198, 0.35);
|
||||
background: rgba(8, 20, 18, 0.9);
|
||||
color: var(--neon-cyan);
|
||||
font-size: 0.62rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
position: relative;
|
||||
box-shadow: 0 0 10px rgba(61, 214, 198, 0.12);
|
||||
}
|
||||
|
||||
.comrade-avatar--sm {
|
||||
min-width: 1.25rem;
|
||||
height: 1.25rem;
|
||||
font-size: 0.55rem;
|
||||
padding: 0 0.25rem;
|
||||
}
|
||||
|
||||
.comrade-avatar::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
right: -1px;
|
||||
bottom: -1px;
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
border-radius: 50%;
|
||||
background: var(--neon-green);
|
||||
box-shadow: 0 0 6px var(--neon-green);
|
||||
border: 1px solid rgba(8, 6, 4, 0.9);
|
||||
animation: comrade-online-dot 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.comrade-avatar--sm::after {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
}
|
||||
|
||||
@keyframes comrade-online-dot {
|
||||
0%,
|
||||
100% {
|
||||
box-shadow: 0 0 4px var(--neon-green);
|
||||
}
|
||||
50% {
|
||||
box-shadow: 0 0 10px var(--neon-green);
|
||||
}
|
||||
}
|
||||
|
||||
.comrade-avatar[title] {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
/* ── "Also here" war-room banners (Crucible, Emberwake) ── */
|
||||
.also-here-banner {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 0.65rem;
|
||||
margin: 0 0 0.75rem;
|
||||
padding: 0.55rem 0.85rem;
|
||||
border-radius: 8px;
|
||||
border: 1px solid rgba(61, 214, 198, 0.3);
|
||||
background: linear-gradient(135deg, rgba(61, 214, 198, 0.1), rgba(8, 6, 4, 0.55));
|
||||
font-size: 0.78rem;
|
||||
color: var(--text-secondary);
|
||||
box-shadow: 0 0 20px rgba(61, 214, 198, 0.06);
|
||||
animation: also-here-glow 4s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes also-here-glow {
|
||||
0%,
|
||||
100% {
|
||||
box-shadow: 0 0 16px rgba(61, 214, 198, 0.05);
|
||||
}
|
||||
50% {
|
||||
box-shadow: 0 0 24px rgba(61, 214, 198, 0.14);
|
||||
}
|
||||
}
|
||||
|
||||
.also-here-beacon {
|
||||
position: relative;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
flex-shrink: 0;
|
||||
margin-top: 0.15rem;
|
||||
}
|
||||
|
||||
.also-here-dot {
|
||||
position: absolute;
|
||||
inset: 2px;
|
||||
border-radius: 50%;
|
||||
background: var(--neon-green);
|
||||
box-shadow: 0 0 8px var(--neon-green);
|
||||
}
|
||||
|
||||
.also-here-pulse-ring {
|
||||
position: absolute;
|
||||
inset: -3px;
|
||||
border-radius: 50%;
|
||||
border: 1px solid rgba(61, 214, 198, 0.55);
|
||||
animation: comrade-beacon-ring 2.4s ease-out infinite;
|
||||
}
|
||||
|
||||
.also-here-body {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.65rem;
|
||||
flex-wrap: wrap;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.also-here-avatars {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.3rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.also-here-text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.1rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.also-here-headline {
|
||||
font-family: var(--font-tech);
|
||||
font-size: 0.68rem;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
color: var(--neon-cyan);
|
||||
}
|
||||
|
||||
.also-here-detail {
|
||||
font-size: 0.76rem;
|
||||
}
|
||||
|
||||
.also-here-zone {
|
||||
color: var(--neon-cyan);
|
||||
font-family: var(--font-tech);
|
||||
letter-spacing: 0.03em;
|
||||
}
|
||||
|
||||
.also-here-names {
|
||||
color: var(--text-primary);
|
||||
font-family: var(--font-tech);
|
||||
letter-spacing: 0.03em;
|
||||
}
|
||||
|
||||
/* ── Emberwake notes typing indicator ── */
|
||||
.emberwake-typing-banner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.55rem;
|
||||
margin-bottom: 0.5rem;
|
||||
padding: 0.45rem 0.75rem;
|
||||
border-radius: 8px;
|
||||
border: 1px solid rgba(255, 107, 44, 0.4);
|
||||
background: linear-gradient(90deg, rgba(255, 107, 44, 0.12), rgba(8, 6, 4, 0.45));
|
||||
font-size: 0.78rem;
|
||||
color: var(--text-secondary);
|
||||
animation: emberwake-typing-pulse 2s ease-in-out infinite;
|
||||
box-shadow: 0 0 18px rgba(255, 107, 44, 0.1);
|
||||
}
|
||||
|
||||
.emberwake-typing-body {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.emberwake-typing-cursor {
|
||||
display: inline-block;
|
||||
width: 2px;
|
||||
height: 0.9em;
|
||||
background: var(--neon-amber);
|
||||
margin-left: 2px;
|
||||
animation: emberwake-cursor-blink 1s step-end infinite;
|
||||
}
|
||||
|
||||
.typing-dots {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 3px;
|
||||
margin-left: 4px;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.typing-dots span {
|
||||
width: 4px;
|
||||
height: 4px;
|
||||
border-radius: 50%;
|
||||
background: var(--neon-amber);
|
||||
animation: typing-dot-bounce 1.2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.typing-dots span:nth-child(2) {
|
||||
animation-delay: 0.15s;
|
||||
}
|
||||
|
||||
.typing-dots span:nth-child(3) {
|
||||
animation-delay: 0.3s;
|
||||
}
|
||||
|
||||
@keyframes typing-dot-bounce {
|
||||
0%,
|
||||
60%,
|
||||
100% {
|
||||
transform: translateY(0);
|
||||
opacity: 0.45;
|
||||
}
|
||||
30% {
|
||||
transform: translateY(-3px);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes emberwake-typing-pulse {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 0.88;
|
||||
box-shadow: 0 0 12px rgba(255, 107, 44, 0.08);
|
||||
}
|
||||
50% {
|
||||
opacity: 1;
|
||||
box-shadow: 0 0 22px rgba(255, 107, 44, 0.18);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes emberwake-cursor-blink {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Layout sidebar glow when comrades online ── */
|
||||
@keyframes sidebar-comrade-glow {
|
||||
0%,
|
||||
100% {
|
||||
box-shadow: inset -1px 0 0 rgba(61, 214, 198, 0.12), 4px 0 20px rgba(61, 214, 198, 0.04);
|
||||
}
|
||||
50% {
|
||||
box-shadow: inset -1px 0 0 rgba(61, 214, 198, 0.28), 4px 0 32px rgba(61, 214, 198, 0.1);
|
||||
}
|
||||
}
|
||||
|
||||
.layout--comrades-online .sidebar--desktop {
|
||||
animation: sidebar-comrade-glow 4s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.sidebar-comrades {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
margin: 0.35rem 0 0;
|
||||
padding: 0.3rem 0.4rem;
|
||||
border-radius: 6px;
|
||||
border: 1px solid rgba(61, 214, 198, 0.18);
|
||||
background: rgba(61, 214, 198, 0.04);
|
||||
}
|
||||
|
||||
.sidebar-comrades-avatars {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.2rem;
|
||||
}
|
||||
|
||||
.sidebar-comrades-label {
|
||||
font-size: 0.62rem;
|
||||
color: var(--neon-cyan);
|
||||
font-family: var(--font-tech);
|
||||
letter-spacing: 0.04em;
|
||||
opacity: 0.9;
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useState, type ReactNode } from 'react';
|
||||
import type { PublicBuildDTO } from '../types';
|
||||
import type { PublicBuildDTO, PublicBuildsResponse } from '../types';
|
||||
import {
|
||||
AETHERFORGE_CLIENT_HEADER,
|
||||
AETHERFORGE_CLIENT_VALUE,
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
} from '../api/auth';
|
||||
import { useSound } from '../context/SoundContext';
|
||||
import { FlowerOfLifeWatermark, KnowledgeKey } from './Visual/sacredGeometry/motifs';
|
||||
import DocsEntryCard from './DocsEntryCard';
|
||||
|
||||
export default function SessionGate({ children }: { children: ReactNode }) {
|
||||
const { play } = useSound();
|
||||
@@ -24,6 +25,8 @@ export default function SessionGate({ children }: { children: ReactNode }) {
|
||||
const [sessionExpired, setSessionExpired] = useState(false);
|
||||
const [publicOpen, setPublicOpen] = useState(false);
|
||||
const [publicBuilds, setPublicBuilds] = useState<PublicBuildDTO[]>([]);
|
||||
const [publicBuildsEnabled, setPublicBuildsEnabled] = useState(false);
|
||||
const [publicLatestN, setPublicLatestN] = useState(3);
|
||||
const [publicLoading, setPublicLoading] = useState(false);
|
||||
const [publicErr, setPublicErr] = useState('');
|
||||
|
||||
@@ -112,8 +115,10 @@ export default function SessionGate({ children }: { children: ReactNode }) {
|
||||
try {
|
||||
const res = await fetch('/api/v1/public/builds');
|
||||
if (!res.ok) throw new Error('unavailable');
|
||||
const data = (await res.json()) as { builds: PublicBuildDTO[] };
|
||||
const data = (await res.json()) as PublicBuildsResponse;
|
||||
setPublicBuilds(data.builds ?? []);
|
||||
setPublicBuildsEnabled(!!data.public_builds_enabled);
|
||||
setPublicLatestN(data.latest_n ?? 3);
|
||||
setPublicOpen(true);
|
||||
} catch {
|
||||
setPublicErr('Public builds are not available yet — forge an installer first.');
|
||||
@@ -163,20 +168,32 @@ export default function SessionGate({ children }: { children: ReactNode }) {
|
||||
<p className="session-gate-whisper" aria-hidden>
|
||||
ψ · the deck remembers every key
|
||||
</p>
|
||||
<div className="session-public-drawer" style={{ marginTop: '1.25rem', width: '100%' }}>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-outline btn-sm"
|
||||
style={{ width: '100%' }}
|
||||
onClick={() => void loadPublicBuilds()}
|
||||
disabled={publicLoading}
|
||||
>
|
||||
{publicLoading ? 'Loading…' : 'Public builds (no login)'}
|
||||
</button>
|
||||
<DocsEntryCard variant="featured" />
|
||||
<div className="session-public-drawer" style={{ marginTop: '1rem', width: '100%' }}>
|
||||
<div style={{ display: 'flex', gap: '0.5rem', flexWrap: 'wrap' }}>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-outline btn-sm"
|
||||
style={{ flex: 1, minWidth: '10rem' }}
|
||||
onClick={() => void loadPublicBuilds()}
|
||||
disabled={publicLoading}
|
||||
>
|
||||
{publicLoading ? 'Loading…' : 'Public builds (no login)'}
|
||||
</button>
|
||||
<a
|
||||
href="/spread/"
|
||||
className="btn btn-outline btn-sm"
|
||||
style={{ flex: 1, minWidth: '10rem', textAlign: 'center' }}
|
||||
>
|
||||
Spread Kit
|
||||
</a>
|
||||
</div>
|
||||
{publicOpen && (
|
||||
<div className="card" style={{ marginTop: '0.75rem', textAlign: 'left' }}>
|
||||
<p className="form-hint" style={{ marginTop: 0 }}>
|
||||
Pinned + latest forged installers — no credentials required.
|
||||
{publicBuildsEnabled
|
||||
? 'All forged installers exposed — no credentials required.'
|
||||
: `Pinned + marked-public + latest ${publicLatestN} forged installers — no credentials required.`}
|
||||
</p>
|
||||
{publicErr && <p className="form-hint" style={{ color: 'var(--accent-red)' }}>{publicErr}</p>}
|
||||
{publicBuilds.length === 0 && !publicErr && (
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { api } from '../../api/client';
|
||||
import ComradeIndicators from '../Presence/ComradeIndicators';
|
||||
import { usePresence } from '../../context/PresenceContext';
|
||||
import '../Presence/Presence.css';
|
||||
import './VisualComponents.css';
|
||||
|
||||
export default function SystemStatusBar() {
|
||||
@@ -8,6 +10,7 @@ export default function SystemStatusBar() {
|
||||
const [agentTotal, setAgentTotal] = useState(0);
|
||||
const [agentOnline, setAgentOnline] = useState(0);
|
||||
const [buildCount, setBuildCount] = useState(0);
|
||||
const { othersOnline } = usePresence();
|
||||
|
||||
useEffect(() => {
|
||||
const poll = async () => {
|
||||
@@ -38,7 +41,7 @@ export default function SystemStatusBar() {
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="system-status-bar">
|
||||
<div className={`system-status-bar${othersOnline ? ' system-status-bar--comrades-online' : ''}`}>
|
||||
<span className={`status-pill ${serverOk ? 'ok' : 'bad'}`}>
|
||||
<span className="status-pill-dot" />
|
||||
SERVER {serverOk ? 'UP' : 'DOWN'}
|
||||
@@ -51,9 +54,16 @@ export default function SystemStatusBar() {
|
||||
<span className="status-pill-dot" />
|
||||
{buildCount} BUILD{buildCount === 1 ? '' : 'S'}
|
||||
</span>
|
||||
<Link to="/guide" className="status-pill" style={{ marginLeft: 'auto', textDecoration: 'none', color: 'var(--neon-cyan)' }}>
|
||||
📖 GUIDE
|
||||
</Link>
|
||||
<ComradeIndicators />
|
||||
<a
|
||||
href="/docs/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="status-pill"
|
||||
style={{ textDecoration: 'none', color: 'var(--neon-cyan)' }}
|
||||
>
|
||||
📖 DOCS
|
||||
</a>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
176
server/web/src/components/WarRoom/CampaignConstellations.tsx
Normal file
176
server/web/src/components/WarRoom/CampaignConstellations.tsx
Normal file
@@ -0,0 +1,176 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import type { WarRoomCampaign } from '../../types';
|
||||
import {
|
||||
buildConstellationGraph,
|
||||
initNodePositions,
|
||||
tickForceLayout,
|
||||
type ConstellationNode,
|
||||
} from '../../help/campaignConstellations';
|
||||
|
||||
interface CampaignConstellationsProps {
|
||||
campaigns: WarRoomCampaign[];
|
||||
onSelectCampaign?: (campaign: string) => void;
|
||||
}
|
||||
|
||||
const SIM_TICKS = 180;
|
||||
const HEIGHT = 360;
|
||||
|
||||
export default function CampaignConstellations({ campaigns, onSelectCampaign }: CampaignConstellationsProps) {
|
||||
const wrapRef = useRef<HTMLDivElement>(null);
|
||||
const [width, setWidth] = useState(640);
|
||||
const [hovered, setHovered] = useState<string | null>(null);
|
||||
const nodesRef = useRef<ConstellationNode[]>([]);
|
||||
const edgesRef = useRef(buildConstellationGraph(campaigns).edges);
|
||||
const [, bump] = useState(0);
|
||||
|
||||
const graphKey = useMemo(
|
||||
() => campaigns.map((c) => `${c.campaign}:${c.hits}:${c.online}:${c.conversion_pct}:${(c.pins ?? []).join(',')}`).join('|'),
|
||||
[campaigns],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const el = wrapRef.current;
|
||||
if (!el) return;
|
||||
const ro = new ResizeObserver((entries) => {
|
||||
const w = entries[0]?.contentRect.width;
|
||||
if (w && w > 0) setWidth(Math.floor(w));
|
||||
});
|
||||
ro.observe(el);
|
||||
setWidth(Math.floor(el.clientWidth) || 640);
|
||||
return () => ro.disconnect();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const { nodes, edges } = buildConstellationGraph(campaigns);
|
||||
initNodePositions(nodes, width, HEIGHT);
|
||||
edgesRef.current = edges;
|
||||
nodesRef.current = nodes;
|
||||
let frame = 0;
|
||||
let alpha = 1;
|
||||
|
||||
const step = () => {
|
||||
if (frame < SIM_TICKS) {
|
||||
tickForceLayout(nodesRef.current, edgesRef.current, width, HEIGHT, alpha);
|
||||
alpha *= 0.96;
|
||||
frame++;
|
||||
bump((n) => n + 1);
|
||||
requestAnimationFrame(step);
|
||||
}
|
||||
};
|
||||
const id = requestAnimationFrame(step);
|
||||
return () => cancelAnimationFrame(id);
|
||||
}, [graphKey, width]);
|
||||
|
||||
const handleClick = useCallback(
|
||||
(id: string) => {
|
||||
onSelectCampaign?.(id);
|
||||
},
|
||||
[onSelectCampaign],
|
||||
);
|
||||
|
||||
const nodes = nodesRef.current;
|
||||
const edges = edgesRef.current;
|
||||
const nodeById = new Map(nodes.map((n) => [n.id, n]));
|
||||
|
||||
return (
|
||||
<div className="war-room-constellations" ref={wrapRef}>
|
||||
<svg
|
||||
className="war-room-constellations-svg"
|
||||
viewBox={`0 0 ${width} ${HEIGHT}`}
|
||||
role="img"
|
||||
aria-label="Campaign constellation force graph"
|
||||
>
|
||||
<defs>
|
||||
<radialGradient id="constellation-bg" cx="50%" cy="45%" r="65%">
|
||||
<stop offset="0%" stopColor="rgba(61, 214, 198, 0.06)" />
|
||||
<stop offset="100%" stopColor="rgba(8, 10, 18, 0)" />
|
||||
</radialGradient>
|
||||
<filter id="constellation-glow">
|
||||
<feGaussianBlur stdDeviation="3" result="blur" />
|
||||
<feMerge>
|
||||
<feMergeNode in="blur" />
|
||||
<feMergeNode in="SourceGraphic" />
|
||||
</feMerge>
|
||||
</filter>
|
||||
</defs>
|
||||
|
||||
<rect width={width} height={HEIGHT} fill="url(#constellation-bg)" rx="8" />
|
||||
|
||||
{edges.map((e) => {
|
||||
const a = nodeById.get(e.source);
|
||||
const b = nodeById.get(e.target);
|
||||
if (!a || !b) return null;
|
||||
const lit = hovered === e.source || hovered === e.target;
|
||||
return (
|
||||
<line
|
||||
key={`${e.source}-${e.target}`}
|
||||
x1={a.x}
|
||||
y1={a.y}
|
||||
x2={b.x}
|
||||
y2={b.y}
|
||||
className={`war-room-constellation-edge${lit ? ' war-room-constellation-edge--lit' : ''}`}
|
||||
strokeWidth={lit ? 1.5 : 1}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
{nodes.map((n) => {
|
||||
const lit = hovered === n.id;
|
||||
const opacity = n.brightness;
|
||||
return (
|
||||
<g
|
||||
key={n.id}
|
||||
className={`war-room-constellation-node${n.pulsing ? ' war-room-constellation-node--pulse' : ''}${lit ? ' war-room-constellation-node--hover' : ''}`}
|
||||
style={{ cursor: 'pointer' }}
|
||||
onMouseEnter={() => setHovered(n.id)}
|
||||
onMouseLeave={() => setHovered(null)}
|
||||
onClick={() => handleClick(n.id)}
|
||||
onKeyDown={(ev) => {
|
||||
if (ev.key === 'Enter' || ev.key === ' ') {
|
||||
ev.preventDefault();
|
||||
handleClick(n.id);
|
||||
}
|
||||
}}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-label={`${n.campaign}: ${n.hits} hits, ${n.online} online, ${n.conversionPct}% conversion`}
|
||||
>
|
||||
{n.pulsing ? (
|
||||
<circle
|
||||
cx={n.x}
|
||||
cy={n.y}
|
||||
r={n.radius + 6}
|
||||
className="war-room-constellation-halo"
|
||||
fill={n.color}
|
||||
/>
|
||||
) : null}
|
||||
<circle
|
||||
cx={n.x}
|
||||
cy={n.y}
|
||||
r={n.radius}
|
||||
fill={n.color}
|
||||
fillOpacity={opacity}
|
||||
stroke={lit ? '#e8eaef' : 'rgba(61, 214, 198, 0.35)'}
|
||||
strokeWidth={lit ? 2 : 1}
|
||||
filter={n.pulsing || lit ? 'url(#constellation-glow)' : undefined}
|
||||
/>
|
||||
<text
|
||||
x={n.x}
|
||||
y={n.y + n.radius + 14}
|
||||
textAnchor="middle"
|
||||
className="war-room-constellation-label"
|
||||
>
|
||||
{n.campaign.length > 14 ? `${n.campaign.slice(0, 12)}…` : n.campaign}
|
||||
</text>
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
</svg>
|
||||
|
||||
<p className="war-room-constellation-legend form-hint">
|
||||
Node size = hits · brightness = online · color = conversion · edges = shared pin/build.
|
||||
Click a star to jump to its funnel card.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
178
server/web/src/components/WarRoom/WarRoomFunnelBoard.tsx
Normal file
178
server/web/src/components/WarRoom/WarRoomFunnelBoard.tsx
Normal file
@@ -0,0 +1,178 @@
|
||||
import { useEffect, useState, type CSSProperties } from 'react';
|
||||
import type { WarRoomCampaign } from '../../types';
|
||||
import {
|
||||
detectFunnelLeaks,
|
||||
formatHashrate,
|
||||
funnelPipeWidth,
|
||||
funnelStages,
|
||||
sparklineBarHeight,
|
||||
sparklineMax,
|
||||
staggerDelayMs,
|
||||
} from '../../help/warRoom';
|
||||
import WarRoomOdometer from './WarRoomOdometer';
|
||||
|
||||
interface WarRoomFunnelBoardProps {
|
||||
campaigns: WarRoomCampaign[];
|
||||
days: number;
|
||||
refreshKey?: string;
|
||||
highlightCampaign?: string | null;
|
||||
}
|
||||
|
||||
export default function WarRoomFunnelBoard({ campaigns, days, refreshKey, highlightCampaign }: WarRoomFunnelBoardProps) {
|
||||
const [alive, setAlive] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!refreshKey) return;
|
||||
setAlive(true);
|
||||
const t = window.setTimeout(() => setAlive(false), 900);
|
||||
return () => window.clearTimeout(t);
|
||||
}, [refreshKey]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`war-room-funnel-board${alive ? ' war-room-funnel-board--alive' : ''}`}
|
||||
role="list"
|
||||
>
|
||||
{campaigns.map((c, cardIndex) => {
|
||||
const stages = funnelStages(c);
|
||||
const leaks = detectFunnelLeaks(c);
|
||||
const primaryLeak = leaks[0];
|
||||
const max = sparklineMax(c.daily_hits);
|
||||
const hits = c.hits ?? 0;
|
||||
|
||||
return (
|
||||
<article
|
||||
key={c.campaign}
|
||||
id={`war-room-campaign-${c.campaign}`}
|
||||
className={`war-room-funnel-card${highlightCampaign === c.campaign ? ' war-room-funnel-card--highlighted' : ''}`}
|
||||
role="listitem"
|
||||
style={{ '--card-stagger': `${cardIndex * 0.12}s` } as CSSProperties}
|
||||
>
|
||||
<header className="war-room-funnel-card-head">
|
||||
<div>
|
||||
<code className="war-room-funnel-slug">{c.campaign}</code>
|
||||
{c.last_activity ? (
|
||||
<span className="war-room-funnel-meta">
|
||||
last {new Date(c.last_activity).toLocaleDateString()}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="war-room-funnel-head-stats">
|
||||
<WarRoomOdometer
|
||||
value={c.conversion_pct}
|
||||
format={(n) => n.toFixed(1)}
|
||||
suffix="% overall"
|
||||
staggerMs={staggerDelayMs(cardIndex, 0)}
|
||||
className="war-room-funnel-overall"
|
||||
showDelta
|
||||
/>
|
||||
{c.online > 0 ? (
|
||||
<WarRoomOdometer
|
||||
value={c.online}
|
||||
suffix=" online"
|
||||
staggerMs={staggerDelayMs(cardIndex, 1)}
|
||||
className="war-room-funnel-online"
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="war-room-funnel-pipeline" aria-label="Campaign funnel">
|
||||
{stages.map((stage, idx) => (
|
||||
<div key={stage.id} className="war-room-funnel-stage">
|
||||
<div className="war-room-funnel-node">
|
||||
<span className="war-room-funnel-node-label">{stage.label}</span>
|
||||
<span className="war-room-funnel-node-value">
|
||||
{stage.id === 'hashrate' ? (
|
||||
<WarRoomOdometer
|
||||
value={stage.value}
|
||||
format={(n) => formatHashrate(n)}
|
||||
staggerMs={staggerDelayMs(cardIndex, idx + 2)}
|
||||
showDelta
|
||||
/>
|
||||
) : (
|
||||
<WarRoomOdometer
|
||||
value={stage.value}
|
||||
staggerMs={staggerDelayMs(cardIndex, idx + 2)}
|
||||
showDelta
|
||||
/>
|
||||
)}
|
||||
</span>
|
||||
{stage.rateFromPrev != null ? (
|
||||
<span
|
||||
className={`war-room-funnel-node-rate${
|
||||
stage.rateFromPrev < 15 && stage.value > 0 ? ' war-room-funnel-node-rate--low' : ''
|
||||
}`}
|
||||
>
|
||||
<WarRoomOdometer
|
||||
value={stage.rateFromPrev}
|
||||
format={(n) => n.toFixed(1)}
|
||||
suffix="%"
|
||||
staggerMs={staggerDelayMs(cardIndex, idx + 2, 55)}
|
||||
/>
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<div
|
||||
className="war-room-funnel-pipe war-room-funnel-pipe--flowing"
|
||||
style={{
|
||||
'--pipe-fill': `${funnelPipeWidth(
|
||||
stage.id === 'hashrate' ? stage.value : stage.value,
|
||||
hits,
|
||||
stage.id === 'hashrate',
|
||||
)}%`,
|
||||
'--pipe-stagger': `${idx * 0.18}s`,
|
||||
} as CSSProperties}
|
||||
>
|
||||
<span className="war-room-funnel-pipe-fill" />
|
||||
<span className="war-room-funnel-pipe-shimmer" aria-hidden />
|
||||
</div>
|
||||
{idx < stages.length - 1 ? (
|
||||
<span className="war-room-funnel-arrow" aria-hidden>
|
||||
▸
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<footer className="war-room-funnel-card-foot">
|
||||
<div className="war-room-sparkline war-room-sparkline--card" title={c.daily_hits.join(', ')}>
|
||||
<span className="war-room-sparkline-label">{days}d hits</span>
|
||||
{c.daily_hits.map((v, i) => (
|
||||
<span key={i} style={{ height: `${sparklineBarHeight(v, max)}%` }} />
|
||||
))}
|
||||
</div>
|
||||
<div className="war-room-funnel-hash" title="Fleet hashrate">
|
||||
<WarRoomOdometer
|
||||
value={c.hashrate}
|
||||
format={(n) => formatHashrate(n)}
|
||||
staggerMs={staggerDelayMs(cardIndex, 8)}
|
||||
showDelta
|
||||
/>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
{primaryLeak ? (
|
||||
<div
|
||||
className={`war-room-leak war-room-leak--${primaryLeak.severity}`}
|
||||
role="status"
|
||||
>
|
||||
<span className="war-room-leak-badge">{primaryLeak.severity === 'critical' ? 'LEAK' : 'Drip'}</span>
|
||||
<div>
|
||||
<p className="war-room-leak-msg">{primaryLeak.message}</p>
|
||||
<p className="war-room-leak-action">{primaryLeak.action}</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="war-room-leak war-room-leak--clear" role="status">
|
||||
<span className="war-room-leak-badge">FLOW</span>
|
||||
<p className="war-room-leak-msg">Funnel flowing — no major leaks detected.</p>
|
||||
</div>
|
||||
)}
|
||||
</article>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
88
server/web/src/components/WarRoom/WarRoomOdometer.tsx
Normal file
88
server/web/src/components/WarRoom/WarRoomOdometer.tsx
Normal file
@@ -0,0 +1,88 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { formatOdometerDelta, odometerDurationMs } from '../../help/warRoom';
|
||||
|
||||
export interface WarRoomOdometerProps {
|
||||
value: number;
|
||||
format?: (n: number) => string;
|
||||
staggerMs?: number;
|
||||
className?: string;
|
||||
suffix?: string;
|
||||
showDelta?: boolean;
|
||||
}
|
||||
|
||||
const defaultFormat = (n: number) => String(Math.round(n));
|
||||
|
||||
export default function WarRoomOdometer({
|
||||
value,
|
||||
format = defaultFormat,
|
||||
staggerMs = 0,
|
||||
className = '',
|
||||
suffix = '',
|
||||
showDelta = false,
|
||||
}: WarRoomOdometerProps) {
|
||||
const [display, setDisplay] = useState(value);
|
||||
const [pulsing, setPulsing] = useState(false);
|
||||
const [deltaLabel, setDeltaLabel] = useState<string | null>(null);
|
||||
const prevRef = useRef(value);
|
||||
const rafRef = useRef<number>();
|
||||
const pulseTimerRef = useRef<ReturnType<typeof setTimeout>>();
|
||||
|
||||
useEffect(() => {
|
||||
const prev = prevRef.current;
|
||||
if (prev === value) return;
|
||||
|
||||
const delta = formatOdometerDelta(prev, value);
|
||||
if (showDelta && delta) setDeltaLabel(delta);
|
||||
|
||||
const startTimer = window.setTimeout(() => {
|
||||
const start = prev;
|
||||
const end = value;
|
||||
const duration = odometerDurationMs(end - start);
|
||||
const startTime = performance.now();
|
||||
|
||||
setPulsing(true);
|
||||
if (pulseTimerRef.current) clearTimeout(pulseTimerRef.current);
|
||||
|
||||
const tick = (now: number) => {
|
||||
const t = Math.min(1, (now - startTime) / duration);
|
||||
setDisplay(start + (end - start) * (1 - (1 - t) ** 3));
|
||||
if (t < 1) {
|
||||
rafRef.current = requestAnimationFrame(tick);
|
||||
} else {
|
||||
setDisplay(end);
|
||||
prevRef.current = end;
|
||||
pulseTimerRef.current = setTimeout(() => {
|
||||
setPulsing(false);
|
||||
setDeltaLabel(null);
|
||||
}, 700);
|
||||
}
|
||||
};
|
||||
rafRef.current = requestAnimationFrame(tick);
|
||||
}, staggerMs);
|
||||
|
||||
return () => {
|
||||
window.clearTimeout(startTimer);
|
||||
if (rafRef.current) cancelAnimationFrame(rafRef.current);
|
||||
if (pulseTimerRef.current) clearTimeout(pulseTimerRef.current);
|
||||
};
|
||||
}, [value, staggerMs, showDelta]);
|
||||
|
||||
return (
|
||||
<span
|
||||
className={[
|
||||
'war-room-odometer',
|
||||
pulsing ? 'war-room-odometer--pulse' : '',
|
||||
className,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ')}
|
||||
>
|
||||
<span className="war-room-odometer-value">{format(display)}{suffix}</span>
|
||||
{deltaLabel ? (
|
||||
<span className="war-room-odometer-delta" aria-hidden>
|
||||
{deltaLabel}
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -159,8 +159,23 @@ describe('HelpTip', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('FieldHint export is deprecated no-op', () => {
|
||||
const { container } = render(<FieldHint field="calibrate_wallet" />);
|
||||
it('shows Read more link in popup when doc anchor exists', async () => {
|
||||
render(<HelpTip field="stealth_mode" />);
|
||||
await userEvent.setup().hover(screen.getByRole('button'));
|
||||
await waitFor(() => {
|
||||
const link = screen.getByRole('link', { name: /Read more/i });
|
||||
expect(link).toHaveAttribute('href', '/docs/#forge-stealth');
|
||||
});
|
||||
});
|
||||
|
||||
it('FieldHint renders doc link when anchor exists', () => {
|
||||
render(<FieldHint field="stealth_mode" />);
|
||||
const link = screen.getByRole('link', { name: /Read more/i });
|
||||
expect(link).toHaveAttribute('href', '/docs/#forge-stealth');
|
||||
});
|
||||
|
||||
it('FieldHint returns null when no anchor', () => {
|
||||
const { container } = render(<FieldHint field="pool_host" />);
|
||||
expect(container.firstChild).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user