- 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>
175 lines
4.4 KiB
Svelte
175 lines
4.4 KiB
Svelte
<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>
|