sl-webui d1aea87bd7 feat: SaltyFace web app UI for Chromium kiosk (Issue #370)
Animated robot expression interface as lightweight web application:

**Architecture:**
- HTML5 Canvas rendering engine
- Node.js HTTP server (localhost:3000)
- ROSLIB WebSocket bridge for ROS2 topics
- Fullscreen responsive design (1024×600)

**Features:**
- 8 emotional states (happy, alert, confused, sleeping, excited, emergency, listening, talking)
- Real-time ROS2 subscriptions:
  - /saltybot/state (emotion triggers)
  - /saltybot/battery (status display)
  - /saltybot/target_track (EXCITED emotion)
  - /saltybot/obstacles (ALERT emotion)
  - /social/speech/is_speaking (TALKING emotion)
  - /social/speech/is_listening (LISTENING emotion)
- Tap-to-toggle status overlay
- 60fps Canvas animation on Wayland
- ~80MB total memory (Node.js + browser)

**Files:**
- public/index.html — Main page (1024×600 fullscreen)
- public/salty-face.js — Canvas rendering + ROS2 integration
- server.js — Node.js HTTP server with CORS support
- systemd/salty-face-server.service — Auto-start systemd service
- docs/SALTY_FACE_WEB_APP.md — Complete setup & API documentation

**Integration:**
- Runs in Chromium kiosk (Issue #374)
- Depends on rosbridge_server for WebSocket bridge
- Serves on localhost:3000 (configurable)

**Next:** Issue #371 (Accessibility enhancements)

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-03-04 08:44:37 -05:00

211 lines
5.3 KiB
HTML

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="theme-color" content="#050510">
<title>SaltyFace — Robot Expression UI</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
background: #050510;
color: #d1d5db;
font-family: 'Monaco', 'Courier New', monospace;
overflow: hidden;
user-select: none;
}
.container {
width: 100vw;
height: 100vh;
display: flex;
flex-direction: column;
background: #050510;
}
#canvas {
flex: 1;
display: block;
background: #050510;
touch-action: none;
}
.overlay {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
justify-content: space-between;
padding: 1rem;
text-align: left;
pointer-events: none;
font-size: 0.75rem;
line-height: 1.5;
}
.overlay.hidden {
display: none;
}
.status-top-left {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.status-item {
display: flex;
align-items: center;
gap: 0.5rem;
}
.status-icon {
width: 1rem;
height: 1rem;
border-radius: 50%;
display: inline-block;
}
.battery-icon {
color: #fbbf24;
}
.state-icon {
color: #06b6d4;
}
.status-bottom-left {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.distance-icon {
color: #10b981;
}
.health-icon {
color: #ef4444;
}
.status-top-right {
position: absolute;
top: 1rem;
right: 1rem;
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 0.5rem;
color: #06b6d4;
}
.speed-display {
font-size: 1rem;
font-weight: bold;
}
.tap-hint {
font-size: 0.65rem;
color: #6b7280;
}
.stats-container {
position: absolute;
bottom: 1rem;
right: 1rem;
display: flex;
flex-direction: column;
gap: 0.25rem;
font-size: 0.7rem;
color: #9ca3af;
background: rgba(5, 5, 16, 0.7);
padding: 0.5rem;
border-radius: 4px;
border: 1px solid #1f2937;
}
.connection-status {
position: absolute;
bottom: 1rem;
left: 1rem;
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.7rem;
color: #9ca3af;
}
.connection-status.connected {
color: #10b981;
}
.connection-status.disconnected {
color: #ef4444;
}
.ws-status {
width: 0.5rem;
height: 0.5rem;
border-radius: 50%;
display: inline-block;
}
</style>
</head>
<body>
<div class="container">
<canvas id="canvas" width="1024" height="600"></canvas>
<div class="overlay" id="overlay">
<div class="status-top-left">
<div class="status-item">
<span class="status-icon battery-icon"></span>
<span id="battery">--</span>%
</div>
<div class="status-item">
<span class="status-icon state-icon"></span>
<span id="state">IDLE</span>
</div>
</div>
<div class="status-bottom-left">
<div class="status-item">
<span class="status-icon distance-icon"></span>
<span id="distance">--</span>m
</div>
<div class="status-item">
<span class="status-icon health-icon" id="health-icon"></span>
<span id="health">--</span>%
</div>
</div>
<div class="status-top-right">
<div class="speed-display" id="speed">-- m/s</div>
<div class="tap-hint">[tap to hide]</div>
</div>
</div>
<div class="connection-status" id="ws-status">
<span class="ws-status" id="ws-dot"></span>
<span id="ws-label">DISCONNECTED</span>
</div>
<div class="stats-container">
<div>Emotion: <span id="emotion">HAPPY</span></div>
<div>Talking: <span id="talking">NO</span></div>
<div>Audio: <span id="audio-level">0%</span></div>
</div>
</div>
<script type="module">
import { initSaltyFace } from './salty-face.js';
// Initialize SaltyFace web app
initSaltyFace();
</script>
</body>
</html>