feat: Build Manager, Crucible ops deck, branding, and portable Cloudflare tunnel

Add Build Manager with pin-to-dropper, Crucible multi-node terminal with SSH probe/wake, Command Deck chart balance and pretty stats, AetherForge logo and sacred geometry UI, Field Guide refresh, and LAUNCH.bat Cloudflare MSI + token service install flow.
This commit is contained in:
AetherForge
2026-05-30 22:38:48 -07:00
parent e6b8d84edf
commit 9232f4c448
45 changed files with 4222 additions and 406 deletions

View File

@@ -101,3 +101,23 @@
height: 80px;
animation: gear-spin 45s linear infinite reverse;
}
/* ── Sacred geometry watermark ── */
.ambient-sacred-geo {
position: absolute;
/* Centre in the main content area (offset for the 260px sidebar) */
left: calc(260px + (100vw - 260px) / 2 - min(38vw, 680px) / 2);
top: 50%;
transform: translateY(-50%);
width: min(38vw, 680px);
height: min(38vw, 680px);
opacity: 0.07;
animation: sacred-geo-rotate 120s linear infinite;
pointer-events: none;
filter: drop-shadow(0 0 4px rgba(201, 162, 39, 0.3));
}
@keyframes sacred-geo-rotate {
from { transform: translateY(-50%) rotate(0deg); }
to { transform: translateY(-50%) rotate(360deg); }
}

View File

@@ -1,5 +1,66 @@
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>
);
}
export default function AmbientBackground() {
return (
<div className="ambient-bg" aria-hidden>
@@ -11,6 +72,8 @@ export default function AmbientBackground() {
<div className="ambient-scanline" />
<div className="ambient-gear ambient-gear-1" />
<div className="ambient-gear ambient-gear-2" />
{/* Sacred geometry watermark — centre of the main content area */}
<SacredGeometry />
</div>
);
}

View File

@@ -1,9 +1,14 @@
.help-tip {
.help-tip-trigger {
display: inline-flex;
align-items: center;
gap: 0.35rem;
margin-left: 0.35rem;
padding: 0;
border: none;
background: none;
cursor: help;
vertical-align: middle;
line-height: 1;
}
.help-tip-icon {
@@ -19,6 +24,55 @@
font-size: 0.6875rem;
font-weight: 700;
box-shadow: 0 0 8px rgba(0, 245, 255, 0.2);
transition: background 0.15s, box-shadow 0.15s, border-color 0.15s;
}
.help-tip-trigger:hover .help-tip-icon,
.help-tip-trigger:focus-visible .help-tip-icon {
background: rgba(0, 245, 255, 0.22);
border-color: rgba(0, 245, 255, 0.65);
box-shadow: 0 0 12px rgba(0, 245, 255, 0.45);
}
.help-tip-label {
font-size: 0.75rem;
color: var(--text-secondary);
}
.help-tip-popup {
position: fixed;
z-index: 10000;
max-width: 280px;
padding: 0.55rem 0.75rem;
font-size: 0.78rem;
line-height: 1.45;
color: #e8f4f8;
background: rgba(6, 14, 18, 0.97);
border: 1px solid rgba(0, 245, 255, 0.45);
border-radius: 4px;
box-shadow:
0 4px 24px rgba(0, 0, 0, 0.55),
0 0 16px rgba(0, 245, 255, 0.12);
pointer-events: auto;
animation: help-tip-in 0.12s ease-out;
}
.help-tip-popup-pinned {
border-color: rgba(255, 180, 0, 0.55);
box-shadow:
0 4px 24px rgba(0, 0, 0, 0.55),
0 0 12px rgba(255, 160, 0, 0.2);
}
@keyframes help-tip-in {
from {
opacity: 0;
transform: translateY(-4px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.cheat-sheet-item {

View File

@@ -1,5 +1,7 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import { FIELD_HELP } from '../help/settingHelp';
import '../components/HelpTip.css';
import './HelpTip.css';
interface HelpTipProps {
field: string;
@@ -8,17 +10,111 @@ interface HelpTipProps {
export function HelpTip({ field, label }: HelpTipProps) {
const text = FIELD_HELP[field];
const triggerRef = useRef<HTMLButtonElement>(null);
const popupRef = useRef<HTMLDivElement>(null);
const [open, setOpen] = useState(false);
const [pinned, setPinned] = useState(false);
const [pos, setPos] = useState({ top: 0, left: 0 });
const reposition = useCallback(() => {
const el = triggerRef.current;
if (!el) return;
const rect = el.getBoundingClientRect();
const popupW = 280;
let left = rect.left;
if (left + popupW > window.innerWidth - 12) {
left = window.innerWidth - popupW - 12;
}
left = Math.max(12, left);
setPos({ top: rect.bottom + 8, left });
}, []);
const show = useCallback(() => {
reposition();
setOpen(true);
}, [reposition]);
const hide = useCallback(() => {
if (!pinned) setOpen(false);
}, [pinned]);
const togglePin = useCallback(
(e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
if (pinned) {
setPinned(false);
setOpen(false);
} else {
reposition();
setPinned(true);
setOpen(true);
}
},
[pinned, reposition],
);
useEffect(() => {
if (!pinned) return;
const onDocClick = (e: MouseEvent) => {
const t = e.target as Node;
if (triggerRef.current?.contains(t) || popupRef.current?.contains(t)) return;
setPinned(false);
setOpen(false);
};
document.addEventListener('mousedown', onDocClick);
return () => document.removeEventListener('mousedown', onDocClick);
}, [pinned]);
useEffect(() => {
if (!open) return;
const onScroll = () => reposition();
window.addEventListener('scroll', onScroll, true);
window.addEventListener('resize', onScroll);
return () => {
window.removeEventListener('scroll', onScroll, true);
window.removeEventListener('resize', onScroll);
};
}, [open, reposition]);
if (!text) return null;
return (
<span className="help-tip" title={text} aria-label={text}>
<span className="help-tip-icon">?</span>
{label && <span className="help-tip-label">{label}</span>}
</span>
<>
<button
ref={triggerRef}
type="button"
className="help-tip-trigger"
aria-label={`Help: ${field}`}
aria-expanded={open}
onMouseEnter={show}
onMouseLeave={hide}
onClick={togglePin}
onFocus={show}
onBlur={hide}
>
<span className="help-tip-icon">?</span>
{label && <span className="help-tip-label">{label}</span>}
</button>
{open &&
createPortal(
<div
ref={popupRef}
className={`help-tip-popup${pinned ? ' help-tip-popup-pinned' : ''}`}
style={{ top: pos.top, left: pos.left }}
role="tooltip"
onMouseEnter={() => setOpen(true)}
onMouseLeave={hide}
>
{text}
</div>,
document.body,
)}
</>
);
}
export function FieldHint({ field }: { field: string }) {
const text = FIELD_HELP[field];
if (!text) return null;
return <span className="form-hint">{text}</span>;
/** @deprecated Use HelpTip on the label instead — hints are shown on ? hover/click only. */
export function FieldHint(_props: { field: string }) {
return null;
}

View File

@@ -43,25 +43,43 @@
.logo-emblem {
position: relative;
width: 48px;
height: 48px;
width: 52px;
height: 52px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.logo-gear {
position: absolute;
inset: 0;
border: 2px dashed rgba(201, 162, 39, 0.4);
inset: -4px;
border: 1px solid rgba(201, 162, 39, 0.22);
border-radius: 50%;
animation: gear-spin 20s linear infinite;
animation: gear-spin 30s linear infinite;
}
.logo-core {
font-size: 1.5rem;
filter: drop-shadow(0 0 8px var(--neon-amber));
.logo-gear::after {
content: '';
position: absolute;
inset: 5px;
border: 1px dashed rgba(201, 162, 39, 0.14);
border-radius: 50%;
animation: gear-spin 18s linear infinite reverse;
}
.logo-af-img {
width: 48px;
height: 48px;
object-fit: contain;
border-radius: 50%;
filter: drop-shadow(0 0 10px rgba(201, 162, 39, 0.7)) drop-shadow(0 0 3px rgba(255, 100, 0, 0.5));
z-index: 1;
transition: filter 0.3s ease;
}
.logo-af-img:hover {
filter: drop-shadow(0 0 16px rgba(201, 162, 39, 0.95)) drop-shadow(0 0 6px rgba(255, 140, 0, 0.7));
}
.logo-text-block {

View File

@@ -4,6 +4,8 @@ import AmbientBackground from '../Ambient/AmbientBackground';
import SystemStatusBar from '../Visual/SystemStatusBar';
import { useWebSocket } from '../../hooks/useWebSocket';
import MatrixRain from './MatrixRain';
import afLogo from '../../assets/af-logo.png';
import CursorFire from '../Visual/CursorFire';
import './Layout.css';
interface LayoutProps {
@@ -13,7 +15,9 @@ interface LayoutProps {
const NAV = [
{ to: '/dashboard', label: 'Command Deck', icon: 'deck' },
{ to: '/agents', label: 'Fleet Roster', icon: 'fleet' },
{ to: '/crucible', label: 'Crucible', icon: 'crucible' },
{ to: '/forge', label: 'Forge', icon: 'forge' },
{ to: '/builds', label: 'Builds', icon: 'builds' },
{ to: '/guide', label: 'Field Guide', icon: 'guide' },
{ to: '/settings', label: 'Calibrate', icon: 'gear' },
] as const;
@@ -41,6 +45,15 @@ function NavIcon({ type }: { type: string }) {
<path d="M8 16l-2 4 4-2" />
</svg>
);
case 'builds':
return (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
<rect x="3" y="14" width="18" height="5" rx="1" />
<rect x="3" y="8" width="18" height="5" rx="1" />
<rect x="3" y="2" width="18" height="5" rx="1" />
<path d="M7 4.5h10M7 10.5h10M7 16.5h10" strokeOpacity="0.35" />
</svg>
);
case 'guide':
return (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
@@ -49,6 +62,15 @@ function NavIcon({ type }: { type: string }) {
<path d="M8 7h8M8 11h6" />
</svg>
);
case 'crucible':
return (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
<path d="M8 3h8l1 5H7L8 3z" />
<path d="M7 8c0 5 2 8 5 10c3-2 5-5 5-10" />
<path d="M4 21h16" />
<path d="M10 12l1.5 2L14 11" />
</svg>
);
default:
return (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
@@ -118,17 +140,18 @@ export default function Layout({ children }: LayoutProps) {
return (
<div className="layout">
<CursorFire />
<AmbientBackground />
<nav className="sidebar">
<div className="sidebar-header">
<div className="logo">
<div className="logo-emblem">
<span className="logo-gear" />
<span className="logo-core"></span>
<img src={afLogo} alt="AetherForge" className="logo-af-img" />
</div>
<div className="logo-text-block">
<span className="logo-text">AetherForge</span>
<span className="logo-tagline font-tech">LAN MINING COMMAND</span>
<span className="logo-tagline font-tech">COMMAND DECK</span>
</div>
</div>
</div>

View File

@@ -1,5 +1,6 @@
import { useEffect, useRef } from 'react';
import { useWebSocket } from '../../hooks/useWebSocket';
import { useForge } from '../../context/ForgeContext';
// Full matrix alphabet: katakana + hex + braille dots for visual density
const KATAKANA =
@@ -18,10 +19,22 @@ interface Column {
livePos: number;
}
const FORGE_STRINGS = [
'COMPILING', 'LINKING', 'GARBLE', 'GO BUILD', 'INJECT',
'STEALTH', 'PERSIST', 'ENCRYPT', 'OBFUSC', 'PACKAGE',
'WORKER', 'FORGE', 'SIGN', 'BUNDLE', 'AGENT',
'RANDOMX', 'STRATUM', 'C2CONN', 'DEPLOY',
];
export default function MatrixRain() {
const canvasRef = useRef<HTMLCanvasElement>(null);
const wrapRef = useRef<HTMLDivElement>(null);
const { agents, recentShares, commandResults } = useWebSocket();
const { forging, stage } = useForge();
const forgingRef = useRef(false);
const stageRef = useRef('');
forgingRef.current = forging;
stageRef.current = stage;
// ── Live data pool ──────────────────────────────────────────────────────────
// Collect strings from the fleet that will be injected character-by-character
@@ -40,6 +53,13 @@ export default function MatrixRain() {
for (const r of (commandResults ?? []).slice(-5)) {
if (r.action) pool.push(r.action.toUpperCase().padEnd(8, '_'));
}
// When forging, flood the pool with build strings so they dominate the rain
if (forgingRef.current) {
pool.push(...FORGE_STRINGS);
if (stageRef.current) {
pool.push(stageRef.current.replace(/[^A-Z0-9]/gi, '').toUpperCase().slice(0, 16));
}
}
livePoolRef.current = pool.length > 0 ? pool : ['AETHERFORGE', 'MINING', '00E5FF'];
}, [agents, recentShares, commandResults]);
@@ -109,23 +129,27 @@ export default function MatrixRain() {
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;
const isForging = forgingRef.current;
const targetFps = isForging ? 50 : 24;
const msPerFrame = 1000 / targetFps;
if (ts - lastTime < msPerFrame) return;
lastTime = ts;
const W = canvas.width;
const H = canvas.height;
// Fade trail
ctx.fillStyle = 'rgba(0,0,0,0.18)';
// Forge mode: less fade = longer glowing trails; normal: quick fade
ctx.fillStyle = isForging ? 'rgba(0,0,0,0.10)' : 'rgba(0,0,0,0.18)';
ctx.fillRect(0, 0, W, H);
ctx.font = `${FONT_SIZE}px 'Courier New', monospace`;
// Speed multiplier: 3× faster while forging
const speedMult = isForging ? 3.2 : 1.0;
for (let i = 0; i < cols.length; i++) {
const col = cols[i];
const x = i * FONT_SIZE;
@@ -140,33 +164,52 @@ export default function MatrixRain() {
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,
);
if (isForging) {
// Forge palette: bright amber/orange head, orange body
ctx.fillStyle = 'rgba(255,220,80,0.98)';
ctx.fillText(ch, x, y * FONT_SIZE);
if (y > 1) {
ctx.fillStyle = '#ff8c00';
ctx.fillText(
ALPHABET[Math.floor(Math.random() * ALPHABET.length)],
x,
(y - 1) * FONT_SIZE,
);
}
// Extra mid-column glyph density during forge
if (Math.random() < 0.12) {
const dimY = Math.floor(Math.random() * Math.max(1, y - 2));
ctx.fillStyle = 'rgba(255,120,0,0.35)';
ctx.fillText(
ALPHABET[Math.floor(Math.random() * ALPHABET.length)],
x,
dimY * FONT_SIZE,
);
}
} else {
// Normal palette: white head, cyan-green body
ctx.fillStyle = 'rgba(255,255,255,0.95)';
ctx.fillText(ch, x, y * FONT_SIZE);
if (y > 1) {
ctx.fillStyle = '#00ff41';
ctx.fillText(
ALPHABET[Math.floor(Math.random() * ALPHABET.length)],
x,
(y - 1) * FONT_SIZE,
);
}
if (Math.random() < 0.04) {
const dimY = Math.floor(Math.random() * Math.max(1, y - 2));
ctx.fillStyle = 'rgba(0,180,60,0.22)';
ctx.fillText(
ALPHABET[Math.floor(Math.random() * ALPHABET.length)],
x,
dimY * 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;
col.y += col.speed * speedMult;
if (col.y * FONT_SIZE > H && Math.random() > 0.96) {
col.y = Math.random() * -20;
col.speed = 0.3 + Math.random() * 0.55;
@@ -179,13 +222,16 @@ export default function MatrixRain() {
const logs = eventLogRef.current;
const lineH = FONT_SIZE + 2;
ctx.font = `${FONT_SIZE - 1}px 'Courier New', monospace`;
const isForging2 = forgingRef.current;
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)})`;
const logColor = isForging2
? `rgba(255,160,0,${(entry.alpha * 0.75).toFixed(2)})`
: `rgba(0,255,65,${(entry.alpha * 0.55).toFixed(2)})`;
ctx.fillStyle = logColor;
ctx.fillText(`> ${entry.text}`, 4, oy);
// fade over time
entry.alpha = Math.max(0, entry.alpha - 0.003);
}
};

View File

@@ -44,10 +44,19 @@
background: linear-gradient(135deg, var(--neon-purple), var(--neon-cyan) 100%);
}
.neon-card-gold .neon-card-rim {
background: linear-gradient(135deg, var(--brass-light), var(--brass-dark) 100%);
opacity: 0.9;
}
.neon-card-cyan:hover {
box-shadow: var(--shadow-panel), var(--shadow-neon-cyan);
}
.neon-card-gold:hover {
box-shadow: var(--shadow-panel), 0 0 20px rgba(232, 197, 71, 0.45);
}
.neon-card-magenta:hover {
box-shadow: var(--shadow-panel), var(--shadow-neon-magenta);
}

View File

@@ -1,7 +1,7 @@
import { ReactNode, CSSProperties } from 'react';
import './NeonCard.css';
type Accent = 'cyan' | 'magenta' | 'amber' | 'green' | 'purple' | 'brass';
type Accent = 'cyan' | 'magenta' | 'amber' | 'green' | 'purple' | 'brass' | 'gold';
interface NeonCardProps {
children: ReactNode;

View File

@@ -0,0 +1,136 @@
import { useEffect, useRef } from 'react';
interface Particle {
x: number;
y: number;
vx: number;
vy: number;
life: number; // 1 → 0
size: number;
decay: number;
}
export default function CursorFire() {
const canvasRef = useRef<HTMLCanvasElement>(null);
const particles = useRef<Particle[]>([]);
const mouse = useRef({ x: -9999, y: -9999, moved: false });
const rafRef = useRef<number>(0);
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const ctx = canvas.getContext('2d');
if (!ctx) return;
const resize = () => {
canvas.width = window.innerWidth;
canvas.height = window.innerHeight;
};
resize();
window.addEventListener('resize', resize);
const onMove = (e: MouseEvent) => {
mouse.current = { x: e.clientX, y: e.clientY, moved: true };
};
window.addEventListener('mousemove', onMove);
const emit = () => {
const { x, y } = mouse.current;
// Emit 6 particles per frame at cursor
for (let i = 0; i < 6; i++) {
const spread = 8;
particles.current.push({
x: x + (Math.random() - 0.5) * spread,
y: y + (Math.random() - 0.5) * (spread * 0.5),
vx: (Math.random() - 0.5) * 1.2,
vy: -(Math.random() * 2.8 + 1.8),
life: 1,
size: Math.random() * 14 + 7,
decay: Math.random() * 0.022 + 0.016,
});
}
// Cap particle count for perf
if (particles.current.length > 400) {
particles.current = particles.current.slice(-400);
}
};
const draw = () => {
ctx.clearRect(0, 0, canvas.width, canvas.height);
// Additive blending makes overlapping particles look white-hot
ctx.globalCompositeOperation = 'screen';
emit();
const alive: Particle[] = [];
for (const p of particles.current) {
// Turbulent horizontal drift
p.vx += (Math.random() - 0.5) * 0.35;
// Slight drag on vx
p.vx *= 0.97;
// Upward acceleration (heat rises)
p.vy -= 0.04;
p.x += p.vx;
p.y += p.vy;
p.life -= p.decay;
// Particles shrink as they cool
p.size *= 0.982;
if (p.life <= 0 || p.size < 1) continue;
alive.push(p);
const l = p.life;
// Color temperature: white-yellow core → orange → red → dark red
let r: number, g: number, b: number;
if (l > 0.75) {
// White-hot
r = 255; g = 255; b = Math.round((l - 0.75) / 0.25 * 220);
} else if (l > 0.5) {
// Yellow-orange
r = 255; g = Math.round(100 + (l - 0.5) / 0.25 * 155); b = 0;
} else if (l > 0.25) {
// Orange-red
r = 255; g = Math.round((l - 0.25) / 0.25 * 100); b = 0;
} else {
// Deep red, fading
r = Math.round(160 + l / 0.25 * 95); g = 0; b = 0;
}
const grad = ctx.createRadialGradient(p.x, p.y, 0, p.x, p.y, p.size);
grad.addColorStop(0, `rgba(${r},${g},${b},${l})`);
grad.addColorStop(0.4, `rgba(${r},${Math.round(g * 0.6)},0,${l * 0.6})`);
grad.addColorStop(1, `rgba(0,0,0,0)`);
ctx.beginPath();
ctx.arc(p.x, p.y, p.size, 0, Math.PI * 2);
ctx.fillStyle = grad;
ctx.fill();
}
particles.current = alive;
rafRef.current = requestAnimationFrame(draw);
};
rafRef.current = requestAnimationFrame(draw);
return () => {
cancelAnimationFrame(rafRef.current);
window.removeEventListener('resize', resize);
window.removeEventListener('mousemove', onMove);
};
}, []);
return (
<canvas
ref={canvasRef}
style={{
position: 'fixed',
inset: 0,
pointerEvents: 'none',
zIndex: 9998,
}}
/>
);
}

View File

@@ -369,3 +369,120 @@
grid-template-columns: 1fr;
}
}
/* ── Guide page additions ─────────────────────────────────────────────────── */
.guide-step-sub {
font-size: 0.82em;
font-weight: 400;
color: var(--text-secondary);
}
.guide-step-btn {
flex-shrink: 0;
align-self: flex-start;
margin-top: 0.15rem;
}
/* Code block with copy button */
.guide-code-block {
position: relative;
margin-top: 0.6rem;
border-radius: 5px;
background: rgba(0, 0, 0, 0.5);
border: 1px solid rgba(201, 162, 39, 0.22);
overflow: hidden;
}
.guide-code-pre {
margin: 0;
padding: 0.65rem 3.5rem 0.65rem 0.9rem;
font-family: 'Courier New', monospace;
font-size: 0.75rem;
line-height: 1.55;
color: #b8e0d0;
white-space: pre-wrap;
word-break: break-all;
}
.guide-code-copy {
position: absolute;
top: 0.35rem;
right: 0.35rem;
font-size: 0.65rem;
font-family: var(--font-tech, monospace);
letter-spacing: 0.06em;
padding: 0.15rem 0.45rem;
background: rgba(0, 245, 255, 0.09);
border: 1px solid rgba(0, 245, 255, 0.3);
border-radius: 3px;
color: var(--neon-cyan, #0ff);
cursor: pointer;
transition: background 0.15s;
}
.guide-code-copy:hover {
background: rgba(0, 245, 255, 0.2);
}
/* Network topology ASCII diagram */
.guide-topology {
margin: 0.5rem 0;
border-radius: 5px;
background: rgba(0, 0, 0, 0.45);
border: 1px solid rgba(201, 162, 39, 0.18);
overflow-x: auto;
}
.guide-topology-pre {
margin: 0;
padding: 0.85rem 1rem;
font-family: 'Courier New', monospace;
font-size: 0.72rem;
line-height: 1.6;
color: #c9a227;
white-space: pre;
}
/* Dropper one-liner quick-ref grid */
.guide-dropper-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: 0.75rem;
margin-top: 0.25rem;
}
.guide-dropper-item {
display: flex;
flex-direction: column;
gap: 0.3rem;
}
.guide-dropper-os {
font-family: 'Courier New', monospace;
font-size: 0.68rem;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--neon-amber, #ffc060);
}
/* Inline code links */
.guide-link {
color: var(--neon-cyan, #0ff);
text-decoration: underline;
text-underline-offset: 2px;
}
.guide-link:hover {
color: #fff;
}
@media (max-width: 640px) {
.guide-dropper-grid {
grid-template-columns: 1fr;
}
.guide-topology-pre {
font-size: 0.6rem;
}
}