## Summary
- Remove `cta-route="contact"` dos cards de modalidade em `/servicos` para reutilizar `PackageContactLink` (WhatsApp / briefing), alinhado à home.
- Band "Conversar com a Amare" aponta para `/briefing` em vez de `/contato` (jornada de parceiros).
- Feature test de regressão em `ServicesPageCtaTest`.
## Test plan
- [x] pest ServicesPageCtaTest + home package CTA
- [ ] CI verde
- [ ] Com whatsapp_number no CMS: CTA do card em /servicos abre wa.me
Reviewed-on: #2
Co-authored-by: manoel freitas <manoel.josefneto@gmail.com>
Co-committed-by: manoel freitas <manoel.josefneto@gmail.com>
* 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>
* 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>
* 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>
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>
* 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>
* 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>
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>
* 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>
* feat: adicionar cadência editorial à home
* fix: reconcile pr23 ci with current main
* test: atualizar baselines visuais da home para cadência editorial
* 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
* chore: add husky pre-commit and pre-push hooks
* feat: frontend audit — formulário de contato, a11y, SEO, performance e conteúdo
* test: regenerate visual baselines from CI environment
* feat: add Dossiê vivo motion to the public site
Introduce a focal Home opening, chapter index, and restrained continuity
reveals while keeping reduced-motion and visual baselines intact.
Co-authored-by: Cursor <cursoragent@cursor.com>
* test: refresh visual baselines from CI Ubuntu screenshots
Co-authored-by: Cursor <cursoragent@cursor.com>
---------
Co-authored-by: Cursor <cursoragent@cursor.com>
Canonical upsert for five authorized couples so staging/prod
can load depoimentos without DatabaseSeeder/ContentSeeder.
Co-authored-by: Cursor <cursoragent@cursor.com>
Avoid browser PUT to R2 (CORS) by defaulting Filament/Livewire temp disk to local, and document Dokploy tinker + R2 CORS gotchas.
Co-authored-by: Cursor <cursoragent@cursor.com>
Publish immutable FrankenPHP images to GHCR, auto-deploy staging after CI,
and promote the same digest to production with smoke, backup, and rollback docs.
Co-authored-by: Cursor <cursoragent@cursor.com>
* feat: recreate public frontend with Heritage Editorial identity
Replace placeholder visual system with EB Garamond/olive tokens, brand assets, editorial home narrative, São Paulo settings, real testimonials, and regenerated visual baselines.
Co-authored-by: Cursor <cursoragent@cursor.com>
* docs: archive recreate-public-frontend and sync Heritage Editorial specs
Merge delta requirements into main OpenSpec capabilities and move the completed change into the dated archive.
Co-authored-by: Cursor <cursoragent@cursor.com>
---------
Co-authored-by: Cursor <cursoragent@cursor.com>
Add production provider deps/config so transactional email and CMS
media can use Resend and a dedicated r2 disk with custom-domain URLs.
Co-authored-by: Cursor <cursoragent@cursor.com>
* feat: ship public site with SEO and visuals
Publish CMS content on public routes with responsive media,
accessibility checks, and deterministic visual baselines.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix: make browser visual CI deterministic for media
Mount host public storage into FrankenPHP so seeded fixtures are served,
and replace PNG-as-JPG fixtures with real JPEGs so Chromium can render them.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix: serve public media on same-origin storage paths
Pest Browser hosts on 127.0.0.1:port while Storage::url used
http://localhost, so screenshots captured broken images. Use relative
/storage URLs for media and absolutize only OG tags via url().
Co-authored-by: Cursor <cursoragent@cursor.com>
* test: refresh visual baselines from CI Ubuntu screenshots
Media now loads on same-origin /storage paths, so baselines must
capture the rendered fixtures. Use full-page snapshots from the CI
runner to keep Pest's exact snapshot match stable across environments.
Co-authored-by: Cursor <cursoragent@cursor.com>
---------
Co-authored-by: Cursor <cursoragent@cursor.com>
Implementa gestão Filament de configurações, serviços, portfólio e depoimentos com policies admin-only, upload validado e seed determinístico.
Co-authored-by: Cursor <cursoragent@cursor.com>