diff --git a/carinha.html b/carinha.html
index b3e9220..c145616 100644
--- a/carinha.html
+++ b/carinha.html
@@ -487,7 +487,18 @@ async function aplicarAparencia() {
} catch (e) { /* sem servidor — segue com o padrao */ }
}
setTimeout(aplicarAparencia, 200);
-if (new URLSearchParams(location.search).get('espelho') === '1') setInterval(aplicarAparencia, 1000);
+// ?painel=1: prévia dentro do painel novo. Nada de polling; o painel repassa os eventos
+// do servidor (SSE) por postMessage.
+const MODO_PAINEL = new URLSearchParams(location.search).get('painel') === '1';
+if (MODO_PAINEL) {
+ window.carinhaAtiva = false;
+ addEventListener('message', (e) => {
+ if (e.origin !== location.origin || e.source !== parent || !e.data) return;
+ const m = e.data;
+ if (m.tipo === 'ordem' && typeof m.ordem === 'string') trata(m.ordem);
+ else if (m.tipo === 'config' && m.config) configurarApp(m.config);
+ });
+} else if (new URLSearchParams(location.search).get('espelho') === '1') setInterval(aplicarAparencia, 1000);
// teclado (util para testar no PC)
const TECLAS = { p: "piscar", f: "feliz", c: "coracao", s: "surpreso", b: "bravo",
diff --git a/controle_pc.py b/controle_pc.py
index 66a3a24..07a8881 100644
--- a/controle_pc.py
+++ b/controle_pc.py
@@ -104,7 +104,7 @@ class ControlePC:
if self.rtc.atender(h, metodo, self.config['preview']):
return True
rota = h.path.split('?', 1)[0].rstrip('/')
- if rota not in ('/app/config', '/app/status', '/camera/frame', '/app/connect', '/app/health'):
+ if rota not in ('/app/config', '/app/padrao', '/app/status', '/camera/frame', '/app/connect', '/app/health'):
return False
def enviar(dados, codigo=200):
@@ -128,7 +128,9 @@ class ControlePC:
else:
dados = None
- if rota == '/app/health':
+ if rota == '/app/padrao':
+ enviar(PADRAO)
+ elif rota == '/app/health':
enviar({'projeto': 'carinha-pc', 'versao': versao()})
elif rota == '/app/config':
if metodo == 'POST':
diff --git a/kiosk-server.py b/kiosk-server.py
index c9888c7..bcca828 100644
--- a/kiosk-server.py
+++ b/kiosk-server.py
@@ -290,6 +290,18 @@ class Handler(SimpleHTTPRequestHandler):
enfileirar(ordem)
return self.json({"ok": True, partes[0]: ordem})
+ # Painel novo (Vite/Svelte em web/dist); as telas antigas seguem nos caminhos próprios.
+ painel = Path("web/dist")
+ if caminho in ("/", "") and (painel / "index.html").is_file():
+ self.path = "/web/dist/index.html"
+ return super().do_GET()
+ if caminho.startswith("/assets/"):
+ nome = caminho[len("/assets/"):]
+ if "/" in nome or "\\" in nome or nome.startswith(".") or not (painel / "assets" / nome).is_file():
+ return self.json({"erro": "Arquivo não disponível"}, 404)
+ self.path = "/web/dist/assets/" + nome
+ return super().do_GET()
+
if caminho in ("/dashboard", "/emojis", "/api"):
self.path = caminho + ".html"
elif caminho == "/painel":
diff --git a/tests/test_painel_web_browser.py b/tests/test_painel_web_browser.py
new file mode 100644
index 0000000..0e4520d
--- /dev/null
+++ b/tests/test_painel_web_browser.py
@@ -0,0 +1,136 @@
+"""Painel novo (web/dist) contra o servidor real: token, SSE, gestos, paleta, E-Stop, ajustes e celular.
+
+Requer `npm run build` em web/. Uso: python tests/test_painel_web_browser.py [pasta-para-capturas]
+"""
+from playwright.sync_api import sync_playwright, expect
+from pathlib import Path
+import importlib.util
+import os
+import sys
+import tempfile
+import threading
+
+ROOT = Path(__file__).resolve().parents[1]
+sys.path.insert(0, str(ROOT))
+os.chdir(ROOT)
+spec = importlib.util.spec_from_file_location('kiosk_web', ROOT / 'kiosk-server.py')
+kiosk = importlib.util.module_from_spec(spec)
+spec.loader.exec_module(kiosk)
+from braco import Braco
+from servos import ControleServos
+
+TOKEN = 'token-de-teste-123'
+
+
+class HW:
+ def __init__(self): self.calls = []
+ def pulse(self, ch, us): self.calls.append((ch, us))
+ def off(self, ch): self.calls.append((ch, None))
+
+
+def main(capturas=None):
+ assert (ROOT / 'web/dist/index.html').is_file(), 'Rode npm run build em web/ primeiro'
+ with tempfile.TemporaryDirectory() as tmp:
+ tmp = Path(tmp)
+ kiosk.ARQUIVO_APARENCIA = str(tmp / 'aparencia.json')
+ server = kiosk.Servidor(('127.0.0.1', 0), kiosk.Handler)
+ server.token = TOKEN
+ server.controle = kiosk.ControlePC(tmp / 'app.json')
+ server.servos = ControleServos(tmp / 'servos.json', HW())
+ server.braco = Braco(tmp / 'braco.json', server.servos)
+ kiosk.ligar_eventos(server)
+ thread = threading.Thread(target=server.serve_forever, daemon=True)
+ thread.start()
+ try:
+ testar('http://127.0.0.1:%d' % server.server_port, server, capturas)
+ finally:
+ kiosk.EVENTOS.fechar()
+ server.shutdown()
+ server.server_close()
+ server.servos.close()
+
+
+def testar(base, server, capturas):
+ auth = {'Authorization': 'Bearer ' + TOKEN}
+ with sync_playwright() as p:
+ browser = p.chromium.launch()
+ page = browser.new_page(viewport={'width': 1440, 'height': 960})
+ erros = []
+ page.on('pageerror', lambda e: erros.append(str(e)))
+ page.goto(base + '/')
+
+ # 1. token
+ expect(page.get_by_role('heading', name='Token do Carinha')).to_be_visible()
+ page.get_by_label('Token').fill(TOKEN)
+ page.get_by_role('button', name='Entrar').click()
+ expect(page.get_by_text('Servidor', exact=True)).to_be_visible(timeout=5000)
+
+ # 2. telemetria do celular chega por SSE
+ page.request.post(base + '/app/status', headers=auth, data={'dormindo': False, 'bateria': 0.8,
+ 'microfone_ativo': True, 'microfone': 0.3})
+ expect(page.get_by_text('Celular online · 80%')).to_be_visible(timeout=5000)
+
+ # 3. gesto pelo dock
+ kiosk.FILA.limpar()
+ page.get_by_role('button', name='Feliz').first.click()
+ expect(page.get_by_text('Expressão: feliz')).to_be_visible(timeout=5000)
+ assert kiosk.FILA.ordens() == ['feliz'], kiosk.FILA.ordens()
+
+ # 4. atalho numérico e paleta
+ page.locator('body').click(position={'x': 5, 'y': 900})
+ page.keyboard.press('2')
+ page.wait_for_timeout(300)
+ assert kiosk.FILA.ordens()[-1] == 'coracao', kiosk.FILA.ordens()
+ page.keyboard.press('Control+k')
+ page.get_by_label('Buscar comando').fill('surpreso')
+ page.keyboard.press('Enter')
+ page.wait_for_timeout(300)
+ assert kiosk.FILA.ordens()[-1] == 'surpreso', kiosk.FILA.ordens()
+ if capturas:
+ page.screenshot(path=str(Path(capturas) / 'ao-vivo.png'), full_page=True)
+
+ # 5. E-Stop real
+ page.get_by_role('button', name='Parar tudo').click()
+ expect(page.get_by_role('button', name='PARADO · Rearmar')).to_be_visible(timeout=5000)
+ assert kiosk.ESTOP['ativo'] and server.servos.travado
+ page.get_by_role('button', name='PARADO · Rearmar').click()
+ expect(page.get_by_role('button', name='Parar tudo')).to_be_visible(timeout=5000)
+ assert not server.servos.travado
+
+ # 6. ajustes salvam sem travar
+ page.get_by_role('link', name='Ajustes').first.click()
+ page.get_by_label('Buscar ajuste').fill('boca')
+ boca = page.get_by_role('switch', name='Mostrar boca')
+ assert boca.is_checked()
+ boca.locator('xpath=..').click()
+ page.wait_for_timeout(500)
+ assert server.controle.config['boca'] is False
+ if capturas:
+ page.get_by_label('Buscar ajuste').fill('')
+ page.screenshot(path=str(Path(capturas) / 'ajustes.png'), full_page=True)
+
+ # 7. celular: barra inferior e nada de rolagem horizontal
+ page.set_viewport_size({'width': 400, 'height': 860})
+ page.get_by_role('link', name='Ao vivo').click()
+ page.wait_for_timeout(300)
+ largura = page.evaluate('document.scrollingElement.scrollWidth')
+ assert largura <= 400, 'rolagem horizontal: %s' % largura
+ if capturas:
+ page.screenshot(path=str(Path(capturas) / 'celular.png'), full_page=True)
+
+ # 8. tema claro
+ page.set_viewport_size({'width': 1280, 'height': 900})
+ inicial = page.evaluate("document.documentElement.dataset.theme")
+ outro = 'light' if inicial == 'dark' else 'dark'
+ page.get_by_role('button', name='Usar tema ' + ('claro' if outro == 'light' else 'escuro')).click()
+ assert page.evaluate("document.documentElement.dataset.theme") == outro
+ if capturas:
+ page.screenshot(path=str(Path(capturas) / ('tema-' + outro + '.png')))
+
+ assert not erros, erros
+ browser.close()
+ print('OK: token, SSE, dock 1-9, paleta, E-Stop, ajustes otimistas, celular 400px, tema claro')
+
+
+if __name__ == '__main__':
+ main(sys.argv[1] if len(sys.argv) > 1 else None)
diff --git a/web/.gitignore b/web/.gitignore
new file mode 100644
index 0000000..b947077
--- /dev/null
+++ b/web/.gitignore
@@ -0,0 +1,2 @@
+node_modules/
+dist/
diff --git a/web/index.html b/web/index.html
new file mode 100644
index 0000000..155f7d3
--- /dev/null
+++ b/web/index.html
@@ -0,0 +1,14 @@
+
+
+
+
+
+
+ Carinha
+
+
+
+
+
+
+
diff --git a/web/package-lock.json b/web/package-lock.json
new file mode 100644
index 0000000..017eb65
--- /dev/null
+++ b/web/package-lock.json
@@ -0,0 +1,1250 @@
+{
+ "name": "carinha-painel",
+ "version": "0.8.0",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "carinha-painel",
+ "version": "0.8.0",
+ "dependencies": {
+ "@fontsource-variable/jetbrains-mono": "^5.3.0",
+ "@fontsource-variable/outfit": "^5.3.0",
+ "@lucide/svelte": "^1.52.0"
+ },
+ "devDependencies": {
+ "@sveltejs/vite-plugin-svelte": "^7.3.1",
+ "@tsconfig/svelte": "^5.0.8",
+ "@types/node": "^26.6.4",
+ "svelte": "^5.57.1",
+ "svelte-check": "^4.7.6",
+ "typescript": "^6.0.3",
+ "vite": "^8.3.2"
+ }
+ },
+ "node_modules/@fontsource-variable/jetbrains-mono": {
+ "version": "5.3.0",
+ "resolved": "https://registry.npmjs.org/@fontsource-variable/jetbrains-mono/-/jetbrains-mono-5.3.0.tgz",
+ "integrity": "sha512-F32xpS2NsGYoQi2ADSkKTgpJj7ozajsGgDJ8woTnqjmIB+dxDIqImjl4pXZVEExu8UFZ2ndhmX18EBS/hdz3Lw==",
+ "license": "OFL-1.1",
+ "funding": {
+ "url": "https://github.com/sponsors/ayuhito"
+ }
+ },
+ "node_modules/@fontsource-variable/outfit": {
+ "version": "5.3.0",
+ "resolved": "https://registry.npmjs.org/@fontsource-variable/outfit/-/outfit-5.3.0.tgz",
+ "integrity": "sha512-fPb5XnYK0k7SbR++HEmm4+ZqDszmLKQ+sPRUr8oQ/KNC0vy6r6hazJFe8iYozv4cqlHbvJ/JECkJWrfDodUMVQ==",
+ "license": "OFL-1.1",
+ "funding": {
+ "url": "https://github.com/sponsors/ayuhito"
+ }
+ },
+ "node_modules/@jridgewell/gen-mapping": {
+ "version": "0.3.13",
+ "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
+ "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==",
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/sourcemap-codec": "^1.5.0",
+ "@jridgewell/trace-mapping": "^0.3.24"
+ }
+ },
+ "node_modules/@jridgewell/remapping": {
+ "version": "2.3.5",
+ "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz",
+ "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/gen-mapping": "^0.3.5",
+ "@jridgewell/trace-mapping": "^0.3.24"
+ }
+ },
+ "node_modules/@jridgewell/resolve-uri": {
+ "version": "3.1.2",
+ "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
+ "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
+ "node_modules/@jridgewell/sourcemap-codec": {
+ "version": "1.6.0",
+ "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz",
+ "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==",
+ "license": "MIT"
+ },
+ "node_modules/@jridgewell/trace-mapping": {
+ "version": "0.3.31",
+ "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz",
+ "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==",
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/resolve-uri": "^3.1.0",
+ "@jridgewell/sourcemap-codec": "^1.4.14"
+ }
+ },
+ "node_modules/@lucide/svelte": {
+ "version": "1.52.0",
+ "resolved": "https://registry.npmjs.org/@lucide/svelte/-/svelte-1.52.0.tgz",
+ "integrity": "sha512-bQ8t3IcZxWqAdmdMZPyZEv8Qf+MDgBAf7n43eRQoXyBeLWr8HWToiMIO6vKlHxC/lnMiCDdTzm09hjy92ch46A==",
+ "license": "ISC",
+ "peerDependencies": {
+ "svelte": "^5"
+ }
+ },
+ "node_modules/@oxc-project/types": {
+ "version": "0.152.0",
+ "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.152.0.tgz",
+ "integrity": "sha512-oM/5rLBm2tPkg0iBgkH/FOeR3PCDpY19GTgAZjMFM8h9WI9VW7cLgzp6nwtarYKmovavIQZ+Fe/RKX/8C8O/Rw==",
+ "dev": true,
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/oxc-project"
+ }
+ },
+ "node_modules/@rolldown/binding-android-arm-eabi": {
+ "version": "1.2.12",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.12.tgz",
+ "integrity": "sha512-dB/a1214qKfHMXCpgqR4OZT+jS4kTyEXbQGJPqzobt5EwH5rX080pxE37alt3RzvR1bf1Yz/yGqRfrYAxuPw0A==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-android-arm64": {
+ "version": "1.2.12",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.12.tgz",
+ "integrity": "sha512-7KHFgQ5VJxIHcLlrwrc3Xbds7oTNQT7Pgi9gQCJKrd2VGab/UksIOYp6VD8MzCstGxOKMgNamPwUCfxPdP1OHg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-darwin-arm64": {
+ "version": "1.2.12",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.12.tgz",
+ "integrity": "sha512-3YIhqHD96nA5SaYNRBR16HnGv4oavZvXfD/ayHM+oYZ0WD/8lBAtf6zQua4kEyAvpqrluKXl0lnOBoiNby7x9w==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-darwin-x64": {
+ "version": "1.2.12",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.12.tgz",
+ "integrity": "sha512-UuuJ35MFw4gmFOrE9pEqIV+K3syIKveph+Qc1/ljHZVdoDW4pz/JHR/eMVom+TZGl/5OOvGJOWaOCVt3ZfqhxA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-freebsd-x64": {
+ "version": "1.2.12",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.12.tgz",
+ "integrity": "sha512-uMvssit0a4W+/7D8CbHUvG719mH3R2jwXAlh/XcPvuHTE0g++LymF88DCGNX0HM2rBOn0xrzgXktIB6fLSJBTQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-arm-gnueabihf": {
+ "version": "1.2.12",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.12.tgz",
+ "integrity": "sha512-XcFu0R0xWnwzSf4IQgFH1rJIckPN1pLy2R+4r9IDB7Yfu/ys9cVqfa4pBrMHj7a3gl8mIR4nRNPg0e5IvEVs6g==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-arm64-gnu": {
+ "version": "1.2.12",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.12.tgz",
+ "integrity": "sha512-260UrKgn8tz39ak+SMDOirKzr7V04M9dWPw5llW00SwBivCZoWcRBKV1d8cXnRkUmSZA3BdiUmBHWk7734Ulpw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-arm64-musl": {
+ "version": "1.2.12",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.12.tgz",
+ "integrity": "sha512-5YK1I9SqDkbPgc1IA8BgDl34suqUS2q0KWnBrirm0E51YjOs6eo6dV6jbQfNE/argHRSvd0QUGgtpIoYx+WWpw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "libc": [
+ "musl"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-ppc64-gnu": {
+ "version": "1.2.12",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.12.tgz",
+ "integrity": "sha512-Rkcrmp7eFRg74yL5fXEU91JEWbdEPLevWwGtXpmhbjlD1StScbWTmO94Bhly+Mo+ketKYkdmM1vNUKeWSlx8cQ==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-s390x-gnu": {
+ "version": "1.2.12",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.12.tgz",
+ "integrity": "sha512-qvK4DuAsQc2BSjlx+Xr+IzOIvvxbGZqxFwdWfG6F518Erj0GGISyQbJ6pIappnOxlNPzNHvo/L0BwB30GZ+zVw==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-x64-gnu": {
+ "version": "1.2.12",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.12.tgz",
+ "integrity": "sha512-Q9uLBO53Xd4QIq1WOycVQyPP1O4HhraEV2qqb3uTrnVw6QZih9duY4vNXOivL1xoUS1/z+W8eF4NMfl2a8Sdjw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-x64-musl": {
+ "version": "1.2.12",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.12.tgz",
+ "integrity": "sha512-3IBxWFMjbOZskDPKv8Lf9BCnahlKuHthWkYnyIxOH/QcJrFcS4EmcenthApkwr/5+nEqZlLzeYbxeMaX7A5u4g==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "libc": [
+ "musl"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-openharmony-arm64": {
+ "version": "1.2.12",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.12.tgz",
+ "integrity": "sha512-xtX61xg4LKPkPWilZU1ynKClz5Gj4bf74LML4r3eVLWumKnGjoEr1OSHQhMdbBDoYTi+yjrujvpZe2pUnqCrrA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openharmony"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-win32-arm64-msvc": {
+ "version": "1.2.12",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.12.tgz",
+ "integrity": "sha512-At7fPB6PCaIjzgIhEZFxuT+BBFqiQibJDT4d3PhiR3f4E7bbMZF4aKblbFfEM3sETRDd1YiQx/+U/g/B/ou5Ew==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-win32-x64-msvc": {
+ "version": "1.2.12",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.12.tgz",
+ "integrity": "sha512-WIw2haVKwjuYdXkHaoC0mF8Le71TuCBxjrdKqLbJGctbBABj+ClfmNvtbOnzpq3RokNo5+V1qhtSzJyXorsklQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/pluginutils": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz",
+ "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@sveltejs/acorn-typescript": {
+ "version": "1.0.13",
+ "resolved": "https://registry.npmjs.org/@sveltejs/acorn-typescript/-/acorn-typescript-1.0.13.tgz",
+ "integrity": "sha512-wgKggnhZVL9Bfx1OaKKTrYY9BFRk6C8UAkQNUcIv1+llzYrIqy+RZm5HPKzn0NpEBvTVhTqB4kQyllZywsRBRQ==",
+ "license": "MIT",
+ "peerDependencies": {
+ "acorn": "^8.9.0"
+ }
+ },
+ "node_modules/@sveltejs/load-config": {
+ "version": "0.2.3",
+ "resolved": "https://registry.npmjs.org/@sveltejs/load-config/-/load-config-0.2.3.tgz",
+ "integrity": "sha512-VT3qmUb8pRV2QrZjd8iAmtg8lf4W0TIjZbvXtz5MKei/q96teWZgGJyyidJzOjzZzvdq616eSRVeMYIQChUTAQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 18.0.0"
+ }
+ },
+ "node_modules/@sveltejs/vite-plugin-svelte": {
+ "version": "7.3.1",
+ "resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte/-/vite-plugin-svelte-7.3.1.tgz",
+ "integrity": "sha512-ZPsLN8B1e/En+Ak5s4V7srFDT532oS0qieLsQwu63NGKsS+iAjoO2Js1BochlHlglcU+Pt7WAO3C5Ee+4f6gVA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "deepmerge": "^4.3.1",
+ "magic-string": "^1.0.0",
+ "obug": "^2.1.0",
+ "vitefu": "^1.1.2"
+ },
+ "engines": {
+ "node": "^20.19 || ^22.12 || >=24"
+ },
+ "peerDependencies": {
+ "svelte": "^5.46.4",
+ "vite": "^8.0.0-beta.7 || ^8.0.0"
+ }
+ },
+ "node_modules/@tsconfig/svelte": {
+ "version": "5.0.8",
+ "resolved": "https://registry.npmjs.org/@tsconfig/svelte/-/svelte-5.0.8.tgz",
+ "integrity": "sha512-UkNnw1/oFEfecR8ypyHIQuWYdkPvHiwcQ78sh+ymIiYoF+uc5H1UBetbjyqT+vgGJ3qQN6nhucJviX6HesWtKQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@types/estree": {
+ "version": "1.0.9",
+ "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz",
+ "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==",
+ "license": "MIT"
+ },
+ "node_modules/@types/node": {
+ "version": "26.6.4",
+ "resolved": "https://registry.npmjs.org/@types/node/-/node-26.6.4.tgz",
+ "integrity": "sha512-ldVPDCzj7fsaGZrLB0NuHuTvJcsNasysBAqMolr/cgxrLd1xbqxIr3XJiPnHHJUCxj5sNF1vnRj9aWnrVh5Jcg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "undici-types": "~8.9.0"
+ }
+ },
+ "node_modules/acorn": {
+ "version": "8.19.0",
+ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.19.0.tgz",
+ "integrity": "sha512-oJlA3XiRm7Cyk6qFD2Jn8ak9B7jSy0qy00ADO3+8dpT0LSjFihQYv4C02LFCSYJV3Q37xYwwRy5m+IpIiUzqWw==",
+ "license": "MIT",
+ "bin": {
+ "acorn": "bin/acorn"
+ },
+ "engines": {
+ "node": ">=0.4.0"
+ }
+ },
+ "node_modules/aria-query": {
+ "version": "5.3.1",
+ "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.1.tgz",
+ "integrity": "sha512-Z/ZeOgVl7bcSYZ/u/rh0fOpvEpq//LZmdbkXyc7syVzjPAhfOa9ebsdTSjEBDU4vs5nC98Kfduj1uFo0qyET3g==",
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/axobject-query": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz",
+ "integrity": "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==",
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/chokidar": {
+ "version": "4.0.3",
+ "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz",
+ "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "readdirp": "^4.0.1"
+ },
+ "engines": {
+ "node": ">= 14.16.0"
+ },
+ "funding": {
+ "url": "https://paulmillr.com/funding/"
+ }
+ },
+ "node_modules/clsx": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
+ "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/deepmerge": {
+ "version": "4.3.1",
+ "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz",
+ "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/detect-libc": {
+ "version": "2.1.2",
+ "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
+ "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/devalue": {
+ "version": "5.9.4",
+ "resolved": "https://registry.npmjs.org/devalue/-/devalue-5.9.4.tgz",
+ "integrity": "sha512-sPAT4pztbu6586/hrhOnMKS17IJrvg12mXiSPSS3W5qDeN2RGgvZ0diZCm31dBbnevfVmujNO3IM2wrS4Y2Rhg==",
+ "license": "MIT"
+ },
+ "node_modules/esm-env": {
+ "version": "1.2.2",
+ "resolved": "https://registry.npmjs.org/esm-env/-/esm-env-1.2.2.tgz",
+ "integrity": "sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA==",
+ "license": "MIT"
+ },
+ "node_modules/esrap": {
+ "version": "2.4.0",
+ "resolved": "https://registry.npmjs.org/esrap/-/esrap-2.4.0.tgz",
+ "integrity": "sha512-CQPcneEHw/czj32uwQC52nYnax8H4SXt25FCM+LIwGZ49cMIxpOj3bP8wmzcI+Z0HIy5KkClaPhRLfCySzwHPA==",
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/sourcemap-codec": "^1.4.15"
+ },
+ "peerDependencies": {
+ "@typescript-eslint/types": "^8.2.0"
+ },
+ "peerDependenciesMeta": {
+ "@typescript-eslint/types": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/fdir": {
+ "version": "6.5.0",
+ "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
+ "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12.0.0"
+ },
+ "peerDependencies": {
+ "picomatch": "^3 || ^4"
+ },
+ "peerDependenciesMeta": {
+ "picomatch": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/fsevents": {
+ "version": "2.3.3",
+ "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
+ "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
+ }
+ },
+ "node_modules/is-reference": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/is-reference/-/is-reference-3.0.3.tgz",
+ "integrity": "sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/estree": "^1.0.6"
+ }
+ },
+ "node_modules/lightningcss": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz",
+ "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==",
+ "dev": true,
+ "license": "MPL-2.0",
+ "dependencies": {
+ "detect-libc": "^2.0.3"
+ },
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ },
+ "optionalDependencies": {
+ "lightningcss-android-arm64": "1.33.0",
+ "lightningcss-darwin-arm64": "1.33.0",
+ "lightningcss-darwin-x64": "1.33.0",
+ "lightningcss-freebsd-x64": "1.33.0",
+ "lightningcss-linux-arm-gnueabihf": "1.33.0",
+ "lightningcss-linux-arm64-gnu": "1.33.0",
+ "lightningcss-linux-arm64-musl": "1.33.0",
+ "lightningcss-linux-x64-gnu": "1.33.0",
+ "lightningcss-linux-x64-musl": "1.33.0",
+ "lightningcss-win32-arm64-msvc": "1.33.0",
+ "lightningcss-win32-x64-msvc": "1.33.0"
+ }
+ },
+ "node_modules/lightningcss-android-arm64": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz",
+ "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-darwin-arm64": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz",
+ "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-darwin-x64": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz",
+ "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-freebsd-x64": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz",
+ "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-arm-gnueabihf": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz",
+ "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-arm64-gnu": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz",
+ "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-arm64-musl": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz",
+ "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "libc": [
+ "musl"
+ ],
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-x64-gnu": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz",
+ "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-x64-musl": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz",
+ "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "libc": [
+ "musl"
+ ],
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-win32-arm64-msvc": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz",
+ "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-win32-x64-msvc": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz",
+ "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/locate-character": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/locate-character/-/locate-character-3.0.0.tgz",
+ "integrity": "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==",
+ "license": "MIT"
+ },
+ "node_modules/magic-string": {
+ "version": "1.4.3",
+ "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-1.4.3.tgz",
+ "integrity": "sha512-z12OxmaPGE0F4xlpGdjuAUckipLpQlTAuAmyGhNoD7ODpYUzvDfvtxUbjM/jwznyP178oju/KDdyi220wNJ0RQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/sourcemap-codec": "^1.6.0"
+ }
+ },
+ "node_modules/mri": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/mri/-/mri-1.2.0.tgz",
+ "integrity": "sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/nanoid": {
+ "version": "3.3.20",
+ "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.20.tgz",
+ "integrity": "sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "bin": {
+ "nanoid": "bin/nanoid.cjs"
+ },
+ "engines": {
+ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
+ }
+ },
+ "node_modules/obug": {
+ "version": "2.2.1",
+ "resolved": "https://registry.npmjs.org/obug/-/obug-2.2.1.tgz",
+ "integrity": "sha512-XrsrhT5sybtKI6wakr2SPOlGZWWYbUXZ7a0jT8/QOeAPau+1X/bSegNe5YR75oJmEZQbKningirmGOEJCIk61Q==",
+ "dev": true,
+ "funding": [
+ "https://github.com/sponsors/sxzz",
+ "https://opencollective.com/debug"
+ ],
+ "license": "MIT",
+ "engines": {
+ "node": ">=12.20.0"
+ }
+ },
+ "node_modules/picocolors": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
+ "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/picomatch": {
+ "version": "4.0.7",
+ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz",
+ "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/jonschlinkert"
+ }
+ },
+ "node_modules/postcss": {
+ "version": "8.5.29",
+ "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.29.tgz",
+ "integrity": "sha512-49cGhUbXj8Qenv0iTMxA1cFBzxXoctpC9Ujd77t1WcbJIr6nF/eI7g/8MgxrYldFRuAXvja7xQRwavoW7kgrxQ==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/postcss/"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/postcss"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "nanoid": "^3.3.19",
+ "picocolors": "^1.1.1",
+ "source-map-js": "^1.2.2"
+ },
+ "engines": {
+ "node": "^10 || ^12 || >=14"
+ }
+ },
+ "node_modules/readdirp": {
+ "version": "4.1.2",
+ "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz",
+ "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 14.18.0"
+ },
+ "funding": {
+ "type": "individual",
+ "url": "https://paulmillr.com/funding/"
+ }
+ },
+ "node_modules/rolldown": {
+ "version": "1.2.12",
+ "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.12.tgz",
+ "integrity": "sha512-8wafseiaG80xmXSfqidUNqZcylTlhmPZZt+za2m+js2sFZ8dTNlhIOV2WcbIPx2hgwPBJpEUGFAMZ9bgBBLTSQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@oxc-project/types": "=0.152.0",
+ "@rolldown/pluginutils": "^1.0.0"
+ },
+ "bin": {
+ "rolldown": "bin/cli.mjs"
+ },
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ },
+ "optionalDependencies": {
+ "@rolldown/binding-android-arm-eabi": "1.2.12",
+ "@rolldown/binding-android-arm64": "1.2.12",
+ "@rolldown/binding-darwin-arm64": "1.2.12",
+ "@rolldown/binding-darwin-x64": "1.2.12",
+ "@rolldown/binding-freebsd-x64": "1.2.12",
+ "@rolldown/binding-linux-arm-gnueabihf": "1.2.12",
+ "@rolldown/binding-linux-arm64-gnu": "1.2.12",
+ "@rolldown/binding-linux-arm64-musl": "1.2.12",
+ "@rolldown/binding-linux-ppc64-gnu": "1.2.12",
+ "@rolldown/binding-linux-s390x-gnu": "1.2.12",
+ "@rolldown/binding-linux-x64-gnu": "1.2.12",
+ "@rolldown/binding-linux-x64-musl": "1.2.12",
+ "@rolldown/binding-openharmony-arm64": "1.2.12",
+ "@rolldown/binding-win32-arm64-msvc": "1.2.12",
+ "@rolldown/binding-win32-x64-msvc": "1.2.12"
+ }
+ },
+ "node_modules/sade": {
+ "version": "1.8.1",
+ "resolved": "https://registry.npmjs.org/sade/-/sade-1.8.1.tgz",
+ "integrity": "sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "mri": "^1.1.0"
+ },
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/source-map-js": {
+ "version": "1.2.2",
+ "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz",
+ "integrity": "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==",
+ "dev": true,
+ "license": "BSD-3-Clause",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/svelte": {
+ "version": "5.57.1",
+ "resolved": "https://registry.npmjs.org/svelte/-/svelte-5.57.1.tgz",
+ "integrity": "sha512-Uqj49lWKB+iSSnneuwiYYJ7MZgkB+eXr0LXBhv4uDuAkXqnWmq65Sxflfvp0Lc6MdKjMUxGaeOKWJqz5SNiVIA==",
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/remapping": "^2.3.5",
+ "@jridgewell/sourcemap-codec": "^1.6.0",
+ "@sveltejs/acorn-typescript": "^1.0.13",
+ "@types/estree": "^1.0.9",
+ "acorn": "^8.18.0",
+ "aria-query": "5.3.1",
+ "axobject-query": "^4.1.0",
+ "clsx": "^2.1.1",
+ "devalue": "^5.9.2",
+ "esm-env": "^1.2.1",
+ "esrap": "^2.3.6",
+ "is-reference": "^3.0.3",
+ "locate-character": "^3.0.0",
+ "magic-string": "^0.30.11",
+ "zimmerframe": "^1.1.2"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/svelte-check": {
+ "version": "4.7.6",
+ "resolved": "https://registry.npmjs.org/svelte-check/-/svelte-check-4.7.6.tgz",
+ "integrity": "sha512-t2scM//ZuVbSY/T2w6FSBw1v9s2NEmh/g+sy1lqtosW5ylBV5AF4wFb1Ts9Kf3MbfPDUDJDZ9L436YT0SPTdvw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/trace-mapping": "^0.3.25",
+ "@sveltejs/load-config": "^0.2.3",
+ "chokidar": "^4.0.1",
+ "fdir": "^6.2.0",
+ "picocolors": "^1.0.0",
+ "sade": "^1.7.4"
+ },
+ "bin": {
+ "svelte-check": "bin/svelte-check"
+ },
+ "engines": {
+ "node": ">= 18.0.0"
+ },
+ "peerDependencies": {
+ "svelte": "^4.0.0 || ^5.0.0-next.0",
+ "typescript": "^5.0.0 || ^6.0.0"
+ }
+ },
+ "node_modules/svelte/node_modules/magic-string": {
+ "version": "0.30.21",
+ "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
+ "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/sourcemap-codec": "^1.5.5"
+ }
+ },
+ "node_modules/tinyglobby": {
+ "version": "0.2.17",
+ "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
+ "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "fdir": "^6.5.0",
+ "picomatch": "^4.0.4"
+ },
+ "engines": {
+ "node": ">=12.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/SuperchupuDev"
+ }
+ },
+ "node_modules/typescript": {
+ "version": "6.0.3",
+ "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz",
+ "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "bin": {
+ "tsc": "bin/tsc",
+ "tsserver": "bin/tsserver"
+ },
+ "engines": {
+ "node": ">=14.17"
+ }
+ },
+ "node_modules/undici-types": {
+ "version": "8.9.0",
+ "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.9.0.tgz",
+ "integrity": "sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/vite": {
+ "version": "8.3.2",
+ "resolved": "https://registry.npmjs.org/vite/-/vite-8.3.2.tgz",
+ "integrity": "sha512-SQr1x6W5vVSbROg7vsyXIaxK9b0G7zsT68acdWWRmnBUsgDieLCRG+Rep9WdZgcposvv/GSnr4GUUBqB3vXq6w==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "lightningcss": "^1.33.0",
+ "picomatch": "^4.0.7",
+ "postcss": "^8.5.28",
+ "rolldown": "~1.2.11",
+ "tinyglobby": "^0.2.17"
+ },
+ "bin": {
+ "vite": "bin/vite.js"
+ },
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ },
+ "funding": {
+ "url": "https://github.com/vitejs/vite?sponsor=1"
+ },
+ "optionalDependencies": {
+ "fsevents": "~2.3.3"
+ },
+ "peerDependencies": {
+ "@types/node": "^20.19.0 || >=22.12.0",
+ "@vitejs/devtools": "^0.7.1",
+ "esbuild": "^0.27.0 || ^0.28.0",
+ "jiti": ">=1.21.0",
+ "less": "^4.0.0",
+ "sass": "^1.70.0",
+ "sass-embedded": "^1.70.0",
+ "stylus": ">=0.54.8",
+ "sugarss": "^5.0.0",
+ "terser": "^5.16.0",
+ "tsx": "^4.8.1",
+ "yaml": "^2.4.2"
+ },
+ "peerDependenciesMeta": {
+ "@types/node": {
+ "optional": true
+ },
+ "@vitejs/devtools": {
+ "optional": true
+ },
+ "esbuild": {
+ "optional": true
+ },
+ "jiti": {
+ "optional": true
+ },
+ "less": {
+ "optional": true
+ },
+ "sass": {
+ "optional": true
+ },
+ "sass-embedded": {
+ "optional": true
+ },
+ "stylus": {
+ "optional": true
+ },
+ "sugarss": {
+ "optional": true
+ },
+ "terser": {
+ "optional": true
+ },
+ "tsx": {
+ "optional": true
+ },
+ "yaml": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/vitefu": {
+ "version": "1.1.3",
+ "resolved": "https://registry.npmjs.org/vitefu/-/vitefu-1.1.3.tgz",
+ "integrity": "sha512-ub4okH7Z5KLjb6hDyjqrGXqWtWvoYdU3IGm/NorpgHncKoLTCfRIbvlhBm7r0YstIaQRYlp4yEbFqDcKSzXSSg==",
+ "dev": true,
+ "license": "MIT",
+ "workspaces": [
+ "tests/deps/*",
+ "tests/projects/*",
+ "tests/projects/workspace/packages/*"
+ ],
+ "peerDependencies": {
+ "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0"
+ },
+ "peerDependenciesMeta": {
+ "vite": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/zimmerframe": {
+ "version": "1.1.5",
+ "resolved": "https://registry.npmjs.org/zimmerframe/-/zimmerframe-1.1.5.tgz",
+ "integrity": "sha512-msJxIvYDYcoNL+PJsu+7qmpDWsYmAxTY+2TNYXXF0hzBzBk0BMecOqDOG/EckUoKCuKwObfbugIl8QpqHDXeFA==",
+ "license": "MIT"
+ }
+ }
+}
diff --git a/web/package.json b/web/package.json
new file mode 100644
index 0000000..46a2b08
--- /dev/null
+++ b/web/package.json
@@ -0,0 +1,26 @@
+{
+ "name": "carinha-painel",
+ "private": true,
+ "version": "0.8.0",
+ "type": "module",
+ "scripts": {
+ "dev": "vite",
+ "build": "vite build",
+ "preview": "vite preview",
+ "check": "svelte-check --tsconfig ./tsconfig.json"
+ },
+ "devDependencies": {
+ "@sveltejs/vite-plugin-svelte": "^7.3.1",
+ "@tsconfig/svelte": "^5.0.8",
+ "@types/node": "^26.6.4",
+ "svelte": "^5.57.1",
+ "svelte-check": "^4.7.6",
+ "typescript": "^6.0.3",
+ "vite": "^8.3.2"
+ },
+ "dependencies": {
+ "@fontsource-variable/jetbrains-mono": "^5.3.0",
+ "@fontsource-variable/outfit": "^5.3.0",
+ "@lucide/svelte": "^1.52.0"
+ }
+}
diff --git a/web/src/App.svelte b/web/src/App.svelte
new file mode 100644
index 0000000..25c5563
--- /dev/null
+++ b/web/src/App.svelte
@@ -0,0 +1,364 @@
+
+
+
+
+{#if estado.conexao === 'token'}
+
+{/if}
+
+
+
+
+
+
+ Carinha
+ {estado.versao ? 'v' + estado.versao : ''}
+
+
+
+ {#each ROTAS as r (r.id)}
+ {@const Icone = ICONES[r.id]}
+
+
+ {r.titulo}
+
+
+ {/each}
+
+
+ Comandos Ctrl K
+
+
+
+
+ {ROTAS.find((r) => r.id === nav.atual)?.titulo}
+
+
+ {conexaoTexto}
+
+
+ Celular {estado.celularOnline ? 'online' : 'offline'}{bateria !== null && estado.celularOnline ? ` · ${bateria}%` : ''}
+
+ {#if estado.conversa}
+ IA: {estado.conversa.provedor === 'gemini' ? 'Gemini' : 'Qwen local'}
+ {/if}
+
+
+
+
+
+ aplicarTema(tema === 'dark' ? 'light' : 'dark')}
+ aria-label={tema === 'dark' ? 'Usar tema claro' : 'Usar tema escuro'}
+ >
+ {#if tema === 'dark'} {:else} {/if}
+
+
+
+
+
+
+
+
+
+ {#if nav.atual !== 'ao-vivo'}
+
+ {/if}
+
+
+
+
+
+
diff --git a/web/src/lib/ajustes.svelte.ts b/web/src/lib/ajustes.svelte.ts
new file mode 100644
index 0000000..58c36e0
--- /dev/null
+++ b/web/src/lib/ajustes.svelte.ts
@@ -0,0 +1,107 @@
+/** Ajustes do app do celular (/app/config): salvamento otimista e campos agrupados. */
+import { pedir, postar } from './api';
+import { avisos } from './avisos.svelte';
+import { estado } from './estado.svelte';
+import type { AppConfig } from './tipos';
+
+/** Atualiza na hora; se o servidor recusar, volta ao valor anterior e avisa. Não trava o formulário. */
+export async function salvarApp(parcial: AppConfig) {
+ const antes = estado.appConfig ? { ...estado.appConfig } : null;
+ if (estado.appConfig) estado.appConfig = { ...estado.appConfig, ...parcial };
+ try {
+ const r = await postar<{ config: AppConfig }>('/app/config', parcial);
+ estado.appConfig = r.config;
+ } catch (e) {
+ estado.appConfig = antes;
+ avisos.erro(e);
+ }
+}
+
+export async function restaurarPadrao() {
+ const padrao = await pedir('/app/padrao');
+ // A transmissão não é "ajuste": continua como está.
+ delete padrao.preview;
+ await salvarApp(padrao);
+ avisos.ok('Ajustes do celular restaurados para o padrão.');
+}
+
+type Opcoes = [string | number, string][];
+export interface Campo {
+ chave: string;
+ rotulo: string;
+ ajuda?: string;
+ tipo: 'toggle' | 'slider' | 'escolha';
+ min?: number;
+ max?: number;
+ passo?: number;
+ unidade?: string;
+ opcoes?: Opcoes;
+}
+
+export const GRUPOS: { titulo: string; descricao: string; campos: Campo[] }[] = [
+ {
+ titulo: 'Tela e personalidade',
+ descricao: 'Como o rosto aparece e se comporta sozinho.',
+ campos: [
+ { chave: 'auto', rotulo: 'Gestos espontâneos', ajuda: 'Pisca e olha em volta sem comando', tipo: 'toggle' },
+ { chave: 'boca', rotulo: 'Mostrar boca', tipo: 'toggle' },
+ { chave: 'sobrancelhas', rotulo: 'Mostrar sobrancelhas', tipo: 'toggle' },
+ { chave: 'audio', rotulo: 'Sons no celular', ajuda: 'Bipes do próprio robô', tipo: 'toggle' },
+ { chave: 'volume', rotulo: 'Volume dos sons', tipo: 'slider', min: 0, max: 1, passo: 0.05 },
+ { chave: 'brilho', rotulo: 'Brilho normal', tipo: 'slider', min: 0.01, max: 1, passo: 0.01 },
+ { chave: 'economia', rotulo: 'Reduzir brilho em repouso', tipo: 'toggle' },
+ { chave: 'brilhoRepouso', rotulo: 'Brilho em repouso', tipo: 'slider', min: 0.01, max: 1, passo: 0.01 },
+ { chave: 'repousoTela', rotulo: 'Espera para reduzir brilho', tipo: 'slider', min: 5, max: 300, passo: 5, unidade: ' s' },
+ ],
+ },
+ {
+ titulo: 'Sensores e reações',
+ descricao: 'O que o celular percebe e como reage.',
+ campos: [
+ { chave: 'sensorMic', rotulo: 'Microfone', ajuda: 'Só o nível de som é usado, nunca o áudio', tipo: 'toggle' },
+ { chave: 'ouvir', rotulo: 'Reagir a sons', tipo: 'toggle' },
+ { chave: 'micSono', rotulo: 'Microfone durante o sono', tipo: 'toggle' },
+ { chave: 'sompausa', rotulo: 'Silêncio para dormir', tipo: 'slider', min: 1, max: 120, passo: 1, unidade: ' s' },
+ { chave: 'sensorCamera', rotulo: 'Câmera como sensor', ajuda: 'Detecta movimento e permite o vídeo', tipo: 'toggle' },
+ { chave: 'reagir', rotulo: 'Reagir a movimento', tipo: 'toggle' },
+ { chave: 'cameraSono', rotulo: 'Câmera durante o sono', tipo: 'toggle' },
+ { chave: 'movolhar', rotulo: 'Quanto os olhos acompanham', tipo: 'slider', min: 0, max: 100, passo: 1, unidade: '%' },
+ ],
+ },
+ {
+ titulo: 'Vídeo e rastreamento',
+ descricao: 'Qualidade da transmissão e rastreamento feito neste PC.',
+ campos: [
+ { chave: 'cameraLado', rotulo: 'Câmera', tipo: 'escolha', opcoes: [['user', 'Frontal'], ['environment', 'Traseira']] },
+ {
+ chave: 'videoResolucao',
+ rotulo: 'Resolução',
+ tipo: 'escolha',
+ opcoes: [['auto', 'Automática'], ['max', 'Máxima'], ['480', '480p'], ['720', '720p'], ['1080', '1080p'], ['2160', '4K'], ['4320', '8K']],
+ },
+ {
+ chave: 'videoFPS',
+ rotulo: 'Quadros por segundo',
+ tipo: 'escolha',
+ opcoes: [[0, 'Automático'], [-1, 'Máximo'], [15, '15'], [24, '24'], [30, '30'], [60, '60'], [120, '120'], [240, '240']],
+ },
+ { chave: 'rastrearRosto', rotulo: 'Rastrear rosto', tipo: 'toggle' },
+ { chave: 'rastrearMaos', rotulo: 'Rastrear mãos', tipo: 'toggle' },
+ { chave: 'seguirRastreamento', rotulo: 'Olhos seguem o alvo', tipo: 'toggle' },
+ { chave: 'mostrarRastreamento', rotulo: 'Desenhar rastreamento no vídeo', tipo: 'toggle' },
+ ],
+ },
+ {
+ titulo: 'Sensibilidade (avançado)',
+ descricao: 'Limiares dos sustos e intervalos entre reações.',
+ campos: [
+ { chave: 'somlim', rotulo: 'Nível para perceber som', tipo: 'slider', min: 0, max: 1, passo: 0.01 },
+ { chave: 'somaltolim', rotulo: 'Som mínimo para susto', tipo: 'slider', min: 0, max: 1, passo: 0.01 },
+ { chave: 'somganho', rotulo: 'Aumento súbito para susto', tipo: 'slider', min: 0, max: 1, passo: 0.01 },
+ { chave: 'somcooldown', rotulo: 'Intervalo entre sustos por som', tipo: 'slider', min: 0, max: 120, passo: 1, unidade: ' s' },
+ { chave: 'movlim', rotulo: 'Movimento mínimo', tipo: 'slider', min: 0, max: 1, passo: 0.01 },
+ { chave: 'movforte', rotulo: 'Movimento para susto', tipo: 'slider', min: 0, max: 1, passo: 0.01 },
+ { chave: 'movcooldown', rotulo: 'Intervalo entre sustos por movimento', tipo: 'slider', min: 0, max: 120, passo: 1, unidade: ' s' },
+ ],
+ },
+];
diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts
new file mode 100644
index 0000000..240af49
--- /dev/null
+++ b/web/src/lib/api.ts
@@ -0,0 +1,70 @@
+/** Cliente HTTP único do painel: token, JSON, tempo limite e erro legível. */
+
+const CHAVE_TOKEN = 'carinha_token';
+
+export class ErroApi extends Error {
+ constructor(public status: number, mensagem: string) {
+ super(mensagem);
+ }
+}
+
+export function lerToken(): string {
+ try {
+ return localStorage.getItem(CHAVE_TOKEN) || '';
+ } catch {
+ return '';
+ }
+}
+
+export function gravarToken(token: string) {
+ try {
+ if (token) localStorage.setItem(CHAVE_TOKEN, token);
+ else localStorage.removeItem(CHAVE_TOKEN);
+ } catch {
+ /* navegação privada: vale só nesta aba */
+ }
+}
+
+/** Avisado quando o servidor recusa o token (abre o diálogo de token). */
+let aoNegar: () => void = () => {};
+export function quandoNegado(fn: () => void) {
+ aoNegar = fn;
+}
+
+export async function pedir(caminho: string, dados?: unknown, tempo = 8000): Promise {
+ const cabecalhos: Record = {};
+ const token = lerToken();
+ if (token) cabecalhos.Authorization = 'Bearer ' + token;
+ const opcoes: RequestInit = { cache: 'no-store', headers: cabecalhos, signal: AbortSignal.timeout(tempo) };
+ if (dados !== undefined) {
+ opcoes.method = 'POST';
+ cabecalhos['Content-Type'] = 'application/json';
+ opcoes.body = JSON.stringify(dados);
+ }
+ let resposta: Response;
+ try {
+ resposta = await fetch(caminho, opcoes);
+ } catch (e) {
+ const nome = (e as Error).name;
+ throw new ErroApi(0, nome === 'TimeoutError' ? 'O servidor demorou a responder' : 'Sem conexão com o servidor');
+ }
+ const texto = await resposta.text();
+ let corpo: any = null;
+ try {
+ corpo = texto ? JSON.parse(texto) : null;
+ } catch {
+ corpo = null;
+ }
+ if (!resposta.ok) {
+ if (resposta.status === 401) aoNegar();
+ throw new ErroApi(resposta.status, corpo?.erro || `Erro ${resposta.status}`);
+ }
+ return corpo as T;
+}
+
+export const postar = (caminho: string, dados: unknown = {}) => pedir(caminho, dados);
+
+export function urlEventos(): string {
+ const token = lerToken();
+ return token ? '/eventos?t=' + encodeURIComponent(token) : '/eventos';
+}
diff --git a/web/src/lib/avisos.svelte.ts b/web/src/lib/avisos.svelte.ts
new file mode 100644
index 0000000..802b7ae
--- /dev/null
+++ b/web/src/lib/avisos.svelte.ts
@@ -0,0 +1,41 @@
+/** Toasts no lugar de alert/confirm: mensagem curta, com ação opcional (ex.: Desfazer). */
+
+export type TipoAviso = 'info' | 'ok' | 'erro';
+
+export interface Aviso {
+ id: number;
+ texto: string;
+ tipo: TipoAviso;
+ acao?: { rotulo: string; fazer: () => void };
+}
+
+class Avisos {
+ lista = $state([]);
+ private seq = 0;
+
+ mostrar(texto: string, tipo: TipoAviso = 'info', acao?: Aviso['acao'], ms = tipo === 'erro' ? 6000 : 3500) {
+ const id = ++this.seq;
+ this.lista = [...this.lista, { id, texto, tipo, acao }].slice(-4);
+ setTimeout(() => this.fechar(id), acao ? Math.max(ms, 6000) : ms);
+ return id;
+ }
+ fechar(id: number) {
+ this.lista = this.lista.filter((a) => a.id !== id);
+ }
+ ok = (t: string, acao?: Aviso['acao']) => this.mostrar(t, 'ok', acao);
+ erro = (e: unknown) => this.mostrar(e instanceof Error ? e.message : String(e), 'erro');
+}
+
+export const avisos = new Avisos();
+
+/** Executa uma ação do painel mostrando o erro num toast em vez de engolir. */
+export async function tentar(fn: () => Promise, sucesso?: string): Promise {
+ try {
+ const r = await fn();
+ if (sucesso) avisos.ok(sucesso);
+ return r;
+ } catch (e) {
+ avisos.erro(e);
+ return undefined;
+ }
+}
diff --git a/web/src/lib/comandos.svelte.ts b/web/src/lib/comandos.svelte.ts
new file mode 100644
index 0000000..94cfe93
--- /dev/null
+++ b/web/src/lib/comandos.svelte.ts
@@ -0,0 +1,89 @@
+/** Ações que qualquer tela pode disparar: gestos, rosto, cor, E-Stop. */
+import { postar } from './api';
+import { avisos, tentar } from './avisos.svelte';
+import { estado } from './estado.svelte';
+
+/** Nome amigável e emoji de cada gesto do rosto ('parar' fecha o kiosk: não vira botão). */
+export const GESTOS_INFO: Record = {
+ feliz: { nome: 'Feliz', emoji: '😊' },
+ alegre: { nome: 'Alegre', emoji: '😄' },
+ coracao: { nome: 'Coração', emoji: '❤️' },
+ piscar: { nome: 'Piscar', emoji: '😌' },
+ piscar2: { nome: 'Piscar duplo', emoji: '😑' },
+ piscadinha: { nome: 'Piscadinha', emoji: '😉' },
+ surpreso: { nome: 'Surpreso', emoji: '😮' },
+ triste: { nome: 'Triste', emoji: '😢' },
+ bravo: { nome: 'Bravo', emoji: '😠' },
+ olhar_esq: { nome: 'Olhar à esquerda', emoji: '👈' },
+ olhar_dir: { nome: 'Olhar à direita', emoji: '👉' },
+ olhar_baixo: { nome: 'Olhar para baixo', emoji: '👇' },
+ dormir: { nome: 'Dormir', emoji: '😴' },
+ acordar: { nome: 'Acordar', emoji: '🌅' },
+ aleatorio: { nome: 'Aleatório', emoji: '🎲' },
+};
+
+export const gestosDisponiveis = () => estado.gestos.filter((g) => g in GESTOS_INFO);
+
+const CHAVE_FAVORITOS = 'carinha_favoritos';
+const PADRAO_FAVORITOS = ['feliz', 'coracao', 'piscadinha', 'surpreso', 'triste', 'olhar_esq', 'olhar_dir', 'dormir', 'acordar'];
+
+function lerFavoritos(): string[] {
+ try {
+ const v = JSON.parse(localStorage.getItem(CHAVE_FAVORITOS) || 'null');
+ if (Array.isArray(v) && v.every((x) => typeof x === 'string')) return v.slice(0, 9);
+ } catch {
+ /* sem armazenamento */
+ }
+ return PADRAO_FAVORITOS;
+}
+
+class Favoritos {
+ lista = $state(lerFavoritos());
+ alternar(g: string) {
+ this.lista = this.lista.includes(g) ? this.lista.filter((x) => x !== g) : [...this.lista, g].slice(0, 9);
+ try {
+ localStorage.setItem(CHAVE_FAVORITOS, JSON.stringify(this.lista));
+ } catch {
+ /* ok */
+ }
+ }
+}
+export const favoritos = new Favoritos();
+
+export const gesto = (nome: string) => tentar(() => postar('/comando', { tipo: 'gesto', valor: nome }));
+export const rosto = (nome: string) => tentar(() => postar('/comando', { tipo: 'rosto', valor: nome }));
+export const cor = (rgb: [number, number, number]) => tentar(() => postar('/comando', { tipo: 'cor', valor: rgb }));
+export const recarregarCelular = () =>
+ tentar(async () => {
+ const r = await postar<{ mensagem: string }>('/comando', { tipo: 'recarregar' });
+ avisos.ok(r.mensagem);
+ });
+
+export async function pararTudo() {
+ try {
+ await postar('/parar-tudo');
+ avisos.mostrar('E-Stop acionado: motores desligados e travados.', 'erro');
+ } catch (e) {
+ avisos.erro(e);
+ }
+}
+
+export const rearmar = () => tentar(() => postar('/rearmar'), 'Motores rearmados.');
+
+export function hex(rgb: number[]) {
+ return '#' + rgb.map((v) => v.toString(16).padStart(2, '0')).join('');
+}
+export function rgb(hexCor: string): [number, number, number] {
+ const n = parseInt(hexCor.slice(1), 16);
+ return [(n >> 16) & 255, (n >> 8) & 255, n & 255];
+}
+export const CORES: { nome: string; rgb: [number, number, number] }[] = [
+ { nome: 'Ciano', rgb: [92, 214, 255] },
+ { nome: 'Verde', rgb: [74, 222, 128] },
+ { nome: 'Amarelo', rgb: [251, 191, 36] },
+ { nome: 'Laranja', rgb: [255, 140, 50] },
+ { nome: 'Rosa', rgb: [255, 110, 190] },
+ { nome: 'Roxo', rgb: [167, 139, 250] },
+ { nome: 'Vermelho', rgb: [248, 90, 90] },
+ { nome: 'Branco', rgb: [235, 240, 245] },
+];
diff --git a/web/src/lib/componentes/Avisos.svelte b/web/src/lib/componentes/Avisos.svelte
new file mode 100644
index 0000000..5315028
--- /dev/null
+++ b/web/src/lib/componentes/Avisos.svelte
@@ -0,0 +1,62 @@
+
+
+
+ {#each avisos.lista as a (a.id)}
+
+ {a.texto}
+ {#if a.acao}
+ {
+ a.acao!.fazer();
+ avisos.fechar(a.id);
+ }}>{a.acao.rotulo}
+ {/if}
+ avisos.fechar(a.id)}>
+
+
+
+ {/each}
+
+
+
diff --git a/web/src/lib/componentes/Card.svelte b/web/src/lib/componentes/Card.svelte
new file mode 100644
index 0000000..62f9ce0
--- /dev/null
+++ b/web/src/lib/componentes/Card.svelte
@@ -0,0 +1,51 @@
+
+
+
+ {#if titulo || acoes}
+
+
+ {#if titulo}
{titulo} {/if}
+ {#if descricao}
{descricao}
{/if}
+
+ {#if acoes}{@render acoes()}
{/if}
+
+ {/if}
+ {@render children()}
+
+
+
diff --git a/web/src/lib/componentes/EStop.svelte b/web/src/lib/componentes/EStop.svelte
new file mode 100644
index 0000000..5a7984f
--- /dev/null
+++ b/web/src/lib/componentes/EStop.svelte
@@ -0,0 +1,42 @@
+
+
+{#if estado.estop.ativo}
+
+ PARADO · Rearmar
+
+{:else}
+
+ Parar tudo
+
+{/if}
+
+
diff --git a/web/src/lib/componentes/Gestos.svelte b/web/src/lib/componentes/Gestos.svelte
new file mode 100644
index 0000000..515c0fe
--- /dev/null
+++ b/web/src/lib/componentes/Gestos.svelte
@@ -0,0 +1,101 @@
+
+
+
+ {#each lista as g, i (g)}
+ {@const info = GESTOS_INFO[g]}
+
+ gesto(g)} title={completo ? info.nome : `${info.nome} (tecla ${i + 1})`}>
+ {info.emoji}
+ {info.nome}
+ {#if !completo}{i + 1} {/if}
+
+ {#if completo}
+ favoritos.alternar(g)}
+ >
+
+
+ {/if}
+
+ {/each}
+
+
+
diff --git a/web/src/lib/componentes/MiniRosto.svelte b/web/src/lib/componentes/MiniRosto.svelte
new file mode 100644
index 0000000..b0e81b5
--- /dev/null
+++ b/web/src/lib/componentes/MiniRosto.svelte
@@ -0,0 +1,87 @@
+
+
+
+
+
+ Rosto {estado.celularOnline ? 'ao vivo' : '· celular offline'}
+ {#if aberto} {:else} {/if}
+
+ {#if aberto}
+
+ {/if}
+
+
+
diff --git a/web/src/lib/componentes/Paleta.svelte b/web/src/lib/componentes/Paleta.svelte
new file mode 100644
index 0000000..8e51dc9
--- /dev/null
+++ b/web/src/lib/componentes/Paleta.svelte
@@ -0,0 +1,174 @@
+
+
+{#if paleta.aberta}
+
+
+
e.stopPropagation()}>
+
+
+
+ Esc
+
+
+ {#each itens as item, i (item.id)}
+ {#if i === 0 || itens[i - 1].grupo !== item.grupo}
+ {item.grupo}
+ {/if}
+ (ativo = i)}
+ onclick={() => executar(item)}
+ >
+ {item.titulo}
+ {#if item.dica}{item.dica} {/if}
+
+ {:else}
+ Nada encontrado.
+ {/each}
+
+
+
+{/if}
+
+
diff --git a/web/src/lib/componentes/RostoPrevia.svelte b/web/src/lib/componentes/RostoPrevia.svelte
new file mode 100644
index 0000000..0f9fbf8
--- /dev/null
+++ b/web/src/lib/componentes/RostoPrevia.svelte
@@ -0,0 +1,62 @@
+
+
+
+
+
diff --git a/web/src/lib/componentes/Selo.svelte b/web/src/lib/componentes/Selo.svelte
new file mode 100644
index 0000000..220dc9a
--- /dev/null
+++ b/web/src/lib/componentes/Selo.svelte
@@ -0,0 +1,50 @@
+
+
+ {@render children()}
+
+
diff --git a/web/src/lib/componentes/Slider.svelte b/web/src/lib/componentes/Slider.svelte
new file mode 100644
index 0000000..1c5d7c2
--- /dev/null
+++ b/web/src/lib/componentes/Slider.svelte
@@ -0,0 +1,97 @@
+
+
+
+
+ {rotulo}
+ {mostrado.toFixed(casas)}{unidade}
+
+ {
+ local = Number(e.currentTarget.value);
+ oninput?.(local);
+ }}
+ onchange={(e) => {
+ const v = Number(e.currentTarget.value);
+ local = null;
+ onchange?.(v);
+ }}
+ />
+
+
+
diff --git a/web/src/lib/componentes/Toggle.svelte b/web/src/lib/componentes/Toggle.svelte
new file mode 100644
index 0000000..be0894c
--- /dev/null
+++ b/web/src/lib/componentes/Toggle.svelte
@@ -0,0 +1,80 @@
+
+
+
+
+ {rotulo}
+ {#if ajuda}{ajuda} {/if}
+
+ onchange(e.currentTarget.checked)} />
+
+
+
+
diff --git a/web/src/lib/componentes/Token.svelte b/web/src/lib/componentes/Token.svelte
new file mode 100644
index 0000000..a2255d9
--- /dev/null
+++ b/web/src/lib/componentes/Token.svelte
@@ -0,0 +1,56 @@
+
+
+
+
+
diff --git a/web/src/lib/estado.svelte.ts b/web/src/lib/estado.svelte.ts
new file mode 100644
index 0000000..1f87e77
--- /dev/null
+++ b/web/src/lib/estado.svelte.ts
@@ -0,0 +1,161 @@
+/**
+ * Estado do painel: uma foto inicial (/estado) + um único fluxo SSE (/eventos).
+ * Nenhuma tela faz polling; todas leem daqui.
+ */
+import { ErroApi, pedir, quandoNegado, urlEventos } from './api';
+import type { AppConfig, Conversa, EstadoServidor, IA, Motores, Reacao, Telemetria } from './tipos';
+
+export type Conexao = 'conectando' | 'aberta' | 'caiu' | 'token';
+
+export interface ItemFeed {
+ id: number;
+ quando: number;
+ tipo: string;
+ texto: string;
+}
+
+class Estado {
+ conexao = $state('conectando');
+ versao = $state('');
+ autenticacao = $state(false);
+ estop = $state({ ativo: false, desde: null as number | null });
+ aparencia = $state({ rosto: 'carinha', cor: [92, 214, 255] as [number, number, number] });
+ gestos = $state([]);
+ rostos = $state([]);
+ app = $state(null);
+ appRecebidoEm = $state(0);
+ appConfig = $state(null);
+ motores = $state(null);
+ conversa = $state(null);
+ ia = $state(null);
+ reacao = $state(null);
+ treinamento = $state({ ocupado: false, erro: '' });
+ api = $state(null);
+ feed = $state([]);
+ /** Relógio de 1 s para derivar "online" sem esperar evento novo. */
+ agora = $state(Date.now());
+
+ celularOnline = $derived(!!this.app?.online && this.agora - this.appRecebidoEm < 6000);
+}
+
+export const estado = new Estado();
+
+type Ouvinte = (dados: any) => void;
+const ouvintes = new Map>();
+
+/** Assina um tipo de evento do servidor; devolve a função para cancelar. */
+export function ouvir(tipo: string, fn: Ouvinte): () => void {
+ if (!ouvintes.has(tipo)) ouvintes.set(tipo, new Set());
+ ouvintes.get(tipo)!.add(fn);
+ return () => ouvintes.get(tipo)!.delete(fn);
+}
+
+let seqFeed = 0;
+function noFeed(tipo: string, texto: string) {
+ estado.feed = [{ id: ++seqFeed, quando: Date.now(), tipo, texto }, ...estado.feed].slice(0, 40);
+}
+
+function aplicarFoto(f: EstadoServidor) {
+ estado.versao = f.versao;
+ estado.autenticacao = f.autenticacao;
+ estado.estop = f.estop;
+ estado.aparencia = f.aparencia;
+ estado.gestos = f.gestos;
+ estado.rostos = f.rostos;
+ if (f.app) {
+ estado.app = f.app;
+ estado.appRecebidoEm = Date.now();
+ }
+ if (f.app_config) estado.appConfig = f.app_config;
+ if (f.motores) estado.motores = f.motores;
+ if (f.conversa) estado.conversa = f.conversa;
+ if (f.ia) estado.ia = f.ia;
+ estado.reacao = f.reacao ?? null;
+ if (f.treinamento) estado.treinamento = f.treinamento;
+ if (f.api) estado.api = f.api;
+}
+
+const tratadores: Record void> = {
+ expressao: (d: { ordem: string }) => {
+ if (d.ordem !== 'calma') noFeed('expressao', 'Expressão: ' + d.ordem);
+ },
+ telemetria: (d: Telemetria) => {
+ estado.app = d;
+ estado.appRecebidoEm = Date.now();
+ },
+ app_config: (d: AppConfig) => (estado.appConfig = d),
+ aparencia: (d) => (estado.aparencia = d),
+ motores: (d: Motores) => (estado.motores = d),
+ estop: (d) => {
+ estado.estop = d;
+ noFeed('estop', d.ativo ? 'E-Stop acionado' : 'Motores rearmados');
+ },
+ conversa: (d: Conversa) => {
+ const nova = d.ultima && d.ultima.id !== estado.conversa?.ultima?.id;
+ estado.conversa = d;
+ if (nova) noFeed('conversa', 'Carinha: ' + d.ultima!.texto);
+ },
+ ia: (d: IA) => {
+ const nova = d.ultima && d.ultima.horario !== estado.ia?.ultima?.horario;
+ estado.ia = d;
+ if (nova) noFeed('ia', `IA local: ${d.ultima!.evento} → ${d.ultima!.gesto}`);
+ },
+ reacao: (d: Reacao | null) => {
+ estado.reacao = d;
+ if (d?.receita?.nome) noFeed('reacao', 'Reação: ' + d.receita.nome);
+ },
+ treinamento: (d) => (estado.treinamento = d),
+};
+
+let fonte: EventSource | null = null;
+let primeiraAbertura = true;
+
+export async function carregarFoto() {
+ aplicarFoto(await pedir('/estado'));
+}
+
+function abrirEventos() {
+ fonte?.close();
+ fonte = new EventSource(urlEventos());
+ fonte.addEventListener('pronto', async () => {
+ estado.conexao = 'aberta';
+ // Ao reconectar, ressincroniza: pode ter perdido eventos além do buffer do servidor.
+ if (!primeiraAbertura) carregarFoto().catch(() => {});
+ primeiraAbertura = false;
+ });
+ fonte.onerror = () => {
+ if (estado.conexao !== 'token') estado.conexao = 'caiu';
+ };
+ for (const tipo of ['expressao', 'telemetria', 'app_config', 'aparencia', 'motores', 'estop', 'conversa', 'ia',
+ 'reacao', 'treinamento']) {
+ fonte.addEventListener(tipo, (e) => {
+ const dados = JSON.parse((e as MessageEvent).data);
+ tratadores[tipo]?.(dados);
+ ouvintes.get(tipo)?.forEach((fn) => fn(dados));
+ });
+ }
+}
+
+export async function conectar() {
+ estado.conexao = 'conectando';
+ try {
+ await carregarFoto();
+ } catch (e) {
+ if (e instanceof ErroApi && e.status === 401) {
+ estado.conexao = 'token';
+ return;
+ }
+ estado.conexao = 'caiu';
+ setTimeout(conectar, 3000);
+ return;
+ }
+ abrirEventos();
+}
+
+quandoNegado(() => {
+ estado.conexao = 'token';
+ fonte?.close();
+ fonte = null;
+});
+
+setInterval(() => (estado.agora = Date.now()), 1000);
diff --git a/web/src/lib/paleta.svelte.ts b/web/src/lib/paleta.svelte.ts
new file mode 100644
index 0000000..8dc1244
--- /dev/null
+++ b/web/src/lib/paleta.svelte.ts
@@ -0,0 +1,48 @@
+/** Registro da command palette (Ctrl+K): cada parte do painel contribui com itens. */
+import { cor, CORES, gesto, GESTOS_INFO, gestosDisponiveis, pararTudo, rearmar, recarregarCelular, rosto } from './comandos.svelte';
+import { estado } from './estado.svelte';
+import { nav, ROTAS } from './rotas.svelte';
+
+export interface ItemPaleta {
+ id: string;
+ grupo: string;
+ titulo: string;
+ dica?: string;
+ fazer: () => unknown;
+}
+
+type Fonte = () => ItemPaleta[];
+const fontes = new Set();
+
+export function registrarNaPaleta(fonte: Fonte): () => void {
+ fontes.add(fonte);
+ return () => fontes.delete(fonte);
+}
+
+class Paleta {
+ aberta = $state(false);
+ abrir = () => (this.aberta = true);
+ fechar = () => (this.aberta = false);
+}
+export const paleta = new Paleta();
+
+export function itensDaPaleta(): ItemPaleta[] {
+ return [...fontes].flatMap((f) => f());
+}
+
+// Itens base: navegação, ações gerais, gestos, rostos e cores.
+registrarNaPaleta(() => [
+ ...ROTAS.map((r) => ({ id: 'ir-' + r.id, grupo: 'Ir para', titulo: r.titulo, fazer: () => nav.ir(r.id) })),
+ estado.estop.ativo
+ ? { id: 'rearmar', grupo: 'Ações', titulo: 'Rearmar motores', fazer: rearmar }
+ : { id: 'estop', grupo: 'Ações', titulo: 'Parar tudo (E-Stop)', dica: 'Shift+Espaço', fazer: pararTudo },
+ { id: 'recarregar', grupo: 'Ações', titulo: 'Recarregar tela do celular', fazer: recarregarCelular },
+ ...gestosDisponiveis().map((g) => ({
+ id: 'gesto-' + g,
+ grupo: 'Gestos',
+ titulo: `${GESTOS_INFO[g].emoji} ${GESTOS_INFO[g].nome}`,
+ fazer: () => gesto(g),
+ })),
+ ...estado.rostos.map((r) => ({ id: 'rosto-' + r, grupo: 'Rosto', titulo: 'Rosto ' + r, fazer: () => rosto(r) })),
+ ...CORES.map((c) => ({ id: 'cor-' + c.nome, grupo: 'Cor dos olhos', titulo: c.nome, fazer: () => cor(c.rgb) })),
+]);
diff --git a/web/src/lib/rastreamento.svelte.ts b/web/src/lib/rastreamento.svelte.ts
new file mode 100644
index 0000000..5e2699f
--- /dev/null
+++ b/web/src/lib/rastreamento.svelte.ts
@@ -0,0 +1,251 @@
+/**
+ * Rastreamento de rosto/mãos no navegador do PC (MediaPipe num Worker servido pelo
+ * servidor em /rastreador-worker.js). Porta de painel-rastreamento.js + rastreamento-core.js.
+ */
+import type { AppConfig } from './tipos';
+import type { AlvoRastreio, VideoAoVivo } from './video.svelte';
+
+interface Ponto {
+ x: number;
+ y: number;
+}
+interface Face extends Ponto {
+ w: number;
+ h: number;
+ score: number;
+ pontos?: Ponto[];
+}
+interface Resultado {
+ faces: Face[];
+ hands: Ponto[][];
+ ms: number;
+ timestamp: number;
+}
+
+const valido = (p: Ponto | undefined) => !!p && Number.isFinite(p.x) && Number.isFinite(p.y);
+const limitar = (v: number) => Math.max(0, Math.min(1, v));
+const LIGACOES = [[0, 1], [1, 2], [2, 3], [3, 4], [0, 5], [5, 6], [6, 7], [7, 8], [5, 9], [9, 10], [10, 11], [11, 12],
+ [9, 13], [13, 14], [14, 15], [15, 16], [13, 17], [0, 17], [17, 18], [18, 19], [19, 20]];
+
+export function escolherAlvo(faces: Face[], maos: Ponto[][], anterior: AlvoRastreio | null): AlvoRastreio | null {
+ let candidatos: AlvoRastreio[] = maos
+ .filter((h) => h.length === 21 && h.every(valido))
+ .map((h) => {
+ const palma = [0, 5, 9, 13, 17].map((i) => h[i]);
+ return { tipo: 'mao', x: palma.reduce((s, p) => s + p.x, 0) / 5, y: palma.reduce((s, p) => s + p.y, 0) / 5 };
+ });
+ if (!candidatos.length)
+ candidatos = faces
+ .filter((f) => valido(f) && f.w > 0 && f.h > 0)
+ .sort((a, b) => b.w * b.h - a.w * a.h)
+ .map((f) => ({ tipo: 'rosto', x: f.x + f.w / 2, y: f.y + f.h / 2 }));
+ if (!candidatos.length) return null;
+ if (anterior && anterior.tipo === candidatos[0].tipo)
+ candidatos.sort((a, b) => Math.hypot(a.x - anterior.x, a.y - anterior.y) - Math.hypot(b.x - anterior.x, b.y - anterior.y));
+ const t = candidatos[0];
+ return { ...t, x: limitar(t.x), y: limitar(t.y) };
+}
+
+function encaixe(w: number, h: number, vw: number, vh: number) {
+ const escala = Math.min(w / vw, h / vh);
+ return { x: Math.max(0, (w - vw * escala) / 2), y: Math.max(0, (h - vh * escala) / 2), w: vw * escala, h: vh * escala };
+}
+
+export class Rastreamento {
+ texto = $state('Rastreamento desligado.');
+
+ private worker: Worker | null = null;
+ private pronto = false;
+ private pendente = false;
+ private geracao = 0;
+ private ativo = false;
+ private config: AppConfig = {};
+ private resultado: Resultado | null = null;
+ private alvo: AlvoRastreio | null = null;
+ private alvoEm = 0;
+ private em = 0;
+ private intervalo = 50;
+ private proximo = 0;
+ private ultimoVideo = -1;
+ private tentarEm = 0;
+ private amostraAnterior = 0;
+ private rascunho = document.createElement('canvas');
+ private relogio: number;
+ private ctx: CanvasRenderingContext2D;
+
+ constructor(
+ private video: HTMLVideoElement,
+ private tela: HTMLCanvasElement,
+ private saida: VideoAoVivo,
+ ) {
+ this.ctx = tela.getContext('2d')!;
+ this.relogio = window.setInterval(() => this.passo(), 20);
+ addEventListener('resize', this.desenhar);
+ }
+
+ private limpar() {
+ this.ctx.clearRect(0, 0, this.tela.width, this.tela.height);
+ }
+
+ private parar(texto: string) {
+ if (this.ativo) this.saida.rastrear(null);
+ this.ativo = false;
+ this.geracao++;
+ this.worker?.terminate();
+ this.worker = null;
+ this.pendente = this.pronto = false;
+ this.resultado = this.alvo = null;
+ this.ultimoVideo = -1;
+ this.amostraAnterior = 0;
+ this.limpar();
+ this.texto = texto;
+ }
+
+ private desenhar = () => {
+ const caixa = this.tela.parentElement!.getBoundingClientRect();
+ if (this.tela.width !== Math.round(caixa.width) || this.tela.height !== Math.round(caixa.height)) {
+ this.tela.width = Math.round(caixa.width);
+ this.tela.height = Math.round(caixa.height);
+ }
+ this.limpar();
+ this.tela.hidden = !this.config.mostrarRastreamento;
+ const r = this.resultado;
+ if (this.tela.hidden || !r || performance.now() - this.em > 350 || !this.video.videoWidth) return;
+ const f = encaixe(this.tela.width, this.tela.height, this.video.videoWidth, this.video.videoHeight);
+ const pt = (p: Ponto) => ({ x: f.x + p.x * f.w, y: f.y + p.y * f.h });
+ const c = this.ctx;
+ c.lineWidth = 2;
+ c.font = '13px system-ui';
+ const bola = (p: Ponto, raio = 3) => {
+ c.beginPath();
+ c.arc(p.x, p.y, raio, 0, Math.PI * 2);
+ c.fill();
+ };
+ for (const rosto of r.faces) {
+ const p = pt(rosto);
+ c.strokeStyle = c.fillStyle = '#5cd6ff';
+ c.strokeRect(p.x, p.y, rosto.w * f.w, rosto.h * f.h);
+ c.fillText('Rosto ' + Math.round(rosto.score * 100) + '%', p.x, Math.max(15, p.y - 6));
+ for (const q of rosto.pontos || []) bola(pt(q));
+ }
+ for (const mao of r.hands) {
+ c.strokeStyle = c.fillStyle = '#4ade80';
+ for (const [a, b] of LIGACOES) {
+ const p = pt(mao[a]);
+ const q = pt(mao[b]);
+ c.beginPath();
+ c.moveTo(p.x, p.y);
+ c.lineTo(q.x, q.y);
+ c.stroke();
+ }
+ for (const q of mao) bola(pt(q));
+ }
+ if (this.alvo) {
+ const p = pt(this.alvo);
+ c.strokeStyle = '#fbbf24';
+ c.beginPath();
+ c.arc(p.x, p.y, 12, 0, Math.PI * 2);
+ c.moveTo(p.x - 18, p.y);
+ c.lineTo(p.x + 18, p.y);
+ c.moveTo(p.x, p.y - 18);
+ c.lineTo(p.x, p.y + 18);
+ c.stroke();
+ }
+ };
+
+ private iniciar() {
+ this.ativo = true;
+ const geracao = ++this.geracao;
+ this.worker = new Worker('/rastreador-worker.js');
+ this.texto = 'Carregando rastreadores locais…';
+ this.worker.onerror = (e) => {
+ this.tentarEm = Date.now() + 10000;
+ this.parar('Falha no rastreador: ' + e.message);
+ };
+ this.worker.onmessage = ({ data }) => {
+ if (geracao !== this.geracao) return;
+ this.pendente = false;
+ if (data.tipo === 'erro') {
+ this.tentarEm = Date.now() + 10000;
+ this.parar('Rastreamento indisponível: ' + data.erro);
+ return;
+ }
+ this.pronto = true;
+ if (data.tipo === 'pronto') {
+ this.texto = 'Rastreadores prontos. Aguardando imagem…';
+ return;
+ }
+ const r = data as Resultado;
+ this.resultado = r;
+ this.em = r.timestamp;
+ const anterior = this.alvo;
+ const alvo = escolherAlvo(r.faces, r.hands, anterior);
+ if (alvo) alvo.atividade = !!anterior && alvo.tipo === anterior.tipo && Math.hypot(alvo.x - anterior.x, alvo.y - anterior.y) > 0.008;
+ this.alvo = alvo;
+ this.alvoEm = r.timestamp;
+ this.intervalo = Math.max(50, Math.min(200, r.ms * 1.2));
+ const hz = this.amostraAnterior ? Math.round(1000 / (r.timestamp - this.amostraAnterior)) : 0;
+ this.amostraAnterior = r.timestamp;
+ this.texto = `${r.faces.length} rosto(s) · ${r.hands.length} mão(s) · ${hz} análises/s · ${Math.round(r.ms)} ms${
+ alvo ? ' · alvo: ' + (alvo.tipo === 'mao' ? 'mão' : 'rosto') : ' · procurando…'
+ }`;
+ if (this.config.seguirRastreamento) this.saida.rastrear(alvo);
+ this.desenhar();
+ };
+ this.pendente = true;
+ this.worker.postMessage({ config: { rosto: this.config.rastrearRosto, maos: this.config.rastrearMaos } });
+ }
+
+ private async passo() {
+ const agora = performance.now();
+ if (this.alvo && agora - this.alvoEm > 350) {
+ this.alvo = null;
+ if (this.config.seguirRastreamento) this.saida.rastrear(null);
+ }
+ if (this.resultado && agora - this.em > 350) {
+ this.resultado = null;
+ this.limpar();
+ }
+ const v = this.video;
+ if (!this.ativo || !this.pronto || this.pendente || agora < this.proximo || v.readyState < 2 || v.currentTime === this.ultimoVideo)
+ return;
+ this.pendente = true;
+ this.proximo = agora + this.intervalo;
+ this.ultimoVideo = v.currentTime;
+ const geracao = this.geracao;
+ try {
+ const escala = Math.min(1, 640 / v.videoWidth);
+ this.rascunho.width = Math.round(v.videoWidth * escala);
+ this.rascunho.height = Math.round(v.videoHeight * escala);
+ this.rascunho.getContext('2d')!.drawImage(v, 0, 0, this.rascunho.width, this.rascunho.height);
+ const bitmap = await createImageBitmap(this.rascunho);
+ if (geracao !== this.geracao) return bitmap.close();
+ this.worker!.postMessage(
+ { bitmap, timestamp: agora, config: { rosto: this.config.rastrearRosto, maos: this.config.rastrearMaos } },
+ [bitmap],
+ );
+ } catch (e) {
+ if (geracao === this.geracao) this.parar('Falha ao analisar vídeo: ' + (e as Error).message);
+ }
+ }
+
+ atualizar(config: AppConfig | null, permitido: boolean) {
+ if (!config) return;
+ const mudou = this.config.rastrearRosto !== config.rastrearRosto || this.config.rastrearMaos !== config.rastrearMaos;
+ if (this.config.seguirRastreamento && !config.seguirRastreamento) this.saida.rastrear(null);
+ this.config = config;
+ if (!permitido || (!config.rastrearRosto && !config.rastrearMaos)) {
+ this.parar(!permitido ? 'Ligue a transmissão para rastrear no PC.' : 'Rastreamento desligado.');
+ return;
+ }
+ if (mudou && this.ativo) this.parar('Atualizando rastreadores…');
+ if (!this.ativo && Date.now() >= this.tentarEm) this.iniciar();
+ this.desenhar();
+ }
+
+ destruir() {
+ clearInterval(this.relogio);
+ removeEventListener('resize', this.desenhar);
+ this.parar('Painel fechado.');
+ }
+}
diff --git a/web/src/lib/rotas.svelte.ts b/web/src/lib/rotas.svelte.ts
new file mode 100644
index 0000000..531c6a5
--- /dev/null
+++ b/web/src/lib/rotas.svelte.ts
@@ -0,0 +1,26 @@
+/** Rotas por hash (#/conversa): o servidor só precisa entregar index.html em "/". */
+
+export type Rota = 'ao-vivo' | 'conversa' | 'reacoes' | 'movimento' | 'ajustes';
+
+export const ROTAS: { id: Rota; titulo: string; atalho: string }[] = [
+ { id: 'ao-vivo', titulo: 'Ao vivo', atalho: 'g v' },
+ { id: 'conversa', titulo: 'Conversa', atalho: 'g c' },
+ { id: 'reacoes', titulo: 'Reações', atalho: 'g r' },
+ { id: 'movimento', titulo: 'Movimento', atalho: 'g m' },
+ { id: 'ajustes', titulo: 'Ajustes', atalho: 'g a' },
+];
+
+function lerHash(): Rota {
+ const id = location.hash.replace(/^#\/?/, '').split('?')[0] as Rota;
+ return ROTAS.some((r) => r.id === id) ? id : 'ao-vivo';
+}
+
+class Navegacao {
+ atual = $state(lerHash());
+ ir(rota: Rota) {
+ location.hash = '/' + rota;
+ }
+}
+
+export const nav = new Navegacao();
+addEventListener('hashchange', () => (nav.atual = lerHash()));
diff --git a/web/src/lib/tema.css b/web/src/lib/tema.css
new file mode 100644
index 0000000..0a0eac6
--- /dev/null
+++ b/web/src/lib/tema.css
@@ -0,0 +1,128 @@
+/* Design system do painel Carinha: tokens + poucos utilitários. Escuro por padrão, claro opcional. */
+:root {
+ color-scheme: dark;
+ --fundo: #0b1017;
+ --fundo-2: #0f1620;
+ --superficie: #141c27;
+ --superficie-2: #1a2431;
+ --superficie-3: #223041;
+ --borda: #263447;
+ --borda-forte: #34475f;
+ --texto: #e8eef6;
+ --texto-2: #a9b8c9;
+ --texto-3: #8396ab; /* AA sobre --superficie */
+ --destaque: #5cd6ff;
+ --destaque-forte: #2fbff0;
+ --destaque-texto: #04202b;
+ --destaque-suave: rgb(92 214 255 / 0.12);
+ --ok: #4ade80;
+ --ok-suave: rgb(74 222 128 / 0.12);
+ --aviso: #fbbf24;
+ --aviso-suave: rgb(251 191 36 / 0.12);
+ --perigo: #f87171;
+ --perigo-forte: #ef4444;
+ --perigo-suave: rgb(248 113 113 / 0.14);
+ --vidro: rgb(15 22 32 / 0.72);
+
+ --fonte: 'Outfit Variable', 'Segoe UI', system-ui, sans-serif;
+ --fonte-mono: 'JetBrains Mono Variable', ui-monospace, Consolas, monospace;
+ --t-xs: 0.75rem; --t-sm: 0.875rem; --t-md: 1rem; --t-lg: 1.25rem; --t-xl: 1.6rem;
+
+ --e-1: 4px; --e-2: 8px; --e-3: 12px; --e-4: 16px; --e-5: 24px; --e-6: 32px;
+ --raio-sm: 8px; --raio: 12px; --raio-lg: 18px; --raio-pill: 999px;
+ --sombra: 0 1px 2px rgb(0 0 0 / 0.3), 0 8px 24px rgb(0 0 0 / 0.25);
+ --sombra-flutuante: 0 12px 40px rgb(0 0 0 / 0.45);
+ --transicao: 160ms cubic-bezier(0.2, 0.8, 0.2, 1);
+
+ --nav-largura: 232px;
+ --topo-altura: 60px;
+}
+
+:root[data-theme='light'] {
+ color-scheme: light;
+ --fundo: #f4f6f9;
+ --fundo-2: #eef2f6;
+ --superficie: #ffffff;
+ --superficie-2: #f3f6fa;
+ --superficie-3: #e7edf4;
+ --borda: #dbe3ec;
+ --borda-forte: #c3cfdc;
+ --texto: #0f1722;
+ --texto-2: #3d4d60;
+ --texto-3: #5a6b7f;
+ --destaque: #0284c7;
+ --destaque-forte: #0369a1;
+ --destaque-texto: #ffffff;
+ --destaque-suave: rgb(2 132 199 / 0.1);
+ --ok: #15803d; --ok-suave: rgb(21 128 61 / 0.1);
+ --aviso: #b45309; --aviso-suave: rgb(180 83 9 / 0.1);
+ --perigo: #dc2626; --perigo-forte: #b91c1c; --perigo-suave: rgb(220 38 38 / 0.1);
+ --vidro: rgb(255 255 255 / 0.78);
+ --sombra: 0 1px 2px rgb(15 23 34 / 0.06), 0 6px 20px rgb(15 23 34 / 0.08);
+ --sombra-flutuante: 0 12px 40px rgb(15 23 34 / 0.18);
+}
+
+*, *::before, *::after { box-sizing: border-box; }
+html, body { margin: 0; height: 100%; }
+body {
+ background: var(--fundo);
+ color: var(--texto);
+ font: 400 var(--t-md)/1.5 var(--fonte);
+ -webkit-font-smoothing: antialiased;
+}
+h1, h2, h3, h4 { margin: 0; line-height: 1.2; font-weight: 600; }
+h1 { font-size: var(--t-xl); }
+h2 { font-size: var(--t-lg); }
+h3 { font-size: var(--t-md); }
+p { margin: 0; }
+a { color: var(--destaque); }
+img, video, canvas, iframe { max-width: 100%; }
+[hidden] { display: none !important; }
+:focus-visible { outline: 2px solid var(--destaque); outline-offset: 2px; }
+
+/* ---------- botões ---------- */
+.btn {
+ display: inline-flex; align-items: center; justify-content: center; gap: var(--e-2);
+ min-height: 40px; padding: 0 var(--e-4);
+ border: 1px solid var(--borda-forte); border-radius: var(--raio-sm);
+ background: var(--superficie-2); color: var(--texto);
+ font: 500 var(--t-sm) var(--fonte); cursor: pointer; white-space: nowrap;
+ transition: background var(--transicao), border-color var(--transicao), transform var(--transicao);
+ touch-action: manipulation; user-select: none;
+}
+.btn:hover:not(:disabled) { background: var(--superficie-3); }
+.btn:active:not(:disabled) { transform: translateY(1px); }
+.btn:disabled { opacity: 0.5; cursor: not-allowed; }
+.btn.primario { background: var(--destaque); border-color: var(--destaque); color: var(--destaque-texto); }
+.btn.primario:hover:not(:disabled) { background: var(--destaque-forte); }
+.btn.perigo { background: var(--perigo-forte); border-color: var(--perigo-forte); color: #fff; }
+.btn.perigo:hover:not(:disabled) { background: var(--perigo); }
+.btn.fantasma { background: transparent; border-color: transparent; }
+.btn.fantasma:hover:not(:disabled) { background: var(--superficie-2); }
+.btn.icone { padding: 0; width: 40px; }
+.btn.pequeno { min-height: 32px; padding: 0 var(--e-3); font-size: var(--t-xs); }
+.btn.pequeno.icone { width: 32px; }
+
+/* ---------- formulários ---------- */
+.campo { display: grid; gap: var(--e-1); }
+.campo > span, .rotulo { font-size: var(--t-sm); color: var(--texto-2); }
+input[type='text'], input[type='password'], input[type='search'], input[type='number'], select, textarea {
+ width: 100%; min-height: 40px; padding: var(--e-2) var(--e-3);
+ border: 1px solid var(--borda-forte); border-radius: var(--raio-sm);
+ background: var(--fundo-2); color: var(--texto); font: inherit; font-size: var(--t-sm);
+}
+textarea { resize: vertical; min-height: 80px; }
+input::placeholder, textarea::placeholder { color: var(--texto-3); }
+
+/* ---------- utilitários ---------- */
+.grade { display: grid; gap: var(--e-4); }
+.linha { display: flex; gap: var(--e-2); align-items: center; flex-wrap: wrap; }
+.entre { justify-content: space-between; }
+.mono { font-family: var(--fonte-mono); font-size: 0.92em; }
+.fraco { color: var(--texto-3); }
+.pequeno-texto { font-size: var(--t-sm); }
+.sr-only { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; }
+
+@media (prefers-reduced-motion: reduce) {
+ *, *::before, *::after { transition-duration: 0ms !important; animation-duration: 0ms !important; }
+}
diff --git a/web/src/lib/tipos.ts b/web/src/lib/tipos.ts
new file mode 100644
index 0000000..ec46684
--- /dev/null
+++ b/web/src/lib/tipos.ts
@@ -0,0 +1,81 @@
+/** Formato do que o servidor manda em /estado e nos eventos SSE. */
+
+export type AppConfig = Record;
+
+export interface Telemetria {
+ online: boolean;
+ idade: number | null;
+ celular_ip: string | null;
+ servidor_ip: string;
+ recarga: { id: string | null; confirmada: boolean };
+ estado: {
+ rosto?: string;
+ dormindo?: boolean;
+ microfone?: number;
+ microfone_ativo?: boolean;
+ movimento?: number;
+ camera_ativa?: boolean;
+ bateria?: { nivel?: number; carregando?: boolean } | number | null;
+ brilho?: number;
+ video?: { qualidade?: string } | null;
+ [k: string]: unknown;
+ };
+ preview: boolean;
+}
+
+export interface Motores {
+ disponivel: boolean;
+ erro: string;
+ ativos: number[];
+ dono: 'braco' | 'diagnostico' | null;
+ travado: boolean;
+}
+
+export interface Mensagem {
+ role: 'user' | 'assistant';
+ content: string;
+}
+
+export interface Conversa {
+ provedor: 'local' | 'gemini';
+ modelo: string;
+ gemini_configurado: boolean;
+ ocupado: boolean;
+ ultima: { id: number; texto: string; gesto: string; horario: number; segundos: number; fonte?: string } | null;
+ erro: string;
+ historico: Mensagem[];
+ lembrancas: { id: number; texto: string }[];
+}
+
+export interface IA {
+ ativo: boolean;
+ ocupado: boolean;
+ modelo: string;
+ executor: string;
+ ultima: { evento: string; gesto: string; segundos: number; horario: number } | null;
+ erro: string;
+}
+
+export interface Reacao {
+ id: number;
+ horario: number;
+ receita: any;
+}
+
+export interface EstadoServidor {
+ versao: string;
+ seq: number;
+ autenticacao: boolean;
+ estop: { ativo: boolean; desde: number | null };
+ aparencia: { rosto: string; cor: [number, number, number] };
+ gestos: string[];
+ rostos: string[];
+ app?: Telemetria;
+ app_config?: AppConfig;
+ motores?: Motores;
+ conversa?: Conversa;
+ ia?: IA;
+ reacao?: Reacao | null;
+ treinamento?: { ocupado: boolean; erro: string };
+ api?: { provedor: string; modelo: string; configurada: boolean; ultimo_teste: unknown };
+}
diff --git a/web/src/lib/video.svelte.ts b/web/src/lib/video.svelte.ts
new file mode 100644
index 0000000..53c920a
--- /dev/null
+++ b/web/src/lib/video.svelte.ts
@@ -0,0 +1,236 @@
+/**
+ * Vídeo ao vivo do celular por WebRTC P2P. A sinalização passa pelo servidor (/rtc/*);
+ * o vídeo vai direto do celular para este navegador. Porta de painel-video.js.
+ * Só a tela Ao vivo cria uma instância: assim não há disputa pela única sessão.
+ */
+import { lerToken, pedir } from './api';
+import type { AppConfig, Telemetria } from './tipos';
+
+export interface AlvoRastreio {
+ tipo: 'mao' | 'rosto';
+ x: number;
+ y: number;
+ atividade?: boolean;
+}
+
+export class VideoAoVivo {
+ mensagem = $state('Transmissão desligada.');
+ exibindo = $state(false);
+ qualidade = $state('');
+
+ private pc: RTCPeerConnection | null = null;
+ private id: string | null = null;
+ private desde = 0;
+ private ice: RTCIceCandidateInit[] = [];
+ private querido = false;
+ private abrindo = false;
+ private consultando = false;
+ private geracao = 0;
+ private tentarEm = 0;
+ private ultimoStats: any = null;
+ private outroPainel = false;
+ private canal: RTCDataChannel | null = null;
+ private seqRastreio = 0;
+ private qualidadeEfetiva = '';
+ private config: AppConfig = {};
+ private relogios: number[] = [];
+
+ constructor(private video: HTMLVideoElement) {
+ video.onloadeddata = video.onplaying = () => this.mostrar();
+ this.relogios.push(
+ window.setInterval(() => {
+ this.iniciar();
+ this.consultar();
+ }, 400),
+ window.setInterval(() => this.estatisticas(), 1000),
+ );
+ addEventListener('pagehide', this.aoSair);
+ }
+
+ private mostrar() {
+ this.exibindo = true;
+ }
+
+ private texto(t: string) {
+ this.mensagem = t;
+ this.exibindo = false;
+ }
+
+ private parar(fechar = true) {
+ this.geracao++;
+ const antiga = this.id;
+ this.id = null;
+ this.pc?.close();
+ this.pc = null;
+ this.canal = null;
+ this.ice = [];
+ this.desde = 0;
+ this.ultimoStats = null;
+ this.video.pause();
+ this.video.srcObject = null;
+ this.exibindo = false;
+ if (antiga && fechar) pedir('/rtc/session', { acao: 'fechar', id: antiga }).catch(() => {});
+ }
+
+ private enviar(mensagem: unknown, sessao?: string) {
+ return pedir('/rtc/send', { id: sessao || this.id, de: 'pc', mensagem }, 3500);
+ }
+
+ private async iniciar() {
+ if (!this.querido || this.pc || this.abrindo || this.outroPainel || Date.now() < this.tentarEm) return;
+ this.abrindo = true;
+ const geracao = this.geracao;
+ this.texto('Conectando vídeo ao vivo… O rosto continua no celular.');
+ try {
+ const sessao = await pedir<{ id: string }>('/rtc/session', { acao: 'abrir' }, 3500);
+ if (geracao !== this.geracao || !this.querido) {
+ pedir('/rtc/session', { acao: 'fechar', id: sessao.id }).catch(() => {});
+ return;
+ }
+ this.id = sessao.id;
+ const id = sessao.id;
+ const pc = new RTCPeerConnection({ iceServers: [] });
+ this.pc = pc;
+ this.canal = pc.createDataChannel('rastreamento', { ordered: false, maxRetransmits: 0 });
+ pc.addTransceiver('video', { direction: 'recvonly' });
+ pc.onicecandidate = (e) => {
+ if (this.pc === pc && e.candidate) this.enviar({ tipo: 'candidate', candidate: e.candidate.toJSON() }, id).catch(() => {});
+ };
+ pc.ontrack = (e) => {
+ if (this.pc !== pc) return;
+ this.video.srcObject = e.streams[0] || new MediaStream([e.track]);
+ this.video.play().catch((err) => this.texto('Clique no vídeo para permitir a reprodução: ' + err.message));
+ };
+ pc.onconnectionstatechange = () => {
+ if (this.pc === pc && pc.connectionState === 'failed') {
+ this.parar();
+ this.tentarEm = Date.now() + 2000;
+ this.texto('Reconectando o vídeo…');
+ }
+ };
+ await pc.setLocalDescription(await pc.createOffer());
+ if (this.pc !== pc) return;
+ await this.enviar({ tipo: 'offer', sdp: pc.localDescription!.sdp }, id);
+ } catch (e) {
+ this.parar();
+ this.tentarEm = Date.now() + 2500;
+ this.texto('Falha ao iniciar vídeo: ' + (e as Error).message);
+ } finally {
+ this.abrindo = false;
+ }
+ }
+
+ private async consultar() {
+ if (!this.id || !this.pc || this.consultando) return;
+ this.consultando = true;
+ const pc = this.pc;
+ const sid = this.id;
+ try {
+ const pacote = await pedir<{ id: string | null; mensagens: { seq: number; mensagem: any }[] }>(
+ `/rtc/poll?lado=pc&desde=${this.desde}&id=${sid}`,
+ undefined,
+ 3500,
+ );
+ if (this.pc !== pc) return;
+ if (pacote.id !== sid) {
+ this.outroPainel = !!pacote.id;
+ this.parar(false);
+ this.texto(this.outroPainel ? 'Vídeo aberto em outro painel. Recarregue para assumir.' : 'Aguardando reconexão do vídeo…');
+ return;
+ }
+ for (const { seq, mensagem: m } of pacote.mensagens) {
+ if (m.tipo === 'answer') {
+ await pc.setRemoteDescription({ type: 'answer', sdp: m.sdp });
+ for (const c of this.ice) await pc.addIceCandidate(c);
+ this.ice = [];
+ } else if (m.tipo === 'candidate') {
+ if (pc.remoteDescription) await pc.addIceCandidate(m.candidate);
+ else this.ice.push(m.candidate);
+ } else if (m.tipo === 'erro') {
+ this.parar();
+ this.tentarEm = Date.now() + 3500;
+ this.texto('Câmera no celular: ' + m.erro);
+ return;
+ }
+ this.desde = Math.max(this.desde, seq);
+ }
+ } catch {
+ if (this.pc === pc) {
+ this.parar();
+ this.tentarEm = Date.now() + 2500;
+ this.texto('Sinalização interrompida. Reconectando…');
+ }
+ } finally {
+ this.consultando = false;
+ }
+ }
+
+ private async estatisticas() {
+ const pc = this.pc;
+ if (!pc || pc.connectionState !== 'connected') return;
+ try {
+ const relatorio = await pc.getStats();
+ relatorio.forEach((s: any) => {
+ if (s.type !== 'inbound-rtp' || (s.kind !== 'video' && s.mediaType !== 'video')) return;
+ const u = this.ultimoStats;
+ const fps = u ? ((s.framesDecoded - u.framesDecoded) * 1000) / (s.timestamp - u.timestamp) : s.framesPerSecond || 0;
+ const kbps = u ? ((s.bytesReceived - u.bytesReceived) * 8) / (s.timestamp - u.timestamp) : 0;
+ this.ultimoStats = s;
+ const auto =
+ this.qualidadeEfetiva === 'auto' || (!this.qualidadeEfetiva && this.config.videoResolucao === 'auto' && !this.config.videoFPS);
+ this.qualidade = `${auto ? 'Automática' : 'Manual'} · ${s.frameWidth || this.video.videoWidth}×${s.frameHeight || this.video.videoHeight} · ${Math.round(fps)} fps · ${Math.round(kbps)} kb/s`;
+ if (fps > 0) this.mostrar();
+ });
+ } catch {
+ /* conexão em transição */
+ }
+ }
+
+ rastrear(alvo: AlvoRastreio | null) {
+ if (this.canal?.readyState === 'open' && this.canal.bufferedAmount < 4096)
+ this.canal.send(JSON.stringify({ tipo: 'alvo', seq: ++this.seqRastreio, alvo }));
+ }
+
+ atualizar(status: Telemetria | null, config: AppConfig | null, online: boolean) {
+ if (!config) return;
+ this.config = config;
+ this.qualidadeEfetiva = (status?.estado?.video as any)?.qualidade || '';
+ const dormindo = !!status?.estado?.dormindo;
+ const liberada = config.sensorCamera !== false && (!dormindo || config.cameraSono === true);
+ const quer = !!config.preview && online && liberada;
+ if (!quer) {
+ if (this.querido || this.pc) this.parar();
+ this.querido = false;
+ this.texto(
+ !liberada
+ ? 'Câmera desativada pelos ajustes de sensores.'
+ : config.preview
+ ? 'Celular sem conexão. Abra o app Carinha.'
+ : 'Transmissão desligada. O rosto e os sensores continuam funcionando.',
+ );
+ } else {
+ this.querido = true;
+ this.iniciar();
+ }
+ }
+
+ private aoSair = () => {
+ if (this.id) {
+ const t = lerToken();
+ navigator.sendBeacon(
+ '/rtc/session' + (t ? '?t=' + encodeURIComponent(t) : ''),
+ new Blob([JSON.stringify({ acao: 'fechar', id: this.id })], { type: 'application/json' }),
+ );
+ }
+ this.querido = false;
+ this.parar(false);
+ };
+
+ destruir() {
+ this.relogios.forEach(clearInterval);
+ removeEventListener('pagehide', this.aoSair);
+ this.querido = false;
+ this.outroPainel = false;
+ this.parar();
+ }
+}
diff --git a/web/src/main.ts b/web/src/main.ts
new file mode 100644
index 0000000..55d761c
--- /dev/null
+++ b/web/src/main.ts
@@ -0,0 +1,7 @@
+import '@fontsource-variable/outfit';
+import '@fontsource-variable/jetbrains-mono';
+import './lib/tema.css';
+import { mount } from 'svelte';
+import App from './App.svelte';
+
+mount(App, { target: document.getElementById('app')! });
diff --git a/web/src/telas/Ajustes.svelte b/web/src/telas/Ajustes.svelte
new file mode 100644
index 0000000..e391ab3
--- /dev/null
+++ b/web/src/telas/Ajustes.svelte
@@ -0,0 +1,341 @@
+
+
+
+
+ {#snippet acoes()}
+
+
+
+
+ {#if confirmarPadrao}
+ Restaurar todos?
+ {
+ confirmarPadrao = false;
+ await tentar(restaurarPadrao);
+ }}>Sim, restaurar
+ (confirmarPadrao = false)}>Cancelar
+ {:else}
+ (confirmarPadrao = true)} disabled={!estado.appConfig}>
+ Restaurar padrão
+
+ {/if}
+ {/snippet}
+
+ {#if !estado.appConfig}
+ Carregando ajustes…
+ {:else}
+
+ {#each grupos as g (g.titulo)}
+
+
+ {g.titulo}
+ {g.descricao}
+
+ {#each g.campos as c (c.chave)}
+ {#if c.tipo === 'toggle'}
+ salvarApp({ [c.chave]: v })} />
+ {:else if c.tipo === 'slider'}
+ salvarApp({ [c.chave]: v })}
+ />
+ {:else}
+
+ {c.rotulo}
+ {
+ const v = e.currentTarget.value;
+ salvarApp({ [c.chave]: typeof c.opcoes![0][0] === 'number' ? Number(v) : v });
+ }}
+ >
+ {#each c.opcoes! as [v, t] (v)}{t} {/each}
+
+
+ {/if}
+ {/each}
+
+ {:else}
+
Nenhum ajuste com “{busca}”.
+ {/each}
+
+ {/if}
+
+
+
+
+
+
Motor da conversa
+
+ trocarProvedor('local')}>
+ Qwen local
+
+ trocarProvedor('gemini')}
+ >
+ Gemini
+
+
+
Local roda no celular/placa, sem custo. Gemini responde melhor e cria reações.
+
+
+
+
+
+
+
+ {#if estado.autenticacao}
+ Servidor exige token
+ {:else}
+ Servidor aberto na rede
+ {/if}
+
+
+ {#if estado.autenticacao}
+ Só quem tem o token controla o robô. Ele fica guardado apenas neste navegador.
+ {:else}
+ Qualquer aparelho da rede local pode controlar o robô. Defina CARINHA_TOKEN na placa
+ (veja docs/SEGURANCA.md) depois de atualizar o app do celular.
+ {/if}
+
+ {#if tokenLocal}
+ Esquecer token neste navegador
+ {/if}
+
+
+
+
+
Versão do servidor {estado.versao || '—'}
+
Celular {estado.app?.celular_ip ?? 'offline'}
+
Servidor {location.host}
+
+
+
+
+
+
+
diff --git a/web/src/telas/AoVivo.svelte b/web/src/telas/AoVivo.svelte
new file mode 100644
index 0000000..c6f615c
--- /dev/null
+++ b/web/src/telas/AoVivo.svelte
@@ -0,0 +1,388 @@
+
+
+
+
+
+
+
+
+ {#if !video?.exibindo}
+
+
+
{video?.mensagem ?? 'Carregando…'}
+
+ {/if}
+
+ Câmera
+ {#if video?.qualidade && video.exibindo}{video.qualidade} {/if}
+
+ {#if video?.exibindo}
+ Foto
+ {/if}
+
+
+
+
+
+ Rosto {estado.celularOnline ? 'no celular' : '· prévia'}
+
+
+
+
+
+
+
salvarApp({ preview: v })}
+ />
+ {#if rastreio && estado.appConfig?.preview}
+ {rastreio.texto}
+ {/if}
+
+
+
+
+
+
+
+
+
+
+
Rosto
+
+ {#each estado.rostos as r (r)}
+ rosto(r)}>{r}
+ {/each}
+
+
+
+
Cor dos olhos
+
+ {#each CORES as c (c.nome)}
+ cor(c.rgb)}
+ >
+ {/each}
+
+ cor(rgb(e.currentTarget.value))} />
+ Escolher outra cor
+
+
+
+
+
+
+ {#snippet acoes()}
+
+ Recarregar tela
+
+ {/snippet}
+ {#if estado.celularOnline}
+
+
+
Bateria
+ {bateria !== null ? bateria + '%' : '—'}
+
+
+
Estado
+ {sensores.dormindo ? 'Dormindo' : 'Acordado'}
+
+
+
Som
+
+ {#if sensores.microfone_ativo}
+
+ {:else}desligado {/if}
+
+
+
+
Movimento
+
+ {#if sensores.camera_ativa}
+
+ {:else}desligado {/if}
+
+
+
+ {estado.app?.celular_ip ?? ''}
+ {:else}
+
+ O celular não está enviando sinais. Abra o app Carinha no aparelho e confira se ele aponta para este servidor.
+
+ {/if}
+
+
+
+
+ {#if estado.ia?.ultima}
+
+ Último: {estado.ia.ultima.evento} → {estado.ia.ultima.gesto}
+ ({estado.ia.ultima.segundos}s)
+
+ {/if}
+ {#if estado.ia?.erro}{estado.ia.erro}
{/if}
+
+
+
+ {#if estado.feed.length}
+
+ {#each estado.feed as item (item.id)}
+ {hora(item.quando)} {item.texto}
+ {/each}
+
+ {:else}
+ Gestos, respostas e reações aparecem aqui assim que acontecerem.
+ {/if}
+
+
+
+
+
diff --git a/web/src/telas/Conversa.svelte b/web/src/telas/Conversa.svelte
new file mode 100644
index 0000000..b3fecfb
--- /dev/null
+++ b/web/src/telas/Conversa.svelte
@@ -0,0 +1,7 @@
+
+
+
+ Enquanto isso, use a tela antiga: abrir Conversa (versão anterior) .
+
diff --git a/web/src/telas/Movimento.svelte b/web/src/telas/Movimento.svelte
new file mode 100644
index 0000000..0c5902e
--- /dev/null
+++ b/web/src/telas/Movimento.svelte
@@ -0,0 +1,7 @@
+
+
+
+ Enquanto isso, use a tela antiga: abrir Movimento (versão anterior) .
+
diff --git a/web/src/telas/Reacoes.svelte b/web/src/telas/Reacoes.svelte
new file mode 100644
index 0000000..8dcd849
--- /dev/null
+++ b/web/src/telas/Reacoes.svelte
@@ -0,0 +1,7 @@
+
+
+
+ Enquanto isso, use a tela antiga: abrir Reações (versão anterior) .
+
diff --git a/web/svelte.config.js b/web/svelte.config.js
new file mode 100644
index 0000000..0378127
--- /dev/null
+++ b/web/svelte.config.js
@@ -0,0 +1,2 @@
+import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
+export default { preprocess: vitePreprocess() };
diff --git a/web/tsconfig.json b/web/tsconfig.json
new file mode 100644
index 0000000..d37e848
--- /dev/null
+++ b/web/tsconfig.json
@@ -0,0 +1,15 @@
+{
+ "extends": "@tsconfig/svelte/tsconfig.json",
+ "compilerOptions": {
+ "target": "ES2022",
+ "module": "ESNext",
+ "moduleResolution": "bundler",
+ "strict": true,
+ "noEmit": true,
+ "verbatimModuleSyntax": true,
+ "isolatedModules": true,
+ "skipLibCheck": true,
+ "types": ["vite/client", "node"]
+ },
+ "include": ["src/**/*.ts", "src/**/*.svelte", "vite.config.ts"]
+}
diff --git a/web/vite.config.ts b/web/vite.config.ts
new file mode 100644
index 0000000..e268fdf
--- /dev/null
+++ b/web/vite.config.ts
@@ -0,0 +1,16 @@
+import { defineConfig } from 'vite';
+import { svelte } from '@sveltejs/vite-plugin-svelte';
+
+// Em desenvolvimento, a API vem do servidor Python (local ou a placa):
+// CARINHA_SERVIDOR=http://192.168.1.35:8100 npm run dev
+const alvo = process.env.CARINHA_SERVIDOR || 'http://127.0.0.1:8100';
+const api = ['/eventos', '/estado', '/comando', '/parar-tudo', '/rearmar', '/fila', '/config', '/faces', '/gestos',
+ '/app', '/rtc', '/camera', '/conversa', '/treinamento', '/braco', '/servos', '/ia', '/api', '/gesto', '/face',
+ '/carinha.html', '/reacoes-render.js', '/rastreador-worker.js', '/vendor', '/audio-robot.js', '/qualidade-auto.js'];
+
+export default defineConfig({
+ plugins: [svelte()],
+ base: '/',
+ build: { outDir: 'dist', emptyOutDir: true, target: 'es2022' },
+ server: { proxy: Object.fromEntries(api.map((p) => [p, { target: alvo, changeOrigin: false }])) },
+});