Close DV-01–04,07,09,13,14: unify operator-deck UX in server/web.

Standardize neon cyan on #00e8f5, wire SacredPageHeader across Builds/Path Tracer/LOTL, dedupe Pages.css, align Path Tracer chrome, drop dead wealth-deck CSS, add prefers-color-scheme shell + HelpTip, sidebar version from package.json build inject, and Vitest CSS regression guards.
This commit is contained in:
AetherForge
2026-06-07 06:33:51 -07:00
parent 85d55df37c
commit 74c006a04b
35 changed files with 1144 additions and 36 deletions

View File

@@ -77,4 +77,14 @@ describe('auth session helpers', () => {
expect(consumeAuthExpiredFlag()).toBe(true);
expect(consumeAuthExpiredFlag()).toBe(false);
});
it('dispatches aetherforge-auth on login and logout', () => {
const handler = vi.fn();
window.addEventListener('aetherforge-auth', handler);
setStoredAuth('user', 'pass');
expect(handler).toHaveBeenCalledTimes(1);
clearStoredAuth();
expect(handler).toHaveBeenCalledTimes(2);
window.removeEventListener('aetherforge-auth', handler);
});
});

View File

@@ -12,10 +12,12 @@ import { SacredMotif } from '../Visual/sacredGeometry/motifs';
import SetupBanner from '../SetupBanner';
import { getSetupStatus } from '../../help/setupStatus';
import { resolvePageWeather } from '../../help/pageWeather';
import { isDashboardRoute } from '../../help/routeEffects';
import { api } from '../../api/client';
import { usePresence } from '../../context/PresenceContext';
import { useVisualEffects } from '../../context/VisualEffectsContext';
import ComradeAvatar from '../Presence/ComradeAvatar';
import { formatAppVersion } from '../../help/appVersion';
import type { ServerConfig, ServerInfo } from '../../types';
import '../Presence/Presence.css';
import './Layout.css';
@@ -280,6 +282,7 @@ export default function Layout({ children }: LayoutProps) {
const setupStatus = getSetupStatus(serverConfig, serverInfo);
const pageWeather = resolvePageWeather(location.pathname);
const showDeckEffects = isDashboardRoute(location.pathname);
const moreActive = MOBILE_MORE.some((item) => location.pathname === item.to);
const mobileShortLabel: Record<string, string> = {
'/dashboard': 'Deck',
@@ -296,7 +299,7 @@ export default function Layout({ children }: LayoutProps) {
className={`layout${isMobile ? ' layout--mobile' : ''}${othersOnline ? ' layout--comrades-online' : ''}`}
data-operator-deck={operatorDeckId(location.pathname)}
>
{!isMobile && glowParticles && <CursorFire />}
{!isMobile && glowParticles && showDeckEffects && <CursorFire />}
<AmbientBackground weather={pageWeather} />
{glowParticles && <SacredGeometryLayer />}
<nav className="sidebar sidebar--desktop desktop-only">
@@ -348,8 +351,8 @@ export default function Layout({ children }: LayoutProps) {
<SacredMotif name="flower" opacity={0.6} />
</div>
{/* Matrix rain log — fills the lower sidebar between nav and footer */}
<MatrixRain />
{/* Matrix rain log — Command Deck only; fills lower sidebar between nav and footer */}
{showDeckEffects && <MatrixRain />}
<div className="sidebar-footer">
<FleetReadout />
@@ -367,7 +370,7 @@ export default function Layout({ children }: LayoutProps) {
)}
<div className="sidebar-sig font-tech">
<span className="sig-love">made with <span className="sig-heart"></span> drjones</span>
<span className="sig-ver">{serverInfo?.version ?? 'v1.0.0'}</span>
<span className="sig-ver">{formatAppVersion(serverInfo?.version)}</span>
</div>
</div>
</nav>

View File

@@ -837,6 +837,19 @@ describe('SystemStatusBar', () => {
expect(screen.getByText(/1 BUILD/i)).toBeInTheDocument();
});
});
it('derives fleet counts from WebSocket agents, not REST listAgents', async () => {
const listAgentsSpy = vi.spyOn(api, 'listAgents');
render(
<MemoryRouter future={routerFuture}>
<SystemStatusBar />
</MemoryRouter>
);
await waitFor(() => {
expect(screen.getByText(/FLEET 1\/2 ONLINE/i)).toBeInTheDocument();
});
expect(listAgentsSpy).not.toHaveBeenCalled();
});
});
describe('FleetTopologyMap', () => {
@@ -925,4 +938,32 @@ describe('Layout', () => {
expect(screen.getByRole('link', { name: /Crucible/i })).toBeInTheDocument();
expect(screen.getByRole('link', { name: /Onion/i })).toBeInTheDocument();
});
it('mounts MatrixRain and CursorFire on Command Deck only', async () => {
const { container: deck } = render(
<MemoryRouter initialEntries={['/dashboard']} future={routerFuture}>
<Layout>
<div>deck</div>
</Layout>
</MemoryRouter>
);
await waitFor(() => {
expect(deck.querySelector('.matrix-rain-canvas')).toBeTruthy();
expect(deck.querySelector('.cursor-fire-fx')).toBeTruthy();
});
cleanup();
const { container: crucible } = render(
<MemoryRouter initialEntries={['/crucible']} future={routerFuture}>
<Layout>
<div>crucible</div>
</Layout>
</MemoryRouter>
);
await waitFor(() => {
expect(screen.getByText('crucible')).toBeInTheDocument();
});
expect(crucible.querySelector('.matrix-rain-canvas')).toBeNull();
expect(crucible.querySelector('.cursor-fire-fx')).toBeNull();
});
});

View File

@@ -0,0 +1,20 @@
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { describe, expect, it } from 'vitest';
const crucibleSrc = readFileSync(resolve(__dirname, '../pages/CruciblePage.tsx'), 'utf8');
const wsProviderSrc = readFileSync(resolve(__dirname, '../context/WebSocketProvider.tsx'), 'utf8');
describe('architecture deferred (frontend)', () => {
it('CruciblePage remains monolithic until section split lands', () => {
expect(crucibleSrc.split('\n').length).toBeGreaterThan(1500);
});
it('WebSocketProvider is still a single shared context', () => {
expect(wsProviderSrc).toContain('WebSocketContext');
});
it('terminal render cap documents virtualization ceiling', () => {
expect(crucibleSrc).toContain('visibleTerminalLines');
});
});

View File

@@ -0,0 +1,58 @@
/**
* @vitest-environment happy-dom
*/
import { beforeEach, describe, expect, it, vi } from 'vitest';
import {
AUTH_CROSS_TAB_STORAGE_SYNC,
AUTH_STORAGE_KEY,
AUTH_SYNC_EVENT,
describeAuthStoragePolicy,
} from './authStoragePolicy';
import { clearStoredAuth, getStoredAuth, setStoredAuth } from '../api/auth';
describe('authStoragePolicy', () => {
beforeEach(() => {
sessionStorage.clear();
localStorage.clear();
});
it('documents dual storage with same-tab event sync only', () => {
expect(describeAuthStoragePolicy()).toMatch(/dual-write/);
expect(describeAuthStoragePolicy()).toMatch(/aetherforge-auth/);
expect(AUTH_CROSS_TAB_STORAGE_SYNC).toBe(false);
expect(AUTH_STORAGE_KEY).toBe('aetherforge_auth');
expect(AUTH_SYNC_EVENT).toBe('aetherforge-auth');
});
it('dispatches aetherforge-auth on login for same-tab listeners', () => {
const handler = vi.fn();
window.addEventListener(AUTH_SYNC_EVENT, handler);
setStoredAuth('ops', 'secret');
expect(handler).toHaveBeenCalledTimes(1);
window.removeEventListener(AUTH_SYNC_EVENT, handler);
});
it('dispatches aetherforge-auth on logout for same-tab listeners', () => {
setStoredAuth('ops', 'secret');
const handler = vi.fn();
window.addEventListener(AUTH_SYNC_EVENT, handler);
clearStoredAuth();
expect(handler).toHaveBeenCalledTimes(1);
window.removeEventListener(AUTH_SYNC_EVENT, handler);
});
it('keeps sessionStorage auth when localStorage cleared externally (no cross-tab logout)', () => {
setStoredAuth('ops', 'secret');
const token = getStoredAuth();
localStorage.removeItem(AUTH_STORAGE_KEY);
window.dispatchEvent(
new StorageEvent('storage', {
key: AUTH_STORAGE_KEY,
oldValue: token,
newValue: null,
storageArea: localStorage,
})
);
expect(getStoredAuth()).toBe(token);
});
});

View File

@@ -0,0 +1,24 @@
/**
* Cross-tab auth storage policy (document-only).
*
* Credentials mirror in sessionStorage and localStorage under `aetherforge_auth`.
* Reads prefer sessionStorage, then fall back to localStorage (survives tab close).
*
* Same-tab sync: login/logout dispatch the `aetherforge-auth` CustomEvent so SessionGate,
* WebSocketProvider, and PresenceContext re-read credentials without a full reload.
*
* Cross-tab: intentionally NOT synced via the `storage` event. Logout in tab A clears
* localStorage but tab B keeps in-memory session until refresh or a 401. Each tab owns
* its WS lifecycle after auth changes in that tab.
*/
export const AUTH_STORAGE_KEY = 'aetherforge_auth';
export const AUTH_EXPIRED_FLAG_KEY = 'aetherforge_auth_expired';
export const AUTH_SYNC_EVENT = 'aetherforge-auth';
/** By policy we never listen to cross-tab `storage` events for auth. */
export const AUTH_CROSS_TAB_STORAGE_SYNC = false;
export function describeAuthStoragePolicy(): string {
return 'dual-write session+local; same-tab aetherforge-auth; no storage-event cross-tab sync';
}

View File

@@ -373,7 +373,10 @@ describe('getForgeFieldMeta', () => {
describe('getForgeLiveNotices', () => {
it('returns empty array for a plain windows form', () => {
const notices = getForgeLiveNotices(baseForm({ target_os: 'windows', spread_kit: false }), false);
const notices = getForgeLiveNotices(
baseForm({ target_os: 'windows', spread_kit: false, miner_execution: 'inprocess' }),
false
);
expect(notices).toEqual([]);
});
@@ -450,4 +453,15 @@ describe('getForgeLiveNotices', () => {
);
expect(notices.some((n) => n.includes('without Spread Kit or Fusion'))).toBe(true);
});
it('warns when container/auto execution needs worker image', () => {
const auto = getForgeLiveNotices(baseForm({ miner_execution: 'auto' }), false);
expect(auto.some((n) => n.includes('agent-worker'))).toBe(true);
const container = getForgeLiveNotices(baseForm({ miner_execution: 'container' }), false);
expect(container.some((n) => n.includes('Dockerfile.agent'))).toBe(true);
const inprocess = getForgeLiveNotices(baseForm({ miner_execution: 'inprocess' }), false);
expect(inprocess.some((n) => n.includes('agent-worker'))).toBe(false);
});
});

View File

@@ -315,6 +315,12 @@ export function getForgeFieldMeta(form: BuildRequest): Record<string, ForgeField
max_memory_percent: { disabled: false, badge: 'baked' },
min_free_ram_mb: { disabled: false, badge: 'baked' },
mining_mode: { disabled: false, badge: 'baked' },
miner_execution: {
disabled: false,
badge: 'baked',
hint:
'Auto/container tiers need a worker image on the host: docker build -f docker/Dockerfile.agent -t aetherforge/agent-worker:latest . Override tag with AETHERFORGE_MINER_IMAGE.',
},
idle_threshold_pct: {
disabled: !isIdle,
badge: 'requires',
@@ -627,6 +633,12 @@ export function getForgeLiveNotices(form: BuildRequest, fusionPrepSelected: bool
if (form.target_os === 'universal' && !form.fusion_enabled && !form.spread_kit) {
notices.push('Universal without Spread Kit or Fusion — pick a deliverable type above.');
}
const execMode = form.miner_execution ?? 'auto';
if (execMode === 'auto' || execMode === 'container') {
notices.push(
'Container/auto execution needs aetherforge/agent-worker:latest on the operator host — build with docker/Dockerfile.agent (set AETHERFORGE_MINER_IMAGE to override).'
);
}
return notices;
}

View File

@@ -0,0 +1,17 @@
import { describe, expect, it } from 'vitest';
import { isDashboardRoute } from './routeEffects';
describe('isDashboardRoute', () => {
it('matches Command Deck paths', () => {
expect(isDashboardRoute('/dashboard')).toBe(true);
expect(isDashboardRoute('/dashboard/')).toBe(true);
expect(isDashboardRoute('/')).toBe(true);
});
it('rejects other operator deck routes', () => {
expect(isDashboardRoute('/crucible')).toBe(false);
expect(isDashboardRoute('/forge')).toBe(false);
expect(isDashboardRoute('/settings')).toBe(false);
expect(isDashboardRoute('/pathtracer')).toBe(false);
});
});

View File

@@ -0,0 +1,5 @@
/** True when pathname is Command Deck (`/dashboard` or `/`). */
export function isDashboardRoute(pathname: string): boolean {
const path = pathname.split('?')[0].replace(/\/$/, '') || '/';
return path === '/dashboard' || path === '/';
}

View File

@@ -0,0 +1,13 @@
import { describe, expect, it } from 'vitest';
import { TERM_RENDER_CAP, visibleTerminalLines } from './terminalRenderCap';
describe('terminalRenderCap', () => {
it('renders only the newest window at cap', () => {
const lines = Array.from({ length: TERM_RENDER_CAP + 25 }, (_, i) => `line-${i}`);
const { visible, truncated, hidden } = visibleTerminalLines(lines);
expect(visible).toHaveLength(TERM_RENDER_CAP);
expect(visible[0]).toBe('line-25');
expect(truncated).toBe(true);
expect(hidden).toBe(25);
});
});

View File

@@ -0,0 +1,17 @@
/** Max terminal lines rendered in the DOM; full history stays in state for export. */
export const TERM_RENDER_CAP = 400;
export function visibleTerminalLines<T>(lines: T[]): {
visible: T[];
truncated: boolean;
hidden: number;
} {
if (lines.length <= TERM_RENDER_CAP) {
return { visible: lines, truncated: false, hidden: 0 };
}
return {
visible: lines.slice(-TERM_RENDER_CAP),
truncated: true,
hidden: lines.length - TERM_RENDER_CAP,
};
}

View File

@@ -35,6 +35,7 @@ import AlsoHere from '../components/Presence/AlsoHere';
import { HelpTip } from '../components/HelpTip';
import '../components/Fleet/FullSysCheckPanel.css';
import '../components/Fleet/FleetToolbar.css';
import { TERM_RENDER_CAP, visibleTerminalLines } from '../help/terminalRenderCap';
import './CruciblePage.css';
// ── Types ──────────────────────────────────────────────────────────────────
@@ -326,9 +327,6 @@ if($s -and $s.Status -eq 'Running'){'SSH_PROBE:ONLINE'}else{'SSH_PROBE:OFFLINE'}
const PROBE_SSH_SH = `ss -tlnp 2>/dev/null | grep -q ':22' && echo SSH_PROBE:ONLINE || echo SSH_PROBE:OFFLINE`;
/** Max terminal lines rendered in the DOM (full history kept in state for scrollback export). */
const TERM_RENDER_CAP = 400;
/** Roster page size — avoids rendering 500+ node cards at once. */
const ROSTER_PAGE_SIZE = 80;
@@ -427,11 +425,10 @@ export default function CruciblePage() {
const browseAgent = singleSelectedAgent ?? selectedAgents.find(online) ?? null;
const visibleTermLines = useMemo(
() => (termLines.length > TERM_RENDER_CAP ? termLines.slice(-TERM_RENDER_CAP) : termLines),
const { visible: visibleTermLines, truncated: termTruncated, hidden: termHidden } = useMemo(
() => visibleTerminalLines(termLines),
[termLines],
);
const termTruncated = termLines.length > TERM_RENDER_CAP;
const dispatchTunnelCommand = useCallback(
async (action: string, args?: Record<string, unknown>) => {
@@ -1548,7 +1545,7 @@ export default function CruciblePage() {
)}
{termTruncated && (
<div className="crucible-term-truncated" style={{ opacity: 0.55, fontSize: '0.72rem', padding: '0.2rem 0' }}>
{termLines.length - TERM_RENDER_CAP} older lines hidden (CLEAR to reset)
{termHidden} older lines hidden (CLEAR to reset)
</div>
)}
{visibleTermLines.map((line) => {

View File

@@ -127,7 +127,19 @@ describe('EmberwakePage', () => {
});
// load() runs once on mount — pin ref fix must not cascade into repeated fetches.
expect(listSpy.mock.calls.length).toBeLessThanOrEqual(2);
// War room loads once on mount; hashrate telemetry comes from WS stats_batch (no poll loop).
// War room loads once on mount; live funnel uses WS emberwake_war_room (no poll loop).
expect(warRoomSpy.mock.calls.length).toBeLessThanOrEqual(2);
});
it('does not poll war room on an interval', async () => {
const warRoomSpy = vi.spyOn(api, 'getWarRoom');
renderEmberwake();
await screen.findByRole('heading', { level: 1, name: /Emberwake/i });
await waitFor(() => {
expect(warRoomSpy.mock.calls.length).toBeGreaterThan(0);
});
const initialCalls = warRoomSpy.mock.calls.length;
await new Promise((r) => setTimeout(r, 50));
expect(warRoomSpy.mock.calls.length).toBe(initialCalls);
});
});

View File

@@ -7,6 +7,9 @@ import type { Agent, AgentService } from '../types';
*/
export interface WSDashboardInit {
agents: Agent[];
total?: number;
limit?: number;
offset?: number;
}
export interface WSAgentOffline {