feat(rosto): motor de palpebras, 4 rostos novos, formato do olho e tela Rosto

- rosto-motor.js: emocoes como posicao de palpebra (cima, inclinacao, baixo)
  com transicao suave; respiracao, sacadas e piscada que achata o olho
- rostos novos Carinha Viva, Bloco, Matriz (LEDs) e Lente (iris/pupila);
  os classicos continuam; emoji/figurinha nos olhos seguem no SVG
- formato do olho (padrao, redondo, pilula, quadrado, largo) para Viva,
  Bloco e Matriz; salvo na aparencia e enviado como ordem "formato X"
- toque na tela do S10: cutucar = susto e risada, deslizar = carinho,
  tocar dormindo acorda
- painel: tela Rosto (celular em destaque, cartoes de rosto, formatos,
  cores, testar emocoes so na previa ou no celular, lista do que ja esta
  pronto); Ao vivo leva a ela; paleta ganha rostos e formatos
- corrige /comando rosto que nao salvava a aparencia

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
julio
2026-10-06 01:33:56 -03:00
co-authored by Claude Opus 5.5
parent b6b8e2efd5
commit 972dd8dd66
18 changed files with 1054 additions and 95 deletions
+9 -5
View File
@@ -10,12 +10,13 @@
import { favoritos, gesto, pararTudo } from './lib/comandos.svelte';
import { conectar, estado } from './lib/estado.svelte';
import { paleta } from './lib/paleta.svelte';
import { nav, ROTAS, type Rota } from './lib/rotas.svelte';
import { nav, ROTAS, SECUNDARIAS, tituloDa, type Rota } from './lib/rotas.svelte';
import AoVivo from './telas/AoVivo.svelte';
import Ajustes from './telas/Ajustes.svelte';
import Conversa from './telas/Conversa.svelte';
import Movimento from './telas/Movimento.svelte';
import Reacoes from './telas/Reacoes.svelte';
import Rosto from './telas/Rosto.svelte';
const TELAS: Record<Rota, Component> = {
'ao-vivo': AoVivo,
@@ -23,8 +24,9 @@
reacoes: Reacoes,
movimento: Movimento,
ajustes: Ajustes,
rosto: Rosto,
};
const ICONES: Record<Rota, Component<any>> = {
const ICONES: Record<string, Component<any>> = {
'ao-vivo': Video,
conversa: MessageCircle,
reacoes: Smile,
@@ -32,6 +34,8 @@
ajustes: Settings,
};
const Tela = $derived(TELAS[nav.atual]);
// Páginas secundárias acendem a seção de onde vieram.
const secao = $derived(SECUNDARIAS.find((r) => r.id === nav.atual)?.volta ?? nav.atual);
// ---------- tema
let tema = $state<'dark' | 'light'>('dark');
@@ -126,7 +130,7 @@
{#each ROTAS as r (r.id)}
{@const Icone = ICONES[r.id]}
<li>
<a href={'#/' + r.id} aria-current={nav.atual === r.id ? 'page' : undefined}>
<a href={'#/' + r.id} aria-current={secao === r.id ? 'page' : undefined}>
<Icone size={20} /> <span>{r.titulo}</span>
</a>
</li>
@@ -138,7 +142,7 @@
</nav>
<header class="topo">
<h1 class="titulo">{ROTAS.find((r) => r.id === nav.atual)?.titulo}</h1>
<h1 class="titulo">{tituloDa(nav.atual)}</h1>
<div class="status linha">
<Selo tom={estado.conexao === 'aberta' ? 'ok' : estado.conexao === 'conectando' ? 'aviso' : 'perigo'}>
{conexaoTexto}
@@ -169,7 +173,7 @@
<Tela />
</main>
{#if nav.atual !== 'ao-vivo' && nav.atual !== 'reacoes'}
{#if nav.atual !== 'ao-vivo' && nav.atual !== 'reacoes' && nav.atual !== 'rosto'}
<MiniRosto />
{/if}
</div>
+16
View File
@@ -53,6 +53,22 @@ export const favoritos = new Favoritos();
export const gesto = (nome: string) => tentar(() => postar('/comando', { tipo: 'gesto', valor: nome }));
export const rosto = (nome: string) => tentar(() => postar('/comando', { tipo: 'rosto', valor: nome }));
export const cor = (rgb: [number, number, number]) => tentar(() => postar('/comando', { tipo: 'cor', valor: rgb }));
export const formato = (nome: string) => tentar(() => postar('/comando', { tipo: 'formato', valor: nome }));
/** Rostos: os novos usam o motor de pálpebras (rosto-motor.js) e aceitam formato de olho. */
export const ROSTOS_INFO: Record<string, { nome: string; descricao: string; novo: boolean; formato: boolean }> = {
vivo: { nome: 'Carinha Viva', descricao: 'O rosto de sempre com pálpebras, brilho e micro-movimentos.', novo: true, formato: true },
bloco: { nome: 'Bloco', descricao: 'Olhos quadrados que se recortam, estilo robô de mesa.', novo: true, formato: true },
matriz: { nome: 'Matriz', descricao: 'Painel de LEDs retrô; cada olho é feito de pontos.', novo: true, formato: true },
lente: { nome: 'Lente', descricao: 'Íris e pupila que dilata, pálpebras de metal.', novo: true, formato: false },
carinha: { nome: 'Clássico', descricao: 'O desenho original, com boca.', novo: false, formato: false },
miudo: { nome: 'Miúdo', descricao: 'Clássico com olhos menores.', novo: false, formato: false },
eilik: { nome: 'Eilik', descricao: 'Clássico sem boca.', novo: false, formato: false },
};
export const FORMATOS_INFO: Record<string, string> = {
padrao: 'Padrão', redondo: 'Redondo', pilula: 'Pílula', quadrado: 'Quadrado', largo: 'Largo',
};
export const nomeRosto = (id: string) => ROSTOS_INFO[id]?.nome ?? id;
export const recarregarCelular = () =>
tentar(async () => {
const r = await postar<{ mensagem: string }>('/comando', { tipo: 'recarregar' });
@@ -36,6 +36,11 @@
];
onDestroy(() => cancelar.forEach((c) => c()));
/** Mostra uma ordem (gesto, rosto, formato) só na prévia, sem mandar ao celular. */
export function ordem(texto: string) {
enviar({ tipo: 'ordem', ordem: texto });
}
/** Para a tela de reações: mostra uma receita sem mandar ao celular. */
export function previa(receita: unknown) {
enviar({ tipo: 'previa-reacao', receita });
+3 -1
View File
@@ -19,7 +19,8 @@ class Estado {
versao = $state('');
autenticacao = $state(false);
estop = $state({ ativo: false, desde: null as number | null });
aparencia = $state({ rosto: 'carinha', cor: [92, 214, 255] as [number, number, number] });
aparencia = $state<{ rosto: string; cor: [number, number, number]; formato?: string }>({ rosto: 'carinha', cor: [92, 214, 255], formato: 'padrao' });
formatos = $state<string[]>([]);
gestos = $state<string[]>([]);
rostos = $state<string[]>([]);
app = $state<Telemetria | null>(null);
@@ -62,6 +63,7 @@ function aplicarFoto(f: EstadoServidor) {
estado.aparencia = f.aparencia;
estado.gestos = f.gestos;
estado.rostos = f.rostos;
estado.formatos = f.formatos ?? [];
if (f.app) {
estado.app = f.app;
estado.appRecebidoEm = Date.now();
+6 -2
View File
@@ -1,5 +1,7 @@
/** Registro da command palette (Ctrl+K): cada parte do painel contribui com itens. */
import { cor, CORES, gesto, GESTOS_INFO, gestosDisponiveis, pararTudo, rearmar, recarregarCelular, rosto } from './comandos.svelte';
import {
cor, CORES, formato, FORMATOS_INFO, gesto, GESTOS_INFO, gestosDisponiveis, nomeRosto, pararTudo, rearmar, recarregarCelular, rosto,
} from './comandos.svelte';
import { estado } from './estado.svelte';
import { nav, ROTAS } from './rotas.svelte';
@@ -33,6 +35,8 @@ export function itensDaPaleta(): ItemPaleta[] {
// Itens base: navegação, ações gerais, gestos, rostos e cores.
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') },
...estado.formatos.map((f) => ({ id: 'formato-' + f, grupo: 'Formato dos olhos', titulo: FORMATOS_INFO[f] ?? f, fazer: () => formato(f) })),
estado.estop.ativo
? { id: 'rearmar', grupo: 'Ações', titulo: 'Rearmar motores', fazer: rearmar }
: { id: 'estop', grupo: 'Ações', titulo: 'Parar tudo (E-Stop)', dica: 'Shift+Espaço', fazer: pararTudo },
@@ -43,6 +47,6 @@ registrarNaPaleta(() => [
titulo: `${GESTOS_INFO[g].emoji} ${GESTOS_INFO[g].nome}`,
fazer: () => gesto(g),
})),
...estado.rostos.map((r) => ({ id: 'rosto-' + r, grupo: 'Rosto', titulo: 'Rosto ' + r, fazer: () => rosto(r) })),
...estado.rostos.map((r) => ({ id: 'rosto-' + r, grupo: 'Rosto', titulo: 'Rosto ' + nomeRosto(r), fazer: () => rosto(r) })),
...CORES.map((c) => ({ id: 'cor-' + c.nome, grupo: 'Cor dos olhos', titulo: c.nome, fazer: () => cor(c.rgb) })),
]);
+7 -2
View File
@@ -1,6 +1,6 @@
/** Rotas por hash (#/conversa): o servidor só precisa entregar index.html em "/". */
export type Rota = 'ao-vivo' | 'conversa' | 'reacoes' | 'movimento' | 'ajustes';
export type Rota = 'ao-vivo' | 'conversa' | 'reacoes' | 'movimento' | 'ajustes' | 'rosto';
export const ROTAS: { id: Rota; titulo: string; atalho: string }[] = [
{ id: 'ao-vivo', titulo: 'Ao vivo', atalho: 'g v' },
@@ -10,9 +10,14 @@ export const ROTAS: { id: Rota; titulo: string; atalho: string }[] = [
{ id: 'ajustes', titulo: 'Ajustes', atalho: 'g a' },
];
/** Páginas abertas a partir de outra tela (não aparecem na navegação). */
export const SECUNDARIAS: { id: Rota; titulo: string; volta: Rota }[] = [{ id: 'rosto', titulo: 'Rosto', volta: 'ao-vivo' }];
export const tituloDa = (id: Rota) => [...ROTAS, ...SECUNDARIAS].find((r) => r.id === id)?.titulo ?? '';
function lerHash(): Rota {
const id = location.hash.replace(/^#\/?/, '').split('?')[0] as Rota;
return ROTAS.some((r) => r.id === id) ? id : 'ao-vivo';
return ROTAS.some((r) => r.id === id) || SECUNDARIAS.some((r) => r.id === id) ? id : 'ao-vivo';
}
class Navegacao {
+2 -1
View File
@@ -69,7 +69,8 @@ export interface EstadoServidor {
seq: number;
autenticacao: boolean;
estop: { ativo: boolean; desde: number | null };
aparencia: { rosto: string; cor: [number, number, number] };
aparencia: { rosto: string; cor: [number, number, number]; formato?: string };
formatos?: string[];
gestos: string[];
rostos: string[];
app?: Telemetria;
+15 -71
View File
@@ -6,7 +6,7 @@
import { postar } from '../lib/api';
import { salvarApp } from '../lib/ajustes.svelte';
import { tentar } from '../lib/avisos.svelte';
import { cor, CORES, hex, recarregarCelular, rgb, rosto } from '../lib/comandos.svelte';
import { FORMATOS_INFO, hex, nomeRosto, recarregarCelular, ROSTOS_INFO } from '../lib/comandos.svelte';
import Celular from '../lib/componentes/Celular.svelte';
import Gestos from '../lib/componentes/Gestos.svelte';
import Grupo from '../lib/componentes/Grupo.svelte';
@@ -154,29 +154,16 @@
</Grupo>
<Grupo titulo="Aparência">
<Linha icone={Smile} cor="#5ac8fa" rotulo="Rosto">
<div class="segmentado" role="radiogroup" aria-label="Rosto">
{#each estado.rostos as r (r)}
<button role="radio" aria-checked={estado.aparencia.rosto === r} onclick={() => rosto(r)}>{r}</button>
{/each}
</div>
</Linha>
<Linha icone={Palette} cor="#ff9500" rotulo="Cor dos olhos">
<div class="cores">
{#each CORES as c (c.nome)}
<button
class="cor"
style="--c: {hex(c.rgb)}"
aria-label={c.nome}
aria-pressed={hex(estado.aparencia.cor) === hex(c.rgb)}
onclick={() => cor(c.rgb)}
></button>
{/each}
<label class="cor livre" title="Outra cor">
<input type="color" value={hex(estado.aparencia.cor)} onchange={(e) => cor(rgb(e.currentTarget.value))} />
<span class="sr-only">Escolher outra cor</span>
</label>
</div>
<Linha
icone={Palette}
cor="#ff9500"
rotulo="Rosto, formato e cor"
detalhe={nomeRosto(estado.aparencia.rosto) + (ROSTOS_INFO[estado.aparencia.rosto]?.formato ? ' · ' + (FORMATOS_INFO[estado.aparencia.formato ?? 'padrao'] ?? '') : '')}
href="#/rosto"
>
{#snippet acessorio()}
<span class="amostra-cor" style="--c: {hex(estado.aparencia.cor)}" aria-hidden="true"></span>
{/snippet}
</Linha>
</Grupo>
@@ -314,55 +301,12 @@
gap: var(--e-5);
min-width: 0;
}
.segmentado {
display: flex;
background: var(--superficie-2);
border-radius: 9px;
padding: 2px;
gap: 2px;
}
.segmentado button {
flex: 1;
min-height: 32px;
border: 0;
border-radius: 7px;
background: transparent;
color: var(--texto-2);
font: 500 var(--t-sm) var(--fonte);
text-transform: capitalize;
cursor: pointer;
}
.segmentado button[aria-checked='true'] {
background: var(--superficie);
color: var(--texto);
box-shadow: 0 1px 3px rgb(0 0 0 / 0.25);
}
.cores {
display: flex;
flex-wrap: wrap;
gap: var(--e-2);
}
.cor {
width: 34px;
height: 34px;
.amostra-cor {
width: 22px;
height: 22px;
border-radius: 50%;
border: 2px solid var(--borda-forte);
background: var(--c);
cursor: pointer;
position: relative;
}
.cor[aria-pressed='true'] {
outline: 2px solid var(--texto);
outline-offset: 2px;
}
.livre {
background: conic-gradient(red, yellow, lime, cyan, blue, magenta, red);
}
.livre input {
position: absolute;
inset: 0;
opacity: 0;
cursor: pointer;
border: 2px solid var(--borda-forte);
}
.feed {
list-style: none;
+412
View File
@@ -0,0 +1,412 @@
<script lang="ts">
import { ArrowLeft, Check } from '@lucide/svelte';
import {
cor, CORES, FORMATOS_INFO, formato, gesto, GESTOS_INFO, hex, nomeRosto, rgb, rosto, ROSTOS_INFO,
} from '../lib/comandos.svelte';
import RostoPrevia from '../lib/componentes/RostoPrevia.svelte';
import Toggle from '../lib/componentes/Toggle.svelte';
import { estado } from '../lib/estado.svelte';
let previa = $state<{ ordem: (t: string) => void }>();
// Por padrão as emoções só aparecem aqui na prévia; ligado, vão também para o celular.
let noCelular = $state(false);
const atual = $derived(estado.aparencia.rosto);
const formatoAtual = $derived(estado.aparencia.formato ?? 'padrao');
const aceitaFormato = $derived(!!ROSTOS_INFO[atual]?.formato);
const novos = $derived(estado.rostos.filter((r) => ROSTOS_INFO[r]?.novo));
const classicos = $derived(estado.rostos.filter((r) => !ROSTOS_INFO[r]?.novo));
const formatos = $derived(estado.formatos.length ? estado.formatos : Object.keys(FORMATOS_INFO));
const EMOCOES = ['feliz', 'coracao', 'surpreso', 'triste', 'bravo', 'piscadinha', 'olhar_esq', 'olhar_dir', 'dormir', 'acordar'];
function emocao(g: string) {
if (noCelular) gesto(g);
else previa?.ordem(g);
}
const IDEIAS: { titulo: string; texto: string; feito: boolean; onde: string }[] = [
{ titulo: 'Pálpebras no lugar de formas prontas', texto: 'Cada emoção é posição de pálpebra; as trocas ficam suaves em vez de saltar.', feito: true, onde: 'rosto' },
{ titulo: 'Nunca parado', texto: 'Respiração, sacadas e piscada que achata o olho. Também protege a tela AMOLED.', feito: true, onde: 'rosto' },
{ titulo: 'Toque na tela', texto: 'Cutucar dá susto e risada; deslizar o dedo é carinho; tocar acorda.', feito: true, onde: 'rosto' },
{ titulo: 'Fala pelo próprio celular', texto: 'A resposta da conversa sai no alto-falante do S10, com os olhos pulsando na voz.', feito: false, onde: 'app' },
{ titulo: 'Ritmo do dia', texto: 'Boceja à noite, fica sonolento com bateria baixa, mostra um raio quando carrega.', feito: false, onde: 'rosto e servidor' },
{ titulo: 'Contato visual', texto: 'Sustenta o olhar de quem a câmera vê e desvia quando fica tímido.', feito: false, onde: 'rosto' },
{ titulo: 'Humor que dura', texto: 'Energia e alegria sobem com conversa e carinho e caem devagar.', feito: false, onde: 'servidor e IA' },
{ titulo: 'Hora nos olhos', texto: 'Ao perguntar a hora, ela aparece dentro dos olhos por alguns segundos.', feito: false, onde: 'rosto' },
];
</script>
<div class="rosto-pagina">
<a class="voltar" href="#/ao-vivo"><ArrowLeft size={16} /> Ao vivo</a>
<header>
<span class="rotulo">Galaxy S10 · 19:9 · AMOLED</span>
<h1>Rosto do Carinha</h1>
<p>Escolha o rosto, o formato dos olhos e a cor. A prévia abaixo é o mesmo rosto que roda no celular; clique nela para cutucar.</p>
</header>
<section class="bancada" aria-label="Prévia">
<div class="celular">
<div class="tela"><RostoPrevia bind:this={previa} titulo="Prévia do rosto" /></div>
<span class="camera-frontal" aria-hidden="true"></span>
<span class="etiqueta">{nomeRosto(atual).toUpperCase()}{aceitaFormato ? ' · ' + (FORMATOS_INFO[formatoAtual] ?? formatoAtual).toUpperCase() : ''}</span>
</div>
<div class="emocoes" role="group" aria-label="Testar emoção">
{#each EMOCOES.filter((g) => GESTOS_INFO[g]) as g (g)}
<button class="tecla" onclick={() => emocao(g)}>{GESTOS_INFO[g].emoji} {GESTOS_INFO[g].nome}</button>
{/each}
</div>
<Toggle
rotulo="Mandar as emoções também para o celular"
ajuda="Desligado, elas aparecem só na prévia"
checked={noCelular}
onchange={(v) => (noCelular = v)}
/>
</section>
<section class="bloco-escolha" aria-labelledby="t-rostos">
<h2 id="t-rostos">Rosto</h2>
<div class="cartoes" role="radiogroup" aria-label="Rosto">
{#each novos as r (r)}
<button class="cartao" role="radio" aria-checked={atual === r} onclick={() => rosto(r)}>
<span class="nome">{ROSTOS_INFO[r].nome} {#if atual === r}<Check size={16} />{/if}</span>
<span class="desc">{ROSTOS_INFO[r].descricao}</span>
</button>
{/each}
</div>
{#if classicos.length}
<div class="classicos" role="radiogroup" aria-label="Rostos clássicos">
<span class="rotulo">Clássicos</span>
{#each classicos as r (r)}
<button class="tecla" role="radio" aria-checked={atual === r} onclick={() => rosto(r)}>{nomeRosto(r)}</button>
{/each}
</div>
{/if}
</section>
<section class="bloco-escolha" aria-labelledby="t-formato">
<h2 id="t-formato">Formato dos olhos</h2>
<div class="formatos" role="radiogroup" aria-label="Formato dos olhos">
{#each formatos as f (f)}
<button class="formato" role="radio" aria-checked={formatoAtual === f} disabled={!aceitaFormato} onclick={() => formato(f)}>
<span class="amostra {f}" aria-hidden="true"></span>
{FORMATOS_INFO[f] ?? f}
</button>
{/each}
</div>
{#if !aceitaFormato}
<p class="fraco pequeno-texto">O formato vale para Carinha Viva, Bloco e Matriz.</p>
{/if}
</section>
<section class="bloco-escolha" aria-labelledby="t-cor">
<h2 id="t-cor">Cor dos olhos</h2>
<div class="cores">
{#each CORES as c (c.nome)}
<button class="cor" style="--c: {hex(c.rgb)}" aria-label={c.nome} aria-pressed={hex(estado.aparencia.cor) === hex(c.rgb)} onclick={() => cor(c.rgb)}></button>
{/each}
<label class="cor livre" title="Outra cor">
<input type="color" value={hex(estado.aparencia.cor)} onchange={(e) => cor(rgb(e.currentTarget.value))} />
<span class="sr-only">Escolher outra cor</span>
</label>
</div>
</section>
<section class="bloco-escolha" aria-labelledby="t-vivo">
<h2 id="t-vivo">O que deixa o Carinha vivo</h2>
<ul class="ideias">
{#each IDEIAS as i (i.titulo)}
<li>
<div class="linha entre">
<h3>{i.titulo}</h3>
<span class="status" class:feito={i.feito}>{i.feito ? 'Pronto' : 'Próximo'}</span>
</div>
<p>{i.texto}</p>
<span class="onde">{i.onde}</span>
</li>
{/each}
</ul>
</section>
</div>
<style>
.rosto-pagina {
display: grid;
gap: var(--e-6);
max-width: 1120px;
}
.voltar {
display: inline-flex;
align-items: center;
gap: var(--e-1);
width: fit-content;
color: var(--texto-2);
text-decoration: none;
font-size: var(--t-sm);
}
header {
display: grid;
gap: var(--e-2);
}
header h1 {
font-size: clamp(1.6rem, 3.5vw, 2.3rem);
letter-spacing: -0.01em;
}
header p {
color: var(--texto-2);
max-width: 65ch;
}
.rotulo {
font: 500 11px var(--fonte-mono);
letter-spacing: 0.12em;
text-transform: uppercase;
color: var(--texto-3);
}
.bancada {
display: grid;
gap: var(--e-4);
}
.celular {
position: relative;
aspect-ratio: 19 / 9;
max-width: 100%;
border-radius: clamp(18px, 3.4vw, 40px);
padding: clamp(6px, 0.9vw, 12px);
background: linear-gradient(145deg, #2a313b, #11161d 45%, #222932);
box-shadow: 0 30px 80px rgb(0 0 0 / 0.45), inset 0 1px 0 rgb(255 255 255 / 0.08);
}
.tela {
width: 100%;
height: 100%;
border-radius: clamp(13px, 2.6vw, 30px);
overflow: hidden;
background: #000;
}
.camera-frontal {
position: absolute;
left: clamp(14px, 2.2vw, 26px);
top: 50%;
translate: 0 -50%;
width: clamp(6px, 0.8vw, 10px);
aspect-ratio: 1;
border-radius: 50%;
background: radial-gradient(circle at 35% 35%, #2b3a55, #05070a 70%);
}
.etiqueta {
position: absolute;
right: clamp(20px, 3vw, 36px);
top: clamp(14px, 2vw, 24px);
font: 500 11px var(--fonte-mono);
color: rgb(255 255 255 / 0.4);
letter-spacing: 0.08em;
pointer-events: none;
}
.emocoes,
.classicos {
display: flex;
flex-wrap: wrap;
gap: var(--e-2);
align-items: center;
}
.tecla {
min-height: 40px;
padding: 0 var(--e-4);
border: 1px solid var(--borda);
border-radius: var(--raio-pill);
background: var(--superficie);
color: var(--texto);
font: 500 var(--t-sm) var(--fonte);
cursor: pointer;
}
.tecla:hover {
border-color: var(--destaque);
}
.tecla[aria-checked='true'] {
background: var(--destaque);
border-color: var(--destaque);
color: var(--destaque-texto);
}
.bloco-escolha {
display: grid;
gap: var(--e-3);
}
.bloco-escolha h2 {
font-size: var(--t-lg);
}
.cartoes {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: var(--e-3);
}
.cartao {
display: grid;
gap: var(--e-1);
align-content: start;
padding: var(--e-4);
text-align: left;
border: 1px solid var(--borda);
border-radius: var(--raio);
background: var(--superficie);
color: var(--texto);
font: inherit;
cursor: pointer;
}
.cartao:hover {
border-color: var(--borda-forte);
}
.cartao[aria-checked='true'] {
border-color: var(--destaque);
background: var(--destaque-suave);
}
.nome {
display: flex;
align-items: center;
justify-content: space-between;
font-weight: 600;
}
.nome :global(svg) {
color: var(--destaque);
}
.desc {
font-size: var(--t-sm);
color: var(--texto-2);
line-height: 1.4;
}
.formatos {
display: grid;
grid-template-columns: repeat(5, minmax(0, 1fr));
gap: var(--e-2);
}
.formato {
display: grid;
justify-items: center;
gap: var(--e-2);
padding: var(--e-3) var(--e-2);
border: 1px solid var(--borda);
border-radius: var(--raio);
background: var(--superficie);
color: var(--texto-2);
font: 500 var(--t-sm) var(--fonte);
cursor: pointer;
}
.formato[aria-checked='true'] {
border-color: var(--destaque);
color: var(--texto);
}
.formato:disabled {
opacity: 0.4;
cursor: not-allowed;
}
/* amostras com as mesmas proporções do rosto-motor.js */
.amostra {
display: block;
width: 20px;
height: 29px;
border-radius: 10px;
background: var(--destaque);
}
.amostra.redondo {
width: 24px;
height: 24px;
border-radius: 50%;
}
.amostra.pilula {
width: 16px;
height: 31px;
border-radius: 8px;
}
.amostra.quadrado {
width: 26px;
height: 26px;
border-radius: 4px;
}
.amostra.largo {
width: 33px;
height: 18px;
border-radius: 6px;
}
.cores {
display: flex;
flex-wrap: wrap;
gap: var(--e-2);
}
.cor {
width: 36px;
height: 36px;
border-radius: 50%;
border: 2px solid var(--borda-forte);
background: var(--c);
cursor: pointer;
position: relative;
}
.cor[aria-pressed='true'] {
outline: 2px solid var(--texto);
outline-offset: 2px;
}
.livre {
background: conic-gradient(red, yellow, lime, cyan, blue, magenta, red);
}
.livre input {
position: absolute;
inset: 0;
opacity: 0;
cursor: pointer;
}
.ideias {
list-style: none;
margin: 0;
padding: 0;
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 1px;
background: var(--borda);
border: 1px solid var(--borda);
border-radius: var(--raio-lg);
overflow: hidden;
}
.ideias li {
display: grid;
gap: var(--e-2);
align-content: start;
padding: var(--e-4) var(--e-5);
background: var(--superficie);
}
.ideias h3 {
font-size: var(--t-md);
}
.ideias p {
font-size: var(--t-sm);
color: var(--texto-2);
}
.status {
flex: none;
font: 500 11px var(--fonte-mono);
padding: 2px 8px;
border-radius: var(--raio-pill);
border: 1px solid var(--borda-forte);
color: var(--texto-3);
}
.status.feito {
color: var(--ok);
border-color: transparent;
background: var(--ok-suave);
}
.onde {
font: 500 11px var(--fonte-mono);
color: var(--texto-3);
}
@media (max-width: 900px) {
.cartoes {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.ideias {
grid-template-columns: 1fr;
}
}
@media (max-width: 520px) {
.formatos {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
}
</style>