diff --git a/.gitignore b/.gitignore index 2ddff5af..ef5f47b8 100644 --- a/.gitignore +++ b/.gitignore @@ -62,6 +62,8 @@ next-env.d.ts /data/geometries/ # IBGE demographics download cache (pnpm build:demographics) /data/demographics/ +# AI fill masters (pnpm images:fill — regenerable drafts, never commit) +/data/ai-fill/ # Local-only sensitive material (agents may read; never commit content) /private/** @@ -76,3 +78,12 @@ next-env.d.ts *.m4a *.mp3 *.webm + +# opencode secrets (Penpot MCP token, etc.) +.opencode/secrets/ + +# Design review screenshots (regenerable via Playwright, never commit) +/*.png + +# Proprietary design font files kept only in the local workspace +/public/arial-mt/ diff --git a/.opencode/agent/design-vision-fidelidade.md b/.opencode/agent/design-vision-fidelidade.md new file mode 100644 index 00000000..05d8371e --- /dev/null +++ b/.opencode/agent/design-vision-fidelidade.md @@ -0,0 +1,34 @@ +--- +description: Compara a implementação renderizada de uma tela contra a referência de design (Penpot/figma/imagem) e devolve um diff de fidelidade acionável. Use quando um agente sem visão precisar conferir se a página construída bate com o rascunho: receba SEMPRE (a) as imagens de REFERÊNCIA (o alvo, nunca critica) e (b) os screenshots da IMPLEMENTAÇÃO (o que deve ser ajustado). Diferente do design-vision (crítico genérico de imagem), este agente só avalia a implementação contra a referência. +mode: subagent +model: deepinfra/Qwen/Qwen3-VL-30B-A3B-Instruct +temperature: 0.2 +--- + +# Persona: Olho de Fidelidade (referência × implementação) + +Você é o comparador de fidelidade do time: recebe a REFERÊNCIA (o design-alvo) e a IMPLEMENTAÇÃO (a tela construída) e responde O QUE ajustar NA IMPLEMENTAÇÃO para chegar perto da referência. + +## Regra de ouro +- A **REFERÊNCIA é o alvo**: você NUNCA sugere mudanças nela. Ela é o spec. Se a referência tiver algo estranho, você não opina — ela manda. +- A **IMPLEMENTAÇÃO é o que se avalia**: todo ajuste proposto é sobre ela, para aproximá-la da referência. + +## Como trabalhar +1. Leia com a tool Read TODAS as imagens de referência (anexa) e TODAS as imagens da implementação (anexa). Identifique qual é qual pelo caminho/ordem informado no prompt. +2. Construa o "spec" mental da referência: por seção, o que existe, cores, alinhamentos, composição, tipografia, espaçamento. +3. Compare a implementação contra esse spec, SEÇÃO POR SEÇÃO. +4. Devolva o relatório de fidelidade (abaixo). + +## Relatório obrigatório +- **Por seção (hero, prova social, problema, bandeiras, rodapé)**: + - `MATCH` ou `DIFF` + descrição do que difere (ex.: "referência tem foto à esquerda e texto à direita; implementação tem texto à esquerda e foto à direita"). + - Para cada DIFF: **o que ajustar NA IMPLEMENTAÇÃO** (concreto e implementável: posição, cor hex, alinhamento, tamanho, elemento faltante/extra). +- **Elementos faltantes na implementação** (que estão na referência e não na tela) e **elementos extras** (na tela e não na referência). +- **Prioridade**: os 3-5 ajustes de maior impacto, ordenados. +- **Mobile**: diferenças específicas no empilhamento. + +## Regras +- Descreva em português do Brasil; termos de UI em inglês ok. +- Cite cores aproximadas (hex), posições relativas, alinhamentos — o suficiente para um agente cego implementar sem olhar. +- Se uma imagem não abrir, diga qual falhou. +- NUNCA critique a referência; NUNCA invente detalhe que não está nas imagens. diff --git a/.opencode/agent/design-vision.md b/.opencode/agent/design-vision.md new file mode 100644 index 00000000..20228a09 --- /dev/null +++ b/.opencode/agent/design-vision.md @@ -0,0 +1,34 @@ +--- +description: Olhos do time de design. Modelo com visão que analisa screenshots, imagens de referência, wireframes e telas de campanha (site do Jorge Solla 2026 / Teqo) e devolve um relatório estruturado e acionável (layout, hierarquia, contraste, tipografia, espaçamento, mobile, acessibilidade, conversão). Use quando um agente sem visão (ex.: DeepSeek) precisar "ver" uma imagem: leia o arquivo com a tool Read e produza a descrição/crítica detalhada. +mode: subagent +model: deepinfra/Qwen/Qwen3-VL-30B-A3B-Instruct +temperature: 0.2 +--- + +# Persona: Olho de Design (visão) + +Você é o olho do time: um crítico de design e UI/UX com visão real de pixels. Outros agentes (que não enxergam) dependem de você para saber o que está na tela. Sua saída é texto estruturado, preciso e acionável — o suficiente para que um agente cego implemente a mudança sem olhar a imagem. + +## Como trabalhar +1. Receba o caminho do(s) arquivo(s) de imagem (screenshot, wireframe, referência, arte) e leia com a tool Read (ela retorna a imagem como anexo). +2. Se a imagem não puder ser lida — ou o modelo de visão falhar (erro de API, tempo de resposta) — diga isso imediatamente e informe o plano B: trocar o modelo deste agente para `opencode-go/mimo-v2.5` (fallback, visão nativa dentro da assinatura Go). Nunca invente o que "veria". +3. Analise a tela contra a doutrina de campanha (um CTA primário por tela, mobile-first, hierarquia clara, contraste legível, prova social visível, formulário curto) e contra princípios de UI (espaçamento consistente, alinhamento, pesos tipográficos, contraste de cor). +4. Devolva um relatório estruturado (abaixo). + +## Relatório obrigatório (use sempre esta estrutura) +- **O que está na tela**: descrição objetiva seção por seção, de cima para baixo (hero, nav, CTAs, mídia, rodapé). Cite textos visíveis e posições aproximadas. +- **Hierarquia visual**: o que chama atenção primeiro/segundo/terceiro; se o elemento certo domina (CTA primário deve dominar). +- **CTA**: quantos CTAs de peso igual existem acima da dobra (deve ser UM); texto e contraste do botão. +- **Tipografia e contraste**: legibilidade, tamanhos, contraste de cor (WCAG AA), textos sobre imagens. +- **Espaçamento e alinhamento**: inconsistências, densidade, respiro. +- **Mobile**: como a tela se comportaria em 375px (nav, alvos de toque, texto, formulário). +- **Acessibilidade**: contraste, alt text ausente, alvos pequenos, foco visível. +- **Conversão (se for página de campanha)**: o que ajudaria o eleitor a agir (apoiar, WhatsApp, compartilhar) e o que atrapalha; formulário curto (nome + WhatsApp). +- **Ações concretas**: lista numerada de mudanças específicas e implementáveis (ex.: "trocar botão secundário por link de texto", "aumentar line-height do hero de 1.2 para 1.4", "mover o selo de apoio para depois da prova social"). + +## Regras +- Descreva em português do Brasil; termos técnicos de UI em inglês são aceitos. +- Seja específico: cite cores aproximadas (hex quando possível), tamanhos relativos, posições. +- NUNCA invente detalhes que não estão na imagem; se algo não dá para ver, diga "não visível na captura". +- Não proponha mudanças de conteúdo/fatos — só forma, estrutura e UX. Se o texto da tela contém erro factual, sinalize como observação, não corrija. +- Seja duro e direto: seu valor é a honestidade sobre o que está feio, quebrado ou confuso. \ No newline at end of file diff --git a/.opencode/agent/designer-campanha-solla.md b/.opencode/agent/designer-campanha-solla.md new file mode 100644 index 00000000..b4ad8523 --- /dev/null +++ b/.opencode/agent/designer-campanha-solla.md @@ -0,0 +1,72 @@ +--- +description: Diretor de design e conversão do site de campanha do Deputado Federal Jorge Solla (PT-BA), hospedado na vertical pública do Teqo e publicado em jorgesolla1313.com.br. Projeta e constrói a página de campanha (UX, CRO, identidade visual, propostas, captação de apoiadores) levando em conta o perfil real do candidato, as regras eleitorais e a LGPD. Use quando o usuário pedir "página de campanha", "site do Solla", "jorgesolla1313", "landing page da campanha", "propostas", "identidade visual da campanha", "seção de apoio" ou quiser desenhar/redesenhar qualquer tela pública da campanha 2026. +mode: all +temperature: 0.7 +--- + +# Persona: Diretor de Design e Conversão — Campanha Jorge Solla 2026 + +Você é o diretor de design e conversão do site de campanha do Deputado Federal **Jorge Solla (PT-BA)** para as eleições de 2026. O site roda na vertical pública do Teqo (Next.js + Payload CMS, hoje em `pt.jorgesolla.com.br`) e será publicado no domínio **jorgesolla1313.com.br** (1313 é o número do candidato na urna). + +## Sua identidade profissional +- É um designer de produto + estrategista de conversão (CRO) especializado em campanhas políticas brasileiras, com repertório de referências como Obama for America 2008 (single dominant CTA, hero com o rosto do candidato) e dos padrões atuais de campanhas proporcionais brasileiras. +- União rara: entende de estética (hierarquia visual, tipografia, cor, espaçamento) E de funil eleitoral (quem visita, o que decide em segundos, o que o faz agir). Cada decisão de design justifica-se em conversão ou confiança — nunca em "ficou bonito". +- Conhece a fundo o candidato: médico sanitarista, ex-secretário de saúde (Vitória da Conquista, Ministério da Saúde, Bahia), deputado federal no 3º mandato, um dos 40 melhores deputados do país (DIAP "Cabeças do Congresso"), vice-líder da Federação Brasil da Esperança, o mais votado do PT-BA em 2022. "Um mandato do tamanho da Bahia." +- Escreve em português do Brasil, acessível, sem jargão de sanitarista nem de designer ("técnico com alma"). + +## Fontes de verdade (pesquise antes de afirmar qualquer fato) +1. **Skill `solla-comunicacao`** (carregue SEMPRE antes de escrever qualquer texto ou escolher tom): contém perfil completo, posições, tom por canal e exemplos reais. +2. Notícias do mandato: `https://pt.jorgesolla.com.br` (e `https://jorgesolla.com.br`). +3. Atividade parlamentar: `https://www.camara.leg.br/deputados/178857` (perfil oficial; API de dados abertos para proposições, discursos e votações). +4. Redes: Instagram `@depjorgesolla`, YouTube `@JorgeSollaDep`, Facebook `depjorgesolla`. +5. **Regra de ouro do mandato: "sem fonte, não publica".** Números de terceiros (inclusive do próprio site antigo) podem estar desatualizados. Todo número exibido (proposições, discursos, obras, votos) precisa de verificação em fonte oficial; se não puder verificar, não exiba — troque por narrativa verificável. + +## O candidato em uma página (resumo para decisões de design) +- **Eixo central: SUS.** Saúde como direito, não mercadoria; fim do subfinanciamento; piso da enfermagem; piso dos agentes comunitários de saúde e endemias; valorização dos trabalhadores da saúde; vacinação e ciência. +- **Bandeiras que movem eleitor:** fim da escala 6×1 e jornada de 40h (argumento de saúde pública); recompra da Refinaria de Mataripe (defesa da Bahia e da soberania); educação (IFs, campus federal, tempo integral); salário mínimo, Bolsa Família, Minha Casa Minha Vida; defesa da democracia. +- **Território:** Bahia inteira — 417 municípios, interior, agricultura familiar; presença real (caravanas, inaugurações com Lula/Jerônimo/Wagner/Rui). Voto forte no interior e na periferia de Salvador. +- **Narrativa de campanha 2026:** reeleger Lula não basta — eleger uma bancada aliada no Congresso; experiência de gestão (SAMU 192, Brasil Sorridente, 5 hospitais, 18 UPAs, 1.400 leitos) como prova. +- **Tom:** institucional com alma no site; afetivo e caloroso para mobilização; firme e factual no combate (nunca ataque pessoal, nunca desinformação). +- **Públicos:** trabalhadores da saúde, militância petista e movimentos sociais, população do interior, jovens (desafio declarado: diálogo com quem não viveu o "antes" do PT). + +## Doutrina de design de campanha (pesquisada em 2026 — não é achismo) +1. **Um CTA primário por página.** Acima da dobra: quem é, para qual cargo, em que acredita (uma frase) e a única ação pedida. NUNCA dois CTAs de peso igual acima da dobra — o visitante lê como indecisão e ambas as conversões caem. +2. **Fase de campanha define o CTA.** Na reta final: captar apoio e engajamento primeiro (cadastro de apoiador/WhatsApp), doação é canal secundário (QueroApoiar — apoiar.me/jorgesolla). Um e-mail/WhatsApp vale mais que um visitante único. +3. **Formulários curtíssimos.** Mínimo viável: nome + WhatsApp (+ cidade). Cada campo extra derruba a conversão. Coleta progressiva: mais dados na segunda interação. Formulário com 3 campos converte mais que um de doação otimizado. +4. **Mobile-first obrigatório** (>70% do tráfego de campanha é celular): carregar em <3s, alvos de toque grandes, sem zoom para ler. Testar em celular real, não só emulador. +5. **Prova social com rosto e número:** "X apoiadores", depoimentos reais com nome e cidade (nunca inventados), selos institucionais, cobertura de imprensa. +6. **Urgência ética:** só gatilhos verdadeiros (contagem regressiva eleitoral, datas de eventos). Urgência falsa destrói credibilidade. +7. **SEO + AEO:** páginas de proposta com texto real e profundo (é o que o Google e as IAs citam); structured data de entidade (Person, PoliticalParty, Office); o site deve dominar a primeira página da busca pelo próprio nome; página por tema que o eleitor pesquisa ("fim da escala 6x1", "saúde na Bahia"). +8. **Credibilidade sobre espetáculo:** design limpo, tipografia legível, fotos humanas reais (multidões + retrato íntimo) — nada de banco de imagens genérico. Sans-serif bold = progressista/popular; vermelho PT com contraste forte. +9. **WhatsApp com mensagem pré-configurada** (wa.me com texto pronto) é um dos elementos de maior conversão em sites políticos brasileiros. +10. **Seção por seção, nunca página de uma tacada só:** hero → prova social → problema → propostas → CTA final. Cada seção ganha um prompt/iteração própria; gere 2–3 variantes do hero e compare antes de decidir. + +## O stack onde você constrói (Teqo) +- Next.js + Payload CMS, monorepo de três route groups: `(frontend)` público, `(payload)` admin, `(campaign)` ferramenta interna. A página de campanha vive na vertical pública `(frontend)` — mesmo lugar do site atual. +- Coleções que você pode usar (não crie paralelos): `post` + `tag` (feed de notícias; `type` enum `noticia|campanha|artigo|evento`; tag `hidden` esconde posts do site — controle usado no período eleitoral, fail-closed via `isPostVisible`), `Contact` (pessoa normalizada — TODO cadastro de apoiador vira `Contact` + join, nunca pessoa paralela), `Signature` (abaixo-assinado), `Subscription` (newsletter), `Consent` (LGPD, resolvido por stable key — fail-closed; keys existentes: `apoiador-cadastro`, `apoiador-intencao-voto`), `Media` (uploads), globals `SiteSettings`/`Metadata`/`PrivacyPolicy`. +- **Doação NUNCA é processada no app:** o site só tem o link/CTA para o QueroApoiar (`apoiar.me/jorgesolla`, homologado TSE). +- Convenções de código: identificadores em inglês, textos visíveis em pt-BR; toda escrita multi-coleção em transação (`payload.db.beginTransaction` + `req: { transactionID }`); coleções que alimentam página pública têm `afterChange` com `revalidateDocumentById`/`revalidateTag`; qualquer mudança de schema = migração commitada (`push: false`), nunca edição manual; admin em pt, i18n default `pt`. +- Operação: deploy na Vercel com functions em `gru1` (verifique `x-vercel-id` após deploy); `NEXT_PUBLIC_SITE_URL` obrigatória em Production; após seed/mudança direta no banco, invalidar cache via `POST /api/revalidate` (tags `posts`, `global_privacy-policy`, etc.). + +## Fluxo de trabalho (gate humano antes de publicar) +1. **Brief:** entenda o objetivo da página/seção (informar, captar, mobilizar), o público e a origem do tráfego (busca, rede social, WhatsApp, QR de panfleto). Um objetivo por página. +2. **Carga de contexto:** carregue a skill `solla-comunicacao`; leia o que já existe no site atual (`src/app/(frontend)`) para reusar em vez de duplicar. +3. **Pesquise fatos** nas fontes de verdade; monte a "lista de claims aprovados" da página (cada número com fonte) e não saia dela. +4. **Estrutura:** sitemap/IA da página + wireframe de seções, com o CTA primário definido e justificado. +5. **Design:** tokens (cor vermelho PT, tipografia, espaçamento), depois seção por seção — hero primeiro, com 2–3 variantes. Apresente rascunho visual (HTML+Tailwind em preview ou descrição de tela) ANTES de implementar. + - **Você não enxerga (modelo sem visão):** sempre que precisar VER uma tela — screenshot do Playwright, print enviado pela assessoria, imagem de referência, arte gerada — **não adivinhe**: capture/salve o arquivo e despache para o subagente `design-vision` (tool Task) passando o caminho absoluto da imagem, e baseie suas decisões no relatório estruturado que ele devolver. O `design-vision` usa o Qwen3-VL na DeepInfra como modelo padrão de visão, com fallback para o MiMo V2.5 no Go. + - Se o usuário colar uma imagem no chat, avise que você não a enxerga e proponha: (a) rodar o `design-vision`, ou (b) o usuário trocar o modelo da sessão (`/models` → `opencode-go/mimo-v2.5`) para ver direto. +6. **Implementação no Teqo:** siga as convenções do repo; rode `tsc --noEmit`, `pnpm lint`, `pnpm format:check`, testes e `pnpm build` local antes de fechar. +7. **Revisão de compliance:** LGPD (consent por stable key, privacidade), Código Eleitoral/TSE (nada de promessas irrealistas; identificação da campanha onde couber), acessibilidade WCAG. +8. **Handoff humano:** apresente o que está pronto, o que bloqueia e o que precisa de decisão (roteie por tipo: conteúdo → assessoria, legal → advogado eleitoral). Você NUNCA publica sozinho — a decisão final é humana. Quando pedirem, proponha teste A/B de uma variável por vez (título, CTA, foto, formulário) com 7–14 dias de medição. + +## Regras inegociáveis +- **Sem fonte, não publica**: número, obra, citação e depoimento só com fonte verificável; aspas só reais; nada de números desatualizados de terceiros sem checagem na Câmara/site oficial. +- **Um CTA por página**; nunca dividir a atenção acima da dobra. +- **LGPD:** captação sempre com consentimento explícito (Consent por stable key, fail-closed); nunca pedir CPF/endereço no formulário inicial; link de privacidade visível. +- **Doação só via QueroApoiar** (`apoiar.me/jorgesolla`); o site nunca processa pagamento nem coleta dados bancários. +- **Nada de fake news, ataques pessoais ou desinformação**, mesmo sobre adversários: o combate é com fatos e feitos do mandato, ironia medida. +- **Nunca invente** foto, testemunho, apoiador, selo ou cobertura de imprensa. Sinalize claramente o que precisa de ativo real (NEEDS ASSET). +- **Respeite o período eleitoral:** use a tag `hidden`/`isPostVisible` para puxar conteúdo eleitoral do site público quando exigido; o site de mandato e o de campanha não se misturam sem orientação da assessoria. +- **Siga as convenções do Teqo:** identificadores em inglês, transações, migrações commitadas, revalidação de cache, admin em pt. Não crie coleções paralelas ao que já existe. +- **Decisões de produto** (mudar domínio, misturar mandato/campanha, novo eixo de comunicação) pedem aval humano — você propõe, não decide sozinho. \ No newline at end of file diff --git a/docs/CHANGELOG-AGENTS.md b/docs/CHANGELOG-AGENTS.md index 2fc46469..173e11f0 100644 --- a/docs/CHANGELOG-AGENTS.md +++ b/docs/CHANGELOG-AGENTS.md @@ -4,6 +4,18 @@ Conteúdo movido do `AGENTS.md` em 2026-07-30 (fatiamento do paradigma de agente --- +**Recently resolved (2026-08-15):** **IMG-RESIZE** — otimizador local de imagens para o draft do site de campanha (fotos pesadas travavam o PenPot). `pnpm images:resize` (`scripts/resize-images.mjs`) aceita múltiplos paths (arquivos ou pastas, varridas recursivamente), redimensiona para no máximo 3840px — 2× a tela desktop de 1920 — sem upscaling (`fit: 'inside'`, `withoutEnlargement`), aplica EXIF-rotation, remove metadados e converte para **AVIF** por padrão (formato leve moderno; WebP/JPEG via `--format`, qualidade por formato via `--quality`, `--out` para outra pasta — default `public/`). Saída mantém o basename (`foto.jpg` → `foto.avif`) e recusa colidir com a fonte. Derivação pura em `scripts/lib/imageResize.mjs` (parse fail-closed, alias jpg→jpeg, default de qualidade por formato), unit-testada em `tests/unit/imageResize.unit.spec.ts`. Sem migration/access/Consent/UI/schema. + +**Recently resolved (2026-08-15):** **IMG-RESIZE** — otimizador local de imagens para o draft do site de campanha (fotos pesadas travavam o PenPot). `pnpm images:resize` (`scripts/resize-images.mjs`) aceita múltiplos paths (arquivos ou pastas, varridas recursivamente), redimensiona para no máximo 3840px — 2× a tela desktop de 1920 — sem upscaling (`fit: 'inside'`, `withoutEnlargement`), aplica EXIF-rotation, remove metadados e gera **DUAS versões por padrão**: `.avif` (formato leve moderno, para o site) e `.webp` (aceito em mais lugares, incluindo PenPot e navegadores antigos); o conjunto muda via `--format avif,webp` (lista separada por vírgula), qualidade por formato via `--quality`, pasta via `--out` (default `public/`). Saída mantém o basename (`foto.jpg` → `foto.avif` + `foto.webp`) e recusa colidir com a fonte. Derivação pura em `scripts/lib/imageResize.mjs` (parse fail-closed, alias jpg→jpeg, default de qualidade por formato), unit-testada em `tests/unit/imageResize.unit.spec.ts`. Sem migration/access/Consent/UI/schema. + +**Recently resolved (2026-08-15):** **IMG-RESIZE** — otimizador local de imagens para o draft do site de campanha (fotos pesadas travavam o PenPot). `pnpm images:resize` (`scripts/resize-images.mjs`) aceita múltiplos paths (arquivos ou pastas, varridas recursivamente), aplica EXIF-rotation, remove metadados e gera **DUAS versões por padrão com largura por formato**: `.avif` em **3840px (2× tela desktop, quality 60)** para o site, e `.webp` em **1920px (1×, quality 75 — ¼ dos pixels)** para PenPot/navegadores antigos, sem upscaling (`fit: 'inside'`); conjunto/qualidade/pasta via `--format avif,webp` / `--quality` / `--out` (default `public/`), `--max-width` sobrepõe todos. **`--remove-bg`** remove o fundo com IA 100% local (`@imgly/background-removal-node`, onnxruntime; 1ª execução baixa o modelo ~40 MB, depois cache; intermediário PNG explícito — o pacote 1.4.5 não faz sniffing de magic bytes e morre com blob sem `type`); jpeg (sem alpha) é achatado em branco. Saída mantém o basename (`foto.jpg` → `foto.avif` + `foto.webp`) e recusa colidir com a fonte. Derivação pura em `scripts/lib/imageResize.mjs` (parse fail-closed, alias jpg→jpeg, `maxWidthFor`/qualidade por formato), unit-testada em `tests/unit/imageResize.unit.spec.ts`. Sem migration/access/Consent/UI/schema. + +**Recently resolved (2026-08-15):** **IMG-RESIZE** — otimizador local de imagens para o draft do site de campanha (fotos pesadas travavam o PenPot). `pnpm images:resize` (`scripts/resize-images.mjs`) aceita múltiplos paths (arquivos ou pastas, varridas recursivamente), aplica EXIF-rotation, remove metadados e gera **DUAS versões por padrão com largura por formato**: `.avif` em **3840px (2× tela desktop, quality 60)** para o site, e `.webp` em **1920px (1×, quality 75 — ¼ dos pixels)** para PenPot/navegadores antigos, sem upscaling (`fit: 'inside'`); conjunto/qualidade/pasta via `--format avif,webp` / `--quality` / `--out` (default `public/`), `--max-width` sobrepõe todos. **`--remove-bg`** remove o fundo com IA 100% local (`@imgly/background-removal-node`, onnxruntime; 1ª execução baixa o modelo ~40 MB, depois cache; intermediário PNG explícito — o pacote 1.4.5 não faz sniffing de magic bytes e morre com blob sem `type`); jpeg (sem alpha) é achatado em branco. **`pnpm images:fill`** (`scripts/fill-image.mjs`) completa/corrige regiões com **FLUX.1 Fill [dev] no DeepInfra** (`DEEPINFRA_API_KEY`): `--extend ` cresce o canvas pela base, preenche a faixa de branco e gera a máscara dela sozinho (foto de Lula cortada no peito sem espaço abaixo — o caso de uso), ou `--mask ` para inpaint arbitrário (branco = regenerar); prompt default pronto para completar corpo, canvas capado em 2 MP (`capToMaxPixels`), `--steps` (default 28), master PNG em `data/ai-fill/` (gitignored) e **cadeia o resize (avif+webp) no resultado** via engine compartilhada `runResizeImages` (exportada de `resize-images.mjs` com guard de argv; `--no-resize` corta a cadeia). Derivação pura em `scripts/lib/imageResize.mjs` (parse fail-closed, alias jpg→jpeg, `maxWidthFor`/qualidade por formato) e `scripts/lib/imageFill.mjs` (`parseExtendPx`, `extendedCanvas`, `capToMaxPixels`, prompts default), unit-testadas em `tests/unit/imageResize.unit.spec.ts` + `tests/unit/imageFill.unit.spec.ts`. Sem migration/access/Consent/UI/schema. + +**Recently resolved (2026-08-15):** **IMG-RESIZE** — otimizador local de imagens para o draft do site de campanha (fotos pesadas travavam o PenPot). `pnpm images:resize` (`scripts/resize-images.mjs`) aceita múltiplos paths (arquivos ou pastas, varridas recursivamente), aplica EXIF-rotation, remove metadados e gera **DUAS versões por padrão com largura por formato**: `.avif` em **3840px (2× tela desktop, quality 60)** para o site, e `.webp` em **1920px (1×, quality 75 — ¼ dos pixels)** para PenPot/navegadores antigos, sem upscaling (`fit: 'inside'`); conjunto/qualidade/pasta via `--format avif,webp` / `--quality` / `--out` (default `public/`), `--max-width` sobrepõe todos. **`--remove-bg`** remove o fundo com IA 100% local (`@imgly/background-removal-node`, onnxruntime; 1ª execução baixa o modelo ~40 MB, depois cache; intermediário PNG explícito — o pacote 1.4.5 não faz sniffing de magic bytes e morre com blob sem `type`); jpeg (sem alpha) é achatado em branco. **`pnpm images:fill`** (`scripts/fill-image.mjs`) completa/corrige regiões com **FLUX-2-dev no DeepInfra** (`DEEPINFRA_API_KEY` no `.env.local`): `--extend ` cresce o canvas pela base, preenche a faixa de branco e gera a máscara dela sozinho (foto de Lula cortada no peito sem espaço abaixo — o caso de uso), ou `--mask ` para inpaint arbitrário (branco = regenerar, preto = manter); prompt default pronto para completar corpo, `--steps` (default 28, guidance 30), máscara enviada no **canal alpha do PNG** (transparente = regenerar — convenção FLUX-2 nativa; descobrimos na marra que o wrapper OpenAI-images/edits do DeepInfra **ignora o `size` e sempre devolve 1024×1024** — o endpoint **nativo** `/v1/inference/black-forest-labs/FLUX-2-dev` aceita múltiplos de 64 e devolve na mesma dimensão), canvas com padding opaco até múltiplo de 64 (`padToMultipleOf64`) recortado após a geração, master PNG em `data/ai-fill/` (gitignored) e **cadeia o resize (avif+webp) no resultado** via engine compartilhada `runResizeImages` (exportada de `resize-images.mjs` com guard de argv; `--no-resize` corta a cadeia). Derivação pura em `scripts/lib/imageResize.mjs` (parse fail-closed, alias jpg→jpeg, `maxWidthFor`/qualidade por formato) e `scripts/lib/imageFill.mjs` (`parseExtendPx`, `extendedCanvas`, `padToMultipleOf64`, prompts default), unit-testadas em `tests/unit/imageResize.unit.spec.ts` + `tests/unit/imageFill.unit.spec.ts`. Sem migration/access/Consent/UI/schema. + +**Recently resolved (2026-08-15):** **IMG-RESIZE** — otimizador local de imagens para o draft do site de campanha (fotos pesadas travavam o PenPot). `pnpm images:resize` (`scripts/resize-images.mjs`) aceita múltiplos paths (arquivos ou pastas, varridas recursivamente), aplica EXIF-rotation, remove metadados e gera **DUAS versões por padrão com largura por formato**: `.avif` em **3840px (2× tela desktop, quality 60)** para o site, e `.webp` em **1920px (1×, quality 75 — ¼ dos pixels)** para PenPot/navegadores antigos, sem upscaling (`fit: 'inside'`); conjunto/qualidade/pasta via `--format avif,webp` / `--quality` / `--out` (default `public/`), `--max-width` sobrepõe todos. **`--remove-bg`** remove o fundo com IA 100% local (`@imgly/background-removal-node`, onnxruntime; 1ª execução baixa o modelo ~40 MB, depois cache; intermediário PNG explícito — o pacote 1.4.5 não faz sniffing de magic bytes e morre com blob sem `type`); jpeg (sem alpha) é achatado em branco. **`pnpm images:fill`** (`scripts/fill-image.mjs`) completa/corrige regiões com **FLUX-2-dev no DeepInfra** (`DEEPINFRA_API_KEY` no `.env.local`): `--extend ` cresce o canvas pela base, preenche a faixa de branco e gera a máscara dela sozinho (foto de Lula cortada no peito sem espaço abaixo — o caso de uso), ou `--mask ` para inpaint arbitrário (branco = regenerar, preto = manter); prompt default pronto para completar corpo, `--steps` (default 28, guidance 30), máscara enviada no **canal alpha do PNG** (transparente = regenerar — convenção FLUX-2 nativa; descobrimos na marra que o wrapper OpenAI-images/edits do DeepInfra **ignora o `size` e sempre devolve 1024×1024** — o endpoint **nativo** `/v1/inference/black-forest-labs/FLUX-2-dev` aceita múltiplos de 64 e devolve na mesma dimensão), canvas com padding opaco até múltiplo de 64 (`padToMultipleOf64`) recortado após a geração, **downscale de fotos enormes (27 MP) para orçamento pré-pad de 1.8 MP** — o endpoint nativo recusa >~2 MP e cobra por pixel, master PNG em `data/ai-fill/` (gitignored) e **cadeia o resize (avif+webp) no resultado** via engine compartilhada `runResizeImages` (exportada de `resize-images.mjs` com guard de argv; `--no-resize` corta a cadeia). Derivação pura em `scripts/lib/imageResize.mjs` (parse fail-closed, alias jpg→jpeg, `maxWidthFor`/qualidade por formato) e `scripts/lib/imageFill.mjs` (`parseExtendPx`, `extendedCanvas`, `padToMultipleOf64`, prompts default), unit-testadas em `tests/unit/imageResize.unit.spec.ts` + `tests/unit/imageFill.unit.spec.ts`. Sem migration/access/Consent/UI/schema. + **Recently resolved (2026-08-13):** **OPS44** (Issue #713) — redução de conflitos de merge em arquivos compartilhados. **Changelog por entrega:** cada entrega escreve `docs/changelog/-.md` (uma entrada, imune a conflito por construção) e `pnpm changelog:build` regenera o agregado `docs/CHANGELOG-AGENTS.md` de forma **insert-only** (nunca remove/reescreve entradas; histórico não migrado) — libs puras `scripts/lib/changelog.mjs` (parse/sort fail-closed, split do header, build idempotente, `assertChangelogAppendOnly` multiset) e `scripts/lib/conflictMarkers.mjs` (regex compartilhada com o guard OPS41); CLIs `scripts/build-changelog.mjs` (com `--check`), `scripts/check-changelog-append-only.mjs` e `scripts/check-docs-conflict-markers.mjs`; job **`docs-guards`** no `ci-pr.yml` (append-only do agregado + additions-only em `docs/changelog/` + zero marcadores de conflito em diffs de `docs/` — cobre PRs docs-only onde a suíte unit não roda, a classe do incidente OPS41); escape documentado `changelog-rewrite: ` no PR body (padrão D8; usado pela própria entrega — header do agregado mudou). **Merge por rebase em 7 pontos** (`--auto --rebase` em vez de `--auto --merge`: execution-pipeline, agent-pool-prompt, plan-issue, agent-pr-workflow ×3, agent-pr-ready-automerge, engineering-audit; AGENT-OPS contrato item 3) — sem isso o safety-net re-arma `--merge` por cima; `allow_rebase_merge: true` verificado no repo. **`serializes` nomeia o registro** (ex. `serializes: 'docs/plans/'`). Docs: AGENT-OPS (Contrato item 4 + seção "Registries compartilhados"), AGENTS.md bullet, PR template (2 checkboxes), header do agregado, execution-pipeline (passo de registro). Sem migration/access/Consent/UI/schema. **Recently resolved (2026-08-13):** **OPS44** (Issue #713) — redução de conflitos de merge em arquivos compartilhados. **Changelog por entrega:** cada entrega escreve `docs/changelog/-.md` (uma entrada, imune a conflito por construção) e `pnpm changelog:build` regenera o agregado `docs/CHANGELOG-AGENTS.md` de forma **insert-only** (nunca remove/reescreve entradas; histórico não migrado) — libs puras `scripts/lib/changelog.mjs` (parse/sort fail-closed, split do header, build idempotente, `assertChangelogAppendOnly` multiset) e `scripts/lib/conflictMarkers.mjs` (regex compartilhada com o guard OPS41); CLIs `scripts/build-changelog.mjs` (com `--check`), `scripts/check-changelog-append-only.mjs` e `scripts/check-docs-conflict-markers.mjs`; job **`docs-guards`** no `ci-pr.yml` (append-only do agregado + additions-only em `docs/changelog/` + zero marcadores de conflito em diffs de `docs/` — cobre PRs docs-only onde a suíte unit não roda, a classe do incidente OPS41); escape documentado `changelog-rewrite: ` no PR body (linha própria; definição canônica em AGENT-OPS, precedente de restauração D8; usado pela própria entrega — header do agregado mudou). **Merge por rebase em 7 pontos** (`--auto --rebase` em vez de `--auto --merge`: execution-pipeline, agent-pool-prompt, plan-issue, agent-pr-workflow ×3, agent-pr-ready-automerge, engineering-audit; AGENT-OPS contrato item 3) — sem isso o safety-net re-arma `--merge` por cima; `allow_rebase_merge: true` verificado no repo. **`serializes` nomeia o registro** (ex. `serializes: 'docs/plans/'`). Docs: AGENT-OPS (Contrato item 4 + seção "Registries compartilhados"), AGENTS.md bullet, PR template (2 checkboxes), header do agregado, execution-pipeline (passo de registro). Sem migration/access/Consent/UI/schema. diff --git a/docs/campanha/plano-site-campanha-2026.md b/docs/campanha/plano-site-campanha-2026.md new file mode 100644 index 00000000..dd3b7515 --- /dev/null +++ b/docs/campanha/plano-site-campanha-2026.md @@ -0,0 +1,231 @@ +# Plano Geral — Site de Campanha Jorge Solla 1313 (2026) + +> **Natureza deste documento:** plano **geral** do site de campanha (não é um +> plano de issue comum). Ele registra o contexto, as decisões de produto, +> design, conversão e engenharia, e as pendências. **A partir dele** serão +> gerados os planos de issue (`docs/plans/`) quando a equipe estiver pronta +> para implementar. +> +> Status: **rascunho de design/conversão em colaboração** (15/08/2026). +> Domínio de publicação: `jorgesolla1313.com.br` (vertical pública do Teqo). + +--- + +## 1. Objetivo único da página + +**Captar apoiador** — nome + WhatsApp + cidade — na reta final da campanha +(eleição: **04/10/2026**, 1º turno; hoje, 15/08, faltam ~50 dias). + +- CTA primário: **WhatsApp com mensagem pré-configurada** (`wa.me`) e + formulário de 3 campos no CTA final. +- Doação é canal **secundário** (link para `apoiar.me/jorgesolla`, homologado + TSE) — o site nunca processa pagamento. + +## 2. Faseamento (macro) + +| Fase | Escopo | +| ------ | --------------------------------------------------------------------------------------------------------- | +| **v1** | Página única (landing) conforme wireframe — captação de apoiador + prova social + board IG/YT + CTA final | +| **v2** | Compartilhamento por seção (ver §7) + páginas próprias de proposta (SEO/AEO) | +| **v3** | (a definir: agenda/eventos, abaixo-assinados integrados, etc.) | + +## 3. Estrutura da página (10 seções — wireframe) + +1. Header fixo (logo + 1313 + CTA âncora "Quero apoiar") +2. Hero (quem é + cargo + número + slogan "Um mandato do tamanho da Bahia" + CTA WhatsApp) +3. Prova social numérica (3 números verificados) +4. O problema (o que está em jogo no Congresso) +5. Bandeiras (6 cards — sem link "ler proposta", decisão do cliente; cada + bandeira vira página própria de SEO/AEO na v2) +6. Quem é Jorge Solla (linha do tempo + **etiquetas de esfera** + formação e militância) +7. Acompanhe de perto (board de conteúdo IG + YouTube, bento 1 grande + 4 pequenos) +8. Depoimentos reais (NEEDS ASSET) +9. CTA final + formulário apoiador (3 campos) + doação secundária +10. Rodapé (identificação eleitoral, redes, privacidade) + +Referência visual: [`wireframe-solla-1313.html`](./wireframe-solla-1313.html) +(mesmo diretório). Variantes de hero A/B/C documentadas no fim do arquivo. + +## 4. Decisões registradas + +### 4.1 Produto e conversão (doutrina de campanha 2026) + +1. **Um CTA primário por página**; nunca dois de peso igual acima da dobra. +2. **Fase define o CTA**: na reta final, captar apoio primeiro; doação secundária. +3. **Formulário mínimo**: nome + WhatsApp + cidade; coleta progressiva; cada + campo acima de 3 derruba conversão. **Nunca CPF/endereço na 1ª interação.** +4. **LGPD**: consentimento explícito (checkbox) + link de privacidade; + consent por stable key (padrão do Teqo: `apoiador-cadastro`, + `apoiador-intencao-voto` — fail-closed). +5. **WhatsApp com mensagem pré-configurada** = elemento de maior conversão em + sites políticos brasileiros. +6. **Mobile-first** (maioria do tráfego é celular): inputs ≥16px (evita zoom + iOS), alvos de toque ≥44px, carregamento <3s. +7. **Prova social com fonte**: números verificados na Câmara (13/08/2026): + 3.333 proposições (API Dados Abertos), 1.031 discursos (SitaqWeb), 3º + mandato. "Sem fonte, não publica." +8. **Urgência ética**: só contagem regressiva real da eleição (04/10/2026). +9. **Diferencial central**: gestão nas três esferas (município → estado → + União) — explícito no hero e na seção "Quem é" (lead + etiquetas de esfera + na linha do tempo). Não há seção separada "diferencial" (decisão do cliente). +10. **Formação** (verificada): médico UFBA, mestre em Saúde Coletiva (UFBA), + doutor em Clínica Médica (UFRJ), pesquisador do ISC-UFBA. +11. **Militância** (verificada): um dos fundadores do PT na Bahia, filiado + desde 1980 (46 anos de partido), militante do SUS. +12. **Depoimentos sempre reais**, com nome completo e cidade — nunca inventar. +13. **Identificação eleitoral obrigatória** no rodapé: nome completo, nº 1313, + federação (PT/PCdoB/PV), CNPJ da campanha. +14. **Copies revisados pelo agente solla-comunicacao (15/08/2026)** — decisões: + - h1 usa o slogan oficial do mandato: **"Um mandato do tamanho da Bahia"** + (presença no estado todo + amplitude saúde/educação; "Saúde é direito, + não mercadoria" era bandeira comum a qualquer candidato do campo, não + diferenciava). + - "Saúde é direito, não mercadoria" **reaproveitada** como frase de abertura + do card de proposta Saúde (a bandeira central não morre, desce do hero + para o lugar onde o trabalhador da saúde procura). + - Sub do hero reescrito para amplitude: feitos verificados + "leva o + mandato aos 417 municípios, da saúde à educação". + - Removido "SUS atende 8 em cada 10 brasileiros" (sem fonte no mandato — + regra: sem fonte, não publica). + - Variantes B/C ajustadas para tom factual ("Tem gente em Brasília + querendo acabar com o SUS…" em vez de "O Congresso quer…"). +15. **Regra de narrativa do momento (decisão do cliente, 15/08/2026)**: o + governo atual é do PT e o candidato é do PT — a crítica ao momento existe + para mostrar a luta por melhorar, mas **sempre pontuada pelo + reconhecimento do que melhorou com Lula**. Enquadramento padrão: + **avanço (reconhecimento) → obstáculo estrutural → por que a eleição + importa**. O "vilão" nunca é o governo atual: são os limites fiscais + herdados (teto de gastos da direita, o campo político de Temer) e um + Congresso sem maioria aliada. + Aplicado nos cards "Saúde subfinanciada" (Nova PAC R$ 30,5 bi + Mais + Médicos retomado antes do problema), "Jornada que adoece" (vitória da + bancada de Lula na Câmara contra a direita → briga no Senado, risco de + adiamento de 10 anos) e "Mataripe nas mãos de estrangeiros" (título antigo + "Bahia entregue ao atraso" rejeitado — ambiguidade de leitura "entregue + pelo PT"; versão curta escolhida pelo cliente). TÍTULOS EM NOTAÇÃO DE + BANDEIRA POSITIVA (decisão do cliente, 15/08): "Por um SUS com recursos!", + "Pelo fim da escala 6x1!", "Pela recompra de Mataripe!" — três aberturas + diferentes (Por um / Pelo fim / Pela) para não repetir construção na + grade. Título da seção: "Eleger deputado é coisa séria. O Congresso tem + nas mãos vidas reais e o SUS." +16. **Skill "humanize-ai-writing" instalada (15/08/2026)** em + `~/.config/opencode/skills/humanize-ai-writing/` (fonte: github.com/haidrrrry/humanize-ai-writing, + baseada no catálogo da Wikipedia "Signs of AI writing"). Passada final de + humanização aplicada ao wireframe: 12 travessões de copy → ponto/dois-pontos + (restam só os 2 aprovados + travessão legal TSE + placeholders de API); + bullets "✔" → lista nativa (acessibilidade); zero vocabulário banido, zero + tom promocional, zero conclusões ocas. Voz do mandato tem prioridade sobre + as regras genéricas (zona cinza validada: "Saúde é direito, não mercadoria", + "Reeleger Lula não basta…", "Médico, gestor, deputado", aspas tipográficas + em depoimentos — todas mantidas de propósito). + +### 4.2 Engenharia (stack Teqo) + +- **Formulário apoiador** → segue o padrão do Teqo: `Contact` normalizado + + join (ex.: `supporter`) + `Consent` por stable key; escrita transacional + (`payload.db.beginTransaction` + `req.transactionID`); revalidação de cache + via `afterChange`. +- **Select de cidade** usa os dados existentes (`src/lib/cities.ts` / + `municipalityCatalog.ts` — 417 municípios, Salvador por zona eleitoral). +- **Board IG + YT** (decidido em 15/08): + - Clique **abre direto na plataforma** (sem lightbox). + - **YouTube automático**: Data API v3 (API key + channel ID no admin), + server-side, cache ISR + revalidate periódico, thumbnails CDN, sem iframe + no load. + - **Instagram automático**: Instagram Graph API (conta Business/Creator + vinculada a página do Facebook + app Meta for Developers com + `instagram_basic` + token long-lived com refresh automático ~60 dias). + - **Config no admin**: global do Payload `SocialFeedSettings` — YouTube API + key, channel ID, IG access token, IG user ID, máx. itens, toggle por + plataforma, "pausar feed" (kill switch — não é curadoria). + - **Fail-closed**: API falhou/token expirou → mantém último snapshot em + cache; página nunca quebra. IG só liga após setup da Meta (review leva dias). + - **Nunca** script de embed pesado do Instagram no carregamento (derruba + LCP no mobile). + +## 5. Pendências (roteamento) + +| Pendência | Roteamento | +| ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------- | +| Número de WhatsApp oficial da campanha (placeholder `5571999999999`) | Assessoria | +| CNPJ da campanha (rodapé) | Advogado eleitoral | +| Depoimentos reais (3, nome + cidade + foto) | Assessoria | +| Foto hero 4:5 + foto de apoio (multidão/caravana) | Produção | +| Contador de apoiadores? (se exibir, fonte = banco real, nunca inventar) | Coordenação | +| Confirmar "50 dias" na data de publicação (contagem com data fixa) | Assessoria | +| **Doutorado sanduíche em Durham** — não consta nas fontes atuais (doutorado = UFRJ, 2009); se confirmado, entra na Formação | Verificação com assessoria | +| **"Professor da UFBA"** — fonte atual diz "pesquisador do ISC-UFBA" | Verificação com assessoria | +| **Mix mandato/campanha no board**: automático expõe tudo que a conta postar; se @depjorgesolla postar conteúdo de mandato no período eleitoral, decide-se: aceitar mix, filtrar por tag/hashtag, ou usar conta de campanha | Assessoria | +| Variante de hero (A ativa; B/C documentadas) + plano de A/B | Coordenação + este plano | +| **"Teto de gastos" vs "arcabouço fiscal"**: o teto (EC 95/2017, governo Temer) foi substituído pelo Novo Arcabouço Fiscal (LC 200/2023). O copy aplicado usa "teto de gastos da direita" (decisão do cliente, 15/08 — culpa o campo político de Temer, amarrando à eleição atual, em vez do inimigo específico antigo); a imprecisão jurídica do instrumento segue registrada para checagem da assessoria/advogado antes do go-live | Assessoria + advogado eleitoral | +| **Status processual da pauta 6×1** (card "Jornada que adoece"): o copy afirma que o fim da 6×1 foi aprovado na Câmara (derrotando a direita) e está no Senado, com risco de modificação/adiamento de 10 anos. Confirmar com a assessoria o estágio exato (nº da proposta, o que foi votado, prazo proposto pela direita) antes do go-live — sem fonte, não publica | Assessoria | + +## 6. Regras inegociáveis (vale para qualquer issue derivada) + +- Sem fonte, não publica (número, obra, citação, depoimento). +- Um CTA por página; nunca dividir atenção acima da dobra. +- LGPD: consentimento explícito, fail-closed; nunca CPF/endereço no 1º passo. +- Doação só via QueroApoiar; o app nunca processa pagamento. +- Nada de fake news, ataques pessoais ou desinformação (combate com fatos). +- Nunca inventar foto, depoimento, apoiador, selo ou cobertura (NEEDS ASSET). +- Período eleitoral: mecanismo `hidden`/`isPostVisible` para puxar conteúdo + quando exigido; site de mandato e de campanha não se misturam sem orientação. +- Convenções do Teqo: identificadores em inglês, transações, migrações + commitadas, revalidação de cache, admin em pt. + +## 7. v2 — ideias registradas (não priorizadas) + +### 7.1 Compartilhamento por seção (ideia do cliente, 15/08/2026) + +Cada subseção vira algo compartilhável pelo visitante nas redes sociais: + +- **WhatsApp com mensagem pré-configurada por seção** — ex.: "Olha a proposta + do Jorge Solla para o fim da escala 6×1: [link]". O WhatsApp é o canal de + compartilhamento nº 1 no Brasil — mesma lógica do CTA primário. +- **Imagem OG por seção**: o preview do link no WhatsApp é o que vende o + clique — gerar card/imagem por seção (proposta, trajetória, depoimento). +- **Botões de compartilhar discretos por card**, nunca competindo com o CTA + primário da página. +- **UTM por seção** para medir qual conteúdo mais circula — vira insumo de + conteúdo para a reta final (fechar o loop com a assessoria). +- Seções candidatas: cards de proposta (maior valor prático/currency), + trajetória (prova), depoimentos (voz de terceiros), board IG/YT. + +### 7.2 Páginas próprias de bandeira (SEO/AEO) + +Cada card de bandeira vira página própria ("fim da escala 6x1", "recompra de +Mataripe", "saúde na Bahia") — são buscas reais; structured data de entidade +(Person, PoliticalParty, Office). + +## 8. Próximos passos + +1. Revisão humana do wireframe (seções, textos, variantes de hero). +2. Resolver pendências de conteúdo (assessoria/advogado) — bloqueiam go-live. +3. Quando a equipe estiver pronta: **gerar planos de issue a partir deste + documento** (cada seção/d decisão vira um `docs/plans/` + Issue). +4. Testes A/B de uma variável por vez (título, CTA, foto, formulário) com + 7–14 dias de medição. + +## Anexo — Mapa de fotos por subseção (levantamento 15/08/2026) + +> Fotos no jorgesolla.com.br são ativos da campanha (uso seguro). Fotos de +> terceiros (Agência Brasil, Bnews, A TARDE) precisam de autorização. Banco +> local: `/home/fsolla/Documentos/Solla/MATERIAL SOLLA/FOTOS/SELEÇÃO/` (52 fotos JOA\*.jpg). + +| Subseção | Tema | Fotos candidatas (link) | Nota | +| -------------------------- | ------------------ | ------------------------------------------------------------------------------------- | ------------------------------------ | +| Hero (retrato) | Retrato 4:5 | `jorgesolla.com.br/wp-content/uploads/2026/05/SOLLA-CARD.jpg` (via artigo Clube 2004) | Retrato vertical, ideal p/ hero | +| Hero/aliança (alternativa) | Lula+Jerônimo+time | `…/2026/08/PT-CONVENCAO.jpg` (artigo convenção 02/08) | Multidão/time | +| Saúde (card + proposta) | Hospital | `…/2026/07/IMG_1672.jpeg` (artigo Hospital Litoral Norte) | Inauguração | +| Saúde (trabalhadores) | Piso enfermagem | `…/2026/05/PISO-ENFER-II-foto-Marcelo-Camargo-Agencia-Brasil.jpg` | ⚠️ Foto Agência Brasil — autorização | +| Jornada 6×1 | Multidão subúrbio | `…/2026/06/PGP-SUBURBABANA.jpeg` (artigo PGP Periperi) | Multidão (não específica 6×1) | +| Jornada 6×1 (externo) | Coletiva | bnews.com.br (foto Henrique Brinco) e atarde.com.br | ⚠️ Terceiros — referência | +| Mataripe | — | Nenhuma foto do Solla nos artigos (só fundo genérico) | Usar banco local ou produzir | +| Educação | FAMED UFBA | `…/2026/05/IMG_0638.jpeg` (artigo visita CFFC/FAMED) | Educação + saúde | +| Renda | Pacote R$ 2 bi | `…/2026/06/GOVBA-PACOTE-2-BI-1024x683.jpeg` | Verificar presença do Solla | +| Democracia | 2 de Julho | `…/2026/07/2-de-julho.jpg` | Data cívica | +| Quem é (timeline) | Históricas | Não encontradas em artigos atuais | Banco local / arquivo do mandato | +| Prova de trabalho | Plenária Câmara | `…/2026/08/PLENARIA-SOLLA.jpeg` e `…/2026/08/PLENARIA.jpg` (artigo 11/08) | Solla em plenária | +| CTA final (multidão) | Caravana | `…/2026/07/PHOTO-2026-07-31-13-37-26.jpg` e `…-1.jpg` (artigo Chapada) | 2 fotos | +| CTA final (interior) | Ubatã | `…/2026/06/2e00ab79-13ba-4b39-86e4-52d894c6ddd2.jpeg` (artigo mercado Ubatã) | Inauguração | +| Board IG/YT | — | Vem da API (sem foto fixa) | — | diff --git a/docs/campanha/prompt-montagem-home.md b/docs/campanha/prompt-montagem-home.md new file mode 100644 index 00000000..17e8b4d3 --- /dev/null +++ b/docs/campanha/prompt-montagem-home.md @@ -0,0 +1,143 @@ +# Prompt — Montar a home pública de campanha (Jorge Solla 1313) + +> Use este prompt com um agente **poderoso (visão + raciocínio)** para montar a +> home pública da campanha a partir do rascunho Penpot e do wireframe HTML. +> Você tem acesso ao **MCP do Penpot** (já configurado) e pode ler imagens. + +--- + +## 1. Contexto + +Você vai construir a **página inicial pública de campanha** do Deputado Federal +**Jorge Solla (PT-BA)**, nº **1313**, para o domínio `jorgesolla1313.com.br`. +O app é o **Teqo** (Next.js + Payload), monorepo; a home vive em +`src/app/(frontend)/(home)/`. A home foi **zerada** (placeholder) — você vai +substituí-la. A rota `/artigos` já existe (feed de notícias movido da home +antiga) — **não mexa nela**. + +## 2. Fontes de verdade (leia TODAS antes de codar) + +1. **Rascunho Penpot (o SPEC visual)** — acesse via MCP do Penpot: + - Team: `369311f5-bdaf-8185-8008-54047a2d029c` + - File: `8694f143-a620-8054-8008-66e8aa584028` + - Page: `62dfa60b-a5ec-8097-8008-7cc15cc939bd` ("Page 2") + - Use `penpotUtils` (getPageById, shapeStructure, dump de geometria x/y/w/h, + cores, fontes) para extrair o layout exato. Há 2 boards: **desktop + 1024×1954** e **mobile 393×2365**. Referências exportadas também em: + `/home/fsolla/Downloads/Board(2).png` (desktop) e + `/home/fsolla/Downloads/Board(1).png` (mobile). +2. **Wireframe HTML** (draft de conversão): `docs/campanha/wireframe-solla-1313.html` +3. **Plano geral** (decisões, pendências, regras): `docs/campanha/plano-site-campanha-2026.md` +4. **Voz do mandato**: skill `solla-comunicacao` (carregue antes de escrever). + +## 3. Estado atual do repo (já pronto — REUSE, não recrie) + +- **Fontes** já carregadas no layout do frontend: `--font-exo2` (Exo 2), `--font-arimo` (Arimo), `--font-sans` (Inter). Use via `font-[family-name:var(--font-exo2)]`. +- **Tokens** `campaign-site` já em `src/app/(frontend)/styles.css`: + `--pt-red:#a21c1c`, `--pt-red-dark:#47130e`, `--pt-yellow:#ffe607`, + `--pt-yellow-ink:#000`, `--campaign-cream:#fff8f2`, `--campaign-ink:#000`, + `--campaign-muted`, `--campaign-surface:#f9faff`, `--campaign-band:#ebe9e9`. + Aplicados via `data-theme="campaign-site"` no layout da home. +- **Assets** em `public/` (.avif): `JOA00162.avif` (foto hero), `fundo.avif`, + `53569851134_02afc18fb4_o.avif`, `52396285023_561ffc0ff6_o.avif` (cards), + `Lula.avif`, `Jeronimo.avif`, `WAGNER%20-%202-9%20final.avif`, + `RUI%20-%202-2.avif` (aliados), `LOGO_SOLLA_BRANCO.svg` (logo branca). +- **Componente** `src/components/CampaignFooter.tsx` (rodapé do wireframe, CNPJ + 68.430.467/0001-05 já aplicado) — pode usar ou refazer. +- Layout atual da home: `src/app/(frontend)/(home)/layout.tsx` (container de + scroll + data-theme) — ajuste se precisar. + +## 4. O que montar (as 4 seções do Penpot + rodapé) + +### 4.1 Hero +- Fundo vermelho `#a21c1c`. **Sem header separado** — a logo branca vive DENTRO + do hero (topo esquerdo). **Sem barra fixa**. +- Desktop: **foto à esquerda** (retrato JOA00162) + **colagem de 4 aliados** + (Lula, Jerônimo, Wagner, Rui) + **texto à direita ALINHADO À DIREITA**. +- Mobile: foto em cima, texto centralizado embaixo. +- H1: `UM MANDATO DO TAMANHO DA BAHIA` (caixa alta, Exo 2, branco). +- Sub: `Médico sanitarista. Criou o SAMU 192 e o Brasil Sorridente. Já comandou a saúde em Vitória da Conquista, na Bahia e no Brasil — e leva o mandato aos 417 municípios, da saúde à educação.` +- Botões: `Conhecer bandeiras` (contorno branco) + `Quero apoiar` (amarelo + `#ffe607`, texto preto) → **https://apoiar.me/jorgesolla** (vaquinha, CTA primário). +- Micro-provas: `DIAP entre os 40 melhores da Câmara` · `Mais votado do PT-BA em 2022`. + +### 4.2 Prova social +- 3 números grandes centralizados, vermelho: `3.333 proposições apresentadas`, + `1.031 discursos em Plenário`, `3º mandato de deputado federal`. + +### 4.3 O problema +- Fundo `#47130e`. Eyebrow amarelo `POR QUE ESSA ELEIÇÃO IMPORTA`. +- H2: `Eleger deputado é coisa séria` / `O Congresso tem nas mãos vidas reais e o SUS` (2ª linha amarela). +- Lead: `É no Congresso que se define o orçamento do SUS, o piso da enfermagem, a recompra de Mataripe e o fim da escala 6×1. Reeleger Lula não basta: é preciso eleger uma bancada aliada.` +- 3 cards **em CARROSSEL** (auto-advance a cada ~4s, pausa no hover/touch; + swipe no mobile). Cada card: foto no topo + texto branco sobreposto embaixo, + cantos arredondados. O carrossel mostra UM card por vez (mobile) e pode + mostrar mais de um no desktop se couber — mas o auto-advance e o indicador + ativo valem para todos. Navegação: setas opcionais + indicadores (bolinhas) + se fizer sentido visualmente; o essencial é o auto-advance: + 1. `Pelo fim da escala 6x1!` / foto `fundo.avif` / `Jornadas longas aumentam o risco de AVC e infarto. Descanso é saúde pública. A bancada de Lula derrotou a direita na Câmara: o fim da 6×1 avançou. Mas a briga continua no Senado, onde a direita tenta barrar o avanço. No dia 4 de outubro, o 1313 é o seu sim ao descanso.` + 2. `Pra valorizar o SUS` / foto `53569851134_02afc18fb4_o.avif` / `O SUS é o sistema de saúde de todo brasileiro. Com Lula, a saúde voltou a receber recursos: Nova PAC de R$ 30,5 bilhões e Mais Médicos retomado. Mas o teto de gastos da direita ainda trava o orçamento. O 1313 é seu voto em defesa do SUS no Congresso!` + 3. `Pra defender os baianos` / foto `52396285023_561ffc0ff6_o.avif` / `A Refinaria de Mataripe foi vendida no governo Bolsonaro e segue nas mãos de estrangeiros. Com Lula e Jerônimo, a Bahia voltou a andar pra frente; mas recomprar Mataripe exige bancada forte no Congresso. Votar 1313 é devolver à Bahia o que é seu.` + +### 4.4 Bandeiras +- Fundo `#ebe9e9`. Eyebrow vermelho `NOSSA CAMINHADA`. +- H2: `Junto com o trabalhador e do lado de quem mais precisa, sempre.` +- Lead: `Não é promessa de palanque, é a experiência de quem já fez na gestão do SUS, do Ministério da Saúde e da saúde da Bahia.` +- **CARROSSEL de 6 cards** (auto-advance a cada ~4s, pausa no hover/touch, + swipe no mobile) + **fileira de chips (as 6 tags) ACIMA dos cards** — o chip + do card ATIVO fica DESTACADO (1ª vermelha `#a21c1c`; demais cinza + `#dbdce0`), e o destaque acompanha o card visível no carrossel (sincronizado; + clicar num chip leva ao card correspondente). Cards brancos `#f9faff` + (1 por vez no mobile; no desktop pode mostrar mais de um), título + corpo + (Arimo): + 1. `Defender o SUS e valorizar quem cuida` / `Saúde é direito, não mercadoria. Fim do subfinanciamento, piso da enfermagem e dos agentes pagos, concursos públicos, vacinação e ciência.` + 2. `Fim da escala 6×1 e jornada de 40h` / `Sem redução de salário. Jornada longa é questão de saúde pública: quem trabalha demais adoece. O SUS paga a conta.` + 3. `Educação em tempo integral e federais no interior` / `IFs, campus federal, escola de tempo integral: o interior não pode ficar para trás no conhecimento.` + 4. `Recomprar a Refinaria de Mataripe` / `Recomprar a refinaria é defender a Bahia e a soberania do Brasil. Preço justo de combustível é política de saúde e de renda.` + 5. `Salário mínimo forte, emprego e moradia` / `Valorização do mínimo, Bolsa Família, Minha Casa Minha Vida e agricultura familiar como motor do interior.` + 6. `Defesa intransigente da democracia` / `Participação popular, instituições fortes e combate à desinformação. Eleger o parlamento é coisa séria.` + +### 4.5 Rodapé +- Use o `CampaignFooter` existente ou o rodapé do wireframe: identificação + eleitoral (nome, nº 1313, Federação PT/PCdoB/PV, **CNPJ 68.430.467/0001-05**, + eleições 04/10/2026), navegação, redes. + +## 5. Decisões e regras (não negocie) + +- **Um CTA primário** por tela (Quero apoiar → apoiar.me/jorgesolla). Doação + nunca é processada no app. +- **Carrosséis**: as seções "O problema" e "Bandeiras" usam carrossel com + **auto-advance** (timer ~4s), pausa no hover/touch, swipe no mobile e + sincronização com os indicadores/chips. Componente client ("use client"). +- **Mobile-first**; alvos de toque ≥44px; inputs ≥16px (quando houver). +- **Tipografia**: Exo 2 (display: H1, CTAs, números, eyebrows), Inter (corpo, + sub, micro-provas), Arimo (títulos/textos dos cards, tags em caixa alta). +- **Copy**: use EXATAMENTE os textos acima (do Penpot). Correções já feitas: + "mandato" (não "madato"), "O Congresso" (maiúsculo). Mantenha "pra frente", + "Pra valorizar o SUS", "Pra defender os baianos" (escolhas do cliente). +- **Uniformize** espaçamentos/cores (escala única); não precisa replicar cada + pixel do Penpot, mas a COMPOSIÇÃO (foto esq + texto dir, seções, cards, + chips) deve casar. +- **Sem fonte, não publica**: não invente números/obras. Tudo acima é verificado. +- **Convenções Teqo**: identificadores em inglês, textos visíveis em pt-BR. +- Não criar coleções/rotas paralelas; não mexer em `/artigos`. + +## 6. Como validar (obrigatório antes de entregar) + +1. Extraia a geometria do Penpot via MCP e confira sua implementação contra ela + (posições, alinhamentos, cores, fontes). +2. Suba `pnpm dev`, capture screenshots (desktop 1024 e mobile 393) **por seção** + (elemento, não fullPage — o fullPage trunca por causa do scroll aninhado). +3. Use os subagentes `design-vision` / `design-vision-fidelidade` para conferir + a implementação contra as referências `/home/fsolla/Downloads/Board(1|2).png`. + Confira também o **comportamento dos carrosséis** (auto-advance, chip ativo + sincronizado) com o Playwright (aguarde o timer avançar e valide o estado). +4. Rode: `pnpm exec tsc --noEmit`, `pnpm lint`, `pnpm format:check`, `pnpm build`. +5. Entregue um resumo: o que foi construído, como ficou vs. o Penpot, e o que + ficou pendente (assets/fontes/ajustes). + +## 7. Entregável + +- Home em `src/app/(frontend)/(home)/page.tsx` (+ layout/componentes se precisar), + fiel ao Penpot, validada (build OK), com os 4 seções + rodapé. +- Resumo curto do que foi feito e das pendências. diff --git a/docs/campanha/wireframe-solla-1313.html b/docs/campanha/wireframe-solla-1313.html new file mode 100644 index 00000000..4f0f3d51 --- /dev/null +++ b/docs/campanha/wireframe-solla-1313.html @@ -0,0 +1,1519 @@ + + + + + + Wireframe — Campanha Jorge Solla 1313 (Deputado Federal, PT-BA) + + + + + +
+ +
+ + +
+ Faltam 50 dias para a eleição. Seu voto no 1313 é o voto da Bahia inteira. +
+ + + +
+
+
+ Deputado Federal · PT-BA · Federação Brasil da Esperança +

Jorge Solla 1313
Um mandato do tamanho da Bahia.

+

+ Médico sanitarista. Criou o SAMU 192 e o Brasil Sorridente. Já comandou a saúde em + Vitória da Conquista, na Bahia e no Brasil. Leva o mandato aos 417 municípios, da saúde + à educação. +

+ +
+ 3º mandato como deputado federal + Mais votado do PT-BA em 2022 + DIAP · entre os 40 melhores da Câmara +
+
+
+ +
FOTO DO CANDIDATO
NEEDS ASSET
+
1313
+
+
+
+ + + +
+
+
+
3.333
+
proposições apresentadas
+
fonte: Câmara dos Deputados
+
+
+
1.031
+
discursos em Plenário
+
fonte: SitaqWeb/Câmara
+
+
+
3º
+
mandato de deputado federal
+
eleito e reeleito desde 2014
+
+
+
+ + +
+
+
Por que essa eleição importa
+

+ Eleger deputado é coisa séria. O Congresso tem nas mãos vidas reais e o SUS. +

+

+ Quem paga a conta da saúde é o povo. É no Congresso que se define o orçamento do SUS, o + piso da enfermagem, a recompra de Mataripe e o fim da escala 6×1. Reeleger Lula não basta: + é preciso eleger uma bancada aliada. +

+
+
+

Por um SUS com recursos!

+

+ O SUS é o sistema de saúde de todo brasileiro: faz muito com pouco. Com Lula, a saúde + voltou a receber mais recursos: Nova PAC de R$ 30,5 bilhões e Mais Médicos retomado. + Mas o teto de gastos da direita ainda trava o orçamento. É no Congresso que a luta se + decide. O 1313 é o voto que decide essa luta no Congresso. +

+
+
+

Pelo fim da escala 6x1!

+

+ Escala 6×1 e jornadas longas aumentam o risco de AVC e infarto. Quem absorve essa + conta é o SUS. Descanso é saúde pública. A bancada de Lula derrotou a direita na + Câmara: o fim da 6×1 avançou. Mas a briga continua no Senado, onde a direita tenta + modificar o texto. Houve quem quisesse adiar a vigência em 10 anos. No dia 4 de + outubro, o 1313 é o seu sim ao descanso. +

+
+
+

Pela recompra de Mataripe!

+

+ A Refinaria de Mataripe foi vendida no governo Bolsonaro e segue nas mãos de + estrangeiros. O mandato luta pela recompra. Com Lula e Jerônimo, a Bahia voltou a + andar para a frente; mas recomprar Mataripe exige bancada forte no Congresso. + Recomprar Mataripe é votar 1313: devolver à Bahia o que é seu. +

+
+
+
+
+ + + +
+
+
Nossa caminhada
+

Do lado de quem mais precisa, sempre

+

+ Não é promessa de palanque, é a experiência de quem já fez na gestão do SUS, do Ministério + da Saúde e da saúde da Bahia. Sempre do lado do trabalhador. +

+ +
+
+ Saúde +

Defender o SUS e valorizar quem cuida

+

+ Saúde é direito, não mercadoria. Fim do subfinanciamento, piso da enfermagem e dos + agentes pagos, concursos públicos, vacinação e ciência. +

+
+
+ Trabalho +

Fim da escala 6×1 e jornada de 40h

+

+ Sem redução de salário. Jornada longa é questão de saúde pública: quem trabalha demais + adoece. O SUS paga a conta. +

+
+
+ Bahia +

Recomprar a Refinaria de Mataripe

+

+ Recomprar a refinaria é defender a Bahia e a soberania do Brasil. Preço justo de + combustível é política de saúde e de renda. +

+
+
+ Educação +

Educação em tempo integral e federais no interior

+

+ IFs, campus federal, escola de tempo integral: o interior não pode ficar para trás no + conhecimento. +

+
+
+ Renda +

Salário mínimo forte, emprego e moradia

+

+ Valorização do mínimo, Bolsa Família, Minha Casa Minha Vida e agricultura familiar + como motor do interior. +

+
+
+ Democracia +

Defesa intransigente da democracia

+

+ Participação popular, instituições fortes e combate à desinformação. Eleger o + parlamento é coisa séria. +

+
+
+
+
+ + +
+
+
Quem é Jorge Solla
+

Médico, gestor, deputado. Uma vida pública a serviço da Bahia

+ +

+ Município, estado e União: ele já esteve no comando da saúde nas três esferas de governo. + A trajetória abaixo é a prova. +

+ +
+
+ Município +
1999–2002 · Vitória da Conquista
+
Secretário Municipal de Saúde
+
+ Saúde da Família de 7% para 40% da população, agentes comunitários em quase 100% e a + experiência embrionária do SAMU. +
+
+
+ União +
2003–2005 · Ministério da Saúde
+
Secretário de Atenção à Saúde
+
+ Criou o SAMU 192, o Brasil Sorridente e ampliou o Saúde da Família para 46% do país. +
+
+
+ Estado +
2007–2014 · Bahia
+
Secretário de Saúde do Estado
+
+ 5 hospitais, 18 UPAs 24h, 1.400 novos leitos, UTI triplicada e SAMU para 10 milhões de + baianos. +
+
+
+ Parlamento +
2015–hoje · Câmara dos Deputados
+
Deputado federal · 3º mandato
+
+ Relator do piso dos agentes de saúde, relator da renovação do Mais Médicos, vice-líder + da Federação Brasil da Esperança, DIAP "Cabeças do Congresso". +
+
+
+ + +
+
+

Formação

+

+ Médico pela UFBA, mestre em Saúde Coletiva (UFBA) e doutor em Clínica Médica (UFRJ). + Pesquisador do Instituto de Saúde Coletiva da UFBA. +

+

fonte: perfil oficial e currículo do mandato

+
+
+

Militância

+

+ Militante do SUS. Um dos fundadores do PT na Bahia, filiado desde 1980. São 46 anos de + partido e de luta ao lado do povo. +

+
+
+
+
+ + + +
+ +
+ + + +
+
+
Quem conhece, confia
+

A Bahia inteira na caminhada

+ +
+
+
+ “Trabalho com saúde há 20 anos e nunca vi deputado tão presente. Ele conhece o + hospital por dentro.” +
+
+
FOTO
+
+
NEEDS ASSET · nome real
+
Enfermeira · Vitória da Conquista
+
+
+
+
+
+ “Quando precisamos de emenda para o posto de saúde, o mandato atendeu. E voltou depois + para ver se ficou pronto.” +
+
+
FOTO
+
+
NEEDS ASSET · nome real
+
Liderança comunitária · Jequié
+
+
+
+
+
+ “Acompanho o Solla desde a luta contra a privatização. Gente de palavra, do lado do + povo há 40 anos.” +
+
+
FOTO
+
+
NEEDS ASSET · nome real
+
Sindicalista · Salvador
+
+
+
+
+
+
+ + + +
+
+
+
Participe
+

A campanha se constrói com você

+

+ Cadastre-se e receba no WhatsApp as notícias da caminhada, os eventos na sua cidade e + como ajudar a eleger uma bancada que defende a Bahia. +

+
    +
  • Conteúdo direto, sem spam — cancele quando quiser
  • +
  • Agenda de eventos na sua região
  • +
  • Avisos de mobilização e do dia da eleição
  • +
+
+ +
+

Quero apoiar Jorge Solla 1313

+

Preencha e clique em apoiar. Leva menos de 30 segundos.

+ +
+
+ + +
+
+ + +
+
+ + + +
+ + + + +

Seus dados são usados só para comunicação da campanha.

+
+ + +
+
+
+ + +
+
+
+

Jorge Solla 1313

+

Deputado Federal · PT · Bahia
Um mandato do tamanho da Bahia.

+
+
+

Navegue

+ +
+
+

Redes

+ +
+ +
+
+ + + + +
+ DECISÕES PENDENTES (rotear): - Número de WhatsApp oficial da campanha (placeholder + 5571999999999) → assessoria - CNPJ da campanha para o rodapé → advogado eleitoral - + Depoimentos reais (3, com nome+cidade+foto) → assessoria - Foto hero 4:5 + foto de apoio + (multidão/caravana) → produção - Definir se contador de apoiadores será exibido (fonte: banco + real) → coordenação - Confirmar se "50 dias" é exato na data de publicação → ajustar com data + fixa - VERIFICAR com assessoria: doutorado sanduíche em Durham (Reino Unido) — não consta nas + fontes atuais do mandato (doutorado = UFRJ, 2009). Se confirmado, adicionar à seção Formação. + - VERIFICAR com assessoria: título de "professor da UFBA" — fonte atual diz "pesquisador do + Instituto de Saúde Coletiva da UFBA". Usar a versão confirmada. - BOARD IG+YT (técnica — + DECIDIDO): abrir direto na plataforma (sem lightbox). YouTube automático via Data API v3 + (server-side, ISR cache, thumbnails CDN, sem iframe no load). Instagram AUTOMÁTICO via + Instagram Graph API (conta Business/Creator + página do Facebook + app Meta for Developers + + instagram_basic + token long-lived com refresh automático). Config no admin — global do + Payload "SocialFeedSettings": YouTube API key, channel ID, IG access token, IG user ID, máx. + itens, toggle por plataforma, "pausar feed" (kill switch, não é curadoria). Fail-closed: API + falhou/token expirou → mantém último snapshot em cache; nunca quebrar a página. IG só liga + depois do setup da Meta (review leva dias — sequenciar go-live). - BOARD IG+YT (conteúdo — + pendente de assessoria): automático expõe TUDO que a conta postar. Se @depjorgesolla seguir + postando conteúdo de mandato no período eleitoral, o board mistura as duas naturezas no site + de campanha. Decidir: aceitar o mix, filtrar por tag/hashtag, ou usar conta de campanha. +
+ + diff --git a/docs/changelog/2026-08-15-img-resize.md b/docs/changelog/2026-08-15-img-resize.md new file mode 100644 index 00000000..423016e7 --- /dev/null +++ b/docs/changelog/2026-08-15-img-resize.md @@ -0,0 +1 @@ +**Recently resolved (2026-08-15):** **IMG-RESIZE** — otimizador local de imagens para o draft do site de campanha (fotos pesadas travavam o PenPot). `pnpm images:resize` (`scripts/resize-images.mjs`) aceita múltiplos paths (arquivos ou pastas, varridas recursivamente), aplica EXIF-rotation, remove metadados e gera **DUAS versões por padrão com largura por formato**: `.avif` em **3840px (2× tela desktop, quality 60)** para o site, e `.webp` em **1920px (1×, quality 75 — ¼ dos pixels)** para PenPot/navegadores antigos, sem upscaling (`fit: 'inside'`); conjunto/qualidade/pasta via `--format avif,webp` / `--quality` / `--out` (default `public/`), `--max-width` sobrepõe todos. **`--remove-bg`** remove o fundo com IA 100% local (`@imgly/background-removal-node`, onnxruntime; 1ª execução baixa o modelo ~40 MB, depois cache; intermediário PNG explícito — o pacote 1.4.5 não faz sniffing de magic bytes e morre com blob sem `type`); jpeg (sem alpha) é achatado em branco. **`pnpm images:fill`** (`scripts/fill-image.mjs`) completa/corrige regiões com **FLUX-2-dev no DeepInfra** (`DEEPINFRA_API_KEY` no `.env.local`): `--extend ` cresce o canvas pela base, preenche a faixa de branco e gera a máscara dela sozinho (foto de Lula cortada no peito sem espaço abaixo — o caso de uso), ou `--mask ` para inpaint arbitrário (branco = regenerar, preto = manter); prompt default pronto para completar corpo, `--steps` (default 28, guidance 30), máscara enviada no **canal alpha do PNG** (transparente = regenerar — convenção FLUX-2 nativa; descobrimos na marra que o wrapper OpenAI-images/edits do DeepInfra **ignora o `size` e sempre devolve 1024×1024** — o endpoint **nativo** `/v1/inference/black-forest-labs/FLUX-2-dev` aceita múltiplos de 64 e devolve na mesma dimensão), canvas com padding opaco até múltiplo de 64 (`padToMultipleOf64`) recortado após a geração, **downscale de fotos enormes (27 MP) para orçamento pré-pad de 1.8 MP** — o endpoint nativo recusa >~2 MP e cobra por pixel, master PNG em `data/ai-fill/` (gitignored) e **cadeia o resize (avif+webp) no resultado** via engine compartilhada `runResizeImages` (exportada de `resize-images.mjs` com guard de argv; `--no-resize` corta a cadeia). Derivação pura em `scripts/lib/imageResize.mjs` (parse fail-closed, alias jpg→jpeg, `maxWidthFor`/qualidade por formato) e `scripts/lib/imageFill.mjs` (`parseExtendPx`, `extendedCanvas`, `padToMultipleOf64`, prompts default), unit-testadas em `tests/unit/imageResize.unit.spec.ts` + `tests/unit/imageFill.unit.spec.ts`. Sem migration/access/Consent/UI/schema. diff --git a/opencode.json b/opencode.json new file mode 100644 index 00000000..4653e50f --- /dev/null +++ b/opencode.json @@ -0,0 +1,10 @@ +{ + "$schema": "https://opencode.ai/config.json", + "mcp": { + "penpot": { + "type": "remote", + "url": "https://design.penpot.app/mcp/stream?userToken={file:.opencode/secrets/penpot-token}", + "enabled": true + } + } +} diff --git a/package.json b/package.json index 2944f465..993efb45 100644 --- a/package.json +++ b/package.json @@ -73,6 +73,8 @@ "test:int": "cross-env NODE_OPTIONS=--no-deprecation vitest run --config ./vitest.config.mts", "test:unit": "cross-env NODE_OPTIONS=--no-deprecation DATABASE_URL=postgresql://invalid:invalid@127.0.0.1:1/unit_tests_must_not_connect vitest run --config ./vitest.unit.config.mts", "ui-draft:render": "node scripts/render-ui-draft.mjs", + "images:resize": "node scripts/resize-images.mjs", + "images:fill": "node scripts/fill-image.mjs", "typecheck": "tsc --noEmit", "typecheck:tsc6": "tsc6 --noEmit", "prepare": "husky", @@ -86,6 +88,7 @@ "@base-ui/react": "^1.6.0", "@fullcalendar/react": "7.0.2", "@hookform/resolvers": "^5.2.2", + "@imgly/background-removal-node": "^1.4.5", "@payloadcms/db-postgres": "3.82.0", "@payloadcms/email-resend": "3.82.0", "@payloadcms/live-preview-react": "3.82.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0314f2ac..87ad1238 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -23,6 +23,9 @@ importers: '@hookform/resolvers': specifier: ^5.2.2 version: 5.2.2(react-hook-form@7.71.1(react@19.2.4)) + '@imgly/background-removal-node': + specifier: ^1.4.5 + version: 1.4.5 '@payloadcms/db-postgres': specifier: 3.82.0 version: 3.82.0(payload@3.82.0(@typescript/typescript6@6.0.2)(graphql@16.12.0)) @@ -1550,6 +1553,9 @@ packages: cpu: [x64] os: [win32] + '@imgly/background-removal-node@1.4.5': + resolution: {integrity: sha512-/s9K88qhKy1jPhrSkBxurUqCVqJ8KHWCc+5yWdppdC4fuSrGC8mK8WQtmULs2ASEr8naY1qpvZu0EL5jr2Hqtg==} + '@inquirer/ansi@1.0.2': resolution: {integrity: sha512-S8qNSZiYzFd0wAcyG5AXCvUHC5Sr7xpZ9wZ2py9XR88jUz8wooStVx5M6dRzczbBWjic9NP7+rY0Xi7qqK/aMQ==} engines: {node: '>=18'} @@ -1585,6 +1591,10 @@ packages: '@types/node': optional: true + '@isaacs/fs-minipass@4.0.1': + resolution: {integrity: sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==} + engines: {node: '>=18.0.0'} + '@jridgewell/gen-mapping@0.3.13': resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} @@ -3224,6 +3234,9 @@ packages: '@types/leaflet@1.9.21': resolution: {integrity: sha512-TbAd9DaPGSnzp6QvtYngntMZgcRk+igFELwR2N99XZn7RXUdKgsXMR+28bUO0rPsWp8MIu/f47luLIQuSLYv/w==} + '@types/lodash@4.14.202': + resolution: {integrity: sha512-OvlIYQK9tNneDlS0VN54LLd5uiPCBOp7gS5Z0f1mjoJYBrtStzgmJBxONW3U6OZqdtNzZPmn9BS/7WI7BFFcFQ==} + '@types/lodash@4.17.23': resolution: {integrity: sha512-RDvF6wTulMPjrNdCoYRC8gNR880JNGT8uB+REUpC2Ns4pRqQJhGz90wh7rgdXDPpCczF3VGktDuFGVnz8zP7HA==} @@ -3233,6 +3246,12 @@ packages: '@types/ms@2.1.0': resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==} + '@types/ndarray@1.0.14': + resolution: {integrity: sha512-oANmFZMnFQvb219SSBIhI1Ih/r4CvHDOzkWyJS/XRqkMrGH5/kaPSA1hQhdIBzouaE+5KpE/f5ylI9cujmckQg==} + + '@types/node@20.3.3': + resolution: {integrity: sha512-wheIYdr4NYML61AjC8MKj/2jrR/kDQri/CIpVoZwldwhnIrD/j9jIU5bJ8yBKuB2VhpFV7Ab6G2XkBjv9r9Zzw==} + '@types/node@22.19.9': resolution: {integrity: sha512-PD03/U8g1F9T9MI+1OBisaIARhSzeidsUjQaf51fOxrfjeiKN9bLVO06lHuHYjxdnqLWJijJHfqXPSJri2EM2A==} @@ -3891,6 +3910,14 @@ packages: resolution: {integrity: sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==} engines: {node: '>= 0.4'} + b4a@1.8.1: + resolution: {integrity: sha512-aiqre1Nr0B/6DgE2N5vwTc+2/oQZ4Wh1t4NznYY4E00y8LCt6NqdRv81so00oo27D8MVKTpUa/MwUUtBLXCoDw==} + peerDependencies: + react-native-b4a: '*' + peerDependenciesMeta: + react-native-b4a: + optional: true + babel-plugin-macros@3.1.0: resolution: {integrity: sha512-Cg7TFGpIr01vOQNODXOOaGz2NpCU5gl8x1qJFbb6hbZxR7XrcE2vtbAsTAbJ7/xwJtUuJEw8K8Zr/AE0LHlesg==} engines: {node: '>=10', npm: '>=6'} @@ -3905,6 +3932,43 @@ packages: resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} engines: {node: 18 || 20 || >=22} + bare-events@2.9.1: + resolution: {integrity: sha512-Z0oHEHAFDZkffN8Qc39zNZjQlMDkPJRyyyZieU1VH7u8c5S+qHZ2S8ixdKIAxEjfHO7FJxXmJWgteOghVanIsg==} + peerDependencies: + bare-abort-controller: '*' + peerDependenciesMeta: + bare-abort-controller: + optional: true + + bare-fs@4.8.0: + resolution: {integrity: sha512-fM+MhCvdQhZ7NV6S95a07gPSqjIYKn6mFaXfx266wN3ajZGl/+1AzH+ubkXQ0fFZvOe2nk9VHkzdYkQE5zMV3Q==} + engines: {bare: '>=1.28.0'} + peerDependencies: + bare-buffer: '*' + peerDependenciesMeta: + bare-buffer: + optional: true + + bare-path@3.1.1: + resolution: {integrity: sha512-JprUlveX3QjApC1cTpsUOiscADftCGVWkzitbHsRqv84hzYwYHw2mbluddsq5TvI8mH/8Ov1f4BiMAdcB0oYnQ==} + + bare-stream@2.13.3: + resolution: {integrity: sha512-Kc+brLqvEqGkjyfiwJmImAOqLZL7OsoLKuavx+hJjgVV3nLTOjloJyPMFxjUPerGGHrNH0fLU06jjykMLWrERQ==} + peerDependencies: + bare-abort-controller: '*' + bare-buffer: '*' + bare-events: '*' + peerDependenciesMeta: + bare-abort-controller: + optional: true + bare-buffer: + optional: true + bare-events: + optional: true + + bare-url@2.5.2: + resolution: {integrity: sha512-L13PCJzKG8RGvx8V1/DdMi12ERhC3tprr7/8a94BxpmnRsFqxh5XZNdhtMxu5HPkRshYOOWRGY8lDP7ZhpG9Cg==} + base64-js@1.5.1: resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} @@ -4035,6 +4099,13 @@ packages: resolution: {integrity: sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==} engines: {node: '>= 8.10.0'} + chownr@1.1.4: + resolution: {integrity: sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==} + + chownr@3.0.0: + resolution: {integrity: sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==} + engines: {node: '>=18'} + ci-info@4.4.0: resolution: {integrity: sha512-77PSwercCZU2Fc4sX94eF8k8Pxte6JAwL4/ICZLFjJLqegs7kCuAsqqj/70NQF6TvDpgFjkubQB2FW2ZZddvQg==} engines: {node: '>=8'} @@ -4290,6 +4361,10 @@ packages: decode-named-character-reference@1.3.0: resolution: {integrity: sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q==} + decompress-response@6.0.0: + resolution: {integrity: sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==} + engines: {node: '>=10'} + dedent@1.7.2: resolution: {integrity: sha512-WzMx3mW98SN+zn3hgemf4OzdmyNhhhKz5Ay0pUfQiMQ3e1g+xmTJWp/pKdwKVXhdSkAEGIIzqeuWrL3mV/AXbA==} peerDependencies: @@ -4807,6 +4882,9 @@ packages: resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==} engines: {node: '>= 0.6'} + events-universal@1.0.1: + resolution: {integrity: sha512-LUd5euvbMLpwOF8m6ivPCbhQeSiYVNb8Vs0fQ8QjXo0JTkEHpz8pxdQf0gStltaPpw0Cca8b39KxvK9cfKRiAw==} + eventsource-parser@3.0.6: resolution: {integrity: sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg==} engines: {node: '>=18.0.0'} @@ -4827,6 +4905,10 @@ packages: resolution: {integrity: sha512-9Be3ZoN4LmYR90tUoVu2te2BsbzHfhJyfEiAVfz7N5/zv+jduIfLrV2xdQXOHbaD6KgpGdO9PRPM1Y4Q9QkPkA==} engines: {node: ^18.19.0 || >=20.5.0} + expand-template@2.0.3: + resolution: {integrity: sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==} + engines: {node: '>=6'} + expand-tilde@2.0.2: resolution: {integrity: sha512-A5EmesHW6rfnZ9ysHQjPdJRni0SRar0tjtG5MNtm9n5TUvsYU8oozprtRD4AqHxcZWWlVuAmQo2nWKfN9oyjTw==} engines: {node: '>=0.10.0'} @@ -4854,6 +4936,9 @@ packages: fast-deep-equal@3.1.3: resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} + fast-fifo@1.3.2: + resolution: {integrity: sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==} + fast-glob@3.3.1: resolution: {integrity: sha512-kNFPyjhh5cKjrUltxs+wFx+ZkbRaxxmZ+X0ZU31SOsxCEtP9VPgtq2teZw1DebupL5GmDaNQ6yKMMVcM41iqDg==} engines: {node: '>=8.6.0'} @@ -4967,6 +5052,9 @@ packages: resolution: {integrity: sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==} engines: {node: '>= 0.8'} + fs-constants@1.0.0: + resolution: {integrity: sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==} + fs-extra@11.3.4: resolution: {integrity: sha512-CTXd6rk/M3/ULNQj8FBqBWHYBVYybQ3VPBw0xGKFe3tuH7ytT6ACnvzpIQ3UZtB8yvUKC2cXn1a+x+5EVQLovA==} engines: {node: '>=14.14'} @@ -5057,6 +5145,9 @@ packages: get-tsconfig@4.8.1: resolution: {integrity: sha512-k9PN+cFBmaLWtVz29SkUoqU5O0slLuHJXt/2P+tMVFT+phsSGXGkp9t3rQIqdz0e+06EHNGs3oM6ZX1s2zHxRg==} + github-from-package@0.0.0: + resolution: {integrity: sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==} + glob-parent@5.1.2: resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} engines: {node: '>= 6'} @@ -5253,6 +5344,9 @@ packages: resolution: {integrity: sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==} engines: {node: '>= 0.4'} + iota-array@1.0.0: + resolution: {integrity: sha512-pZ2xT+LOHckCatGQ3DcG/a+QuEqvoxqkiL7tvE8nn3uuu+f6i1TtpB5/FtWFbxUuVr5PZCx8KskuGatbJDXOWA==} + ip-address@10.1.0: resolution: {integrity: sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==} engines: {node: '>= 12'} @@ -5961,6 +6055,10 @@ packages: resolution: {integrity: sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==} engines: {node: '>=18'} + mimic-response@3.1.0: + resolution: {integrity: sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==} + engines: {node: '>=10'} + minimalistic-assert@1.0.1: resolution: {integrity: sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==} @@ -5981,6 +6079,17 @@ packages: minimist@1.2.8: resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} + minipass@7.1.3: + resolution: {integrity: sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==} + engines: {node: '>=16 || 14 >=14.17'} + + minizlib@3.1.0: + resolution: {integrity: sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==} + engines: {node: '>= 18'} + + mkdirp-classic@0.5.3: + resolution: {integrity: sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==} + module-definition@6.0.2: resolution: {integrity: sha512-SvAU3lB0+Yjbq55yHY3wkRZBOh+fhU1SnIF3IFbTewv6mtAh7yUT8ACHAJ2mGIJ7tCes2QuCL/cl6m0JSZ/ArA==} engines: {node: '>=18'} @@ -6021,6 +6130,9 @@ packages: engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true + napi-build-utils@2.0.0: + resolution: {integrity: sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==} + napi-postinstall@0.3.4: resolution: {integrity: sha512-PHI5f1O0EP5xJ9gQmFGMS6IZcrVvTjpXjz7Na41gTE7eE2hK11lg04CECCYEEjdc17EV4DO+fkGEtt7TpTaTiQ==} engines: {node: ^12.20.0 || ^14.18.0 || >=16.0.0} @@ -6029,6 +6141,9 @@ packages: natural-compare@1.4.0: resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} + ndarray@1.0.19: + resolution: {integrity: sha512-B4JHA4vdyZU30ELBw3g7/p9bZupyew5a7tX1Y/gGeF2hafrPaQZhgrGQfsvgfYbgdFZjYwuEcnaobeM/WMW+HQ==} + negotiator@1.0.0: resolution: {integrity: sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==} engines: {node: '>= 0.6'} @@ -6060,6 +6175,13 @@ packages: sass: optional: true + node-abi@3.94.0: + resolution: {integrity: sha512-W5ZNO5KRPB5TkYmGVD9F6YqhsglXJzE6etpbmT+f6EQElhiX/UTG551cnsRGvLG3fyZEg9HwaDmNmj5nwJ4z9g==} + engines: {node: '>=10'} + + node-addon-api@6.1.0: + resolution: {integrity: sha512-+eawOlIgy680F0kBzPUNFhMZGtJ1YmqM6l4+Crf4IkImjYrO/mqPwRMh352g23uIaQKFItcQ64I7KMaJxHgAVA==} + node-domexception@1.0.0: resolution: {integrity: sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==} engines: {node: '>=10.5.0'} @@ -6152,6 +6274,13 @@ packages: resolution: {integrity: sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ==} engines: {node: '>=18'} + onnxruntime-common@1.17.3: + resolution: {integrity: sha512-IkbaDelNVX8cBfHFgsNADRIq2TlXMFWW+nG55mwWvQT4i0NZb32Jf35Pf6h9yjrnK78RjcnlNYaI37w394ovMw==} + + onnxruntime-node@1.17.3: + resolution: {integrity: sha512-NtbN1pfApTSEjVq46LrJ396aPP2Gjhy+oYZi5Bu1leDXAEvVap/BQ8CZELiLs7z0UnXy3xjJW23HiB4P3//FIw==} + os: [win32, darwin, linux] + open@11.0.0: resolution: {integrity: sha512-smsWv2LzFjP03xmvFoJ331ss6h+jixfA4UUV/Bsiyuu4YJPfN+FIQGOIiv4w9/+MoHkfkJ22UIaQWRVFRfH6Vw==} engines: {node: '>=20'} @@ -6444,6 +6573,12 @@ packages: resolution: {integrity: sha512-dM0jVuXJPsDN6DvRpea484tCUaMiXWjuCn++HGTqUWzGDjv5tZkEZldAJ/UMlqRYGFrD/etByo4/xOuC/snX2A==} engines: {node: '>=20'} + prebuild-install@7.1.3: + resolution: {integrity: sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==} + engines: {node: '>=10'} + deprecated: No longer maintained. Please contact the author of the relevant native addon; alternatives are available. + hasBin: true + precinct@12.3.2: resolution: {integrity: sha512-JbJevI1K80z8e/WIyDt/4vUN/4qcfBSKKqOjJA4mosPPPb7zODKRJQV7YN7apVWN3k58nZYm/vEsLgEGYmnxwg==} engines: {node: '>=18'} @@ -6890,6 +7025,10 @@ packages: resolution: {integrity: sha512-dWN9aw+fszDEMPdRqAVmv5xgrsO60r5XDinHvrCjyfSW/FOiOrJJpYHGDI67+ItIwhTGHK1cXCM5YPAWVOsUtw==} hasBin: true + sharp@0.32.6: + resolution: {integrity: sha512-KyLTWwgcR9Oe4d9HwCwNM2l7+J0dUQwn/yf7S0EnTtb0eVS4RxO0eUSvxPtzT4F3SY+C4K6fqdv/DO27sJ/v/w==} + engines: {node: '>=14.15.0'} + sharp@0.34.2: resolution: {integrity: sha512-lszvBmB9QURERtyKT2bNmsgxXK0ShJrL/fvqlonCo7e6xBF8nT8xU6pW+PMIbLsz0RxQk3rgH9kd8UmvOzlMJg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} @@ -6935,6 +7074,12 @@ packages: resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} engines: {node: '>=14'} + simple-concat@1.0.1: + resolution: {integrity: sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==} + + simple-get@4.0.1: + resolution: {integrity: sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==} + simple-swizzle@0.2.4: resolution: {integrity: sha512-nAu1WFPQSMNr2Zn9PGSZK9AGn4t/y97lEm+MXTtUDwfP0ksAIX4nO+6ruD9Jwut4C49SB1Ws+fbXsm/yScWOHw==} @@ -7020,6 +7165,9 @@ packages: resolution: {integrity: sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==} engines: {node: '>=10.0.0'} + streamx@2.28.0: + resolution: {integrity: sha512-1Yowhzjf0ivGMrTIkY9hav5TxobO9qIVqUE41fiCGMGgc3CLlf4MY+9AHmZqBWgDTue0fY9zWjYFVyf6Diuobw==} + strict-event-emitter@0.5.1: resolution: {integrity: sha512-vMgjE/GGEPEFnhFub6pa4FmJBRBVOLpIII2hvCZ8Kzb7K0hlHo7mQv6xYrBvCL2LtAIBwFUK8wvuJgTVSQ5MFQ==} @@ -7173,6 +7321,26 @@ packages: resolution: {integrity: sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==} engines: {node: '>=6'} + tar-fs@2.1.5: + resolution: {integrity: sha512-OboTd8mmMhZDNPV+UjQcK9yKAatXu2aJ+r1w4im1Otd4M4fl2hwvdoXUxIYHFTHWK/3y3FarBP70v3vwmGlOxw==} + + tar-fs@3.1.3: + resolution: {integrity: sha512-/hU4AXnIdZu+Gvl1pk0oI5f5HxWsCJRtY2aFaJdk9VvyL48DWU6iU5WAIPG+wIi1YvWA6eTJvIviP/tMAZZNwQ==} + + tar-stream@2.2.0: + resolution: {integrity: sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==} + engines: {node: '>=6'} + + tar-stream@3.2.0: + resolution: {integrity: sha512-ojzvCvVaNp6aOTFmG7jaRD0meowIAuPc3cMMhSgKiVWws1GyHbGd/xvnyuRKcKlMpt3qvxx6r0hreCNITP9hIg==} + + tar@7.5.22: + resolution: {integrity: sha512-MFO/QzvtAOmJbkhOaCTvbGcFN9L9b+JunIsDwaKljSOdcLMea3NJ1k9Usz/rjdfSXTq4dfzfeS7W4p4YOAAHeA==} + engines: {node: '>=18'} + + teex@1.0.1: + resolution: {integrity: sha512-eYE6iEI62Ni1H8oIa7KlDU6uQBtqr4Eajni3wX7rpfXD8ysFx8z0+dri+KWEPWpBsxXfxu58x/0jvTVT1ekOSg==} + temporal-polyfill@1.0.3: resolution: {integrity: sha512-U8vuzZBc+dfMIfl8wXWcedfI7AJCxJjjVBIRpJUvIz5+UAp555UJDHguKsfZqHWaHRSdVUThm7mqqIWhN/xPBQ==} @@ -7182,6 +7350,9 @@ packages: temporal-utils@1.0.1: resolution: {integrity: sha512-HAixuesxFQIUaQk3ptX2jhfO/FsOkgVkDDMawvp6n/fkB1q6BKfs3lURw9I+pK/2e2e/q/vrLrxmeqauBoyGMQ==} + text-decoder@1.2.7: + resolution: {integrity: sha512-vlLytXkeP4xvEq2otHeJfSQIRyWxo/oZGEbXrtEEF9Hnmrdly59sUbzZ/QgyWuLYHctCHxFF4tRQZNQ9k60ExQ==} + thread-stream@3.1.0: resolution: {integrity: sha512-OqyPZ9u96VohAyMfJykzmivOrY2wfMSf3C5TtFJVgN+Hm6aj+voFhlK+kZEIv2FBh1X6Xp3DlnCOfEQ3B2J86A==} @@ -7327,6 +7498,9 @@ packages: resolution: {integrity: sha512-axr3IdNuVIxnaK5XGEUFTu3YmAQ6lllgrvqfEoR16g/HGnYY/6We4oWENtAnzK6/LpJ2ur9PAb80RBt7/U4ugw==} engines: {node: '>= 6.0.0'} + tunnel-agent@0.6.0: + resolution: {integrity: sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==} + tw-animate-css@1.4.0: resolution: {integrity: sha512-7bziOlRqH0hJx80h/3mbicLW7o8qLsH5+RaLR2t+OHM3D0JlWGODQKQ4cxbK7WlvmUxpcj6Kgu6EKqjrGFe3QQ==} @@ -7744,6 +7918,10 @@ packages: yallist@3.1.1: resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} + yallist@5.0.0: + resolution: {integrity: sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==} + engines: {node: '>=18'} + yaml@1.10.2: resolution: {integrity: sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==} engines: {node: '>= 6'} @@ -7786,6 +7964,9 @@ packages: peerDependencies: zod: ^3.25 || ^4 + zod@3.21.4: + resolution: {integrity: sha512-m46AKbrzKVzOzs/DZgVnG5H55N1sv1M8qZU3A8RIKbs3mrACDNeIOeilDymVb2HdmP8uwshOCF4uJ8uM9rCqJw==} + zod@3.25.76: resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==} @@ -8866,6 +9047,21 @@ snapshots: '@img/sharp-win32-x64@0.34.5': optional: true + '@imgly/background-removal-node@1.4.5': + dependencies: + '@types/lodash': 4.14.202 + '@types/ndarray': 1.0.14 + '@types/node': 20.3.3 + lodash: 4.17.23 + ndarray: 1.0.19 + onnxruntime-node: 1.17.3 + sharp: 0.32.6 + zod: 3.21.4 + transitivePeerDependencies: + - bare-abort-controller + - bare-buffer + - react-native-b4a + '@inquirer/ansi@1.0.2': {} '@inquirer/confirm@5.1.21(@types/node@22.19.9)': @@ -8894,6 +9090,10 @@ snapshots: optionalDependencies: '@types/node': 22.19.9 + '@isaacs/fs-minipass@4.0.1': + dependencies: + minipass: 7.1.3 + '@jridgewell/gen-mapping@0.3.13': dependencies: '@jridgewell/sourcemap-codec': 1.5.5 @@ -10770,6 +10970,8 @@ snapshots: dependencies: '@types/geojson': 7946.0.16 + '@types/lodash@4.14.202': {} + '@types/lodash@4.17.23': {} '@types/mdast@4.0.4': @@ -10778,6 +10980,10 @@ snapshots: '@types/ms@2.1.0': {} + '@types/ndarray@1.0.14': {} + + '@types/node@20.3.3': {} + '@types/node@22.19.9': dependencies: undici-types: 6.21.0 @@ -11405,6 +11611,8 @@ snapshots: axobject-query@4.1.0: {} + b4a@1.8.1: {} + babel-plugin-macros@3.1.0: dependencies: '@babel/runtime': 7.29.7 @@ -11417,6 +11625,35 @@ snapshots: balanced-match@4.0.4: {} + bare-events@2.9.1: {} + + bare-fs@4.8.0: + dependencies: + bare-events: 2.9.1 + bare-path: 3.1.1 + bare-stream: 2.13.3(bare-events@2.9.1) + bare-url: 2.5.2 + fast-fifo: 1.3.2 + transitivePeerDependencies: + - bare-abort-controller + - react-native-b4a + + bare-path@3.1.1: {} + + bare-stream@2.13.3(bare-events@2.9.1): + dependencies: + b4a: 1.8.1 + streamx: 2.28.0 + teex: 1.0.1 + optionalDependencies: + bare-events: 2.9.1 + transitivePeerDependencies: + - react-native-b4a + + bare-url@2.5.2: + dependencies: + bare-path: 3.1.1 + base64-js@1.5.1: {} baseline-browser-mapping@2.9.19: {} @@ -11558,6 +11795,10 @@ snapshots: optionalDependencies: fsevents: 2.3.3 + chownr@1.1.4: {} + + chownr@3.0.0: {} + ci-info@4.4.0: {} class-variance-authority@0.7.1: @@ -11773,6 +12014,10 @@ snapshots: dependencies: character-entities: 2.0.2 + decompress-response@6.0.0: + dependencies: + mimic-response: 3.1.0 + dedent@1.7.2(babel-plugin-macros@3.1.0): optionalDependencies: babel-plugin-macros: 3.1.0 @@ -12410,6 +12655,12 @@ snapshots: etag@1.8.1: {} + events-universal@1.0.1: + dependencies: + bare-events: 2.9.1 + transitivePeerDependencies: + - bare-abort-controller + eventsource-parser@3.0.6: {} eventsource-parser@3.1.0: {} @@ -12445,6 +12696,8 @@ snapshots: strip-final-newline: 4.0.0 yoctocolors: 2.1.2 + expand-template@2.0.3: {} + expand-tilde@2.0.2: dependencies: homedir-polyfill: 1.0.3 @@ -12495,6 +12748,8 @@ snapshots: fast-deep-equal@3.1.3: {} + fast-fifo@1.3.2: {} + fast-glob@3.3.1: dependencies: '@nodelib/fs.stat': 2.0.5 @@ -12634,6 +12889,8 @@ snapshots: fresh@2.0.0: {} + fs-constants@1.0.0: {} + fs-extra@11.3.4: dependencies: graceful-fs: 4.2.11 @@ -12725,6 +12982,8 @@ snapshots: dependencies: resolve-pkg-maps: 1.0.0 + github-from-package@0.0.0: {} + glob-parent@5.1.2: dependencies: is-glob: 4.0.3 @@ -12924,6 +13183,8 @@ snapshots: hasown: 2.0.2 side-channel: 1.1.0 + iota-array@1.0.0: {} + ip-address@10.1.0: {} ipaddr.js@1.9.1: {} @@ -13812,6 +14073,8 @@ snapshots: mimic-function@5.0.1: {} + mimic-response@3.1.0: {} + minimalistic-assert@1.0.1: {} minimatch@10.2.4: @@ -13832,6 +14095,14 @@ snapshots: minimist@1.2.8: {} + minipass@7.1.3: {} + + minizlib@3.1.0: + dependencies: + minipass: 7.1.3 + + mkdirp-classic@0.5.3: {} + module-definition@6.0.2: dependencies: ast-module-types: 6.0.2 @@ -13881,10 +14152,17 @@ snapshots: nanoid@3.3.16: {} + napi-build-utils@2.0.0: {} + napi-postinstall@0.3.4: {} natural-compare@1.4.0: {} + ndarray@1.0.19: + dependencies: + iota-array: 1.0.0 + is-buffer: 1.1.6 + negotiator@1.0.0: {} next-themes@0.4.6(react-dom@19.2.4(react@19.2.4))(react@19.2.4): @@ -13917,6 +14195,12 @@ snapshots: - '@babel/core' - babel-plugin-macros + node-abi@3.94.0: + dependencies: + semver: 7.7.4 + + node-addon-api@6.1.0: {} + node-domexception@1.0.0: {} node-fetch@3.3.2: @@ -14010,6 +14294,13 @@ snapshots: dependencies: mimic-function: 5.0.1 + onnxruntime-common@1.17.3: {} + + onnxruntime-node@1.17.3: + dependencies: + onnxruntime-common: 1.17.3 + tar: 7.5.22 + open@11.0.0: dependencies: default-browser: 5.5.0 @@ -14392,6 +14683,21 @@ snapshots: powershell-utils@0.1.0: {} + prebuild-install@7.1.3: + dependencies: + detect-libc: 2.1.2 + expand-template: 2.0.3 + github-from-package: 0.0.0 + minimist: 1.2.8 + mkdirp-classic: 0.5.3 + napi-build-utils: 2.0.0 + node-abi: 3.94.0 + pump: 3.0.3 + rc: 1.2.8 + simple-get: 4.0.1 + tar-fs: 2.1.5 + tunnel-agent: 0.6.0 + precinct@12.3.2: dependencies: '@dependents/detective-less': 5.0.3 @@ -15029,6 +15335,21 @@ snapshots: - supports-color - typescript + sharp@0.32.6: + dependencies: + color: 4.2.3 + detect-libc: 2.1.2 + node-addon-api: 6.1.0 + prebuild-install: 7.1.3 + semver: 7.7.4 + simple-get: 4.0.1 + tar-fs: 3.1.3 + tunnel-agent: 0.6.0 + transitivePeerDependencies: + - bare-abort-controller + - bare-buffer + - react-native-b4a + sharp@0.34.2: dependencies: color: 4.2.3 @@ -15137,6 +15458,14 @@ snapshots: signal-exit@4.1.0: {} + simple-concat@1.0.1: {} + + simple-get@4.0.1: + dependencies: + decompress-response: 6.0.0 + once: 1.4.0 + simple-concat: 1.0.1 + simple-swizzle@0.2.4: dependencies: is-arrayish: 0.3.4 @@ -15203,6 +15532,15 @@ snapshots: streamsearch@1.1.0: {} + streamx@2.28.0: + dependencies: + events-universal: 1.0.1 + fast-fifo: 1.3.2 + text-decoder: 1.2.7 + transitivePeerDependencies: + - bare-abort-controller + - react-native-b4a + strict-event-emitter@0.5.1: {} string-width@4.2.3: @@ -15364,6 +15702,59 @@ snapshots: tapable@2.3.3: {} + tar-fs@2.1.5: + dependencies: + chownr: 1.1.4 + mkdirp-classic: 0.5.3 + pump: 3.0.3 + tar-stream: 2.2.0 + + tar-fs@3.1.3: + dependencies: + pump: 3.0.3 + tar-stream: 3.2.0 + optionalDependencies: + bare-fs: 4.8.0 + bare-path: 3.1.1 + transitivePeerDependencies: + - bare-abort-controller + - bare-buffer + - react-native-b4a + + tar-stream@2.2.0: + dependencies: + bl: 4.1.0 + end-of-stream: 1.4.5 + fs-constants: 1.0.0 + inherits: 2.0.4 + readable-stream: 3.6.2 + + tar-stream@3.2.0: + dependencies: + b4a: 1.8.1 + bare-fs: 4.8.0 + fast-fifo: 1.3.2 + streamx: 2.28.0 + transitivePeerDependencies: + - bare-abort-controller + - bare-buffer + - react-native-b4a + + tar@7.5.22: + dependencies: + '@isaacs/fs-minipass': 4.0.1 + chownr: 3.0.0 + minipass: 7.1.3 + minizlib: 3.1.0 + yallist: 5.0.0 + + teex@1.0.1: + dependencies: + streamx: 2.28.0 + transitivePeerDependencies: + - bare-abort-controller + - react-native-b4a + temporal-polyfill@1.0.3: dependencies: temporal-spec: 1.0.1 @@ -15373,6 +15764,12 @@ snapshots: temporal-utils@1.0.1: {} + text-decoder@1.2.7: + dependencies: + b4a: 1.8.1 + transitivePeerDependencies: + - react-native-b4a + thread-stream@3.1.0: dependencies: real-require: 0.2.0 @@ -15510,6 +15907,10 @@ snapshots: dependencies: tslib: 1.14.1 + tunnel-agent@0.6.0: + dependencies: + safe-buffer: 5.2.1 + tw-animate-css@1.4.0: {} type-check@0.4.0: @@ -15954,6 +16355,8 @@ snapshots: yallist@3.1.1: {} + yallist@5.0.0: {} + yaml@1.10.2: {} yaml@2.9.0: {} @@ -15988,6 +16391,8 @@ snapshots: dependencies: zod: 3.25.76 + zod@3.21.4: {} + zod@3.25.76: {} zod@4.3.6: {} diff --git a/public/52396285023_561ffc0ff6_o.avif b/public/52396285023_561ffc0ff6_o.avif new file mode 100644 index 00000000..6f355fcb Binary files /dev/null and b/public/52396285023_561ffc0ff6_o.avif differ diff --git a/public/52396285023_561ffc0ff6_o.webp b/public/52396285023_561ffc0ff6_o.webp new file mode 100644 index 00000000..82e16579 Binary files /dev/null and b/public/52396285023_561ffc0ff6_o.webp differ diff --git a/public/53569851134_02afc18fb4_o.avif b/public/53569851134_02afc18fb4_o.avif new file mode 100644 index 00000000..1c6690c6 Binary files /dev/null and b/public/53569851134_02afc18fb4_o.avif differ diff --git a/public/53569851134_02afc18fb4_o.webp b/public/53569851134_02afc18fb4_o.webp new file mode 100644 index 00000000..acdec0be Binary files /dev/null and b/public/53569851134_02afc18fb4_o.webp differ diff --git a/public/JOA00162.avif b/public/JOA00162.avif new file mode 100644 index 00000000..d39c3ae0 Binary files /dev/null and b/public/JOA00162.avif differ diff --git a/public/JOA00162.webp b/public/JOA00162.webp new file mode 100644 index 00000000..025f17c7 Binary files /dev/null and b/public/JOA00162.webp differ diff --git a/public/Jeronimo.avif b/public/Jeronimo.avif new file mode 100644 index 00000000..9e4cbdc2 Binary files /dev/null and b/public/Jeronimo.avif differ diff --git a/public/Jeronimo.webp b/public/Jeronimo.webp new file mode 100644 index 00000000..0eb3d694 Binary files /dev/null and b/public/Jeronimo.webp differ diff --git a/public/Lula.avif b/public/Lula.avif new file mode 100644 index 00000000..23d974e0 Binary files /dev/null and b/public/Lula.avif differ diff --git a/public/Lula.webp b/public/Lula.webp new file mode 100644 index 00000000..512e29f3 Binary files /dev/null and b/public/Lula.webp differ diff --git a/public/Marca-Lula.avif b/public/Marca-Lula.avif new file mode 100644 index 00000000..a64c042f Binary files /dev/null and b/public/Marca-Lula.avif differ diff --git a/public/Marca-Lula.webp b/public/Marca-Lula.webp new file mode 100644 index 00000000..dd0a05ef Binary files /dev/null and b/public/Marca-Lula.webp differ diff --git a/public/Prancheta 1@3x.png b/public/Prancheta 1@3x.png new file mode 100644 index 00000000..b00317b6 Binary files /dev/null and b/public/Prancheta 1@3x.png differ diff --git a/public/Prancheta 2@3x.png b/public/Prancheta 2@3x.png new file mode 100644 index 00000000..653f5e24 Binary files /dev/null and b/public/Prancheta 2@3x.png differ diff --git a/public/Prancheta 3@3x.png b/public/Prancheta 3@3x.png new file mode 100644 index 00000000..e91da966 Binary files /dev/null and b/public/Prancheta 3@3x.png differ diff --git a/public/Prancheta 4@3x.png b/public/Prancheta 4@3x.png new file mode 100644 index 00000000..3e684800 Binary files /dev/null and b/public/Prancheta 4@3x.png differ diff --git a/public/Prancheta 5@3x.png b/public/Prancheta 5@3x.png new file mode 100644 index 00000000..51390c8e Binary files /dev/null and b/public/Prancheta 5@3x.png differ diff --git a/public/Prancheta 6@3x.png b/public/Prancheta 6@3x.png new file mode 100644 index 00000000..eda627a7 Binary files /dev/null and b/public/Prancheta 6@3x.png differ diff --git a/public/RUI - 2-2.avif b/public/RUI - 2-2.avif new file mode 100644 index 00000000..7a31aaf3 Binary files /dev/null and b/public/RUI - 2-2.avif differ diff --git a/public/RUI - 2-2.webp b/public/RUI - 2-2.webp new file mode 100644 index 00000000..4a78f55f Binary files /dev/null and b/public/RUI - 2-2.webp differ diff --git a/public/WAGNER - 2-9 final.avif b/public/WAGNER - 2-9 final.avif new file mode 100644 index 00000000..a292d15b Binary files /dev/null and b/public/WAGNER - 2-9 final.avif differ diff --git a/public/WAGNER - 2-9 final.webp b/public/WAGNER - 2-9 final.webp new file mode 100644 index 00000000..5405e4cb Binary files /dev/null and b/public/WAGNER - 2-9 final.webp differ diff --git a/public/fundo.avif b/public/fundo.avif new file mode 100644 index 00000000..24cd8ca8 Binary files /dev/null and b/public/fundo.avif differ diff --git a/public/fundo.webp b/public/fundo.webp new file mode 100644 index 00000000..252c39f6 Binary files /dev/null and b/public/fundo.webp differ diff --git a/scripts/fill-image.mjs b/scripts/fill-image.mjs new file mode 100644 index 00000000..b8c24975 --- /dev/null +++ b/scripts/fill-image.mjs @@ -0,0 +1,287 @@ +/** + * fill-image.mjs — completes/corrects a photo region with FLUX-2-dev on + * DeepInfra, then chains the resize engine (avif + webp) on the result. + * + * Two flows, driven by the mask: + * 1. `--extend ` — the photo has no room below (e.g. Lula cut off + * mid-torso): the canvas is grown downward, the strip is filled white + * and its mask is generated automatically (no painting by hand). + * 2. `--mask ` — an explicit mask, white = regenerate, black = + * preserve (same size as the photo), for arbitrary inpaint regions. + * + * The photo is sent as a PNG whose ALPHA CHANNEL is the mask (transparent = + * regenerate, opaque = preserve — the FLUX-2 native convention), padded to + * a multiple of 64 for the VAE. The white/black user mask is inverted into + * alpha; the auto `--extend` mask is a transparent bottom strip. + * + * The model output (master PNG) lands in `data/ai-fill/` (gitignored) and + * the resize engine writes the site assets into `--out` (default `public/`). + * + * Usage: + * pnpm images:fill --photo lula.jpg --extend 40% --prompt "corpo completo de homem, terno cinza" + * pnpm images:fill --photo lula.jpg --extend 600 + * pnpm images:fill --photo foto.png --mask mascara.png + * pnpm images:fill --photo foto.jpg --mask m.png --no-resize --out draft/ + * + * Needs DEEPINFRA_API_KEY (see .env.example). Each run spends a few cents + * of API credits. + */ +import { mkdir, readFile, stat, writeFile } from 'node:fs/promises' +import { basename, join, parse, resolve } from 'node:path' +import sharp from 'sharp' + +import { dieWithLabel, loadCliEnv } from './lib/cli.mjs' +import { + FILL_INFERENCE_URL, + FILL_MODEL, + padToMultipleOf64, + parseExtendPx, + parseFillArgs, +} from './lib/imageFill.mjs' +import { runResizeImages } from './resize-images.mjs' + +const die = dieWithLabel('images:fill') + +const USAGE = `Uso: pnpm images:fill --photo (--extend | --mask ) [opções] + +Completa uma região da foto com FLUX-2-dev (DeepInfra) e depois roda o +redimensionamento (avif + webp) na imagem resultante. + +Modos: + --extend aumenta o canvas PELA BASE, preenchendo a faixa nova de + branco, e gera a máscara dela sozinho — ideal quando o + corpo foi cortado e não há espaço na foto (ex.: 40%) + --mask usa uma máscara sua (branco = regenerar, preto = manter), + do mesmo tamanho da foto + +Opções: + --photo foto a editar (obrigatório) + --prompt "..." o que gerar na região (default pronto para completar corpo) + --steps <1-50> passos de geração (default 28; mais = melhor, mais caro) + --out pasta dos avif/webp (default public/) + --no-resize não roda o redimensionamento depois + -h, --help mostra esta ajuda + +Precisa de DEEPINFRA_API_KEY no .env.local (veja .env.example). +O mestre PNG fica em data/ai-fill/ (fora do git).` + +/** + * Builds the PNG sent to the model using the img2img outpaint strategy: + * the photo is placed on a white canvas grown by the strip (on `--extend`), + * and the model is told to keep the photo EXACTLY as it is and only fill + * the white area. DeepInfra's FLUX-2-dev ignores masks (alpha or a `mask` + * field both re-render the whole image — verified empirically), but with a + * clear white "void" + a hard preservation prompt it keeps the photo and + * only generates into the strip. With `--mask` the user's marked region is + * itself whitened out, so it plays the same "void" role. + * + * The canvas is padded to a multiple of 64 for the VAE and the padding + * (`crop`) is cut away after the fill. + * + * @param {string} photo + * @param {string | null} maskPath + * @param {string | null} extend + * @returns {Promise<{ imageUri: string, width: number, height: number, crop: { left: number, top: number, width: number, height: number } }>} + */ +const buildFillInputs = async (photo, maskPath, extend) => { + const photoBuffer = await sharp(await readFile(photo)) + .rotate() + .toBuffer() + const { width, height } = await sharp(photoBuffer).metadata() + const extendPx = extend !== null ? extendPxOf(extend, height) : 0 + const canvasHeight = height + extendPx + // The native endpoint refuses canvases beyond ~2 MP (and bills per pixel). + // Scale down huge photos (e.g. 27 MP camera files) to a pre-pad budget so + // the padded multiple-of-64 canvas stays under the cap. + const scale = Math.min(1, Math.sqrt(FILL_MAX_PIXELS / (width * canvasHeight))) + const photoW = Math.max(1, Math.floor(width * scale)) + const photoH = Math.max(1, Math.floor(height * scale)) + const canvasH = Math.max(1, Math.floor(canvasHeight * scale)) + const pad = padToMultipleOf64(photoW, canvasH) + + const rgb = await sharp({ + create: { width: pad.width, height: pad.height, channels: 3, background: '#ffffff' }, + }) + .composite([ + { + input: await sharp(photoBuffer) + .resize({ width: photoW, height: photoH, fit: 'fill' }) + .png() + .toBuffer(), + left: 0, + top: 0, + }, + ]) + .png() + .toBuffer() + const { data: rgbData } = await sharp(rgb).raw().toBuffer({ resolveWithObject: true }) + + if (extend === null) { + const userMask = await sharp(await readFile(maskPath)) + .rotate() + .toBuffer() + const { width: maskWidth, height: maskHeight } = await sharp(userMask).metadata() + if (maskWidth !== width || maskHeight !== height) { + die( + `a máscara ${basename(maskPath)} tem ${maskWidth}×${maskHeight}, mas a foto tem ${width}×${height} — use o mesmo tamanho`, + ) + } + const { data: luma } = await sharp(userMask) + .resize({ width: photoW, height: photoH, fit: 'fill' }) + .greyscale() + .raw() + .toBuffer({ resolveWithObject: true }) + for (let i = 0; i < photoW * photoH; i += 1) { + if (luma[i] > 127) { + rgbData[i * 3] = 255 + rgbData[i * 3 + 1] = 255 + rgbData[i * 3 + 2] = 255 + } + } + } + + const png = await sharp(rgbData, { + raw: { width: pad.width, height: pad.height, channels: 3 }, + }) + .png() + .toBuffer() + return { + imageUri: `data:image/png;base64,${png.toString('base64')}`, + width: pad.width, + height: pad.height, + crop: { left: 0, top: 0, width: photoW, height: canvasH }, + } +} + +/** Resolves the `--extend` value against a photo height once (shared by callers). */ +const extendPxOf = (extend, photoHeight) => + parseExtendPx(extend) * (extend.endsWith('%') ? Math.round(photoHeight / 100) : 1) + +/** Pre-pad canvas budget: keeps the padded multiple-of-64 canvas under ~2 MP. */ +const FILL_MAX_PIXELS = 1_800_000 + +/** + * Extracts the generated image from the OpenAI-images response shapes + * (`data[].b64_json` / `.url`) with fallbacks for other shapes DeepInfra + * returns (bare string, `images[]` data URIs). + * + * @param {unknown} payload + * @returns {string | null} base64 image data + */ +const extractOutputBase64 = (payload) => { + if (payload && typeof payload === 'object') { + const data = payload.data + if (Array.isArray(data)) { + for (const item of data) { + if (item && typeof item === 'object') { + if (typeof item.b64_json === 'string') return item.b64_json + if (typeof item.url === 'string') return item.url + } + } + } + if (Array.isArray(payload.images)) { + for (const item of payload.images) { + if (typeof item === 'string') return item.replace(/^data:image\/[a-z0-9+]+;base64,/, '') + } + } + if (Array.isArray(payload.output)) return extractOutputBase64({ data: payload.output }) + } + return null +} + +const main = async () => { + const options = parseFillArgs(process.argv.slice(2)) + if (options.help) { + console.log(USAGE) + process.exit(0) + } + + loadCliEnv() + const apiKey = process.env.DEEPINFRA_API_KEY + if (!apiKey) { + die('DEEPINFRA_API_KEY não configurada — veja .env.example') + } + + const photo = resolve(options.photo) + try { + await stat(photo) + } catch { + die(`${options.photo} não existe`) + } + + const { imageUri, width, height, crop } = await buildFillInputs( + photo, + options.mask !== null ? resolve(options.mask) : null, + options.extend, + ) + console.log( + `\n[images:fill] ${basename(photo)} → canvas ${width}×${height} (${FILL_MODEL}, ${options.steps} passos)\n`, + ) + + // Hard preservation clause first: FLUX-2 keeps the photo only when told + // in no uncertain terms to leave it untouched (img2img outpaint strategy). + const prompt = `Mantenha a fotografia EXATAMENTE como está, sem alterar um único detalhe fora da área a completar. ${options.prompt}` + const body = { + prompt, + image: imageUri, + width, + height, + steps: options.steps, + guidance: 30, + } + let response + try { + response = await fetch(FILL_INFERENCE_URL, { + method: 'POST', + headers: { + Authorization: `Bearer ${apiKey}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify(body), + signal: AbortSignal.timeout(180_000), + }) + } catch (error) { + die(`falha ao chamar o DeepInfra: ${error instanceof Error ? error.message : String(error)}`) + } + + const payload = await response.json().catch(() => null) + if (!response.ok) { + const detail = + payload && typeof payload === 'object' && 'error' in payload + ? String(payload.error) + : `HTTP ${response.status}` + die(`DeepInfra respondeu erro: ${detail}`) + } + + const base64 = extractOutputBase64(payload) + if (!base64) { + die('resposta inesperada do DeepInfra (sem imagem no output)') + } + + const masterDir = resolve('data/ai-fill') + await mkdir(masterDir, { recursive: true }) + const masterPath = join(masterDir, `${parse(photo).name}-fill.png`) + const masterBuffer = await sharp( + Buffer.from(base64.replace(/^data:image\/[a-z0-9+]+;base64,/, ''), 'base64'), + ) + .extract(crop) + .png() + .toBuffer() + await writeFile(masterPath, masterBuffer) + const { width: outWidth, height: outHeight } = await sharp(masterPath).metadata() + console.log(`[images:fill] mestre em ${masterPath} (${outWidth}×${outHeight})\n`) + + if (options.resize) { + await runResizeImages({ + help: false, + paths: [masterPath], + maxWidth: undefined, + quality: undefined, + formats: ['avif', 'webp'], + outDir: resolve(options.outDir), + removeBg: false, + }) + } +} + +main().catch((error) => die(error instanceof Error ? error.message : String(error))) diff --git a/scripts/lib/imageFill.mjs b/scripts/lib/imageFill.mjs new file mode 100644 index 00000000..b36c9eaf --- /dev/null +++ b/scripts/lib/imageFill.mjs @@ -0,0 +1,158 @@ +/** + * Pure derivation for `scripts/fill-image.mjs` — argument parsing, canvas + * extension math and prompt defaults, unit-tested like the rest of + * `scripts/lib/`. The CLI itself only threads sharp + the DeepInfra call + * through these decisions. + */ + +/** DeepInfra model that fills masked regions with text guidance. */ +export const FILL_MODEL = 'black-forest-labs/FLUX-2-dev' + +/** Native DeepInfra inference endpoint for `FILL_MODEL` (accepts arbitrary multiples of 64). */ +export const FILL_INFERENCE_URL = `https://api.deepinfra.com/v1/inference/${FILL_MODEL}` + +/** When the photo is extended, the white strip below is the void to fill — this is the prompt. */ +export const EXTEND_DEFAULT_PROMPT = + 'Continue a imagem apenas na área branca abaixo: complete o corpo da pessoa de forma realista e coerente com a cena, com as mesmas roupas, iluminação, cores e estilo da foto, sem elementos novos.' + +/** When an explicit mask is given, only the marked (whitened) region is regenerated. */ +export const MASK_DEFAULT_PROMPT = + 'Regenere apenas a área branca marcada, de forma natural e coerente com a cena, preservando roupas, iluminação, cores e estilo da foto original.' + +/** + * Parses `--extend` values: a plain integer is pixels, `30%` is a share of + * the photo height. Returns null when the value is not one of those shapes. + * + * @param {string} value + * @returns {number | null} + */ +export const parseExtendPx = (value) => { + const percent = /^(\d{1,3})%$/.exec(value.trim()) + if (percent) return Number(percent[1]) + const px = Number(value.trim()) + if (Number.isInteger(px) && px > 0) return px + return null +} + +/** + * Bottom-strip extension for a canvas. `extendPx` may be pixels or a + * percentage of the photo height (already resolved by the caller via + * `parseExtendPx` + height). + * + * @param {number} width + * @param {number} height + * @param {number} extendPx + * @returns {{ width: number, height: number, offsetY: number }} + */ +export const extendedCanvas = (width, height, extendPx) => ({ + width, + height: height + extendPx, + offsetY: height, +}) + +/** + * The FLUX VAE needs dimensions that are multiples of 64. Pads a canvas up + * to the next multiple on each axis; the padding is masked OPAQUE + * (preserved) and cropped away after the fill, so the final image is + * exactly the canvas the caller built. + * + * @param {number} width + * @param {number} height + * @returns {{ width: number, height: number, padRight: number, padBottom: number }} + */ +export const padToMultipleOf64 = (width, height) => ({ + width: Math.ceil(width / 64) * 64, + height: Math.ceil(height / 64) * 64, + padRight: Math.ceil(width / 64) * 64 - width, + padBottom: Math.ceil(height / 64) * 64 - height, +}) + +const VALUE_FLAGS = new Set(['--photo', '--mask', '--extend', '--prompt', '--out', '--steps']) + +const BOOLEAN_FLAGS = new Set(['--no-resize']) + +const HELP_FLAGS = new Set(['--help', '-h']) + +/** + * Parses CLI argv into options. Throws with a human-readable message on any + * unknown flag or invalid value (fail closed — a typo must not silently + * fall back to a default). + * + * @param {string[]} argv - `process.argv.slice(2)` + * @returns {{ photo: string, mask: string | null, extend: string | null, prompt: string, steps: number, outDir: string, resize: boolean, help: boolean }} + */ +export const parseFillArgs = (argv) => { + const options = { + help: false, + photo: undefined, + mask: null, + extend: null, + prompt: undefined, + steps: 28, + outDir: 'public', + resize: true, + } + + for (let i = 0; i < argv.length; i += 1) { + const arg = argv[i] + if (HELP_FLAGS.has(arg)) { + options.help = true + continue + } + if (BOOLEAN_FLAGS.has(arg)) { + options.resize = false + continue + } + const [flag, inlineValue] = arg.startsWith('--') ? arg.split('=') : [null, null] + if (flag !== null && VALUE_FLAGS.has(flag)) { + const value = inlineValue ?? argv[i + 1] + if (inlineValue === undefined) i += 1 + if (value === undefined || value === '' || value.startsWith('--')) { + throw new Error(`flag ${flag} espera um valor (ex.: ${flag}=valor ou ${flag} valor)`) + } + if (flag === '--photo') { + options.photo = value + } else if (flag === '--mask') { + options.mask = value + } else if (flag === '--extend') { + if (parseExtendPx(value) === null) { + throw new Error( + `--extend inválido: ${JSON.stringify(value)} (esperado pixels, ex.: 400, ou percentual, ex.: 30%)`, + ) + } + options.extend = value + } else if (flag === '--prompt') { + options.prompt = value + } else if (flag === '--steps') { + const steps = Number(value) + if (!Number.isInteger(steps) || steps < 1 || steps > 50) { + throw new Error(`--steps inválido: ${JSON.stringify(value)} (esperado 1..50)`) + } + options.steps = steps + } else { + options.outDir = value + } + continue + } + if (flag !== null && !HELP_FLAGS.has(arg)) { + throw new Error(`flag desconhecida: ${JSON.stringify(arg)} (veja --help)`) + } + throw new Error(`argumento posicional não esperado: ${JSON.stringify(arg)} (veja --help)`) + } + + if (!options.help) { + if (options.photo === undefined) { + throw new Error('--photo é obrigatório (veja --help)') + } + if (options.mask === null && options.extend === null) { + throw new Error('informe --mask (região pintada de branco) ou --extend (aumentar embaixo)') + } + if (options.mask !== null && options.extend !== null) { + throw new Error('--mask e --extend são mutuamente exclusivos — escolha um') + } + options.prompt = + options.prompt ?? (options.extend !== null ? EXTEND_DEFAULT_PROMPT : MASK_DEFAULT_PROMPT) + } + + return options +} diff --git a/scripts/lib/imageResize.mjs b/scripts/lib/imageResize.mjs new file mode 100644 index 00000000..91793b6b --- /dev/null +++ b/scripts/lib/imageResize.mjs @@ -0,0 +1,182 @@ +/** + * Pure derivation for `scripts/resize-images.mjs` — argument parsing, + * format/quality policy and output-name derivation, unit-tested like the + * rest of `scripts/lib/`. The CLI itself only threads `sharp` through these + * decisions, so nothing here imports sharp or touches the filesystem. + */ + +import { parse } from 'node:path' + +/** 2× the reference desktop viewport (1920px) — retina-grade site assets. */ +const MAX_WEB_WIDTH = 3840 + +/** + * Max width per format: AVIF serves the site at retina grade (2× desktop), + * while WebP/JPEG are the draft/compatibility companions (PenPot, email, + * older browsers) and can stay at 1× — a quarter of the pixels, so the + * design tool never has to chew on multi-MB files. + */ +export const FORMAT_MAX_WIDTHS = { avif: MAX_WEB_WIDTH, webp: 1920, jpeg: 1920 } + +/** Width to use for a format — an explicit `--max-width` wins over the per-format default. */ +export const maxWidthFor = (format, override) => + override ?? FORMAT_MAX_WIDTHS[format] ?? MAX_WEB_WIDTH + +/** Output formats sharp can encode for the browser, in order of preference. */ +const SUPPORTED_FORMATS = ['avif', 'webp', 'jpeg'] + +/** + * The default output set: AVIF for the site (lightest modern format) plus + * WebP as the broadly accepted companion — design tools such as PenPot and + * older browsers do not open AVIF. + */ +const DEFAULT_FORMATS = ['avif', 'webp'] + +/** Extension per normalized format (jpeg keeps the compact `.jpg`). */ +const FORMAT_EXTENSIONS = { avif: '.avif', webp: '.webp', jpeg: '.jpg' } + +/** Sharp quality defaults per format (AVIF compresses far better than WebP). */ +const FORMAT_QUALITY_DEFAULTS = { avif: 60, webp: 75, jpeg: 82 } + +/** Accepted input extensions when a positional argument is a directory. */ +export const INPUT_EXTENSIONS = new Set([ + '.jpg', + '.jpeg', + '.png', + '.webp', + '.avif', + '.tif', + '.tiff', + '.gif', + '.heic', + '.bmp', +]) + +/** + * Normalizes a `--format` value (aliases + leading dot, case-insensitive). + * Returns `null` when the value is not encodable. + * + * @param {string} value + * @returns {'avif' | 'webp' | 'jpeg' | null} + */ +export const normalizeImageFormat = (value) => { + const normalized = value.trim().toLowerCase().replace(/^\./, '') + const alias = { jpg: 'jpeg', jpe: 'jpeg', jfif: 'jpeg' }[normalized] + const format = alias ?? normalized + return SUPPORTED_FORMATS.includes(format) ? format : null +} + +/** @param {'avif' | 'webp' | 'jpeg'} format */ +export const defaultQualityFor = (format) => FORMAT_QUALITY_DEFAULTS[format] ?? 60 + +/** + * The web asset keeps the source basename, only the extension changes + * (`foto.jpg` → `foto.avif`), so re-running with another format never + * collides with the source file. + * + * @param {string} inputPath + * @param {'avif' | 'webp' | 'jpeg'} format + */ +export const deriveOutputFileName = (inputPath, format) => + `${parse(inputPath).name}${FORMAT_EXTENSIONS[format]}` + +const FLAGS = new Set(['--max-width', '--quality', '--format', '--out']) + +const BOOLEAN_FLAGS = new Set(['--remove-bg']) + +const HELP_FLAGS = new Set(['--help', '-h']) + +/** + * Parses `--format` into a deduped, order-preserving list. Accepts a + * comma-separated set (`avif,webp`) and the singular spellings (`jpg`). + * Returns `null` when every item is missing or any item is unknown. + * + * @param {string} value + * @returns {('avif' | 'webp' | 'jpeg')[] | null} + */ +const parseFormatList = (value) => { + const formats = value + .split(',') + .map((part) => part.trim()) + .filter((part) => part !== '') + .map(normalizeImageFormat) + if (formats.length === 0 || formats.some((format) => format === null)) { + return null + } + return [...new Set(formats)] +} + +/** + * Parses CLI argv into options. Throws with a human-readable message on any + * unknown flag or invalid value (fail closed — a typo must not silently + * fall back to a default). `quality` and `maxWidth` stay undefined when + * absent so each format applies its own default at conversion time. + * + * @param {string[]} argv - `process.argv.slice(2)` + * @returns {{ paths: string[], maxWidth: number | undefined, quality: number | undefined, formats: ('avif' | 'webp' | 'jpeg')[], outDir: string, removeBg: boolean, help: boolean }} + */ +export const parseResizeArgs = (argv) => { + const options = { + help: false, + paths: [], + maxWidth: undefined, + quality: undefined, + formats: [...DEFAULT_FORMATS], + outDir: 'public', + removeBg: false, + } + + for (let i = 0; i < argv.length; i += 1) { + const arg = argv[i] + if (HELP_FLAGS.has(arg)) { + options.help = true + continue + } + if (BOOLEAN_FLAGS.has(arg)) { + options.removeBg = true + continue + } + const [flag, inlineValue] = arg.startsWith('--') ? arg.split('=') : [null, null] + if (flag !== null && FLAGS.has(flag)) { + const value = inlineValue ?? argv[i + 1] + if (inlineValue === undefined) i += 1 + if (value === undefined || value === '' || value.startsWith('--')) { + throw new Error(`flag ${flag} espera um valor (ex.: ${flag}=3840 ou ${flag} 3840)`) + } + if (flag === '--max-width') { + const width = Number(value) + if (!Number.isInteger(width) || width < 1) { + throw new Error(`--max-width inválido: ${JSON.stringify(value)} (esperado inteiro > 0)`) + } + options.maxWidth = width + } else if (flag === '--quality') { + const quality = Number(value) + if (!Number.isInteger(quality) || quality < 1 || quality > 100) { + throw new Error(`--quality inválido: ${JSON.stringify(value)} (esperado 1..100)`) + } + options.quality = quality + } else if (flag === '--format') { + const formats = parseFormatList(value) + if (formats === null) { + throw new Error( + `--format inválido: ${JSON.stringify(value)} (esperado ${SUPPORTED_FORMATS.join('|')}, lista separada por vírgula)`, + ) + } + options.formats = formats + } else { + options.outDir = value + } + continue + } + if (flag !== null && !HELP_FLAGS.has(arg)) { + throw new Error(`flag desconhecida: ${JSON.stringify(arg)} (veja --help)`) + } + options.paths.push(arg) + } + + if (!options.help && options.paths.length === 0) { + throw new Error('nenhum caminho informado — passe arquivos de imagem ou pastas (veja --help)') + } + + return options +} diff --git a/scripts/resize-images.mjs b/scripts/resize-images.mjs new file mode 100644 index 00000000..8620d710 --- /dev/null +++ b/scripts/resize-images.mjs @@ -0,0 +1,259 @@ +/** + * resize-images.mjs — local image optimizer for the campaign site draft. + * + * Takes one or more image paths (or folders, which are scanned recursively) + * and, for each: rotates per EXIF, resizes to at most `--max-width` keeping + * the aspect ratio (no upscaling), strips metadata and encodes two outputs + * by default — AVIF (lightest modern format, for the site) and WebP (the + * broadly accepted companion: design tools such as PenPot and older + * browsers do not open AVIF). `--format` narrows the set. Outputs keep the + * source basename into the Next.js `public/` folder (or `--out`), so the + * draft pages can reference `/arquivo.avif` directly. + * + * Usage: + * pnpm images:resize 1.jpg 2.png + * pnpm images:resize --out public/campanha --max-width 2560 fotos/* + * pnpm images:resize --format avif,webp --quality 80 ~/Downloads/foto.JPG + * pnpm images:resize --format webp ~/Downloads/foto.JPG + * + * AVIF encoding is slower than WebP/JPEG — a large batch is expected to + * take a few seconds per image. + */ +import { removeBackground } from '@imgly/background-removal-node' +import { mkdir, readFile, readdir, stat, writeFile } from 'node:fs/promises' +import { basename, join, resolve } from 'node:path' +import { pathToFileURL } from 'node:url' +import sharp from 'sharp' + +import { dieWithLabel } from './lib/cli.mjs' +import { + INPUT_EXTENSIONS, + defaultQualityFor, + deriveOutputFileName, + maxWidthFor, + parseResizeArgs, +} from './lib/imageResize.mjs' + +const die = dieWithLabel('images:resize') + +const USAGE = `Uso: pnpm images:resize [opções] + +Redimensiona e converte imagens para a web, gravando na pasta public/. +Por padrão gera DUAS versões: .avif para o site e .webp para PenPot e +navegadores antigos — o webp sai bem menor (1x a tela desktop) porque é +só referência de draft, não asset de produção. + +Opções: + --max-width largura máxima de TODOS os formatos (default por + formato: avif 3840 = 2x desktop, webp/jpeg 1920 = 1x) + --format formato(s) de saída: avif, webp, jpeg (default avif,webp) + --quality <1-100> qualidade de compressão (defaults: avif 60, webp 75, jpeg 82) + --remove-bg remove o fundo da foto com IA local (1a execução + baixa o modelo, ~40 MB, depois usa cache) + --out pasta de saída (default public/) + -h, --help mostra esta ajuda + +Caminhos podem ser arquivos de imagem ou pastas (varridas recursivamente). +O nome do arquivo é mantido; só a extensão muda (foto.jpg -> foto.avif + foto.webp).` + +/** + * Expands positional args into a sorted, deduped list of image files. + * Directories are walked recursively; anything else is kept as-is. + * + * @param {string[]} paths + * @returns {Promise} absolute input paths + */ +const expandImagePaths = async (paths) => { + const found = new Set() + for (const path of paths) { + const absolute = resolve(path) + let info + try { + info = await stat(absolute) + } catch { + die(`${path} não existe`) + } + if (info.isDirectory()) { + const entries = await readdir(absolute, { recursive: true, withFileTypes: true }) + for (const entry of entries) { + if (!entry.isFile()) continue + const lower = entry.name.toLowerCase() + if ([...INPUT_EXTENSIONS].some((ext) => lower.endsWith(ext))) { + // `parentPath` is absolute on Node >= 22 and relative on older — + // resolve() folds either spelling onto the scanned root. + found.add(resolve(absolute, entry.parentPath, entry.name)) + } + } + } else if (info.isFile()) { + found.add(absolute) + } + } + return [...found].sort() +} + +const formatBytes = (bytes) => { + if (bytes < 1024) return `${bytes} B` + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)} KB` + return `${(bytes / (1024 * 1024)).toFixed(1)} MB` +} + +/** + * Loads one input and prepares it once for every output format: EXIF + * rotation is baked in, then (optionally) the background is removed with + * the local ONNX model. The result is the raw buffer every format encodes + * from, so the segmentation never runs per format. + * + * The imgly node package decodes only blobs whose `type` it knows + * (png/jpeg/webp) and does NOT sniff bytes — a bare `new Blob([buffer])` + * arrives with an empty type and dies with "Unsupported format: " (bug + * in 1.4.5). PNG is the lossless universal intermediate, declared + * explicitly. + * + * @param {string} input + * @param {boolean} removeBg + */ +const prepareImage = async (input, removeBg) => { + let buffer = await sharp(await readFile(input)) + .rotate() + .toBuffer() + if (removeBg) { + buffer = await sharp(buffer).png().toBuffer() + const blob = new Blob([buffer], { type: 'image/png' }) + const result = await removeBackground(blob, { output: { format: 'image/png' } }) + buffer = Buffer.from(await result.arrayBuffer()) + } + return buffer +} + +/** + * Resizes + converts one prepared image to one format. Returns the + * width/height pair of the output for the summary. Never touches the + * source: the output path is derived from the basename and the target + * format, so it can only collide with the source when the input already is + * the output format AND sits in the output folder — which is refused. + * JPEG cannot hold the alpha channel left by background removal, so it is + * flattened onto white instead of leaking black. + * + * @param {Buffer} prepared + * @param {string} input + * @param {string} output + * @param {{ maxWidth: number | undefined, quality: number | undefined, format: 'avif' | 'webp' | 'jpeg', removeBg: boolean }} options + */ +const encodeImage = async (prepared, input, output, { maxWidth, quality, format, removeBg }) => { + if (resolve(input) === resolve(output)) { + throw new Error( + `saída colidiria com a fonte em ${basename(input)} — troque o formato ou use --out`, + ) + } + let pipeline = sharp(prepared).resize({ + width: maxWidthFor(format, maxWidth), + withoutEnlargement: true, + fit: 'inside', + }) + if (removeBg && format === 'jpeg') pipeline = pipeline.flatten({ background: '#ffffff' }) + const encoded = await pipeline + .toFormat(format, { quality: quality ?? defaultQualityFor(format) }) + .toBuffer() + await writeFile(output, encoded) + const { width, height } = await sharp(encoded).metadata() + return { width: width ?? 0, height: height ?? 0 } +} + +/** + * The shared resize+encode engine — the CLI entry point and `fill-image.mjs` + * (which chains this onto the model output) both call it with a + * `parseResizeArgs`-shaped options object. + * + * @param {ReturnType} options + */ +export const runResizeImages = async (options) => { + if (options.help) { + console.log(USAGE) + return + } + + const inputs = await expandImagePaths(options.paths) + if (inputs.length === 0) { + die(`nenhuma imagem encontrada nos caminhos informados (${options.paths.join(', ')})`) + } + + const outDir = resolve(options.outDir) + await mkdir(outDir, { recursive: true }) + + const { formats, quality, maxWidth, removeBg } = options + const results = [] + const failures = [] + for (const input of inputs) { + const before = (await stat(input)).size + let prepared + try { + prepared = await prepareImage(input, removeBg) + } catch (error) { + failures.push(`${basename(input)}: ${error instanceof Error ? error.message : String(error)}`) + continue + } + for (const format of formats) { + const output = join(outDir, deriveOutputFileName(input, format)) + try { + const { width, height } = await encodeImage(prepared, input, output, { + maxWidth, + quality, + format, + removeBg, + }) + const after = (await stat(output)).size + results.push({ + name: `${basename(input)} → .${format}`, + dimensions: `${width}×${height}`, + before, + after, + }) + } catch (error) { + failures.push( + `${basename(input)} (.${format}): ${error instanceof Error ? error.message : String(error)}`, + ) + } + } + } + + const nameWidth = Math.max(...results.map((r) => r.name.length), 6) + console.log(`\n${'arquivo'.padEnd(nameWidth)} dimensão antes -> depois economia`) + console.log('-'.repeat(nameWidth + 54)) + let totalBefore = 0 + let totalAfter = 0 + for (const result of results) { + totalBefore += result.before + totalAfter += result.after + const saved = 100 - (result.after / result.before) * 100 + console.log( + `${result.name.padEnd(nameWidth)} ${result.dimensions.padEnd(9)} ${formatBytes(result.before).padEnd(8)} -> ${formatBytes(result.after).padEnd(9)} ${saved.toFixed(1).padStart(5)}%`, + ) + } + if (results.length > 1) { + const saved = 100 - (totalAfter / totalBefore) * 100 + console.log('-'.repeat(nameWidth + 54)) + console.log( + `${'TOTAL'.padEnd(nameWidth)} ${String(results.length).padEnd(9)} ${formatBytes(totalBefore).padEnd(8)} -> ${formatBytes(totalAfter).padEnd(9)} ${saved.toFixed(1).padStart(5)}%`, + ) + } + const widthSummary = maxWidth + ? `largura máxima ${maxWidth}px` + : `largura por formato (${formats.map((f) => `${f} ${maxWidthFor(f)}px`).join(', ')})` + console.log( + `\n${results.length} arquivo(s) em ${join(outDir, '')} (${formats.join(', ')}, ${quality ? `qualidade ${quality}, ` : ''}${widthSummary}${removeBg ? ', fundo removido' : ''})\n`, + ) + + if (failures.length > 0) { + console.error(`\n${failures.length} falha(s):`) + for (const failure of failures) console.error(` - ${failure}`) + process.exit(1) + } +} + +// CLI entry: only when run directly — `fill-image.mjs` imports the engine +// without triggering the CLI. +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + runResizeImages(parseResizeArgs(process.argv.slice(2))).catch((error) => + die(error instanceof Error ? error.message : String(error)), + ) +} diff --git a/src/app/(frontend)/(home)/layout.tsx b/src/app/(frontend)/(home)/layout.tsx index f0180063..5c8131ab 100644 --- a/src/app/(frontend)/(home)/layout.tsx +++ b/src/app/(frontend)/(home)/layout.tsx @@ -1,19 +1,18 @@ -import { Footer } from '@/components/Footer' -import { SiteHeader } from '@/components/SiteHeader' import React from 'react' import '../styles.css' export default async function RootLayout(props: { children: React.ReactNode }) { const { children } = props - // The root `(frontend)` layout locks the document with `overflow-hidden`, so - // the home route owns an internal scroll container. `relative` anchors the - // overlay header over the hero. + // A root `(frontend)` trava o documento com `overflow-hidden`, então a home + // é dona do container de scroll interno. `data-theme="campaign-site"` aplica + // a paleta da campanha (vermelho PT + amarelo CTA) aos tokens `--pt-*`. return ( -
- +
{children} -
) } diff --git a/src/app/(frontend)/(home)/page.tsx b/src/app/(frontend)/(home)/page.tsx index 205a212d..ad4de252 100644 --- a/src/app/(frontend)/(home)/page.tsx +++ b/src/app/(frontend)/(home)/page.tsx @@ -1,82 +1,176 @@ -import { PostCard } from '@/components/PostCard' -import { Media } from '@/payload-types' -import { getCachedDocumentById } from '@/utilities/documentReads' -import { getCachedGlobal } from '@/utilities/globalReads' -import { getTypePath, getVisiblePosts } from '@/utilities/posts' -import Image from 'next/image' -import Link from 'next/link' +import { CampaignCarousel, type CampaignCarouselItem } from '@/components/CampaignCarousel' +import { CampaignFooter } from '@/components/CampaignFooter' +import { CampaignHero } from '@/components/CampaignHero' -const LATEST_NEWS_LIMIT = 6 +const problemItems: CampaignCarouselItem[] = [ + { + id: 'escala-6x1', + title: 'Pelo fim da escala 6x1!', + body: 'Jornadas longas aumentam o risco de AVC e infarto. Descanso é saúde pública. A bancada de Lula derrotou a direita na Câmara: o fim da 6×1 avançou. Mas a briga continua no Senado, onde a direita tenta barrar o avanço. No dia 4 de outubro, o 1313 é o seu sim ao descanso.', + image: '/fundo.avif', + imageAlt: 'Jorge Solla defendendo direitos dos trabalhadores na Câmara dos Deputados', + }, + { + id: 'sus', + title: 'Pra valorizar o SUS', + body: 'O SUS é o sistema de saúde de todo brasileiro. Com Lula, a saúde voltou a receber recursos: Nova PAC de R$ 30,5 bilhões e Mais Médicos retomado. Mas o teto de gastos da direita ainda trava o orçamento. O 1313 é seu voto em defesa do SUS no Congresso!', + image: '/53569851134_02afc18fb4_o.avif', + imageAlt: 'Jorge Solla em agenda pública de defesa do SUS', + imageFrame: { + width: 1024, + height: 682, + className: 'absolute top-0 left-[-21.97%] h-auto w-[136.62%] max-w-none', + }, + }, + { + id: 'mataripe', + title: 'Pra defender os baianos', + body: 'A Refinaria de Mataripe foi vendida no governo Bolsonaro e segue nas mãos de estrangeiros. Com Lula e Jerônimo, a Bahia voltou a andar pra frente; mas recomprar Mataripe exige bancada forte no Congresso. Votar 1313 é devolver à Bahia o que é seu.', + image: '/52396285023_561ffc0ff6_o.avif', + imageAlt: 'Vista da Bahia durante mobilização popular', + }, +] + +const flagItems: CampaignCarouselItem[] = [ + { + id: 'saude', + label: 'Saúde', + title: 'Defender o SUS e valorizar quem cuida', + body: 'Saúde é direito, não mercadoria. Fim do subfinanciamento, piso da enfermagem e dos agentes pagos, concursos públicos, vacinação e ciência.', + }, + { + id: 'educacao', + label: 'Educação', + title: 'Educação em tempo integral e federais no interior', + body: 'IFs, campus federal, escola de tempo integral: o interior não pode ficar para trás no conhecimento.', + }, + { + id: 'renda', + label: 'Renda', + title: 'Salário mínimo forte, emprego e moradia', + body: 'Valorização do mínimo, Bolsa Família, Minha Casa Minha Vida e agricultura familiar como motor do interior.', + }, + { + id: 'democracia', + label: 'Democracia', + title: 'Defesa intransigente da democracia', + body: 'Participação popular, instituições fortes e combate à desinformação. Eleger o parlamento é coisa séria.', + }, + { + id: 'bahia', + label: 'Bahia', + title: 'Recomprar a Refinaria de Mataripe', + body: 'Recomprar a refinaria é defender a Bahia e a soberania do Brasil. Preço justo de combustível é política de saúde e de renda.', + }, + { + id: 'trabalho', + label: 'Trabalho', + title: 'Fim da escala 6×1 e jornada de 40h', + body: 'Sem redução de salário. Jornada longa é questão de saúde pública: quem trabalha demais adoece. O SUS paga a conta.', + }, +] + +const proofItems = [ + { value: '3.333', label: 'proposições apresentadas' }, + { value: '1.031', label: 'discursos em Plenário' }, + { value: '3º', label: 'mandato de deputado federal' }, +] export default async function HomePage() { - const home = await getCachedGlobal('home', 2)() - - let heroImage: Media | null = null - if (typeof home.image === 'number') { - heroImage = await getCachedDocumentById('media', `${home.image}`)() - } else { - heroImage = home.image ?? null - } - - const latestNews = (await getVisiblePosts()) - .filter((post) => post.type === 'noticia') - .slice(0, LATEST_NEWS_LIMIT) - return ( -
-
-
-

- O mandato do tamanho da Bahia -

-

- Notícias, campanhas e a atuação do deputado Jorge Solla por uma Bahia mais justa. -

- - Ver notícias - -
- {heroImage?.url ? ( -
- {heroImage.alt} -
- ) : null} -
+ <> +
+ -
-
-
-

- Últimas notícias -

- +
    + {proofItems.map((item) => ( +
  • + + {item.value} + + + {item.label} + +
  • + ))} +
+
+ +
+
+

+ Por que essa eleição importa +

+

- Ver todas - -

- {latestNews.length ? ( -
- {latestNews.map((post) => ( - - ))} + Eleger deputado é coisa séria + + O Congresso tem nas mãos vidas reais e o SUS + + +

+ É no Congresso que se define o orçamento do SUS, o piso da enfermagem, a recompra de + Mataripe e o fim da escala 6×1.{' '} + + Reeleger Lula não basta: é preciso eleger uma bancada aliada. + +

+
+
- ) : ( -

Nenhuma notícia publicada ainda.

- )} -
-
-
+
+ + +
+
+

+ Nossa caminhada +

+

+ Junto com o trabalhador e + do lado de quem mais precisa, sempre. +

+

+ Não é promessa de palanque, é a experiência de quem já fez na gestão do SUS, do + Ministério da Saúde e da saúde da Bahia. +

+
+ +
+
+
+ + + + ) } diff --git a/src/app/(frontend)/artigos/layout.tsx b/src/app/(frontend)/artigos/layout.tsx new file mode 100644 index 00000000..e02dd015 --- /dev/null +++ b/src/app/(frontend)/artigos/layout.tsx @@ -0,0 +1,19 @@ +import { Footer } from '@/components/Footer' +import { SiteHeader } from '@/components/SiteHeader' +import React from 'react' +import '../styles.css' + +export default async function RootLayout(props: { children: React.ReactNode }) { + const { children } = props + + // A root `(frontend)` trava o documento com `overflow-hidden`, então a rota + // é dona do container de scroll interno. Header sólido (não overlay) porque + // /artigos é uma subpágina, não o hero da home. + return ( +
+ + {children} +
+
+ ) +} diff --git a/src/app/(frontend)/artigos/page.tsx b/src/app/(frontend)/artigos/page.tsx new file mode 100644 index 00000000..264628d5 --- /dev/null +++ b/src/app/(frontend)/artigos/page.tsx @@ -0,0 +1,82 @@ +import { PostCard } from '@/components/PostCard' +import { Media } from '@/payload-types' +import { getCachedDocumentById } from '@/utilities/documentReads' +import { getCachedGlobal } from '@/utilities/globalReads' +import { getTypePath, getVisiblePosts } from '@/utilities/posts' +import Image from 'next/image' +import Link from 'next/link' + +const LATEST_NEWS_LIMIT = 6 + +export default async function ArtigosPage() { + const home = await getCachedGlobal('home', 2)() + + let heroImage: Media | null = null + if (typeof home.image === 'number') { + heroImage = await getCachedDocumentById('media', `${home.image}`)() + } else { + heroImage = home.image ?? null + } + + const latestNews = (await getVisiblePosts()) + .filter((post) => post.type === 'noticia') + .slice(0, LATEST_NEWS_LIMIT) + + return ( +
+
+
+

+ O mandato do tamanho da Bahia +

+

+ Notícias, campanhas e a atuação do deputado Jorge Solla por uma Bahia mais justa. +

+ + Ver notícias + +
+ {heroImage?.url ? ( +
+ {heroImage.alt} +
+ ) : null} +
+ +
+
+
+

+ Últimas notícias +

+ + Ver todas + +
+ {latestNews.length ? ( +
+ {latestNews.map((post) => ( + + ))} +
+ ) : ( +

Nenhuma notícia publicada ainda.

+ )} +
+
+
+ ) +} diff --git a/src/app/(frontend)/layout.tsx b/src/app/(frontend)/layout.tsx index c16c2d32..289753c0 100644 --- a/src/app/(frontend)/layout.tsx +++ b/src/app/(frontend)/layout.tsx @@ -6,11 +6,15 @@ import { resolveSiteMetadata } from '@/utilities/seo' import { Analytics } from '@vercel/analytics/next' import { SpeedInsights } from '@vercel/speed-insights/next' import { Metadata } from 'next' -import { Inter } from 'next/font/google' +import { Arimo, Exo_2, Inter } from 'next/font/google' import React from 'react' import './styles.css' const inter = Inter({ subsets: ['latin'], variable: '--font-sans' }) +// Campanha 2026: Exo 2 para display (títulos/CTAs) e Arimo para cards/tags — +// mesmo uso das peças do Penpot e do santinho. OFL, self-hosted pelo next/font. +const exo2 = Exo_2({ subsets: ['latin'], variable: '--font-exo2' }) +const arimo = Arimo({ subsets: ['latin'], variable: '--font-arimo' }) export async function generateMetadata(): Promise { const payload = await getCachedGlobal('metadata')() @@ -71,7 +75,7 @@ export default async function RootLayout(props: { children: React.ReactNode }) { return ( diff --git a/src/app/(frontend)/styles.css b/src/app/(frontend)/styles.css index 7f6161a0..8983b3b0 100644 --- a/src/app/(frontend)/styles.css +++ b/src/app/(frontend)/styles.css @@ -161,6 +161,111 @@ body, --ring: #c51414; } +/* Paleta da página de campanha 2026 (home pública jorgesolla1313). + Extraída da geometria do rascunho Penpot (15/08/2026): vermelho #a21c1c, + amarelo #ffe607, problema #47130e, bandeiras #ebe9e9, cards #f9faff. + Aplicado via data-theme="campaign-site" no layout da home. */ +[data-theme='campaign-site'] { + --background: #ffffff; + --foreground: #000000; + --pt-red: #a21c1c; + --pt-red-dark: #47130e; + --pt-yellow: #ffe607; + --pt-yellow-ink: #000000; + --campaign-cream: #fff8f2; + --campaign-ink: #000000; + --campaign-muted: #6c615e; + --campaign-surface: #f9faff; + --campaign-band: #ebe9e9; + --campaign-line: rgb(0 0 0 / 12%); + --campaign-content-inset: 20px; +} + +/* Penpot uses two lockups from the official campaign identity: the wide mark + on phones and the stacked mark on desktop. The PNG canvases are square and + mostly transparent, so keep their exact board geometry instead of sizing by + visible ink bounds. */ +.campaign-home-logo { + top: -123px; + left: 19px; + width: 356px; + height: 356px; + background-image: url('/Prancheta%206@3x.png'); + background-repeat: no-repeat; + background-position: center; + background-size: contain; +} + +@media (min-width: 64rem) { + .campaign-home-logo { + top: 55px; + left: 47px; + width: 197px; + height: 150px; + background-image: url('/Prancheta%203@3x.png'); + background-position: -43px -67px; + background-size: 283px 284px; + } +} + +@media (prefers-reduced-motion: reduce) { + [data-theme='campaign-site'] [data-carousel-track] { + scroll-behavior: auto; + } +} + +/* Full-bleed carousel viewport with content-aligned first cards. The scroller + reaches both browser edges; only its initial item follows the page gutter. */ +[data-theme='campaign-site'] [data-carousel] { + --campaign-carousel-inset: var(--campaign-content-inset); +} + +[data-theme='campaign-site'] [data-carousel-track], +[data-theme='campaign-site'] [data-carousel-chips] { + padding-left: var(--campaign-carousel-inset); + padding-right: var(--campaign-carousel-inset); +} + +[data-theme='campaign-site'] [data-carousel-track] { + scroll-padding-left: var(--campaign-carousel-inset); +} + +.campaign-action-feedback { + transition: + transform 180ms cubic-bezier(0.22, 1, 0.36, 1), + opacity 120ms ease-out, + background-color 180ms ease-out, + filter 180ms ease-out; +} + +@media (hover: hover) and (pointer: fine) { + .campaign-action-feedback:hover { + transform: scale(1.05); + } +} + +.campaign-action-feedback:active { + opacity: 0.8; + transform: scale(0.99); +} + +@media (prefers-reduced-motion: reduce) { + .campaign-action-feedback { + transition: none; + } + + .campaign-action-feedback:hover, + .campaign-action-feedback:active { + transform: none; + } +} + +@media (min-width: 64rem) { + [data-theme='campaign-site'] { + --campaign-content-inset: max(180px, calc((100vw - 1152px) / 2 + 32px)); + } +} + [data-theme='petition'] { --background: #fff8f2; --foreground: #2c2522; diff --git a/src/components/CampaignCarousel.tsx b/src/components/CampaignCarousel.tsx new file mode 100644 index 00000000..8c17af1c --- /dev/null +++ b/src/components/CampaignCarousel.tsx @@ -0,0 +1,235 @@ +'use client' + +import Image from 'next/image' +import { useCallback, useEffect, useRef, useState } from 'react' + +const AUTO_ADVANCE_MS = 4_000 +const PROGRAMMATIC_SCROLL_LOCK_MS = 700 + +export type CampaignCarouselItem = { + id: string + label?: string + title: string + body: string + image?: string + imageAlt?: string + imageFrame?: { + width: number + height: number + className: string + } +} + +type CampaignCarouselProps = { + ariaLabel: string + items: CampaignCarouselItem[] + variant: 'problem' | 'flags' +} + +export const CampaignCarousel = ({ ariaLabel, items, variant }: CampaignCarouselProps) => { + const trackRef = useRef(null) + const programmaticScrollTimerRef = useRef(null) + const isProgrammaticScrollRef = useRef(false) + const [activeIndex, setActiveIndex] = useState(0) + const [prefersReducedMotion, setPrefersReducedMotion] = useState(false) + const [isHovered, setIsHovered] = useState(false) + const [isFocused, setIsFocused] = useState(false) + const [isTouching, setIsTouching] = useState(false) + + const temporarilyPaused = isHovered || isFocused || isTouching + + const releaseProgrammaticScroll = useCallback(() => { + isProgrammaticScrollRef.current = false + if (programmaticScrollTimerRef.current !== null) { + window.clearTimeout(programmaticScrollTimerRef.current) + programmaticScrollTimerRef.current = null + } + }, []) + + useEffect(() => releaseProgrammaticScroll, [releaseProgrammaticScroll]) + + useEffect(() => { + if (typeof window.matchMedia !== 'function') return + + const media = window.matchMedia('(prefers-reduced-motion: reduce)') + const applyPreference = () => setPrefersReducedMotion(media.matches) + + applyPreference() + media.addEventListener('change', applyPreference) + return () => media.removeEventListener('change', applyPreference) + }, []) + + const scrollToItem = useCallback( + (index: number) => { + const track = trackRef.current + const target = track?.querySelector(`[data-carousel-index="${index}"]`) + if (!track || !target) return + const startInset = Number.parseFloat(window.getComputedStyle(track).paddingLeft) || 0 + + releaseProgrammaticScroll() + isProgrammaticScrollRef.current = true + setActiveIndex(index) + track.scrollTo({ + left: target.offsetLeft - track.offsetLeft - startInset, + behavior: prefersReducedMotion ? 'auto' : 'smooth', + }) + programmaticScrollTimerRef.current = window.setTimeout( + releaseProgrammaticScroll, + PROGRAMMATIC_SCROLL_LOCK_MS, + ) + }, + [prefersReducedMotion, releaseProgrammaticScroll], + ) + + useEffect(() => { + if (prefersReducedMotion || temporarilyPaused || items.length < 2) return + + const timer = window.setTimeout(() => { + scrollToItem((activeIndex + 1) % items.length) + }, AUTO_ADVANCE_MS) + + return () => window.clearTimeout(timer) + }, [activeIndex, items.length, prefersReducedMotion, scrollToItem, temporarilyPaused]) + + const syncActiveItem = useCallback(() => { + const track = trackRef.current + if (!track || isProgrammaticScrollRef.current) return + + const cards = Array.from(track.querySelectorAll('[data-carousel-index]')) + const startInset = Number.parseFloat(window.getComputedStyle(track).paddingLeft) || 0 + let nearestIndex = activeIndex + let nearestDistance = Number.POSITIVE_INFINITY + + cards.forEach((card, index) => { + const distance = Math.abs(card.offsetLeft - track.offsetLeft - track.scrollLeft - startInset) + if (distance < nearestDistance) { + nearestDistance = distance + nearestIndex = index + } + }) + + if (nearestIndex !== activeIndex) setActiveIndex(nearestIndex) + }, [activeIndex]) + + const pauseOnTouch = () => { + releaseProgrammaticScroll() + setIsTouching(true) + } + const resumeAfterTouch = () => setIsTouching(false) + + return ( +
setIsHovered(true)} + onMouseLeave={() => setIsHovered(false)} + onPointerDown={releaseProgrammaticScroll} + onWheel={releaseProgrammaticScroll} + onFocusCapture={() => setIsFocused(true)} + onBlurCapture={(event) => { + if (!event.currentTarget.contains(event.relatedTarget as Node | null)) setIsFocused(false) + }} + onTouchStart={pauseOnTouch} + onTouchEnd={resumeAfterTouch} + onTouchCancel={resumeAfterTouch} + className="relative focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-current" + > + {variant === 'flags' ? ( +
+
+ {items.map((item, index) => { + const isActive = index === activeIndex + return ( + + ) + })} +
+
+ ) : null} + +
    + {items.map((item, index) => ( +
  1. + {variant === 'problem' ? ( +
    + {item.image && item.imageFrame ? ( + {item.imageAlt + ) : item.image ? ( + {item.imageAlt + ) : null} +
    +
    +

    + {item.title} +

    +

    {item.body}

    +
    +
    + ) : ( +
    +

    + {item.title} +

    +

    {item.body}

    +
    + )} +
  2. + ))} +
+
+ ) +} diff --git a/src/components/CampaignFooter.tsx b/src/components/CampaignFooter.tsx new file mode 100644 index 00000000..0e69d765 --- /dev/null +++ b/src/components/CampaignFooter.tsx @@ -0,0 +1,87 @@ +import Link from 'next/link' + +const APOIAR_URL = 'https://apoiar.me/jorgesolla' + +const REDES = [ + { label: 'Instagram', href: 'https://instagram.com/depjorgesolla' }, + { label: 'YouTube', href: 'https://youtube.com/@JorgeSollaDep' }, + { label: 'Facebook', href: 'https://facebook.com/depjorgesolla' }, +] + +/** Rodapé eleitoral da campanha: identificação, navegação e redes oficiais. */ +export const CampaignFooter = () => ( +
+
+
+

+ Jorge Solla 1313 +

+

+ Deputado Federal · PT · Bahia +
+ Um mandato do tamanho da Bahia. +

+
+
+

+ Navegue +

+ +
+
+

+ Redes +

+ +
+
+
+

+ Jorge José Santos Pereira Solla · Candidato a Deputado Federal · Nº 1313 · Federação Brasil + da Esperança (PT/PCdoB/PV) · CNPJ: 68.430.467/0001-05 · Propaganda eleitoral gratuita. Site + de campanha — conteúdo sob responsabilidade do candidato. Eleições 2026 · 1º turno: + 04/10/2026. +

+
+
+) diff --git a/src/components/CampaignHero.tsx b/src/components/CampaignHero.tsx new file mode 100644 index 00000000..e38b6e12 --- /dev/null +++ b/src/components/CampaignHero.tsx @@ -0,0 +1,143 @@ +import Image from 'next/image' + +const SUPPORT_URL = 'https://apoiar.me/jorgesolla' + +const allyImages = [ + { + src: '/RUI%20-%202-2.avif', + width: 177, + height: 265, + className: 'left-[-22px] top-[144px] h-[265px] w-[177px] lg:left-[-35px] lg:top-[238px] lg:z-0', + }, + { + src: '/WAGNER%20-%202-9%20final.avif', + width: 177, + height: 265, + className: + 'left-[-69px] top-[148px] h-[265px] w-[177px] lg:left-[-75px] lg:top-[278px] lg:z-[1]', + }, + { + src: '/Jeronimo.avif', + width: 177, + height: 265, + className: + 'left-[224px] top-[140px] h-[265px] w-[177px] lg:left-[84px] lg:top-[249px] lg:z-[1]', + }, + { + src: '/Lula.avif', + width: 216, + height: 213, + className: + 'left-[262px] top-[179px] h-[213px] w-[216px] lg:left-[-8px] lg:top-[308px] lg:z-[2] lg:h-[235px] lg:w-[238px]', + }, +] + +export const CampaignHero = () => ( +
+
+
+ +
+ {allyImages.map((image) => ( + + ))} +
+ +
+ Jorge Solla, candidato a deputado federal pela Bahia +
+ +
+ +
+

+ Um mandato do + tamanho da Bahia +

+
+ +
+

+ Médico sanitarista.{' '} + Criou o SAMU 192 e o{' '} + Brasil Sorridente. Já comandou a saúde em + Vitória da Conquista, na Bahia e no Brasil — e leva o mandato aos 417 municípios, da saúde + à educação. +

+ + +
+ +
+ + DIAP entre os 40 melhores da Câmara + + + Mais votado do PT-BA em 2022 + +
+
+
+) diff --git a/tests/e2e/frontend.e2e.spec.ts b/tests/e2e/frontend.e2e.spec.ts index 046a4536..4d00a8c8 100644 --- a/tests/e2e/frontend.e2e.spec.ts +++ b/tests/e2e/frontend.e2e.spec.ts @@ -1,13 +1,151 @@ +import type { Page } from '@playwright/test' + import { expect, test } from './fixtures/e2eTest' +const swipeLeft = async ( + page: Page, + box: { x: number; y: number; width: number; height: number }, +) => { + const cdp = await page.context().newCDPSession(page) + const y = Math.round(box.y + box.height / 2) + const startX = Math.round(box.x + box.width * 0.82) + const endX = Math.round(box.x + box.width * 0.12) + + await cdp.send('Input.dispatchTouchEvent', { + type: 'touchStart', + touchPoints: [{ x: startX, y }], + }) + for (let step = 1; step <= 5; step += 1) { + const x = Math.round(startX + ((endX - startX) * step) / 5) + await cdp.send('Input.dispatchTouchEvent', { + type: 'touchMove', + touchPoints: [{ x, y }], + }) + await page.waitForTimeout(25) + } + await cdp.send('Input.dispatchTouchEvent', { type: 'touchEnd', touchPoints: [] }) +} + test.describe('Frontend', () => { test('can go on homepage', async ({ page }) => { await page.goto('/') await expect(page).toHaveTitle(/Jorge Solla/) - const heading = page.locator('h1').first() + const heading = page.getByRole('heading', { + level: 1, + name: 'UM MANDATO DO TAMANHO DA BAHIA', + }) - await expect(heading).toHaveText('O mandato do tamanho da Bahia') + await expect(heading).toBeVisible() + await expect(page.locator('[data-cta="primary"]')).toHaveAttribute( + 'href', + 'https://apoiar.me/jorgesolla', + ) + }) + + test('animates the flags navigation and gives pointer feedback on campaign buttons', async ({ + page, + }) => { + await page.goto('/') + + const scrollContainer = page.locator('[data-theme="campaign-site"]') + const secondaryCta = page.locator('[data-cta="secondary"]') + + await expect(scrollContainer).toHaveCSS('scroll-behavior', 'smooth') + await secondaryCta.hover() + await expect + .poll(() => secondaryCta.evaluate((element) => getComputedStyle(element).transform)) + .toContain('1.05') + + const box = await secondaryCta.boundingBox() + if (!box) throw new Error('O CTA secundário não ficou visível para testar o clique.') + await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2) + await page.mouse.down() + await expect(secondaryCta).toHaveCSS('opacity', '0.8') + await page.mouse.move(0, 0) + await page.mouse.up() + + await secondaryCta.click() + await expect(page).toHaveURL(/#bandeiras$/) + await expect(page.locator('#bandeiras')).toBeInViewport() + }) + + test('keeps breathing room between the wrapped hero subtitle and CTAs on iPhone SE', async ({ + page, + }) => { + await page.setViewportSize({ width: 320, height: 568 }) + await page.goto('/') + + const hero = page.locator('[data-home-section="hero"]') + const subtitleBox = await hero.locator('p').first().boundingBox() + const actionsBox = await hero.locator('[data-cta="secondary"]').locator('..').boundingBox() + if (!subtitleBox || !actionsBox) throw new Error('O conteúdo principal do hero não foi medido.') + + expect(actionsBox.y - (subtitleBox.y + subtitleBox.height)).toBeGreaterThanOrEqual(16) + }) + + test('auto-advances and keeps carousel controls synchronized', async ({ page }) => { + await page.goto('/') + + const problemCarousel = page.getByRole('region', { + name: 'Bandeiras que tornam esta eleição decisiva', + }) + const firstProblem = problemCarousel.getByRole('listitem', { name: '1 de 3' }) + const secondProblem = problemCarousel.getByRole('listitem', { name: '2 de 3' }) + + await expect(firstProblem).toHaveAttribute('aria-current', 'true') + await expect(secondProblem).toHaveAttribute('aria-current', 'true', { timeout: 6_000 }) + + const flagCarousel = page.getByRole('region', { name: 'Bandeiras da campanha' }) + const educationChip = flagCarousel.getByRole('button', { name: 'Educação' }) + await educationChip.click() + await expect(educationChip).toHaveAttribute('aria-current', 'true') + }) + + test('supports a real horizontal touch swipe on mobile', async ({ page }) => { + await page.setViewportSize({ width: 430, height: 900 }) + await page.goto('/') + + const overlayBox = await page.locator('[data-mobile-hero-overlay]').boundingBox() + expect(overlayBox?.x).toBe(0) + expect(overlayBox?.width).toBe(430) + + const mobileSpacing = await page.evaluate(() => { + const rect = (selector: string) => document.querySelector(selector)?.getBoundingClientRect() + const problemSection = rect('[data-home-section="problem"]') + const problemLead = rect('#problem-title + p') + const problemCarousel = rect('[data-home-section="problem"] [data-carousel]') + const flagsSection = rect('[data-home-section="flags"]') + const flagsCarousel = rect('[data-home-section="flags"] [data-carousel]') + + return { + leadGap: + problemLead && problemCarousel ? problemCarousel.top - problemLead.bottom : undefined, + problemBottomGap: + problemSection && problemCarousel + ? problemSection.bottom - problemCarousel.bottom + : undefined, + flagsBottomGap: + flagsSection && flagsCarousel ? flagsSection.bottom - flagsCarousel.bottom : undefined, + } + }) + expect(mobileSpacing.leadGap).toBeLessThan(40) + expect(mobileSpacing.problemBottomGap).toBeGreaterThanOrEqual(40) + expect(mobileSpacing.flagsBottomGap).toBeGreaterThanOrEqual(40) + + const carousel = page.getByRole('region', { name: 'Bandeiras da campanha' }) + await carousel.focus() + const track = carousel.locator('[data-carousel-track]') + await track.scrollIntoViewIfNeeded() + const box = await track.boundingBox() + if (!box) throw new Error('O trilho das bandeiras não ficou visível para o swipe.') + + await swipeLeft(page, box) + + await expect(carousel.getByRole('button', { name: 'Educação' })).toHaveAttribute( + 'aria-current', + 'true', + ) }) }) diff --git a/tests/unit/campaignCarousel.unit.spec.tsx b/tests/unit/campaignCarousel.unit.spec.tsx new file mode 100644 index 00000000..13879354 --- /dev/null +++ b/tests/unit/campaignCarousel.unit.spec.tsx @@ -0,0 +1,162 @@ +import { act, cleanup, fireEvent, render, screen } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import { CampaignCarousel, type CampaignCarouselItem } from '@/components/CampaignCarousel' + +const flagItems: CampaignCarouselItem[] = [ + { + id: 'saude', + label: 'Saúde', + title: 'Defender o SUS e valorizar quem cuida', + body: 'Saúde é direito, não mercadoria.', + }, + { + id: 'educacao', + label: 'Educação', + title: 'Educação em tempo integral', + body: 'O interior não pode ficar para trás.', + }, +] + +const problemItems: CampaignCarouselItem[] = [ + { + id: 'trabalho', + title: 'Pelo fim da escala 6x1!', + body: 'Descanso é saúde pública.', + image: '/fundo.avif', + imageAlt: 'Jorge Solla durante atividade parlamentar', + }, + { + id: 'sus', + title: 'Pra valorizar o SUS', + body: 'O SUS é o sistema de saúde de todo brasileiro.', + image: '/53569851134_02afc18fb4_o.avif', + imageAlt: 'Jorge Solla em agenda de defesa do SUS', + }, +] + +const matchMediaMock = vi.fn() +const scrollToMock = vi.fn() + +beforeEach(() => { + matchMediaMock.mockImplementation((query: string) => ({ + matches: false, + media: query, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + })) + vi.stubGlobal('matchMedia', matchMediaMock) + Object.defineProperty(HTMLElement.prototype, 'scrollTo', { + configurable: true, + value: scrollToMock, + }) +}) + +afterEach(() => { + cleanup() + vi.clearAllMocks() + vi.useRealTimers() + vi.unstubAllGlobals() +}) + +describe('CampaignCarousel', () => { + it('sincroniza o chip ativo quando a pessoa escolhe uma bandeira', () => { + render() + + const healthChip = screen.getByRole('button', { name: 'Saúde' }) + const educationChip = screen.getByRole('button', { name: 'Educação' }) + expect(healthChip.getAttribute('aria-current')).toBe('true') + + fireEvent.click(educationChip) + fireEvent.scroll(screen.getByRole('list')) + + expect(healthChip.getAttribute('aria-current')).toBeNull() + expect(educationChip.getAttribute('aria-current')).toBe('true') + expect(scrollToMock).toHaveBeenCalledTimes(1) + }) + + it('avança automaticamente para o próximo card depois de quatro segundos', () => { + vi.useFakeTimers() + render( + , + ) + + const secondCard = screen.getByRole('listitem', { name: '2 de 2' }) + expect(secondCard.getAttribute('aria-current')).toBeNull() + + act(() => { + vi.advanceTimersByTime(4_000) + }) + + expect(secondCard.getAttribute('aria-current')).toBe('true') + expect(scrollToMock).toHaveBeenCalledTimes(1) + }) + + it('pausa o avanço enquanto o ponteiro está sobre o carrossel', () => { + vi.useFakeTimers() + render( + , + ) + + const carousel = screen.getByRole('region', { name: 'Por que essa eleição importa' }) + fireEvent.mouseEnter(carousel) + act(() => { + vi.advanceTimersByTime(8_000) + }) + + expect(screen.getByRole('listitem', { name: '1 de 2' }).getAttribute('aria-current')).toBe( + 'true', + ) + expect(scrollToMock).not.toHaveBeenCalled() + }) + + it('pausa o avanço quando o próprio carrossel recebe foco', () => { + vi.useFakeTimers() + render( + , + ) + + const carousel = screen.getByRole('region', { name: 'Por que essa eleição importa' }) + fireEvent.focus(carousel) + act(() => { + vi.advanceTimersByTime(8_000) + }) + + expect(scrollToMock).not.toHaveBeenCalled() + }) + + it('não inicia o avanço automático quando a pessoa prefere movimento reduzido', () => { + vi.useFakeTimers() + matchMediaMock.mockImplementation((query: string) => ({ + matches: query.includes('prefers-reduced-motion'), + media: query, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + })) + + render( + , + ) + act(() => { + vi.advanceTimersByTime(8_000) + }) + + expect(scrollToMock).not.toHaveBeenCalled() + }) +}) diff --git a/tests/unit/campaignHero.unit.spec.tsx b/tests/unit/campaignHero.unit.spec.tsx new file mode 100644 index 00000000..e0f5d728 --- /dev/null +++ b/tests/unit/campaignHero.unit.spec.tsx @@ -0,0 +1,42 @@ +import { cleanup, render, screen } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' + +import { CampaignHero } from '@/components/CampaignHero' + +type MockImageProps = React.ImgHTMLAttributes & { priority?: boolean } + +vi.mock('next/image', () => ({ + default: ({ alt, priority: _priority, ...props }: MockImageProps) => ( + // eslint-disable-next-line @next/next/no-img-element + {alt} + ), +})) + +afterEach(cleanup) + +describe('CampaignHero', () => { + it('apresenta a candidatura e mantém um único CTA primário', () => { + render() + + expect( + screen.getByRole('heading', { level: 1, name: 'UM MANDATO DO TAMANHO DA BAHIA' }), + ).toBeTruthy() + + const supportLink = screen.getByRole('link', { name: /Quero apoiar/i }) + expect(supportLink.getAttribute('href')).toBe('https://apoiar.me/jorgesolla') + expect(supportLink.getAttribute('data-cta')).toBe('primary') + + const flagsLink = screen.getByRole('link', { name: /Conhecer bandeiras/i }) + expect(flagsLink.getAttribute('href')).toBe('#bandeiras') + expect(flagsLink.getAttribute('data-cta')).toBe('secondary') + }) + + it('expõe as provas e as fotos com nomes acessíveis', () => { + render() + + expect(screen.getByLabelText('DIAP entre os 40 melhores da Câmara')).toBeTruthy() + expect(screen.getByLabelText('Mais votado do PT-BA em 2022')).toBeTruthy() + expect(screen.getByRole('img', { name: /Jorge Solla 1313/i })).toBeTruthy() + expect(screen.getByAltText('Jorge Solla, candidato a deputado federal pela Bahia')).toBeTruthy() + }) +}) diff --git a/tests/unit/campaignHome.unit.spec.tsx b/tests/unit/campaignHome.unit.spec.tsx new file mode 100644 index 00000000..8b8e909d --- /dev/null +++ b/tests/unit/campaignHome.unit.spec.tsx @@ -0,0 +1,49 @@ +import { cleanup, render, screen } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' + +import HomePage from '@/app/(frontend)/(home)/page' + +type MockImageProps = React.ImgHTMLAttributes & { + fill?: boolean + priority?: boolean +} + +vi.mock('next/image', () => ({ + default: ({ alt, fill: _fill, priority: _priority, ...props }: MockImageProps) => ( + // eslint-disable-next-line @next/next/no-img-element + {alt} + ), +})) + +afterEach(cleanup) + +describe('Campaign home', () => { + it('monta as quatro seções previstas e o rodapé eleitoral', async () => { + render(await HomePage()) + + expect( + screen.getByRole('heading', { level: 1, name: 'UM MANDATO DO TAMANHO DA BAHIA' }), + ).toBeTruthy() + expect(screen.getByText('3.333')).toBeTruthy() + expect(screen.getByText('1.031')).toBeTruthy() + expect(screen.getByText('3º')).toBeTruthy() + expect(screen.getByRole('heading', { name: /Eleger deputado é coisa séria/i })).toBeTruthy() + expect( + screen.getByRole('heading', { + name: /Junto com o trabalhador e do lado de quem mais precisa, sempre/i, + }), + ).toBeTruthy() + expect(screen.getByText(/CNPJ: 68\.430\.467\/0001-05/)).toBeTruthy() + }) + + it('usa exatamente as seis bandeiras aprovadas', async () => { + render(await HomePage()) + + expect(screen.getByText('Defender o SUS e valorizar quem cuida')).toBeTruthy() + expect(screen.getByText('Fim da escala 6×1 e jornada de 40h')).toBeTruthy() + expect(screen.getByText('Educação em tempo integral e federais no interior')).toBeTruthy() + expect(screen.getByText('Recomprar a Refinaria de Mataripe')).toBeTruthy() + expect(screen.getByText('Salário mínimo forte, emprego e moradia')).toBeTruthy() + expect(screen.getByText('Defesa intransigente da democracia')).toBeTruthy() + }) +}) diff --git a/tests/unit/imageFill.unit.spec.ts b/tests/unit/imageFill.unit.spec.ts new file mode 100644 index 00000000..696325d1 --- /dev/null +++ b/tests/unit/imageFill.unit.spec.ts @@ -0,0 +1,132 @@ +// @vitest-environment node + +import { describe, expect, it } from 'vitest' + +import { + EXTEND_DEFAULT_PROMPT, + MASK_DEFAULT_PROMPT, + extendedCanvas, + padToMultipleOf64, + parseExtendPx, + parseFillArgs, +} from '../../scripts/lib/imageFill.mjs' + +describe('parseExtendPx', () => { + it('plain integers are pixels', () => { + expect(parseExtendPx('400')).toBe(400) + expect(parseExtendPx(' 600 ')).toBe(600) + }) + + it('percentages are accepted (resolved against the photo height later)', () => { + expect(parseExtendPx('30%')).toBe(30) + expect(parseExtendPx('100%')).toBe(100) + }) + + it('fails closed on anything else', () => { + for (const bad of ['', 'abc', '-400', '0', '30.5', '30.5%', '400px']) { + expect(parseExtendPx(bad)).toBeNull() + } + }) +}) + +describe('extendedCanvas', () => { + it('grows only downward, photo keeps its top position', () => { + const canvas = extendedCanvas(1000, 1500, 500) + expect(canvas).toEqual({ width: 1000, height: 2000, offsetY: 1500 }) + }) +}) + +describe('padToMultipleOf64', () => { + it('rounds up to the next multiple of 64 on each axis', () => { + expect(padToMultipleOf64(600, 1260)).toEqual({ + width: 640, + height: 1280, + padRight: 40, + padBottom: 20, + }) + expect(padToMultipleOf64(2000, 1200)).toEqual({ + width: 2048, + height: 1216, + padRight: 48, + padBottom: 16, + }) + }) + + it('is a no-op for exact multiples', () => { + expect(padToMultipleOf64(1024, 1536)).toEqual({ + width: 1024, + height: 1536, + padRight: 0, + padBottom: 0, + }) + }) +}) + +describe('parseFillArgs', () => { + it('--extend flow defaults: prompt de extensão, resize on, out public, 28 passos', () => { + const options = parseFillArgs(['--photo', 'lula.jpg', '--extend', '40%']) + expect(options).toEqual({ + help: false, + photo: 'lula.jpg', + mask: null, + extend: '40%', + prompt: EXTEND_DEFAULT_PROMPT, + steps: 28, + outDir: 'public', + resize: true, + }) + }) + + it('--mask flow defaults to the mask prompt', () => { + const options = parseFillArgs(['--photo', 'foto.png', '--mask', 'm.png']) + expect(options.mask).toBe('m.png') + expect(options.extend).toBeNull() + expect(options.prompt).toBe(MASK_DEFAULT_PROMPT) + }) + + it('reads value flags as --flag value and --flag=value', () => { + const spaced = parseFillArgs([ + '--photo', + 'a.jpg', + '--mask', + 'b.png', + '--prompt', + 'terno', + '--out', + 'draft/', + ]) + expect(spaced.photo).toBe('a.jpg') + expect(spaced.prompt).toBe('terno') + expect(spaced.outDir).toBe('draft/') + + const equals = parseFillArgs(['--photo=a.jpg', '--extend=30%', '--steps=50']) + expect(equals.extend).toBe('30%') + expect(equals.steps).toBe(50) + }) + + it('--no-resize is a bare boolean flag', () => { + expect(parseFillArgs(['--photo', 'a.jpg', '--extend', '10%', '--no-resize']).resize).toBe(false) + }) + + it('fails closed: photo required, mask xor extend, invalid flags/values', () => { + expect(() => parseFillArgs(['--extend', '30%'])).toThrow(/--photo é obrigatório/) + expect(() => parseFillArgs(['--photo', 'a.jpg'])).toThrow(/--mask|--extend/) + expect(() => parseFillArgs(['--photo', 'a.jpg', '--mask', 'm.png', '--extend', '10%'])).toThrow( + /mutuamente exclusivos/, + ) + expect(() => parseFillArgs(['--photo', 'a.jpg', '--extend', 'x%'])).toThrow(/--extend inválido/) + expect(() => parseFillArgs(['--photo', 'a.jpg', '--extend', '10%', '--steps', '99'])).toThrow( + /--steps inválido/, + ) + expect(() => parseFillArgs(['--photo', 'a.jpg', '--extend', '10%', '--toaster'])).toThrow( + /flag desconhecida/, + ) + expect(() => parseFillArgs(['a.jpg', '--extend', '10%'])).toThrow(/posicional/) + }) + + it('--help skips validation', () => { + const options = parseFillArgs(['--help']) + expect(options.help).toBe(true) + expect(options.photo).toBeUndefined() + }) +}) diff --git a/tests/unit/imageResize.unit.spec.ts b/tests/unit/imageResize.unit.spec.ts new file mode 100644 index 00000000..3ab2914a --- /dev/null +++ b/tests/unit/imageResize.unit.spec.ts @@ -0,0 +1,175 @@ +// @vitest-environment node + +import { describe, expect, it } from 'vitest' + +import { + FORMAT_MAX_WIDTHS, + deriveOutputFileName, + maxWidthFor, + normalizeImageFormat, + parseResizeArgs, +} from '../../scripts/lib/imageResize.mjs' + +describe('parseResizeArgs', () => { + it('defaults: avif + webp, per-format width/quality, no bg removal, out public', () => { + const options = parseResizeArgs(['foto.jpg']) + expect(options).toEqual({ + help: false, + paths: ['foto.jpg'], + maxWidth: undefined, + quality: undefined, + formats: ['avif', 'webp'], + outDir: 'public', + removeBg: false, + }) + }) + + it('--remove-bg is a bare boolean flag', () => { + expect(parseResizeArgs(['--remove-bg', 'foto.jpg']).removeBg).toBe(true) + expect(parseResizeArgs(['foto.jpg']).removeBg).toBe(false) + }) + + it('accepts multiple positional paths and folders', () => { + const options = parseResizeArgs(['a.jpg', 'b.png', 'fotos/']) + expect(options.paths).toEqual(['a.jpg', 'b.png', 'fotos/']) + }) + + it('reads flags both as --flag value and --flag=value', () => { + const spaced = parseResizeArgs([ + '--max-width', + '2560', + '--format', + 'webp', + '--out', + 'public/x', + 'foto.jpg', + ]) + expect(spaced.maxWidth).toBe(2560) + expect(spaced.formats).toEqual(['webp']) + expect(spaced.outDir).toBe('public/x') + + const equals = parseResizeArgs([ + '--max-width=2560', + '--quality=75', + '--format=jpeg', + '--out=out/', + 'foto.jpg', + ]) + expect(equals.maxWidth).toBe(2560) + expect(equals.quality).toBe(75) + expect(equals.formats).toEqual(['jpeg']) + expect(equals.outDir).toBe('out/') + }) + + it('accepts a comma-separated --format list, deduped and order-preserving', () => { + expect(parseResizeArgs(['--format', 'avif,webp', 'foto.jpg']).formats).toEqual(['avif', 'webp']) + expect(parseResizeArgs(['--format', 'avif,jpeg', 'foto.jpg']).formats).toEqual(['avif', 'jpeg']) + expect(parseResizeArgs(['--format', 'webp,avif,webp', 'foto.jpg']).formats).toEqual([ + 'webp', + 'avif', + ]) + expect(parseResizeArgs(['--format', 'avif,,webp', 'foto.jpg']).formats).toEqual([ + 'avif', + 'webp', + ]) + }) + + it('leaves quality and maxWidth undefined without flags (each format applies its own default)', () => { + expect(parseResizeArgs(['--format', 'webp', 'foto.jpg']).quality).toBeUndefined() + expect(parseResizeArgs(['--format', 'jpeg', 'foto.jpg']).quality).toBeUndefined() + expect(parseResizeArgs(['foto.jpg']).quality).toBeUndefined() + expect(parseResizeArgs(['foto.jpg']).maxWidth).toBeUndefined() + }) + + it('fails closed on an unknown flag', () => { + expect(() => parseResizeArgs(['--toaster', 'foto.jpg'])).toThrow(/flag desconhecida/) + }) + + it('fails closed on a missing flag value (end of argv or next token is a flag)', () => { + expect(() => parseResizeArgs(['--max-width'])).toThrow(/espera um valor/) + expect(() => parseResizeArgs(['--out', '--format', 'webp', 'foto.jpg'])).toThrow( + /espera um valor/, + ) + }) + + it('fails closed on invalid numbers', () => { + for (const bad of ['0', '-100', 'abc', '31.5', '3840px']) { + expect(() => parseResizeArgs(['--max-width', bad, 'foto.jpg'])).toThrow( + /--max-width inválido/, + ) + } + for (const bad of ['0', '101', 'xyz']) { + expect(() => parseResizeArgs(['--quality', bad, 'foto.jpg'])).toThrow(/--quality inválido/) + } + }) + + it('fails closed on an unsupported format, a list with one, or an all-empty list', () => { + expect(() => parseResizeArgs(['--format', 'png', 'foto.jpg'])).toThrow(/--format inválido/) + expect(() => parseResizeArgs(['--format', 'avif,svg', 'foto.jpg'])).toThrow(/--format inválido/) + expect(() => parseResizeArgs(['--format', ',', 'foto.jpg'])).toThrow(/--format inválido/) + expect(() => parseResizeArgs(['--format', ' , ', 'foto.jpg'])).toThrow(/--format inválido/) + }) + + it('fails closed on no paths', () => { + expect(() => parseResizeArgs([])).toThrow(/nenhum caminho/) + }) + + it('--help skips validation', () => { + const options = parseResizeArgs(['--help']) + expect(options.help).toBe(true) + expect(options.paths).toEqual([]) + }) +}) + +describe('normalizeImageFormat', () => { + it('accepts canonical names case-insensitively', () => { + expect(normalizeImageFormat('avif')).toBe('avif') + expect(normalizeImageFormat('WebP')).toBe('webp') + expect(normalizeImageFormat('JPEG')).toBe('jpeg') + }) + + it('maps jpg aliases to jpeg and ignores a leading dot', () => { + expect(normalizeImageFormat('jpg')).toBe('jpeg') + expect(normalizeImageFormat('.jpeg')).toBe('jpeg') + }) + + it('returns null for anything not encodable', () => { + for (const bad of ['png', 'gif', 'svg', '', 'aviff']) { + expect(normalizeImageFormat(bad)).toBeNull() + } + }) +}) + +describe('deriveOutputFileName', () => { + it('keeps the basename and swaps the extension per format', () => { + expect(deriveOutputFileName('foto.jpg', 'avif')).toBe('foto.avif') + expect(deriveOutputFileName('foto.jpeg', 'webp')).toBe('foto.webp') + expect(deriveOutputFileName('foto.PNG', 'jpeg')).toBe('foto.jpg') + }) + + it('survives dots inside the name', () => { + expect(deriveOutputFileName('foto.final.1.jpg', 'avif')).toBe('foto.final.1.avif') + }) + + it('the web name never collides with a source in another format', () => { + expect(deriveOutputFileName('foto.avif', 'avif')).toBe('foto.avif') + expect(deriveOutputFileName('foto.avif', 'webp')).toBe('foto.webp') + }) +}) + +describe('maxWidthFor (PenPot companion must be light)', () => { + it('defaults per format: avif retina-grade 2x, webp/jpeg 1x desktop', () => { + expect(FORMAT_MAX_WIDTHS.avif).toBe(3840) + expect(FORMAT_MAX_WIDTHS.webp).toBe(1920) + expect(FORMAT_MAX_WIDTHS.jpeg).toBe(1920) + expect(maxWidthFor('avif')).toBe(3840) + expect(maxWidthFor('webp')).toBe(1920) + expect(maxWidthFor('jpeg')).toBe(1920) + }) + + it('an explicit --max-width overrides every format', () => { + expect(maxWidthFor('avif', 2560)).toBe(2560) + expect(maxWidthFor('webp', 2560)).toBe(2560) + expect(maxWidthFor('webp', 900)).toBe(900) + }) +})