feat(criacao): estúdio para criar expressões, atividades e figurinhas com IA

Menu "Criar com IA" (Ao vivo › Gestos e Reações) com grade igual à dos
favoritos. Para cada pedido escolhe-se o tipo (expressão, atividade ou
figurinha) e a IA: Gemini, IA local (qualquer servidor compatível com
OpenAI na rede, como o ROG Ally com LM Studio/llama-server) ou o Qwen do S10.

- A IA não escreve código: responde uma receita de formato fixo (schema
  JSON) montada só com peças que o rosto já anima. O servidor valida,
  limita números e descarta peças desconhecidas antes de chegar ao celular.
- Reaproveita o que existe: o prompt leva exemplos reais dos gestos e
  atividades atuais, e "Basear em" manda a receita de um gesto, atividade
  ou criação para a IA fazer uma variação.
- Expressões viram sequências como as de sequencias(), com mãos e espiral.
- Atividades usam uma cena genérica com o bonequinho (poses, pernas, pulo,
  balanço) e os objetos das cenas feitas à mão (bola, livro, xícara,
  notas, estrelas, coração, Zzz, chama, bolha, halter, microfone,
  controle, pincel, flor), com movimentos pular/girar/subir/orbitar.
- Figurinhas reaproveitam o desenho e a biblioteca de reações.
- A estrela põe expressões e atividades criadas nos favoritos (e nas
  teclas 1 a 9); a IA local é configurada na própria tela, com teste.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
julio
2026-10-06 22:22:52 -03:00
co-authored by Claude Opus 5.5
parent ff06f02705
commit 05e82dfd75
19 changed files with 1522 additions and 32 deletions
+5 -3
View File
@@ -7,7 +7,7 @@
import Paleta from './lib/componentes/Paleta.svelte';
import Selo from './lib/componentes/Selo.svelte';
import Token from './lib/componentes/Token.svelte';
import { favoritos, gesto, pararTudo } from './lib/comandos.svelte';
import { favoritos, pararTudo, tocarFavorito } from './lib/comandos.svelte';
import { conectar, estado } from './lib/estado.svelte';
import { paleta } from './lib/paleta.svelte';
import { nav, ROTAS, SECUNDARIAS, tituloDa, type Rota } from './lib/rotas.svelte';
@@ -19,6 +19,7 @@
import Rosto from './telas/Rosto.svelte';
import Eventos from './telas/Eventos.svelte';
import Guia from './telas/Guia.svelte';
import Criar from './telas/Criar.svelte';
import Ajuda from './lib/componentes/Ajuda.svelte';
import { TOPICO_DA_TELA } from './lib/guia';
@@ -31,6 +32,7 @@
ajustes: Ajustes,
rosto: Rosto,
guia: Guia,
criar: Criar,
};
const ICONES: Record<string, Component<any>> = {
'ao-vivo': Video,
@@ -93,7 +95,7 @@
}
if (/^[1-9]$/.test(e.key)) {
const g = favoritos.lista[Number(e.key) - 1];
if (g) gesto(g);
if (g) tocarFavorito(g);
}
}
@@ -179,7 +181,7 @@
<Tela />
</main>
{#if nav.atual !== 'ao-vivo' && nav.atual !== 'reacoes' && nav.atual !== 'rosto' && nav.atual !== 'guia'}
{#if nav.atual !== 'ao-vivo' && nav.atual !== 'reacoes' && nav.atual !== 'rosto' && nav.atual !== 'guia' && nav.atual !== 'criar'}
<MiniRosto />
{/if}
</div>
+3
View File
@@ -112,6 +112,9 @@ class Favoritos {
export const favoritos = new Favoritos();
export const gesto = (nome: string) => tentar(() => postar('/comando', { tipo: 'gesto', valor: nome }));
/** Favorito pode ser um gesto ou uma criação do estúdio ("criado:12"). */
export const tocarFavorito = (g: string) =>
g.startsWith('criado:') ? tentar(() => postar('/criacao/tocar', { id: Number(g.slice(7)) })) : gesto(g);
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 }));
+23 -5
View File
@@ -1,17 +1,28 @@
<script lang="ts">
import { Star } from '@lucide/svelte';
import { favoritos, gesto, GESTOS_INFO, gestosDisponiveis } from '../comandos.svelte';
import { favoritos, GESTOS_INFO, gestosDisponiveis, tocarFavorito } from '../comandos.svelte';
import { criacao } from '../criacao.svelte';
// compacto: só os favoritos com atalho 1–9 (dock). Completo: todos, com estrela para fixar.
// compacto: só os favoritos com atalho 1–9 (dock), inclusive criações do estúdio. Completo: todos, com estrela.
let { completo = false }: { completo?: boolean } = $props();
const lista = $derived(completo ? gestosDisponiveis() : favoritos.lista.filter((g) => g in GESTOS_INFO));
$effect(() => {
if (!completo && !criacao.status && favoritos.lista.some((g) => g.startsWith('criado:'))) criacao.carregar();
});
function infoDe(g: string) {
if (g.startsWith('criado:')) {
const c = criacao.item(Number(g.slice(7)));
return c ? { nome: c.nome, emoji: c.emoji } : null;
}
return GESTOS_INFO[g] ?? null;
}
const lista = $derived(completo ? gestosDisponiveis() : favoritos.lista.filter((g) => infoDe(g)));
</script>
<div class="gestos" class:completo>
{#each lista as g, i (g)}
{@const info = GESTOS_INFO[g]}
{@const info = infoDe(g)!}
<div class="item">
<button class="gesto" onclick={() => gesto(g)} title={completo || i > 8 ? info.nome : `${info.nome} (tecla ${i + 1})`}>
<button class="gesto" class:criado={g.startsWith('criado:')} onclick={() => tocarFavorito(g)} title={completo || i > 8 ? info.nome : `${info.nome} (tecla ${i + 1})`}>
<span class="emoji" aria-hidden="true">{info.emoji}</span>
<span class="nome">{info.nome}</span>
{#if !completo && i < 9}<kbd>{i + 1}</kbd>{/if}
@@ -71,6 +82,13 @@
color: var(--texto-2);
text-align: center;
}
.gesto.criado::after {
content: '✨';
position: absolute;
left: 6px;
top: 4px;
font-size: 10px;
}
kbd {
position: absolute;
top: 4px;
+100
View File
@@ -0,0 +1,100 @@
/**
* Estúdio de criação (/criacao/*): expressões, atividades e figurinhas feitas por IA.
* O estado chega por SSE ('criacao') e é recarregado ao abrir a tela.
*/
import { pedir, postar } from './api';
import { avisos, tentar } from './avisos.svelte';
import { ouvir } from './estado.svelte';
export type TipoCriacao = 'expressao' | 'atividade' | 'figurinha';
export type IaCriacao = 'gemini' | 'local' | 's10';
export interface ItemCriado {
id: number;
horario: number;
tipo: TipoCriacao;
nome: string;
emoji: string;
receita: any;
ia: IaCriacao;
pedido: string;
}
export interface Provedor {
nome: string;
pronto: boolean;
detalhe: string;
motivo: string;
url?: string;
modelo?: string;
chave?: boolean;
}
export interface StatusCriacao {
ocupado: { tipo: TipoCriacao; ia: IaCriacao; desde: number; pedido: string } | null;
erro: string;
provedores: Record<IaCriacao, Provedor>;
criacoes: ItemCriado[];
bases: Record<'expressao' | 'atividade', { id: string; nome: string; emoji: string }[]>;
}
export const TIPOS: { id: TipoCriacao; nome: string; emoji: string; exemplo: string }[] = [
{ id: 'expressao', nome: 'Expressão', emoji: '😊', exemplo: 'Ex.: desconfiado, olhando de lado com um olho meio fechado' },
{ id: 'atividade', nome: 'Atividade', emoji: '🤹', exemplo: 'Ex.: fazendo malabarismo com 3 bolinhas, concentrado' },
{ id: 'figurinha', nome: 'Figurinha', emoji: '🎨', exemplo: 'Ex.: um sol de óculos escuros sorrindo' },
];
class Criacao {
status = $state<StatusCriacao | null>(null);
async carregar() {
try {
this.status = await pedir<StatusCriacao>('/criacao/status');
} catch (e) {
avisos.erro(e);
}
}
item(id: number) {
return this.status?.criacoes.find((c) => c.id === id) ?? null;
}
async criar(tipo: TipoCriacao, pedido: string, ia: IaCriacao, base: string) {
const r = await tentar(() => postar<StatusCriacao>('/criacao/criar', { tipo, pedido, ia, base: base || null }));
if (r) this.status = r;
return !!r;
}
tocar(id: number) {
return tentar(() => postar('/criacao/tocar', { id }));
}
async apagar(id: number) {
await tentar(() => postar('/criacao/apagar', { id }), 'Criação apagada.');
}
async configurar(local: { url?: string; modelo?: string; chave?: string }) {
const r = await tentar(() => postar<StatusCriacao>('/criacao/config', { local }), 'IA local salva.');
if (r) this.status = r;
}
async testar(ia: IaCriacao) {
const r = await tentar(() => postar<{ mensagem: string }>('/criacao/testar', { ia }));
if (r) avisos.ok(r.mensagem);
}
}
export const criacao = new Criacao();
ouvir('criacao', (d: StatusCriacao) => {
const antes = criacao.status;
criacao.status = d;
// terminou: avisa o resultado
if (antes?.ocupado && !d.ocupado) {
if (d.erro) avisos.erro(new Error(d.erro));
else if (d.criacoes[0]) avisos.ok(`Criado: ${d.criacoes[0].emoji} ${d.criacoes[0].nome}`);
}
});
/** Favoritos podem guardar criações ("criado:12"): tocar no celular. */
export const ehCriado = (g: string) => g.startsWith('criado:');
export const idCriado = (g: string) => Number(g.slice(7));
+8 -2
View File
@@ -116,7 +116,13 @@ function aplicarFoto(f: EstadoServidor) {
const tratadores: Record<string, (d: any) => void> = {
expressao: (d: { ordem: string }) => {
// 'humor x' é o modo vida avisando o celular; aparece no cartão Vida, não no feed
if (d.ordem !== 'calma' && !d.ordem.startsWith('humor ')) noFeed('expressao', 'Expressão: ' + d.ordem);
if (d.ordem === 'calma' || /^(humor|ritmo|zanga|sono) /.test(d.ordem)) return;
const criada = /^(expressao|atividade)_criada (.*)$/.exec(d.ordem);
if (criada) {
let nome = '';
try { nome = JSON.parse(criada[2]).nome; } catch { /* ok */ }
noFeed('expressao', (criada[1] === 'expressao' ? 'Expressão criada: ' : 'Atividade criada: ') + nome);
} else noFeed('expressao', 'Expressão: ' + d.ordem);
},
telemetria: (d: Telemetria) => {
estado.app = d;
@@ -177,7 +183,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']) {
'reacao', 'treinamento', 'historico', 'pir', 'vida', 'criacao']) {
fonte.addEventListener(tipo, (e) => {
const dados = JSON.parse((e as MessageEvent).data);
tratadores[tipo]?.(dados);
+19 -2
View File
@@ -179,6 +179,23 @@ export const TOPICOS: Record<string, TopicoGuia> = {
{ emoji: '👆', nome: 'Parar', texto: 'Botão Parar no painel ou um toque na tela do celular. Dormir também encerra a atividade.' },
],
},
criar: {
titulo: 'Criar com IA',
emoji: '🪄',
onde: 'Ao vivo › Gestos › Criar com IA (ou Reações › Criar com IA)',
resumo:
'Escreva um pedido e uma IA cria uma expressão, uma atividade ou uma figurinha nova. Ela não escreve código: monta a criação só com peças que o Carinha já sabe animar, com exemplos reais do que já existe. Tudo é conferido no servidor antes de chegar ao celular.',
itens: [
{ emoji: '😊', nome: 'Expressão', texto: 'Sequência de olhos, boca, cor, olhar e mãozinhas, como os gestos favoritos. Ex.: "desconfiado, olhando de lado".' },
{ emoji: '🤹', nome: 'Atividade', texto: 'O bonequinho faz algo com braços, pernas e objetos (bola, livro, notas, estrelas, halter, microfone, flor...). Ex.: "malabarismo com 3 bolinhas".' },
{ emoji: '🎨', nome: 'Figurinha', texto: 'Desenho que aparece nos olhos ou em volta do rosto.' },
{ emoji: '🧬', nome: 'Basear em', texto: 'Escolha um gesto, atividade ou criação que já existe e peça uma variação: a IA parte da receita dele.' },
{ emoji: '✨', nome: 'Gemini', texto: 'O melhor resultado e o mais rápido (alguns segundos). Usa a chave paga.' },
{ emoji: '🖥️', nome: 'IA local', texto: 'Um servidor na sua rede (LM Studio ou llama-server, como o ROG Ally). Grátis, mas pode levar alguns minutos e criar coisas mais simples. Configure em "IA local".' },
{ emoji: '📱', nome: 'Qwen do S10', texto: 'O modelo pequeno do celular. Rápido e grátis, mas fraco para criar: bom só para testar.' },
{ emoji: '⭐', nome: 'Favoritos', texto: 'A estrela põe expressões e atividades criadas na grade de favoritos do Ao vivo (e nas teclas 1 a 9).' },
],
},
personalidade: {
titulo: 'IA local (Qwen)',
emoji: '🧠',
@@ -302,7 +319,7 @@ export const TOPICOS: Record<string, TopicoGuia> = {
/** Ordem do tutorial (página Guia). */
export const ORDEM_GUIA = [
'inicio', 'vida', 'cuidados', 'humores', 'ritmo', 'sono', 'celular', 'camera', 'gestos', 'atividades', 'aparencia',
'inicio', 'vida', 'cuidados', 'humores', 'ritmo', 'sono', 'celular', 'camera', 'gestos', 'atividades', 'criar', 'aparencia',
'personalidade', 'acontecimentos', 'eventos', 'presenca', 'tela', 'sensores', 'sensibilidade',
'reacoes', 'conversa', 'movimento', 'atalhos',
];
@@ -310,5 +327,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',
movimento: 'movimento', ajustes: 'tela', rosto: 'aparencia', guia: 'inicio', criar: 'criar',
};
+1
View File
@@ -37,6 +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-criar', grupo: 'Ir para', titulo: 'Criar com IA (expressões, atividades, figurinhas)', fazer: () => nav.ir('criar') },
...Object.entries(ATIVIDADES_INFO).map(([id, a]) => ({
id: 'atividade-' + id, grupo: 'Atividades', titulo: a.emoji + ' ' + a.nome, fazer: () => atividade(id),
})),
+2 -1
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';
export type Rota = 'ao-vivo' | 'eventos' | 'conversa' | 'reacoes' | 'movimento' | 'ajustes' | 'rosto' | 'guia' | 'criar';
export const ROTAS: { id: Rota; titulo: string; atalho: string }[] = [
{ id: 'ao-vivo', titulo: 'Ao vivo', atalho: 'g v' },
@@ -15,6 +15,7 @@ export const ROTAS: { id: Rota; titulo: string; atalho: string }[] = [
export const SECUNDARIAS: { id: Rota; titulo: string; volta: Rota }[] = [
{ id: 'rosto', titulo: 'Rosto', volta: 'ao-vivo' },
{ id: 'guia', titulo: 'Guia', volta: 'ajustes' },
{ id: 'criar', titulo: 'Criar com IA', volta: 'reacoes' },
];
export const tituloDa = (id: Rota) => [...ROTAS, ...SECUNDARIAS].find((r) => r.id === id)?.titulo ?? '';
+2 -1
View File
@@ -1,6 +1,6 @@
<script lang="ts">
import {
Brain, Camera, Download, Eye, Footprints, Gamepad2, Hand, History, Palette, PenLine, RefreshCw, Repeat, ScanFace, Settings2, Smile, Sparkles, Video,
Brain, Camera, Download, Eye, Footprints, Gamepad2, Hand, History, Palette, PenLine, RefreshCw, Repeat, ScanFace, Settings2, Smile, Sparkles, Video, Wand2,
} from '@lucide/svelte';
import { onDestroy, onMount } from 'svelte';
import { postar } from '../lib/api';
@@ -184,6 +184,7 @@ const pirAtivo = $derived(
{/if}
</div>
</Linha>
<Linha icone={Wand2} cor="#af52de" rotulo="Criar com IA" detalhe="Expressões, atividades e figurinhas novas" href="#/criar" />
<Linha icone={Sparkles} cor="#ff2d55" rotulo="Todos os gestos e reações" href="#/reacoes" />
</Grupo>
+377
View File
@@ -0,0 +1,377 @@
<script lang="ts">
import { Cpu, Settings2, Sparkles, Star, Trash2, Wand2 } from '@lucide/svelte';
import { onMount } from 'svelte';
import { favoritos } from '../lib/comandos.svelte';
import Card from '../lib/componentes/Card.svelte';
import RostoPrevia from '../lib/componentes/RostoPrevia.svelte';
import Selo from '../lib/componentes/Selo.svelte';
import { criacao, TIPOS, type IaCriacao, type TipoCriacao } from '../lib/criacao.svelte';
import { estado } from '../lib/estado.svelte';
// Estúdio: escolhe o que criar e com qual IA, escreve o pedido (pode partir de algo que já existe)
// e as criações ficam numa grade igual à dos favoritos.
onMount(() => criacao.carregar());
const st = $derived(criacao.status);
let tipo = $state<TipoCriacao>('expressao');
let ia = $state<IaCriacao>('gemini');
let pedido = $state('');
let base = $state('');
let filtro = $state<'todos' | TipoCriacao>('todos');
let configAberta = $state(false);
let url = $state('');
let modelo = $state('');
let chave = $state('');
$effect(() => {
// base só vale para o tipo atual
tipo;
base = '';
});
$effect(() => {
const l = st?.provedores.local;
if (l && !url && !modelo) {
url = l.url ?? '';
modelo = l.modelo ?? '';
}
});
const IAS: { id: IaCriacao; icone: string }[] = [
{ id: 'gemini', icone: '✨' },
{ id: 'local', icone: '🖥️' },
{ id: 's10', icone: '📱' },
];
const tipoInfo = $derived(TIPOS.find((t) => t.id === tipo)!);
const bases = $derived(
tipo === 'figurinha' || !st
? []
: [
...st.bases[tipo].map((b) => ({ id: b.id, rotulo: `${b.emoji} ${b.nome}` })),
...st.criacoes.filter((c) => c.tipo === tipo).map((c) => ({ id: `criado:${c.id}`, rotulo: `${c.emoji} ${c.nome} (criado)` })),
],
);
const lista = $derived((st?.criacoes ?? []).filter((c) => filtro === 'todos' || c.tipo === filtro));
const ocupado = $derived(st?.ocupado ?? null);
const segundos = $derived(ocupado ? Math.max(0, Math.round(estado.agora / 1000 - ocupado.desde)) : 0);
const provedor = $derived(st?.provedores[ia]);
async function criar(e: SubmitEvent) {
e.preventDefault();
if (await criacao.criar(tipo, pedido.trim(), ia, base)) pedido = '';
}
async function salvarLocal(e: SubmitEvent) {
e.preventDefault();
const dados: { url: string; modelo: string; chave?: string } = { url: url.trim(), modelo: modelo.trim() };
if (chave.trim()) dados.chave = chave.trim();
await criacao.configurar(dados);
chave = '';
}
const nomeTipo = (t: TipoCriacao) => TIPOS.find((x) => x.id === t)?.nome ?? t;
const podeFavoritar = (t: TipoCriacao) => t !== 'figurinha';
</script>
<div class="criar">
<div class="topo">
<Card titulo="Criar com IA" ajuda="criar" descricao="A IA monta a criação só com peças que o Carinha já sabe animar, usando exemplos do que já existe.">
<form class="form" onsubmit={criar}>
<div class="campo">
<span>O que criar</span>
<div class="segmentado" role="radiogroup" aria-label="O que criar">
{#each TIPOS as t (t.id)}
<button type="button" role="radio" aria-checked={tipo === t.id} onclick={() => (tipo = t.id)}>
<span aria-hidden="true">{t.emoji}</span> {t.nome}
</button>
{/each}
</div>
</div>
<div class="campo">
<span>Qual IA</span>
<div class="ias" role="radiogroup" aria-label="Qual IA">
{#each IAS as x (x.id)}
{@const p = st?.provedores[x.id]}
<button type="button" role="radio" class="ia" aria-checked={ia === x.id} class:indisponivel={p && !p.pronto} onclick={() => (ia = x.id)}>
<span class="ia-nome"><span aria-hidden="true">{x.icone}</span> {p?.nome ?? x.id}</span>
<span class="ia-detalhe">{p ? (p.pronto ? p.detalhe : p.motivo) : '…'}</span>
</button>
{/each}
</div>
</div>
<label class="campo">
<span>Pedido</span>
<textarea bind:value={pedido} maxlength="600" placeholder={tipoInfo.exemplo} required></textarea>
</label>
{#if bases.length}
<label class="campo">
<span>Basear em (opcional)</span>
<select bind:value={base}>
<option value="">Nada: criar do zero (com exemplos do que existe)</option>
{#each bases as b (b.id)}<option value={b.id}>{b.rotulo}</option>{/each}
</select>
</label>
{/if}
<div class="linha acoes">
<button class="btn primario" type="submit" disabled={!!ocupado || !pedido.trim() || !provedor?.pronto}>
<Wand2 size={16} /> {ocupado ? 'Criando…' : `Criar ${nomeTipo(tipo).toLowerCase()}`}
</button>
<button class="btn fantasma pequeno" type="button" onclick={() => (configAberta = !configAberta)}>
<Settings2 size={14} /> IA local
</button>
</div>
{#if ocupado}
<Selo tom="aviso">
{st?.provedores[ocupado.ia]?.nome} criando {nomeTipo(ocupado.tipo).toLowerCase()}… {segundos}s
{ocupado.ia !== 'gemini' ? '(IA local pode levar alguns minutos)' : ''}
</Selo>
{:else if st?.erro}
<p class="erro pequeno-texto">{st.erro}</p>
{/if}
</form>
{#if configAberta}
<form class="form local" onsubmit={salvarLocal}>
<p class="fraco pequeno-texto">
Servidor compatível com OpenAI na sua rede (LM Studio com "Serve on Local Network" ou llama-server), por exemplo o ROG Ally.
</p>
<label class="campo"><span>Endereço</span><input type="text" bind:value={url} placeholder="http://192.168.1.50:1234" /></label>
<label class="campo"><span>Modelo</span><input type="text" bind:value={modelo} placeholder="qwen3-coder-30b-a3b-instruct" /></label>
<label class="campo">
<span>Senha da API {st?.provedores.local.chave ? '(deixe vazio para manter)' : '(opcional)'}</span>
<input type="password" bind:value={chave} autocomplete="off" />
</label>
<div class="linha">
<button class="btn pequeno primario" type="submit">Salvar</button>
<button class="btn pequeno" type="button" onclick={() => criacao.testar('local')} disabled={!st?.provedores.local.url}><Cpu size={14} /> Testar</button>
</div>
</form>
{/if}
</Card>
<div class="previa"><RostoPrevia titulo="Prévia das criações" espelharSono={false} /></div>
</div>
<Card titulo="Criações" descricao="Toque para mostrar no celular. A estrela fixa expressões e atividades nos favoritos do Ao vivo.">
{#snippet acoes()}
<div class="segmentado pequeno" role="radiogroup" aria-label="Filtrar">
<button role="radio" aria-checked={filtro === 'todos'} onclick={() => (filtro = 'todos')}>Todas</button>
{#each TIPOS as t (t.id)}
<button role="radio" aria-checked={filtro === t.id} onclick={() => (filtro = t.id)}>{t.emoji}</button>
{/each}
</div>
{/snippet}
{#if !st}
<p class="fraco">Carregando…</p>
{:else if !lista.length}
<p class="fraco">Nada criado ainda. Escreva um pedido acima. <Sparkles size={14} /></p>
{:else}
<div class="grade">
{#each lista as c (c.id)}
{@const fav = favoritos.lista.includes(`criado:${c.id}`)}
<div class="item">
<button class="cartao" onclick={() => criacao.tocar(c.id)} title={c.pedido}>
<span class="emoji" aria-hidden="true">{c.emoji}</span>
<span class="nome">{c.nome}</span>
<span class="meta">{nomeTipo(c.tipo)} · {st.provedores[c.ia]?.nome ?? c.ia}</span>
</button>
{#if podeFavoritar(c.tipo)}
<button
class="canto estrela"
class:ativa={fav}
aria-pressed={fav}
aria-label={fav ? `Tirar ${c.nome} dos favoritos` : `Fixar ${c.nome} nos favoritos`}
onclick={() => favoritos.alternar(`criado:${c.id}`)}><Star size={14} /></button
>
{/if}
<button class="canto lixo" aria-label={`Apagar ${c.nome}`} onclick={() => criacao.apagar(c.id)}><Trash2 size={13} /></button>
</div>
{/each}
</div>
{/if}
</Card>
</div>
<style>
.criar {
display: grid;
gap: var(--e-4);
}
.topo {
display: grid;
gap: var(--e-4);
align-items: start;
}
@media (min-width: 1024px) {
.topo {
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
}
.previa {
position: sticky;
top: calc(var(--topo-altura) + var(--e-4));
}
}
.previa {
aspect-ratio: 16 / 9;
border-radius: var(--raio-lg);
overflow: hidden;
background: #000;
border: 1px solid var(--borda);
}
.form {
display: grid;
gap: var(--e-3);
}
.form.local {
margin-top: var(--e-3);
padding-top: var(--e-3);
border-top: 1px solid var(--borda);
}
textarea {
min-height: 84px;
resize: vertical;
}
.segmentado {
display: flex;
flex-wrap: wrap;
gap: 2px;
padding: 2px;
border-radius: var(--raio-sm);
background: var(--superficie-3);
}
.segmentado button {
flex: 1;
min-height: 34px;
padding: 0 var(--e-3);
border: 0;
border-radius: calc(var(--raio-sm) - 2px);
background: transparent;
color: var(--texto-2);
font: 500 var(--t-sm) var(--fonte);
cursor: pointer;
white-space: nowrap;
}
.segmentado.pequeno button {
min-height: 28px;
padding: 0 var(--e-2);
font-size: var(--t-xs);
}
.segmentado button[aria-checked='true'] {
background: var(--superficie);
color: var(--texto);
box-shadow: var(--sombra);
}
.ias {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
gap: var(--e-2);
}
.ia {
display: grid;
gap: 2px;
padding: var(--e-2) var(--e-3);
border: 1px solid var(--borda);
border-radius: var(--raio);
background: var(--superficie-2);
color: var(--texto);
font: inherit;
text-align: left;
cursor: pointer;
}
.ia[aria-checked='true'] {
border-color: var(--destaque);
background: var(--destaque-suave);
}
.ia.indisponivel .ia-detalhe {
color: var(--aviso);
}
.ia-nome {
font-weight: 600;
font-size: var(--t-sm);
}
.ia-detalhe {
font-size: var(--t-xs);
color: var(--texto-3);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.acoes {
justify-content: space-between;
}
.erro {
color: var(--perigo);
}
.grade {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
gap: var(--e-2);
}
.item {
position: relative;
}
.cartao {
width: 100%;
display: grid;
justify-items: center;
gap: 2px;
padding: var(--e-3) var(--e-2) var(--e-2);
border: 1px solid var(--borda);
border-radius: var(--raio);
background: var(--superficie-2);
color: var(--texto);
font: inherit;
cursor: pointer;
transition: background var(--transicao), border-color var(--transicao), transform var(--transicao);
}
.cartao:hover {
border-color: var(--destaque);
background: var(--destaque-suave);
}
.cartao:active {
transform: scale(0.97);
}
.emoji {
font-size: 1.7rem;
line-height: 1.2;
}
.nome {
font-size: var(--t-sm);
text-align: center;
overflow-wrap: anywhere;
}
.meta {
font-size: 10px;
color: var(--texto-3);
text-align: center;
}
.canto {
position: absolute;
top: 2px;
width: 26px;
height: 26px;
display: grid;
place-items: center;
border: 0;
border-radius: 50%;
background: transparent;
color: var(--texto-3);
cursor: pointer;
}
.estrela {
right: 2px;
}
.lixo {
left: 2px;
}
.lixo:hover {
color: var(--perigo);
}
.estrela.ativa {
color: var(--aviso);
}
.estrela.ativa :global(svg) {
fill: currentColor;
}
</style>
+3
View File
@@ -171,6 +171,9 @@
{/if}
{:else if aba === 'criar'}
<div class="grade-criar">
<Card titulo="Estúdio de criação" ajuda="criar" descricao="Expressões, atividades e figurinhas novas, escolhendo a IA (Gemini, IA local ou Qwen do S10).">
<a class="btn primario" href="#/criar">Abrir o estúdio</a>
</Card>
<Card titulo="Desenhar figurinha" ajuda="reacoes" descricao="O Gemini desenha um SVG e o celular mostra assim que ficar pronto.">
<form class="grade" onsubmit={desenhar}>
<label class="campo">