Initial release — Re-Commander v1.0

Local web-based control interface for the Jibo social robot via the ROM
WebSocket API (port 8160) and on-device ASR (port 8088). Features head
navigation via click-to-look and arrow keys, speech/listen/Voice-AI loop,
display control, camera/photo capture, and entity tracking — no cloud
dependency required.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
pasketti
2026-04-19 02:40:41 -04:00
commit 11d72f1e75
9 changed files with 3636 additions and 0 deletions

579
public/app.js Normal file
View File

@@ -0,0 +1,579 @@
'use strict';
// ── WebSocket to server ──────────────────────────────────────────────────────
let ws;
let connected = false;
let sessionActive = false;
let currentAngles = [0, 0];
let lastSayTx = null;
let lastListenTx = null;
let videoActive = false;
let entities = {}; // entityId → track data
function connectWS() {
const proto = location.protocol === 'https:' ? 'wss' : 'ws';
ws = new WebSocket(`${proto}://${location.host}/ws`);
ws.onopen = () => {
connected = true;
};
ws.onclose = () => {
connected = false;
setStatus(false, 'Disconnected — reconnecting…');
setTimeout(connectWS, 2000);
};
ws.onerror = () => {};
ws.onmessage = (e) => {
let msg;
try { msg = JSON.parse(e.data); } catch { return; }
handleServerMessage(msg);
};
}
function handleServerMessage(msg) {
if (msg.type === 'status') {
sessionActive = !!msg.sessionID;
currentAngles = msg.angles || [0, 0];
setStatus(msg.connected && sessionActive,
msg.connected
? (sessionActive ? 'Connected • ' + msg.sessionID.slice(0, 8) + '…' : 'Connecting…')
: 'Disconnected');
return;
}
if (msg.type === 'jiboEvent') {
handleJiboEvent(msg.body, msg.txId);
}
}
function handleJiboEvent(body, txId) {
if (!body) return;
const evt = body.Event || body.ResponseString || '?';
if (evt === 'onConfig') return; // heartbeat GetConfig response — suppress from log
logEvent(evt, body, txId);
switch (body.Event) {
case 'onHotWordHeard':
flashHotword(body.utterance || 'hey jibo', body.score);
if (document.getElementById('auto-listen-toggle').checked) doListen();
break;
case 'onStart':
if (txId === lastListenTx) {
clearListenTimeout();
document.getElementById('listen-result').textContent = '(listening…)';
}
break;
case 'onListenResult': {
clearListenTimeout();
const speech = body.Speech || '';
document.getElementById('listen-result').textContent = '"' + speech + '"';
if (speech && document.getElementById('llm-toggle').checked) runLLMLoop(speech);
break;
}
case 'onPhotoSaved':
if (body.url) addPhoto(body.url);
break;
case 'onVideoReady':
if (body.URI) startVideoFeed(body.URI);
break;
case 'onEntityGained':
case 'onEntityUpdate':
if (body.Tracks) body.Tracks.forEach(t => { entities[t.EntityID] = t; });
renderEntities();
break;
case 'onEntityLost':
if (body.Tracks) body.Tracks.forEach(t => { delete entities[t.EntityID]; });
renderEntities();
break;
case 'onHeadTouch':
if (body.Pads) renderHeadTouch(body.Pads);
break;
case 'onLookAtAchieved':
break;
case 'onStop':
if (txId === lastListenTx) {
clearListenTimeout();
const reason = body.StopReason || body.ListenStopReason || 'stopped';
document.getElementById('listen-result').textContent = '(stopped: ' + reason + ')';
}
break;
case 'onError':
if (txId === lastListenTx) {
clearListenTimeout();
const errStr = body.EventError?.ErrorString || body.ErrorString || 'unknown error';
document.getElementById('listen-result').textContent = '(error: ' + errStr + ')';
}
break;
}
}
// ── REST helpers ─────────────────────────────────────────────────────────────
async function api(method, path, body) {
try {
const opts = { method, headers: { 'Content-Type': 'application/json' } };
if (body) opts.body = JSON.stringify(body);
const res = await fetch(path, opts);
return await res.json();
} catch (err) {
logEvent('api-error: ' + path, { error: err.message }, null);
}
}
const post = (path, body) => api('POST', path, body);
const get = (path) => api('GET', path);
// ── Status ───────────────────────────────────────────────────────────────────
function setStatus(ok, label) {
const dot = document.getElementById('status-dot');
const lbl = document.getElementById('status-label');
dot.className = ok ? 'ok' : '';
lbl.textContent = label;
}
// ── Arrow pad ────────────────────────────────────────────────────────────────
// ── Directional step ──────────────────────────────────────────────────────────
// Left/right: angle nudge (preserves psi exactly, no vertical drift).
// Up/down: screen-coord click 30% from center (same math as click-to-look).
// Both endpoints block server-side until the robot acks, so the loop has
// exactly one command in-flight at all times. Gen counter kills a stale loop
// on direction change after at most one more in-flight step completes.
const STEP_FRAC = 0.30;
let moveGen = 0;
let moveHeld = false;
// Horizontal steps use pctY slightly above true center to compensate for
// a small downward bias in the camera's optical axis vs the head neutral.
// Tune HORIZ_CENTER_Y if left/right still drifts up or down.
const HORIZ_CENTER_Y = 0.38;
async function runMoveLoop(dir, gen) {
while (moveHeld && gen === moveGen) {
let pctX, pctY;
if (dir === 'left') { pctX = 0.5 - STEP_FRAC; pctY = HORIZ_CENTER_Y; }
else if (dir === 'right') { pctX = 0.5 + STEP_FRAC; pctY = HORIZ_CENTER_Y; }
else if (dir === 'up') { pctX = 0.5; pctY = 0.5 - STEP_FRAC; }
else { pctX = 0.5; pctY = 0.5 + STEP_FRAC; }
const x = Math.round((1 - pctX) * 640);
const y = Math.round(pctY * 480);
await post('/api/look/step', { x, y });
}
}
function startMove(dir) {
moveHeld = true;
runMoveLoop(dir, ++moveGen);
}
function stopMove() {
moveHeld = false;
}
function addMoveButton(id, dir) {
const btn = document.getElementById(id);
btn.addEventListener('pointerdown', (e) => { e.preventDefault(); startMove(dir); });
btn.addEventListener('pointerup', stopMove);
btn.addEventListener('pointercancel', stopMove);
btn.addEventListener('pointerleave', stopMove);
}
addMoveButton('btn-up', 'up');
addMoveButton('btn-down', 'down');
addMoveButton('btn-left', 'left');
addMoveButton('btn-right', 'right');
// Keyboard arrow keys — browser key-repeat ignored; our await loop is the repeat
const KEY_TO_DIR = { ArrowLeft: 'left', ArrowRight: 'right', ArrowUp: 'up', ArrowDown: 'down' };
const heldKeys = new Set();
document.addEventListener('keydown', (e) => {
if (['INPUT', 'TEXTAREA', 'SELECT'].includes(document.activeElement.tagName)) return;
if (e.key === ' ') { e.preventDefault(); post('/api/look/angle', { theta: 0, psi: 0 }); return; }
const dir = KEY_TO_DIR[e.key];
if (!dir || e.repeat || heldKeys.has(e.key)) return;
e.preventDefault();
heldKeys.add(e.key);
startMove(dir);
});
document.addEventListener('keyup', (e) => {
if (!KEY_TO_DIR[e.key]) return;
heldKeys.delete(e.key);
stopMove();
});
// ── Click-to-look ─────────────────────────────────────────────────────────────
const cameraWrap = document.getElementById('camera-wrap');
const clickDot = document.getElementById('click-dot');
cameraWrap.addEventListener('click', (e) => {
const rect = cameraWrap.getBoundingClientRect();
const x = e.clientX - rect.left;
const y = e.clientY - rect.top;
const pctX = x / rect.width;
const pctY = y / rect.height;
// Show click indicator
clickDot.style.left = x + 'px';
clickDot.style.top = y + 'px';
clickDot.style.display = 'block';
setTimeout(() => { clickDot.style.display = 'none'; }, 800);
// Mirror X to match the horizontally-flipped display
const camX = Math.round((1 - pctX) * 640);
const camY = Math.round(pctY * 480);
const track = document.getElementById('track-flag').checked;
post('/api/look/screen', { x: camX, y: camY, track });
});
// ── Say ───────────────────────────────────────────────────────────────────────
document.getElementById('btn-say').addEventListener('click', async () => {
const text = document.getElementById('say-text').value.trim();
if (!text) return;
const r = await post('/api/say', { text });
if (r) lastSayTx = r.txId;
});
document.getElementById('btn-say-cancel').addEventListener('click', () => {
if (lastSayTx) post('/api/cancel', { txId: lastSayTx });
});
// ── Listen ────────────────────────────────────────────────────────────────────
let listenClientTimer = null;
function clearListenTimeout() {
if (listenClientTimer) { clearTimeout(listenClientTimer); listenClientTimer = null; }
}
async function doListen() {
clearListenTimeout();
document.getElementById('listen-result').textContent = '(waiting for robot…)';
const maxNoSpeech = parseInt(document.getElementById('listen-max-nosp').value) || 5000;
const maxSpeech = parseInt(document.getElementById('listen-max-speech').value) || 10000;
const r = await post('/api/listen', { maxSpeech, maxNoSpeech });
if (r) {
lastListenTx = r.txId;
const clientTimeout = Math.max(maxSpeech, maxNoSpeech) + 16000;
listenClientTimer = setTimeout(() => {
document.getElementById('listen-result').textContent =
'(timed out — cloud ASR may be unavailable)';
}, clientTimeout);
}
}
document.getElementById('btn-listen').addEventListener('click', doListen);
document.getElementById('btn-listen-cancel').addEventListener('click', () => {
clearListenTimeout();
if (lastListenTx) post('/api/cancel', { txId: lastListenTx });
document.getElementById('listen-result').textContent = '(cancelled)';
});
// ── Auto-listen + Voice AI ────────────────────────────────────────────────────
let llmHistory = []; // [{role:'user'|'assistant', content:string}]
function llmStatus(msg) {
document.getElementById('llm-status').textContent = msg;
}
async function runLLMLoop(speechText) {
llmHistory.push({ role: 'user', content: speechText });
llmStatus('Thinking…');
const endpoint = document.getElementById('llm-endpoint').value.trim();
const model = document.getElementById('llm-model').value.trim();
const systemPrompt = document.getElementById('llm-system-prompt').value.trim();
const r = await post('/api/llm/chat', {
messages: llmHistory,
endpoint: endpoint || undefined,
model: model || undefined,
systemPrompt: systemPrompt || undefined,
});
if (!r || r.error) {
llmStatus('LLM error: ' + (r?.error || 'no response'));
llmHistory.pop(); // undo the user push so history stays consistent
return;
}
const reply = r.reply;
llmHistory.push({ role: 'assistant', content: reply });
llmStatus(`[${llmHistory.length / 2} turns] Last: "${reply.slice(0, 60)}${reply.length > 60 ? '…' : ''}"`);
// Fill say box so user can see what Jibo is about to say
document.getElementById('say-text').value = reply;
await post('/api/say', { text: reply });
}
document.getElementById('btn-llm-clear').addEventListener('click', () => {
llmHistory = [];
llmStatus('Conversation cleared.');
});
// ── Attention ─────────────────────────────────────────────────────────────────
document.querySelectorAll('.attn-btn').forEach(btn => {
btn.addEventListener('click', function () {
document.querySelectorAll('.attn-btn').forEach(b => b.classList.remove('active'));
this.classList.add('active');
post('/api/attention', { mode: this.dataset.mode });
});
});
// ── Volume ────────────────────────────────────────────────────────────────────
document.getElementById('volume-slider').addEventListener('input', function () {
document.getElementById('volume-label').textContent =
Math.round(this.value * 100) + '%';
});
document.getElementById('btn-set-volume').addEventListener('click', () => {
const level = parseFloat(document.getElementById('volume-slider').value);
post('/api/volume', { level });
});
// ── Camera / Video ────────────────────────────────────────────────────────────
let videoTxId = null;
document.getElementById('btn-video-start').addEventListener('click', async () => {
const r = await post('/api/video/start', { duration: 0 });
if (r) videoTxId = r.txId;
document.getElementById('video-status').textContent = 'Waiting for VideoReady…';
});
document.getElementById('btn-video-stop').addEventListener('click', () => {
post('/api/video/stop', {});
stopVideoFeed();
});
function startVideoFeed(uri) {
const feed = document.getElementById('camera-feed');
const noFeed = document.getElementById('camera-no-feed');
feed.src = '/proxy/stream?uri=' + encodeURIComponent(uri);
feed.style.display = 'block';
noFeed.style.display = 'none';
document.getElementById('video-status').textContent = 'Streaming';
videoActive = true;
feed.onerror = () => {
stopVideoFeed();
document.getElementById('video-status').textContent = 'Stream error';
};
}
function stopVideoFeed() {
const feed = document.getElementById('camera-feed');
feed.src = '';
feed.style.display = 'none';
document.getElementById('camera-no-feed').style.display = '';
document.getElementById('video-status').textContent = '';
videoActive = false;
}
// ── Take Photo ────────────────────────────────────────────────────────────────
document.getElementById('btn-photo').addEventListener('click', () => {
post('/api/photo', {
camera: document.getElementById('photo-camera').value,
resolution: document.getElementById('photo-res').value
});
});
function addPhoto(url) {
const strip = document.getElementById('photo-strip');
const img = document.createElement('img');
img.src = url;
img.title = url;
img.addEventListener('click', () => openPhotoModal(img.src));
strip.prepend(img);
// Show photo in camera area if not streaming video
if (!videoActive) {
const feed = document.getElementById('camera-feed');
const noFeed = document.getElementById('camera-no-feed');
feed.src = img.src;
feed.style.display = 'block';
noFeed.style.display = 'none';
}
}
// ── Photo modal ───────────────────────────────────────────────────────────────
function openPhotoModal(src) {
document.getElementById('photo-modal-img').src = src;
document.getElementById('photo-modal').classList.add('open');
}
document.getElementById('photo-modal').addEventListener('click', (e) => {
if (e.target === e.currentTarget || e.target.id === 'photo-modal-close') {
document.getElementById('photo-modal').classList.remove('open');
}
});
// ── Display controls ──────────────────────────────────────────────────────────
document.getElementById('btn-eye').addEventListener('click', () => {
post('/api/display/eye', {});
});
document.getElementById('btn-play-anim').addEventListener('click', () => {
const name = document.getElementById('eye-anim-select').value;
if (name) post('/api/display/anim', { name });
});
document.getElementById('btn-display-text').addEventListener('click', () => {
const text = document.getElementById('display-text').value.trim();
if (!text) return;
post('/api/display/text', { text });
});
document.getElementById('btn-display-image').addEventListener('click', () => {
const src = document.getElementById('display-img-src').value.trim();
if (!src) return;
post('/api/display/image', { src });
});
// ── Entities ──────────────────────────────────────────────────────────────────
function renderEntities() {
const list = document.getElementById('entity-list');
const ids = Object.keys(entities);
if (ids.length === 0) {
list.innerHTML = '<div style="color:var(--muted);font-size:12px;">No entities tracked</div>';
return;
}
list.innerHTML = ids.map(id => {
const t = entities[id];
const sc = t.ScreenCoords ? t.ScreenCoords.map(v => v.toFixed(0)).join(',') : '?';
return `<div class="entity-item" data-id="${id}">
<span>${t.Type || 'Person'} #${id} (${t.Confidence || 0}%)<br>
<span style="color:var(--muted);font-size:11px;">screen: [${sc}]</span></span>
<button class="track-btn btn" onclick="trackEntity(${id})">Track</button>
</div>`;
}).join('');
}
window.trackEntity = function (entityId) {
post('/api/look/entity', { entityId, track: true });
};
// ── Head Touch ────────────────────────────────────────────────────────────────
const padNames = ['frontLeft', 'middleLeft', 'backLeft', 'frontRight', 'middleRight', 'backRight'];
function renderHeadTouch(pads) {
padNames.forEach((name, i) => {
const el = document.querySelector(`.pad-indicator[data-pad="${name}"]`);
if (el) el.classList.toggle('active', !!pads[i]);
});
// Clear after 1s
setTimeout(() => padNames.forEach(name => {
const el = document.querySelector(`.pad-indicator[data-pad="${name}"]`);
if (el) el.classList.remove('active');
}), 1000);
}
// ── Subscriptions (toggle) ────────────────────────────────────────────────────
// Subscriptions are always-on by default (server subscribes on session start).
// Buttons here allow re-subscribing if needed.
['entity', 'motion', 'headtouch'].forEach(sub => {
document.getElementById('btn-sub-' + sub).addEventListener('click', function () {
const map = { entity: 'Entity', motion: 'Motion', headtouch: 'HeadTouch' };
post('/api/look/angle', {}); // ping/no-op to keep conn; subscriptions are server-managed
});
});
// ── Tabs ──────────────────────────────────────────────────────────────────────
document.querySelectorAll('.tab-btn').forEach(btn => {
btn.addEventListener('click', function () {
document.querySelectorAll('.tab-btn').forEach(b => b.classList.remove('active'));
document.querySelectorAll('.tab-panel').forEach(p => p.classList.remove('active'));
this.classList.add('active');
document.getElementById(this.dataset.tab).classList.add('active');
});
});
// ── Event log ─────────────────────────────────────────────────────────────────
const MAX_LOG = 200;
function logEvent(eventName, body, txId) {
const log = document.getElementById('event-log');
const now = new Date().toLocaleTimeString();
// Pick color class
let cls = 'evt';
if (eventName.toLowerCase().includes('error')) cls = 'evt-error';
else if (eventName === 'MotionDetected') cls = 'evt-motion';
else if (['TrackGained', 'TrackUpdate', 'TrackLost'].includes(eventName)) cls = 'evt-entity';
// Short summary of body
let detail = '';
if (body.Speech) detail = ' "' + body.Speech + '"';
else if (body.URI) detail = ' ' + body.URI;
else if (body.ErrorString) detail = ' ' + body.ErrorString;
const el = document.createElement('div');
el.className = 'log-entry';
el.innerHTML = `<span class="ts">${now}</span> <span class="${cls}">${eventName}</span>${detail}`;
log.prepend(el);
// Trim
while (log.children.length > MAX_LOG) log.removeChild(log.lastChild);
}
document.getElementById('btn-clear-log').addEventListener('click', () => {
document.getElementById('event-log').innerHTML = '';
});
// ── Hotword indicator ─────────────────────────────────────────────────────────
let hotwordTimer = null;
function flashHotword(utterance, score) {
const el = document.getElementById('hotword-indicator');
if (!el) return;
el.textContent = '🎙 "' + utterance + '" (score: ' + (score || 0).toFixed(0) + ')';
el.classList.add('active');
clearTimeout(hotwordTimer);
hotwordTimer = setTimeout(() => el.classList.remove('active'), 3000);
}
// ── Init ──────────────────────────────────────────────────────────────────────
connectWS();
// Populate LLM fields from server config (.env defaults)
get('/api/config').then(cfg => {
if (!cfg) return;
if (cfg.llmEndpoint) document.getElementById('llm-endpoint').value = cfg.llmEndpoint;
if (cfg.llmModel) document.getElementById('llm-model').value = cfg.llmModel;
if (cfg.llmSystemPrompt) document.getElementById('llm-system-prompt').value = cfg.llmSystemPrompt;
});

592
public/index.html Normal file
View File

@@ -0,0 +1,592 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Re-Commander — Jibo</title>
<link rel="stylesheet" href="style.css">
</head>
<body>
<header>
<div id="status-dot"></div>
<h1>Re<span>-Commander</span></h1>
<span id="status-label">Connecting…</span>
<span id="hotword-indicator"></span>
</header>
<div class="main">
<!-- ── LEFT PANEL ── -->
<div class="left-panel">
<!-- Look / Navigation -->
<div class="section">
<div class="section-title">Head Navigation</div>
<div style="display:flex; gap:16px; align-items:center; margin-bottom:8px;">
<div class="arrow-pad">
<div></div>
<button id="btn-up" title="Look up (↑)"></button>
<div></div>
<button id="btn-left" title="Look left (←)"></button>
<div></div>
<button id="btn-right" title="Look right (→)"></button>
<div></div>
<button id="btn-down" title="Look down (↓)"></button>
<div></div>
</div>
</div>
</div>
<!-- Say -->
<div class="section">
<div class="section-title">Say</div>
<div class="field">
<textarea id="say-text" rows="2" placeholder="Hello! I am Jibo."></textarea>
</div>
<div class="controls-row">
<button id="btn-say">▶ Say</button>
<button id="btn-say-cancel" class="danger">✕ Stop</button>
</div>
</div>
<!-- Listen -->
<div class="section">
<div class="section-title">Listen</div>
<div class="row">
<label>Max speech</label>
<input type="number" id="listen-max-speech" value="10000" step="1000">
<label>ms</label>
</div>
<div class="row">
<label>No-speech TO</label>
<input type="number" id="listen-max-nosp" value="5000" step="1000">
<label>ms</label>
</div>
<div class="controls-row" style="margin-bottom:8px;">
<button id="btn-listen">🎙 Listen</button>
<button id="btn-listen-cancel" class="danger">✕ Cancel</button>
</div>
<div id="listen-result">(result appears here)</div>
<div style="margin-top:8px;display:flex;align-items:center;gap:6px;">
<input type="checkbox" id="auto-listen-toggle" style="width:auto;">
<label for="auto-listen-toggle" style="margin:0;cursor:pointer;">Auto-listen on hotword</label>
</div>
</div>
<!-- Voice AI -->
<div class="section">
<div class="section-title">Voice AI</div>
<div style="display:flex;align-items:center;gap:6px;margin-bottom:8px;">
<input type="checkbox" id="llm-toggle" style="width:auto;">
<label for="llm-toggle" style="margin:0;cursor:pointer;">LLM voice loop</label>
<button id="btn-llm-clear" style="margin-left:auto;font-size:11px;padding:2px 8px;" title="Clear conversation history">↺ Clear</button>
</div>
<div class="field">
<label>Completions endpoint</label>
<input type="text" id="llm-endpoint" placeholder="http://localhost:11434/v1/chat/completions">
</div>
<div class="field">
<label>Model</label>
<input type="text" id="llm-model" placeholder="llama3">
</div>
<div class="field">
<label>System prompt</label>
<textarea id="llm-system-prompt" rows="3" placeholder="You are Jibo, a friendly social robot. Keep responses brief and conversational."></textarea>
</div>
<div id="llm-status" style="font-size:11px;color:var(--muted);font-style:italic;min-height:16px;"></div>
</div>
<!-- Attention -->
<div class="section">
<div class="section-title">Attention Mode</div>
<div class="attention-grid">
<button class="attn-btn" data-mode="OFF">Off</button>
<button class="attn-btn" data-mode="IDLE">Idle</button>
<button class="attn-btn" data-mode="DISENGAGE">Disengage</button>
<button class="attn-btn" data-mode="ENGAGED">Engaged</button>
<button class="attn-btn" data-mode="SPEAKING">Speaking</button>
<button class="attn-btn" data-mode="FIXATED">Fixated</button>
<button class="attn-btn" data-mode="ATTRACTABLE">Attractable</button>
<button class="attn-btn" data-mode="COMMAND">Command</button>
</div>
</div>
<!-- Volume -->
<div class="section">
<div class="section-title">Volume</div>
<div class="row">
<input type="range" id="volume-slider" min="0" max="1" step="0.05" value="0.75">
<span id="volume-label" style="min-width:32px;text-align:right;font-size:12px;">75%</span>
</div>
<button id="btn-set-volume">Set Volume</button>
</div>
</div><!-- /left-panel -->
<!-- ── CENTER PANEL ── -->
<div class="center-panel">
<!-- Camera feed -->
<div id="camera-wrap">
<img id="camera-feed" alt="Jibo camera" style="display:none;">
<div id="camera-no-feed">
<div style="font-size:40px;">📷</div>
<div>No camera feed</div>
<div style="font-size:11px;color:var(--muted);">Use the Camera tab to start video or take a photo</div>
</div>
<div id="click-dot"></div>
</div>
<!-- Arrow pad (compact, centered under feed) -->
<div class="controls-row" style="justify-content:center; gap:10px;">
<div style="display:flex;flex-direction:column;align-items:center;gap:4px;">
<div style="font-size:11px;color:var(--muted);">Click on feed → look there</div>
<div style="display:flex;gap:6px;align-items:center;">
<span style="font-size:12px;color:var(--muted);">Track:</span>
<input type="checkbox" id="track-flag" style="width:auto;">
<span id="video-status"></span>
</div>
</div>
</div>
<!-- Photos -->
<div style="width:100%;max-width:640px;">
<div class="section-title" style="margin-bottom:6px;">Photo Strip</div>
<div id="photo-strip"></div>
</div>
</div><!-- /center-panel -->
<!-- ── RIGHT PANEL ── -->
<div class="right-panel">
<div class="tabs">
<button class="tab-btn active" data-tab="tab-camera">Camera</button>
<button class="tab-btn" data-tab="tab-display">Display</button>
<button class="tab-btn" data-tab="tab-entities">Entities</button>
<button class="tab-btn" data-tab="tab-log">Log</button>
</div>
<!-- Camera tab -->
<div class="tab-panel active" id="tab-camera">
<div class="section-title">Video Stream</div>
<div class="video-controls" style="margin-bottom:8px;">
<button id="btn-video-start">▶ Start Video</button>
<button id="btn-video-stop" class="danger">■ Stop</button>
</div>
<div class="section-title" style="margin-top:10px;">Take Photo</div>
<div class="row">
<label>Camera</label>
<select id="photo-camera">
<option value="right">Right</option>
<option value="left">Left</option>
</select>
</div>
<div class="row">
<label>Resolution</label>
<select id="photo-res">
<option value="highRes">HighRes</option>
<option value="medRes">MedRes</option>
<option value="lowRes">LowRes</option>
<option value="microRes">MicroRes</option>
</select>
</div>
<button id="btn-photo" style="width:100%;margin-bottom:10px;">📷 Take Photo</button>
<div class="section-title" style="margin-top:10px;">Subscriptions</div>
<div class="controls-row">
<button id="btn-sub-entity" class="active">👤 Entity</button>
<button id="btn-sub-motion" class="active">〰 Motion</button>
<button id="btn-sub-headtouch" class="active">✋ Touch</button>
</div>
</div>
<!-- Display tab -->
<div class="tab-panel" id="tab-display">
<div class="section-title">Eye</div>
<button id="btn-eye" style="width:100%;margin-bottom:10px;">👁 Show Eye</button>
<div class="section-title">Play Animation</div>
<div class="field">
<select id="eye-anim-select">
<optgroup label="── Blinks">
<option>Eye_Blink_01</option>
<option>Eye_Blink_02</option>
<option>Eye_Double_Blink_01</option>
<option>Eye_Double_Blink_02</option>
<option>Eye_Double_Blink_03</option>
<option>Eye_Long_Blink_01</option>
<option>Eye_Medium_Blink_01</option>
<option>Eye_Quick_Blink_01</option>
<option>Eye_Quick_Blink_02</option>
</optgroup>
<optgroup label="── Expressions">
<option>Eye_Curious_01</option>
<option>Eye_Disgusted_01</option>
<option>Eye_Disgusted_02</option>
<option>eye_happy_00</option>
<option>eye_happy_01</option>
<option>eye_happy_02</option>
<option>eye_sad_01</option>
<option>eye_sad_02</option>
<option>eye_scared_00</option>
<option>eye_scared_01</option>
<option>eye_scared_02</option>
<option>eye_thinking_02</option>
<option>Confused_00</option>
<option>surprised_00</option>
<option>worried_01</option>
<option>worried_03</option>
<option>worried_04</option>
</optgroup>
<optgroup label="── Eye Moves">
<option>Checking_00</option>
<option>Checking_00_02</option>
<option>Checking_01</option>
<option>checking-lr-high-01</option>
<option>checking-lr-low-01</option>
<option>checking-lr-mid-01</option>
<option>checking-c-highlow-01</option>
<option>checking-lr-lowhigh-01</option>
<option>checking-refresher-01</option>
<option>checking_08</option>
<option>Glance_Down_01</option>
<option>Glance_Left_01</option>
<option>Glance_Left_02</option>
<option>Glance_Right_01</option>
<option>Glance_Right_02</option>
</optgroup>
<optgroup label="── Poses">
<option>Eye_Look_Center_Middle_01</option>
<option>Eye_Look_Center_Up_01</option>
<option>Eye_Look_Center_Down_01</option>
<option>Eye_Look_Left_Middle_01</option>
<option>Eye_Look_Left_Up_01</option>
<option>Eye_Look_Left_Down_01</option>
<option>Eye_Look_Right_Middle_01</option>
<option>Eye_Look_Right_Up_01</option>
<option>Eye_Look_Right_Down_01</option>
</optgroup>
<optgroup label="── Global Eye">
<option>Close_To_Open_01</option>
<option>Open_To_Close_01</option>
<option>EyeToHappy_00</option>
<option>EyeToHappy_01</option>
<option>EyeToHappy_02</option>
<option>HappyToEye_00</option>
<option>HappyToEye_01</option>
<option>eye-bounce-01</option>
<option>eye-bounce-02</option>
<option>eye-closed</option>
<option>eye-laugh-00</option>
<option>eye-laugh-01</option>
<option>eye-pop-to-rest-01</option>
<option>eye-pop-to-rest-02</option>
<option>ahh-01</option>
<option>dilation</option>
<option>dilation-02</option>
<option>no-match-eye</option>
<option>quiver-01</option>
<option>quiver-02</option>
<option>quiver-03</option>
</optgroup>
<optgroup label="── Transitions">
<option>eye-transition-blink-00</option>
<option>eye-transition-blink-02</option>
<option>eye-transition-fade-00</option>
<option>eye-closed-fade-to-black-01</option>
<option>eye-fade-from-black-00</option>
<option>eye-fade-from-black-01</option>
<option>dim-transition</option>
</optgroup>
<optgroup label="── Globals">
<option>disengaged-to-engaged-01</option>
<option>disengaged-to-engaged-02</option>
<option>disengaged-to-listening-01</option>
<option>disengaged-to-listening-02</option>
<option>disengaged-to-listening-03</option>
<option>engaged-01</option>
<option>engaged-02</option>
<option>engaged-03</option>
<option>engaged-04</option>
<option>eye-center-to-up-01</option>
<option>eye-center-to-down-01</option>
<option>eye-up-to-center-01</option>
<option>eye-down-to-center-01</option>
</optgroup>
<optgroup label="── Thinking">
<option>Thinking_Dots_03</option>
<option>eye_thinking_02</option>
</optgroup>
<optgroup label="── Misc">
<option>Sleeping_Idle_01</option>
<option>end-listening-01</option>
<option>eye-default</option>
<option>Exclamation_00</option>
</optgroup>
<optgroup label="── Emoji (Classic)">
<option>Emoji_Airplane</option>
<option>Emoji_Ant</option>
<option>Emoji_Apple</option>
<option>Emoji_Art</option>
<option>Emoji_Baby</option>
<option>Emoji_Baseball</option>
<option>Emoji_Basketball</option>
<option>Emoji_Beer</option>
<option>Emoji_Bicycle</option>
<option>Emoji_Bird_Blue</option>
<option>Emoji_Book</option>
<option>Emoji_Bowling</option>
<option>Emoji_Boxing</option>
<option>Emoji_Brain</option>
<option>Emoji_Burger</option>
<option>Emoji_Cake</option>
<option>Emoji_Camera</option>
<option>Emoji_Car</option>
<option>Emoji_Cat</option>
<option>Emoji_Checkmark</option>
<option>Emoji_ChristmasTree</option>
<option>Emoji_Clap</option>
<option>Emoji_Coffee</option>
<option>Emoji_Computer</option>
<option>Emoji_Dog</option>
<option>Emoji_Drawing</option>
<option>Emoji_Earth</option>
<option>Emoji_ExclamationBlue</option>
<option>Emoji_ExclamationRed</option>
<option>Emoji_ExclamationYellow</option>
<option>Emoji_Fire</option>
<option>Emoji_Fireworks</option>
<option>Emoji_Fish</option>
<option>Emoji_Football</option>
<option>Emoji_Gift</option>
<option>Emoji_Golf</option>
<option>Emoji_Halo</option>
<option>Emoji_HeartArrow</option>
<option>Emoji_HeartBlue</option>
<option>Emoji_HeartRed</option>
<option>Emoji_Hockey</option>
<option>Emoji_Hotdog</option>
<option>Emoji_House</option>
<option>Emoji_IceCream</option>
<option>Emoji_Lightbulb</option>
<option>Emoji_LightningBolt</option>
<option>Emoji_Lock</option>
<option>Emoji_Lunch</option>
<option>Emoji_Magic</option>
<option>Emoji_Money</option>
<option>Emoji_Moon</option>
<option>Emoji_Mountain</option>
<option>Emoji_Music</option>
<option>Emoji_PartyBlue</option>
<option>Emoji_PartyPink</option>
<option>Emoji_Penguin</option>
<option>Emoji_Pizza</option>
<option>Emoji_Planet</option>
<option>Emoji_Question</option>
<option>Emoji_Rainbow</option>
<option>Emoji_Robot</option>
<option>Emoji_Rocket</option>
<option>Emoji_Running</option>
<option>Emoji_Santa</option>
<option>Emoji_Shark</option>
<option>Emoji_Snowflake</option>
<option>Emoji_Snowman</option>
<option>Emoji_Soccer</option>
<option>Emoji_Star</option>
<option>Emoji_Sun</option>
<option>Emoji_Sunglasses</option>
<option>Emoji_Sushi</option>
<option>Emoji_Taco</option>
<option>Emoji_Tennis</option>
<option>Emoji_ThumbsDown</option>
<option>Emoji_ThumbsUp</option>
<option>Emoji_Tools</option>
<option>Emoji_Tree</option>
<option>Emoji_Truck</option>
<option>Emoji_Umbrella</option>
<option>Emoji_VideoGame</option>
<option>Emoji_Watermelon</option>
<option>Emoji_Waving</option>
<option>Emoji_Whale</option>
<option>Emoji_Wine</option>
</optgroup>
<optgroup label="── Emoji (HF)">
<option>emoji-airplane-hf-01</option>
<option>emoji-alligator-hf-01</option>
<option>emoji-ant-hf-01</option>
<option>emoji-apple-red-hf-01</option>
<option>emoji-baby-hf-01</option>
<option>emoji-balloons-hf-01</option>
<option>emoji-bandaid-hf-01</option>
<option>emoji-baseball-hf-01</option>
<option>emoji-basketball-hf-01</option>
<option>emoji-beach-hf-01</option>
<option>emoji-beer-hf-01</option>
<option>emoji-bike-hf-01</option>
<option>emoji-bird-blue-hf-01</option>
<option>emoji-book-hf-01</option>
<option>emoji-bowling-hf-01</option>
<option>emoji-boxing-hf-01</option>
<option>emoji-breakfast-hf-01</option>
<option>emoji-broken-heart-hf-01</option>
<option>emoji-bunny-hf-01</option>
<option>emoji-burger-hf-01</option>
<option>emoji-cake-hf-01</option>
<option>emoji-camera-hf-01</option>
<option>emoji-car-hf-01</option>
<option>emoji-cat-hf-01</option>
<option>emoji-checkmark-hf-01</option>
<option>emoji-christmas-tree-hf-01</option>
<option>emoji-computer-chip-hf-01</option>
<option>emoji-cow-hf-01</option>
<option>emoji-diamond-hf-01</option>
<option>emoji-dinner-hf-01</option>
<option>emoji-disco-ball-hf-01</option>
<option>emoji-dog-hf-01</option>
<option>emoji-dress-hf-01</option>
<option>emoji-earth-hf-01</option>
<option>emoji-easter-egg-hf-01</option>
<option>emoji-elephant-hf-01</option>
<option>emoji-exclamation-mark-red-hf-01</option>
<option>emoji-exclamation-mark-yellow-hf-01</option>
<option>emoji-fire-hf-01</option>
<option>emoji-fish-hf-01</option>
<option>emoji-fishing-hf-01</option>
<option>emoji-flamingo-hf-01</option>
<option>emoji-flower-pink-hf-01</option>
<option>emoji-football-hf-01</option>
<option>emoji-ghost-hf-01</option>
<option>emoji-golf-hf-01</option>
<option>emoji-guitar-hf-01</option>
<option>emoji-halo-hf-01</option>
<option>emoji-hat-hf-01</option>
<option>emoji-heart-hf-01</option>
<option>emoji-hockey-hf-01</option>
<option>emoji-hot-dog-hf-01</option>
<option>emoji-house-hf-01</option>
<option>emoji-ice-cream-hf-01</option>
<option>emoji-ice-cube-hf-01</option>
<option>emoji-jack-o-lantern-hf-01</option>
<option>emoji-laptop-hf-01</option>
<option>emoji-light-bulb-hf-01</option>
<option>emoji-lightning-hf-01</option>
<option>emoji-lock-hf-01</option>
<option>emoji-magic-hf-01</option>
<option>emoji-magnifying-glass-hf-01</option>
<option>emoji-microphone-hf-01</option>
<option>emoji-money-hf-01</option>
<option>emoji-monkey-hf-01</option>
<option>emoji-moon-hf-01</option>
<option>emoji-mountains-hf-01</option>
<option>emoji-mouse-hf-01</option>
<option>emoji-music-hf-01</option>
<option>emoji-new-years-hf-01</option>
<option>emoji-ocean-hf-01</option>
<option>emoji-outlet-hf-01</option>
<option>emoji-pants-hf-01</option>
<option>emoji-party-hf-01</option>
<option>emoji-penguin-hf-01</option>
<option>emoji-phone-hf-01</option>
<option>emoji-pig-hf-01</option>
<option>emoji-pizza-hf-01</option>
<option>emoji-popcorn-hf-01</option>
<option>emoji-pretzel-hf-01</option>
<option>emoji-pumpkin-hf-01</option>
<option>emoji-puzzle-piece-hf-01</option>
<option>emoji-question-mark-hf-01</option>
<option>emoji-rainbow-hf-01</option>
<option>emoji-robot-hf-01</option>
<option>emoji-rocket-hf-01</option>
<option>emoji-running-hf-01</option>
<option>emoji-santa-hf-01</option>
<option>emoji-school-bus-hf-01</option>
<option>emoji-sheep-hf-01</option>
<option>emoji-shopping-hf-01</option>
<option>emoji-snorkel-hf-01</option>
<option>emoji-snowflake-hf-01</option>
<option>emoji-soccer-hf-01</option>
<option>emoji-star-hf-01</option>
<option>emoji-stork-hf-01</option>
<option>emoji-sun-hf-01</option>
<option>emoji-sushi-hf-01</option>
<option>emoji-taco-hf-01</option>
<option>emoji-tennis-hf-01</option>
<option>emoji-thunder-hf-01</option>
<option>emoji-toaster-hf-01</option>
<option>emoji-toilet-paper-hf-01</option>
<option>emoji-tools-hf-01</option>
<option>emoji-trash-can-hf-01</option>
<option>emoji-tree-hf-01</option>
<option>emoji-truck-hf-01</option>
<option>emoji-turtle-hf-01</option>
<option>emoji-tv-hf-01</option>
<option>emoji-umbrella-hf-01</option>
<option>emoji-watermelon-hf-01</option>
<option>emoji-whale-hf-01</option>
<option>emoji-wine-hf-01</option>
</optgroup>
</select>
</div>
<button id="btn-play-anim" style="width:100%;margin-bottom:10px;">▶ Play Animation</button>
<div class="section-title" style="margin-top:4px;">Display Text</div>
<div class="field">
<textarea id="display-text" rows="2" placeholder="Text to show on screen…"></textarea>
</div>
<button id="btn-display-text" style="width:100%;margin-bottom:10px;">Show Text</button>
<div class="section-title">Display Image</div>
<div class="field">
<input type="text" id="display-img-src" placeholder="http://… image URL">
</div>
<button id="btn-display-image" style="width:100%;">Show Image</button>
</div>
<!-- Entities tab -->
<div class="tab-panel" id="tab-entities">
<div class="section-title">Detected Entities</div>
<div id="entity-list"><div style="color:var(--muted);font-size:12px;">Waiting for entity events…</div></div>
<div class="section-title" style="margin-top:12px;">Head Touch</div>
<div id="head-touch-display" style="display:grid;grid-template-columns:repeat(3,1fr);gap:4px;">
<div class="pad-indicator" data-pad="frontLeft">FL</div>
<div class="pad-indicator" data-pad="middleLeft">ML</div>
<div class="pad-indicator" data-pad="backLeft">BL</div>
<div class="pad-indicator" data-pad="frontRight">FR</div>
<div class="pad-indicator" data-pad="middleRight">MR</div>
<div class="pad-indicator" data-pad="backRight">BR</div>
</div>
<style>
.pad-indicator {
background: var(--surface2); border: 1px solid var(--border);
border-radius: 4px; padding: 6px; text-align: center;
font-size: 11px; color: var(--muted); transition: all 0.2s;
}
.pad-indicator.active { background: var(--accent); color: #000; border-color: var(--accent); }
</style>
</div>
<!-- Log tab -->
<div class="tab-panel" id="tab-log" style="padding:0;display:flex;flex-direction:column;flex:1;">
<div style="display:flex;justify-content:space-between;align-items:center;padding:8px 10px;border-bottom:1px solid var(--border);">
<span class="section-title" style="margin:0;">Event Log</span>
<button id="btn-clear-log" style="font-size:11px;padding:3px 8px;">Clear</button>
</div>
<div id="event-log"></div>
</div>
</div><!-- /right-panel -->
</div>
<!-- Photo modal -->
<div id="photo-modal">
<div id="photo-modal-close"></div>
<img id="photo-modal-img" src="" alt="Photo">
</div>
<script src="app.js"></script>
</body>
</html>

376
public/style.css Normal file
View File

@@ -0,0 +1,376 @@
:root {
--bg: #0e0e0e;
--surface: #1a1a1a;
--surface2: #242424;
--border: #333;
--accent: #00d4ff;
--accent2: #0099bb;
--text: #e8e8e8;
--muted: #888;
--danger: #ff4444;
--success: #44dd88;
--warn: #ffaa00;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
background: var(--bg);
color: var(--text);
font-family: 'Segoe UI', system-ui, sans-serif;
font-size: 14px;
height: 100vh;
overflow: hidden;
display: flex;
flex-direction: column;
}
header {
display: flex;
align-items: center;
gap: 12px;
padding: 8px 16px;
background: var(--surface);
border-bottom: 1px solid var(--border);
flex-shrink: 0;
}
header h1 { font-size: 16px; font-weight: 600; letter-spacing: 0.05em; }
header h1 span { color: var(--accent); }
#status-dot {
width: 10px; height: 10px;
border-radius: 50%;
background: var(--danger);
flex-shrink: 0;
}
#status-dot.ok { background: var(--success); }
#status-label { font-size: 12px; color: var(--muted); }
#angle-display { margin-left: auto; font-size: 12px; color: var(--muted); font-family: monospace; }
#hotword-indicator {
font-size: 12px; color: var(--muted); font-style: italic;
opacity: 0; transition: opacity 0.2s;
white-space: nowrap;
}
#hotword-indicator.active { opacity: 1; color: var(--accent); }
.main {
display: grid;
grid-template-columns: 340px 1fr 300px;
gap: 0;
flex: 1;
overflow: hidden;
}
/* ── Left panel ── */
.left-panel {
background: var(--surface);
border-right: 1px solid var(--border);
display: flex;
flex-direction: column;
overflow-y: auto;
}
/* ── Center panel ── */
.center-panel {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 12px;
gap: 10px;
background: var(--bg);
overflow: hidden;
}
/* ── Right panel ── */
.right-panel {
background: var(--surface);
border-left: 1px solid var(--border);
display: flex;
flex-direction: column;
overflow: hidden;
}
/* ── Sections ── */
.section {
border-bottom: 1px solid var(--border);
padding: 10px 12px;
}
.section-title {
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--muted);
margin-bottom: 8px;
}
/* ── Camera feed ── */
#camera-wrap {
position: relative;
width: 100%;
max-width: 640px;
aspect-ratio: 640 / 480;
background: #000;
border-radius: 6px;
overflow: hidden;
cursor: crosshair;
border: 1px solid var(--border);
flex-shrink: 0;
}
#camera-feed {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
transform: scaleX(-1);
}
#camera-overlay {
position: absolute;
inset: 0;
pointer-events: none;
}
#click-dot {
position: absolute;
width: 16px; height: 16px;
border-radius: 50%;
border: 2px solid var(--accent);
transform: translate(-50%, -50%);
display: none;
pointer-events: none;
box-shadow: 0 0 8px var(--accent);
}
#camera-no-feed {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
flex-direction: column;
gap: 8px;
color: var(--muted);
font-size: 13px;
}
#camera-no-feed canvas { display: none; }
/* ── Arrow pad ── */
.arrow-pad {
display: grid;
grid-template-columns: repeat(3, 40px);
grid-template-rows: repeat(3, 40px);
gap: 4px;
}
.arrow-pad button {
width: 40px; height: 40px;
background: var(--surface2);
border: 1px solid var(--border);
border-radius: 6px;
color: var(--text);
font-size: 16px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: background 0.1s;
user-select: none;
}
.arrow-pad button:hover { background: var(--accent2); border-color: var(--accent); }
.arrow-pad button:active { background: var(--accent); }
/* ── Controls row ── */
.controls-row {
display: flex;
flex-wrap: wrap;
gap: 6px;
align-items: center;
}
/* ── Buttons ── */
button, .btn {
background: var(--surface2);
border: 1px solid var(--border);
border-radius: 5px;
color: var(--text);
padding: 5px 10px;
cursor: pointer;
font-size: 13px;
transition: background 0.1s, border-color 0.1s;
white-space: nowrap;
}
button:hover, .btn:hover { background: var(--accent2); border-color: var(--accent); }
button.active { background: var(--accent); border-color: var(--accent); color: #000; font-weight: 600; }
button.danger { border-color: var(--danger); }
button.danger:hover { background: var(--danger); }
/* ── Inputs ── */
input[type="text"], input[type="number"], select, textarea {
background: var(--surface2);
border: 1px solid var(--border);
border-radius: 5px;
color: var(--text);
padding: 5px 8px;
font-size: 13px;
outline: none;
width: 100%;
}
input:focus, select:focus, textarea:focus { border-color: var(--accent); }
input[type="range"] {
width: 100%;
accent-color: var(--accent);
}
label { font-size: 12px; color: var(--muted); display: block; margin-bottom: 3px; }
.row { display: flex; gap: 6px; align-items: center; margin-bottom: 6px; }
.row label { margin: 0; flex-shrink: 0; }
.row input, .row select { flex: 1; }
.field { margin-bottom: 8px; }
/* ── Photo strip ── */
#photo-strip {
display: flex;
flex-wrap: wrap;
gap: 6px;
max-height: 200px;
overflow-y: auto;
}
#photo-strip img {
width: 80px; height: 60px;
object-fit: cover;
border-radius: 4px;
border: 1px solid var(--border);
cursor: pointer;
transition: border-color 0.1s;
}
#photo-strip img:hover { border-color: var(--accent); }
/* ── Event log ── */
#event-log {
flex: 1;
overflow-y: auto;
font-family: monospace;
font-size: 11px;
padding: 8px;
}
.log-entry {
padding: 2px 0;
border-bottom: 1px solid var(--border);
word-break: break-all;
}
.log-entry .ts { color: var(--muted); }
.log-entry .evt { color: var(--accent); }
.log-entry .evt-error { color: var(--danger); }
.log-entry .evt-motion { color: var(--warn); }
.log-entry .evt-entity { color: var(--success); }
/* ── Tabs ── */
.tabs {
display: flex;
border-bottom: 1px solid var(--border);
flex-shrink: 0;
}
.tab-btn {
flex: 1;
padding: 7px 4px;
background: none;
border: none;
border-bottom: 2px solid transparent;
color: var(--muted);
font-size: 12px;
cursor: pointer;
border-radius: 0;
}
.tab-btn:hover { color: var(--text); background: none; border-color: transparent; }
.tab-btn.active { color: var(--accent); border-bottom-color: var(--accent); background: none; font-weight: 600; }
.tab-panel { display: none; padding: 10px 12px; overflow-y: auto; flex: 1; }
.tab-panel.active { display: block; }
/* ── Attention grid ── */
.attention-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 4px;
}
/* ── Listen result ── */
#listen-result {
background: var(--surface2);
border: 1px solid var(--border);
border-radius: 5px;
padding: 6px 8px;
min-height: 40px;
font-style: italic;
color: var(--accent);
font-size: 13px;
}
/* ── Entity list ── */
#entity-list { font-size: 12px; }
.entity-item {
padding: 4px 6px;
background: var(--surface2);
border-radius: 4px;
margin-bottom: 4px;
cursor: pointer;
display: flex;
justify-content: space-between;
align-items: center;
border: 1px solid transparent;
}
.entity-item:hover { border-color: var(--accent); }
.entity-item .track-btn { font-size: 11px; padding: 2px 6px; }
/* ── Scrollbars ── */
::-webkit-scrollbar { width: 6px; height: 6px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: var(--border); border-radius: 3px; }
::-webkit-scrollbar-thumb:hover { background: var(--muted); }
/* ── Photo modal ── */
#photo-modal {
display: none;
position: fixed;
inset: 0;
background: rgba(0,0,0,0.85);
z-index: 100;
align-items: center;
justify-content: center;
}
#photo-modal.open { display: flex; }
#photo-modal img {
max-width: 90vw;
max-height: 90vh;
border-radius: 6px;
border: 1px solid var(--border);
}
#photo-modal-close {
position: absolute;
top: 16px; right: 16px;
font-size: 28px;
cursor: pointer;
color: var(--text);
background: var(--surface);
border: 1px solid var(--border);
border-radius: 50%;
width: 36px; height: 36px;
display: flex;
align-items: center;
justify-content: center;
line-height: 1;
}
/* ── Video area ── */
.video-controls { display: flex; gap: 6px; align-items: center; }
#video-status { font-size: 12px; color: var(--muted); }