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
+62
View File
@@ -0,0 +1,62 @@
<script lang="ts">
import { X } from '@lucide/svelte';
import { avisos } from '../avisos.svelte';
</script>
<div class="avisos" role="status" aria-live="polite">
{#each avisos.lista as a (a.id)}
<div class="aviso {a.tipo}">
<span>{a.texto}</span>
{#if a.acao}
<button
class="btn pequeno"
onclick={() => {
a.acao!.fazer();
avisos.fechar(a.id);
}}>{a.acao.rotulo}</button
>
{/if}
<button class="btn fantasma pequeno icone" aria-label="Fechar aviso" onclick={() => avisos.fechar(a.id)}>
<X size={16} />
</button>
</div>
{/each}
</div>
<style>
.avisos {
position: fixed;
z-index: 90;
right: var(--e-4);
bottom: calc(var(--e-4) + env(safe-area-inset-bottom, 0px));
display: grid;
gap: var(--e-2);
width: min(420px, calc(100vw - 2 * var(--e-4)));
}
.aviso {
display: flex;
align-items: center;
gap: var(--e-2);
padding: var(--e-2) var(--e-2) var(--e-2) var(--e-4);
border-radius: var(--raio);
background: var(--superficie-2);
border: 1px solid var(--borda-forte);
border-left: 4px solid var(--destaque);
box-shadow: var(--sombra-flutuante);
font-size: var(--t-sm);
}
.aviso span {
flex: 1;
}
.ok {
border-left-color: var(--ok);
}
.erro {
border-left-color: var(--perigo);
}
@media (max-width: 1023px) {
.avisos {
bottom: calc(76px + env(safe-area-inset-bottom, 0px));
}
}
</style>
+51
View File
@@ -0,0 +1,51 @@
<script lang="ts">
import type { Snippet } from 'svelte';
let { titulo = '', descricao = '', acoes, children, class: classe = '' }: {
titulo?: string;
descricao?: string;
acoes?: Snippet;
children: Snippet;
class?: string;
} = $props();
</script>
<section class="card {classe}">
{#if titulo || acoes}
<header>
<div>
{#if titulo}<h2>{titulo}</h2>{/if}
{#if descricao}<p class="fraco pequeno-texto">{descricao}</p>{/if}
</div>
{#if acoes}<div class="linha">{@render acoes()}</div>{/if}
</header>
{/if}
{@render children()}
</section>
<style>
.card {
background: var(--superficie);
border: 1px solid var(--borda);
border-radius: var(--raio-lg);
padding: var(--e-4);
display: grid;
gap: var(--e-4);
align-content: start;
min-width: 0;
}
header {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: var(--e-3);
flex-wrap: wrap;
}
h2 {
font-size: var(--t-md);
}
@media (min-width: 640px) {
.card {
padding: var(--e-5);
}
}
</style>
+42
View File
@@ -0,0 +1,42 @@
<script lang="ts">
import { OctagonX, RotateCcw } from '@lucide/svelte';
import { pararTudo, rearmar } from '../comandos.svelte';
import { estado } from '../estado.svelte';
</script>
{#if estado.estop.ativo}
<button class="btn estop parado" onclick={rearmar} title="Motores travados pelo E-Stop. Clique para rearmar.">
<RotateCcw size={18} /> <span>PARADO · Rearmar</span>
</button>
{:else}
<button class="btn perigo estop" onclick={pararTudo} title="Parar tudo (Shift+Espaço ou Esc duas vezes)">
<OctagonX size={18} /> <span>Parar tudo</span>
</button>
{/if}
<style>
.estop {
font-weight: 700;
letter-spacing: 0.02em;
}
.parado {
background: var(--aviso-suave);
border-color: var(--aviso);
color: var(--aviso);
animation: pulso 1.6s ease-in-out infinite;
}
@keyframes pulso {
50% {
box-shadow: 0 0 0 4px var(--aviso-suave);
}
}
@media (max-width: 480px) {
span {
display: none;
}
.estop {
width: 44px;
padding: 0;
}
}
</style>
+101
View File
@@ -0,0 +1,101 @@
<script lang="ts">
import { Star } from '@lucide/svelte';
import { favoritos, gesto, GESTOS_INFO, gestosDisponiveis } from '../comandos.svelte';
// compacto: só os favoritos com atalho 1–9 (dock). Completo: todos, com estrela para fixar.
let { completo = false }: { completo?: boolean } = $props();
const lista = $derived(completo ? gestosDisponiveis() : favoritos.lista.filter((g) => g in GESTOS_INFO));
</script>
<div class="gestos" class:completo>
{#each lista as g, i (g)}
{@const info = GESTOS_INFO[g]}
<div class="item">
<button class="gesto" onclick={() => gesto(g)} title={completo ? info.nome : `${info.nome} (tecla ${i + 1})`}>
<span class="emoji" aria-hidden="true">{info.emoji}</span>
<span class="nome">{info.nome}</span>
{#if !completo}<kbd>{i + 1}</kbd>{/if}
</button>
{#if completo}
<button
class="estrela"
class:ativa={favoritos.lista.includes(g)}
aria-label={favoritos.lista.includes(g) ? `Tirar ${info.nome} dos favoritos` : `Fixar ${info.nome} nos favoritos`}
aria-pressed={favoritos.lista.includes(g)}
onclick={() => favoritos.alternar(g)}
>
<Star size={14} />
</button>
{/if}
</div>
{/each}
</div>
<style>
.gestos {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(92px, 1fr));
gap: var(--e-2);
}
.item {
position: relative;
}
.gesto {
width: 100%;
display: grid;
justify-items: center;
gap: 2px;
padding: var(--e-3) var(--e-1) var(--e-2);
border: 1px solid var(--borda);
border-radius: var(--raio);
background: var(--superficie-2);
color: var(--texto);
font: inherit;
cursor: pointer;
position: relative;
transition: background var(--transicao), border-color var(--transicao), transform var(--transicao);
}
.gesto:hover {
border-color: var(--destaque);
background: var(--destaque-suave);
}
.gesto:active {
transform: scale(0.97);
}
.emoji {
font-size: 1.6rem;
line-height: 1.2;
}
.nome {
font-size: var(--t-xs);
color: var(--texto-2);
text-align: center;
}
kbd {
position: absolute;
top: 4px;
right: 6px;
font: 500 10px var(--fonte-mono);
color: var(--texto-3);
}
.estrela {
position: absolute;
top: 2px;
right: 2px;
width: 26px;
height: 26px;
display: grid;
place-items: center;
border: 0;
border-radius: 50%;
background: transparent;
color: var(--texto-3);
cursor: pointer;
}
.estrela.ativa {
color: var(--aviso);
}
.estrela.ativa :global(svg) {
fill: currentColor;
}
</style>
+87
View File
@@ -0,0 +1,87 @@
<script lang="ts">
import { ChevronDown, ChevronUp } from '@lucide/svelte';
import { estado } from '../estado.svelte';
import RostoPrevia from './RostoPrevia.svelte';
// Um só mini-rosto em todas as telas (exceto Ao vivo, que já tem o rosto grande).
let aberto = $state(true);
try {
aberto = localStorage.getItem('carinha_mini') !== '0';
} catch {
/* ok */
}
function alternar() {
aberto = !aberto;
try {
localStorage.setItem('carinha_mini', aberto ? '1' : '0');
} catch {
/* ok */
}
}
</script>
<aside class="mini" class:aberto aria-label="Rosto ao vivo">
<button class="barra" onclick={alternar} aria-expanded={aberto}>
<span class="ponto" class:online={estado.celularOnline} aria-hidden="true"></span>
<span>Rosto {estado.celularOnline ? 'ao vivo' : '· celular offline'}</span>
{#if aberto}<ChevronDown size={16} />{:else}<ChevronUp size={16} />{/if}
</button>
{#if aberto}
<div class="tela"><RostoPrevia /></div>
{/if}
</aside>
<style>
.mini {
position: fixed;
z-index: 55;
right: var(--e-4);
bottom: calc(84px + env(safe-area-inset-bottom, 0px));
width: 220px;
border-radius: var(--raio);
overflow: hidden;
background: var(--vidro);
backdrop-filter: blur(16px);
border: 1px solid var(--borda-forte);
box-shadow: var(--sombra-flutuante);
}
.barra {
width: 100%;
display: flex;
align-items: center;
gap: var(--e-2);
padding: var(--e-2) var(--e-3);
border: 0;
background: transparent;
color: var(--texto-2);
font: 500 var(--t-xs) var(--fonte);
cursor: pointer;
}
.barra span:nth-child(2) {
flex: 1;
text-align: left;
}
.ponto {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--texto-3);
}
.ponto.online {
background: var(--ok);
}
.tela {
aspect-ratio: 16 / 9;
}
@media (max-width: 639px) {
.mini {
width: 160px;
}
}
@media (min-width: 1024px) {
.mini {
bottom: var(--e-4);
width: 260px;
}
}
</style>
+174
View File
@@ -0,0 +1,174 @@
<script lang="ts">
import { Search } from '@lucide/svelte';
import { itensDaPaleta, paleta, type ItemPaleta } from '../paleta.svelte';
let busca = $state('');
let ativo = $state(0);
let campo = $state<HTMLInputElement>();
const normal = (t: string) => t.normalize('NFD').replace(/[̀-ͯ]/g, '').toLowerCase();
const itens = $derived.by(() => {
const termos = normal(busca).split(/\s+/).filter(Boolean);
return itensDaPaleta()
.filter((i) => termos.every((t) => normal(i.grupo + ' ' + i.titulo).includes(t)))
.slice(0, 50);
});
$effect(() => {
if (paleta.aberta) {
busca = '';
ativo = 0;
queueMicrotask(() => campo?.focus());
}
});
$effect(() => {
void busca;
ativo = 0;
});
function executar(i: ItemPaleta | undefined) {
if (!i) return;
paleta.fechar();
i.fazer();
}
function tecla(e: KeyboardEvent) {
if (e.key === 'ArrowDown') {
ativo = Math.min(itens.length - 1, ativo + 1);
e.preventDefault();
} else if (e.key === 'ArrowUp') {
ativo = Math.max(0, ativo - 1);
e.preventDefault();
} else if (e.key === 'Enter') {
executar(itens[ativo]);
e.preventDefault();
} else if (e.key === 'Escape') {
paleta.fechar();
e.stopPropagation();
}
}
</script>
{#if paleta.aberta}
<!-- svelte-ignore a11y_click_events_have_key_events, a11y_no_static_element_interactions -->
<div class="fundo" onclick={paleta.fechar}>
<div class="caixa" role="dialog" tabindex="-1" aria-modal="true" aria-label="Comandos" onclick={(e) => e.stopPropagation()}>
<div class="busca">
<Search size={18} />
<input
bind:this={campo}
bind:value={busca}
onkeydown={tecla}
placeholder="Gesto, rosto, tela ou ação…"
aria-label="Buscar comando"
role="combobox"
aria-expanded="true"
aria-controls="paleta-lista"
aria-activedescendant={itens[ativo] ? 'pal-' + itens[ativo].id : undefined}
/>
<kbd>Esc</kbd>
</div>
<ul id="paleta-lista" role="listbox">
{#each itens as item, i (item.id)}
{#if i === 0 || itens[i - 1].grupo !== item.grupo}
<li class="grupo" role="presentation">{item.grupo}</li>
{/if}
<li
id={'pal-' + item.id}
role="option"
aria-selected={i === ativo}
class:ativo={i === ativo}
onmousemove={() => (ativo = i)}
onclick={() => executar(item)}
>
<span>{item.titulo}</span>
{#if item.dica}<kbd>{item.dica}</kbd>{/if}
</li>
{:else}
<li class="vazio">Nada encontrado.</li>
{/each}
</ul>
</div>
</div>
{/if}
<style>
.fundo {
position: fixed;
inset: 0;
z-index: 95;
background: rgb(0 0 0 / 0.45);
display: grid;
justify-items: center;
align-items: start;
padding: 12vh var(--e-4) var(--e-4);
}
.caixa {
width: min(560px, 100%);
max-height: 70vh;
display: grid;
grid-template-rows: auto 1fr;
background: var(--vidro);
backdrop-filter: blur(18px);
border: 1px solid var(--borda-forte);
border-radius: var(--raio-lg);
box-shadow: var(--sombra-flutuante);
overflow: hidden;
}
.busca {
display: flex;
align-items: center;
gap: var(--e-3);
padding: var(--e-3) var(--e-4);
border-bottom: 1px solid var(--borda);
color: var(--texto-3);
}
.busca input {
border: 0;
background: transparent;
min-height: 36px;
font-size: var(--t-md);
padding: 0;
}
.busca input:focus-visible {
outline: none;
}
ul {
list-style: none;
margin: 0;
padding: var(--e-2);
overflow: auto;
}
li[role='option'] {
display: flex;
justify-content: space-between;
align-items: center;
padding: var(--e-2) var(--e-3);
border-radius: var(--raio-sm);
cursor: pointer;
font-size: var(--t-sm);
}
li.ativo {
background: var(--destaque-suave);
color: var(--destaque);
}
.grupo {
padding: var(--e-3) var(--e-3) var(--e-1);
font-size: var(--t-xs);
color: var(--texto-3);
text-transform: uppercase;
letter-spacing: 0.06em;
}
.vazio {
padding: var(--e-4);
color: var(--texto-3);
font-size: var(--t-sm);
}
kbd {
font: 500 11px var(--fonte-mono);
color: var(--texto-3);
border: 1px solid var(--borda);
border-radius: 4px;
padding: 1px 5px;
}
</style>
@@ -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>
+50
View File
@@ -0,0 +1,50 @@
<script lang="ts">
import type { Snippet } from 'svelte';
// Estado nunca só por cor: o selo sempre tem texto (e o ponto é decorativo).
let { tom = 'neutro', children }: { tom?: 'ok' | 'aviso' | 'perigo' | 'neutro' | 'destaque'; children: Snippet } =
$props();
</script>
<span class="selo {tom}"><span class="ponto" aria-hidden="true"></span>{@render children()}</span>
<style>
.selo {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 2px 10px;
border-radius: var(--raio-pill);
font-size: var(--t-xs);
font-weight: 500;
background: var(--superficie-2);
color: var(--texto-2);
border: 1px solid var(--borda);
white-space: nowrap;
}
.ponto {
width: 7px;
height: 7px;
border-radius: 50%;
background: currentColor;
}
.ok {
color: var(--ok);
background: var(--ok-suave);
border-color: transparent;
}
.aviso {
color: var(--aviso);
background: var(--aviso-suave);
border-color: transparent;
}
.perigo {
color: var(--perigo);
background: var(--perigo-suave);
border-color: transparent;
}
.destaque {
color: var(--destaque);
background: var(--destaque-suave);
border-color: transparent;
}
</style>
+97
View File
@@ -0,0 +1,97 @@
<script lang="ts">
let { rotulo, valor, min, max, passo = 1, unidade = '', disabled = false, oninput, onchange }: {
rotulo: string;
valor: number;
min: number;
max: number;
passo?: number;
unidade?: string;
disabled?: boolean;
oninput?: (v: number) => void;
onchange?: (v: number) => void;
} = $props();
const id = 's' + Math.random().toString(36).slice(2, 9);
let local = $state<number | null>(null);
const mostrado = $derived(local ?? valor);
const casas = $derived(passo < 1 ? Math.min(2, String(passo).split('.')[1]?.length ?? 2) : 0);
</script>
<div class="slider">
<label for={id}>
<span>{rotulo}</span>
<output for={id} class="mono">{mostrado.toFixed(casas)}{unidade}</output>
</label>
<input
{id}
type="range"
{min}
{max}
step={passo}
value={mostrado}
{disabled}
style="--p: {((mostrado - min) / (max - min)) * 100}%"
oninput={(e) => {
local = Number(e.currentTarget.value);
oninput?.(local);
}}
onchange={(e) => {
const v = Number(e.currentTarget.value);
local = null;
onchange?.(v);
}}
/>
</div>
<style>
.slider {
display: grid;
gap: var(--e-1);
}
label {
display: flex;
justify-content: space-between;
font-size: var(--t-sm);
gap: var(--e-2);
}
output {
color: var(--texto-2);
}
input {
-webkit-appearance: none;
appearance: none;
width: 100%;
height: 28px;
background: transparent;
cursor: pointer;
}
input::-webkit-slider-runnable-track {
height: 6px;
border-radius: 3px;
background: linear-gradient(to right, var(--destaque) var(--p), var(--superficie-3) var(--p));
}
input::-moz-range-track {
height: 6px;
border-radius: 3px;
background: linear-gradient(to right, var(--destaque) var(--p), var(--superficie-3) var(--p));
}
input::-webkit-slider-thumb {
-webkit-appearance: none;
width: 20px;
height: 20px;
margin-top: -7px;
border-radius: 50%;
background: var(--texto);
border: 3px solid var(--destaque);
}
input::-moz-range-thumb {
width: 14px;
height: 14px;
border-radius: 50%;
background: var(--texto);
border: 3px solid var(--destaque);
}
input:disabled {
opacity: 0.5;
cursor: not-allowed;
}
</style>
+80
View File
@@ -0,0 +1,80 @@
<script lang="ts">
let { rotulo, ajuda = '', checked = false, disabled = false, onchange }: {
rotulo: string;
ajuda?: string;
checked?: boolean;
disabled?: boolean;
onchange: (v: boolean) => void;
} = $props();
const id = 't' + Math.random().toString(36).slice(2, 9);
</script>
<label class="toggle" for={id}>
<span class="texto">
<span>{rotulo}</span>
{#if ajuda}<small class="fraco">{ajuda}</small>{/if}
</span>
<input {id} type="checkbox" role="switch" {checked} {disabled} onchange={(e) => onchange(e.currentTarget.checked)} />
<span class="trilho" aria-hidden="true"></span>
</label>
<style>
.toggle {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--e-3);
min-height: 44px;
cursor: pointer;
position: relative;
}
.texto {
display: grid;
font-size: var(--t-sm);
}
small {
font-size: var(--t-xs);
}
input {
position: absolute;
opacity: 0;
width: 1px;
height: 1px;
}
.trilho {
flex: none;
width: 42px;
height: 24px;
border-radius: var(--raio-pill);
background: var(--superficie-3);
border: 1px solid var(--borda-forte);
position: relative;
transition: background var(--transicao);
}
.trilho::after {
content: '';
position: absolute;
top: 2px;
left: 2px;
width: 18px;
height: 18px;
border-radius: 50%;
background: var(--texto-2);
transition: transform var(--transicao), background var(--transicao);
}
input:checked + .trilho {
background: var(--destaque);
border-color: var(--destaque);
}
input:checked + .trilho::after {
transform: translateX(18px);
background: var(--destaque-texto);
}
input:focus-visible + .trilho {
outline: 2px solid var(--destaque);
outline-offset: 2px;
}
input:disabled + .trilho {
opacity: 0.5;
}
</style>
+56
View File
@@ -0,0 +1,56 @@
<script lang="ts">
import { KeyRound } from '@lucide/svelte';
import { gravarToken } from '../api';
import { conectar } from '../estado.svelte';
let token = $state('');
async function entrar(e: SubmitEvent) {
e.preventDefault();
gravarToken(token.trim());
await conectar();
}
</script>
<div class="fundo">
<form class="caixa" onsubmit={entrar}>
<KeyRound size={28} />
<h1>Token do Carinha</h1>
<p class="fraco pequeno-texto">
Este servidor exige um token para controlar o robô. Ele fica em <span class="mono">CARINHA_TOKEN</span> ou
<span class="mono">seguranca.json</span> na placa e é guardado só neste navegador.
</p>
<label class="campo">
<span>Token</span>
<!-- svelte-ignore a11y_autofocus -->
<input type="password" bind:value={token} autocomplete="current-password" required minlength="8" autofocus />
</label>
<button class="btn primario" type="submit">Entrar</button>
</form>
</div>
<style>
.fundo {
position: fixed;
inset: 0;
z-index: 100;
display: grid;
place-items: center;
padding: var(--e-4);
background: var(--fundo);
}
.caixa {
width: min(400px, 100%);
display: grid;
gap: var(--e-4);
padding: var(--e-6);
border-radius: var(--raio-lg);
background: var(--superficie);
border: 1px solid var(--borda);
color: var(--destaque);
}
h1,
p,
label {
color: var(--texto);
}
</style>