Commit Graph

77 Commits

Author SHA1 Message Date
4859fc5a8b docs: proposta openspec home editorial 2026-08-11 16:24:12 -03:00
dfa798552f feat: home editorial e header/footer conforme preview 2026-08-11 16:22:28 -03:00
2573a4aa73 feat: modalidades de casamento e etapas corporativas 2026-08-11 16:22:25 -03:00
213d6843e7 docs: reconciliar OpenSpec e escopo do MVP (MAN-124) (#50) 2026-08-11 12:05:00 -03:00
f8765add85 feat(home): widen photographic hero (#49) 2026-08-11 11:21:32 -03:00
168a21e459 feat(home): preencher hero fotográfico (#48)
* docs: especificar hero full-bleed da home

* docs: planejar hero full-bleed da home

* feat(home): preencher hero fotográfico
2026-08-11 10:35:17 -03:00
7902b35393 feat(home): alinhar cadência editorial (#47)
* feat(home): alinhar cadência editorial

* test(home): ajustar contrato do hero dividido
2026-08-11 09:52:08 -03:00
28e25b4464 feat: recompor cadência editorial pública (#46) 2026-08-11 09:07:58 -03:00
8afb925e17 feat: adicionar heros fotográficos imersivos (#45) 2026-08-11 08:28:09 -03:00
338e3c9adb chore: remover regressão visual (#44) 2026-08-10 22:57:20 -03:00
0ecae4c7d6 fix: reduzir I/O remoto de imagens R2 (MAN-109) (#43)
* perf: cortar 157 KiB do caminho crítico e medir o LCP de forma reprodutível (MAN-109)

A auditoria do PR #34 mediu LCP acima da meta de 2,5 s da SPEC §6.6 em todas
as páginas no mobile, mas ficou registrada apenas como tabela num comentário
do Linear — `storage/app/lighthouse` é gitignored, então não havia artefato
para comparar depois. Esta entrega mede de novo, encontra a causa dominante e
corta o que dava para cortar.

## Fontes servidas em dobro (88 KiB)

Bunny entrega cada peso de EB Garamond em woff2 e woff, e o plugin de fontes
emitia uma regra `@font-face` para cada, woff2 primeiro e woff depois. Duas
regras com a mesma família, peso, estilo e unicode-range fazem a última
vencer: o navegador renderizava a partir dos woff e descartava os woff2
pré-carregados.

O log de rede da home prova: 3 woff em prioridade VeryHigh (88 KiB), a mais
alta da página e à frente do elemento de LCP, mais 3 woff2 em High (74 KiB)
baixados só por causa do `<link rel="preload">`. 162 KiB de tráfego para
74 KiB de fonte útil.

woff2 é suportado por todo navegador que este site atende desde 2016, então as
regras woff eram peso morto, não fallback. O plugin `amare:fonts-woff2-only`
remove as regras do CSS e do manifest e tira os arquivos do bundle. É o que
derruba o FCP de 1,51 s para 0,91 s em todas as páginas.

## Ativos de marca reencodados (102 KiB) — absorve MAN-122

O logotipo era servido a 512 px de largura para renderizar em 48 px (lockup,
cabeçalho e rodapé de toda página) e 32 px (mark, home), com `loading="eager"`.
Reencodados a 3× do maior render: lockup 149×144 (84 → 14 KiB) e mark 191×96
(42 → 10 KiB), mesmos nomes de arquivo para não invalidar cache. As variantes
`on-dark` foram reencodadas junto por consistência; nenhuma view as usa hoje.

Os 16 baselines visuais foram regenerados no runner Linux e o diff é
imperceptível a 2× de zoom — mesma forma, mesma cor, menos bytes.

## Efeito medido

Mobile, mediana de 3 execuções por página: home 4,58 → 3,39 s; portfolio
3,98 → 1,58 s; portfolio-detalhe 3,98 → 3,01 s; servicos 3,68 → 2,63 s;
sobre 3,01 → 1,58 s; contato 2,55 → 1,51 s. Desktop passa com folga em todas
(máximo 0,79 s). Acessibilidade, boas práticas e SEO seguem 100, CLS 0,000 e
TBT 0 ms.

`contato`, `portfolio` e `sobre` entraram na meta. `servicos`,
`portfolio-detalhe` e `home` continuam fora, e o que falta está identificado:
o elemento de LCP da home é a imagem do hero em JPEG q82 (143 KiB), e
`Improve image delivery` estima 0,90 s de ganho restante. O lever é variante
WebP em ResponsiveImage com `<picture>` — fora deste escopo porque mexe no
pipeline de mídia que o CMS usa para upload.

## Ferramental

- `scripts/perf/lighthouse.sh` ganha `/portfolio/{slug}`, passada desktop,
  mediana de 3 execuções e uma guarda de HTTP 200 antes de auditar. Uma rota
  em 404 produz relatório com score alto: a rota mais pesada do site
  apareceria como ótima e ninguém notaria.
- `scripts/perf/summarize-lighthouse.mjs` extrai do JSON o elemento de LCP, a
  decomposição em fases e as requests até o LCP.
- `docker/ci-runner.Dockerfile` e `scripts/test/visual-update-ci.sh` versionam
  a receita de regeneração de baselines, que existia só como checklist.
- `CLAUDE.md` corrigido: Pest Browser serve a aplicação de um servidor Amp
  in-process, não do FrankenPHP. A paridade que importa nos baselines é
  Linux vs macOS, não o runtime HTTP.
- Sem gate de performance no CI (§14.1 e §22). No lugar, orçamento de bytes
  para os ativos de marca e asserção de que o build só emite woff2.

Staging não foi medido: a origem responde 303 para blocked.teams.cloudflare.com
a partir da rede corporativa, inclusive em `/up`. Duas hipóteses seguem não
validadas por dependerem de `FILESYSTEM_DISK=r2` — os ~4 round trips a R2 por
imagem que `x-media.image` faz sem cache, e a falta de `preconnect` para a
origem cross-origin de mídia. Ambas documentadas em
docs/evidence/lighthouse/README.md.

Co-Authored-By: Claude noreply@anthropic.com
AI-Assisted: yes
AI-Tool: claude-code

* perf: servir imagens de conteúdo em webp e descrever sizes corretamente (MAN-109)

Fecha a meta de LCP ≤ 2,5 s da SPEC §6.6 em todas as páginas, nos dois presets.
Continuação direta do commit anterior: com fontes e marca resolvidas, o elemento
de LCP de toda página no mobile passou a ser a imagem do hero, e o Load Delay de
1,88 s era contenção de banda pura.

## Variantes webp

`ResponsiveImage::generate()` escreve uma variante `.webp` ao lado de cada
variante no formato original, e `x-media.image` a oferece num
`<source type="image/webp">`. O `<img>` continua apontando para o formato
original: o `<source>` é preferência, não substituição, então nada quebra em
quem não decodifica webp, e mídia antiga sem irmãos webp renderiza `<img>` puro
como antes.

O `<picture>` recebe `display: contents` porque os chamadores estilizam o `<img>`
com classes como `h-full w-full object-cover` que resolvem contra o pai grid ou
flex — um wrapper inline quebraria isso.

O nome do arquivo acrescenta a extensão em vez de trocá-la
(`photo-720.jpg.webp`): uploads usam UUID, então colisão já era improvável, mas
`photo-720.webp` colidiria com a variante webp de um `photo.png`.

## sizes que descreve a realidade

Nenhuma imagem do site ocupa a viewport inteira — todas ficam dentro de
`container-amare`, que reserva 1,5rem de padding de cada lado. Declarar `100vw`
fazia uma viewport de 412 px em DPR 1,75 pedir 721 px e pular para a variante de
960 para desenhar uma caixa de 637 px. Errar por um pixel custava um terço a mais
de bytes em toda página.

Com `calc(100vw - 3rem)` a home passa a usar a variante de 720: 74 KiB, contra
143 KiB no início da investigação. Foi também por isso que 720 entrou em
`ResponsiveImage::WIDTHS` — sem ela o salto de 480 para 960 é grande demais para
a viewport mobile mais comum.

## Efeito medido

Mobile, mediana de 3 execuções: home 3,39 → 2,49 s; portfolio-detalhe
3,01 → 2,18 s; servicos 2,63 → 2,03 s; portfolio 1,58 s; sobre 1,58 s;
contato 1,50 s. Desktop no máximo 0,65 s. Peso total da home 798 → 347 KiB.

Contra o início da investigação (`2e43fde`): home 4,58 → 2,49 s com score de
performance 83 → 98.

A home passa **em cima da linha** — a pior das três execuções deu 2,57 s. Está
documentado como aprovada por margem, não com folga, e os levers restantes estão
listados em docs/evidence/lighthouse/README.md em ordem de custo.

Os 16 baselines visuais não mudaram: nenhum seeder gera variantes e
`media:generate-variants` não roda no runner visual, então naquele ambiente
`availableVariants()` volta vazio e o componente renderiza `<img>` puro. A
cobertura do caminho com variantes fica em MediaImageComponentTest e
ResponsiveImageTest, não nos baselines — anotado como lacuna conhecida.

Co-Authored-By: Claude noreply@anthropic.com
AI-Assisted: yes
AI-Tool: claude-code

* docs: registrar a dependência do deploy e corrigir os commits nas evidências

Três correções de honestidade nos artefatos de medição.

O campo `Commit:` era gravado com o HEAD do momento da coleta, que é sempre
anterior ao commit que contém as mudanças medidas — as duas passadas pós-correção
diziam `2e43fde` e `65919d4` quando as árvores medidas viraram `65919d4` e
`9ab2beb`. Corrigido à mão nos dois arquivos, e o script passa a marcar
`+alterações não commitadas` quando a árvore está suja, para o artefato não voltar
a afirmar o que não é.

O ganho das imagens não aparece em staging nem em produção antes de
`media:generate-variants` rodar: lá o disco é `r2` e as variantes webp e a largura
720 ainda não existem para a mídia publicada. O serviço `migrate` do
docker-compose.deploy.yml roda o comando sem condição a cada deploy e ele
regenera mesmo quando já há variantes, então o primeiro deploy resolve — mas
estava implícito e agora está escrito.

E o custo que as variantes webp introduzem no lado servidor está registrado com o
número certo: `x-media.image` faz 8 `exists()` por imagem no lugar de 3, o que em
`r2` são ~9 round trips remotos por imagem e ~36 na home. Isso agrava a hipótese
não validada de TTFB em staging em vez de melhorá-la, e promove o cache desses
metadados a item mais urgente da lista.

Co-Authored-By: Claude noreply@anthropic.com
AI-Assisted: yes
AI-Tool: claude-code

* fix: cache responsive image metadata for R2

* test: atualizar baselines visuais Linux

---------

Co-authored-by: manoel.neto <manoel.neto@creditas.com>
2026-08-10 20:08:15 -03:00
2e43fdeb04 chore: usar America/Sao_Paulo como timezone da aplicação (#42)
A cidade de atuação é São Paulo, garantida por teste em quatro lugares e
exigida por openspec/specs/site-settings/spec.md. O identificador de
timezone, porém, era America/Fortaleza. O identificador passa a
acompanhar o negócio.

A mudança não altera comportamento. America/Sao_Paulo e
America/Fortaleza são UTC-3 o ano inteiro desde que o horário de verão
brasileiro foi extinto — verificado para janeiro, março e dezembro de
2026, idênticos ao segundo. Nada renderizado muda, o relógio congelado
dos testes visuais usa offset absoluto (-03:00) e os baselines seguem
válidos.

O motivo de mexer é outro: a divergência entre o timezone e a cidade
custou tempo real. Uma sessão anterior a interpretou como drift e
"corrigiu" a SPEC no sentido errado, mudando o documento normativo para
Fortaleza em vez de olhar o que o negócio é. Com os dois valores dizendo
São Paulo, não há mais o que interpretar.

Escopo: config/app.php, .env.example, os dois pontos do ci.yml,
SPEC.md (§0, §13.5, §15.4), README.md, docs/deployment/dokploy.md,
CLAUDE.md, openspec/config.yaml, openspec/specs/visual-regression/spec.md
e o withTimezone do VisualRegressionTest.

Intocados de propósito: as asserções que garantem que Fortaleza não
aparece como cidade de operação, em PublicPagesTest, SiteSettingsTest,
ContentSeederProductionGatingTest e openspec/specs/site-settings. Essas
tratam de cidade, não de fuso, e continuam corretas.

Co-Authored-By: Claude noreply@anthropic.com
AI-Assisted: yes
AI-Tool: claude-code

Co-authored-by: manoel.neto <manoel.neto@creditas.com>
2026-08-10 11:37:03 -03:00
cc4b40b5d8 docs: registrar o recorte do lançamento e adiar as Fases 2 a 5 (#41)
A SPEC §4.1 declarava CRM de leads, conversão de lead em evento, eventos,
tarefas, fornecedores, orçamento, pagamentos manuais, documentos,
dashboard e auditoria como parte do MVP. O projeto do lançamento aprovado
com a dona do produto é o site institucional — Fases 0 e 1 — e o
repositório reflete isso: não existe model, migration ou tela para
nenhuma dessas capacidades.

Nada disso estava registrado em lugar nenhum, então a §1 e a §25 seguiam
instruindo qualquer agente que lesse o documento a implementar as Fases 2
a 5. A divergência era silenciosa e a §0.2 proíbe justamente isso.

O que muda:

- §4 ganha nota de recorte apontando para a ADR-016, e a §4.1 separa o
  que entra no lançamento do que fica adiado. A distinção entre adiado e
  fora do MVP fica explícita: os itens adiados seguem sendo o produto
  pretendido, enquanto a §4.2 lista o que não deve ser implementado nunca.
- §18 ganha nota equivalente e as Fases 2 a 5 passam a ser marcadas como
  ADIADA no próprio título.
- §21 registra a ADR-016.
- §23 é dividida em dois recortes. Sem isso o documento passaria a dizer
  que o MVP nunca pode ser concluído: das dezoito condições originais,
  nove pertencem às fases adiadas. A §23.1 fecha por conta própria e a
  §23.2 não bloqueia o lançamento.

Uma condição foi corrigida no caminho, não só movida: a original dizia
"briefing cria leads de forma segura". Criar Lead é Fase 2. O formulário
atual envia e-mail e não persiste nada, então a condição do lançamento
passa a ser enviar o pedido com proteção contra abuso e aceite de
privacidade registrado — o que o código realmente faz.

Marca também o hello-world em staging na §18, agora que existe evidência:
o deploy passou em 7e68c0e depois da correção do PR #38, com smoke verde.
Com isso o critério de saída da Fase 0 está cumprido.

O openspec/config.yaml recebe a linha de stack corrigida que ficou de
fora do PR #35. A afirmação sobre a cidade de atuação permanece: São
Paulo está correto e é garantido por teste — a suspeita de que também
estivesse desatualizada não se confirmou.

Co-Authored-By: Claude noreply@anthropic.com
AI-Assisted: yes
AI-Tool: claude-code

Co-authored-by: manoel.neto <manoel.neto@creditas.com>
2026-08-10 11:19:35 -03:00
63c0269489 feat: aplicar identidade Heritage Editorial ao painel Filament (MAN-118) (#40)
* feat: aplicar identidade Heritage Editorial ao painel Filament

O painel admin ainda usava os defaults de fábrica do Filament (Amber,
Zinc, Inter Variable, dark mode ligado) enquanto o site público já
seguia o design system Heritage Editorial há várias entregas — a
inconsistência ficava evidente para quem navegava entre as duas áreas
e não havia nenhum teste travando a configuração do painel.

- Cores primary/gray/danger/warning/success viram arrays explícitos de
  11 tons (não Color::hex()/string), a única forma de preservar os
  hexadecimais exatos do DESIGN.md — Color::hex() decompõe a cor e
  remonta lightness/chroma por uma tabela fixa, perdendo a cor real.
  Os tons foram escolhidos verificando empiricamente com
  ButtonComponentColorMap qual shade o botão sólido realmente usa
  (600/500/50), e o tom 50 do primary é um verde-oliva claro (não
  branco puro) porque BadgeComponent usa bg-color-50 diretamente e um
  branco puro tornaria o badge quase invisível sobre o Papel Marfim.
- EB Garamond self-hosted via LocalFontProvider (sans/mono/serif), sem
  reintroduzir uma requisição externa ao fonts.bunny.net — o hook
  HEAD_END reaproveita o mesmo <x-fonts /> do site público.
- Novo tema Vite (resources/css/filament/admin/theme.css) zera radius
  e shadow só no escopo do Filament, incluindo a variável --radius
  "bare" (usada em ~227 regras do próprio Filament) e um override
  manual para o CSS pré-compilado do tooltip Tippy.js, que não é
  alcançado pelo @theme.
- Dark mode desativado: o Heritage Editorial é uma paleta única.
- Teste de regressão (AdminPanelBrandParityTest) pinando cores, fontes
  e configuração do tema, incluindo uma renderização real de
  /admin/login — foi essa renderização que pegou um bug real: passar
  a família já entre aspas simples quebrava a declaração CSS
  (--font-family: ''EB Garamond''), silenciosamente caindo para
  ui-sans-serif.

Bundle público verificado byte a byte: nenhuma declaração CSS
existente mudou de valor (apenas classes novas e não usadas pelo site
público foram adicionadas ao app.css, um efeito colateral inerte de
ter uma segunda entrada Tailwind no mesmo build do Vite).

Co-Authored-By: Claude noreply@anthropic.com
AI-Assisted: yes
AI-Tool: claude-code

* fix(build): copiar CSS do Filament no estágio frontend e neutralizar sombras

Dois defeitos encontrados ao revisar a paridade visual do painel.

O primeiro impedia o build da imagem. O `resources/css/filament/admin/
theme.css` importa o CSS não compilado do próprio Filament, e o estágio
`frontend` do Dockerfile copia apenas `package.json`, `vite.config.js`,
`resources` e `public` — nunca `vendor`. O `npm run build` falhava na
resolução do import, o que derrubaria os jobs `container` e `browser` do
CI. Agora a subárvore `vendor/filament` é copiada do estágio do composer,
em vez de todo o `vendor`, para manter o contexto pequeno.

O segundo é silencioso e mais interessante. O plugin do Tailwind compartilha
um único contexto entre todas as entradas do build, então o `@source
app/Filament/**` da entrada do painel torna visível o uso de `shadow-*`
do Filament e o Tailwind passa a emitir seus valores padrão de
`--shadow-sm/md/lg` também no bundle público. Verificado por diff do
`app.css` construído com e sem a entrada do tema.

Nada no site público usa utilitário de sombra hoje, então a renderização
não muda e os baselines visuais seguem válidos. Mas deixar valores reais
de sombra definidos no CSS que vai para produção permitiria que um
`shadow-sm` futuro em elemento público violasse a Tonal Layer Rule do
DESIGN.md em silêncio — e o HeritageEditorialTokensTest só inspeciona
arquivos de origem, então não pegaria.

Os tokens de sombra passam a ser fixados em transparente no `@theme` do
`app.css`, com teste que falha se algum deixar de ser. A regra continua
verdadeira no artefato que realmente ship.

Co-Authored-By: Claude noreply@anthropic.com
AI-Assisted: yes
AI-Tool: claude-code

---------

Co-authored-by: manoel.neto <manoel.neto@creditas.com>
2026-08-10 11:09:01 -03:00
7e68c0e37f chore: fechar portões de qualidade da Fase 0 (MAN-120) (#39)
* feat: gates de qualidade da Fase 0 (auth, npm audit, cobertura, compose local)

Implementa os itens pendentes de SPEC.md §18 mapeados pela mudança OpenSpec
complete-foundation-parity (grupos 1, 2 e 3 de tasks.md):

- MustVerifyEmail em User, com canAccessPanel exigindo is_active E
  hasVerifiedEmail(). Seeds de admin/assistant local passam a marcar
  email_verified_at. Verificação de e-mail permanece administrada apenas
  pelo admin (seed/edição) — não há rota de auto-verificação registrada
  (->emailVerification() não foi chamado) — decisão deliberada, já que
  usuários são criados pelo admin, não se autocadastram.

- Reset de senha seguro: página customizada
  App\Filament\Pages\Auth\RequestPasswordReset substitui a página padrão do
  Filament, que vaza a existência de contas via notificação de erro
  distinguível em dois casos — Password::INVALID_USER (e-mail inexistente)
  e Password::RESET_THROTTLED (só ocorre para usuário existente, quando um
  token foi criado há pouco). Ambos os casos agora respondem com a mesma
  notificação de sucesso.

- declare(strict_types=1) adicionado aos 15 arquivos de app/ que ainda não
  tinham: AdminPanelProvider, Controller e 13 páginas de Filament Resources.

- npm audit adicionado ao script `quality` do composer e ao job `static` do
  CI, com --audit-level=high (documentado inline no workflow). O gate é hoje
  vazio na prática — package.json não tem `dependencies` de runtime — mas
  protege contra regressões futuras.

- Gate de cobertura Domain/Application >= 80% no job `unit` do CI. Escopo
  via phpunit.coverage.xml dedicado (o phpunit.xml global continua cobrindo
  todo app/ para os demais usos). O job `unit` ganhou serviço de postgres e
  passou a rodar Unit+Architecture+Feature juntas, porque as classes de
  App\Application\Queries\Marketing só são exercitadas por testes Feature
  hoje — um gate baseado só em Unit ficaria bem abaixo de 80%. Medido
  localmente com pcov: 98,1% (nenhum teste novo foi necessário para atingir
  o limiar).

- Serviço `app` (FrankenPHP) no docker-compose.yml para paridade local com
  staging/produção, dependente de postgres saudável, com DB_HOST/DB_PORT
  sobrescritos para resolver o serviço postgres pelo nome (o padrão
  127.0.0.1 do .env só funciona para processos no host). Validado com um
  smoke isolado (projeto/portas dedicados via overlay `!override`, sem
  tocar o container amare-postgres compartilhado por outra worktree):
  depends_on/healthcheck funcionou, `php artisan migrate` rodou dentro do
  container via DB_HOST=postgres, e /up respondeu 200. README atualizado
  (PHP 8.4 canônico, composer.json mantém ^8.3 por design).

Fora do escopo: deploy hello-world em staging (Dokploy com falha, item de
infra não relacionado a este change) — L2353 e o critério de saída da Fase
0 continuam sem marcar. Também ficam pendentes, por dependerem de uma
execução real de CI/PR (task 3.4, 6.1, 6.2 do OpenSpec): prova de que o CI
falha com quebra intencional de audit/cobertura, e evidência de deploy/
rollback em staging.

Co-Authored-By: Claude noreply@anthropic.com
AI-Assisted: yes
AI-Tool: claude-code

* fix: fecha lockout de admin-criado e documenta pcov/compose no auth de usuários

Correção de acompanhamento ao commit anterior (gates de qualidade da Fase 0),
achada por revisão adicional após o primeiro commit:

- CreateUser::handleRecordCreation agora define email_verified_at ao criar um
  usuário pelo painel. Sem isso, UserForm não expõe esse campo (nem está no
  #[Fillable] de User — de propósito, para nunca virar mass-assignable via
  formulário), então todo usuário criado pelo admin nascia com
  email_verified_at nulo e, com canAccessPanel() agora exigindo e-mail
  verificado, ficava trancado para sempre — sem rota de auto-verificação e
  sem recuperação via reset de senha (o callback do Filament pula o envio
  para quem falha canAccessPanel(), mas ainda reporta sucesso). Usa
  forceFill() (não atribuição direta, que o PHPStan rejeita pelo tipo
  Carbon/string do cast) para contornar o guard de mass assignment só nesse
  ponto. Regressão coberta por teste que cria um usuário via
  Livewire::test(CreateUser::class) — não pela factory, que não passa pelo
  mesmo caminho — e confirma login em seguida; teste falha sem a correção
  (verificado manualmente revertendo e rodando de novo).

- Confirmado que DatabaseSeeder::run() (commit anterior) já funciona sem
  ajuste: User::query()->updateOrCreate() pareceria sofrer o mesmo guard de
  #[Fillable], mas `php artisan db:seed` executa dentro de
  Model::unguarded() (Illuminate\Database\Console\Seeds\SeedCommand), o que
  levanta o guard durante o seed inteiro. Adicionado teste de regressão que
  roda via $this->seed(DatabaseSeeder::class) (o mesmo caminho de
  `composer setup`) para travar esse comportamento — chamar a mesma lógica
  fora do comando db:seed reproduz o guard bloqueando o campo, confirmando
  que a dependência do unguard() é real, não coincidência.

- README: nota de que `docker compose up -d postgres` agora exige o `.env`
  do passo 1, porque o serviço `app` referencia `.env` via `env_file` e o
  Compose valida o arquivo inteiro antes de subir qualquer serviço.

- AGENTS.md: documentado `composer test:coverage` (usado pelo job `unit` do
  CI) e que `docker compose up -d` sem argumento também sobe o serviço `app`
  agora, não só o Postgres.

Nota para quem for escrever o próximo teste de recurso Filament neste repo:
Livewire::test(...)->fillForm([...]) é um no-op silencioso aqui —
app()->runningUnitTests() retorna false no setup de testes deste projeto,
então o guard de Filament\Schemas\Concerns\InteractsWithSchemas::
fillFormDataForTesting() nunca aplica o estado, e o teste falha depois com
erros de validação "required" que parecem um bug no formulário, não no
teste. Use ->set('data.campo', valor) (como os testes de Login já fazem)
até isso ser investigado — fora do escopo deste change.

Co-Authored-By: Claude noreply@anthropic.com
AI-Assisted: yes
AI-Tool: claude-code

* fix(auth): verificar usuários existentes ao adotar MustVerifyEmail

O `canAccessPanel` passou a exigir `hasVerifiedEmail()`, mas nenhuma
migration preenchia `email_verified_at` para contas que já existem. Toda
conta criada antes desta mudança tem o campo nulo e ficaria trancada fora
do /admin no instante do deploy.

E não haveria volta pelo aplicativo: nenhuma rota de verificação
self-service é registrada, e o fluxo de reset de senha deliberadamente
não notifica quem falha no `canAccessPanel` — ainda reportando sucesso.
Recuperar exigiria shell no contêiner.

Verificar essas contas é o padrão correto, não um atalho. Não existe
cadastro público: toda conta existente foi criada por um admin, pelo
painel ou pelo snippet de tinker do runbook de deploy. Esse ato é a
verificação — exatamente o raciocínio que o CreateUser aplica às contas
criadas de agora em diante.

A verificação é datada pelo `created_at` da conta, não pelo momento em
que a migration roda, para não inventar um histórico. O `down()` é um
no-op documentado: anular as colunas recriaria justamente a falha que
esta migration existe para evitar, e a divisão original entre nulos e
não-nulos não está registrada em lugar nenhum.

Co-Authored-By: Claude noreply@anthropic.com
AI-Assisted: yes
AI-Tool: claude-code

---------

Co-authored-by: manoel.neto <manoel.neto@creditas.com>
2026-08-10 10:48:04 -03:00
c84c347dfd fix(deploy): voltar command do migrate para exec-array de uma linha (#38)
Esta é a causa real da queda do deploy de staging, reportada pelo erro
`sh: 2: Syntax error: "&&" unexpected`.

O `command` do serviço `migrate` estava escrito como block scalar
dobrado (`command: >`). Em YAML, o dobramento junta linhas com espaço
apenas quando elas têm a mesma indentação da primeira; toda linha mais
indentada preserva a quebra. As linhas `&& ...` estavam um espaço mais à
direita, então o valor final era:

    sh -c "php artisan migrate --force --no-interaction
    && php artisan db:seed --class=ContentSeeder --force --no-interaction
    && php artisan media:generate-variants"

O `sh` recebe isso como duas linhas e aborta na segunda, antes de rodar
qualquer comando — daí o `sh: 2:` no erro. Como todos os outros serviços
dependem do migrate por `condition: service_completed_successfully`, a
stack inteira nunca sobe, e o Dokploy reporta `status=error` com
`errorMessage: null` porque nada da aplicação chegou a executar.

O formato exec-array de uma linha não tem essa ambiguidade. Ele já havia
sido adotado em 5949fad exatamente por isso, e o squash de reconciliação
58f24a6 o reverteu para o formato dobrado — o mesmo commit que também
apagou os oito baselines visuais restaurados no PR #29.

O `--force` inválido em `media:generate-variants`, corrigido no PR #32,
era um segundo defeito real na mesma linha, mas não era o que derrubava o
deploy: o `sh` morria antes de chegar lá.

Acrescenta guarda de regressão: nenhum `command` do compose de deploy
pode ser block scalar. Verificada nos dois sentidos — passa com o formato
atual e falha com a mensagem certa se o formato dobrado voltar.

Co-Authored-By: Claude noreply@anthropic.com
AI-Assisted: yes
AI-Tool: claude-code

Co-authored-by: manoel.neto <manoel.neto@creditas.com>
2026-08-10 10:16:33 -03:00
37296f758f feat: capturar origem de marketing do briefing de contato (MAN-105) (#37)
* feat: capturar origem de marketing do briefing de contato (MAN-105)

Adiciona tracking simples da origem (UTM ou referrer, primeiro-toque
vence) para satisfazer WEB-05: a origem viaja apenas com o e-mail
interno do briefing, sem Lead model, tabela ou superfície de CRM
(Fase 2 fora de escopo). Middleware no grupo "web" cobre só as rotas
públicas (o painel Filament tem sua própria stack de middleware);
lógica de captura/sanitização isolada em App\Domain\Contact para não
poluir controller nem view.

Fail-open por construção: qualquer falha ao capturar ou ler a origem é
logada sem dados pessoais e a submissão segue normalmente. Respeita
LGPD (§12.5): nada além da origem é guardado, valores são truncados
para não inflar a sessão, e um referrer apontando para o próprio host
é descartado por não ser sinal de marketing. Valores não confiáveis
(query string, referrer) são sanitizados antes de entrar na sessão e
no e-mail em HTML.

Co-Authored-By: Claude noreply@anthropic.com
AI-Assisted: yes
AI-Tool: claude-code

* fix(contato): capturar apenas a origem do referrer, não a URL completa

O fallback de referrer em MarketingOrigin::capture() armazenava a URL
inteira (incluindo query string e fragmento), não apenas o site de
origem. Um link de campanha de e-mail personalizado (ex.:
?email=...&subscriber_id=...) chegava intacto à sessão (persistida em
banco via SESSION_DRIVER=database) e ao corpo do e-mail de briefing
lido por um humano — indo além do que SPEC.md WEB-05 pede ("origem de
marketing") e do §12.5 ("coletar apenas dados necessários").

Agora o referrer é reduzido a esquema+host antes de sanitizar,
descartando path, query string e userinfo.

Co-Authored-By: Claude noreply@anthropic.com
AI-Assisted: yes
AI-Tool: claude-code

* test(contato): cobrir first-touch quando a navegação seguinte traz UTM concorrente

test_first_touch_wins_when_later_navigation_has_no_utm não exercitava
de fato a guarda "primeira captura vence" em CaptureMarketingOrigin —
as duas navegações seguintes não carregavam nenhum sinal (nem UTM, nem
referrer), então MarketingOrigin::capture() já retornava [] e o
early-return por ausência de sinal (não a guarda de sessão) é quem
impedia a sobrescrita. Confirmado removendo a guarda: a suíte
continuava passando.

Este teste novo usa uma segunda navegação com UTM diferente
(utm_source=facebook), o que só passa se a guarda de sessão estiver
ativa — mutação verificada manualmente antes de escrever a asserção.

Co-Authored-By: Claude noreply@anthropic.com
AI-Assisted: yes
AI-Tool: claude-code

* fix(contato): não capturar origem em sitemap.xml e robots.txt

O middleware estava anexado ao grupo `web` inteiro, que também cobre
`/sitemap.xml` e `/robots.txt`. Um crawler chegando em
`/sitemap.xml?utm_source=...` consumia o slot de primeiro toque com
tráfego que nunca vai enviar um briefing, e ainda iniciava sessão em uma
rota técnica.

As duas rotas passam a ser ignoradas por nome, com teste cobrindo que
elas não gravam nada na sessão e que uma visita humana seguinte ainda é
capturada normalmente.

Co-Authored-By: Claude noreply@anthropic.com
AI-Assisted: yes
AI-Tool: claude-code

---------

Co-authored-by: manoel.neto <manoel.neto@creditas.com>
2026-08-10 09:12:26 -03:00
49fbc0fdfa fix(seed): impedir ContentSeeder de sobrescrever conteúdo em produção (MAN-103) (#36)
* fix(seed): impedir ContentSeeder de sobrescrever conteúdo em produção

O migrate do Dokploy roda `db:seed --class=ContentSeeder --force` em todo
deploy, tanto em staging quanto em produção (mesmo compose, só o .env
muda). Como ContentSeeder usa updateOrCreate/delete incondicionais, cada
deploy em produção revertia edições feitas pela dona no Filament
(SiteSetting, Services, PortfolioCases), republicava os 3 casos e
serviços fictícios, apagava fotos reais da galeria e reenviava as
fixtures de imagem para o bucket R2 de produção — além de publicar os
depoimentos reais via TestimonialsSeeder, sem autorização explícita.

Adiciona uma guarda de ambiente no início de ContentSeeder::run(),
seguindo o mesmo padrão já usado em AppServiceProvider::
freezeClockWhenConfigured(): em APP_ENV=production o método retorna
sem efeito colateral (exit 0), preservando o passo migrate&&seed&&...
do docker-compose.deploy.yml. TestimonialsSeeder não é tocado — ele
continua sendo o único caminho de publicação de depoimentos em
produção, documentado como passo manual em docs/deployment/dokploy.md.
Staging (APP_ENV=staging) continua recebendo conteúdo de demonstração
normalmente, preservando a revisão visual.

Atualiza docs/deployment/dokploy.md, que afirmava (incorretamente,
desde o commit 10a1f0d) que os workflows de deploy eram "migrate-only"
e que ContentSeeder nunca rodava em staging/produção.

Adiciona teste de regressão cobrindo: banco vazio em produção (nenhum
registro criado, nenhuma fixture enviada ao disco), banco com conteúdo
editado pela dona em produção (seeding duas vezes não altera nada) e
comportamento inalterado fora de produção (semeia o conteúdo de demo
normalmente).

Co-Authored-By: Claude noreply@anthropic.com
AI-Assisted: yes
AI-Tool: claude-code

* fix(seed): trocar guarda de produção do ContentSeeder para allow-list

O guard anterior (`App::environment('production')`) é uma comparação
exata e sensível a maiúsculas contra o valor literal de APP_ENV, que é
digitado manualmente no editor de texto livre do Dokploy sem validação,
enum ou valor padrão garantido por este repositório. Se esse valor
algum dia ficar vazio, vier com case diferente ('Production',
'PRODUCTION'), espaço em branco ou for simplesmente digitado errado, o
guard falha aberto: o ContentSeeder roda seu caminho destrutivo em
produção sem nenhuma outra checagem no caminho de deploy que pegasse o
erro — exatamente o cenário que esta guarda deveria evitar.

Inverte a lógica para uma allow-list dos ambientes conhecidos e
seguros ('local', 'staging', 'testing'), então qualquer valor não
reconhecido de APP_ENV — incluindo 'production', vazio, mal digitado
ou com case diferente — falha fechado (no-op) em vez de falhar aberto
(sobrescrever dados reais). 'testing' entra na lista porque é o valor
de APP_ENV usado pelo job `feature` do CI (ver .github/workflows/ci.yml),
que roda testes que chamam ContentSeeder diretamente — sem esse valor
a suíte quebraria em CI mesmo passando localmente (APP_ENV=local via
.env).

Adiciona cobertura de regressão via data provider cobrindo tanto os
três ambientes permitidos quanto uma lista de valores não permitidos
('production', string vazia, 'Production', 'PRODUCTION', espaço à
direita, valor arbitrário desconhecido), para que a guarda seja testada
como allow-list e não apenas contra o literal 'production'.

Co-Authored-By: Claude noreply@anthropic.com
AI-Assisted: yes
AI-Tool: claude-code

* docs(deploy): corrigir descrição da guarda do ContentSeeder no runbook

O texto descrevia a guarda antiga (deny-list de 'production') e ainda
afirmava, de forma incorreta, que a publicação dos cinco depoimentos
reais era "um passo manual e deliberado em todo ambiente (incluindo
staging)". Isso nunca foi verdade: o ContentSeeder chama o
TestimonialsSeeder internamente e só pula essa chamada quando a guarda
não casa com o ambiente atual — em staging a guarda casa, então os
depoimentos são publicados/republicados automaticamente a cada deploy,
sem nenhum passo manual envolvido (o próprio teste
test_content_seeder_still_seeds_demo_content_outside_production já
provava isso).

Atualiza as duas passagens para descrever a allow-list
('local'/'staging'/'testing') introduzida no commit anterior e para
deixar explícito que staging publica depoimentos automaticamente,
reservando o comando manual apenas para produção. Também documenta o
trade-off do fail-closed: um APP_ENV de staging digitado errado agora
faz o conteúdo de demonstração parar de ser resemeado silenciosamente.

Co-Authored-By: Claude noreply@anthropic.com
AI-Assisted: yes
AI-Tool: claude-code

---------

Co-authored-by: manoel.neto <manoel.neto@creditas.com>
2026-08-10 09:12:18 -03:00
2e2860396e docs: reconciliar a stack declarada do site público com a implementação (#35)
Conclusão da auditoria pedida na MAN-117.

A pergunta da issue era se "Vue + Livewire" faz sentido na arquitetura
atual. Vue não aparece em lugar nenhum: não está no package.json, não há
nenhum arquivo .vue, e a própria SPEC §11.3 já proibia framework SPA.
Metade da premissa da issue não tem base no repositório.

Livewire existe, mas não onde a SPEC dizia. O pacote entra apenas como
dependência transitiva de filament/support e opera somente dentro do
painel /admin. O site público é Blade renderizado no servidor mais JS
vanilla progressivo: o layouts.public carrega apenas
@vite(['resources/css/app.css', 'resources/js/app.js']), sem
@livewireScripts, e não há uma única diretiva wire: ou x-data em
resources/views. Alpine só existe empacotado dentro do runtime do
Livewire, duas indireções abaixo do Filament — nunca chega a uma página
pública.

Mesmo assim, cinco lugares afirmavam o contrário: §0 metadados, §5.3,
§9.1, §11 inteira e a ADR-002, que está marcada como Aceita descrevendo
uma arquitetura que a área que ela nomeia não implementa.

A correção adota o que existe em vez de construir o que estava
declarado. O motivo é o próprio documento: a §11.2 já registrava
Blade + Controller como resultado aceito, os requisitos que a §11.2 pede
do formulário (loading state, botão desabilitado, aria-busy) já são
atendidos por resources/js/app.js, e nada nas Fases 2 a 5 pede
reatividade no site público. Adotar Livewire agora colocaria um bundle
global de JS e CSS em páginas cujo resultado visual está fixado por
baselines de snapshot, a serviço de um requisito que ninguém escreveu.

Como a §0.2 proíbe alterar silenciosamente uma decisão do documento, a
correção não reescreve a ADR-002 fingindo que ela sempre disse outra
coisa: o texto dela é emendado apontando para uma ADR-015 nova, que
registra a decisão explicitamente. E a §22 ganha uma linha nomeando a
condição objetiva que justificaria reabrir o assunto — a mesma que a
§11.1 já previa em prosa, "se houver necessidade de consulta dinâmica".

Fica de fora deste commit, para evitar conflito com trabalho em curso no
mesmo arquivo: a linha equivalente em openspec/config.yaml, que também
carrega uma afirmação desatualizada sobre a cidade de atuação e será
corrigida de uma vez só.

Co-Authored-By: Claude noreply@anthropic.com
AI-Assisted: yes
AI-Tool: claude-code

Co-authored-by: manoel.neto <manoel.neto@creditas.com>
2026-08-10 08:58:42 -03:00
54cd6ba0da perf: cachear assets estáticos e reservar dimensões do logotipo (#34)
A auditoria de Lighthouse da MAN-109 mostrou que todas as páginas
públicas estouram a meta de LCP da SPEC §6.6 (≤ 2,5 s) em mobile
simulado: home 4,4 s, portfólio 4,2 s, serviços 3,6 s, sobre 3,0 s e
contato 2,6 s. CLS e TBT passam com folga, e acessibilidade, boas
práticas e SEO marcam 100 em todas elas.

Duas causas apontadas pela auditoria são corrigidas aqui.

O Caddyfile não definia Cache-Control, então tudo sob /storage e /brand
voltava com cacheLifetimeMs 0 e era rebaixado a cada visita — cerca de
256 KiB por page view repetido à toa. Agora /build e /storage recebem um
ano imutável, o que é seguro porque ambos usam nomes que mudam junto com
o conteúdo: o Vite gera hash no nome e o PublicImageUploadRules grava
cada upload sob um UUID novo. /brand fica em um dia, porque esses
arquivos são versionados na imagem sob nome fixo e um cache longo demais
prenderia um logotipo antigo no navegador depois de uma troca de marca.

O componente de logotipo também não declarava dimensões, o que a SPEC
§6.4 exige para reservar espaço e evitar deslocamento de layout. Passa a
declarar width/height intrínsecos dos arquivos versionados. Um logotipo
enviado pelo painel tem dimensões arbitrárias e por isso não recebe
atributo nenhum — melhor sem do que errado. As classes CSS continuam
governando o tamanho renderizado nos dois casos.

Efeito medido na home: unsized-images sai da lista de diagnósticos e
uses-long-cache-ttl cai de 10 recursos para 2.

Adiciona scripts/perf/lighthouse.sh com a receita da medição, incluindo
o passo media:generate-variants — rodar a auditoria sem ele infla o LCP
da home em cerca de 2,5 s, porque as imagens originais são servidas em
tamanho cheio.

Fica de fora, para PR próprio: reencodar os arquivos de marca, que hoje
pesam 82 KB para renderizar em 41x40 px. Isso muda pixels no cabeçalho
de todas as páginas e exige regerar os 16 baselines visuais no ambiente
de paridade com o CI.

Nenhum gate de performance foi adicionado ao CI. A SPEC §14.1 enumera os
cinco jobs bloqueantes e a §22 define os gatilhos objetivos para
adicionar capacidade; nenhum gatilho aponta para isso, e um gate criado
agora nasceria vermelho.

Co-Authored-By: Claude noreply@anthropic.com
AI-Assisted: yes
AI-Tool: claude-code

Co-authored-by: manoel.neto <manoel.neto@creditas.com>
2026-08-10 03:04:22 -03:00
4001cd0e5d docs: adicionar runbook de atualização de conteúdo (MAN-106 AC4) (#33)
* docs: adicionar runbook de atualização de conteúdo (MAN-106 AC4)

A dona do negócio precisa editar textos, fotos, casos de portfólio,
depoimentos e dados de contato pelo painel admin sem depender de
engenharia. Documenta as regras que mais confundem (publicação não é
agendamento; home exige publicado + destaque, exceto depoimentos),
os campos editáveis por tela, requisitos de imagem/alt text e o que
não deve ser mexido sem apoio técnico.

Também esclarece o status do depoimentos.md: é material bruto
histórico que nunca foi lido por código (confirmado por busca no
repo), então recebe uma nota de status no topo do arquivo em vez de
ser movido ou apagado, já que ainda é citado por documentos técnicos
antigos como origem do conteúdo.

Co-Authored-By: Claude noreply@anthropic.com
AI-Assisted: yes
AI-Tool: claude-code

* docs: reforçar avisos sobre resumo institucional e publicação em Configurações

Ajusta o runbook de atualização de conteúdo: deixa explícito que o
resumo institucional pode ser salvo em branco sem erro (produzindo um
parágrafo vazio silencioso na página Sobre) e que a tela Configurações
não tem passo de publicação — toda alteração entra no ar assim que é
salva.

Co-Authored-By: Claude noreply@anthropic.com
AI-Assisted: yes
AI-Tool: claude-code

* docs: corrigir regra de imagem opcional para capa de caso e galeria

A regra geral "sem imagem, texto alternativo pode ficar em branco"
(seção Imagens) estava sendo lida como "a imagem em si é opcional" para
todo campo do painel. Isso é falso para a capa de um caso de portfólio
e para a imagem de um item de galeria: as colunas cover_image_path,
cover_image_alt (portfolio_cases) e path, alt_text (portfolio_images)
são NOT NULL no banco, mas o formulário do Filament nunca chama
->required() nesses uploads. Quem seguisse a doc e tentasse salvar sem
imagem passaria pela validação do formulário e só então bateria num
erro de banco não tratado, em vez da mensagem amigável de campo
obrigatório usada para título, resumo etc. Confirmado em
database/migrations/2026_07_28_230932_create_portfolio_cases_table.php,
database/migrations/2026_07_28_230933_create_portfolio_images_table.php
e app/Support/PublicImageUploadRules.php — os demais campos de imagem
do painel (logo, foto de depoimento, imagem da página Sobre, imagem de
SEO, capa de serviço) são de fato nullable no banco, então a ressalva
fica restrita a portfólio.

Co-Authored-By: Claude noreply@anthropic.com
AI-Assisted: yes
AI-Tool: claude-code

* docs: avisar que Excluir apaga registros para sempre, sem lixeira

O runbook só ensinava publicar/despublicar (preencher ou apagar
"Publicado em") como forma de mostrar/esconder conteúdo em Portfólio,
Depoimentos e Serviços, e nunca mencionava que cada linha dessas
tabelas também tem um botão "Excluir" (e ação de exclusão em massa)
bem ao lado do de editar. Nenhum model usa SoftDeletes (confirmado em
app/Models) e as tabelas de Portfólio, Depoimentos e Serviços usam
DeleteAction/DeleteBulkAction (app/Filament/Resources/{PortfolioCases,
Testimonials,Services}/Tables/*.php e ImagesRelationManager.php), ou
seja, um clique em Excluir apaga o registro para sempre, sem
possibilidade de recuperação — inclusive apagando em cascata a galeria
inteira de um caso de portfólio (cascadeOnDelete em
portfolio_images.portfolio_case_id). Quem seguisse só a instrução de
"apagar o Publicado em" não tinha como saber que existia esse outro
controle, irreversível, na mesma linha.

Co-Authored-By: Claude noreply@anthropic.com
AI-Assisted: yes
AI-Tool: claude-code

* docs: explicar como converter foto HEIC do iPhone para o formato aceito

A instrução para formato de imagem recusado dizia só "converta a
imagem antes de enviar", sem dizer como. HEIC é o formato padrão de
fotos do iPhone e está listado no próprio texto como um dos formatos
recusados — é exatamente o erro que uma pessoa não técnica enviando
foto do celular vai encontrar primeiro, e o guia não pressupõe em
nenhum outro lugar que ela saiba converter formato de imagem. Adiciona
duas instruções concretas e não técnicas: ajustar o iPhone para salvar
fotos novas em JPEG, e usar o WhatsApp para converter fotos que já
estão em HEIC.

Co-Authored-By: Claude noreply@anthropic.com
AI-Assisted: yes
AI-Tool: claude-code

* docs: avisar que o e-mail de Contato recebe os briefings do site

O runbook descrevia o campo E-mail de Configurações → Contato só como
algo que vira link clicável no rodapé e na página /contato. Não
mencionava que esse mesmo endereço é o destino de todo envio do
formulário de contato: ContactController::dispatchEmails() (app/Http/
Controllers/PublicSite/ContactController.php) chama
Mail::to($settings->email)->send(new ContactBriefing(...)) para cada
briefing enviado por um visitante. Alguém lendo só esta doc poderia
trocar esse valor por um e-mail apenas decorativo e parar
silenciosamente de receber notificações de leads, sem nenhum erro
visível no admin.

Co-Authored-By: Claude noreply@anthropic.com
AI-Assisted: yes
AI-Tool: claude-code

* docs: documentar a tela Usuários para quem realmente pode acessá-la

O runbook só citava "Usuários" na negativa ("se você não é
Administradora, peça a uma Administradora"), mas toda política de
autorização do painel (PortfolioCasePolicy, ServicePolicy,
TestimonialPolicy, SiteSettingPolicy, UserPolicy — todas checando
$user->isAdmin()) garante que quem consegue seguir qualquer outra
seção deste guia é, por construção, uma Administradora com acesso
total a Usuários. A doc nunca explicava os campos da tela (Nome,
E-mail, Papel, Ativo, Senha) nem como usá-la para dar acesso a alguém,
apesar de seções anteriores (Como entrar no painel) já dependerem de a
leitora saber fazer exatamente isso. Adiciona uma seção "Usuários"
ligando os campos ao comportamento já descrito em outras seções (Papel
Assistente remove todo acesso; Ativo desligado bloqueia login via
User::canAccessPanel()) e avisa sobre um risco real e não coberto:
como UserPolicy não impede autoedição, uma Administradora pode trocar
o próprio Papel ou desligar o próprio Ativo e ficar travada fora do
painel sem nenhuma forma de recuperação self-service. Também reescreve
o bullet de Usuários em "O que NÃO mexer" para não instruir a mesma
leitora a evitar uma tela que, pela própria lógica de acesso do
sistema, é dela.

Co-Authored-By: Claude noreply@anthropic.com
AI-Assisted: yes
AI-Tool: claude-code

---------

Co-authored-by: manoel.neto <manoel.neto@creditas.com>
2026-08-10 03:03:30 -03:00
f5dd28089c fix(deploy): remover --force inexistente de media:generate-variants (#32)
O serviço `migrate` do docker-compose.deploy.yml encadeia três comandos
artisan com `&&`, e o último é `php artisan media:generate-variants
--force`. Esse comando não declara nenhuma opção — sua assinatura é
apenas `media:generate-variants`. O artisan responde `The "--force"
option does not exist.` e sai com código 1.

Como todos os outros serviços dependem do migrate por
`condition: service_completed_successfully`, o one-shot falhando impede
`web`, `queue` e `scheduler` de subirem. A stack inteira fica fora do ar
e o Dokploy reporta `status=error` com `errorMessage: null`, sem nada no
log do CI que aponte a causa.

O deploy de staging falhou assim em quatro commits seguidos: 58f24a6,
949f6ae, e9b4534 e fc3d5c4. A opção entrou em 10a1f0d.

Reproduzido dentro da própria imagem de produção:

    docker run --rm --entrypoint sh amare-app:ci \
      -c 'php artisan media:generate-variants --force; echo "EXIT=$?"'
    The "--force" option does not exist.
    EXIT=1

Além de remover a opção, adiciona um teste que percorre todas as chamadas
`php artisan` do compose de deploy e verifica que cada comando existe e
que cada opção passada está definida — considerando também as opções
globais do console, como `--no-interaction`. O teste falha se `--force`
voltar.

Co-Authored-By: Claude noreply@anthropic.com
AI-Assisted: yes
AI-Tool: claude-code

Co-authored-by: manoel.neto <manoel.neto@creditas.com>
2026-08-10 02:55:41 -03:00
eed8240487 fix(contato): normalizar e-mail e telefone no formulário público de briefing (#31)
* fix(contato): normalizar e-mail e telefone no formulário público de briefing

SPEC.md §12.3 exige normalização de e-mail e telefone no formulário
público, mas o ContactBriefingRequest só validava, sem normalizar
(MAN-104).

E-mail: aplica trim + lowercase em prepareForValidation — evita
duplicatas triviais ("Maria@X.com" vs "maria@x.com") e mantém a
detecção de duplicidade por hash consistente.

Telefone: normaliza para o formato brasileiro legível
"(DD) 9XXXX-XXXX"/"(DD) XXXX-XXXX", extraído para o value object
App\Domain\Contact\BrazilianPhoneNumber por concentrar lógica de
decisão (contagem de dígitos, remoção de DDI 55) que merece teste
isolado. Optou-se pelo formato com máscara em vez de dígitos puros
porque o Mailable ContactBriefing e seus templates (html/text) apenas
imprimem o valor do campo "Telefone/WhatsApp" em uma tabela/lista,
sem link "tel:" nem formatação na view — "(11) 98888-7777" é o que
fica legível para quem recebe o briefing por e-mail, enquanto
"11988887777" é opaco de bater o olho.

Para não descartar informação que o destinatário precisa, o
normalizador só reformata quando a string é composta exclusivamente
por caracteres de telefone; anotações como "(WhatsApp)" ou "falar com
João" junto do número são preservadas como estão. Entradas que não
batem com 10/11 dígitos (após remover DDI) também são preservadas,
apenas com espaços internos colapsados.

Honeypot, rate limit e aceite de privacidade não foram tocados.

Cobertura: teste de unidade para o value object (formatos válidos,
DDI, anotações, formatos não reconhecidos) e teste de feature
provando que uma submissão com e-mail maiúsculo/padded e telefone
"sujo" chega normalizada nos Mailables ContactBriefing e
ContactBriefingConfirmation.

Co-Authored-By: Claude noreply@anthropic.com
AI-Assisted: yes
AI-Tool: claude-code

* fix(contato): evitar fabricar DDD/9º dígito em números não brasileiros

O normalizador aceitava qualquer string de 10 ou 11 dígitos como se
fosse um telefone brasileiro, sem checar se os dois primeiros dígitos
formam um DDD plausível (11-99, nunca com zero em nenhuma posição) ou
se um número de 11 dígitos tem o 9º dígito obrigatório do celular.
Isso fazia números estrangeiros como '2025551234' (EUA) virarem
'(20) 2555-1234' — um número brasileiro plausível, porém inventado,
que destrói silenciosamente o contato real na tabela do e-mail de
briefing. Um '+55' explícito sem DDD (ex.: '+55 98888-7777') também
era lido como DDD 55 em vez de número incompleto.

Passa a validar a forma do DDD e o 9º dígito do celular antes de
formatar, preservando o texto original quando a checagem falha —
mesmo comportamento já usado para números com contagem de dígitos
fora do padrão. Mantém DDD 55 (Rio Grande do Sul) funcionando
normalmente quando o DDD é digitado de fato.

Limitação residual conhecida e aceita: DDDs americanos que colidem
estruturalmente com um DDD brasileiro válido (ex. '2125551234', área
212 de Nova York, bate com DDD 21) continuam sendo formatados como
brasileiros — não há como distinguir os dois casos só pela forma dos
dígitos. Da mesma forma, um '55' sem o '+' explícito (ex.
'55988887777') é ambiguamente tratado como DDD 55 real, já que nada
no texto indica se é código de país ou área.

Co-Authored-By: Claude noreply@anthropic.com
AI-Assisted: yes
AI-Tool: claude-code

---------

Co-authored-by: manoel.neto <manoel.neto@creditas.com>
2026-08-10 02:48:55 -03:00
fc3d5c444f docs: atualizar estado da autenticação do gh no CLAUDE.md (#30)
O gh passou a autenticar como manoel-freitas, então gh pr create já
funciona neste repositório. Registra o estado atual e mantém a instrução
de verificar com gh auth status antes de assumir, já que a conta padrão
da máquina não enxerga o repositório.

Co-Authored-By: Claude noreply@anthropic.com
AI-Assisted: yes
AI-Tool: claude-code

Co-authored-by: manoel.neto <manoel.neto@creditas.com>
2026-08-10 02:33:22 -03:00
e9b4534df9 test: restaurar baselines visuais removidos por engano na reconciliação (#29)
O commit 58f24a6 ("chore: reconcile main deployment and agent docs")
apagou oito baselines de regressão visual — home, services, portfolio e
portfolio-detail, em desktop e mobile. O VisualRegressionTest continua
declarando os oito cenários, então as quatro telas mais importantes do
site ficaram sem proteção contra regressão visual.

Os arquivos voltam exatamente como estavam em 42b282c. Aquele commit e o
atual HEAD não têm nenhuma diferença que afete renderização: 58f24a6
tocou apenas AGENTS.md, docker-compose.deploy.yml, tasks.md e a própria
remoção dos snapshots. Nenhum Blade, CSS, token ou asset mudou, então os
baselines restaurados descrevem a renderização atual.

O job browser do CI, que roda em paridade com o ambiente de geração,
é a verificação desta mudança.

Co-Authored-By: Claude noreply@anthropic.com
AI-Assisted: yes
AI-Tool: claude-code

Co-authored-by: manoel.neto <manoel.neto@creditas.com>
2026-08-10 02:24:15 -03:00
949f6ae19f Docs/fix timezone drift (#27)
* docs: add CLAUDE.md with repository architecture guidance

Imports AGENTS.md as the primary contract and records the cross-file facts
that are not discoverable from any single file: the public-site request spine
(controller -> Application query -> DTO -> Blade), the layouts.public view
composer, what the architecture test actually enforces, the CI-parity
constraint on visual baselines, and the determinism trio behind them.

Co-Authored-By: Claude <noreply@anthropic.com>
AI-Assisted: yes
AI-Tool: claude-code
AI-Harness: creditas-agentic-harness
AI-Harness-Command: /init

* docs: corrigir timezone normativo para America/Fortaleza

SPEC.md e openspec/config.yaml declaravam APP_TIMEZONE=America/Sao_Paulo,
divergindo da implementação (config/app.php, .env.example), dos quatro jobs
de CI e de openspec/specs/visual-regression/spec.md, que fixa
America/Fortaleza e cita SPEC §13.5 como autoridade. As baselines visuais
foram geradas sob America/Fortaleza.

Cidade de atuação permanece São Paulo (capital) — é copy pública, não
timezone de runtime.

Co-Authored-By: Claude <noreply@anthropic.com>
AI-Assisted: yes
AI-Tool: claude-code
AI-Harness: creditas-agentic-harness
AI-Harness-Command: /init

* docs: corrigir referências mortas no AGENTS.md

- Design Context apontava para `.impeccable.md` na raiz, que não existe. O
  sistema de design vive em DESIGN.md/PRODUCT.md, tokens em
  resources/css/tokens.css, briefs por superfície em .impeccable/surfaces/ e
  a skill vendorizada em .github/skills/impeccable/SKILL.md.
- Domain docs afirmava CONTEXT.md na raiz. Não existe: o domínio está em
  SPEC.md §8 e PRODUCT.md, capacidades em openspec/specs/, e
  docs/adr/README.md é apenas índice das ADRs decididas em SPEC.md §21.
- Issue tracker: registra que o repo não traz .mcp.json, portanto o MCP do
  Linear precisa estar habilitado na sessão.

Co-Authored-By: Claude <noreply@anthropic.com>
AI-Assisted: yes
AI-Tool: claude-code
AI-Harness: creditas-agentic-harness
AI-Harness-Command: /init

* docs: registrar autenticação do remote no CLAUDE.md

O repo é do account `manoel-freitas`, mas a identidade SSH padrão da máquina é
outro account (`manoel-freitas-neto`) que não enxerga o repo — o push falha com
`Repository not found`, que parece repo inexistente e é problema de acesso. A
chave certa é `~/.ssh/id_github_pessoal`, já fixada em `core.sshCommand`.

Registra também que o `gh` autentica por token, não por chave SSH, então
`gh pr create` continua falhando mesmo com o push funcionando.

Co-Authored-By: Claude <noreply@anthropic.com>
AI-Assisted: yes
AI-Tool: claude-code
AI-Harness: creditas-agentic-harness
AI-Harness-Command: /init

---------

Co-authored-by: manoel.neto <manoel.neto@creditas.com>
Co-authored-by: Claude <noreply@anthropic.com>
2026-08-10 02:04:52 -03:00
58f24a6d5a chore: reconcile main deployment and agent docs
Squash merge of the validated main reconciliation changes.
2026-08-07 22:22:24 -03:00
4061662c4b chore: setup agent skills config (#25) 2026-08-07 21:48:48 -03:00
42b282c1f2 feat: adicionar cadência editorial à home (#23)
* feat: adicionar cadência editorial à home

* fix: reconcile pr23 ci with current main

* test: atualizar baselines visuais da home para cadência editorial
2026-08-07 21:40:35 -03:00
457845758c test: regenerar baselines visuais em ambiente CI-parity (#24)
* test: regenerar baselines visuais em ambiente CI-parity

* chore: bump league/commonmark para 2.9.0
2026-08-07 21:21:41 -03:00
0aee15e848 Merge pull request #22 from manoel-freitas/feat/public-site-motion
feat: animação editorial fluida no site público
2026-08-06 14:31:31 -03:00
b372eaea4f docs: add public motion visual evidence 2026-08-06 14:04:47 -03:00
7380998e8e feat: add fluid editorial motion to public site 2026-08-06 14:03:51 -03:00
4b440b7e5b Merge pull request #21 from manoel-freitas/fix/seed-resilient
fix(deploy): array command form + resilient seed
2026-08-06 11:27:48 -03:00
5949fad9b1 fix(deploy): array command form for dokploy + resilient seed
sh -c via folded scalar broke dokoy compose deploy; use array command.
Seed+media best-effort so web starts even if R2 seed errors.
2026-08-06 11:23:13 -03:00
d054d04ab9 Merge pull request #20 from manoel-freitas/fix/seed-resilient
fix(deploy): keep staging web up when content seed is best-effort
2026-08-06 11:16:20 -03:00
db4453b165 fix(deploy): keep staging web up when content seed is best-effort
Seed+media step made non-fatal so a transient R2 write issue
doesn't block web/queue/scheduler from starting. Seed output
preserved in dokploy deployment logs for diagnosis.
2026-08-06 11:11:45 -03:00
946939ba94 Merge pull request #19 from manoel-freitas/fix/seed-to-active-disk
fix(deploy): seed content on staging deploy
2026-08-06 11:00:37 -03:00
10a1f0dc7c fix(deploy): run seed + media:generate-variants after migrate
staging R2 was empty — migrate ran but no seeder, so ContentSeeder
never placed fixtures on the configured disk (r2) and media:generate-variants
had nothing for image paths. Staging deploy now seeds content.
2026-08-06 10:55:58 -03:00
c04790ee1f Merge pull request #18 from manoel-freitas/fix/seed-to-active-disk
fix: seed content images onto active filesystem disk
2026-08-06 10:21:14 -03:00
edf1c48050 fix: seed content images onto active filesystem disk
Preview/prod use FILESYSTEM_DISK=r2; seeder wrote only to public,
so media.hellomanoel.com URLs 404/403. Put fixtures on active disk.
2026-08-06 10:16:23 -03:00
3c711a74f3 Merge pull request #17 from manoel-freitas/fix/seed-fixtures-in-image
fix: ship seed image fixtures inside Docker image
2026-08-06 10:05:22 -03:00
88d63f349d fix: ship seed image fixtures inside Docker image
Move demo JPEGs from tests/fixtures (dockerignored) to
database/fixtures so ContentSeeder works in the container.
2026-08-06 10:03:26 -03:00
e0390ff333 Merge pull request #16 from manoel-freitas/feat/visual-placeholders
feat: visual placeholders, type scale, CTAs and about image
2026-08-06 09:54:52 -03:00
321f0cee11 fix: restore lazy loading and CI visual baselines
Drop test-wide eager override that broke MediaImageComponentTest.
Replace local Chromium baselines with CI FrankenPHP host captures.
2026-08-06 09:50:48 -03:00
1d23e16625 merge: origin/main into feat/visual-placeholders
Resolve conflicts keeping DESIGN.md type scale, main blade fallbacks,
and StableScreenshot visual helper. Regenerate browser baselines.
2026-08-06 09:42:37 -03:00
33788af172 feat: visual placeholders, type scale, CTAs and about image
Replace tiny JPEG stubs with per-slot Unsplash editorial placeholders,
align public type scale to DESIGN.md display/headline tokens, add
final-cta on all subpages, about_image_path on site settings, and harden
visual regression with solid-color VisualContentSeeder fixtures.
2026-08-06 09:38:13 -03:00
119711d6f0 chore: mark browser CI gate tasks complete in tasks.md 2026-08-06 09:09:08 -03:00
f09ea83071 fix: stabilize and diagnose browser CI gate (#15)
* fix: stabilize and diagnose browser CI gate

- Replace networkidle screenshot wait (5s client timeout, flaky with
  long-lived connections) with readyState + fonts-loaded wait and fixed
  settle in a StableScreenshot helper.
- Export standalone diff/expected/actual PNGs on visual mismatch so CI
  artifacts are directly viewable (vendor only writes an HTML diff view).
- Create .env in CI test jobs; without it Laravel's env bootstrap emits a
  file_get_contents warning on every test.
- Bump checkout/cache actions to v5 (Node 20 deprecation).
- Gitignore tests/Browser/Screenshots.

* docs: track browser CI gate task status in tasks.md
2026-08-06 08:59:03 -03:00
2fe262ab59 Hardening public blades: overflow, fallbacks, branded errors, JS robustness (#14)
* fix: prevent long strings from overflowing public layout

* fix: fall back to default hero copy and tighten hero line-height

* fix: skip blank testimonials and guard empty portfolio metadata

* feat: add branded 419, 429 and 503 error pages

* fix: reset submit state on bfcache restore, swap label while sending, trap mobile menu focus

* chore: track hardening task status

* fix: restore contact submit state after bfcache

* fix: allow contact links to wrap long unbroken strings
2026-08-06 00:22:53 -03:00