feat(web): cria painel Svelte com shell, Ao vivo e Ajustes

- web/ com Vite + Svelte 5 + TypeScript; servidor entrega web/dist em "/"
  (telas antigas seguem em /dashboard, /painel, /braco...)
- design system em tokens CSS, tema escuro e claro, fontes locais
- estado unico: foto de /estado + um EventSource em /eventos, sem polling
- shell com navegacao lateral/inferior, selos de status reais e E-Stop fixo
- command palette (Ctrl+K), gestos favoritos com atalhos 1-9, g+letra navega
- Ao vivo: camera WebRTC e rastreamento portados para TS, rosto, aparencia,
  sensores do celular, IA local e feed de acontecimentos
- Ajustes: campos do celular agrupados com busca, salvamento otimista e
  restaurar padrao (GET /app/padrao); motor da conversa, Gemini e token
- carinha.html ganha modo ?painel=1 dirigido por postMessage (sem polling)
- teste Playwright de ponta a ponta do painel novo

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
julio
2026-10-05 19:47:18 -03:00
co-authored by Claude Opus 5.5
parent 35d443a358
commit c4b53a8976
40 changed files with 4710 additions and 3 deletions
@@ -0,0 +1,62 @@
<script lang="ts">
/**
* Prévia do rosto: o próprio carinha.html em modo ?painel=1 (sem polling).
* O painel repassa por postMessage o que chega no SSE, então a prévia mostra
* exatamente as mesmas ordens que o celular recebe.
*/
import { onDestroy } from 'svelte';
import { estado, ouvir } from '../estado.svelte';
let { titulo = 'Prévia do rosto' }: { titulo?: string } = $props();
let quadro: HTMLIFrameElement;
let pronto = $state(false);
function enviar(msg: unknown) {
if (pronto) quadro?.contentWindow?.postMessage(msg, location.origin);
}
function aoCarregar() {
pronto = true;
if (estado.appConfig) enviar({ tipo: 'config', config: $state.snapshot(estado.appConfig) });
}
let ultimaConversa = estado.conversa?.ultima?.id;
const cancelar = [
ouvir('expressao', (d) => enviar({ tipo: 'ordem', ordem: d.ordem })),
ouvir('app_config', (c) => enviar({ tipo: 'config', config: c })),
ouvir('reacao', (r) => r?.receita && enviar({ tipo: 'previa-reacao', receita: r.receita })),
// O celular reage à resposta da conversa com acordar + gesto; a prévia imita.
ouvir('conversa', (c) => {
if (c.ultima && c.ultima.id !== ultimaConversa) {
ultimaConversa = c.ultima.id;
enviar({ tipo: 'ordem', ordem: 'acordar' });
enviar({ tipo: 'ordem', ordem: c.ultima.gesto });
}
}),
];
onDestroy(() => cancelar.forEach((c) => c()));
/** Para a tela de reações: mostra uma receita sem mandar ao celular. */
export function previa(receita: unknown) {
enviar({ tipo: 'previa-reacao', receita });
}
</script>
<iframe
bind:this={quadro}
src="/carinha.html?espelho=1&painel=1&auto=0&camera=0&audio=0"
title={titulo}
tabindex="-1"
onload={aoCarregar}
></iframe>
<style>
iframe {
display: block;
width: 100%;
height: 100%;
border: 0;
background: #000;
border-radius: inherit;
}
</style>