Files
Kao/index.html
Spencer Grimes e82151daa0 Add optional sound effects for state changes
- Warning: soft double-beep
- Critical: urgent descending tone
- Notify: gentle ping
- Recovery: happy ascending chirp

Enable with ?sound=on or tap screen to activate.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-03 17:45:23 -06:00

332 lines
9.3 KiB
HTML

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
<title>Sentry-Emote</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
background: #000000;
min-height: 100vh;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
font-family: monospace;
overflow: hidden;
}
#emote {
font-size: 18vw;
font-weight: bold;
text-align: center;
transition: color 0.3s ease;
}
#message {
font-size: 4vw;
margin-top: 2vh;
opacity: 0.7;
text-align: center;
}
/* Breathing animation - slow pulse */
.breathing {
animation: breathe 3s ease-in-out infinite;
}
@keyframes breathe {
0%, 100% {
opacity: 1;
transform: scale(1);
}
50% {
opacity: 0.7;
transform: scale(0.98);
}
}
/* Shaking animation - rapid jitter for Critical */
.shaking {
animation: shake 0.15s linear infinite;
}
@keyframes shake {
0%, 100% { transform: translateX(0); }
25% { transform: translateX(-5px); }
75% { transform: translateX(5px); }
}
/* Popping animation - scale up for Notifications */
.popping {
animation: pop 1s ease-in-out infinite;
}
@keyframes pop {
0%, 100% {
transform: scale(1);
}
50% {
transform: scale(1.08);
}
}
/* Celebrating animation - bounce and wiggle */
.celebrating {
animation: celebrate 0.5s ease-in-out infinite;
}
@keyframes celebrate {
0%, 100% {
transform: translateY(0) rotate(0deg);
}
25% {
transform: translateY(-10px) rotate(-5deg);
}
75% {
transform: translateY(-10px) rotate(5deg);
}
}
/* Floating animation - gentle drift */
.floating {
animation: float 4s ease-in-out infinite;
}
@keyframes float {
0%, 100% {
transform: translateY(0);
}
50% {
transform: translateY(-8px);
}
}
/* Bouncing animation - playful hop */
.bouncing {
animation: bounce 1s ease-in-out infinite;
}
@keyframes bounce {
0%, 100% {
transform: translateY(0);
}
50% {
transform: translateY(-12px);
}
}
/* Swaying animation - curious side-to-side */
.swaying {
animation: sway 3s ease-in-out infinite;
}
@keyframes sway {
0%, 100% {
transform: rotate(0deg);
}
25% {
transform: rotate(-3deg);
}
75% {
transform: rotate(3deg);
}
}
/* Blink animation - quick fade for winks */
.blink {
animation: blink 0.3s ease-in-out 1;
}
@keyframes blink {
0%, 100% {
opacity: 1;
}
50% {
opacity: 0.3;
}
}
/* Searching animation - looking around for connection */
.searching {
animation: search 2s ease-in-out infinite;
}
@keyframes search {
0%, 100% {
transform: translateX(0);
opacity: 0.6;
}
25% {
transform: translateX(-10px);
opacity: 0.8;
}
75% {
transform: translateX(10px);
opacity: 0.8;
}
}
/* Sleeping animation - very slow, subtle breathing */
.sleeping {
animation: sleep 6s ease-in-out infinite;
}
@keyframes sleep {
0%, 100% {
opacity: 0.4;
transform: scale(1);
}
50% {
opacity: 0.2;
transform: scale(0.98);
}
}
</style>
</head>
<body>
<div id="emote" class="breathing">( ^_^)</div>
<div id="message">Loading...</div>
<script>
const emoteEl = document.getElementById('emote');
const messageEl = document.getElementById('message');
const POLL_INTERVAL = 2000;
// Sound system
let audioCtx = null;
let soundEnabled = new URLSearchParams(window.location.search).get('sound') === 'on';
let lastState = null;
function initAudio() {
if (!audioCtx) {
audioCtx = new (window.AudioContext || window.webkitAudioContext)();
}
if (audioCtx.state === 'suspended') {
audioCtx.resume();
}
}
function playTone(frequency, duration, type = 'sine', volume = 0.1) {
if (!soundEnabled || !audioCtx) return;
const osc = audioCtx.createOscillator();
const gain = audioCtx.createGain();
osc.type = type;
osc.frequency.value = frequency;
gain.gain.value = volume;
gain.gain.exponentialRampToValueAtTime(0.001, audioCtx.currentTime + duration);
osc.connect(gain);
gain.connect(audioCtx.destination);
osc.start();
osc.stop(audioCtx.currentTime + duration);
}
function playWarningSound() {
// Soft double-beep
playTone(440, 0.15);
setTimeout(() => playTone(440, 0.15), 180);
}
function playCriticalSound() {
// Urgent descending tone
playTone(600, 0.2);
setTimeout(() => playTone(400, 0.3), 220);
}
function playNotifySound() {
// Gentle ping
playTone(880, 0.1, 'sine', 0.08);
}
function playRecoverySound() {
// Happy ascending chirp
playTone(523, 0.1);
setTimeout(() => playTone(659, 0.1), 100);
setTimeout(() => playTone(784, 0.15), 200);
}
function handleStateChange(newState, newEmote) {
if (!lastState) {
lastState = newState;
return;
}
// State transitions that trigger sounds
if (newState !== lastState) {
if (newState === 'critical') {
playCriticalSound();
} else if (newState === 'warning') {
playWarningSound();
} else if (newState === 'notify') {
playNotifySound();
} else if (newState === 'optimal' && lastState !== 'optimal' && lastState !== 'sleeping') {
// Recovery - also check for celebration emote
playRecoverySound();
}
lastState = newState;
}
}
// Enable sound on first tap (browser autoplay policy)
document.body.addEventListener('click', () => {
if (!soundEnabled) {
soundEnabled = true;
initAudio();
// Brief confirmation chirp
playTone(660, 0.08, 'sine', 0.05);
}
}, { once: false });
// Also init if ?sound=on
if (soundEnabled) {
document.addEventListener('DOMContentLoaded', initAudio);
}
async function fetchStatus() {
try {
const response = await fetch('/status');
if (!response.ok) throw new Error('Failed to fetch');
const data = await response.json();
updateDisplay(data);
} catch (err) {
// Connection lost state
emoteEl.textContent = '( ?.?)';
emoteEl.style.color = '#888888';
emoteEl.className = 'searching';
messageEl.style.color = '#888888';
messageEl.textContent = '';
}
}
function updateDisplay(data) {
// Check for state changes and play sounds
handleStateChange(data.current_state, data.active_emote);
emoteEl.textContent = data.active_emote;
emoteEl.style.color = data.color;
messageEl.style.color = data.color;
// Only show message when there's something to report
const topEvent = data.active_events && data.active_events[0];
messageEl.textContent = (topEvent && topEvent.message) || '';
// Update animation class
emoteEl.className = '';
if (data.animation) {
emoteEl.classList.add(data.animation);
}
}
// Initial fetch and start polling
fetchStatus();
setInterval(fetchStatus, POLL_INTERVAL);
</script>
</body>
</html>