feat(painel): Ajustes com submenu e ligações do PIR pelo esquema real

Ajustes vira uma tela com submenu (#/ajustes/<seção>): cada grupo do
celular é uma seção (Tela, Sensores, Sono, Vídeo, Sensibilidade), mais
Robô (Inteligência artificial, Ligações da placa) e Painel (Segurança,
Sistema). No PC o menu fica numa coluna fixa com descrição; no celular,
numa fileira rolável que traz a seção aberta para a vista. A busca
procura em todos os grupos do celular.

Ligações da placa passa a ser uma seção dos Ajustes (#/ligacoes antigo
redireciona) e segue o esquema que já tinha sido desenhado para o PIR:
- HC-SR505 mini, marcação + · out · −; no cabo, + no fio preto →
  pino 4, out no verde → pino 11, − no branco → pino 14;
- etiquetas do conector e tabela mostram a cor real do fio;
- tela TFT reserva também o pino 7; pinos livres listados;
- avisos do HC-SR505 (sinal de ~8 s, 30–60 s para estabilizar, teste
  com gpiomon).

Rotas ganham nav.sub (seção da tela); atalhos vão direto à seção
(câmera, IA, ligações).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
julio
2026-10-09 18:26:15 -03:00
co-authored by Claude Opus 5.5
parent 47a836276a
commit ccd89d125e
9 changed files with 427 additions and 207 deletions
@@ -0,0 +1,335 @@
<script lang="ts">
import { onMount } from 'svelte';
import { pedir } from '../api';
import { estado } from '../estado.svelte';
import { dispositivos, NOME_TIPO, pinoDaLinha, PINOS, type Dispositivo, type Fio, type Pino, type TipoPino } from '../ligacoes';
import Card from './Card.svelte';
// Conector de 26 pinos do Orange Pi Zero 3 com o que está ligado em cada pino. O pino do PIR
// vem da placa (CARINHA_PIR); o estado ao vivo do PIR e da PCA9685 vem do servidor.
let pir = $state<{ linha: string; disponivel: boolean; erro: string; ativo?: boolean } | null>(null);
let foco = $state<string | null>(null);
onMount(async () => {
try {
pir = (await pedir<{ pir: typeof pir }>('/historico/status')).pir;
} catch {
pir = null;
}
});
const linhaPir = $derived(pir ? Number(pir.linha.split(':')[1]) : 70);
const pinoPir = $derived(pinoDaLinha(linhaPir));
const lista = $derived(dispositivos(pinoPir));
const pirAtivo = $derived(estado.pir?.ativo ?? pir?.ativo ?? false);
// pino -> fios ligados nele (um pino pode ter mais de um dispositivo só no caso dos GND/5 V)
const fiosPorPino = $derived.by(() => {
const m = new Map<number, (Fio & { d: Dispositivo })[]>();
for (const d of lista) for (const f of d.fios) m.set(f.pino, [...(m.get(f.pino) ?? []), { ...f, d }]);
return m;
});
const linhas = $derived(Array.from({ length: 13 }, (_, i) => [PINOS[i * 2], PINOS[i * 2 + 1]] as [Pino, Pino]));
function apagado(p: Pino) {
if (!foco) return false;
return !(fiosPorPino.get(p.num) ?? []).some((f) => f.d.id === foco);
}
function estadoDe(d: Dispositivo): { texto: string; tom: 'ok' | 'aviso' | 'neutro' } {
if (d.id === 'pir') {
if (!pir) return { texto: 'sem resposta da placa', tom: 'neutro' };
if (!pir.disponivel) return { texto: pir.erro || 'sensor indisponível', tom: 'aviso' };
return pirAtivo ? { texto: 'sinal alto agora (vendo movimento)', tom: 'ok' } : { texto: 'ligado, sem movimento agora', tom: 'ok' };
}
if (d.id === 'pca9685') {
const m = estado.motores;
if (!m) return { texto: 'sem resposta da placa', tom: 'neutro' };
return m.disponivel ? { texto: 'respondendo no I²C', tom: 'ok' } : { texto: m.erro || 'não encontrada no I²C', tom: 'aviso' };
}
return { texto: 'pinos reservados', tom: 'neutro' };
}
const CORES: Record<TipoPino, string> = {
'5v': '#ff3b30', '3v3': '#ff9500', gnd: '#636366', gpio: '#34c759', i2c: '#0a84ff', spi: '#af52de', uart: '#30b0c7',
};
</script>
<div class="pagina">
<Card titulo="Conector da placa" ajuda="ligacoes" descricao="Orange Pi Zero 3, conector de 26 pinos em 2 fileiras: ímpares de um lado, pares do outro. Para achar o pino 1, procure o número 1 impresso na placa ou a ilha de solda quadrada no verso. Toque num dispositivo para destacar os pinos dele.">
<div class="rolagem">
<div class="conector" role="img" aria-label="Mapa do conector de 26 pinos e o que está ligado em cada pino">
{#each linhas as [impar, par] (impar.num)}
{@const fe = fiosPorPino.get(impar.num) ?? []}
{@const fd = fiosPorPino.get(par.num) ?? []}
<div class="fios esq" class:apagado={apagado(impar)}>
{#each fe as f (f.d.id)}
<span class="fio" style="--c: {f.cor}" title={f.d.nome + (f.cabo ? ' · fio ' + f.cabo.nome : '')}>
{f.d.emoji} {f.terminal}{#if f.cabo}<i class="cabo" style="--k: {f.cabo.cor}" aria-label={'fio ' + f.cabo.nome}></i>{/if}
</span>
{/each}
</div>
<div class="pino esq" class:apagado={apagado(impar)} class:usado={fe.length} title={impar.funcao ?? NOME_TIPO[impar.tipo]}>
<span class="nome">{impar.nome}</span>
<span class="bola" style="--c: {CORES[impar.tipo]}" class:quadrada={impar.num === 1}>{impar.num}</span>
</div>
<div class="pino dir" class:apagado={apagado(par)} class:usado={fd.length} title={par.funcao ?? NOME_TIPO[par.tipo]}>
<span class="bola" style="--c: {CORES[par.tipo]}">{par.num}</span>
<span class="nome">{par.nome}</span>
</div>
<div class="fios dir" class:apagado={apagado(par)}>
{#each fd as f (f.d.id)}
<span class="fio" style="--c: {f.cor}" title={f.d.nome + (f.cabo ? ' · fio ' + f.cabo.nome : '')}>
{#if f.cabo}<i class="cabo" style="--k: {f.cabo.cor}" aria-label={'fio ' + f.cabo.nome}></i>{/if}{f.d.emoji} {f.terminal}
</span>
{/each}
</div>
{/each}
</div>
</div>
<p class="legenda">
{#each Object.entries(NOME_TIPO) as [tipo, nome] (tipo)}
<span><i style="--c: {CORES[tipo as TipoPino]}"></i>{nome}</span>
{/each}
</p>
</Card>
<div class="lista">
{#each lista as d (d.id)}
{@const st = estadoDe(d)}
<Card>
<button
class="cabeca"
class:ativo={foco === d.id}
onclick={() => (foco = foco === d.id ? null : d.id)}
aria-pressed={foco === d.id}
title="Destacar os pinos deste dispositivo"
>
<span class="emoji" aria-hidden="true">{d.emoji}</span>
<span class="txt">
<strong>{d.nome}</strong>
<span class="st {st.tom}">{d.situacao === 'reservado' ? 'reservado' : 'em uso'} · {st.texto}</span>
</span>
</button>
<p class="desc">{d.descricao}</p>
{@const temCabo = d.fios.some((f) => f.cabo)}
<table>
<thead><tr><th>No dispositivo</th>{#if temCabo}<th>Fio do cabo</th>{/if}<th>Pino da placa</th><th>O que é</th></tr></thead>
<tbody>
{#each d.fios as f (f.pino + f.terminal)}
{@const p = PINOS[f.pino - 1]}
<tr>
<td><span class="ponto" style="--c: {f.cor}"></span>{f.terminal}</td>
{#if temCabo}
<td class="sem-quebra">{#if f.cabo}<i class="cabo" style="--k: {f.cabo.cor}"></i> {f.cabo.nome}{/if}</td>
{/if}
<td class="mono">{f.pino}</td>
<td>{p.funcao ?? NOME_TIPO[p.tipo]}{p.tipo === 'gpio' ? ` (${p.nome})` : ''}</td>
</tr>
{/each}
</tbody>
</table>
{#if d.avisos.length}
<ul class="avisos">
{#each d.avisos as a (a)}<li>{a}</li>{/each}
</ul>
{/if}
{#if d.id === 'pir' && pir}
<p class="nota mono">Configuração da placa: CARINHA_PIR={pir.linha} → pino {pinoPir ?? '?'}</p>
{/if}
</Card>
{/each}
</div>
</div>
<style>
.pagina {
display: grid;
gap: var(--e-4);
align-items: start;
}
@media (min-width: 1024px) {
.pagina {
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
}
}
.lista {
display: grid;
gap: var(--e-4);
}
.rolagem {
overflow-x: auto;
}
.conector {
display: grid;
grid-template-columns: minmax(6.5em, 1fr) auto auto minmax(6.5em, 1fr);
gap: 3px 6px;
align-items: center;
width: max-content;
min-width: 100%;
margin-top: var(--e-3);
}
.pino {
display: flex;
align-items: center;
gap: 6px;
font-size: var(--t-xs);
color: var(--texto-2);
transition: opacity var(--transicao);
}
.pino.esq {
justify-content: flex-end;
}
.pino.usado .nome {
color: var(--texto);
font-weight: 600;
}
.bola {
display: grid;
place-items: center;
width: 24px;
height: 24px;
border-radius: 50%;
background: color-mix(in srgb, var(--c) 22%, var(--superficie-2));
border: 2px solid var(--c);
color: var(--texto);
font: 600 10px var(--fonte-mono, monospace);
font-variant-numeric: tabular-nums;
}
.bola.quadrada {
border-radius: 4px;
}
.fios {
display: flex;
flex-wrap: wrap;
gap: 4px;
transition: opacity var(--transicao);
}
.fios.esq {
justify-content: flex-end;
}
.fio {
padding: 1px 7px;
border-radius: var(--raio-pill);
background: color-mix(in srgb, var(--c) 18%, transparent);
border: 1px solid var(--c);
color: var(--texto);
font-size: 11px;
white-space: nowrap;
}
.sem-quebra {
white-space: nowrap;
}
.cabo {
display: inline-block;
width: 10px;
height: 10px;
margin: 0 4px;
border-radius: 50%;
background: var(--k);
border: 1.5px solid var(--texto-3);
vertical-align: -1px;
}
.apagado {
opacity: 0.25;
}
.legenda {
display: flex;
flex-wrap: wrap;
gap: 4px var(--e-3);
margin: var(--e-3) 0 0;
font-size: var(--t-xs);
color: var(--texto-3);
}
.legenda span {
display: inline-flex;
align-items: center;
gap: 5px;
}
.legenda i {
width: 10px;
height: 10px;
border-radius: 50%;
border: 2px solid var(--c);
}
.cabeca {
display: flex;
align-items: center;
gap: var(--e-3);
width: 100%;
padding: 0;
border: 0;
background: transparent;
color: inherit;
text-align: left;
cursor: pointer;
}
.cabeca .emoji {
display: grid;
place-items: center;
width: 40px;
height: 40px;
flex: none;
border-radius: 50%;
background: var(--superficie-2);
font-size: 22px;
}
.cabeca.ativo .emoji {
outline: 2px solid var(--destaque);
}
.txt {
display: grid;
gap: 2px;
}
.st {
font-size: var(--t-xs);
color: var(--texto-3);
}
.st.ok {
color: var(--ok);
}
.st.aviso {
color: var(--aviso, #ff9500);
}
.desc {
margin: var(--e-3) 0 var(--e-2);
font-size: var(--t-sm);
color: var(--texto-2);
}
table {
width: 100%;
border-collapse: collapse;
font-size: var(--t-sm);
}
th {
text-align: left;
font-weight: 500;
font-size: var(--t-xs);
color: var(--texto-3);
padding: 4px 6px;
border-bottom: 1px solid var(--borda);
}
td {
padding: 5px 6px;
border-bottom: 1px solid var(--borda);
}
td .ponto {
display: inline-block;
width: 9px;
height: 9px;
margin-right: 6px;
border-radius: 50%;
background: var(--c);
}
.avisos {
display: grid;
gap: 6px;
margin: var(--e-3) 0 0;
padding-left: 1.1em;
font-size: var(--t-sm);
color: var(--texto-2);
}
.nota {
margin: var(--e-2) 0 0;
font-size: var(--t-xs);
color: var(--texto-3);
}
</style>
+4 -4
View File
@@ -81,12 +81,12 @@ export const TOPICOS: Record<string, TopicoGuia> = {
resumo:
'Mapa do conector de 26 pinos do Orange Pi Zero 3 com o que está ligado em cada pino: o sensor de presença (PIR), a placa dos servos (PCA9685) e os pinos reservados para a tela SPI. O pino do PIR vem da própria placa, então o desenho acompanha a configuração.',
itens: [
{ emoji: '🚶', nome: 'PIR', texto: 'VCC no 5 V (pino 2 ou 4), OUT no pino 11 (PC6) e GND num terra (pino 9, por exemplo). Nunca ligue o VCC no pino 11: os GPIOs são de 3,3 V. A ordem dos terminais muda entre módulos; leia o que está escrito no sensor.' },
{ emoji: '🚶', nome: 'PIR (HC-SR505)', texto: 'Plaquinha marcada + · out · −. No seu cabo: + no fio PRETO → pino 4 (5 V); out no fio VERDE → pino 11 (PC6); − no fio BRANCO → pino 14 (terra; o 9 também serve). Siga a marcação, não a cor. Nunca ligue o + no pino 11: os GPIOs são de 3,3 V.' },
{ emoji: '🦾', nome: 'PCA9685', texto: 'VCC no 3,3 V (pino 1), SDA no 3, SCL no 5 e GND no 6. Os servos usam a fonte externa no borne V+, com terra comum com a placa.' },
{ emoji: '🖥️', nome: 'Tela SPI (tft35)', texto: 'O overlay tft35 reserva os pinos 18, 19, 21, 22, 23 e 24. Não ligue sensores neles.' },
{ emoji: '🖥️', nome: 'Tela SPI (tft35)', texto: 'O overlay tft35 reserva os pinos 7, 18, 19, 21, 22, 23 e 24. Não ligue sensores neles. Livres para sensores novos: 12, 13, 15, 16 e 26.' },
{ emoji: '🟢', nome: 'Estado ao vivo', texto: 'Cada dispositivo mostra se a placa está lendo: o PIR diz se o sinal está alto agora, e a PCA9685 se responde no I²C.' },
],
dica: 'Sinal do PIR alto o tempo todo, por muito tempo, costuma ser fio trocado (VCC e OUT invertidos), sensor apontado para uma fonte de calor ou o ajuste de tempo do HC-SR501 no máximo.',
dica: 'Sinal do PIR alto o tempo todo, por muito tempo, não é normal no HC-SR505 (ele segura só uns 8 s): confira se o + e o out não estão trocados, ou se ele aponta para uma fonte de calor.',
},
cuidados: {
titulo: 'Cuidar do Carinha',
@@ -375,5 +375,5 @@ export const ORDEM_GUIA = [
/** Tópico de cada tela, para o ⓘ do topo. */
export const TOPICO_DA_TELA: Record<string, string> = {
'ao-vivo': 'inicio', eventos: 'eventos', conversa: 'conversa', reacoes: 'reacoes',
movimento: 'movimento', ajustes: 'tela', rosto: 'aparencia', guia: 'inicio', criar: 'criar', 'dia-a-dia': 'aprendizado', ligacoes: 'ligacoes',
movimento: 'movimento', ajustes: 'tela', rosto: 'aparencia', guia: 'inicio', criar: 'criar', 'dia-a-dia': 'aprendizado',
};
+18 -14
View File
@@ -53,6 +53,8 @@ export interface Fio {
/** Nome do terminal no dispositivo (VCC, OUT, GND, SDA...). */
terminal: string;
cor: string;
/** Cor real do fio no cabo, quando a gente sabe (ex.: o PIR tem o fio preto no +). */
cabo?: { nome: string; cor: string };
}
export interface Dispositivo {
@@ -79,19 +81,20 @@ export function dispositivos(pinoPir: number | null): Dispositivo[] {
id: 'pir',
nome: 'Sensor de presença (PIR)',
emoji: '🚶',
cor: '#34c759',
cor: '#1c9b4f',
situacao: 'em uso',
descricao: 'Módulo PIR (tipo HC-SR501 ou AM312). Avisa quando alguém se mexe na frente: acorda o rosto e liga a câmera no modo presença.',
descricao: 'HC-SR505 mini. Avisa quando alguém se mexe na frente (até uns 3 m, cone de 100°): acorda o rosto e liga a câmera no modo presença.',
fios: [
{ pino: 2, terminal: 'VCC', cor: '#ff3b30' },
{ pino: saida, terminal: 'OUT', cor: '#ffcc00' },
{ pino: 9, terminal: 'GND', cor: '#8e8e93' },
{ pino: 4, terminal: '+', cor: '#1c9b4f', cabo: { nome: 'preto', cor: '#111111' } },
{ pino: saida, terminal: 'out', cor: '#1c9b4f', cabo: { nome: 'verde', cor: '#1c9b4f' } },
{ pino: 14, terminal: '−', cor: '#1c9b4f', cabo: { nome: 'branco', cor: '#f4f4f4' } },
],
avisos: [
'A ordem dos 3 terminais muda de um módulo para outro: leia o que está escrito na plaquinha do sensor (no HC-SR501, tire a cúpula branca para ver VCC, OUT e GND).',
`Nunca ligue o VCC (5 V) no pino ${saida}: os GPIOs da placa são de 3,3 V e queimam com 5 V.`,
'A saída do HC-SR501 e do AM312 é de 3,3 V, segura para o GPIO. Se o seu sensor soltar 5 V na saída, use um divisor de tensão.',
'O VCC pode ir em qualquer 5 V (pino 2 ou 4) e o GND em qualquer terra (6, 9, 14, 20 ou 25).',
'No seu cabo o fio PRETO está no +. Siga a marcação da plaquinha (+ · out · −, da esquerda para a direita), não a cor do fio. Inverter + e − pode queimar o sensor.',
`Nunca ligue o + (5 V) no pino ${saida}: os GPIOs da placa são de 3,3 V e queimam com 5 V. O out do HC-SR505 já sai em 3,3 V, seguro para o pino ${saida}.`,
'O HC-SR505 não tem ajuste e segura o sinal ligado por uns 8 s depois de cada movimento. Sinal ligado direto por minutos seguidos não é normal: confira os fios.',
'Depois de ligado, ele leva de 30 a 60 s para estabilizar; disparos falsos nesse tempo são normais. Aponte para onde as pessoas passam, longe de sol e de saída de ar quente.',
'O − também pode ir no pino 9 (é terra). Teste na placa: gpiomon -c gpiochip1 -e rising ' + (saida === 11 ? '70' : 'LINHA') + ' imprime uma linha a cada movimento.',
],
},
{
@@ -102,13 +105,13 @@ export function dispositivos(pinoPir: number | null): Dispositivo[] {
situacao: 'em uso',
descricao: 'Controla os servos do braço por I²C (barramento /dev/i2c-2, endereço 0x40).',
fios: [
{ pino: 1, terminal: 'VCC', cor: '#ff9500' },
{ pino: 1, terminal: 'VCC', cor: '#0a84ff' },
{ pino: 3, terminal: 'SDA', cor: '#0a84ff' },
{ pino: 5, terminal: 'SCL', cor: '#5ac8fa' },
{ pino: 6, terminal: 'GND', cor: '#8e8e93' },
{ pino: 5, terminal: 'SCL', cor: '#0a84ff' },
{ pino: 6, terminal: 'GND', cor: '#0a84ff' },
],
avisos: [
'O VCC da PCA9685 vai no 3,3 V (pino 1): é só a lógica.',
'Já ligada e funcionando. O VCC da PCA9685 vai no 3,3 V (pino 1): é só a lógica.',
'Os servos NÃO são alimentados pela placa: o borne V+ da PCA9685 recebe a fonte externa (6 V), com o GND da fonte ligado ao GND da placa (terra comum).',
],
},
@@ -118,8 +121,9 @@ export function dispositivos(pinoPir: number | null): Dispositivo[] {
emoji: '🖥️',
cor: '#af52de',
situacao: 'reservado',
descricao: 'O overlay tft35 está ativo no boot e reserva estes pinos para uma tela SPI. O Carinha não usa essa tela: não ligue outros sensores neles.',
descricao: 'O overlay tft35 está ativo no boot e reserva estes pinos para uma tela SPI. O Carinha não usa essa tela: não ligue outros sensores neles. Livres para sensores novos: 12, 13, 15, 16 e 26.',
fios: [
{ pino: 7, terminal: 'TFT', cor: '#af52de' },
{ pino: 18, terminal: 'DC', cor: '#af52de' },
{ pino: 19, terminal: 'MOSI', cor: '#af52de' },
{ pino: 21, terminal: 'MISO', cor: '#af52de' },
+1 -1
View File
@@ -37,7 +37,7 @@ export function itensDaPaleta(): ItemPaleta[] {
registrarNaPaleta(() => [
...ROTAS.map((r) => ({ id: 'ir-' + r.id, grupo: 'Ir para', titulo: r.titulo, fazer: () => nav.ir(r.id) })),
{ id: 'ir-rosto', grupo: 'Ir para', titulo: 'Rosto (estilo, formato e cor)', fazer: () => nav.ir('rosto') },
{ id: 'ir-ligacoes', grupo: 'Ir para', titulo: 'Ligações da placa (pinos do PIR, servos e tela)', fazer: () => nav.ir('ligacoes') },
{ id: 'ir-ligacoes', grupo: 'Ir para', titulo: 'Ligações da placa (pinos do PIR, servos e tela)', fazer: () => nav.ir('ajustes', 'ligacoes') },
{ id: 'ir-dia-a-dia', grupo: 'Ir para', titulo: 'Dia a dia (rotina e decisões do Carinha)', fazer: () => nav.ir('dia-a-dia') },
{ id: 'ir-criar', grupo: 'Ir para', titulo: 'Criar com IA (expressões, atividades, figurinhas)', fazer: () => nav.ir('criar') },
...Object.entries(ATIVIDADES_INFO).map(([id, a]) => ({
+18 -7
View File
@@ -1,6 +1,6 @@
/** Rotas por hash (#/conversa): o servidor só precisa entregar index.html em "/". */
export type Rota = 'ao-vivo' | 'eventos' | 'conversa' | 'reacoes' | 'movimento' | 'ajustes' | 'rosto' | 'guia' | 'criar' | 'dia-a-dia' | 'ligacoes';
export type Rota = 'ao-vivo' | 'eventos' | 'conversa' | 'reacoes' | 'movimento' | 'ajustes' | 'rosto' | 'guia' | 'criar' | 'dia-a-dia';
export const ROTAS: { id: Rota; titulo: string; atalho: string }[] = [
{ id: 'ao-vivo', titulo: 'Ao vivo', atalho: 'g v' },
@@ -17,23 +17,34 @@ export const SECUNDARIAS: { id: Rota; titulo: string; volta: Rota }[] = [
{ id: 'guia', titulo: 'Guia', volta: 'ajustes' },
{ id: 'criar', titulo: 'Criar com IA', volta: 'reacoes' },
{ id: 'dia-a-dia', titulo: 'Dia a dia', volta: 'ao-vivo' },
{ id: 'ligacoes', titulo: 'Ligações da placa', volta: 'ajustes' },
];
export const tituloDa = (id: Rota) => [...ROTAS, ...SECUNDARIAS].find((r) => r.id === id)?.titulo ?? '';
/** #/ajustes/ia -> ['ajustes', 'ia']: a segunda parte é a seção (submenu) da tela. */
function partes(): [string, string] {
const [id, sub = ''] = location.hash.replace(/^#\/?/, '').split('?')[0].split('/');
if (id === 'historico') return ['eventos', sub]; // endereço antigo
if (id === 'ligacoes') return ['ajustes', 'ligacoes']; // virou seção dos Ajustes
return [id, sub];
}
function lerHash(): Rota {
let id = location.hash.replace(/^#\/?/, '').split('?')[0] as Rota;
if ((id as string) === 'historico') id = 'eventos'; // endereço antigo
const id = partes()[0] as Rota;
return ROTAS.some((r) => r.id === id) || SECUNDARIAS.some((r) => r.id === id) ? id : 'ao-vivo';
}
class Navegacao {
atual = $state<Rota>(lerHash());
ir(rota: Rota) {
location.hash = '/' + rota;
/** Seção dentro da tela (ex.: a parte dos Ajustes aberta). */
sub = $state(partes()[1]);
ir(rota: Rota, sub = '') {
location.hash = '/' + rota + (sub ? '/' + sub : '');
}
}
export const nav = new Navegacao();
addEventListener('hashchange', () => (nav.atual = lerHash()));
addEventListener('hashchange', () => {
nav.atual = lerHash();
nav.sub = partes()[1];
});