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:
92
server/web/src/context/SoundContext.tsx
Normal file
92
server/web/src/context/SoundContext.tsx
Normal file
@@ -0,0 +1,92 @@
|
||||
import React, { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react';
|
||||
import { hapticEngine, loadSoundEnabled, loadSoundVolume, type SoundCue } from '../audio/hapticEngine';
|
||||
|
||||
type SoundContextValue = {
|
||||
enabled: boolean;
|
||||
volume: number;
|
||||
setEnabled: (v: boolean) => void;
|
||||
setVolume: (v: number) => void;
|
||||
play: (cue: SoundCue) => void;
|
||||
preview: (cue?: SoundCue) => void;
|
||||
};
|
||||
|
||||
const SoundContext = createContext<SoundContextValue | null>(null);
|
||||
|
||||
export function SoundProvider({ children }: { children: React.ReactNode }) {
|
||||
const [enabled, setEnabledState] = useState(loadSoundEnabled);
|
||||
const [volume, setVolumeState] = useState(loadSoundVolume);
|
||||
|
||||
const setEnabled = useCallback((v: boolean) => {
|
||||
hapticEngine.setEnabled(v);
|
||||
setEnabledState(v);
|
||||
}, []);
|
||||
|
||||
const setVolume = useCallback((v: number) => {
|
||||
hapticEngine.setVolume(v);
|
||||
setVolumeState(hapticEngine.getVolume());
|
||||
}, []);
|
||||
|
||||
const play = useCallback((cue: SoundCue) => {
|
||||
hapticEngine.play(cue);
|
||||
}, []);
|
||||
|
||||
const preview = useCallback((cue: SoundCue = 'click') => {
|
||||
hapticEngine.unlock();
|
||||
hapticEngine.play(cue);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
hapticEngine.setEnabled(enabled);
|
||||
hapticEngine.setVolume(volume);
|
||||
}, [enabled, volume]);
|
||||
|
||||
useEffect(() => {
|
||||
const unlock = () => hapticEngine.unlock();
|
||||
window.addEventListener('pointerdown', unlock, { once: true, passive: true });
|
||||
window.addEventListener('keydown', unlock, { once: true });
|
||||
return () => {
|
||||
window.removeEventListener('pointerdown', unlock);
|
||||
window.removeEventListener('keydown', unlock);
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const onClick = (e: MouseEvent) => {
|
||||
if (!hapticEngine.isEnabled()) return;
|
||||
const target = e.target as HTMLElement | null;
|
||||
if (!target) return;
|
||||
if (target.closest('[data-sfx="off"]')) return;
|
||||
const interactive = target.closest(
|
||||
'button:not(:disabled), .btn:not(:disabled), [role="button"]:not([aria-disabled="true"]), .nav-link, .mobile-nav__link, .mobile-nav__more-btn'
|
||||
);
|
||||
if (!interactive) return;
|
||||
const isNav =
|
||||
interactive.classList.contains('nav-link') ||
|
||||
!!interactive.closest('.layout-nav, .mobile-nav');
|
||||
hapticEngine.play(isNav ? 'nav' : 'click');
|
||||
};
|
||||
document.addEventListener('click', onClick, true);
|
||||
return () => document.removeEventListener('click', onClick, true);
|
||||
}, [enabled]);
|
||||
|
||||
const value = useMemo(
|
||||
() => ({ enabled, volume, setEnabled, setVolume, play, preview }),
|
||||
[enabled, volume, setEnabled, setVolume, play, preview]
|
||||
);
|
||||
|
||||
return <SoundContext.Provider value={value}>{children}</SoundContext.Provider>;
|
||||
}
|
||||
|
||||
const noopSound: SoundContextValue = {
|
||||
enabled: false,
|
||||
volume: 0,
|
||||
setEnabled: () => {},
|
||||
setVolume: () => {},
|
||||
play: () => {},
|
||||
preview: () => {},
|
||||
};
|
||||
|
||||
export function useSound() {
|
||||
const ctx = useContext(SoundContext);
|
||||
return ctx ?? noopSound;
|
||||
}
|
||||
53
server/web/src/context/VisualEffectsContext.tsx
Normal file
53
server/web/src/context/VisualEffectsContext.tsx
Normal file
@@ -0,0 +1,53 @@
|
||||
import React, { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react';
|
||||
import {
|
||||
dispatchVisualPrefsChange,
|
||||
loadGlowParticlesEnabled,
|
||||
saveGlowParticlesEnabled,
|
||||
VISUAL_PREFS_EVENT,
|
||||
} from '../visual/visualPrefs';
|
||||
|
||||
type VisualEffectsContextValue = {
|
||||
glowParticles: boolean;
|
||||
setGlowParticles: (v: boolean) => void;
|
||||
};
|
||||
|
||||
const VisualEffectsContext = createContext<VisualEffectsContextValue | null>(null);
|
||||
|
||||
export function VisualEffectsProvider({ children }: { children: React.ReactNode }) {
|
||||
const [glowParticles, setGlowState] = useState(loadGlowParticlesEnabled);
|
||||
|
||||
const setGlowParticles = useCallback((v: boolean) => {
|
||||
saveGlowParticlesEnabled(v);
|
||||
setGlowState(v);
|
||||
dispatchVisualPrefsChange();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const sync = () => setGlowState(loadGlowParticlesEnabled());
|
||||
window.addEventListener(VISUAL_PREFS_EVENT, sync);
|
||||
return () => window.removeEventListener(VISUAL_PREFS_EVENT, sync);
|
||||
}, []);
|
||||
|
||||
const value = useMemo(
|
||||
() => ({ glowParticles, setGlowParticles }),
|
||||
[glowParticles, setGlowParticles]
|
||||
);
|
||||
|
||||
return (
|
||||
<VisualEffectsContext.Provider value={value}>{children}</VisualEffectsContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useVisualEffects(): VisualEffectsContextValue {
|
||||
const ctx = useContext(VisualEffectsContext);
|
||||
if (!ctx) {
|
||||
return {
|
||||
glowParticles: loadGlowParticlesEnabled(),
|
||||
setGlowParticles: (v) => {
|
||||
saveGlowParticlesEnabled(v);
|
||||
dispatchVisualPrefsChange();
|
||||
},
|
||||
};
|
||||
}
|
||||
return ctx;
|
||||
}
|
||||
Reference in New Issue
Block a user