feat(pessoas): reconhecimento de quem chega, só na placa
- reconhecimento.py: cada foto de evento passa pelo YuNet (acha rostos)
e pelo SFace (impressão de 128 números) no OpenCV da placa (~0,3 s por
foto); compara com as amostras marcadas (cosseno >= 0,40). Rostos sem
nome ficam agrupados por semelhança para marcar de uma vez; marcar uma
pessoa reconhece também os rostos antigos parecidos. Procura nas fotos
guardadas, renomear, apagar (apaga recortes e impressões), sem nome
somem em 7 dias, liga/desliga.
- modo vida: espera até 8 s a foto para cumprimentar pelo nome ("Ana!
Senti sua falta!"), saudade de cada pessoa, "Oi, Bia!" para quem chega
com alguém já por perto, afinidade com quem dá carinho logo depois.
- painel: tela Pessoas e tópico no guia; console mostra "Reconheceu Ana".
- sistema/reconhecimento/instalar.sh: python3-opencv e os modelos do
opencv_zoo.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
+4
-1
@@ -1,5 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { Bell, Bot, Command, Hand, MessageCircle, Moon, Settings, Smile, Sun, Terminal, Video } from '@lucide/svelte';
|
||||
import { Bell, Bot, Command, Hand, MessageCircle, Moon, Settings, Smile, Sun, Terminal, Users, Video } from '@lucide/svelte';
|
||||
import { onMount, type Component } from 'svelte';
|
||||
import Avisos from './lib/componentes/Avisos.svelte';
|
||||
import EStop from './lib/componentes/EStop.svelte';
|
||||
@@ -22,6 +22,7 @@
|
||||
import Criar from './telas/Criar.svelte';
|
||||
import DiaADia from './telas/DiaADia.svelte';
|
||||
import Console from './telas/Console.svelte';
|
||||
import Pessoas from './telas/Pessoas.svelte';
|
||||
import Ajuda from './lib/componentes/Ajuda.svelte';
|
||||
import { TOPICO_DA_TELA } from './lib/guia';
|
||||
|
||||
@@ -37,11 +38,13 @@
|
||||
criar: Criar,
|
||||
'dia-a-dia': DiaADia,
|
||||
console: Console,
|
||||
pessoas: Pessoas,
|
||||
};
|
||||
const ICONES: Record<string, Component<any>> = {
|
||||
'ao-vivo': Video,
|
||||
eventos: Bell,
|
||||
console: Terminal,
|
||||
pessoas: Users,
|
||||
conversa: MessageCircle,
|
||||
reacoes: Smile,
|
||||
movimento: Hand,
|
||||
|
||||
@@ -188,7 +188,7 @@ function abrirEventos() {
|
||||
if (estado.conexao !== 'token') estado.conexao = 'caiu';
|
||||
};
|
||||
for (const tipo of ['expressao', 'telemetria', 'app_config', 'aparencia', 'motores', 'estop', 'conversa', 'ia',
|
||||
'reacao', 'treinamento', 'historico', 'pir', 'vida', 'aprendizado', 'criacao', 'inspiracao', 'console']) {
|
||||
'reacao', 'treinamento', 'historico', 'pir', 'vida', 'aprendizado', 'criacao', 'inspiracao', 'console', 'pessoas']) {
|
||||
fonte.addEventListener(tipo, (e) => {
|
||||
const dados = JSON.parse((e as MessageEvent).data);
|
||||
tratadores[tipo]?.(dados);
|
||||
|
||||
+16
-2
@@ -242,6 +242,20 @@ export const TOPICOS: Record<string, TopicoGuia> = {
|
||||
{ emoji: '🧠', nome: 'IA local / Aprendizado / Ideias', texto: 'Escolhas da IA do S10, decisões tiradas da rotina e as ideias do dia.' },
|
||||
],
|
||||
},
|
||||
pessoas: {
|
||||
titulo: 'Reconhecimento de pessoas',
|
||||
emoji: '🧑🤝🧑',
|
||||
onde: 'Pessoas (menu lateral)',
|
||||
resumo:
|
||||
'Quando o sensor vê alguém, o celular manda uma foto e a placa procura rostos nela (YuNet) e compara com quem ele conhece (SFace). Reconheceu: cumprimenta pelo nome e a saudade é de cada pessoa ("Ana! Senti sua falta!"). Tudo roda e fica na placa; nada vai para a internet.',
|
||||
itens: [
|
||||
{ emoji: '👆', nome: 'Ensinar', texto: 'Em "Rostos sem nome", toque nos rostos da mesma pessoa e escolha quem é (ou crie a pessoa). Com 3 a 5 fotos ele já reconhece bem; marcar uma pessoa também reconhece os rostos antigos parecidos.' },
|
||||
{ emoji: '🔎', nome: 'Procurar nas fotos guardadas', texto: 'Analisa as fotos de eventos que já existem, para cadastrar as pessoas logo de cara.' },
|
||||
{ emoji: '💗', nome: 'Afinidade', texto: 'Carinho, brincadeira ou conversa logo depois de reconhecer alguém aumenta a afinidade com essa pessoa: quem tem bastante ganha coração na festa.' },
|
||||
{ emoji: '🌙', nome: 'Limites', texto: 'Precisa de luz e do rosto virado para a câmera do celular. No escuro, ou de costas, ele cumprimenta sem o nome.' },
|
||||
{ emoji: '🛡️', nome: 'Privacidade', texto: 'Rostos sem nome somem em 7 dias. Apagar uma pessoa apaga as fotos e as impressões dela. Dá para desligar o reconhecimento a qualquer hora.' },
|
||||
],
|
||||
},
|
||||
jeito: {
|
||||
titulo: 'Jeito de gente',
|
||||
emoji: '🧒',
|
||||
@@ -402,13 +416,13 @@ export const TOPICOS: Record<string, TopicoGuia> = {
|
||||
|
||||
/** Ordem do tutorial (página Guia). */
|
||||
export const ORDEM_GUIA = [
|
||||
'inicio', 'vida', 'aprendizado', 'cuidados', 'humores', 'ritmo', 'sono', 'celular', 'camera', 'gestos', 'atividades', 'jeito', 'console', 'criar', 'ideias', 'aparencia',
|
||||
'inicio', 'vida', 'aprendizado', 'cuidados', 'humores', 'ritmo', 'sono', 'celular', 'camera', 'gestos', 'atividades', 'jeito', 'console', 'pessoas', 'criar', 'ideias', 'aparencia',
|
||||
'personalidade', 'acontecimentos', 'ligacoes', 'eventos', 'presenca', 'tela', 'sensores', 'sensibilidade',
|
||||
'reacoes', 'conversa', 'movimento', 'atalhos',
|
||||
];
|
||||
|
||||
/** Tópico de cada tela, para o ⓘ do topo. */
|
||||
export const TOPICO_DA_TELA: Record<string, string> = {
|
||||
'ao-vivo': 'inicio', console: 'console', eventos: 'eventos', conversa: 'conversa', reacoes: 'reacoes',
|
||||
'ao-vivo': 'inicio', console: 'console', pessoas: 'pessoas', eventos: 'eventos', conversa: 'conversa', reacoes: 'reacoes',
|
||||
movimento: 'movimento', ajustes: 'tela', rosto: 'aparencia', guia: 'inicio', criar: 'criar', 'dia-a-dia': 'aprendizado',
|
||||
};
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
/**
|
||||
* Reconhecimento de pessoas (/pessoas): quem ele conhece, os rostos que viu e ainda não sabe de quem
|
||||
* são, e as ações para ensinar ("esta é a Ana"). Tudo fica na placa. Estado por SSE ('pessoas').
|
||||
*/
|
||||
import { lerToken, pedir, postar } from './api';
|
||||
import { avisos, tentar } from './avisos.svelte';
|
||||
import { ouvir } from './estado.svelte';
|
||||
|
||||
export interface FotoRosto { id: number; recorte: string }
|
||||
export interface Pessoa {
|
||||
id: number;
|
||||
nome: string;
|
||||
visto: number | null;
|
||||
vezes: number;
|
||||
afinidade: number;
|
||||
amostras: number;
|
||||
fotos: FotoRosto[];
|
||||
}
|
||||
export interface RostoSemNome { id: number; horario: number; evento: number; recorte: string; parecido: number; grupo: number }
|
||||
export interface StatusPessoas {
|
||||
config: { ligado: boolean };
|
||||
disponivel: boolean;
|
||||
motivo: string;
|
||||
pessoas: Pessoa[];
|
||||
sem_nome: RostoSemNome[];
|
||||
/** ids dos rostos sem nome agrupados por semelhança (provavelmente a mesma pessoa) */
|
||||
grupos: number[][];
|
||||
rostos: number;
|
||||
varrendo: { feitas: number; total: number } | null;
|
||||
limiar: number;
|
||||
dias_sem_nome: number;
|
||||
}
|
||||
|
||||
export function urlRosto(arquivo: string) {
|
||||
const t = lerToken();
|
||||
return '/pessoas/rosto/' + arquivo + (t ? '?t=' + encodeURIComponent(t) : '');
|
||||
}
|
||||
|
||||
class Pessoas {
|
||||
status = $state<StatusPessoas | null>(null);
|
||||
|
||||
async carregar() {
|
||||
try {
|
||||
this.status = await pedir<StatusPessoas>('/pessoas');
|
||||
} catch (e) {
|
||||
avisos.erro(e);
|
||||
}
|
||||
}
|
||||
|
||||
private async enviar(rota: string, dados: object, ok?: string) {
|
||||
const r = await tentar(() => postar<StatusPessoas>(rota, dados), ok);
|
||||
if (r) this.status = r;
|
||||
return !!r;
|
||||
}
|
||||
|
||||
marcar(rostos: number[], alvo: { pessoa?: number; nome?: string }) {
|
||||
const quem = alvo.nome ?? this.status?.pessoas.find((p) => p.id === alvo.pessoa)?.nome;
|
||||
return this.enviar('/pessoas/marcar', { rostos, ...alvo }, quem ? `Aprendi o rosto de ${quem}.` : undefined);
|
||||
}
|
||||
descartar(rostos: number[]) {
|
||||
return this.enviar('/pessoas/marcar', { rostos }, 'Rostos descartados.');
|
||||
}
|
||||
renomear(pessoa: number, nome: string) {
|
||||
return this.enviar('/pessoas/renomear', { pessoa, nome }, 'Nome trocado.');
|
||||
}
|
||||
apagar(pessoa: number) {
|
||||
return this.enviar('/pessoas/apagar', { pessoa }, 'Pessoa apagada, com todas as fotos dela.');
|
||||
}
|
||||
ligar(ligado: boolean) {
|
||||
return this.enviar('/pessoas/config', { ligado });
|
||||
}
|
||||
varrer() {
|
||||
return this.enviar('/pessoas/varrer', {}, 'Procurando rostos nas fotos guardadas…');
|
||||
}
|
||||
}
|
||||
|
||||
export const pessoas = new Pessoas();
|
||||
ouvir('pessoas', (d: StatusPessoas) => (pessoas.status = d));
|
||||
@@ -1,11 +1,12 @@
|
||||
/** 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' | 'console';
|
||||
export type Rota = 'ao-vivo' | 'eventos' | 'conversa' | 'reacoes' | 'movimento' | 'ajustes' | 'rosto' | 'guia' | 'criar' | 'dia-a-dia' | 'console' | 'pessoas';
|
||||
|
||||
export const ROTAS: { id: Rota; titulo: string; atalho: string }[] = [
|
||||
{ id: 'ao-vivo', titulo: 'Ao vivo', atalho: 'g v' },
|
||||
{ id: 'console', titulo: 'Console', atalho: 'g k' },
|
||||
{ id: 'eventos', titulo: 'Eventos', atalho: 'g e' },
|
||||
{ id: 'pessoas', titulo: 'Pessoas', atalho: 'g p' },
|
||||
{ id: 'conversa', titulo: 'Conversa', atalho: 'g c' },
|
||||
{ id: 'reacoes', titulo: 'Reações', atalho: 'g r' },
|
||||
{ id: 'movimento', titulo: 'Movimento', atalho: 'g m' },
|
||||
|
||||
@@ -0,0 +1,307 @@
|
||||
<script lang="ts">
|
||||
import { Check, Pencil, ScanFace, ShieldCheck, Trash2, UserPlus, X } from '@lucide/svelte';
|
||||
import { onMount } from 'svelte';
|
||||
import Card from '../lib/componentes/Card.svelte';
|
||||
import Selo from '../lib/componentes/Selo.svelte';
|
||||
import Toggle from '../lib/componentes/Toggle.svelte';
|
||||
import { estado } from '../lib/estado.svelte';
|
||||
import { pessoas, urlRosto, type Pessoa } from '../lib/pessoas.svelte';
|
||||
|
||||
// Quem ele conhece e os rostos que ainda não sabe de quem são. Marcar "esta é a Ana" ensina;
|
||||
// com 3 a 5 fotos de cada pessoa ele reconhece bem. Tudo fica na placa.
|
||||
onMount(() => pessoas.carregar());
|
||||
const st = $derived(pessoas.status);
|
||||
|
||||
let escolhidos = $state<number[]>([]);
|
||||
let novoNome = $state('');
|
||||
let editando = $state<number | null>(null);
|
||||
let nomeEditado = $state('');
|
||||
let apagando = $state<number | null>(null);
|
||||
|
||||
const alternar = (id: number) =>
|
||||
(escolhidos = escolhidos.includes(id) ? escolhidos.filter((x) => x !== id) : [...escolhidos, id]);
|
||||
|
||||
async function paraPessoa(p: Pessoa) {
|
||||
if (await pessoas.marcar(escolhidos, { pessoa: p.id })) escolhidos = [];
|
||||
}
|
||||
async function criar(e: SubmitEvent) {
|
||||
e.preventDefault();
|
||||
if (novoNome.trim() && (await pessoas.marcar(escolhidos, { nome: novoNome.trim() }))) {
|
||||
escolhidos = [];
|
||||
novoNome = '';
|
||||
}
|
||||
}
|
||||
async function descartar() {
|
||||
if (await pessoas.descartar(escolhidos)) escolhidos = [];
|
||||
}
|
||||
async function salvarNome(p: Pessoa) {
|
||||
if (nomeEditado.trim() && (await pessoas.renomear(p.id, nomeEditado.trim()))) editando = null;
|
||||
}
|
||||
function quando(s: number | null) {
|
||||
if (!s) return 'ainda não foi visto';
|
||||
const min = Math.round((estado.agora / 1000 - s) / 60);
|
||||
if (min < 1) return 'visto agora há pouco';
|
||||
if (min < 60) return `visto há ${min} min`;
|
||||
if (min < 1440) return `visto há ${Math.round(min / 60)} h`;
|
||||
return `visto há ${Math.round(min / 1440)} dia(s)`;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="pessoas">
|
||||
<Card titulo="Reconhecimento de pessoas" ajuda="pessoas" descricao="Pelas fotos dos eventos ele descobre quem chegou e cumprimenta pelo nome.">
|
||||
{#snippet acoes()}
|
||||
<button class="btn pequeno" onclick={() => pessoas.varrer()} disabled={!st?.disponivel || !!st?.varrendo}>
|
||||
<ScanFace size={14} /> Procurar nas fotos guardadas
|
||||
</button>
|
||||
{/snippet}
|
||||
{#if !st}
|
||||
<p class="fraco">Carregando…</p>
|
||||
{:else}
|
||||
<div class="topo">
|
||||
<Toggle rotulo="Reconhecer quem chega" checked={st.config.ligado} disabled={!st.disponivel} onchange={(v) => pessoas.ligar(v)} />
|
||||
{#if !st.disponivel}
|
||||
<Selo tom="aviso">{st.motivo}</Selo>
|
||||
{:else if st.varrendo}
|
||||
<Selo tom="aviso">Procurando rostos: {st.varrendo.feitas} de {st.varrendo.total} fotos</Selo>
|
||||
{/if}
|
||||
<p class="privacidade"><ShieldCheck size={15} /> Tudo fica na placa: fotos, rostos e nomes não vão para a internet. Rostos sem nome somem em {st.dias_sem_nome} dias; apagar uma pessoa apaga tudo dela.</p>
|
||||
</div>
|
||||
{/if}
|
||||
</Card>
|
||||
|
||||
{#if st}
|
||||
<Card titulo="Quem ele conhece" descricao={st.pessoas.length ? 'Mais fotos marcadas = reconhece melhor (3 a 5 por pessoa já é bom).' : 'Ninguém ainda. Marque os rostos abaixo para ensinar.'}>
|
||||
{#if st.pessoas.length}
|
||||
<ul class="lista">
|
||||
{#each st.pessoas as p (p.id)}
|
||||
<li class="pessoa">
|
||||
<div class="fotos">
|
||||
{#each p.fotos.slice(0, 4) as f (f.id)}<img src={urlRosto(f.recorte)} alt="" loading="lazy" />{/each}
|
||||
</div>
|
||||
<div class="info">
|
||||
{#if editando === p.id}
|
||||
<form class="renomear" onsubmit={(e) => { e.preventDefault(); salvarNome(p); }}>
|
||||
<input bind:value={nomeEditado} maxlength="30" aria-label="Novo nome" />
|
||||
<button class="btn pequeno" type="submit" aria-label="Salvar nome"><Check size={14} /></button>
|
||||
<button class="btn pequeno fantasma" type="button" onclick={() => (editando = null)} aria-label="Cancelar"><X size={14} /></button>
|
||||
</form>
|
||||
{:else}
|
||||
<strong>{p.nome}</strong>
|
||||
{/if}
|
||||
<small>{quando(p.visto)} · {p.vezes} {p.vezes === 1 ? 'vez' : 'vezes'} · {p.amostras} foto(s) de exemplo</small>
|
||||
<span class="afinidade" title="Afinidade: cresce com carinho e conversa logo depois de reconhecer">
|
||||
<span class="barra"><span style:width="{Math.min(100, p.afinidade)}%"></span></span> afinidade {Math.round(p.afinidade)}
|
||||
</span>
|
||||
</div>
|
||||
<div class="botoes">
|
||||
<button class="btn pequeno fantasma" onclick={() => { editando = p.id; nomeEditado = p.nome; }} aria-label={`Renomear ${p.nome}`}><Pencil size={14} /></button>
|
||||
{#if apagando === p.id}
|
||||
<button class="btn pequeno perigo" onclick={() => { pessoas.apagar(p.id); apagando = null; }}>Apagar tudo de {p.nome}</button>
|
||||
<button class="btn pequeno fantasma" onclick={() => (apagando = null)}>Cancelar</button>
|
||||
{:else}
|
||||
<button class="btn pequeno fantasma" onclick={() => (apagando = p.id)} aria-label={`Apagar ${p.nome}`}><Trash2 size={14} /></button>
|
||||
{/if}
|
||||
</div>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
</Card>
|
||||
|
||||
<Card titulo="Rostos sem nome" descricao="Toque nos rostos da mesma pessoa e diga quem é. Foto ruim ou de ninguém: descarte.">
|
||||
{#if st.sem_nome.length}
|
||||
{#each st.grupos as ids, g (ids[0])}
|
||||
{@const todos = ids.every((i) => escolhidos.includes(i))}
|
||||
<section class="grupo" aria-label="Provavelmente a mesma pessoa">
|
||||
<header>
|
||||
<span>{st.grupos.length > 1 ? `Desconhecido ${g + 1}` : 'Desconhecido'} · {ids.length} {ids.length === 1 ? 'foto' : 'fotos'}</span>
|
||||
<button class="btn pequeno fantasma" onclick={() => (escolhidos = todos ? escolhidos.filter((x) => !ids.includes(x)) : [...new Set([...escolhidos, ...ids])])}>
|
||||
{todos ? 'Tirar a seleção' : 'Selecionar todas'}
|
||||
</button>
|
||||
</header>
|
||||
<div class="grade" role="group">
|
||||
{#each st.sem_nome.filter((r) => r.grupo === g) as r (r.id)}
|
||||
<button class="rosto" aria-pressed={escolhidos.includes(r.id)} onclick={() => alternar(r.id)}
|
||||
title={new Date(r.horario * 1000).toLocaleString('pt-BR')}>
|
||||
<img src={urlRosto(r.recorte)} alt="Rosto visto em {new Date(r.horario * 1000).toLocaleString('pt-BR')}" loading="lazy" />
|
||||
{#if escolhidos.includes(r.id)}<span class="marca"><Check size={14} /></span>{/if}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</section>
|
||||
{/each}
|
||||
{#if escolhidos.length}
|
||||
<div class="acoes-marcar">
|
||||
<span>{escolhidos.length} escolhido(s): é…</span>
|
||||
{#each st.pessoas as p (p.id)}
|
||||
<button class="btn pequeno" onclick={() => paraPessoa(p)}>{p.nome}</button>
|
||||
{/each}
|
||||
<form class="novo" onsubmit={criar}>
|
||||
<input bind:value={novoNome} placeholder="Nome de alguém novo" maxlength="30" aria-label="Nome de alguém novo" />
|
||||
<button class="btn pequeno primario" type="submit" disabled={!novoNome.trim()}><UserPlus size={14} /> Criar</button>
|
||||
</form>
|
||||
<button class="btn pequeno fantasma" onclick={descartar}><Trash2 size={14} /> Não é ninguém</button>
|
||||
</div>
|
||||
{/if}
|
||||
{:else}
|
||||
<p class="fraco">Nenhum rosto esperando nome. Eles aparecem aqui quando o sensor vê alguém e a câmera pega o rosto (com luz).</p>
|
||||
{/if}
|
||||
</Card>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.pessoas {
|
||||
display: grid;
|
||||
gap: var(--e-4);
|
||||
}
|
||||
.topo {
|
||||
display: grid;
|
||||
gap: var(--e-2);
|
||||
}
|
||||
.privacidade {
|
||||
display: flex;
|
||||
gap: var(--e-2);
|
||||
align-items: flex-start;
|
||||
margin: 0;
|
||||
font-size: var(--t-xs);
|
||||
color: var(--texto-3);
|
||||
}
|
||||
.privacidade :global(svg) {
|
||||
flex: none;
|
||||
color: var(--ok);
|
||||
margin-top: 1px;
|
||||
}
|
||||
.lista {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: grid;
|
||||
gap: var(--e-2);
|
||||
}
|
||||
.pessoa {
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(0, 1fr) auto;
|
||||
gap: var(--e-3);
|
||||
align-items: center;
|
||||
padding: var(--e-3);
|
||||
border: 1px solid var(--borda);
|
||||
border-radius: var(--raio);
|
||||
background: var(--superficie-2);
|
||||
}
|
||||
@media (max-width: 640px) {
|
||||
.pessoa {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
}
|
||||
.fotos {
|
||||
display: flex;
|
||||
}
|
||||
.fotos img {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border-radius: 50%;
|
||||
object-fit: cover;
|
||||
border: 2px solid var(--superficie);
|
||||
margin-left: -10px;
|
||||
background: var(--superficie-3);
|
||||
}
|
||||
.fotos img:first-child {
|
||||
margin-left: 0;
|
||||
}
|
||||
.info {
|
||||
display: grid;
|
||||
gap: 2px;
|
||||
min-width: 0;
|
||||
}
|
||||
.info small {
|
||||
color: var(--texto-3);
|
||||
font-size: var(--t-xs);
|
||||
}
|
||||
.afinidade {
|
||||
display: flex;
|
||||
gap: var(--e-2);
|
||||
align-items: center;
|
||||
font-size: var(--t-xs);
|
||||
color: var(--texto-3);
|
||||
}
|
||||
.barra {
|
||||
width: 90px;
|
||||
height: 5px;
|
||||
border-radius: var(--raio-pill);
|
||||
background: var(--superficie-3);
|
||||
overflow: hidden;
|
||||
}
|
||||
.barra span {
|
||||
display: block;
|
||||
height: 100%;
|
||||
background: #ff6b9a;
|
||||
}
|
||||
.botoes, .renomear, .novo, .acoes-marcar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--e-2);
|
||||
align-items: center;
|
||||
}
|
||||
.renomear input, .novo input {
|
||||
min-width: 0;
|
||||
width: 180px;
|
||||
}
|
||||
.grupo + .grupo {
|
||||
margin-top: var(--e-4);
|
||||
}
|
||||
.grupo header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: var(--e-2);
|
||||
margin-bottom: var(--e-2);
|
||||
font-size: var(--t-sm);
|
||||
color: var(--texto-2);
|
||||
}
|
||||
.grade {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(76px, 1fr));
|
||||
gap: var(--e-2);
|
||||
}
|
||||
.rosto {
|
||||
position: relative;
|
||||
padding: 0;
|
||||
border: 2px solid transparent;
|
||||
border-radius: var(--raio);
|
||||
background: var(--superficie-3);
|
||||
overflow: hidden;
|
||||
cursor: pointer;
|
||||
aspect-ratio: 1;
|
||||
}
|
||||
.rosto img {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
.rosto[aria-pressed='true'] {
|
||||
border-color: var(--destaque);
|
||||
}
|
||||
.marca {
|
||||
position: absolute;
|
||||
top: 4px;
|
||||
right: 4px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 50%;
|
||||
background: var(--destaque);
|
||||
color: var(--destaque-texto);
|
||||
}
|
||||
.acoes-marcar {
|
||||
margin-top: var(--e-3);
|
||||
padding-top: var(--e-3);
|
||||
border-top: 1px solid var(--borda);
|
||||
font-size: var(--t-sm);
|
||||
}
|
||||
.perigo {
|
||||
color: var(--perigo);
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user