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

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:
AetherForge
2026-06-04 22:36:17 -07:00
parent 1551bd5dad
commit a32860b0d9
154 changed files with 17383 additions and 601 deletions

View File

@@ -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);
}

View File

@@ -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>
);

View File

@@ -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;

View File

@@ -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>

View 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;
}

View 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 &amp; troubleshooting
</span>
</span>
<span className="docs-entry-card-arrow" aria-hidden>
</span>
</a>
);
}

View 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>
);
}

View 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>
)}
</>
);
}

View File

@@ -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>

View File

@@ -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]);

View 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);
}

View 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>
);
}

View File

@@ -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>

View 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;
}

View 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/&#123;name&#125;</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>
</>
);
}

View File

@@ -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);

View File

@@ -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;
}

View File

@@ -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"
>

View File

@@ -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;
}

View File

@@ -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} />;
}

View File

@@ -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 */

View File

@@ -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) => (

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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;
}

View File

@@ -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 && (

View File

@@ -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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View File

@@ -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();
});
});