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
+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>