add new face
This commit is contained in:
parent
cd8569318f
commit
a36981da66
|
|
@ -1,194 +0,0 @@
|
|||
const label = document.getElementById("label");
|
||||
const eyes = Array.from(document.querySelectorAll(".eye"));
|
||||
const mouthShape = document.querySelector(".mouth-shape");
|
||||
|
||||
let current = "neutral";
|
||||
let wanderEnabled = true;
|
||||
let wanderTimer = null;
|
||||
|
||||
let talkEnabled = false;
|
||||
let talkCfg = { rate_hz: 3.2, amount: 0.9, jitter: 0.25 };
|
||||
let talkTimer = null;
|
||||
|
||||
function clamp(v, lo, hi){ return Math.max(lo, Math.min(hi, v)); }
|
||||
|
||||
function setEmotion(name) {
|
||||
const safe = String(name || "neutral")
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9_-]/g, "");
|
||||
|
||||
current = safe;
|
||||
document.body.className = `emotion-${safe}`;
|
||||
label.textContent = safe;
|
||||
|
||||
// surprised -> O-mouth
|
||||
if (safe === "surprised") document.body.classList.add("has-omouth");
|
||||
else document.body.classList.remove("has-omouth");
|
||||
|
||||
// frown for sad/angry
|
||||
if (safe === "sad" || safe === "angry") mouthShape.classList.add("frown");
|
||||
else mouthShape.classList.remove("frown");
|
||||
}
|
||||
|
||||
function blinkOnce() {
|
||||
eyes.forEach(e => e.classList.add("blink"));
|
||||
setTimeout(() => eyes.forEach(e => e.classList.remove("blink")), 120);
|
||||
}
|
||||
|
||||
function setLook(x, y) {
|
||||
// x/y in -1..1 -> px offsets
|
||||
const pxX = Math.round(clamp(x, -1, 1) * 12);
|
||||
const pxY = Math.round(clamp(y, -1, 1) * 10);
|
||||
document.documentElement.style.setProperty("--pupil-x", `${pxX}px`);
|
||||
document.documentElement.style.setProperty("--pupil-y", `${pxY}px`);
|
||||
}
|
||||
|
||||
function setIntensity(v) {
|
||||
const val = clamp(Number(v ?? 0.7), 0, 1);
|
||||
document.documentElement.style.setProperty("--intensity", String(val));
|
||||
}
|
||||
|
||||
function setMouthOpen(v) {
|
||||
const val = clamp(Number(v ?? 0), 0, 1);
|
||||
document.documentElement.style.setProperty("--mouth-open", String(val));
|
||||
}
|
||||
|
||||
function speak(amount = 0.8, durationMs = 600) {
|
||||
if (talkEnabled) return; // Talk-Mode steuert Mund
|
||||
setMouthOpen(amount);
|
||||
setTimeout(() => setMouthOpen(0), Math.max(0, durationMs));
|
||||
}
|
||||
|
||||
function stopTalk() {
|
||||
talkEnabled = false;
|
||||
if (talkTimer) {
|
||||
clearTimeout(talkTimer);
|
||||
talkTimer = null;
|
||||
}
|
||||
setMouthOpen(0);
|
||||
}
|
||||
|
||||
function startTalk(cfg) {
|
||||
talkEnabled = true;
|
||||
talkCfg = {
|
||||
rate_hz: clamp(Number(cfg?.rate_hz ?? 3.2), 0.5, 10),
|
||||
amount: clamp(Number(cfg?.amount ?? 0.9), 0, 1),
|
||||
jitter: clamp(Number(cfg?.jitter ?? 0.25), 0, 1),
|
||||
};
|
||||
|
||||
if (talkTimer) clearTimeout(talkTimer);
|
||||
|
||||
// “sprech”-Animation: Mund öffnet/schließt schnell, mit bisschen Zufall
|
||||
const tick = () => {
|
||||
if (!talkEnabled) return;
|
||||
|
||||
const base = talkCfg.amount;
|
||||
const j = talkCfg.jitter;
|
||||
|
||||
// random-ish open amount between ~0.2..1.0, scaled
|
||||
const r = (0.35 + Math.random() * 0.65);
|
||||
const open = clamp(base * r * (1 - j + Math.random() * j), 0, 1);
|
||||
setMouthOpen(open);
|
||||
|
||||
// timing from rate_hz (Hz -> ms)
|
||||
const interval = Math.max(60, Math.round(1000 / talkCfg.rate_hz));
|
||||
// add a bit of jitter to cadence
|
||||
const next = interval + Math.round((Math.random() * 2 - 1) * interval * 0.25);
|
||||
|
||||
talkTimer = setTimeout(tick, next);
|
||||
};
|
||||
|
||||
tick();
|
||||
}
|
||||
|
||||
|
||||
/* Pupillen-Wandern nur wenn kein look gesetzt ist */
|
||||
function startWander() {
|
||||
if (wanderTimer) clearTimeout(wanderTimer);
|
||||
|
||||
const tick = () => {
|
||||
if (!wanderEnabled) return;
|
||||
|
||||
const x = (Math.random() * 2 - 1) * 0.7;
|
||||
const y = (Math.random() * 2 - 1) * 0.6;
|
||||
setLook(x, y);
|
||||
|
||||
const next = 600 + Math.random() * 900;
|
||||
wanderTimer = setTimeout(tick, next);
|
||||
};
|
||||
tick();
|
||||
}
|
||||
|
||||
function applyState(s) {
|
||||
if (!s || typeof s !== "object") return;
|
||||
|
||||
if (s.emotion) setEmotion(s.emotion);
|
||||
if (s.intensity !== undefined) setIntensity(s.intensity);
|
||||
|
||||
// one-shot blink
|
||||
if (s.blink) blinkOnce();
|
||||
|
||||
// look: if null -> enable wander. if object -> fixed look
|
||||
if ("look" in s) {
|
||||
if (s.look === null) {
|
||||
wanderEnabled = true;
|
||||
startWander();
|
||||
} else if (typeof s.look === "object") {
|
||||
wanderEnabled = false;
|
||||
setLook(s.look.x ?? 0, s.look.y ?? 0);
|
||||
}
|
||||
}
|
||||
|
||||
// mouth command
|
||||
if (s.mouth && typeof s.mouth === "object") {
|
||||
const open = !!s.mouth.open;
|
||||
const amount = clamp(Number(s.mouth.amount ?? 0.8), 0, 1);
|
||||
const dur = Number(s.mouth.duration_ms ?? 600);
|
||||
|
||||
if (open) speak(amount, dur);
|
||||
else setMouthOpen(0);
|
||||
}
|
||||
|
||||
// talk mode
|
||||
if (s.talk && typeof s.talk === "object") {
|
||||
const enabled = !!s.talk.enabled;
|
||||
if (enabled) startTalk(s.talk);
|
||||
else stopTalk();
|
||||
}
|
||||
}
|
||||
|
||||
function connect() {
|
||||
const es = new EventSource("/events");
|
||||
es.addEventListener("state", (e) => {
|
||||
try { applyState(JSON.parse(e.data)); } catch {}
|
||||
});
|
||||
es.onmessage = (e) => {
|
||||
// fallback: treat as state json
|
||||
try { applyState(JSON.parse(e.data)); } catch {}
|
||||
};
|
||||
es.onerror = () => {
|
||||
es.close();
|
||||
setTimeout(connect, 1000);
|
||||
};
|
||||
}
|
||||
|
||||
connect();
|
||||
startWander();
|
||||
|
||||
/* zufälliges Blinzeln unabhängig vom Push */
|
||||
(function startBlinkLoop(){
|
||||
const loop = () => {
|
||||
let base = 3500;
|
||||
if (current === "sleepy") base = 2200;
|
||||
if (current === "surprised") base = 4200;
|
||||
|
||||
const next = base + Math.random() * 2200;
|
||||
setTimeout(() => {
|
||||
blinkOnce();
|
||||
if (Math.random() < 0.12) setTimeout(blinkOnce, 220);
|
||||
loop();
|
||||
}, next);
|
||||
};
|
||||
loop();
|
||||
})();
|
||||
|
||||
|
|
@ -0,0 +1,312 @@
|
|||
// Helva Display Face (ONLY visor/display) - state driven
|
||||
// Supports payload like:
|
||||
// {"emotion":"angry","intensity":0.9,"look":{"x":0.9,"y":0.0}}
|
||||
|
||||
(() => {
|
||||
const OVERLAY_TIMEOUT_MS = 3500;
|
||||
|
||||
const state = {
|
||||
emotion: "neutral",
|
||||
intensity: 0.7, // 0..1
|
||||
look: { x: 0, y: 0 }, // -1..1
|
||||
lookLock: false, // if true: no drift (stare)
|
||||
speaking: false,
|
||||
eyesMoving: true,
|
||||
connected: true,
|
||||
};
|
||||
|
||||
// DOM
|
||||
const stage = document.getElementById("stage");
|
||||
const overlay = document.getElementById("overlay");
|
||||
|
||||
const dotConn = document.getElementById("dotConn");
|
||||
const pillText = document.getElementById("pillText");
|
||||
|
||||
const emotionLabel = document.getElementById("emotionLabel");
|
||||
const emotionControlLink = document.getElementById("emotionControlLink");
|
||||
|
||||
const btnSpeak = document.getElementById("btnSpeak");
|
||||
const btnEyes = document.getElementById("btnEyes");
|
||||
const speakState = document.getElementById("speakState");
|
||||
const eyesState = document.getElementById("eyesState");
|
||||
|
||||
const mouthShape = document.getElementById("mouthShape");
|
||||
const mouthInner = document.getElementById("mouthInner");
|
||||
const statusLed = document.getElementById("statusLed");
|
||||
const cheekL = document.getElementById("cheekL");
|
||||
const cheekR = document.getElementById("cheekR");
|
||||
|
||||
const eyeLIris = document.querySelector("#eyeL .iris");
|
||||
const eyeRIris = document.querySelector("#eyeR .iris");
|
||||
const pupilL = document.querySelector("#eyeL .pupil");
|
||||
const pupilR = document.querySelector("#eyeR .pupil");
|
||||
const lidL = document.getElementById("lidL");
|
||||
const lidR = document.getElementById("lidR");
|
||||
const mouthGroup = document.getElementById("mouth");
|
||||
|
||||
// Overlay on touch
|
||||
let overlayTimer = null;
|
||||
function showOverlay() {
|
||||
overlay.classList.add("show");
|
||||
clearTimeout(overlayTimer);
|
||||
overlayTimer = setTimeout(() => overlay.classList.remove("show"), OVERLAY_TIMEOUT_MS);
|
||||
}
|
||||
stage.addEventListener("pointerdown", () => showOverlay(), { passive: true });
|
||||
btnSpeak.addEventListener("click", (e) => { e.stopPropagation(); window.setSpeaking(!state.speaking); showOverlay(); });
|
||||
btnEyes.addEventListener("click", (e) => { e.stopPropagation(); window.setEyesMoving(!state.eyesMoving); showOverlay(); });
|
||||
|
||||
// Helpers
|
||||
function clamp(n, a, b) { return Math.max(a, Math.min(b, n)); }
|
||||
function clamp01(v) {
|
||||
const n = Number(v);
|
||||
if (Number.isNaN(n)) return 0;
|
||||
return clamp(n, 0, 1);
|
||||
}
|
||||
|
||||
// Emotions
|
||||
const EMOTIONS = new Set(["neutral","happy","sad","angry","sleepy","surprised","excited"]);
|
||||
|
||||
function updateControlLink() {
|
||||
emotionLabel.textContent = state.emotion;
|
||||
emotionControlLink.href = `/control?current=${encodeURIComponent(state.emotion)}`;
|
||||
}
|
||||
|
||||
function setEyeOffset(dx, dy) {
|
||||
const max = 10;
|
||||
const x = clamp(dx, -max, max);
|
||||
const y = clamp(dy, -max, max);
|
||||
|
||||
// base positions: L(200,170), R(320,170)
|
||||
eyeLIris.setAttribute("cx", 200 + x);
|
||||
eyeLIris.setAttribute("cy", 170 + y);
|
||||
eyeRIris.setAttribute("cx", 320 + x);
|
||||
eyeRIris.setAttribute("cy", 170 + y);
|
||||
|
||||
pupilL.setAttribute("cx", 200 + x);
|
||||
pupilL.setAttribute("cy", 170 + y);
|
||||
pupilR.setAttribute("cx", 320 + x);
|
||||
pupilR.setAttribute("cy", 170 + y);
|
||||
}
|
||||
|
||||
function applyLook(look, lock = true) {
|
||||
if (!look) return;
|
||||
const x = clamp(Number(look.x ?? 0), -1, 1);
|
||||
const y = clamp(Number(look.y ?? 0), -1, 1);
|
||||
state.look = { x, y };
|
||||
state.lookLock = !!lock;
|
||||
setEyeOffset(x * 10, y * 10);
|
||||
}
|
||||
|
||||
function applyIntensity(intensity) {
|
||||
state.intensity = clamp01(intensity);
|
||||
// LED brightness
|
||||
statusLed.setAttribute("opacity", String(0.35 + state.intensity * 0.65));
|
||||
// cheeks baseline
|
||||
cheekL.style.opacity = String(0.12 + 0.55 * state.intensity);
|
||||
cheekR.style.opacity = String(0.12 + 0.55 * state.intensity);
|
||||
}
|
||||
|
||||
function setEmotionUI(em) {
|
||||
const emotion = EMOTIONS.has(em) ? em : "neutral";
|
||||
state.emotion = emotion;
|
||||
updateControlLink();
|
||||
|
||||
const mouths = {
|
||||
neutral: {
|
||||
outer: "M225,250 C245,262 275,262 295,250 C290,275 230,275 225,250Z",
|
||||
inner: "M238,258 C250,267 270,267 282,258 C275,272 245,272 238,258Z"
|
||||
},
|
||||
happy: {
|
||||
outer: "M215,242 C240,292 280,292 305,242 C295,310 225,310 215,242Z",
|
||||
inner: "M232,265 C250,288 270,288 288,265 C280,298 240,298 232,265Z"
|
||||
},
|
||||
sad: {
|
||||
outer: "M225,282 C245,260 275,260 295,282 C285,245 235,245 225,282Z",
|
||||
inner: "M240,276 C250,265 270,265 280,276 C272,258 248,258 240,276Z"
|
||||
},
|
||||
angry: {
|
||||
outer: "M220,270 C255,255 265,255 300,270 C292,292 228,292 220,270Z",
|
||||
inner: "M240,272 C255,264 265,264 280,272 C274,283 246,283 240,272Z"
|
||||
},
|
||||
sleepy: {
|
||||
outer: "M230,280 C250,286 270,286 290,280 C283,292 237,292 230,280Z",
|
||||
inner: "M244,282 C252,286 268,286 276,282 C270,290 250,290 244,282Z"
|
||||
},
|
||||
surprised: {
|
||||
outer: "M240,238 C260,228 260,228 280,238 C305,265 305,298 260,305 C215,298 215,265 240,238Z",
|
||||
inner: "M248,254 C260,246 260,246 272,254 C288,270 288,286 260,291 C232,286 232,270 248,254Z"
|
||||
},
|
||||
excited: {
|
||||
outer: "M210,242 C240,322 280,322 310,242 C300,336 220,336 210,242Z",
|
||||
inner: "M228,274 C250,308 270,308 292,274 C284,322 236,322 228,274Z"
|
||||
}
|
||||
};
|
||||
|
||||
const m = mouths[emotion] ?? mouths.neutral;
|
||||
mouthShape.setAttribute("d", m.outer);
|
||||
mouthInner.setAttribute("d", m.inner);
|
||||
|
||||
// LED color hint per emotion
|
||||
switch (emotion) {
|
||||
case "sad":
|
||||
case "sleepy":
|
||||
statusLed.setAttribute("fill", "rgba(124,183,255,.90)");
|
||||
break;
|
||||
case "angry":
|
||||
statusLed.setAttribute("fill", "rgba(255,107,107,.92)");
|
||||
break;
|
||||
default:
|
||||
statusLed.setAttribute("fill", "rgba(124,255,201,.92)");
|
||||
}
|
||||
|
||||
pillText.textContent = state.speaking ? (state.emotion + " • speaking") : state.emotion;
|
||||
}
|
||||
|
||||
function setConnectedUI(on) {
|
||||
state.connected = !!on;
|
||||
dotConn.classList.toggle("on", state.connected);
|
||||
dotConn.classList.toggle("off", !state.connected);
|
||||
pillText.textContent = state.connected ? (state.speaking ? (state.emotion + " • speaking") : state.emotion) : "offline";
|
||||
}
|
||||
|
||||
function setSpeakingUI(on) {
|
||||
state.speaking = !!on;
|
||||
speakState.textContent = state.speaking ? "an" : "aus";
|
||||
pillText.textContent = state.speaking ? (state.emotion + " • speaking") : state.emotion;
|
||||
}
|
||||
|
||||
function setEyesMovingUI(on) {
|
||||
state.eyesMoving = !!on;
|
||||
eyesState.textContent = state.eyesMoving ? "an" : "aus";
|
||||
}
|
||||
|
||||
// Animations: blink + drift + speaking flap
|
||||
let blinkTimer = null;
|
||||
let driftTimer = null;
|
||||
let speakTimer = null;
|
||||
|
||||
function doBlink() {
|
||||
const down = 70, up = 90;
|
||||
lidL.animate([{ transform: "scaleY(0)" }, { transform: "scaleY(1)" }], { duration: down, fill: "forwards" });
|
||||
lidR.animate([{ transform: "scaleY(0)" }, { transform: "scaleY(1)" }], { duration: down, fill: "forwards" });
|
||||
|
||||
setTimeout(() => {
|
||||
lidL.animate([{ transform: "scaleY(1)" }, { transform: "scaleY(0)" }], { duration: up, fill: "forwards" });
|
||||
lidR.animate([{ transform: "scaleY(1)" }, { transform: "scaleY(0)" }], { duration: up, fill: "forwards" });
|
||||
}, down + 25);
|
||||
}
|
||||
|
||||
function scheduleBlink() {
|
||||
clearTimeout(blinkTimer);
|
||||
const base = state.emotion === "sleepy" ? 4500 : 2600;
|
||||
blinkTimer = setTimeout(() => { doBlink(); scheduleBlink(); }, base + Math.random() * 2200);
|
||||
}
|
||||
|
||||
function scheduleEyeDrift() {
|
||||
clearTimeout(driftTimer);
|
||||
|
||||
if (state.lookLock) {
|
||||
setEyeOffset(state.look.x * 10, state.look.y * 10);
|
||||
return;
|
||||
}
|
||||
if (!state.eyesMoving) {
|
||||
setEyeOffset(0,0);
|
||||
return;
|
||||
}
|
||||
|
||||
const dx = (Math.random() * 2 - 1) * (state.emotion === "surprised" ? 6 : 4);
|
||||
const dy = (Math.random() * 2 - 1) * (state.emotion === "surprised" ? 6 : 4);
|
||||
|
||||
const startX = (parseFloat(eyeLIris.getAttribute("cx")) - 200) || 0;
|
||||
const startY = (parseFloat(eyeLIris.getAttribute("cy")) - 170) || 0;
|
||||
|
||||
const steps = 18;
|
||||
let i = 0;
|
||||
|
||||
const tick = () => {
|
||||
i++;
|
||||
const t = i / steps;
|
||||
const ease = t < 0.5 ? 2*t*t : 1 - Math.pow(-2*t+2,2)/2;
|
||||
setEyeOffset(startX + (dx - startX)*ease, startY + (dy - startY)*ease);
|
||||
if (i < steps) requestAnimationFrame(tick);
|
||||
};
|
||||
requestAnimationFrame(tick);
|
||||
|
||||
driftTimer = setTimeout(scheduleEyeDrift, 700 + Math.random() * 700);
|
||||
}
|
||||
|
||||
function startSpeakingFlap() {
|
||||
stopSpeakingFlap();
|
||||
let open = false;
|
||||
const interval = (state.emotion === "excited") ? 90 : (state.emotion === "sleepy" ? 170 : 120);
|
||||
|
||||
speakTimer = setInterval(() => {
|
||||
if (!state.speaking) return;
|
||||
open = !open;
|
||||
const s = open ? 1.12 : 0.96;
|
||||
mouthGroup.setAttribute("transform", `translate(0, ${open ? -2 : 0}) scale(1, ${s})`);
|
||||
}, interval);
|
||||
}
|
||||
|
||||
function stopSpeakingFlap() {
|
||||
if (speakTimer) clearInterval(speakTimer);
|
||||
speakTimer = null;
|
||||
mouthGroup.setAttribute("transform", "translate(0,0)");
|
||||
}
|
||||
|
||||
// Public API
|
||||
window.setEmotion = (emotion) => { setEmotionUI(emotion); scheduleBlink(); scheduleEyeDrift(); };
|
||||
window.setSpeaking = (on) => { setSpeakingUI(on); if (state.speaking) startSpeakingFlap(); else stopSpeakingFlap(); };
|
||||
window.setEyesMoving = (on) => { setEyesMovingUI(on); scheduleEyeDrift(); };
|
||||
window.setConnected = (on) => { setConnectedUI(on); };
|
||||
|
||||
window.applyFaceState = (payload) => {
|
||||
if (!payload || typeof payload !== "object") return;
|
||||
|
||||
if (payload.intensity !== undefined) applyIntensity(payload.intensity);
|
||||
if (typeof payload.emotion === "string") setEmotionUI(payload.emotion);
|
||||
|
||||
if (payload.look && typeof payload.look === "object") {
|
||||
applyLook(payload.look, true); // lock stare
|
||||
} else {
|
||||
state.lookLock = false;
|
||||
scheduleEyeDrift();
|
||||
}
|
||||
|
||||
if (typeof payload.speaking === "boolean") window.setSpeaking(payload.speaking);
|
||||
if (typeof payload.eyesMoving === "boolean") window.setEyesMoving(payload.eyesMoving);
|
||||
if (typeof payload.connected === "boolean") window.setConnected(payload.connected);
|
||||
|
||||
scheduleBlink();
|
||||
if (state.speaking) startSpeakingFlap();
|
||||
};
|
||||
|
||||
// Optional WebSocket to /ws (if present)
|
||||
(function connectWS(){
|
||||
const proto = (location.protocol === "https:") ? "wss" : "ws";
|
||||
const wsUrl = `${proto}://${location.host}/ws`;
|
||||
|
||||
let ws;
|
||||
function open() {
|
||||
ws = new WebSocket(wsUrl);
|
||||
ws.onopen = () => window.setConnected(true);
|
||||
ws.onmessage = (ev) => {
|
||||
try { window.applyFaceState(JSON.parse(ev.data)); } catch (_) {}
|
||||
};
|
||||
ws.onclose = () => { window.setConnected(false); setTimeout(open, 800); };
|
||||
ws.onerror = () => { try { ws.close(); } catch (_) {} };
|
||||
}
|
||||
open();
|
||||
})();
|
||||
|
||||
// Init
|
||||
updateControlLink();
|
||||
applyIntensity(state.intensity);
|
||||
setConnectedUI(true);
|
||||
setEmotionUI("neutral");
|
||||
setSpeakingUI(false);
|
||||
setEyesMovingUI(true);
|
||||
scheduleBlink();
|
||||
scheduleEyeDrift();
|
||||
})();
|
||||
|
||||
|
|
@ -2,27 +2,271 @@
|
|||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
||||
<title>Robot Face</title>
|
||||
<link rel="stylesheet" href="/style.css?v=2" />
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover" />
|
||||
<title>Face</title>
|
||||
<style>
|
||||
:root {
|
||||
--bg: #000;
|
||||
--fg: #fff;
|
||||
}
|
||||
|
||||
html, body {
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
background: var(--bg);
|
||||
overflow: hidden;
|
||||
touch-action: manipulation;
|
||||
user-select: none;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
font-family: system-ui, -apple-system, Segoe UI, Roboto, Arial, sans-serif;
|
||||
}
|
||||
|
||||
/* Fullscreen stage */
|
||||
#stage {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: var(--bg);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
}
|
||||
|
||||
/* Face canvas-ish container */
|
||||
#face {
|
||||
width: min(92vw, 92vh);
|
||||
height: min(92vw, 92vh);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* Controls: hidden until touch/move */
|
||||
#overlay {
|
||||
position: fixed;
|
||||
left: 12px;
|
||||
top: 12px;
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition: opacity 180ms ease;
|
||||
z-index: 10;
|
||||
}
|
||||
#overlay.visible {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.btn {
|
||||
color: var(--fg);
|
||||
text-decoration: none;
|
||||
font-size: 14px;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid rgba(255,255,255,0.25);
|
||||
border-radius: 12px;
|
||||
background: rgba(0,0,0,0.35);
|
||||
backdrop-filter: blur(6px);
|
||||
-webkit-backdrop-filter: blur(6px);
|
||||
line-height: 1;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.btn:active { transform: translateY(1px); }
|
||||
|
||||
/* --- Simple face elements (SVG) --- */
|
||||
svg { width: 100%; height: 100%; display: block; }
|
||||
|
||||
/* Keep strokes crisp */
|
||||
.stroke {
|
||||
fill: none;
|
||||
stroke: #fff;
|
||||
stroke-linecap: round;
|
||||
stroke-linejoin: round;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body class="emotion-neutral">
|
||||
<div id="face" aria-label="Robot face">
|
||||
<div class="eyes">
|
||||
<div class="eye"></div>
|
||||
<div class="eye"></div>
|
||||
<body>
|
||||
<div id="stage">
|
||||
<div id="overlay" aria-hidden="true">
|
||||
<a class="btn" id="btnControl" href="/control">neutral</a>
|
||||
<a class="btn" id="btnDrive" href="/drive">/drive</a>
|
||||
</div>
|
||||
|
||||
<div class="mouth">
|
||||
<div class="mouth-shape"></div>
|
||||
<div id="face" aria-label="Robot Face">
|
||||
<!-- Minimal clean face in SVG (eyes + mouth); driven by state below -->
|
||||
<svg viewBox="0 0 100 100" role="img" aria-hidden="true">
|
||||
<!-- Eyes -->
|
||||
<g id="eyes" class="stroke" stroke-width="6">
|
||||
<!-- left eye -->
|
||||
<path id="eyeL" d="M22 40 C 30 30, 40 30, 48 40" />
|
||||
<!-- right eye -->
|
||||
<path id="eyeR" d="M52 40 C 60 30, 70 30, 78 40" />
|
||||
</g>
|
||||
|
||||
<!-- Pupils (small dots) -->
|
||||
<g id="pupils" fill="#fff">
|
||||
<circle id="pupilL" cx="35" cy="40" r="2.2" />
|
||||
<circle id="pupilR" cx="65" cy="40" r="2.2" />
|
||||
</g>
|
||||
|
||||
<!-- Mouth -->
|
||||
<g class="stroke" stroke-width="6">
|
||||
<path id="mouth" d="M30 68 C 40 76, 60 76, 70 68" />
|
||||
</g>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<div class="label" id="label">neutral</div>
|
||||
|
||||
</div>
|
||||
|
||||
<a href="/drive" class="control-link">🎮 Steuerung öffnen</a>
|
||||
<script src="/app.js?v=2"></script>
|
||||
<script>
|
||||
// --- State (matches your face-server structure) ---
|
||||
const state = {
|
||||
emotion: "neutral",
|
||||
intensity: 0.7, // 0..1
|
||||
look: null, // {"x": -1..1, "y": -1..1} or null
|
||||
mouth: { open: false, amount: 0.0, duration_ms: 0 },
|
||||
talk: { enabled: false, rate_hz: 3.2, amount: 0.9, jitter: 0.25 },
|
||||
};
|
||||
|
||||
// --- UI refs ---
|
||||
const overlay = document.getElementById("overlay");
|
||||
const btnControl = document.getElementById("btnControl");
|
||||
|
||||
const pupilL = document.getElementById("pupilL");
|
||||
const pupilR = document.getElementById("pupilR");
|
||||
const mouth = document.getElementById("mouth");
|
||||
const eyeL = document.getElementById("eyeL");
|
||||
const eyeR = document.getElementById("eyeR");
|
||||
|
||||
// --- Overlay show-on-touch/move ---
|
||||
let overlayTimer = null;
|
||||
function showOverlayBriefly() {
|
||||
overlay.classList.add("visible");
|
||||
overlay.setAttribute("aria-hidden", "false");
|
||||
if (overlayTimer) clearTimeout(overlayTimer);
|
||||
overlayTimer = setTimeout(() => {
|
||||
overlay.classList.remove("visible");
|
||||
overlay.setAttribute("aria-hidden", "true");
|
||||
}, 2500);
|
||||
}
|
||||
window.addEventListener("touchstart", showOverlayBriefly, { passive: true });
|
||||
window.addEventListener("mousemove", showOverlayBriefly, { passive: true });
|
||||
window.addEventListener("keydown", showOverlayBriefly);
|
||||
|
||||
// --- Face rendering helpers ---
|
||||
function clamp(v, a, b) { return Math.max(a, Math.min(b, v)); }
|
||||
|
||||
function applyLook(look) {
|
||||
// Pupils range in SVG coords
|
||||
const maxX = 3.2, maxY = 2.2;
|
||||
const x = look ? clamp(look.x, -1, 1) * maxX : 0;
|
||||
const y = look ? clamp(look.y, -1, 1) * maxY : 0;
|
||||
|
||||
pupilL.setAttribute("cx", (35 + x).toFixed(2));
|
||||
pupilL.setAttribute("cy", (40 + y).toFixed(2));
|
||||
pupilR.setAttribute("cx", (65 + x).toFixed(2));
|
||||
pupilR.setAttribute("cy", (40 + y).toFixed(2));
|
||||
}
|
||||
|
||||
function applyMouth(emotion, mouthState) {
|
||||
// mouthState.amount: 0..1, open toggles
|
||||
const amt = clamp(mouthState?.amount ?? 0, 0, 1);
|
||||
const open = !!mouthState?.open;
|
||||
|
||||
// Base mouth by emotion (simple, clean)
|
||||
// neutral: slight smile, happy: bigger smile, sad: frown, angry: flat, surprised: open O
|
||||
if (emotion === "sad") {
|
||||
mouth.setAttribute("d", "M30 72 C 42 62, 58 62, 70 72");
|
||||
} else if (emotion === "angry") {
|
||||
mouth.setAttribute("d", "M32 70 C 44 70, 56 70, 68 70");
|
||||
} else if (emotion === "happy") {
|
||||
mouth.setAttribute("d", "M28 66 C 40 80, 60 80, 72 66");
|
||||
} else if (emotion === "surprised") {
|
||||
// fake an "O" using a short curve; (keeps it minimal)
|
||||
const r = 6 + amt * 6;
|
||||
mouth.setAttribute("d", `M50 ${70-r} C ${50+r} ${70-r}, ${50+r} ${70+r}, 50 ${70+r} C ${50-r} ${70+r}, ${50-r} ${70-r}, 50 ${70-r}`);
|
||||
} else {
|
||||
// neutral
|
||||
mouth.setAttribute("d", "M30 68 C 40 76, 60 76, 70 68");
|
||||
}
|
||||
|
||||
// If mouth.open => exaggerate a bit (except surprised which is already open-ish)
|
||||
if (open && emotion !== "surprised") {
|
||||
// slightly "deeper" smile/frown/line
|
||||
// We just scale stroke width a touch and nudge curve visually by intensity
|
||||
// (simple, avoids layout thrash)
|
||||
const sw = 6 + amt * 2.5;
|
||||
mouth.parentElement.setAttribute("stroke-width", sw.toFixed(2));
|
||||
} else {
|
||||
mouth.parentElement.setAttribute("stroke-width", "6");
|
||||
}
|
||||
}
|
||||
|
||||
function applyEyes(emotion) {
|
||||
// optional: angry eyebrows effect via eye curve shape
|
||||
if (emotion === "angry") {
|
||||
eyeL.setAttribute("d", "M22 42 C 32 30, 42 34, 48 42");
|
||||
eyeR.setAttribute("d", "M52 42 C 58 34, 68 30, 78 42");
|
||||
} else if (emotion === "sad") {
|
||||
eyeL.setAttribute("d", "M22 40 C 30 34, 40 30, 48 40");
|
||||
eyeR.setAttribute("d", "M52 40 C 60 30, 70 34, 78 40");
|
||||
} else {
|
||||
// default
|
||||
eyeL.setAttribute("d", "M22 40 C 30 30, 40 30, 48 40");
|
||||
eyeR.setAttribute("d", "M52 40 C 60 30, 70 30, 78 40");
|
||||
}
|
||||
}
|
||||
|
||||
function render() {
|
||||
btnControl.textContent = state.emotion; // /control link text = aktuelle Emotion
|
||||
applyLook(state.look);
|
||||
applyEyes(state.emotion);
|
||||
applyMouth(state.emotion, state.mouth);
|
||||
}
|
||||
|
||||
// --- WebSocket hookup (adjust WS_URL to your setup) ---
|
||||
// Typical options:
|
||||
// 1) Direct: ws://<pi-ip>:8001/ws
|
||||
// 2) Via nginx: wss://<host>/face-ws
|
||||
const WS_URL = (() => {
|
||||
// Default: same host, path /ws
|
||||
const proto = (location.protocol === "https:") ? "wss:" : "ws:";
|
||||
return `${proto}//${location.host}/ws`;
|
||||
})();
|
||||
|
||||
let ws = null;
|
||||
let wsBackoffMs = 300;
|
||||
|
||||
function connectWs() {
|
||||
try { ws?.close(); } catch {}
|
||||
ws = new WebSocket(WS_URL);
|
||||
|
||||
ws.onopen = () => { wsBackoffMs = 300; };
|
||||
ws.onmessage = (ev) => {
|
||||
try {
|
||||
const msg = JSON.parse(ev.data);
|
||||
|
||||
// accept either:
|
||||
// - {type:"state", state:{...}}
|
||||
// - {state:{...}}
|
||||
// - direct state object
|
||||
const incoming = msg?.state ?? (msg?.type === "state" ? msg.state : msg);
|
||||
|
||||
if (incoming && typeof incoming === "object") {
|
||||
Object.assign(state, incoming);
|
||||
render();
|
||||
}
|
||||
} catch (e) {
|
||||
// ignore malformed messages
|
||||
}
|
||||
};
|
||||
ws.onclose = () => {
|
||||
setTimeout(connectWs, wsBackoffMs);
|
||||
wsBackoffMs = Math.min(wsBackoffMs * 1.7, 8000);
|
||||
};
|
||||
ws.onerror = () => {
|
||||
try { ws.close(); } catch {}
|
||||
};
|
||||
}
|
||||
|
||||
render();
|
||||
connectWs();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
|
|
|
|||
|
|
@ -1,300 +1,138 @@
|
|||
:root {
|
||||
--bg: #0b0f14;
|
||||
--panel: #121925;
|
||||
--fg: #d7e3f2;
|
||||
|
||||
/* Glow / Stimmung */
|
||||
--glow: rgba(0, 255, 180, 0.22);
|
||||
|
||||
/* Pupillen-Offset (wird via JS verändert) */
|
||||
--pupil-x: 0px;
|
||||
--pupil-y: 0px;
|
||||
|
||||
/* Mund-Parameter (Default = neutral) */
|
||||
--mouth-w: 38vw;
|
||||
--mouth-h: 10vh;
|
||||
--mouth-radius: 999px;
|
||||
--mouth-line-y: 50%;
|
||||
--mouth-line-h: 10px;
|
||||
--mouth-line-opacity: 0.85;
|
||||
|
||||
/* „Smile“-Bogen (0 = aus) */
|
||||
--smile: 0;
|
||||
/* „Frown“-Bogen (0 = aus) */
|
||||
--frown: 0;
|
||||
|
||||
/* „O“-Mund (0 = aus, sonst Größe) */
|
||||
--omouth: 0;
|
||||
}
|
||||
|
||||
html, body {
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
background: var(--bg);
|
||||
overflow: hidden;
|
||||
font-family: system-ui, -apple-system, Segoe UI, Roboto, sans-serif;
|
||||
}
|
||||
|
||||
#face {
|
||||
height: 100%;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
gap: 3.5vh;
|
||||
}
|
||||
|
||||
.eyes {
|
||||
display: flex;
|
||||
gap: 8vw;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.eye {
|
||||
width: 14vw;
|
||||
height: 14vw;
|
||||
max-width: 220px;
|
||||
max-height: 220px;
|
||||
border-radius: 999px;
|
||||
background: var(--panel);
|
||||
box-shadow: 0 0 40px var(--glow);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
transition: border-radius 220ms ease, transform 220ms ease, height 220ms ease;
|
||||
}
|
||||
|
||||
/* Pupille */
|
||||
.eye::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 28%;
|
||||
border-radius: 999px;
|
||||
background: var(--fg);
|
||||
opacity: 0.9;
|
||||
transform: translate(var(--pupil-x), var(--pupil-y));
|
||||
transition: transform 180ms ease;
|
||||
}
|
||||
|
||||
/* Blinzeln: wir "quetschen" das Auge kurz */
|
||||
.eye.blink {
|
||||
transform: scaleY(0.12);
|
||||
}
|
||||
|
||||
/* Mund-Container */
|
||||
.mouth {
|
||||
width: var(--mouth-w);
|
||||
height: var(--mouth-h);
|
||||
max-width: 600px;
|
||||
max-height: 120px;
|
||||
border-radius: var(--mouth-radius);
|
||||
background: var(--panel);
|
||||
box-shadow: 0 0 40px var(--glow);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
transition: width 220ms ease, height 220ms ease, border-radius 220ms ease;
|
||||
}
|
||||
|
||||
/* Mund-Shape (Linie + Bögen + O-Mund) */
|
||||
.mouth-shape {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
}
|
||||
|
||||
/* Mund-Linie */
|
||||
.mouth-shape::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 12%;
|
||||
right: 12%;
|
||||
top: var(--mouth-line-y);
|
||||
height: var(--mouth-line-h);
|
||||
transform: translateY(-50%);
|
||||
background: var(--fg);
|
||||
border-radius: 999px;
|
||||
opacity: var(--mouth-line-opacity);
|
||||
transition: all 220ms ease;
|
||||
}
|
||||
|
||||
/* Smile-Bogen */
|
||||
.mouth-shape::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 16%;
|
||||
right: 16%;
|
||||
top: 38%;
|
||||
height: 55%;
|
||||
border: calc(6px + 6px * var(--smile)) solid rgba(215,227,242,0.85);
|
||||
border-top: none;
|
||||
border-left-color: transparent;
|
||||
border-right-color: transparent;
|
||||
border-bottom-left-radius: 999px;
|
||||
border-bottom-right-radius: 999px;
|
||||
opacity: calc(0.10 + 0.60 * var(--smile));
|
||||
transition: opacity 220ms ease, border-width 220ms ease;
|
||||
}
|
||||
|
||||
/* Frown-Bogen als extra Element über box-shadow Trick */
|
||||
.mouth-shape {
|
||||
filter: drop-shadow(0 0 0 rgba(0,0,0,0));
|
||||
}
|
||||
.mouth-shape.frown::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 16%;
|
||||
right: 16%;
|
||||
bottom: 38%;
|
||||
height: 55%;
|
||||
border: calc(6px + 6px * var(--frown)) solid rgba(215,227,242,0.85);
|
||||
border-bottom: none;
|
||||
border-left-color: transparent;
|
||||
border-right-color: transparent;
|
||||
border-top-left-radius: 999px;
|
||||
border-top-right-radius: 999px;
|
||||
opacity: calc(0.10 + 0.60 * var(--frown));
|
||||
}
|
||||
|
||||
/* O-Mund: wir machen aus dem Mund-Container einen Kreis und verstecken Linie */
|
||||
body.has-omouth .mouth {
|
||||
width: calc(18vw + 8vw * var(--omouth));
|
||||
height: calc(18vw + 8vw * var(--omouth));
|
||||
max-width: 260px;
|
||||
max-height: 260px;
|
||||
border-radius: 999px;
|
||||
}
|
||||
body.has-omouth .mouth-shape::after {
|
||||
left: 28%;
|
||||
right: 28%;
|
||||
top: 50%;
|
||||
height: 42%;
|
||||
border-radius: 999px;
|
||||
opacity: 0.9;
|
||||
}
|
||||
body.has-omouth .mouth-shape::before {
|
||||
opacity: 0; /* Smile aus */
|
||||
}
|
||||
|
||||
/* Label */
|
||||
.label {
|
||||
position: fixed;
|
||||
bottom: 18px;
|
||||
left: 18px;
|
||||
padding: 10px 14px;
|
||||
background: rgba(18, 25, 37, 0.75);
|
||||
color: var(--fg);
|
||||
border-radius: 14px;
|
||||
backdrop-filter: blur(8px);
|
||||
border: 1px solid rgba(255,255,255,0.08);
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
/* ===== Emotionen über Variablen ===== */
|
||||
|
||||
body.emotion-neutral {
|
||||
--glow: rgba(0, 255, 180, 0.22);
|
||||
--smile: 0;
|
||||
--frown: 0;
|
||||
--omouth: 0;
|
||||
--mouth-line-opacity: 0.85;
|
||||
--mouth-line-h: 10px;
|
||||
}
|
||||
body.emotion-neutral .mouth-shape { }
|
||||
body.emotion-neutral .mouth-shape.frown { } /* no-op */
|
||||
|
||||
body.emotion-happy {
|
||||
--glow: rgba(0, 255, 120, 0.32);
|
||||
--smile: 1;
|
||||
--frown: 0;
|
||||
--omouth: 0;
|
||||
--mouth-line-y: 58%;
|
||||
--mouth-line-h: 12px;
|
||||
--mouth-line-opacity: 0;
|
||||
}
|
||||
body.emotion-happy .mouth-shape { }
|
||||
body.emotion-happy .mouth-shape.frown { } /* no-op */
|
||||
body.emotion-happy .mouth-shape::before {
|
||||
top: -20%; /* war 38% -> kleiner = weiter nach oben */
|
||||
height: 62%; /* etwas größer, damit der Bogen schön wirkt */
|
||||
}
|
||||
|
||||
body.emotion-sad {
|
||||
--glow: rgba(120, 180, 255, 0.32);
|
||||
--smile: 0;
|
||||
--frown: 1;
|
||||
--omouth: 0;
|
||||
--mouth-line-y: 42%;
|
||||
--mouth-line-h: 12px;
|
||||
--mouth-line-opacity: 0;
|
||||
}
|
||||
body.emotion-sad .mouth-shape { }
|
||||
body.emotion-sad .mouth-shape.frown { } /* no-op */
|
||||
|
||||
body.emotion-angry {
|
||||
--glow: rgba(255, 70, 70, 0.32);
|
||||
--smile: 0;
|
||||
--frown: 0.35;
|
||||
--omouth: 0;
|
||||
--mouth-line-opacity: 0.95;
|
||||
--mouth-line-h: 16px;
|
||||
}
|
||||
body.emotion-angry .eye {
|
||||
border-radius: 26% 74% 60% 40% / 55% 45% 55% 45%;
|
||||
transform: rotate(-2deg);
|
||||
}
|
||||
|
||||
body.emotion-surprised {
|
||||
--glow: rgba(255, 220, 90, 0.34);
|
||||
--smile: 0;
|
||||
--frown: 0;
|
||||
--omouth: 1;
|
||||
--mouth-line-opacity: 0.95;
|
||||
}
|
||||
body.emotion-surprised { }
|
||||
body.emotion-surprised.has-omouth { } /* marker in JS */
|
||||
|
||||
body.emotion-sleepy {
|
||||
--glow: rgba(180, 180, 255, 0.22);
|
||||
--smile: 0;
|
||||
--frown: 0;
|
||||
--omouth: 0;
|
||||
--mouth-line-opacity: 0.55;
|
||||
--mouth-line-h: 8px;
|
||||
}
|
||||
body.emotion-sleepy .eye {
|
||||
height: 6vw;
|
||||
max-height: 90px;
|
||||
}
|
||||
|
||||
|
||||
/* Smooth transition for everything */
|
||||
* { box-sizing: border-box; }
|
||||
|
||||
|
||||
:root{
|
||||
--intensity: 0.7; /* 0..1 */
|
||||
--mouth-open: 0; /* 0..1 */
|
||||
--bg:#000;
|
||||
--panel:#0b1020cc;
|
||||
--text:#e8f0ff;
|
||||
--muted:#a8b6d8;
|
||||
--accent:#7cffc9;
|
||||
--accent2:#7cb7ff;
|
||||
--danger:#ff6b6b;
|
||||
--shadow: 0 12px 40px rgba(0,0,0,.55);
|
||||
--r: 18px;
|
||||
}
|
||||
|
||||
/* Glow stärker je nach Intensität */
|
||||
.eye, .mouth {
|
||||
box-shadow: 0 0 calc(26px + 30px * var(--intensity)) var(--glow);
|
||||
html,body{
|
||||
height:100%;
|
||||
margin:0;
|
||||
background:var(--bg);
|
||||
color:var(--text);
|
||||
font-family:system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial;
|
||||
}
|
||||
|
||||
/* Mund-Linie “öffnet”: wird dicker und etwas tiefer */
|
||||
.mouth-shape::after {
|
||||
height: calc(var(--mouth-line-h) + 26px * var(--mouth-open));
|
||||
top: calc(var(--mouth-line-y) + 6% * var(--mouth-open));
|
||||
opacity: calc(var(--mouth-line-opacity) + 0.10 * var(--mouth-open));
|
||||
.stage{
|
||||
position:fixed; inset:0;
|
||||
display:grid; place-items:center;
|
||||
overflow:hidden;
|
||||
touch-action:manipulation;
|
||||
user-select:none;
|
||||
background:#000;
|
||||
}
|
||||
|
||||
|
||||
.control-link{
|
||||
position: fixed;
|
||||
right: 16px;
|
||||
bottom: 16px;
|
||||
padding: 12px 16px;
|
||||
background: rgba(0,0,0,0.6);
|
||||
color: white;
|
||||
text-decoration: none;
|
||||
border-radius: 12px;
|
||||
font-family: system-ui, sans-serif;
|
||||
font-size: 16px;
|
||||
.hint{
|
||||
position:fixed;
|
||||
top: max(10px, env(safe-area-inset-top));
|
||||
left: 0;
|
||||
right: 0;
|
||||
text-align:center;
|
||||
opacity:.55;
|
||||
font-size:12px;
|
||||
color:var(--muted);
|
||||
pointer-events:none;
|
||||
}
|
||||
.control-link:active{ transform: scale(0.98); }
|
||||
|
||||
.displayWrap{
|
||||
width:min(96vw, 720px);
|
||||
aspect-ratio: 520 / 360;
|
||||
position:relative;
|
||||
display:grid; place-items:center;
|
||||
}
|
||||
|
||||
.display{
|
||||
width:100%;
|
||||
height:100%;
|
||||
display:block;
|
||||
filter: drop-shadow(0 18px 40px rgba(0,0,0,.70));
|
||||
}
|
||||
|
||||
/* Touch overlay */
|
||||
.overlay{
|
||||
position:absolute;
|
||||
left: max(12px, env(safe-area-inset-left));
|
||||
right: max(12px, env(safe-area-inset-right));
|
||||
bottom: max(12px, env(safe-area-inset-bottom));
|
||||
display:flex;
|
||||
gap:10px;
|
||||
align-items:center;
|
||||
justify-content:space-between;
|
||||
background:var(--panel);
|
||||
border:1px solid rgba(255,255,255,.10);
|
||||
border-radius: var(--r);
|
||||
padding:10px 12px;
|
||||
box-shadow: var(--shadow);
|
||||
backdrop-filter: blur(10px);
|
||||
-webkit-backdrop-filter: blur(10px);
|
||||
opacity:0;
|
||||
transform: translateY(10px);
|
||||
pointer-events:none;
|
||||
transition: opacity .18s ease, transform .18s ease;
|
||||
}
|
||||
|
||||
.overlay.show{
|
||||
opacity:1;
|
||||
transform: translateY(0);
|
||||
pointer-events:auto;
|
||||
}
|
||||
|
||||
.overlay .left,
|
||||
.overlay .right{
|
||||
display:flex;
|
||||
gap:10px;
|
||||
align-items:center;
|
||||
flex-wrap:wrap;
|
||||
}
|
||||
|
||||
a.btn, button.btn{
|
||||
appearance:none;
|
||||
border:1px solid rgba(255,255,255,.14);
|
||||
background: rgba(255,255,255,.06);
|
||||
color:var(--text);
|
||||
border-radius: 14px;
|
||||
padding:10px 12px;
|
||||
font-size:14px;
|
||||
text-decoration:none;
|
||||
line-height:1;
|
||||
display:inline-flex;
|
||||
align-items:center;
|
||||
gap:8px;
|
||||
cursor:pointer;
|
||||
}
|
||||
|
||||
a.btn:active, button.btn:active{ transform: translateY(1px); }
|
||||
|
||||
.pill{
|
||||
font-size:12px;
|
||||
color:var(--muted);
|
||||
padding:8px 10px;
|
||||
border-radius:999px;
|
||||
border:1px solid rgba(255,255,255,.12);
|
||||
background: rgba(255,255,255,.04);
|
||||
display:inline-flex;
|
||||
align-items:center;
|
||||
gap:8px;
|
||||
}
|
||||
|
||||
.dot{
|
||||
width:9px;
|
||||
height:9px;
|
||||
border-radius:99px;
|
||||
background: var(--accent2);
|
||||
box-shadow: 0 0 0 3px rgba(124,183,255,.15);
|
||||
}
|
||||
.dot.on{
|
||||
background: var(--accent);
|
||||
box-shadow: 0 0 0 3px rgba(124,255,201,.15);
|
||||
}
|
||||
.dot.off{
|
||||
background: var(--danger);
|
||||
box-shadow: 0 0 0 3px rgba(255,107,107,.12);
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue