Compare commits

..

27 Commits

Author SHA1 Message Date
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
166 changed files with 7408 additions and 1237 deletions

View File

@@ -8,7 +8,7 @@ APP_URL=http://localhost
APP_LOCALE=pt_BR
APP_FALLBACK_LOCALE=pt_BR
APP_FAKER_LOCALE=pt_BR
APP_TIMEZONE=America/Fortaleza
APP_TIMEZONE=America/Sao_Paulo
# Freeze application clock outside production (visual regression / deterministic seeds).
# Example: APP_FROZEN_NOW=2026-03-15T12:00:00-03:00

View File

@@ -14,7 +14,7 @@ env:
APP_KEY: base64:NXm/6jIyFcDGHoMKGc5QZuSaq0dRZFYPg1Isuy1fNvE=
APP_LOCALE: pt_BR
APP_FALLBACK_LOCALE: pt_BR
APP_TIMEZONE: America/Fortaleza
APP_TIMEZONE: America/Sao_Paulo
BCRYPT_ROUNDS: 4
CACHE_STORE: database
DB_CONNECTION: pgsql
@@ -51,10 +51,36 @@ jobs:
- run: composer pint:check
- run: composer phpstan
- run: composer audit
# audit-level=high: package.json has no runtime `dependencies` today (npm
# itself reports prod:1, 0 vulnerabilities), so this is currently a
# vacuous forward guard rather than an active protection. `high` is
# chosen deliberately over `low`/`moderate` so that once a real runtime
# JS dependency is added, the gate flags exploitable issues without
# becoming noisy on every transitive dev-only advisory.
- run: npm audit --omit=dev --audit-level=high
unit:
name: unit
runs-on: ubuntu-latest
# Postgres is required here (not just in `feature`) because the
# Domain/Application coverage gate below runs the Feature suite too: the
# App\Application\Queries\Marketing\* classes are only exercised through
# Feature (HTTP) tests today, so a Unit-only coverage run would undercount
# them well under the 80% threshold.
services:
postgres:
image: postgres:17
env:
POSTGRES_DB: amare_test
POSTGRES_USER: amare
POSTGRES_PASSWORD: secret
ports:
- 5432:5432
options: >-
--health-cmd "pg_isready -U amare -d amare_test"
--health-interval 5s
--health-timeout 5s
--health-retries 10
steps:
- uses: actions/checkout@v5
- run: cp .env.example .env
@@ -63,7 +89,7 @@ jobs:
with:
php-version: "8.4"
extensions: dom, curl, libxml, mbstring, zip, pcntl, pdo, pdo_pgsql, bcmath, intl, sodium, gd
coverage: none
coverage: pcov
- uses: actions/cache@v5
with:
@@ -71,10 +97,24 @@ jobs:
key: composer-${{ runner.os }}-${{ hashFiles('**/composer.lock') }}
restore-keys: composer-${{ runner.os }}-
- uses: actions/cache@v5
with:
path: ~/.npm
key: npm-${{ runner.os }}-${{ hashFiles('**/package-lock.json') }}
restore-keys: npm-${{ runner.os }}-
- run: composer install --no-interaction --prefer-dist
- run: npm ci
- run: npm run build
- run: composer test:unit
- run: php artisan migrate --force
# Domain/Application coverage gate (SPEC.md L2351, 80% minimum). Scoped
# via phpunit.coverage.xml rather than editing the project-wide
# phpunit.xml <source> block, which stays covering all of app/ for
# every other test/coverage invocation. app/Domain currently holds only
# the DomainModule placeholder (zero executable lines), so in practice
# this gates app/Application until Domain gains real logic.
- run: composer test:coverage
feature:
name: feature
@@ -165,15 +205,12 @@ jobs:
- run: npm run build
- run: npx playwright install chromium --with-deps
- run: php artisan migrate --force
- run: php artisan db:seed --class=VisualContentSeeder --force
- run: php artisan storage:link
- name: Build application image
run: docker build -t amare-app:ci .
- name: Run browser tests against FrankenPHP container
env:
APP_FROZEN_NOW: "2026-03-15T12:00:00-03:00"
run: |
docker run -d --name amare-web \
-e APP_ENV=testing \
@@ -181,8 +218,7 @@ jobs:
-e APP_URL=http://127.0.0.1:8000 \
-e APP_LOCALE=pt_BR \
-e APP_FALLBACK_LOCALE=pt_BR \
-e APP_TIMEZONE=America/Fortaleza \
-e APP_FROZEN_NOW="${APP_FROZEN_NOW}" \
-e APP_TIMEZONE=America/Sao_Paulo \
-e DB_CONNECTION=pgsql \
-e DB_HOST=host.docker.internal \
-e DB_PORT=5432 \
@@ -213,8 +249,6 @@ jobs:
mkdir -p artifacts/browser
docker logs amare-web > artifacts/browser/container.log 2>&1 || true
cp -R storage/logs artifacts/browser/app-logs 2>/dev/null || true
cp -R tests/Browser/Screenshots artifacts/browser/screenshots 2>/dev/null || true
cp -R tests/.pest artifacts/browser/pest 2>/dev/null || true
- name: Upload browser failure artifacts
if: failure()

1
.gitignore vendored
View File

@@ -18,7 +18,6 @@
/public/fonts-manifest.dev.json
/public/hot
/public/storage
/tests/Browser/Screenshots
/storage/*.key
/storage/pail
/vendor

View File

@@ -24,7 +24,7 @@ try {
new PDO($dsn, $config["DB_USERNAME"], $config["DB_PASSWORD"], [PDO::ATTR_TIMEOUT => 3]);
} catch (PDOException $e) {
fwrite(STDERR, "\033[31mPostgreSQL is unreachable on {$config["DB_HOST"]}:{$config["DB_PORT"]} (db: {$config["DB_DATABASE"]}).\033[0m\n");
fwrite(STDERR, "Start it with: docker compose up -d\n");
fwrite(STDERR, "Start it with: docker compose up -d postgres\n");
fwrite(STDERR, "Then retry the push.\n");
exit(1);
}

View File

@@ -7,11 +7,12 @@ This is a Laravel 13 application for an event-planning consultancy. Application
## Build, Test, and Development Commands
- `composer setup` installs PHP and npm dependencies, creates `.env`, migrates, and builds assets.
- `docker compose up -d` starts the local PostgreSQL service.
- `docker compose up -d postgres` starts the local PostgreSQL service. `docker compose up -d` (no service name) also builds and starts the `app` service — a local FrankenPHP container for parity with staging/production, see README.md.
- `composer dev` runs Laravel, the queue listener, logs, and Vite together.
- `npm run build` creates the production frontend bundle.
- `composer quality` runs formatting checks, PHPStan level 5, dependency audit, and every test suite.
- `composer quality` runs formatting checks, PHPStan level 5, PHP + npm dependency audits, and every test suite.
- `composer test:unit`, `composer test:feature`, or `composer test:browser` run focused suites.
- `composer test:coverage` runs the Domain/Application coverage gate (80% minimum, scoped via `phpunit.coverage.xml`) used by CI's `unit` job. Requires a coverage driver (`pcov` or `xdebug`); fails with "No code coverage driver available" without one — that's an environment gap, not a broken repo.
Feature and browser tests require the `amare_test` PostgreSQL database configured in `phpunit.xml`.
@@ -24,7 +25,7 @@ Always work in a git worktree created from the `main` ref — never modify `main
Hooks live in `.husky/` and auto-install on any plain `npm install` via the `prepare` script. Note `composer setup` runs `npm install --ignore-scripts`, which skips hook installation — after setup, run `npm install` once (or `npx husky`) to activate hooks.
- `pre-commit`: runs `composer pint:check` and `composer phpstan`.
- `pre-push`: gates on the `amare_test` database (settings parsed from `phpunit.xml`), blocks the push with a `docker compose up -d` hint when Postgres is unreachable, then runs `composer test:unit` and `composer test:feature`. Browser tests are CI-only (FrankenPHP container).
- `pre-push`: gates on the `amare_test` database (settings parsed from `phpunit.xml`), blocks the push with a `docker compose up -d postgres` hint when Postgres is unreachable, then runs `composer test:unit` and `composer test:feature`. Browser tests are CI-only (FrankenPHP container).
## Coding Style & Naming Conventions
@@ -41,3 +42,23 @@ History follows Conventional Commit-style subjects, for example `feat: Fase 0
## Security & Configuration
Copy `.env.example`; never commit secrets or production credentials. Development seed credentials are local-only. Validate uploads and authorization through Laravel policies, and run `composer security-audit` after dependency changes.
## Design Context
Amare: refined, humane, precise — Heritage Editorial. Trust-first, both private + corporate audiences. Never generic wedding decor (hearts/gold/script) or AI-slop. Real proof only.
The design system lives in `DESIGN.md` (palette, typography, layout, do's and don'ts) and positioning in `PRODUCT.md`; tokens are implemented in `resources/css/tokens.css` and asserted by `tests/Feature/PublicSite/HeritageEditorialTokensTest.php`. Per-surface briefs live in `.impeccable/surfaces/`. The Impeccable skill itself is vendored at `.github/skills/impeccable/SKILL.md` — its setup step reads `PRODUCT.md`, `DESIGN.md`, and the matching surface brief.
## Agent skills
### Issue tracker
Issues live in Linear, driven through the Linear MCP tools. See `docs/agents/issue-tracker.md` for workspace, team, and tool conventions. The repo ships no `.mcp.json`, so the Linear MCP has to be enabled for the session before those tools exist — if it isn't, report that instead of silently falling back to another tracker.
### Triage labels
Default vocabulary: `needs-triage`, `needs-info`, `ready-for-agent`, `ready-for-human`, `wontfix`. See `docs/agents/triage-labels.md`.
### Domain docs
Single-context repo. There is no `CONTEXT.md` — the domain is documented in `SPEC.md` (§8 is the domain model and database schema) and `PRODUCT.md`, with current capabilities described per-capability under `openspec/specs/`. `docs/adr/README.md` is an index only: ADR-001 through ADR-010 are decided in `SPEC.md` §21, and there are no standalone ADR files. `docs/agents/domain.md` describes the generic `CONTEXT.md`/`CONTEXT-MAP.md` layout that the engineering skills look for and instructs them to proceed silently when it's absent, which is the case here.

72
CLAUDE.md Normal file
View File

@@ -0,0 +1,72 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
@AGENTS.md
If the import above did not load, read `AGENTS.md` at the repo root now — it is the primary contract.
`AGENTS.md` (imported above) is the primary contract: structure, commands, style, testing, husky hooks, commit/PR rules. This file records the cross-file architecture and environment facts that are not obvious from any single file.
## Non-negotiables (repeated from AGENTS.md because breaking them is expensive)
- Never modify or commit from the primary working tree on `main`. Create a worktree per branch: `git worktree add -b <branch> <path> main`.
- Every project-owned PHP file starts with `declare(strict_types=1);` immediately after `<?php`.
- Browser tests are CI-only (they run against a FrankenPHP container built by `docker build`, not `artisan serve`).
## Environment note
PHP and Composer are **not on PATH** in this environment, and `vendor/` and `node_modules/` are absent. Every `composer …` / `php artisan …` command in `AGENTS.md` and `README.md` assumes a PHP 8.4+ runtime with Composer 2 installed. Verify the toolchain before promising a command ran.
## Git remote auth — two GitHub accounts
`origin` is `git@github.com:manoel-freitas/amore-site.git`, owned by the **`manoel-freitas`** account. The machine's default SSH identity is a different account (`manoel-freitas-neto`) that cannot see this repo, so pushes fail with `ERROR: Repository not found.` — an access error that reads like a missing repo.
- Correct key: `~/.ssh/id_github_pessoal`. Verify with `ssh -i ~/.ssh/id_github_pessoal -o IdentitiesOnly=yes -T git@github.com` → should greet `Hi manoel-freitas!`.
- The repo has `core.sshCommand = ssh -i ~/.ssh/id_github_pessoal -o IdentitiesOnly=yes` set locally, so plain `git push` works. If that config is lost, restore it instead of editing the remote URL.
- **`gh` authenticates separately**, by token rather than SSH key. As of 2026-08-10 it is logged in as `manoel-freitas`, so `gh pr create` / `gh repo view` work. Confirm with `gh auth status` before assuming: if it reports `manoel-freitas-neto`, that account cannot see this repo and every `gh` call fails on it. Recovering needs an interactive `gh auth login` (or `gh auth switch` with both accounts added), so ask the user to run it.
## Request spine for the public site
Adding or changing a public page follows one path — controllers never query models directly:
```
routes/web.php
→ App\Http\Controllers\PublicSite\*Controller (thin; injects a query object)
→ App\Application\Queries\Marketing\* (invokable, final; owns all Eloquent access)
→ App\Application\Data\* (DTO: HomeContent, PageMeta)
→ resources/views/pages/*.blade.php
```
`HomeController` + `GetHomeContent` together show the shape. Page-level SEO is built with `PageMeta::forPage(canonical:, settings:, jsonLd:)`.
`AppServiceProvider::boot()` registers a **View composer on `layouts.public`** that auto-injects `siteSettings` and `pageMeta` when the view didn't supply them — new pages do not have to pass them manually.
Site-wide content is a singleton row reached via `SiteSetting::instance()`. Publication state comes from the `HasPublication` concern (`->published()` scope).
## Architecture boundary — what is actually enforced
`tests/Architecture/DomainBoundariesTest.php` enforces only:
- `App\Domain` uses strict types and never `dd`/`dump`/`die`.
- `App\Domain` never depends on `App\Filament` or `App\Livewire`.
`App\Application` is **not** covered by that rule. `app/Domain/` currently holds a single placeholder (`DomainModule.php`); business reads live in `app/Application/Queries`. Extend the arch test when you add a boundary.
## Deterministic test support
`APP_FROZEN_NOW` configures `CarbonImmutable::setTestNow()` through `AppServiceProvider::freezeClockWhenConfigured()` outside production. `VisualContentSeeder` provides deterministic image/content fixtures for tests that explicitly need them. Neither setting is a browser-CI global default.
## Other things that bite
- **Livewire/Filament temp uploads are pinned to the `local` disk** when `FILESYSTEM_DISK=r2`, because the S3 driver would make the browser PUT straight to R2 and hit CORS. Final media still lands on `r2` via `App\Support\PublicImageUploadRules`. Set `LIVEWIRE_TEMPORARY_FILE_UPLOAD_DISK` explicitly to override.
- **Contact form is rate limited**: named limiter `contact-briefing`, 5/min per IP, registered in `AppServiceProvider` and applied in `routes/web.php`.
- **Filament 5 nested resource layout**: resources are split into `app/Filament/Resources/<Resource>/{Pages,Schemas,Tables,RelationManagers}` rather than a flat resource class. Follow the existing shape in `Resources/PortfolioCases/`.
- **Everything user-facing is pt-BR**: routes are `/servicos`, `/portfolio`, `/portfolio/{slug}`, `/sobre`, `/privacidade`, `/contato`. `APP_LOCALE=pt_BR`, `APP_TIMEZONE=America/Sao_Paulo` (`config/app.php:68`).
- **Design tokens** live in `resources/css/tokens.css` (Heritage Editorial; see `DESIGN.md`). `tests/Feature/PublicSite/HeritageEditorialTokensTest.php` reads that file and asserts the exact hex values, `EB Garamond`, zero border radii, `--amare-container-max: 1120px`, and the *absence* of shadow tokens — so any token edit is a deliberate test change too. Motion lives in `resources/js/motion.js` and is asserted by `tests/Feature/PublicSite/MotionMarkupTest.php` + `tests/Browser/MotionTest.php`.
## Navigating the normative docs
- `SPEC.md` is the product source of truth and is ~2600 lines. **Never read it whole**`grep -n '^## ' SPEC.md` and read the numbered section you need (e.g. 7 functional requirements, 8 domain model/DB, 9 technical architecture, 13 test strategy, 15 FrankenPHP deploy).
- `openspec/` is the channel for planned change: `openspec/specs/<capability>/spec.md` for current capabilities, `openspec/changes/<change>/{proposal,design,tasks}.md` for in-flight work. `openspec/config.yaml` holds the precedence rule (product owner > `SPEC.md` > ADRs > tests > conventions) and repo-wide constraints (YAGNI, money as BIGINT centavos, no generic repositories/BaseService).
- `PRODUCT.md` for positioning, `DESIGN.md` for the design system, `docs/conventions/php-strict-types.md`, `docs/deployment/dokploy.md` for the deploy runbook.

View File

@@ -23,6 +23,10 @@ RUN npm ci
COPY vite.config.js ./
COPY resources ./resources
COPY public ./public
# resources/css/filament/admin/theme.css imports Filament's own uncompiled CSS,
# so the Vite build needs those files present. Only Filament's subtree is copied
# rather than all of vendor/, to keep this stage's context small.
COPY --from=composer /app/vendor/filament ./vendor/filament
RUN npm run build
FROM dunglas/frankenphp:1-php${PHP_VERSION}-bookworm AS runtime

View File

@@ -58,7 +58,7 @@ Site público premium e operação interna formam um fluxo único. A mesma plata
## Evidence on Hand
- `SPEC.md` define escopo, personas, jornadas, métricas, requisitos e decisões do MVP.
- Código atual contém site público, CMS, estrutura de acessibilidade, testes visuais e conteúdo demonstrativo.
- Código atual contém site público, CMS, estrutura de acessibilidade, testes browser e conteúdo demonstrativo.
- Textos, fotografias, portfólio, depoimentos, contatos e perfis sociais presentes nos seeders são dados fictícios de desenvolvimento e não constituem prova comercial.
- Ainda não há logo, fotografia proprietária, depoimentos autorizados, cases reais, imprensa, credenciais, CNPJ ou texto jurídico definitivo confirmados no repositório. Trabalho futuro não deve inventá-los.
@@ -68,7 +68,7 @@ Site público premium e operação interna formam um fluxo único. A mesma plata
2. **Uma informação, um fluxo:** conduzir o dado do briefing ao evento sem redigitação ou planilhas paralelas.
3. **Adoção sem ruptura:** organizar o trabalho existente da equipe sem impor processo desnecessariamente complexo.
4. **Confiança sustentada por fatos:** usar somente conteúdo, resultados e provas reais e autorizados.
5. **Qualidade verificável:** tratar acessibilidade, desempenho, testes visuais e jornadas críticas como critérios de entrega.
5. **Qualidade verificável:** tratar acessibilidade, desempenho, motion e jornadas críticas como critérios de entrega.
## Accessibility & Inclusion

View File

@@ -4,7 +4,7 @@ Aplicação Laravel 13 para assessoria de eventos (Fase 0 — Fundação).
## Requisitos
- PHP 8.5+ com extensões `pdo_pgsql`, `intl`, `mbstring`, `zip`, `sodium`
- PHP 8.4 com extensões `pdo_pgsql`, `intl`, `mbstring`, `zip`, `sodium` (canônico do `Dockerfile` e do CI; `composer.json` aceita `^8.3`)
- Composer 2.x
- Node.js 22+ e npm
- Docker e Docker Compose
@@ -18,10 +18,10 @@ cp .env.example .env
php artisan key:generate
```
2. Suba o PostgreSQL:
2. Suba o PostgreSQL (requer o `.env` do passo 1 — o serviço `app` referencia esse arquivo via `env_file`, e o Compose valida todo o `docker-compose.yml` mesmo ao subir só o `postgres`):
```bash
docker compose up -d
docker compose up -d postgres
```
3. Instale dependências e rode migrations:
@@ -49,12 +49,24 @@ Painel interno: `/admin`
Healthcheck: `GET /up`
## Paridade local com produção (FrankenPHP via Docker Compose)
Além do fluxo host-side acima, `docker-compose.yml` tem um serviço `app` que builda a mesma imagem FrankenPHP usada em staging/produção (`Dockerfile`), útil para testar o comportamento real do container antes do deploy:
```bash
docker compose up -d # sobe postgres + app
php artisan migrate # rode migrations (o entrypoint do container não migra sozinho)
curl localhost:8000/up
```
O serviço `app` depende de `postgres` estar saudável (`depends_on: condition: service_healthy`) e sobrescreve `DB_HOST`/`DB_PORT` do `.env` para apontar para o serviço `postgres` pelo nome (o padrão `127.0.0.1` do `.env` só funciona para processos rodando no host). Não há job de CI dedicado a este smoke — o job `container` do CI já builda e healthcheca a mesma imagem.
## Variáveis principais
| Variável | Valor local |
|---|---|
| `APP_LOCALE` | `pt_BR` |
| `APP_TIMEZONE` | `America/Fortaleza` |
| `APP_TIMEZONE` | `America/Sao_Paulo` |
| `DB_CONNECTION` | `pgsql` |
| `SESSION_DRIVER` | `database` |
| `CACHE_STORE` | `database` |
@@ -63,7 +75,7 @@ Healthcheck: `GET /up`
## Comandos de qualidade
```bash
composer quality # Pint + PHPStan + audit + testes
composer quality # Pint + PHPStan + audit (composer + npm) + testes
composer test:unit # Unit + Architecture
composer test:feature # Feature + Livewire + Filament
composer test:browser # E2E browser
@@ -125,6 +137,7 @@ Após `php artisan db:seed`:
- [SPEC.md](SPEC.md) — especificação do produto
- [docs/adr/](docs/adr/) — ADRs aceitas
- [docs/conventions/php-strict-types.md](docs/conventions/php-strict-types.md) — convenção de strict types
- [docs/operations/atualizacao-de-conteudo.md](docs/operations/atualizacao-de-conteudo.md) — runbook de atualização de conteúdo do site pelo painel admin
- [docs/deployment/dokploy.md](docs/deployment/dokploy.md) — deploy staging/produção no Dokploy + GHCR
## Deploy (Dokploy)

173
SPEC.md
View File

@@ -20,10 +20,10 @@
| Princípio principal | YAGNI — implementar somente o necessário para validar o produto |
| Arquitetura | Monólito modular Laravel |
| Área interna | Filament |
| Área pública | Livewire + Blade |
| Área pública | Blade + JS vanilla progressivo (Livewire é dependência do Filament, não usada no site público — ver ADR-015) |
| Servidor de aplicação | FrankenPHP em modo regular |
| Banco de dados | PostgreSQL |
| Testes | Pest, Pest Browser/Playwright e testes visuais |
| Testes | Pest e Pest Browser/Playwright |
### 0.1 Vocabulário normativo
@@ -80,7 +80,6 @@ O agente:
- NÃO DEVE colocar regras financeiras diretamente em views, Resources, Models observers ou callbacks de formulário.
- NÃO DEVE usar `float` para dinheiro.
- NÃO DEVE persistir status derivados que possam ser calculados corretamente a partir dos dados fonte.
- NÃO DEVE atualizar snapshots visuais apenas para fazer o CI passar sem revisar o diff.
### 1.2 Entrega incremental
@@ -135,7 +134,6 @@ Uma assessora adotará o sistema quando ele:
| Operação | Tarefas vencidas sem responsável | `0` |
| Financeiro | Itens de orçamento sem valor ou status | Menos de 5% por evento ativo |
| Qualidade | Jornadas E2E críticas passando | 100% antes de deploy |
| Visual | Snapshots aprovados | 100% |
| Confiabilidade | Erros não tratados | Alerta imediato e tendência decrescente |
---
@@ -194,11 +192,24 @@ Não instalar sistema de permissões granular no MVP.
## 4. Escopo
> **Recorte vigente do lançamento (ADR-016).** O escopo aprovado para o lançamento de 31/08/2026 é o **site institucional**: Fases 0 e 1. As Fases 2 a 5 — CRM de leads, conversão de lead em evento, eventos, tarefas, fornecedores, orçamento, pagamentos manuais, documentos, dashboard orientado a exceções e auditoria — permanecem especificadas neste documento mas ficam **adiadas**, sem data.
>
> A §4.1 abaixo descreve o produto completo, não o recorte do lançamento. Os itens marcados como adiados estão fora do que se constrói agora. Ver §18 para a divisão por fase e §23 para as condições de conclusão de cada recorte.
### 4.1 Incluído no MVP
No recorte do lançamento (Fases 01):
- site público;
- CMS interno do site;
- formulário de briefing;
- usuários internos e papéis simples;
- SEO básico;
- acessibilidade automatizada;
- CI/CD e deploy em contêiner com FrankenPHP.
Adiados para depois do lançamento (Fases 25, ver ADR-016):
- CRM de leads;
- conversão de lead em evento;
- cadastro e visão consolidada de eventos;
@@ -208,12 +219,10 @@ Não instalar sistema de permissões granular no MVP.
- pagamentos inseridos manualmente;
- documentos vinculados a leads e eventos;
- dashboard orientado a exceções;
- usuários internos e papéis simples;
- notificações internas e por e-mail para novos leads;
- auditoria de ações críticas;
- SEO básico;
- acessibilidade e testes visuais;
- CI/CD e deploy em contêiner com FrankenPHP.
- auditoria de ações críticas.
Adiado **não** é o mesmo que fora do MVP: os itens acima seguem especificados neste documento e continuam sendo o produto pretendido. A §4.2 lista o que **NÃO DEVE** ser implementado em nenhum momento.
### 4.2 Fora do MVP
@@ -315,8 +324,8 @@ Administração
- Dashboard: `Filament Page` customizada com widgets orientados a exceção.
- Detalhe do evento: página customizada do Resource com resumo operacional.
- Briefing público: componente Livewire próprio.
- Home: Blade/Livewire com componentes de design reutilizáveis.
- Briefing público: Blade + Controller (`POST /contato`), ver §11.2.
- Home: Blade com componentes de design reutilizáveis.
### 5.4 Decisões de UX YAGNI
@@ -1553,7 +1562,7 @@ Constraints de banco devem proteger:
| Runtime | PHP com versão minor fixada no Docker |
| Framework | Laravel 13 |
| Admin | Filament 5 |
| UI pública | Livewire 4 + Blade + Alpine + Tailwind |
| UI pública | Blade + Tailwind + JS vanilla progressivo (sem framework reativo; Livewire 4 confinado ao Filament — ver ADR-015 e §22) |
| Banco | PostgreSQL |
| Servidor | FrankenPHP + Caddy |
| Assets | Vite |
@@ -1774,21 +1783,21 @@ Usar componentes/Widgets menores e testáveis.
---
## 11. Livewire e site público
## 11. Site público e interatividade
### 11.1 Componentes sugeridos
### 11.1 Estado atual e componentes candidatos
- `ContactBriefingForm`;
- `FeaturedPortfolioCases` se houver necessidade de consulta dinâmica;
- `PublishedServices` se houver necessidade de consulta dinâmica.
O site público **não usa Livewire nem Alpine hoje**. As páginas são Blade renderizado no servidor mais JavaScript vanilla progressivo (`resources/js/app.js` e `resources/js/motion.js`). O `layouts.public` carrega apenas `@vite(['resources/css/app.css', 'resources/js/app.js'])` — nenhum `@livewireScripts`. O pacote `livewire/livewire` existe no projeto apenas como dependência transitiva de `filament/support` e opera somente dentro do painel `/admin`.
Não transformar todas as seções estáticas em componentes Livewire. Usar Blade quando não houver estado ou interação.
Se surgir a necessidade de consulta dinâmica, os candidatos naturais a Livewire seriam `FeaturedPortfolioCases` e `PublishedServices`. Essa adoção está condicionada ao gatilho registrado em §22.
Não transformar seções estáticas em componentes Livewire. Usar Blade quando não houver estado ou interação.
### 11.2 Formulário de briefing
> **Estado atual (Fases 01):** o formulário é implementado em Blade + Controller (`POST /contato`, `ContactBriefingRequest`), conforme WEB-05. Se a Fase 2 mantiver Blade + Controller, os requisitos abaixo valem para o formulário e seus testes independentemente da tecnologia; a criação de Lead segue para a Fase 2.
> **Estado atual:** o formulário é implementado em Blade + Controller (`POST /contato`, `ContactBriefingRequest`), conforme WEB-05, e essa é a abordagem aceita — não um estágio provisório. Os requisitos abaixo valem independentemente da tecnologia; a criação de Lead segue para a Fase 2.
O componente deve:
O formulário deve:
- ter estado tipado ou Form Object quando útil;
- validar no servidor;
@@ -1797,15 +1806,16 @@ O componente deve:
- preservar acessibilidade;
- limpar dados após sucesso;
- evitar exposição de exceção;
- suportar teste Livewire sem browser;
- suportar teste de submissão sem navegador (feature test);
- suportar jornada E2E em navegador real.
### 11.3 JavaScript
- usar Alpine apenas para interações pequenas;
- manter o site público em JS vanilla progressivo;
- não introduzir framework SPA;
- não usar dependência JS quando CSS/HTML/Livewire resolverem;
- toda interação crítica deve funcionar sem estado global complexo.
- não usar dependência JS quando CSS e HTML resolverem;
- toda interação crítica deve funcionar sem estado global complexo;
- Alpine só entra junto com Livewire, se o gatilho de §22 disparar.
---
@@ -1889,7 +1899,7 @@ A pirâmide deve possuir:
1. muitos testes unitários rápidos;
2. testes feature/integration suficientes para Laravel, PostgreSQL, Livewire e Filament;
3. poucos testes E2E cobrindo jornadas críticas;
4. testes visuais determinísticos nas telas mais importantes.
4. testes E2E, de acessibilidade, motion e smoke nas jornadas públicas mais importantes.
A distribuição é por intenção, não por percentual rígido.
@@ -1971,40 +1981,7 @@ Em falha, CI deve publicar:
- logs do browser;
- HTML report quando disponível.
### 13.5 Regressão visual
Usar `assertScreenshotMatches()` ou API equivalente do Pest Browser.
Snapshots obrigatórios:
| Tela | Desktop | Mobile |
|---|---:|---:|
| Home | 1440×1000 | 390×844 |
| Serviços | 1440×1000 | 390×844 |
| Portfólio | 1440×1000 | 390×844 |
| Detalhe do portfólio | 1440×1000 | 390×844 |
| Briefing vazio | 1280×900 | 390×844 |
| Briefing com erros | 1280×900 | 390×844 |
| Briefing sucesso | 1280×900 | 390×844 |
| Login | 1280×900 | Opcional |
| Dashboard | 1440×1000 | Não obrigatório |
| Detalhe do evento | 1440×1000 | Não obrigatório |
Determinismo obrigatório:
- Chromium e imagem Linux fixos;
- viewport fixo;
- timezone `America/Sao_Paulo`;
- locale `pt-BR`;
- fontes instaladas na imagem;
- relógio congelado;
- seed determinístico;
- animações e transições desabilitadas;
- dados dinâmicos mascarados quando necessário.
Atualização de baseline deve usar comando explícito e revisão humana do diff.
### 13.6 Testes de arquitetura
### 13.5 Testes de arquitetura
Criar regras Pest Architecture:
@@ -2021,14 +1998,14 @@ arch()
Adicionar regras conforme a estrutura real, sem tornar a suíte excessivamente frágil.
### 13.7 Cobertura
### 13.6 Cobertura
- meta de 80% para `Domain` e `Application`;
- não medir views, migrations, código gerado e framework;
- cobertura não substitui critérios de aceite;
- queda de cobertura em módulo crítico deve bloquear merge.
### 13.8 Acessibilidade automatizada
### 13.7 Acessibilidade automatizada
Smoke/browser tests devem verificar:
@@ -2046,7 +2023,7 @@ Revisão manual mínima antes do lançamento:
- zoom a 200%;
- leitor de tela nos fluxos de briefing e login.
### 13.9 Comandos padronizados
### 13.8 Comandos padronizados
O projeto deve expor scripts equivalentes:
@@ -2056,7 +2033,6 @@ composer test:feature
composer test:browser
composer test
composer quality
composer visual:update
```
Composição esperada:
@@ -2064,7 +2040,7 @@ Composição esperada:
```text
test:unit → Unit + Architecture
test:feature → Feature + Livewire + Filament
test:browser → E2E + visual + acessibilidade + smoke
test:browser → E2E + acessibilidade + motion + smoke
quality → Pint check + PHPStan/Larastan + audits + testes
```
@@ -2079,7 +2055,7 @@ quality → Pint check + PHPStan/Larastan + audits + testes
| `static` | Pint, PHPStan/Larastan, Composer validate, Composer e npm audit | Sim |
| `unit` | Unitários, arquitetura e cobertura | Sim |
| `feature` | PostgreSQL, migrations, Livewire, Filament e integração | Sim |
| `browser` | Vite, FrankenPHP, E2E, smoke, acessibilidade e visual | Sim |
| `browser` | Vite, FrankenPHP, E2E, smoke, acessibilidade e motion | Sim |
| `container` | Build da imagem final e healthcheck | Sim |
### 14.2 Regras do pipeline
@@ -2113,7 +2089,6 @@ Uma história só está concluída quando:
- PHPStan/Larastan está verde;
- unit e feature tests cobrem regras relevantes;
- E2E foi criado ou atualizado para fluxo crítico;
- snapshot foi criado ou revisado para UI coberta;
- acessibilidade automatizada não possui issues críticas/sérias;
- migration foi testada;
- autorização foi verificada;
@@ -2324,6 +2299,8 @@ Para visual e browser tests:
## 18. Backlog de implementação
> **Recorte vigente (ADR-016).** Fases 0 e 1 são o escopo do lançamento e estão concluídas. **Fases 2 a 5 estão adiadas, sem data.** Não iniciar nenhuma delas sem uma decisão nova do responsável pelo produto — a §1.1 manda trabalhar uma fase por vez, e a fase corrente é o acabamento e a publicação do site.
O agente deve implementar na sequência, salvo instrução explícita.
### Fase 0 — Fundação
@@ -2335,7 +2312,7 @@ O agente deve implementar na sequência, salvo instrução explícita.
- [x] Filament instalado e autenticado;
- [x] Livewire configurado;
- [x] Tailwind/Vite;
- [~] FrankenPHP e Docker Compose local (imagem pronta; serviço de aplicação local pendente);
- [x] FrankenPHP e Docker Compose local;
- [x] papéis admin/assistant;
- [x] Pint;
- [x] PHPStan/Larastan;
@@ -2346,11 +2323,11 @@ O agente deve implementar na sequência, salvo instrução explícita.
- [x] design tokens mínimos;
- [x] healthcheck;
- [x] seed de admin local.
- [ ] verificação de e-mail e reset seguro (MustVerifyEmail);
- [ ] npm audit no `composer quality` e no job `static`;
- [ ] gate de cobertura `Domain`/`Application` ≥ 80%;
- [ ] serviço de aplicação FrankenPHP no Compose local;
- [ ] hello-world implantado em staging (critério de saída).
- [x] verificação de e-mail e reset seguro (MustVerifyEmail);
- [x] npm audit no `composer quality` e no job `static`;
- [x] gate de cobertura `Domain`/`Application` ≥ 80%;
- [x] serviço de aplicação FrankenPHP no Compose local;
- [x] hello-world implantado em staging (critério de saída) — run [31395107465](https://github.com/manoel-freitas/amore-site/actions/runs/31395107465) em `7e68c0e`, com `Dokploy deployment succeeded` e smoke verde em `/up`, `/` e `/admin/login`.
> Os itens pendentes acima são tratados pela mudança OpenSpec `complete-foundation-parity`; o critério de saída da fase só é atingido com staging implantado.
@@ -2369,12 +2346,11 @@ O agente deve implementar na sequência, salvo instrução explícita.
- [x] privacidade;
- [x] SEO;
- [x] mídia otimizada;
- [x] snapshots desktop/mobile;
- [x] testes de acessibilidade.
**Critério de saída:** conteúdo gerenciável no Filament e site público aprovado visualmente (baselines em `tests/.pest/snapshots/`; aprovação humana do diff visual no PR).
**Critério de saída:** conteúdo gerenciável no Filament, testes browser funcionais, de acessibilidade e motion aprovados, e revisão humana do site público no PR.
### Fase 2 — Leads
### Fase 2 — Leads — ADIADA (ADR-016)
- [ ] migration/model/factory de Lead;
- [ ] LeadActivity;
@@ -2391,7 +2367,7 @@ O agente deve implementar na sequência, salvo instrução explícita.
**Critério de saída:** jornada visitante → lead → tratamento interna totalmente verde.
### Fase 3 — Eventos e tarefas
### Fase 3 — Eventos e tarefas — ADIADA (ADR-016)
- [ ] Event;
- [ ] EventTask;
@@ -2406,7 +2382,7 @@ O agente deve implementar na sequência, salvo instrução explícita.
**Critério de saída:** lead é convertido e evento pode ser administrado.
### Fase 4 — Fornecedores e financeiro
### Fase 4 — Fornecedores e financeiro — ADIADA (ADR-016)
- [ ] Vendor;
- [ ] EventVendor;
@@ -2422,7 +2398,7 @@ O agente deve implementar na sequência, salvo instrução explícita.
**Critério de saída:** assessora acompanha orçamento e pagamentos sem integração bancária.
### Fase 5 — Documentos, hardening e lançamento
### Fase 5 — Documentos, hardening e lançamento — ADIADA (ADR-016)
- [ ] documentos privados;
- [ ] auditoria completa;
@@ -2483,8 +2459,7 @@ Toda operação financeira deve:
| Risco | Mitigação obrigatória |
|---|---|
| Filament concentrar domínio | Resources finos, Actions e Queries testáveis |
| Site parecer genérico | design tokens, conteúdo real, fotografia e visual tests |
| Snapshots instáveis | contêiner fixo, relógio/dados/fontes determinísticos |
| Site parecer genérico | design tokens, conteúdo real, fotografia e revisão humana |
| Escopo crescer | lista de não objetivos e mudança somente com hipótese real |
| Financeiro virar contabilidade | limitar a orçamento e pagamentos manuais |
| Worker mode vazar estado | manter modo regular até benchmark e ADR |
@@ -2500,10 +2475,10 @@ Toda operação financeira deve:
| ADR | Decisão | Status |
|---|---|---|
| ADR-001 | Monólito modular Laravel, sem microserviços | Aceita |
| ADR-002 | Filament para área interna e Livewire/Blade para área pública | Aceita |
| ADR-002 | Filament para área interna (Livewire é dependência interna do Filament); site público em Blade — ver ADR-015 | Aceita |
| ADR-003 | PostgreSQL como único banco transacional | Aceita |
| ADR-004 | Pagamentos somente manuais | Aceita |
| ADR-005 | Pest unifica unit, feature, browser e visual | Aceita |
| ADR-005 | Pest unifica unit, feature e browser | Aceita |
| ADR-006 | FrankenPHP regular mode; worker mode adiado | Aceita |
| ADR-007 | Single-tenant; SaaS e portal do cliente adiados | Aceita |
| ADR-008 | Database queue; Redis adiado | Aceita |
@@ -2513,6 +2488,8 @@ Toda operação financeira deve:
| ADR-012 | E-mail transacional via Resend (mailer nativo Laravel) | Aceita |
| ADR-013 | Design system Heritage Editorial para o site público | Aceita |
| ADR-014 | Deploy via Dokploy Compose com imagem imutável por SHA no GHCR | Aceita |
| ADR-015 | Site público permanece Blade + JS vanilla; Livewire e Alpine ficam restritos ao Filament até o gatilho de §22. Emenda o texto da ADR-002 | Aceita |
| ADR-016 | Lançamento de 31/08/2026 entrega apenas o site institucional (Fases 01); Fases 25 seguem especificadas e adiadas, sem data | Aceita |
---
@@ -2529,6 +2506,7 @@ Toda operação financeira deve:
| API pública | existe consumidor real e contrato de integração |
| Kanban | tabela de leads demonstra limitação frequente observada |
| Editor de checklist | diferentes tipos de evento exigem manutenção frequente do seed |
| Livewire no site público | portfólio ou serviços exigem consulta ou filtro dinâmico que Blade + JS vanilla não resolvem de forma simples |
---
@@ -2536,24 +2514,39 @@ Toda operação financeira deve:
O MVP está concluído somente quando:
A ADR-016 divide estas condições em dois recortes. Cada um se fecha por conta própria; o segundo não bloqueia o lançamento.
### 23.1 Lançamento do site (Fases 01)
O lançamento está concluído somente quando:
- site público está publicado e visualmente aprovado;
- assessora edita os conteúdos essenciais sem desenvolvedor;
- briefing cria leads de forma segura;
- briefing envia o pedido de proposta de forma segura, com proteção contra abuso e aceite de privacidade registrado;
- usuários e Policies estão corretos;
- testes unit, feature, visuais e arquitetura estão verdes;
- CI bloqueia regressões;
- imagem FrankenPHP é reproduzível;
- staging e produção usam a mesma imagem promovida;
- backup, restauração e monitoramento estão documentados;
- nenhum item explicitamente fora do MVP (§4.2) foi introduzido.
Note a diferença em relação à versão anterior desta seção: o critério do briefing é **enviar o pedido**, não "criar leads". Criar Lead é Fase 2 e está adiado; o formulário atual envia e-mail e não persiste nada.
### 23.2 Produto completo (Fases 25, adiado)
Além de tudo em §23.1:
- pipeline e próxima ação funcionam;
- briefing cria leads de forma segura e persistente;
- lead é convertido uma única vez em evento;
- checklist é criado automaticamente;
- evento possui visão consolidada;
- fornecedores podem ser cadastrados e vinculados;
- orçamento e pagamentos manuais possuem totais consistentes;
- dashboard mostra pendências do dia;
- usuários e Policies estão corretos;
- auditoria registra operações críticas;
- testes unit, feature, E2E, visuais e arquitetura estão verdes;
- CI bloqueia regressões;
- imagem FrankenPHP é reproduzível;
- staging e produção usam a mesma imagem promovida;
- backup, restauração e monitoramento estão documentados;
- nenhum item explicitamente fora do MVP foi introduzido.
- jornadas E2E das fases correspondentes estão verdes.
---

View File

@@ -29,6 +29,7 @@ final class MediaGenerateVariantsCommand extends Command
foreach ($paths as $path) {
if (! Storage::disk($disk)->exists($path)) {
ResponsiveImage::forgetMetadata($path, $disk);
$this->warn("Missing file: {$path}");
$skipped++;
@@ -56,9 +57,21 @@ final class MediaGenerateVariantsCommand extends Command
if ($settings && filled($settings->default_og_image_path)) {
$paths[] = (string) $settings->default_og_image_path;
}
if ($settings && filled($settings->hero_image_path)) {
$paths[] = (string) $settings->hero_image_path;
}
if ($settings && filled($settings->about_image_path)) {
$paths[] = (string) $settings->about_image_path;
}
if ($settings && filled($settings->hero_image_path)) {
$paths[] = (string) $settings->hero_image_path;
}
if ($settings && filled($settings->services_hero_image_path)) {
$paths[] = (string) $settings->services_hero_image_path;
}
if ($settings && filled($settings->portfolio_hero_image_path)) {
$paths[] = (string) $settings->portfolio_hero_image_path;
}
foreach (Service::query()->whereNotNull('cover_image_path')->pluck('cover_image_path') as $path) {
$paths[] = (string) $path;

View File

@@ -0,0 +1,85 @@
<?php
declare(strict_types=1);
namespace App\Domain\Contact;
/**
* Normalizes a raw phone/WhatsApp number typed into the public briefing
* form into a canonical, human-readable Brazilian format.
*
* The briefing e-mail simply prints the field value in a table, so the
* canonical form must stay legible for the staff member reading it
* "(11) 98888-7777" rather than an opaque "11988887777" digit string.
*/
final class BrazilianPhoneNumber
{
/**
* Formats to "(DD) 9XXXX-XXXX" for an 11-digit mobile number or
* "(DD) XXXX-XXXX" for a 10-digit landline number, stripping a
* leading "+55"/"55" country code when present.
*
* A 10- or 11-digit string is only formatted when it is structurally
* plausible as a Brazilian number: the first two digits must be a
* possible DDD (`[1-9][1-9]`, since Brazilian area codes run 11-99
* and never carry a '0' in either position), and an 11-digit number
* must additionally have a '9' as its third digit (mandatory on all
* Brazilian mobile numbers since 2012). An explicit "+55" prefix that
* leaves no digits for a DDD (e.g. "+55 98888-7777") is treated as a
* number missing its area code, not as DDD 55.
*
* When the digit count does not match either shape, or the shape
* fails the checks above (foreign numbers, partial input, extensions,
* etc.), the original text is preserved only whitespace is
* collapsed so no information the recipient might need is
* discarded or silently fabricated.
*/
public static function normalize(string $raw): string
{
$trimmed = trim($raw);
$collapsed = preg_replace('/\s+/', ' ', $trimmed) ?? $trimmed;
// Only reformat when the text is made exclusively of phone
// characters. Anything else — "(WhatsApp)", "falar com João",
// a ramal — is information the recipient needs, so it is left
// untouched rather than stripped away by the digit extraction.
if (preg_match('/^[0-9()+\-.\/ ]+$/', $collapsed) !== 1) {
return $collapsed;
}
$digits = preg_replace('/\D+/', '', $collapsed) ?? '';
if (in_array(strlen($digits), [12, 13], true) && str_starts_with($digits, '55')) {
$digits = substr($digits, 2);
} elseif (str_starts_with($digits, '55') && preg_match('/^\+\s*55\b/', $collapsed) === 1) {
// An explicit "+55" was written, but the total digit count
// never reached 12/13, meaning nothing precedes it that could
// be a DDD — e.g. "+55 98888-7777" is a mobile number missing
// its area code, not DDD 55 with a coincidentally-matching
// subscriber number. Guessing a DDD here would fabricate one.
return $collapsed;
}
return match (strlen($digits)) {
11 => self::isPlausibleDdd($digits) && $digits[2] === '9'
? sprintf('(%s) %s-%s', substr($digits, 0, 2), substr($digits, 2, 5), substr($digits, 7))
: $collapsed,
10 => self::isPlausibleDdd($digits)
? sprintf('(%s) %s-%s', substr($digits, 0, 2), substr($digits, 2, 4), substr($digits, 6))
: $collapsed,
default => $collapsed,
};
}
/**
* A Brazilian DDD (area code) runs 11-99: the first digit is never
* '0' (not a valid leading digit) and the second is never '0' either
* (no DDD like "10", "20", "30" exists). This does not check that the
* DDD is one of the officially assigned codes only that its shape
* is plausible enough to distinguish it from a foreign number.
*/
private static function isPlausibleDdd(string $digits): bool
{
return preg_match('/^[1-9][1-9]/', $digits) === 1;
}
}

View File

@@ -0,0 +1,181 @@
<?php
declare(strict_types=1);
namespace App\Domain\Contact;
/**
* Captures the marketing origin of a visit (SPEC.md WEB-05, "origem de
* marketing capturada quando disponível") from raw, untrusted request
* data and turns it into a short, human-readable pt-BR label for the
* internal briefing e-mail.
*
* Deliberately framework-free (no Illuminate\Http\Request dependency) so
* it stays a pure transformation, mirroring BrazilianPhoneNumber: callers
* in the HTTP layer extract the query string / referrer and hand them in
* as primitives.
*/
final class MarketingOrigin
{
/**
* Session key both the capturing middleware and the controller read
* from kept here so there is exactly one name for the concept.
*/
public const string SESSION_KEY = 'marketing_origin';
/**
* Caps every captured value. This is marketing metadata, not user
* content a crafted query string must not be able to bloat the
* session (SPEC.md §12.5 LGPD).
*/
private const int MAX_LENGTH = 100;
/**
* @var list<string>
*/
private const array UTM_KEYS = [
'utm_source',
'utm_medium',
'utm_campaign',
'utm_term',
'utm_content',
];
/**
* pt-BR labels for the UTM keys, in the order they should be
* displayed when composing the briefing e-mail row.
*
* @var array<string, string>
*/
private const array LABELS = [
'utm_source' => 'origem',
'utm_medium' => 'mídia',
'utm_campaign' => 'campanha',
'utm_term' => 'termo',
'utm_content' => 'conteúdo',
];
/**
* Reads UTM parameters from the query string, falling back to the
* HTTP referrer when none are present. Returns an empty array when
* neither is available capturing nothing is a valid outcome
* ("capturada quando disponível").
*
* A referrer pointing back at this same host is not a marketing
* origin it is the visitor clicking from one internal page to
* another (common once the original session has expired and a
* fresh one starts mid-visit) so it is discarded rather than
* stored as noise (SPEC.md §12.5, "coletar apenas dados
* necessários").
*
* @param array<string, mixed> $queryParams
* @return array<string, string>
*/
public static function capture(array $queryParams, ?string $referrer, ?string $requestHost): array
{
$utm = [];
foreach (self::UTM_KEYS as $key) {
$value = $queryParams[$key] ?? null;
if (is_string($value) && trim($value) !== '') {
$utm[$key] = self::sanitize($value);
}
}
if ($utm !== []) {
return $utm;
}
if (is_string($referrer) && trim($referrer) !== '' && ! self::isSameHost($referrer, $requestHost)) {
$origin = self::originOf($referrer);
if ($origin !== null) {
return ['referrer' => self::sanitize($origin)];
}
}
return [];
}
/**
* Reduces a referrer URL to its scheme+host identity, dropping the
* path, query string, fragment, and any userinfo. The referrer is
* only ever used as a marketing-origin *site* label (SPEC.md WEB-05)
* the query string can carry data that identifies an individual
* (e.g. a personalized campaign link's `?email=...`), which would
* exceed "coletar apenas dados necessários" (SPEC.md §12.5) once it
* lands in the session and the internal briefing e-mail.
*/
private static function originOf(string $referrer): ?string
{
$host = parse_url($referrer, PHP_URL_HOST);
if (! is_string($host) || $host === '') {
return null;
}
$scheme = parse_url($referrer, PHP_URL_SCHEME);
$prefix = is_string($scheme) && $scheme !== '' ? "{$scheme}://" : '';
return "{$prefix}{$host}";
}
private static function isSameHost(string $referrer, ?string $requestHost): bool
{
if ($requestHost === null || $requestHost === '') {
return false;
}
$referrerHost = parse_url($referrer, PHP_URL_HOST);
return is_string($referrerHost) && strcasecmp($referrerHost, $requestHost) === 0;
}
/**
* Composes the single-row, pt-BR display value for the internal
* briefing e-mail. Returns null when nothing was captured, letting
* the caller fall back to the same "" convention already used for
* other optional briefing fields.
*
* Accepts loosely-typed input on purpose: this reads back whatever
* was put in the session, so it is treated as untrusted rather than
* assumed to still match the shape `capture()` produced.
*
* @param array<string, mixed> $origin
*/
public static function describe(array $origin): ?string
{
if (isset($origin['referrer']) && is_string($origin['referrer']) && $origin['referrer'] !== '') {
return $origin['referrer'];
}
$parts = [];
foreach (self::LABELS as $key => $label) {
if (isset($origin[$key]) && is_string($origin[$key]) && $origin[$key] !== '') {
$parts[] = "{$label}: {$origin[$key]}";
}
}
return $parts === [] ? null : implode(' | ', $parts);
}
/**
* Untrusted input (query string, HTTP referrer) that ends up in an
* HTML e-mail: strip any markup, drop control characters (also
* closes off header-injection-style newline tricks), trim, and cap
* the length before it ever reaches storage.
*/
private static function sanitize(string $value): string
{
$withoutTags = strip_tags($value);
// No /u flag: this is a byte-wise scrub of ASCII control bytes, so
// it cannot land mid-sequence in valid UTF-8 (continuation bytes
// are all >= 0x80) — unlike the Unicode-mode regex, it never
// blanks the whole string just because one byte is malformed.
$withoutControlChars = preg_replace('/[\x00-\x1F\x7F]/', '', $withoutTags) ?? '';
return mb_substr(trim($withoutControlChars), 0, self::MAX_LENGTH);
}
}

View File

@@ -0,0 +1,43 @@
<?php
declare(strict_types=1);
namespace App\Filament\Pages\Auth;
use Filament\Auth\Pages\PasswordReset\RequestPasswordReset as BaseRequestPasswordReset;
use Filament\Notifications\Notification;
use Illuminate\Support\Facades\Password;
/**
* Overrides Filament's stock request-reset page to close an account
* enumeration leak (SPEC 12.1 / ADM-01 "reset seguro"). The vendor page
* shows a distinguishable danger notification for two statuses that only
* ever occur for existing users, letting an attacker tell registered emails
* apart from unregistered ones:
*
* - Password::INVALID_USER no such user at all.
* - Password::RESET_THROTTLED Illuminate\Auth\Passwords\PasswordBroker
* only returns this when a token was already recently created for that
* user, which requires the user to exist. Two requests for the same
* registered email (allowed by this page's own rate limit of 2) would
* otherwise flip from "sent" to "throttled" while an unknown email stays
* "sent" both times the same leak, reached a different way. The
* tradeoff: a legitimate user requesting twice sees "sent" again instead
* of "please wait", which is an acceptable UX cost on an admin-only panel.
*
* Existing-but-ineligible users (inactive, or since this panel now requires
* a verified email) already resolve to Password::RESET_LINK_SENT with no
* mail sent see the vendor callback in the base class so only these two
* branches need normalizing here.
*/
class RequestPasswordReset extends BaseRequestPasswordReset
{
protected function getFailureNotification(string $status): ?Notification
{
if (in_array($status, [Password::INVALID_USER, Password::RESET_THROTTLED], true)) {
return $this->getSentNotification(Password::RESET_LINK_SENT);
}
return parent::getFailureNotification($status);
}
}

View File

@@ -160,11 +160,24 @@ class ManageSiteSettings extends Page
Textarea::make('hero_note')
->label('Nota do hero')
->rows(2),
PublicImageUploadRules::fileUpload('hero_image_path', 'Imagem do hero', 'content/home'),
PublicImageUploadRules::altTextField('hero_image_alt', 'hero_image_path'),
Textarea::make('about_summary')
->label('Resumo institucional')
->rows(3),
])
->columns(2),
Section::make('Imagens das aberturas')
->description('Fotos reais e autorizadas para as primeiras dobras. Sem imagem, cada rota mantém a abertura tonal.')
->schema([
PublicImageUploadRules::fileUpload('hero_image_path', 'Imagem da home', 'content/heroes'),
PublicImageUploadRules::altTextField('hero_image_alt', 'hero_image_path'),
PublicImageUploadRules::fileUpload('services_hero_image_path', 'Imagem de Serviços', 'content/heroes'),
PublicImageUploadRules::altTextField('services_hero_image_alt', 'services_hero_image_path'),
PublicImageUploadRules::fileUpload('portfolio_hero_image_path', 'Imagem de Portfólio', 'content/heroes'),
PublicImageUploadRules::altTextField('portfolio_hero_image_alt', 'portfolio_hero_image_path'),
])
->columns(2),
Section::make('Manifesto editorial')
->schema([
TextInput::make('manifesto_title')

View File

@@ -1,5 +1,7 @@
<?php
declare(strict_types=1);
namespace App\Filament\Resources\PortfolioCases\Pages;
use App\Filament\Resources\PortfolioCases\PortfolioCaseResource;

View File

@@ -1,5 +1,7 @@
<?php
declare(strict_types=1);
namespace App\Filament\Resources\PortfolioCases\Pages;
use App\Filament\Resources\PortfolioCases\PortfolioCaseResource;

View File

@@ -1,5 +1,7 @@
<?php
declare(strict_types=1);
namespace App\Filament\Resources\PortfolioCases\Pages;
use App\Filament\Resources\PortfolioCases\PortfolioCaseResource;

View File

@@ -1,5 +1,7 @@
<?php
declare(strict_types=1);
namespace App\Filament\Resources\Services\Pages;
use App\Filament\Resources\Services\ServiceResource;

View File

@@ -1,5 +1,7 @@
<?php
declare(strict_types=1);
namespace App\Filament\Resources\Services\Pages;
use App\Filament\Resources\Services\ServiceResource;

View File

@@ -1,5 +1,7 @@
<?php
declare(strict_types=1);
namespace App\Filament\Resources\Services\Pages;
use App\Filament\Resources\Services\ServiceResource;

View File

@@ -1,5 +1,7 @@
<?php
declare(strict_types=1);
namespace App\Filament\Resources\Testimonials\Pages;
use App\Filament\Resources\Testimonials\TestimonialResource;

View File

@@ -1,5 +1,7 @@
<?php
declare(strict_types=1);
namespace App\Filament\Resources\Testimonials\Pages;
use App\Filament\Resources\Testimonials\TestimonialResource;

View File

@@ -1,5 +1,7 @@
<?php
declare(strict_types=1);
namespace App\Filament\Resources\Testimonials\Pages;
use App\Filament\Resources\Testimonials\TestimonialResource;

View File

@@ -1,11 +1,38 @@
<?php
declare(strict_types=1);
namespace App\Filament\Resources\Users\Pages;
use App\Filament\Resources\Users\UserResource;
use App\Models\User;
use Filament\Resources\Pages\CreateRecord;
use Illuminate\Database\Eloquent\Model;
class CreateUser extends CreateRecord
{
protected static string $resource = UserResource::class;
/**
* Verification stays admin-managed only (no self-service verify route is
* registered): a user created here by an admin is, by that act, verified.
* Without this, `email_verified_at` would stay null forever and
* canAccessPanel() would permanently lock the new user out with no
* in-app path to recover the password-reset callback skips notifying
* users that fail canAccessPanel() while still reporting success.
*
* `email_verified_at` is deliberately not added to User's #[Fillable]
* list (it isn't a UserForm field either) so it can never be set via
* mass assignment from form/API input `forceFill()` bypasses that
* guard here on purpose, after construction.
*/
protected function handleRecordCreation(array $data): Model
{
/** @var User $record */
$record = new (static::getModel())($data);
$record->forceFill(['email_verified_at' => now()]);
$record->save();
return $record;
}
}

View File

@@ -1,5 +1,7 @@
<?php
declare(strict_types=1);
namespace App\Filament\Resources\Users\Pages;
use App\Filament\Resources\Users\UserResource;

View File

@@ -1,5 +1,7 @@
<?php
declare(strict_types=1);
namespace App\Filament\Resources\Users\Pages;
use App\Filament\Resources\Users\UserResource;

View File

@@ -1,5 +1,7 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers;
abstract class Controller

View File

@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace App\Http\Controllers\PublicSite;
use App\Domain\Contact\MarketingOrigin;
use App\Http\Controllers\Controller;
use App\Http\Requests\PublicSite\ContactBriefingRequest;
use App\Mail\ContactBriefing;
@@ -34,7 +35,7 @@ final class ContactController extends Controller
$this->rememberSubmission($validated);
$settings = SiteSetting::instance();
$fields = $this->buildFields($validated);
$fields = $this->buildFields($validated, $this->resolveMarketingOrigin($request));
$this->dispatchEmails($settings, $validated['nome'], $validated['email'], $fields);
@@ -44,7 +45,7 @@ final class ContactController extends Controller
/**
* @param array<string, mixed> $validated
*/
private function buildFields(array $validated): array
private function buildFields(array $validated, ?string $marketingOrigin): array
{
return [
'Nome' => (string) $validated['nome'],
@@ -56,9 +57,32 @@ final class ContactController extends Controller
'Número estimado de convidados' => isset($validated['convidados']) ? (string) $validated['convidados'] : null,
'Serviço de interesse' => isset($validated['servico_interesse']) ? (string) $validated['servico_interesse'] : null,
'Mensagem' => (string) $validated['mensagem'],
'Origem de marketing' => $marketingOrigin,
];
}
/**
* Reads the origin captured on arrival (SPEC.md WEB-05) so it can
* ride along with the internal briefing e-mail only never the
* confirmation sent to the visitor. Fail-open: any problem reading
* or interpreting the session value must never block the
* submission, so it degrades to "not captured" instead.
*/
private function resolveMarketingOrigin(ContactBriefingRequest $request): ?string
{
try {
$origin = $request->session()->get(MarketingOrigin::SESSION_KEY, []);
return is_array($origin) ? MarketingOrigin::describe($origin) : null;
} catch (Throwable $exception) {
Log::warning('Falha ao ler origem de marketing armazenada (ignorada; fail-open)', [
'exception' => $exception::class,
]);
return null;
}
}
/**
* @param array<string, mixed> $validated
*/

View File

@@ -0,0 +1,85 @@
<?php
declare(strict_types=1);
namespace App\Http\Middleware;
use App\Domain\Contact\MarketingOrigin;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Log;
use Symfony\Component\HttpFoundation\Response;
use Throwable;
/**
* Captures the marketing origin (UTM parameters, or the HTTP referrer as
* a fallback) on the first public page load of a visit and stores it in
* the session, so it can later travel with a contact briefing submission
* (SPEC.md WEB-05).
*
* First *informative* touch wins: nothing is written until a page load
* actually carries a UTM parameter or an external referrer, and once
* that happens later page views never overwrite it navigating to
* another page without UTM parameters must not erase what arrived with
* the visitor. A page load with no signal at all is left unrecorded so a
* later, informative page load in the same session can still be
* captured.
*
* Fail-open by construction: any failure here is caught and logged
* without request data, and the request proceeds untouched. This path
* must never be able to block a page load or, downstream, a conversion.
*/
final class CaptureMarketingOrigin
{
/**
* Technical routes that share the `web` middleware group but are never a
* human arriving at the site. A crawler fetching `/sitemap.xml?utm_source=…`
* would otherwise consume the first-touch slot with traffic that will never
* submit a briefing.
*
* @var list<string>
*/
private const IGNORED_ROUTES = ['sitemap', 'robots'];
public function handle(Request $request, Closure $next): Response
{
if (in_array($request->route()?->getName(), self::IGNORED_ROUTES, true)) {
return $next($request);
}
try {
$this->captureFirstTouch($request);
} catch (Throwable $exception) {
Log::warning('Falha ao capturar origem de marketing (ignorada; fail-open)', [
'exception' => $exception::class,
]);
}
return $next($request);
}
private function captureFirstTouch(Request $request): void
{
if (! $request->hasSession()) {
return;
}
$session = $request->session();
if ($session->has(MarketingOrigin::SESSION_KEY)) {
return;
}
$origin = MarketingOrigin::capture(
$request->query(),
$request->headers->get('referer'),
$request->getHost(),
);
if ($origin === []) {
return;
}
$session->put(MarketingOrigin::SESSION_KEY, $origin);
}
}

View File

@@ -4,10 +4,26 @@ declare(strict_types=1);
namespace App\Http\Requests\PublicSite;
use App\Domain\Contact\BrazilianPhoneNumber;
use Illuminate\Foundation\Http\FormRequest;
final class ContactBriefingRequest extends FormRequest
{
/**
* Normaliza e-mail e telefone antes da validação (SPEC.md §12.3),
* mantendo o valor legível para quem recebe o briefing por e-mail.
*/
protected function prepareForValidation(): void
{
$email = $this->input('email');
$telefone = $this->input('telefone');
$this->merge([
'email' => is_string($email) ? mb_strtolower(trim($email)) : $email,
'telefone' => is_string($telefone) ? BrazilianPhoneNumber::normalize($telefone) : $telefone,
]);
}
/**
* @return array<string, array<int, string>>
*/

View File

@@ -18,6 +18,12 @@ use Illuminate\Database\Eloquent\Model;
* @property string|null $default_og_image_alt
* @property string|null $about_image_path
* @property string|null $about_image_alt
* @property string|null $hero_image_path
* @property string|null $hero_image_alt
* @property string|null $services_hero_image_path
* @property string|null $services_hero_image_alt
* @property string|null $portfolio_hero_image_path
* @property string|null $portfolio_hero_image_alt
* @property string|null $logo_path
* @property string|null $logo_alt
*/
@@ -31,6 +37,12 @@ use Illuminate\Database\Eloquent\Model;
'hero_cta_label',
'hero_secondary_cta_label',
'hero_note',
'hero_image_path',
'hero_image_alt',
'services_hero_image_path',
'services_hero_image_alt',
'portfolio_hero_image_path',
'portfolio_hero_image_alt',
'about_summary',
'about_image_path',
'about_image_alt',

View File

@@ -8,6 +8,8 @@ use App\Enums\UserRole;
use Database\Factories\UserFactory;
use Filament\Models\Contracts\FilamentUser;
use Filament\Panel;
use Illuminate\Auth\MustVerifyEmail;
use Illuminate\Contracts\Auth\MustVerifyEmail as MustVerifyEmailContract;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Attributes\Hidden;
use Illuminate\Database\Eloquent\Factories\HasFactory;
@@ -20,14 +22,14 @@ use Illuminate\Notifications\Notifiable;
*/
#[Fillable(['name', 'email', 'password', 'role', 'is_active'])]
#[Hidden(['password', 'remember_token'])]
class User extends Authenticatable implements FilamentUser
class User extends Authenticatable implements FilamentUser, MustVerifyEmailContract
{
/** @use HasFactory<UserFactory> */
use HasFactory, Notifiable;
use HasFactory, MustVerifyEmail, Notifiable;
public function canAccessPanel(Panel $panel): bool
{
return $this->is_active;
return $this->is_active && $this->hasVerifiedEmail();
}
public function isAdmin(): bool

View File

@@ -1,7 +1,11 @@
<?php
declare(strict_types=1);
namespace App\Providers\Filament;
use App\Filament\Pages\Auth\RequestPasswordReset;
use Filament\FontProviders\LocalFontProvider;
use Filament\Http\Middleware\Authenticate;
use Filament\Http\Middleware\AuthenticateSession;
use Filament\Http\Middleware\DisableBladeIconComponents;
@@ -9,7 +13,7 @@ use Filament\Http\Middleware\DispatchServingFilamentEvent;
use Filament\Pages\Dashboard;
use Filament\Panel;
use Filament\PanelProvider;
use Filament\Support\Colors\Color;
use Filament\View\PanelsRenderHook;
use Filament\Widgets\AccountWidget;
use Filament\Widgets\FilamentInfoWidget;
use Illuminate\Cookie\Middleware\AddQueuedCookiesToResponse;
@@ -21,6 +25,137 @@ use Illuminate\View\Middleware\ShareErrorsFromSession;
class AdminPanelProvider extends PanelProvider
{
/**
* Heritage Editorial Oliva Herança primary ramp.
*
* Filament needs an explicit 11-stop shade array to preserve exact brand
* hexes (a bare string/`Color::hex()` runs the value through
* `Color::generatePalette()`, which discards its lightness and maps a
* fixed per-shade lightness/chroma table instead see MAN-118 survey).
* Shade 600 pins `#556B2F` (Oliva Herança) and shade 700 pins `#3E5219`
* (Oliva Profundo). `Filament\Support\View\Components\ColorMaps\
* ButtonComponentColorMap` was used to verify empirically (not assumed)
* that a solid primary button resolves to `bg: 600, hover:bg: 500, text:
* 50` at 5.42:1 / 4.57:1 contrast (WCAG AA). Shade 50 is a pale olive
* tint rather than pure white deliberately: `BadgeComponent`/
* `badge.css` apply `bg-color-50` directly as a badge's background
* (independent of the button map above), and a pure-white 50 would
* render a "primary" badge as an all-but-invisible pill against the
* Papel Marfim (`#FBF9F4`) panel body.
*
* @return array<int, string>
*/
private function primaryColor(): array
{
return [
50 => '#F3F5EC',
100 => '#E7EBDA',
200 => '#D4DCBE',
300 => '#8B9D77', // Sálvia Silenciosa
400 => '#6E8354',
500 => '#61763A',
600 => '#556B2F', // Oliva Herança
700 => '#3E5219', // Oliva Profundo
800 => '#2E3D13',
900 => '#1F290D',
950 => '#141B08',
];
}
/**
* Heritage Editorial paper/ink neutral ramp, replacing Filament's
* stock Zinc gray so panel chrome (sidebar, topbar, body background,
* borders) matches the public site's paper tones instead of true gray.
* Anchored at the exact tokens where DESIGN.md defines them (50/100/200
* = Papel Marfim/Profundo/Arquivo, 300 = Linha Botânica, 500 = Tinta
* Suave, 900 = Tinta Oliva); the remaining stops are interpolated to
* keep the ramp monotonic.
*
* @return array<int, string>
*/
private function grayColor(): array
{
return [
50 => '#FBF9F4', // Papel Marfim
100 => '#F0EEE9', // Papel Profundo
200 => '#E4E2DD', // Papel Arquivo
300 => '#C5C8B8', // Linha Botânica
400 => '#919486',
500 => '#5D6155', // Tinta Suave
600 => '#43453D',
700 => '#32342E',
800 => '#252622',
900 => '#1B1C19', // Tinta Oliva
950 => '#121210',
];
}
/**
* Semantic ramps built the same way as the primary/gray ramps above:
* an explicit 11-stop array (never `Color::hex()`) anchored so the
* existing `--amare-color-{success,warning,error}` hex lands exactly on
* shade 600 verified against `ButtonComponentColorMap` to resolve to
* `bg: 600` with white text at WCAG AA contrast, same as primary.
*
* @return array<int, string>
*/
private function dangerColor(): array
{
return [
50 => '#F7EDED',
100 => '#EBD1D1',
200 => '#D8A8A8',
300 => '#C88484',
400 => '#B85F5F',
500 => '#A73B3B',
600 => '#991B1B',
700 => '#7D1616',
800 => '#651212',
900 => '#4C0E0E',
950 => '#3A0A0A',
];
}
/**
* @return array<int, string>
*/
private function warningColor(): array
{
return [
50 => '#F6F0EC',
100 => '#E9D9CF',
200 => '#D6B6A3',
300 => '#C4987D',
400 => '#B37956',
500 => '#A15B30',
600 => '#92400E',
700 => '#78340B',
800 => '#602A09',
900 => '#492007',
950 => '#371805',
];
}
/**
* @return array<int, string>
*/
private function successColor(): array
{
return [
50 => '#ECF3EF',
100 => '#D0E0D6',
200 => '#A6C4B2',
300 => '#81AC91',
400 => '#5C9371',
500 => '#377B50',
600 => '#166534',
700 => '#12532B',
800 => '#0F4322',
900 => '#0B321A',
950 => '#082614',
];
}
public function panel(Panel $panel): Panel
{
return $panel
@@ -28,9 +163,47 @@ class AdminPanelProvider extends PanelProvider
->id('admin')
->path('admin')
->login()
->passwordReset(requestAction: RequestPasswordReset::class)
->brandName('Amare Assessoria')
->brandLogo(fn (): string => asset('brand/lockup-on-light.webp'))
->brandLogoHeight('2rem')
->colors([
'primary' => Color::Amber,
'primary' => $this->primaryColor(),
'gray' => $this->grayColor(),
'danger' => $this->dangerColor(),
'warning' => $this->warningColor(),
'success' => $this->successColor(),
])
// Heritage Editorial is a single paper palette by design (see
// DESIGN.md) — no dark-mode variant exists, so the switcher is
// removed rather than left pointing at an unstyled dark theme.
->darkMode(false)
// Self-hosted EB Garamond, same family as the public site.
// LocalFontProvider is pinned explicitly: HasFont::getFontProvider()
// otherwise defaults a custom family to BunnyFontProvider, which
// would emit a live request to fonts.bunny.net from the panel.
// LocalFontProvider renders no <link>/@font-face itself (no $url
// given), so the actual face comes from the render hook below,
// reusing the public site's own <x-fonts /> component/manifest.
//
// The monospace and serif faces are set too, not just the base
// (sans) family: Filament exposes them via the separate
// ->monoFont()/->serifFont() calls below, and the `KeyValue`
// field (used in ManageSiteSettings) renders in the monospace
// face directly — left unset, that field would silently fall
// back to a system monospace stack instead of EB Garamond,
// breaking Heritage Editorial's single-voice typography.
->font('EB Garamond', provider: LocalFontProvider::class)
->monoFont('EB Garamond', provider: LocalFontProvider::class)
->serifFont('EB Garamond', provider: LocalFontProvider::class)
->renderHook(
PanelsRenderHook::HEAD_END,
fn (): string => view('components.fonts')->render(),
)
// Compiled Filament theme entry — sharp corners, flat surfaces
// (see resources/css/filament/admin/theme.css for the full
// rationale and the two vendored-CSS exceptions it can't reach).
->viteTheme('resources/css/filament/admin/theme.css')
->discoverResources(in: app_path('Filament/Resources'), for: 'App\Filament\Resources')
->discoverPages(in: app_path('Filament/Pages'), for: 'App\Filament\Pages')
->pages([

View File

@@ -5,6 +5,7 @@ declare(strict_types=1);
namespace App\Support;
use Illuminate\Contracts\Filesystem\Filesystem;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Storage;
use Intervention\Image\Drivers\Gd\Driver;
use Intervention\Image\ImageManager;
@@ -12,75 +13,130 @@ use Throwable;
final class ResponsiveImage
{
/** @var list<int> */
public const WIDTHS = [480, 960, 1440];
/**
* 720 sits between the original 480 and 960 because that is where the common
* mobile viewport lands: 412 CSS px at a 1.75 device pixel ratio asks for
* ~721 px, so a full-width image used to jump straight to the 960 variant and
* pay for a third more pixels than it drew. Measured on the hero (MAN-109):
* 143 KiB at 960 in jpeg against 75 KiB at 720 in webp.
*
* @var list<int>
*/
public const WIDTHS = [480, 720, 960, 1440];
public static function generate(string $path, ?string $disk = null): void
{
self::forgetMetadata($path, $disk);
$filesystem = self::filesystem($disk);
if (! $filesystem->exists($path)) {
return;
}
$manager = new ImageManager(new Driver);
$contents = $filesystem->get($path);
if ($contents === null) {
return;
}
$extension = strtolower(pathinfo($path, PATHINFO_EXTENSION));
foreach (self::WIDTHS as $width) {
$variantPath = self::variantPath($path, $width);
$variant = $manager->read($contents);
if ($variant->width() > $width) {
$variant->scale(width: $width);
try {
if (! $filesystem->exists($path)) {
return;
}
$encoded = match ($extension) {
'png' => $variant->toPng(),
'webp' => $variant->toWebp(quality: 82),
default => $variant->toJpeg(quality: 82),
};
$manager = new ImageManager(new Driver);
$contents = $filesystem->get($path);
$filesystem->put($variantPath, (string) $encoded);
if ($contents === null) {
return;
}
$extension = strtolower(pathinfo($path, PATHINFO_EXTENSION));
foreach (self::WIDTHS as $width) {
$variantPath = self::variantPath($path, $width);
$variant = $manager->read($contents);
if ($variant->width() > $width) {
$variant->scale(width: $width);
}
$encoded = match ($extension) {
'png' => $variant->toPng(),
'webp' => $variant->toWebp(quality: 82),
default => $variant->toJpeg(quality: 82),
};
$filesystem->put($variantPath, (string) $encoded);
// A webp sibling for every variant. The MAN-109 audit measured the
// hero as the LCP element on every mobile page, at 143 KiB for a
// 960 px jpeg — webp carries the same picture for roughly a third of
// that. `x-media.image` offers these through a <source> so a browser
// that cannot decode webp still gets the original format.
if ($extension === 'webp') {
continue;
}
$webp = $manager->read($contents);
if ($webp->width() > $width) {
$webp->scale(width: $width);
}
$filesystem->put(
self::webpVariantPath($path, $width),
(string) $webp->toWebp(quality: 80)
);
}
} finally {
self::forgetMetadata($path, $disk);
}
self::metadata($path, $disk);
}
public static function deleteVariants(string $path, ?string $disk = null): void
{
self::forgetMetadata($path, $disk);
$filesystem = self::filesystem($disk);
foreach (self::WIDTHS as $width) {
$variantPath = self::variantPath($path, $width);
if ($filesystem->exists($variantPath)) {
$filesystem->delete($variantPath);
try {
foreach (self::WIDTHS as $width) {
foreach ([self::variantPath($path, $width), self::webpVariantPath($path, $width)] as $variantPath) {
if ($filesystem->exists($variantPath)) {
$filesystem->delete($variantPath);
}
}
}
} finally {
self::forgetMetadata($path, $disk);
}
}
public static function delete(string $path, ?string $disk = null): void
{
self::forgetMetadata($path, $disk);
$filesystem = self::filesystem($disk);
self::deleteVariants($path, $disk);
try {
self::deleteVariants($path, $disk);
if ($filesystem->exists($path)) {
$filesystem->delete($path);
if ($filesystem->exists($path)) {
$filesystem->delete($path);
}
} finally {
self::forgetMetadata($path, $disk);
}
}
public static function replace(string $previousPath, string $newPath, ?string $disk = null): void
{
if ($previousPath !== '' && $previousPath !== $newPath) {
self::delete($previousPath, $disk);
self::forgetMetadata($previousPath, $disk);
self::forgetMetadata($newPath, $disk);
try {
if ($previousPath !== '' && $previousPath !== $newPath) {
self::delete($previousPath, $disk);
}
self::generate($newPath, $disk);
} finally {
self::forgetMetadata($previousPath, $disk);
self::forgetMetadata($newPath, $disk);
}
self::generate($newPath, $disk);
self::metadata($newPath, $disk);
}
public static function variantPath(string $path, int $width): string
@@ -93,26 +149,35 @@ final class ResponsiveImage
return $directory === '' ? $variantName : $directory.'/'.$variantName;
}
/**
* The webp sibling of a variant, named by appending rather than replacing the
* extension. Uploads are stored under a UUID so a collision is already
* unlikely, but `photo-480.jpg.webp` cannot collide with the webp variant of
* a `photo.png` the way `photo-480.webp` would.
*/
public static function webpVariantPath(string $path, int $width): string
{
return self::variantPath($path, $width).'.webp';
}
/**
* @return list<array{path: string, width: int}>
*/
public static function availableVariants(string $path, ?string $disk = null): array
{
$filesystem = self::filesystem($disk);
$variants = [];
return self::metadata($path, $disk)['variants'] ?? [];
}
foreach (self::WIDTHS as $width) {
$variantPath = self::variantPath($path, $width);
if ($filesystem->exists($variantPath)) {
$variants[] = [
'path' => $variantPath,
'width' => $width,
];
}
}
return $variants;
/**
* The webp variants that exist for a path. Empty when the media predates
* `media:generate-variants` running with webp support, which is why
* `x-media.image` treats the <source> as optional rather than assuming it.
*
* @return list<array{path: string, width: int}>
*/
public static function availableWebpVariants(string $path, ?string $disk = null): array
{
return self::metadata($path, $disk)['webp_variants'] ?? [];
}
/**
@@ -120,13 +185,38 @@ final class ResponsiveImage
*/
public static function dimensions(string $path, ?string $disk = null): ?array
{
$filesystem = self::filesystem($disk);
$metadata = self::metadata($path, $disk);
if (! $filesystem->exists($path)) {
return null;
return $metadata === null ? null : [
'width' => $metadata['width'],
'height' => $metadata['height'],
];
}
/**
* @return array{
* width: int,
* height: int,
* variants: list<array{path: string, width: int}>,
* webp_variants: list<array{path: string, width: int}>
* }|null
*/
public static function metadata(string $path, ?string $disk = null): ?array
{
$key = self::metadataCacheKey($path, $disk);
$cached = self::cachedMetadata($key);
if (is_array($cached)) {
return $cached;
}
try {
$filesystem = self::filesystem($disk);
if (! $filesystem->exists($path)) {
return null;
}
$contents = $filesystem->get($path);
if ($contents === null) {
@@ -134,18 +224,88 @@ final class ResponsiveImage
}
$image = (new ImageManager(new Driver))->read($contents);
return [
$metadata = [
'width' => $image->width(),
'height' => $image->height(),
'variants' => self::existingVariants($filesystem, $path, false),
'webp_variants' => self::existingVariants($filesystem, $path, true),
];
self::storeMetadata($key, $metadata);
return $metadata;
} catch (Throwable) {
return null;
}
}
public static function forgetMetadata(string $path, ?string $disk = null): void
{
try {
Cache::forget(self::metadataCacheKey($path, $disk));
} catch (Throwable) {
// Cache availability must not block public media rendering or cleanup.
}
}
private static function filesystem(?string $disk): Filesystem
{
return Storage::disk($disk ?? PublicImageUploadRules::disk());
return Storage::disk(self::diskName($disk));
}
/**
* @return list<array{path: string, width: int}>
*/
private static function existingVariants(Filesystem $filesystem, string $path, bool $webp): array
{
$variants = [];
foreach (self::WIDTHS as $width) {
$variantPath = $webp
? self::webpVariantPath($path, $width)
: self::variantPath($path, $width);
if ($filesystem->exists($variantPath)) {
$variants[] = ['path' => $variantPath, 'width' => $width];
}
}
return $variants;
}
private static function metadataCacheKey(string $path, ?string $disk): string
{
return 'responsive-image:metadata:'.sha1(self::diskName($disk).'|'.$path);
}
/**
* @return array<string, mixed>|null
*/
private static function cachedMetadata(string $key): ?array
{
try {
$cached = Cache::get($key);
return is_array($cached) ? $cached : null;
} catch (Throwable) {
return null;
}
}
/**
* @param array<string, mixed> $metadata
*/
private static function storeMetadata(string $key, array $metadata): void
{
try {
Cache::forever($key, $metadata);
} catch (Throwable) {
// The uncached result remains safe to use for this request.
}
}
private static function diskName(?string $disk): string
{
return $disk ?? PublicImageUploadRules::disk();
}
}

View File

@@ -1,5 +1,6 @@
<?php
use App\Http\Middleware\CaptureMarketingOrigin;
use Illuminate\Foundation\Application;
use Illuminate\Foundation\Configuration\Exceptions;
use Illuminate\Foundation\Configuration\Middleware;
@@ -14,6 +15,13 @@ return Application::configure(basePath: dirname(__DIR__))
->withMiddleware(function (Middleware $middleware): void {
// Trust Traefik/Dokploy (and local reverse proxies) for X-Forwarded-* headers.
$middleware->trustProxies(at: '*');
// Public-site request spine only (routes/web.php uses the "web"
// group; the Filament admin panel defines its own middleware
// stack in AdminPanelProvider and never touches this group).
$middleware->web(append: [
CaptureMarketingOrigin::class,
]);
})
->withExceptions(function (Exceptions $exceptions): void {
$exceptions->shouldRenderJsonWhen(

View File

@@ -65,6 +65,9 @@
"test:browser": [
"@php artisan test --testsuite=Browser"
],
"test:coverage": [
"vendor/bin/pest -c phpunit.coverage.xml --testsuite=Unit,Architecture,Feature --coverage --min=80"
],
"pint": [
"vendor/bin/pint"
],
@@ -81,11 +84,9 @@
"@pint:check",
"@phpstan",
"composer audit --no-interaction",
"npm audit --omit=dev --audit-level=high",
"@test"
],
"visual:update": [
"@php artisan test --testsuite=Browser --update-snapshots"
],
"post-autoload-dump": [
"Illuminate\\Foundation\\ComposerScripts::postAutoloadDump",
"@php artisan package:discover --ansi",

12
composer.lock generated
View File

@@ -2802,16 +2802,16 @@
},
{
"name": "league/commonmark",
"version": "2.8.3",
"version": "2.9.0",
"source": {
"type": "git",
"url": "https://github.com/thephpleague/commonmark.git",
"reference": "1902f60f984235023acbe03db6ad614a37b3c3e7"
"reference": "5703d83ba3da3b2e356a5fedc848ed6d8ffb6529"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/thephpleague/commonmark/zipball/1902f60f984235023acbe03db6ad614a37b3c3e7",
"reference": "1902f60f984235023acbe03db6ad614a37b3c3e7",
"url": "https://api.github.com/repos/thephpleague/commonmark/zipball/5703d83ba3da3b2e356a5fedc848ed6d8ffb6529",
"reference": "5703d83ba3da3b2e356a5fedc848ed6d8ffb6529",
"shasum": ""
},
"require": {
@@ -2848,7 +2848,7 @@
"type": "library",
"extra": {
"branch-alias": {
"dev-main": "2.9-dev"
"dev-main": "2.10-dev"
}
},
"autoload": {
@@ -2905,7 +2905,7 @@
"type": "tidelift"
}
],
"time": "2026-07-12T15:29:16+00:00"
"time": "2026-08-03T13:42:31+00:00"
},
{
"name": "league/config",

View File

@@ -65,7 +65,7 @@ return [
|
*/
'timezone' => env('APP_TIMEZONE', 'America/Fortaleza'),
'timezone' => env('APP_TIMEZONE', 'America/Sao_Paulo'),
/*
|--------------------------------------------------------------------------
@@ -73,7 +73,7 @@ return [
|--------------------------------------------------------------------------
|
| When set outside production, the application clock is frozen for
| deterministic rendering (visual regression / seeded content).
| deterministic time-dependent tests or seeded content.
|
*/

View File

@@ -0,0 +1,42 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
/**
* Backfills `email_verified_at` for users that already exist.
*
* `User` now implements MustVerifyEmail and `canAccessPanel()` requires
* `hasVerifiedEmail()`. Every account created before this change has
* `email_verified_at` as null, so without this backfill they are locked out of
* `/admin` the moment the change deploys and there is no way back in from
* inside the app: no self-service verification route is registered, and the
* password-reset flow deliberately skips notifying users that fail
* `canAccessPanel()` while still reporting success. Recovery would need shell
* access to the container.
*
* Verifying them is the correct default, not a shortcut. There is no public
* registration: every existing account was created by an admin, through the
* panel or the tinker snippet in docs/deployment/dokploy.md. That act is the
* verification which is exactly the reasoning CreateUser applies to accounts
* created from now on.
*/
return new class extends Migration
{
public function up(): void
{
DB::table('users')
->whereNull('email_verified_at')
->update(['email_verified_at' => DB::raw('COALESCE(created_at, NOW())')]);
}
public function down(): void
{
// Deliberately not reversed. Nulling these columns would lock every
// existing user out of the panel, which is the failure this migration
// exists to prevent — and the pre-migration null/non-null split is not
// recorded anywhere, so it could not be restored faithfully anyway.
}
};

View File

@@ -0,0 +1,36 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('site_settings', function (Blueprint $table): void {
$table->string('hero_image_path')->nullable()->after('hero_note');
$table->string('hero_image_alt')->nullable()->after('hero_image_path');
$table->string('services_hero_image_path')->nullable()->after('hero_image_alt');
$table->string('services_hero_image_alt')->nullable()->after('services_hero_image_path');
$table->string('portfolio_hero_image_path')->nullable()->after('services_hero_image_alt');
$table->string('portfolio_hero_image_alt')->nullable()->after('portfolio_hero_image_path');
});
}
public function down(): void
{
Schema::table('site_settings', function (Blueprint $table): void {
$table->dropColumn([
'hero_image_path',
'hero_image_alt',
'services_hero_image_path',
'services_hero_image_alt',
'portfolio_hero_image_path',
'portfolio_hero_image_alt',
]);
});
}
};

View File

@@ -11,6 +11,7 @@ use App\Models\SiteSetting;
use App\Support\PublicImageUploadRules;
use Illuminate\Database\Seeder;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\App;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Facades\Storage;
@@ -18,8 +19,32 @@ class ContentSeeder extends Seeder
{
private const SEED_TIMESTAMP = '2026-01-15 10:00:00';
/**
* Demo/fixture content is meant for local dev, automated testing, and
* staging visual review only. It must never overwrite owner-edited
* SiteSetting, Service, and PortfolioCase records, never re-upload
* fixture images to the production storage disk, and never auto-publish
* the fictional portfolio cases or (via TestimonialsSeeder) the real
* testimonials.
*
* This is deliberately an allow-list of the known-safe environments
* ('local', 'staging', 'testing') rather than a deny-list of
* 'production'. APP_ENV is a free-text value hand-typed into the
* Dokploy environment UI with no validation a blank value, a typo, or
* an unexpected casing (e.g. '', 'Production', 'staginng') must fail
* closed (skip seeding) rather than fail open (seed/overwrite
* production data). Only the three recognized values run this seeder;
* everything else, including 'production' itself, is a no-op.
*
* Publishing testimonials in production remains a deliberate, manually
* triggered step see docs/deployment/dokploy.md.
*/
public function run(): void
{
if (! App::environment(['local', 'staging', 'testing'])) {
return;
}
$this->seedSiteSettings();
$this->seedServices();
$this->seedPortfolioCases();
@@ -36,6 +61,12 @@ class ContentSeeder extends Seeder
'hero_cta_label' => 'Solicitar proposta',
'hero_secondary_cta_label' => 'Conheça nosso olhar',
'hero_note' => 'Planejamento cuidadoso, comunicação clara e execução segura — do primeiro encontro ao último detalhe.',
'hero_image_path' => $this->copyFixture('case-casamento-ana-lucas.jpg', 'content/heroes/home.jpg'),
'hero_image_alt' => 'Celebração ao ar livre em São Paulo',
'services_hero_image_path' => $this->copyFixture('service-eventos-corporativos.jpg', 'content/heroes/services.jpg'),
'services_hero_image_alt' => 'Mesa preparada para um evento corporativo',
'portfolio_hero_image_path' => $this->copyFixture('case-lancamento-verano.jpg', 'content/heroes/portfolio.jpg'),
'portfolio_hero_image_alt' => 'Ambientação de um evento de lançamento',
'about_summary' => 'Assessoria boutique especializada em experiências memoráveis em São Paulo.',
'about_image_path' => $this->copyFixture('about-image.jpg', 'content/about/about-image.jpg'),
'about_image_alt' => 'Mesa de planejamento com caderno, café e guardanapos de pano',

View File

@@ -23,6 +23,7 @@ class DatabaseSeeder extends Seeder
'password' => Hash::make('password'),
'role' => UserRole::Admin,
'is_active' => true,
'email_verified_at' => now(),
],
);
@@ -33,6 +34,7 @@ class DatabaseSeeder extends Seeder
'password' => Hash::make('password'),
'role' => UserRole::Assistant,
'is_active' => true,
'email_verified_at' => now(),
],
);

View File

@@ -14,7 +14,7 @@ use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Storage;
/**
* Deterministic content for visual regression. Keep separate from ContentSeeder demo data.
* Deterministic content and image fixtures. Keep separate from ContentSeeder demo data.
*/
class VisualContentSeeder extends Seeder
{
@@ -42,6 +42,12 @@ class VisualContentSeeder extends Seeder
'hero_cta_label' => 'Solicitar proposta',
'hero_secondary_cta_label' => 'Conheça nosso olhar',
'hero_note' => 'Planejamento cuidadoso, comunicação clara e execução segura — do primeiro encontro ao último detalhe.',
'hero_image_path' => $this->writeSolidJpeg('visual/heroes/home.jpg', 1600, 1100, [218, 224, 205]),
'hero_image_alt' => 'Imagem editorial da home',
'services_hero_image_path' => $this->writeSolidJpeg('visual/heroes/services.jpg', 1600, 1100, [196, 200, 184]),
'services_hero_image_alt' => 'Imagem editorial de Serviços',
'portfolio_hero_image_path' => $this->writeSolidJpeg('visual/heroes/portfolio.jpg', 1600, 1100, [228, 226, 221]),
'portfolio_hero_image_alt' => 'Imagem editorial de Portfólio',
'about_summary' => 'Assessoria boutique especializada em experiências memoráveis em São Paulo.',
'about_image_path' => $this->writeSolidJpeg('visual/about/about-image.jpg', 1200, 900, [232, 228, 218]),
'about_image_alt' => 'Imagem editorial da página Sobre',

View File

@@ -1,3 +1,12 @@
> **Status deste arquivo:** material bruto histórico, não consumido por
> nenhum código do site. Os cinco depoimentos abaixo foram transcritos
> manualmente para dentro de `database/seeders/TestimonialsSeeder.php` e,
> em produção, o conteúdo real vive no banco de dados, editável apenas
> pela tela **Depoimentos** do painel administrativo (`/admin`). Editar
> este arquivo não tem nenhum efeito no site publicado. Mantido apenas como
> referência histórica de onde o conteúdo original veio. Ver
> `docs/operations/atualizacao-de-conteudo.md` para o fluxo de edição real.
Mi, quero agradecer você e a sua equipe por todo empenho, atenção, vocês são abençoadas.
Era nítida sua preocupação em garantir que todos os detalhes planejados desta comemoração, fossem atendidos.
Que você possa transformar o grande dia das noivinhas sempre com essa sua leveza!!! ❤️

View File

@@ -14,7 +14,13 @@ services:
restart: "no"
env_file:
- .env
command: ["/app/scripts/deploy/initialize-application.sh"]
# Exec-array form on a single line, deliberately. A folded block scalar
# (`command: >`) keeps the newline before any line indented deeper than the
# first, so `sh -c` receives a multi-line string and dies with
# `sh: 2: Syntax error: "&&" unexpected` before running anything. That broke
# every staging deploy from 58f24a6 onward, and had already broken them once
# before 5949fad. Keep this on one line; do not reformat it for width.
command: ["sh", "-c", "php artisan migrate --force --no-interaction && php artisan db:seed --class=ContentSeeder --force --no-interaction && php artisan media:generate-variants"]
networks:
- dokploy-network

View File

@@ -18,5 +18,34 @@ services:
retries: 10
start_period: 10s
# Local parity with the FrankenPHP image used on staging/production.
# Manual smoke test (not run in CI — the `container` job already builds and
# health-checks the same Dockerfile-based image):
# docker compose up -d
# curl localhost:8000/up
app:
build:
context: .
dockerfile: Dockerfile
container_name: amare-app
restart: unless-stopped
depends_on:
postgres:
condition: service_healthy
env_file:
- .env
environment:
# Override host-side .env defaults: DB_HOST=127.0.0.1 only resolves for
# processes running on the host, not for this container reaching the
# `postgres` service by its Compose service name. DB_PORT is also
# forced back to Postgres's internal container port (5432) — .env may
# have DB_PORT remapped for host-side tooling (e.g. to avoid a local
# port clash), but that remapping only applies to the published host
# port, never to container-to-container traffic.
DB_HOST: postgres
DB_PORT: "5432"
ports:
- "8000:8000"
volumes:
amare_postgres_data:

View File

@@ -5,5 +5,20 @@
:8000 {
root * /app/public
encode gzip zstd
# Vite emits content-hashed filenames, so a build asset URL never changes
# meaning. Same for uploaded media: PublicImageUploadRules stores every
# upload under a fresh UUID (and ResponsiveImage derives its variants from
# that name), so replacing an image produces a new URL rather than new bytes
# at the old one. Both are safe to pin for a year.
@immutable path /build/* /storage/*
header @immutable Cache-Control "public, max-age=31536000, immutable"
# Brand assets ship inside the image under stable filenames, so a rebrand
# reuses the same URL. One day plus Caddy's ETag revalidation keeps repeat
# views cheap without pinning an outdated logo in browsers.
@brand path /brand/*
header @brand Cache-Control "public, max-age=86400"
php_server
}

51
docs/agents/domain.md Normal file
View File

@@ -0,0 +1,51 @@
# Domain Docs
How the engineering skills should consume this repo's domain documentation when exploring the codebase.
## Before exploring, read these
- **`CONTEXT.md`** at the repo root, or
- **`CONTEXT-MAP.md`** at the repo root if it exists — it points at one `CONTEXT.md` per context. Read each one relevant to the topic.
- **`docs/adr/`** — read ADRs that touch the area you're about to work in. In multi-context repos, also check `src/<context>/docs/adr/` for context-scoped decisions.
If any of these files don't exist, **proceed silently**. Don't flag their absence; don't suggest creating them upfront. The `/domain-modeling` skill (reached via `/grill-with-docs` and `/improve-codebase-architecture`) creates them lazily when terms or decisions actually get resolved.
## File structure
Single-context repo (most repos):
```
/
├── CONTEXT.md
├── docs/adr/
│ ├── 0001-event-sourced-orders.md
│ └── 0002-postgres-for-write-model.md
└── src/
```
Multi-context repo (presence of `CONTEXT-MAP.md` at the root):
```
/
├── CONTEXT-MAP.md
├── docs/adr/ ← system-wide decisions
└── src/
├── ordering/
│ ├── CONTEXT.md
│ └── docs/adr/ ← context-specific decisions
└── billing/
├── CONTEXT.md
└── docs/adr/
```
## Use the glossary's vocabulary
When your output names a domain concept (in an issue title, a refactor proposal, a hypothesis, a test name), use the term as defined in `CONTEXT.md`. Don't drift to synonyms the glossary explicitly avoids.
If the concept you need isn't in the glossary yet, that's a signal — either you're inventing language the project doesn't use (reconsider) or there's a real gap (note it for `/domain-modeling`).
## Flag ADR conflicts
If your output contradicts an existing ADR, surface it explicitly rather than silently overriding:
> _Contradicts ADR-0007 (event-sourced orders) — but worth reopening because…_

View File

@@ -0,0 +1,26 @@
# Issue tracker: Linear
Issues and specs for this repo live in Linear. Use the Linear MCP tools for all operations.
- **Workspace**: maneco-workspace (https://linear.app/maneco-workspace)
- **Team**: Maneco-workspace
- **Issue statuses**: Backlog, Todo, In Progress, Done, Canceled, Duplicate
## Conventions
- **Create an issue**: `save_issue` with `title`, `team` (`Maneco-workspace`), and markdown `description`. Assign via `assignee` ("me" for the current user).
- **Read an issue**: `get_issue` by identifier (e.g. `LIN-123`).
- **List issues**: `list_issues` filtered by `team`, `assignee`, `state`, or `project`.
- **Comment on an issue**: `save_comment` with `issueId` and markdown `body`.
- **Apply / remove labels**: `save_issue` with `labels` (replaces the full set).
- **Move workflow state**: `save_issue` with `state` (e.g. `Todo`, `In Progress`, `Done`, `Canceled`).
- **Link work**: `save_issue` with `project`, `cycle`, `parentId`, `blocks` / `blockedBy` (relations are append-only).
- **Cross-link to a PR**: attach the PR URL via `save_issue` `links`.
## When a skill says "publish to the issue tracker"
Create a Linear issue with `save_issue` on team `Maneco-workspace`.
## When a skill says "fetch the relevant ticket"
Run `get_issue` on the identifier and read the description, state, labels, and assignee.

View File

@@ -0,0 +1,15 @@
# Triage Labels
The skills speak in terms of five canonical triage roles. This file maps those roles to the actual label strings used in this repo's issue tracker.
| Label in mattpocock/skills | Label in our tracker | Meaning |
| -------------------------- | -------------------- | ---------------------------------------- |
| `needs-triage` | `needs-triage` | Maintainer needs to evaluate this issue |
| `needs-info` | `needs-info` | Waiting on reporter for more information |
| `ready-for-agent` | `ready-for-agent` | Fully specified, ready for an AFK agent |
| `ready-for-human` | `ready-for-human` | Requires human implementation |
| `wontfix` | `wontfix` | Will not be actioned |
When a skill mentions a role (e.g. "apply the AFK-ready triage label"), use the corresponding label string from this table.
Edit the right-hand column to match whatever vocabulary you actually use.

View File

@@ -17,7 +17,7 @@ Promote (manual) → retag same digest as :production (no rebuild)
| Piece | Detail |
|---|---|
| Compose file | [`docker-compose.deploy.yml`](../../docker-compose.deploy.yml) |
| Processes | `migrate` initialization (one-shot) → `web` / `queue` / `scheduler` |
| Processes | `migrate` (one-shot) → `web` / `queue` / `scheduler` |
| Image | `ghcr.io/<owner>/<repo>:<sha>` (+ aliases `:staging`, `:production`) |
| Database | Dokploy PostgreSQL **per environment** (not in the app image) |
| Media | Cloudflare R2 (`FILESYSTEM_DISK=r2`), separate buckets per environment |
@@ -67,7 +67,7 @@ APP_URL=https://staging.example.com
APP_LOCALE=pt_BR
APP_FALLBACK_LOCALE=pt_BR
APP_TIMEZONE=America/Fortaleza
APP_TIMEZONE=America/Sao_Paulo
DB_CONNECTION=pgsql
DB_HOST=<dokploy-postgres-internal-host> # Internal Host from Dokploy UI (requires dokploy-network)
@@ -145,7 +145,7 @@ HTTP 404 from `compose.deploy` usually means the compose id is wrong (Applicatio
1. Waits for workflow `CI` success on push to `main`.
2. Builds once; pushes `:<full-sha>` and `:staging`.
3. Calls Dokploy `compose.deploy`; initialization migrates, runs `ContentSeeder`, and generates media variants before the app services start.
3. Calls Dokploy `compose.deploy` and polls until done.
4. Runs [`scripts/deploy/smoke.sh`](../../scripts/deploy/smoke.sh) against `STAGING_URL`.
### Production (manual)
@@ -155,11 +155,7 @@ HTTP 404 from `compose.deploy` usually means the compose id is wrong (Applicatio
1. Operator runs **Actions → Promote production**.
2. Inputs: full `sha` already on GHCR; `confirm` must be exactly `PRODUCTION`.
3. Retags the **same digest** as `:production` (no rebuild).
4. Deploys production compose; initialization runs migrations only, then smoke.
The one-shot initializer is a dependency of `web`, `queue`, and `scheduler`. Migration failure is fatal in every environment. In staging, `ContentSeeder` and `media:generate-variants` also run automatically and are fatal: if any initialization command fails, application services do not start. Production and every non-staging environment remain migrate-only.
When Dokploy reports a failed deployment, [`scripts/deploy/dokploy-deploy.sh`](../../scripts/deploy/dokploy-deploy.sh) prints the latest deployment metadata and fetches the last 1,000 log lines through `deployment.readLogs`. Log retrieval is best-effort and cannot hide the original deployment failure.
4. Deploys production compose + smoke.
Private repos on GitHub Free do not get Environment required reviewers; human approval is the explicit `workflow_dispatch` + confirmation string. GitHub Pro Environment reviewers are optional later.
@@ -196,9 +192,9 @@ XDG_CONFIG_HOME=/tmp php artisan tinker --execute="echo \\App\\Models\\User::que
Never reuse `admin@amare.local` / `password`.
### Load authorized testimonials in production
### Load authorized testimonials after migrations
Staging receives the five authorized testimonials through the automatic `ContentSeeder`. Production is migrate-only, so manually load them there after migrations. From Dokploy, open a terminal on the production `web` service (or run an equivalent one-off process):
After migrations, manually load the five authorized testimonials in **staging**, then repeat in **production**. From Dokploy, open a terminal on the environment's `web` service (or run an equivalent one-off process):
```bash
php artisan db:seed --class='Database\Seeders\TestimonialsSeeder' --force --no-interaction
@@ -223,7 +219,11 @@ echo (\$valid ? 'ok' : 'invalid').PHP_EOL;
Expected output: `ok`.
Never run `DatabaseSeeder` in staging or production because it creates local credentials. Do not run `ContentSeeder` in production: its broad content fixtures are intended for staging initialization only. Production testimonial loading remains the deliberate, narrowly scoped manual operation above.
The `migrate` service in `docker-compose.deploy.yml` runs `php artisan db:seed --class=ContentSeeder --force --no-interaction` on every deploy, in both stacks. `ContentSeeder::run()` guards itself with an **allow-list**`App::environment(['local', 'staging', 'testing'])` — and returns immediately (exit code 0, no side effects) unless `APP_ENV` is exactly one of those three values. In **staging** (`APP_ENV=staging`) the guard matches, so `ContentSeeder` still seeds its demo content on every deploy — this is required for visual review and is expected behavior, not a bug. In **production** (`APP_ENV=production`), and for any blank, mistyped, or unexpectedly cased `APP_ENV` value in any stack, the guard does not match, so the step is a deliberate no-op: it never overwrites SiteSetting/Service/PortfolioCase records edited in Filament, never re-uploads fixture images to the production storage disk, and never auto-publishes the fictional portfolio cases. Note the tradeoff this implies: if staging's `APP_ENV` is ever typo'd away from exactly `staging`, demo content silently stops being (re)seeded there too — check the Dokploy environment value first if a staging deploy stops refreshing demo content.
Do not run bare `DatabaseSeeder` in staging or production: it also creates the `admin@amare.local` / `password` local-dev credentials.
Publishing the five real testimonials is a separate, deliberate, manually triggered step **only in production**`ContentSeeder` calls `TestimonialsSeeder` internally, but that call is skipped in production by the same allow-list guard, so the command above is the only path that publishes testimonials there. In **staging**, this is not manual: because the allow-list guard matches `staging`, `ContentSeeder` calls `TestimonialsSeeder` automatically on every deploy, auto-publishing/re-publishing the five canonical testimonials each time (consistent with staging's role as a demo/preview environment).
## Backup and restore

View File

@@ -0,0 +1,409 @@
# Lighthouse — local
- Origem: `http://127.0.0.1:8000`
- Lighthouse: 12.8.2
- Navegador: `Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/151.0.0.0 Safari/537.36`
- Coletado em: 2026-08-10T15:55:03.101Z
- Execuções por página/preset: 3 (reportada a de LCP mediano)
- Commit: `2e43fde`
- Seeder: `ContentSeeder`
Metas SPEC §6.6: LCP ≤ 2,5 s · CLS ≤ 0,1 · INP ≤ 200 ms · zero erro de console.
| página | preset | perf | a11y | BP | SEO | LCP | FCP | CLS | TBT | TTFB servidor | LCP minmax |
|---|---|---|---|---|---|---|---|---|---|---|---|
| contato | desktop | 100 | 100 | 100 | 100 | 0.53 s | 0.37 s | 0.000 | 0 ms | 55 ms | 0.53 s 0.53 s |
| contato | mobile | 97 | 100 | 100 | 100 | 2.55 s | 1.50 s | 0.000 | 0 ms | 97 ms | 2.55 s 2.56 s |
| home | desktop | 99 | 100 | 100 | 100 | 0.87 s | 0.37 s | 0.000 | 0 ms | 75 ms | 0.87 s 0.87 s |
| home | mobile | 83 | 100 | 100 | 100 | 4.58 s | 1.51 s | 0.000 | 0 ms | 217 ms | 4.36 s 4.58 s |
| portfolio-detalhe | desktop | 99 | 100 | 100 | 100 | 0.97 s | 0.37 s | 0.000 | 0 ms | 89 ms | 0.97 s 0.97 s |
| portfolio-detalhe | mobile | 87 | 100 | 100 | 100 | 3.98 s | 1.51 s | 0.000 | 0 ms | 126 ms | 3.98 s 4.05 s |
| portfolio | desktop | 99 | 100 | 100 | 100 | 0.83 s | 0.37 s | 0.000 | 0 ms | 187 ms | 0.63 s 0.83 s |
| portfolio | mobile | 87 | 100 | 100 | 100 | 3.98 s | 1.51 s | 0.000 | 0 ms | 108 ms | 3.91 s 4.20 s |
| servicos | desktop | 100 | 100 | 100 | 100 | 0.77 s | 0.37 s | 0.000 | 0 ms | 124 ms | 0.77 s 0.77 s |
| servicos | mobile | 89 | 100 | 100 | 100 | 3.68 s | 1.51 s | 0.000 | 0 ms | 78 ms | 3.61 s 3.68 s |
| sobre | desktop | 100 | 100 | 100 | 100 | 0.61 s | 0.37 s | 0.000 | 0 ms | 48 ms | 0.57 s 0.61 s |
| sobre | mobile | 94 | 100 | 100 | 100 | 3.01 s | 1.51 s | 0.000 | 0 ms | 133 ms | 2.87 s 3.01 s |
## Decomposição do LCP
### contato — desktop
Elemento de LCP: `<h1 class="text-headline font-medium tracking-tight text-amare-text">`
| fase | tempo |
|---|---|
| TTFB | 0.12 s |
| Load Delay | 0.00 s |
| Load Time | 0.00 s |
| Render Delay | 0.41 s |
Requests concluídas até o LCP, mais pesadas primeiro:
| recurso | tipo | transferido | fim |
|---|---|---|---|
| `/brand/lockup-on-light.webp` | Image | 83 KiB | 0.07 s |
| `/build/assets/eb-garamond-600-normal-xCe6GkOx.woff` | Font | 30 KiB | 0.08 s |
| `/build/assets/eb-garamond-500-normal-DZU8xF9o.woff` | Font | 30 KiB | 0.08 s |
| `/build/assets/eb-garamond-400-normal-maQnHeKB.woff` | Font | 28 KiB | 0.08 s |
| `/build/assets/eb-garamond-600-normal-DHwxsLHv.woff2` | Font | 25 KiB | 0.07 s |
| `/build/assets/eb-garamond-500-normal-DehAIUv0.woff2` | Font | 25 KiB | 0.06 s |
| `/build/assets/eb-garamond-400-normal-BCNrxLz_.woff2` | Font | 24 KiB | 0.06 s |
| `/build/assets/app-Cswasu1n.css` | Stylesheet | 10 KiB | 0.06 s |
| insight com falha | ganho estimado de LCP | bytes |
|---|---|---|
| Improve image delivery | 0.10 s | — |
| Use efficient cache lifetimes | 0.00 s | — |
| Network dependency tree | 0.00 s | — |
| Render blocking requests | 0.00 s | — |
### contato — mobile
Elemento de LCP: `<h1 class="text-headline font-medium tracking-tight text-amare-text">`
| fase | tempo |
|---|---|
| TTFB | 0.45 s |
| Load Delay | 0.00 s |
| Load Time | 0.00 s |
| Render Delay | 2.10 s |
Requests concluídas até o LCP, mais pesadas primeiro:
| recurso | tipo | transferido | fim |
|---|---|---|---|
| `/brand/lockup-on-light.webp` | Image | 83 KiB | 0.11 s |
| `/build/assets/eb-garamond-600-normal-xCe6GkOx.woff` | Font | 30 KiB | 0.12 s |
| `/build/assets/eb-garamond-500-normal-DZU8xF9o.woff` | Font | 30 KiB | 0.12 s |
| `/build/assets/eb-garamond-400-normal-maQnHeKB.woff` | Font | 28 KiB | 0.12 s |
| `/build/assets/eb-garamond-600-normal-DHwxsLHv.woff2` | Font | 25 KiB | 0.11 s |
| `/build/assets/eb-garamond-500-normal-DehAIUv0.woff2` | Font | 25 KiB | 0.11 s |
| `/build/assets/eb-garamond-400-normal-BCNrxLz_.woff2` | Font | 24 KiB | 0.11 s |
| `/build/assets/app-Cswasu1n.css` | Stylesheet | 10 KiB | 0.11 s |
| insight com falha | ganho estimado de LCP | bytes |
|---|---|---|
| Render blocking requests | 0.60 s | — |
| Improve image delivery | 0.45 s | — |
| Use efficient cache lifetimes | 0.15 s | — |
| Network dependency tree | 0.00 s | — |
### home — desktop
Elemento de LCP: `<img src="http://127.0.0.1:8000/storage/content/og/og-default-960.jpg" srcset="/storage/content/og/og-default-480.jpg 480w, /storage/content/og/og-defaul…" size`
| fase | tempo |
|---|---|
| TTFB | 0.12 s |
| Load Delay | 0.45 s |
| Load Time | 0.03 s |
| Render Delay | 0.27 s |
Requests concluídas até o LCP, mais pesadas primeiro:
| recurso | tipo | transferido | fim |
|---|---|---|---|
| `/storage/content/og/og-default-960.jpg` | Image | 143 KiB | 0.09 s |
| `/storage/content/portfolio/casamento-ana-lucas-cover-960.jpg` | Image | 142 KiB | 0.10 s |
| `/storage/content/portfolio/lancamento-verano-cover-960.jpg` | Image | 106 KiB | 0.10 s |
| `/storage/content/portfolio/mini-wedding-marina-cover-960.jpg` | Image | 100 KiB | 0.10 s |
| `/brand/lockup-on-light.webp` | Image | 83 KiB | 0.08 s |
| `/brand/mark-on-light.webp` | Image | 42 KiB | 0.08 s |
| `/build/assets/eb-garamond-600-normal-xCe6GkOx.woff` | Font | 30 KiB | 0.09 s |
| `/build/assets/eb-garamond-500-normal-DZU8xF9o.woff` | Font | 30 KiB | 0.09 s |
| insight com falha | ganho estimado de LCP | bytes |
|---|---|---|
| Improve image delivery | 0.25 s | — |
| Use efficient cache lifetimes | 0.05 s | — |
| Network dependency tree | 0.00 s | — |
| Render blocking requests | 0.00 s | — |
### home — mobile
Elemento de LCP: `<img src="http://127.0.0.1:8000/storage/content/og/og-default-960.jpg" srcset="/storage/content/og/og-default-480.jpg 480w, /storage/content/og/og-defaul…" size`
| fase | tempo |
|---|---|
| TTFB | 0.45 s |
| Load Delay | 3.30 s |
| Load Time | 0.11 s |
| Render Delay | 0.72 s |
Requests concluídas até o LCP, mais pesadas primeiro:
| recurso | tipo | transferido | fim |
|---|---|---|---|
| `/storage/content/og/og-default-960.jpg` | Image | 143 KiB | 0.23 s |
| `/storage/content/portfolio/casamento-ana-lucas-cover-960.jpg` | Image | 142 KiB | 0.25 s |
| `/storage/content/portfolio/lancamento-verano-cover-960.jpg` | Image | 106 KiB | 0.25 s |
| `/storage/content/portfolio/mini-wedding-marina-cover-960.jpg` | Image | 100 KiB | 0.25 s |
| `/brand/lockup-on-light.webp` | Image | 83 KiB | 0.23 s |
| `/brand/mark-on-light.webp` | Image | 42 KiB | 0.23 s |
| `/build/assets/eb-garamond-600-normal-xCe6GkOx.woff` | Font | 30 KiB | 0.24 s |
| `/build/assets/eb-garamond-500-normal-DZU8xF9o.woff` | Font | 30 KiB | 0.24 s |
| insight com falha | ganho estimado de LCP | bytes |
|---|---|---|
| Improve image delivery | 1.35 s | — |
| Use efficient cache lifetimes | 0.30 s | — |
| Network dependency tree | 0.00 s | — |
| Render blocking requests | 0.00 s | — |
### portfolio-detalhe — desktop
Elemento de LCP: `<img src="http://127.0.0.1:8000/storage/content/portfolio/casamento-ana-lucas-cover-…" srcset="/storage/content/portfolio/casamento-ana-lucas-cover-480.jpg 480w`
| fase | tempo |
|---|---|
| TTFB | 0.12 s |
| Load Delay | 0.54 s |
| Load Time | 0.04 s |
| Render Delay | 0.26 s |
Requests concluídas até o LCP, mais pesadas primeiro:
| recurso | tipo | transferido | fim |
|---|---|---|---|
| `/storage/content/portfolio/casamento-ana-lucas-cover-1440.jpg` | Image | 287 KiB | 0.10 s |
| `/storage/content/portfolio/casamento-ana-lucas-gallery-1-960.jpg` | Image | 209 KiB | 0.11 s |
| `/storage/content/portfolio/casamento-ana-lucas-gallery-2-960.jpg` | Image | 172 KiB | 0.11 s |
| `/brand/lockup-on-light.webp` | Image | 83 KiB | 0.10 s |
| `/build/assets/eb-garamond-600-normal-xCe6GkOx.woff` | Font | 30 KiB | 0.10 s |
| `/build/assets/eb-garamond-500-normal-DZU8xF9o.woff` | Font | 30 KiB | 0.11 s |
| `/build/assets/eb-garamond-400-normal-maQnHeKB.woff` | Font | 28 KiB | 0.10 s |
| `/build/assets/eb-garamond-600-normal-DHwxsLHv.woff2` | Font | 25 KiB | 0.10 s |
| insight com falha | ganho estimado de LCP | bytes |
|---|---|---|
| Improve image delivery | 0.20 s | — |
| Use efficient cache lifetimes | 0.05 s | — |
| LCP request discovery | 0.00 s | — |
| Network dependency tree | 0.00 s | — |
| Render blocking requests | 0.00 s | — |
### portfolio-detalhe — mobile
Elemento de LCP: `<img src="http://127.0.0.1:8000/storage/content/portfolio/casamento-ana-lucas-cover-…" srcset="/storage/content/portfolio/casamento-ana-lucas-cover-480.jpg 480w`
| fase | tempo |
|---|---|
| TTFB | 0.45 s |
| Load Delay | 2.62 s |
| Load Time | 0.06 s |
| Render Delay | 0.85 s |
Requests concluídas até o LCP, mais pesadas primeiro:
| recurso | tipo | transferido | fim |
|---|---|---|---|
| `/storage/content/portfolio/casamento-ana-lucas-gallery-1-960.jpg` | Image | 209 KiB | 0.16 s |
| `/storage/content/portfolio/casamento-ana-lucas-gallery-2-960.jpg` | Image | 172 KiB | 0.16 s |
| `/storage/content/portfolio/casamento-ana-lucas-cover-960.jpg` | Image | 142 KiB | 0.14 s |
| `/brand/lockup-on-light.webp` | Image | 83 KiB | 0.14 s |
| `/build/assets/eb-garamond-600-normal-xCe6GkOx.woff` | Font | 30 KiB | 0.15 s |
| `/build/assets/eb-garamond-500-normal-DZU8xF9o.woff` | Font | 30 KiB | 0.15 s |
| `/build/assets/eb-garamond-400-normal-maQnHeKB.woff` | Font | 28 KiB | 0.15 s |
| `/build/assets/eb-garamond-600-normal-DHwxsLHv.woff2` | Font | 25 KiB | 0.14 s |
| insight com falha | ganho estimado de LCP | bytes |
|---|---|---|
| Improve image delivery | 1.20 s | — |
| Use efficient cache lifetimes | 0.15 s | — |
| LCP request discovery | 0.00 s | — |
| Network dependency tree | 0.00 s | — |
| Render blocking requests | 0.00 s | — |
### portfolio — desktop
Elemento de LCP: `<img src="http://127.0.0.1:8000/storage/content/portfolio/casamento-ana-lucas-cover-…" srcset="/storage/content/portfolio/casamento-ana-lucas-cover-480.jpg 480w`
| fase | tempo |
|---|---|
| TTFB | 0.19 s |
| Load Delay | 0.51 s |
| Load Time | 0.01 s |
| Render Delay | 0.12 s |
Requests concluídas até o LCP, mais pesadas primeiro:
| recurso | tipo | transferido | fim |
|---|---|---|---|
| `/storage/content/portfolio/casamento-ana-lucas-cover-960.jpg` | Image | 142 KiB | 0.23 s |
| `/storage/content/portfolio/lancamento-verano-cover-960.jpg` | Image | 106 KiB | 0.23 s |
| `/storage/content/portfolio/mini-wedding-marina-cover-960.jpg` | Image | 100 KiB | 0.23 s |
| `/brand/lockup-on-light.webp` | Image | 83 KiB | 0.20 s |
| `/build/assets/eb-garamond-600-normal-xCe6GkOx.woff` | Font | 30 KiB | 0.23 s |
| `/build/assets/eb-garamond-500-normal-DZU8xF9o.woff` | Font | 30 KiB | 0.23 s |
| `/build/assets/eb-garamond-400-normal-maQnHeKB.woff` | Font | 28 KiB | 0.22 s |
| `/build/assets/eb-garamond-600-normal-DHwxsLHv.woff2` | Font | 25 KiB | 0.21 s |
| insight com falha | ganho estimado de LCP | bytes |
|---|---|---|
| Improve image delivery | 0.25 s | — |
| Use efficient cache lifetimes | 0.00 s | — |
| LCP request discovery | 0.00 s | — |
| Network dependency tree | 0.00 s | — |
| Render blocking requests | 0.00 s | — |
### portfolio — mobile
Elemento de LCP: `<img src="http://127.0.0.1:8000/storage/content/portfolio/casamento-ana-lucas-cover-…" srcset="/storage/content/portfolio/casamento-ana-lucas-cover-480.jpg 480w`
| fase | tempo |
|---|---|
| TTFB | 0.45 s |
| Load Delay | 2.68 s |
| Load Time | 0.09 s |
| Render Delay | 0.76 s |
Requests concluídas até o LCP, mais pesadas primeiro:
| recurso | tipo | transferido | fim |
|---|---|---|---|
| `/storage/content/portfolio/casamento-ana-lucas-cover-960.jpg` | Image | 142 KiB | 0.13 s |
| `/storage/content/portfolio/lancamento-verano-cover-960.jpg` | Image | 106 KiB | 0.13 s |
| `/storage/content/portfolio/mini-wedding-marina-cover-960.jpg` | Image | 100 KiB | 0.13 s |
| `/brand/lockup-on-light.webp` | Image | 83 KiB | 0.12 s |
| `/build/assets/eb-garamond-600-normal-xCe6GkOx.woff` | Font | 30 KiB | 0.13 s |
| `/build/assets/eb-garamond-500-normal-DZU8xF9o.woff` | Font | 30 KiB | 0.13 s |
| `/build/assets/eb-garamond-400-normal-maQnHeKB.woff` | Font | 28 KiB | 0.13 s |
| `/build/assets/eb-garamond-600-normal-DHwxsLHv.woff2` | Font | 25 KiB | 0.12 s |
| insight com falha | ganho estimado de LCP | bytes |
|---|---|---|
| Improve image delivery | 1.35 s | — |
| Use efficient cache lifetimes | 0.15 s | — |
| LCP request discovery | 0.00 s | — |
| Network dependency tree | 0.00 s | — |
| Render blocking requests | 0.00 s | — |
### servicos — desktop
Elemento de LCP: `<img src="http://127.0.0.1:8000/storage/content/services/casamentos-960.jpg" srcset="/storage/content/services/casamentos-480.jpg 480w, /storage/content/servic…`
| fase | tempo |
|---|---|
| TTFB | 0.13 s |
| Load Delay | 0.49 s |
| Load Time | 0.03 s |
| Render Delay | 0.12 s |
Requests concluídas até o LCP, mais pesadas primeiro:
| recurso | tipo | transferido | fim |
|---|---|---|---|
| `/storage/content/services/casamentos-960.jpg` | Image | 107 KiB | 0.15 s |
| `/storage/content/services/eventos-corporativos-960.jpg` | Image | 100 KiB | 0.15 s |
| `/storage/content/services/celebracoes-intimistas-960.jpg` | Image | 83 KiB | 0.15 s |
| `/brand/lockup-on-light.webp` | Image | 83 KiB | 0.14 s |
| `/build/assets/eb-garamond-600-normal-xCe6GkOx.woff` | Font | 30 KiB | 0.15 s |
| `/build/assets/eb-garamond-500-normal-DZU8xF9o.woff` | Font | 30 KiB | 0.15 s |
| `/build/assets/eb-garamond-400-normal-maQnHeKB.woff` | Font | 28 KiB | 0.15 s |
| `/build/assets/eb-garamond-600-normal-DHwxsLHv.woff2` | Font | 25 KiB | 0.14 s |
| insight com falha | ganho estimado de LCP | bytes |
|---|---|---|
| Improve image delivery | 0.20 s | — |
| Use efficient cache lifetimes | 0.05 s | — |
| LCP request discovery | 0.00 s | — |
| Network dependency tree | 0.00 s | — |
| Render blocking requests | 0.00 s | — |
### servicos — mobile
Elemento de LCP: `<img src="http://127.0.0.1:8000/storage/content/services/casamentos-960.jpg" srcset="/storage/content/services/casamentos-480.jpg 480w, /storage/content/servic…`
| fase | tempo |
|---|---|
| TTFB | 0.45 s |
| Load Delay | 2.22 s |
| Load Time | 0.07 s |
| Render Delay | 0.94 s |
Requests concluídas até o LCP, mais pesadas primeiro:
| recurso | tipo | transferido | fim |
|---|---|---|---|
| `/storage/content/services/casamentos-960.jpg` | Image | 107 KiB | 0.12 s |
| `/storage/content/services/eventos-corporativos-960.jpg` | Image | 100 KiB | 0.12 s |
| `/storage/content/services/celebracoes-intimistas-960.jpg` | Image | 83 KiB | 0.12 s |
| `/brand/lockup-on-light.webp` | Image | 83 KiB | 0.09 s |
| `/build/assets/eb-garamond-600-normal-xCe6GkOx.woff` | Font | 30 KiB | 0.12 s |
| `/build/assets/eb-garamond-500-normal-DZU8xF9o.woff` | Font | 30 KiB | 0.12 s |
| `/build/assets/eb-garamond-400-normal-maQnHeKB.woff` | Font | 28 KiB | 0.12 s |
| `/build/assets/eb-garamond-600-normal-DHwxsLHv.woff2` | Font | 25 KiB | 0.09 s |
| insight com falha | ganho estimado de LCP | bytes |
|---|---|---|
| Improve image delivery | 1.20 s | — |
| Use efficient cache lifetimes | 0.15 s | — |
| LCP request discovery | 0.00 s | — |
| Network dependency tree | 0.00 s | — |
| Render blocking requests | 0.00 s | — |
### sobre — desktop
Elemento de LCP: `<img src="http://127.0.0.1:8000/storage/content/about/about-image-960.jpg" srcset="/storage/content/about/about-image-480.jpg 480w, /storage/content/about/ab…" `
| fase | tempo |
|---|---|
| TTFB | 0.12 s |
| Load Delay | 0.25 s |
| Load Time | 0.01 s |
| Render Delay | 0.22 s |
Requests concluídas até o LCP, mais pesadas primeiro:
| recurso | tipo | transferido | fim |
|---|---|---|---|
| `/brand/lockup-on-light.webp` | Image | 83 KiB | 0.06 s |
| `/storage/content/about/about-image-960.jpg` | Image | 60 KiB | 0.07 s |
| `/build/assets/eb-garamond-600-normal-xCe6GkOx.woff` | Font | 30 KiB | 0.06 s |
| `/build/assets/eb-garamond-500-normal-DZU8xF9o.woff` | Font | 30 KiB | 0.06 s |
| `/build/assets/eb-garamond-400-normal-maQnHeKB.woff` | Font | 28 KiB | 0.06 s |
| `/build/assets/eb-garamond-600-normal-DHwxsLHv.woff2` | Font | 25 KiB | 0.06 s |
| `/build/assets/eb-garamond-500-normal-DehAIUv0.woff2` | Font | 25 KiB | 0.05 s |
| `/build/assets/eb-garamond-400-normal-BCNrxLz_.woff2` | Font | 24 KiB | 0.05 s |
| insight com falha | ganho estimado de LCP | bytes |
|---|---|---|
| Improve image delivery | 0.10 s | — |
| Use efficient cache lifetimes | 0.05 s | — |
| LCP request discovery | 0.00 s | — |
| Network dependency tree | 0.00 s | — |
| Render blocking requests | 0.00 s | — |
### sobre — mobile
Elemento de LCP: `<img src="http://127.0.0.1:8000/storage/content/about/about-image-960.jpg" srcset="/storage/content/about/about-image-480.jpg 480w, /storage/content/about/ab…" `
| fase | tempo |
|---|---|
| TTFB | 0.45 s |
| Load Delay | 1.94 s |
| Load Time | 0.07 s |
| Render Delay | 0.54 s |
Requests concluídas até o LCP, mais pesadas primeiro:
| recurso | tipo | transferido | fim |
|---|---|---|---|
| `/brand/lockup-on-light.webp` | Image | 83 KiB | 0.14 s |
| `/storage/content/about/about-image-960.jpg` | Image | 60 KiB | 0.16 s |
| `/build/assets/eb-garamond-600-normal-xCe6GkOx.woff` | Font | 30 KiB | 0.15 s |
| `/build/assets/eb-garamond-500-normal-DZU8xF9o.woff` | Font | 30 KiB | 0.15 s |
| `/build/assets/eb-garamond-400-normal-maQnHeKB.woff` | Font | 28 KiB | 0.16 s |
| `/build/assets/eb-garamond-600-normal-DHwxsLHv.woff2` | Font | 25 KiB | 0.14 s |
| `/build/assets/eb-garamond-500-normal-DehAIUv0.woff2` | Font | 25 KiB | 0.14 s |
| `/build/assets/eb-garamond-400-normal-BCNrxLz_.woff2` | Font | 24 KiB | 0.14 s |
| insight com falha | ganho estimado de LCP | bytes |
|---|---|---|
| Improve image delivery | 0.90 s | — |
| Use efficient cache lifetimes | 0.30 s | — |
| LCP request discovery | 0.00 s | — |
| Network dependency tree | 0.00 s | — |
| Render blocking requests | 0.00 s | — |

View File

@@ -0,0 +1,407 @@
# Lighthouse — local-sizes
- Origem: `http://127.0.0.1:8000`
- Lighthouse: 12.8.2
- Navegador: `Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/151.0.0.0 Safari/537.36`
- Coletado em: 2026-08-10T17:38:33.741Z
- Execuções por página/preset: 3 (reportada a de LCP mediano)
- Commit: `9ab2beb` (o script gravou `65919d4`, o HEAD no momento da coleta; a árvore medida é a que virou `9ab2beb`)
- Seeder: `ContentSeeder`
Metas SPEC §6.6: LCP ≤ 2,5 s · CLS ≤ 0,1 · INP ≤ 200 ms · zero erro de console.
| página | preset | perf | a11y | BP | SEO | LCP | FCP | CLS | TBT | TTFB servidor | LCP minmax |
|---|---|---|---|---|---|---|---|---|---|---|---|
| contato | desktop | 100 | 100 | 100 | 100 | 0.36 s | 0.25 s | 0.000 | 0 ms | 91 ms | 0.36 s 0.37 s |
| contato | mobile | 100 | 100 | 100 | 100 | 1.50 s | 0.92 s | 0.000 | 0 ms | 66 ms | 1.50 s 1.51 s |
| home | desktop | 100 | 100 | 100 | 100 | 0.53 s | 0.25 s | 0.000 | 0 ms | 110 ms | 0.53 s 0.54 s |
| home | mobile | 98 | 100 | 100 | 100 | 2.49 s | 0.92 s | 0.000 | 0 ms | 77 ms | 2.48 s 2.57 s |
| portfolio-detalhe | desktop | 100 | 100 | 100 | 100 | 0.65 s | 0.25 s | 0.000 | 0 ms | 81 ms | 0.65 s 0.66 s |
| portfolio-detalhe | mobile | 99 | 100 | 100 | 100 | 2.18 s | 0.90 s | 0.000 | 0 ms | 77 ms | 2.18 s 2.18 s |
| portfolio | desktop | 100 | 100 | 100 | 100 | 0.36 s | 0.25 s | 0.000 | 0 ms | 72 ms | 0.36 s 0.49 s |
| portfolio | mobile | 100 | 100 | 100 | 100 | 1.58 s | 0.91 s | 0.000 | 0 ms | 71 ms | 1.58 s 1.58 s |
| servicos | desktop | 100 | 100 | 100 | 100 | 0.36 s | 0.24 s | 0.000 | 0 ms | 59 ms | 0.36 s 0.38 s |
| servicos | mobile | 99 | 100 | 100 | 100 | 2.03 s | 0.90 s | 0.000 | 0 ms | 62 ms | 1.58 s 2.03 s |
| sobre | desktop | 100 | 100 | 100 | 100 | 0.36 s | 0.25 s | 0.000 | 0 ms | 51 ms | 0.36 s 0.37 s |
| sobre | mobile | 100 | 100 | 100 | 100 | 1.58 s | 0.91 s | 0.000 | 0 ms | 93 ms | 1.58 s 1.58 s |
## Decomposição do LCP
### contato — desktop
Elemento de LCP: `<h1 class="text-headline font-medium tracking-tight text-amare-text">`
| fase | tempo |
|---|---|
| TTFB | 0.12 s |
| Load Delay | 0.00 s |
| Load Time | 0.00 s |
| Render Delay | 0.24 s |
Requests concluídas até o LCP, mais pesadas primeiro:
| recurso | tipo | transferido | fim |
|---|---|---|---|
| `/build/assets/eb-garamond-600-normal-DHwxsLHv.woff2` | Font | 25 KiB | 0.10 s |
| `/build/assets/eb-garamond-500-normal-DehAIUv0.woff2` | Font | 25 KiB | 0.10 s |
| `/build/assets/eb-garamond-400-normal-BCNrxLz_.woff2` | Font | 24 KiB | 0.10 s |
| `/brand/lockup-on-light.webp` | Image | 14 KiB | 0.10 s |
| `/build/assets/app-8wBXH8kj.css` | Stylesheet | 10 KiB | 0.10 s |
| `/contato` | Document | 5 KiB | 0.09 s |
| `/build/assets/app-Cr9w0NCu.js` | Script | 2 KiB | 0.10 s |
| insight com falha | ganho estimado de LCP | bytes |
|---|---|---|
| Use efficient cache lifetimes | 0.00 s | — |
| Improve image delivery | 0.00 s | — |
| Network dependency tree | 0.00 s | — |
| Render blocking requests | 0.00 s | — |
### contato — mobile
Elemento de LCP: `<h1 class="text-headline font-medium tracking-tight text-amare-text">`
| fase | tempo |
|---|---|
| TTFB | 0.45 s |
| Load Delay | 0.00 s |
| Load Time | 0.00 s |
| Render Delay | 1.05 s |
Requests concluídas até o LCP, mais pesadas primeiro:
| recurso | tipo | transferido | fim |
|---|---|---|---|
| `/build/assets/eb-garamond-600-normal-DHwxsLHv.woff2` | Font | 25 KiB | 0.08 s |
| `/build/assets/eb-garamond-500-normal-DehAIUv0.woff2` | Font | 25 KiB | 0.07 s |
| `/build/assets/eb-garamond-400-normal-BCNrxLz_.woff2` | Font | 24 KiB | 0.07 s |
| `/brand/lockup-on-light.webp` | Image | 14 KiB | 0.08 s |
| `/build/assets/app-8wBXH8kj.css` | Stylesheet | 10 KiB | 0.08 s |
| `/contato` | Document | 5 KiB | 0.07 s |
| `/build/assets/app-Cr9w0NCu.js` | Script | 2 KiB | 0.08 s |
| insight com falha | ganho estimado de LCP | bytes |
|---|---|---|
| Use efficient cache lifetimes | 0.00 s | — |
| Improve image delivery | 0.00 s | — |
| Network dependency tree | 0.00 s | — |
| Render blocking requests | 0.00 s | — |
### home — desktop
Elemento de LCP: `<img src="http://127.0.0.1:8000/storage/content/og/og-default-720.jpg.webp" srcset="/storage/content/og/og-default-480.jpg 480w, /storage/content/og/og-defaul…"`
| fase | tempo |
|---|---|
| TTFB | 0.12 s |
| Load Delay | 0.25 s |
| Load Time | 0.03 s |
| Render Delay | 0.12 s |
Requests concluídas até o LCP, mais pesadas primeiro:
| recurso | tipo | transferido | fim |
|---|---|---|---|
| `/storage/content/og/og-default-720.jpg.webp` | Image | 74 KiB | 0.13 s |
| `/storage/content/portfolio/casamento-ana-lucas-cover-720.jpg.webp` | Image | 67 KiB | 0.14 s |
| `/storage/content/portfolio/lancamento-verano-cover-720.jpg.webp` | Image | 45 KiB | 0.14 s |
| `/storage/content/portfolio/mini-wedding-marina-cover-720.jpg.webp` | Image | 43 KiB | 0.14 s |
| `/build/assets/eb-garamond-600-normal-DHwxsLHv.woff2` | Font | 25 KiB | 0.12 s |
| `/build/assets/eb-garamond-500-normal-DehAIUv0.woff2` | Font | 25 KiB | 0.12 s |
| `/build/assets/eb-garamond-400-normal-BCNrxLz_.woff2` | Font | 24 KiB | 0.12 s |
| `/brand/lockup-on-light.webp` | Image | 14 KiB | 0.12 s |
| insight com falha | ganho estimado de LCP | bytes |
|---|---|---|
| Improve image delivery | 0.10 s | — |
| Use efficient cache lifetimes | 0.00 s | — |
| Network dependency tree | 0.00 s | — |
| Render blocking requests | 0.00 s | — |
### home — mobile
Elemento de LCP: `<img src="http://127.0.0.1:8000/storage/content/og/og-default-720.jpg.webp" srcset="/storage/content/og/og-default-480.jpg 480w, /storage/content/og/og-defaul…"`
| fase | tempo |
|---|---|
| TTFB | 0.45 s |
| Load Delay | 1.24 s |
| Load Time | 0.10 s |
| Render Delay | 0.69 s |
Requests concluídas até o LCP, mais pesadas primeiro:
| recurso | tipo | transferido | fim |
|---|---|---|---|
| `/storage/content/og/og-default-720.jpg.webp` | Image | 74 KiB | 0.09 s |
| `/storage/content/portfolio/casamento-ana-lucas-cover-720.jpg.webp` | Image | 67 KiB | 0.10 s |
| `/storage/content/portfolio/lancamento-verano-cover-720.jpg.webp` | Image | 45 KiB | 0.10 s |
| `/storage/content/portfolio/mini-wedding-marina-cover-720.jpg.webp` | Image | 43 KiB | 0.10 s |
| `/build/assets/eb-garamond-600-normal-DHwxsLHv.woff2` | Font | 25 KiB | 0.09 s |
| `/build/assets/eb-garamond-500-normal-DehAIUv0.woff2` | Font | 25 KiB | 0.09 s |
| `/build/assets/eb-garamond-400-normal-BCNrxLz_.woff2` | Font | 24 KiB | 0.09 s |
| `/brand/lockup-on-light.webp` | Image | 14 KiB | 0.09 s |
| insight com falha | ganho estimado de LCP | bytes |
|---|---|---|
| Improve image delivery | 0.45 s | — |
| Use efficient cache lifetimes | 0.00 s | — |
| Network dependency tree | 0.00 s | — |
| Render blocking requests | 0.00 s | — |
### portfolio-detalhe — desktop
Elemento de LCP: `<img src="http://127.0.0.1:8000/storage/content/portfolio/casamento-ana-lucas-cover-…" srcset="/storage/content/portfolio/casamento-ana-lucas-cover-480.jpg 480w`
| fase | tempo |
|---|---|
| TTFB | 0.12 s |
| Load Delay | 0.28 s |
| Load Time | 0.03 s |
| Render Delay | 0.22 s |
Requests concluídas até o LCP, mais pesadas primeiro:
| recurso | tipo | transferido | fim |
|---|---|---|---|
| `/storage/content/portfolio/casamento-ana-lucas-cover-1440.jpg.webp` | Image | 210 KiB | 0.09 s |
| `/storage/content/portfolio/casamento-ana-lucas-gallery-1-720.jpg.webp` | Image | 105 KiB | 0.10 s |
| `/storage/content/portfolio/casamento-ana-lucas-gallery-2-720.jpg.webp` | Image | 88 KiB | 0.10 s |
| `/build/assets/eb-garamond-600-normal-DHwxsLHv.woff2` | Font | 25 KiB | 0.09 s |
| `/build/assets/eb-garamond-500-normal-DehAIUv0.woff2` | Font | 25 KiB | 0.09 s |
| `/build/assets/eb-garamond-400-normal-BCNrxLz_.woff2` | Font | 24 KiB | 0.09 s |
| `/brand/lockup-on-light.webp` | Image | 14 KiB | 0.09 s |
| `/build/assets/app-8wBXH8kj.css` | Stylesheet | 10 KiB | 0.09 s |
| insight com falha | ganho estimado de LCP | bytes |
|---|---|---|
| Improve image delivery | 0.10 s | — |
| Use efficient cache lifetimes | 0.00 s | — |
| LCP request discovery | 0.00 s | — |
| Network dependency tree | 0.00 s | — |
| Render blocking requests | 0.00 s | — |
### portfolio-detalhe — mobile
Elemento de LCP: `<img src="http://127.0.0.1:8000/storage/content/portfolio/casamento-ana-lucas-cover-…" srcset="/storage/content/portfolio/casamento-ana-lucas-cover-480.jpg 480w`
| fase | tempo |
|---|---|
| TTFB | 0.45 s |
| Load Delay | 1.11 s |
| Load Time | 0.04 s |
| Render Delay | 0.57 s |
Requests concluídas até o LCP, mais pesadas primeiro:
| recurso | tipo | transferido | fim |
|---|---|---|---|
| `/storage/content/portfolio/casamento-ana-lucas-gallery-1-720.jpg.webp` | Image | 105 KiB | 0.09 s |
| `/storage/content/portfolio/casamento-ana-lucas-gallery-2-720.jpg.webp` | Image | 88 KiB | 0.09 s |
| `/storage/content/portfolio/casamento-ana-lucas-cover-720.jpg.webp` | Image | 67 KiB | 0.09 s |
| `/build/assets/eb-garamond-600-normal-DHwxsLHv.woff2` | Font | 25 KiB | 0.08 s |
| `/build/assets/eb-garamond-500-normal-DehAIUv0.woff2` | Font | 25 KiB | 0.08 s |
| `/build/assets/eb-garamond-400-normal-BCNrxLz_.woff2` | Font | 24 KiB | 0.08 s |
| `/brand/lockup-on-light.webp` | Image | 14 KiB | 0.09 s |
| `/build/assets/app-8wBXH8kj.css` | Stylesheet | 10 KiB | 0.09 s |
| insight com falha | ganho estimado de LCP | bytes |
|---|---|---|
| Improve image delivery | 0.45 s | — |
| Use efficient cache lifetimes | 0.00 s | — |
| LCP request discovery | 0.00 s | — |
| Network dependency tree | 0.00 s | — |
| Render blocking requests | 0.00 s | — |
### portfolio — desktop
Elemento de LCP: `<img src="http://127.0.0.1:8000/storage/content/portfolio/casamento-ana-lucas-cover-…" srcset="/storage/content/portfolio/casamento-ana-lucas-cover-480.jpg 480w`
| fase | tempo |
|---|---|
| TTFB | 0.12 s |
| Load Delay | 0.14 s |
| Load Time | 0.01 s |
| Render Delay | 0.09 s |
Requests concluídas até o LCP, mais pesadas primeiro:
| recurso | tipo | transferido | fim |
|---|---|---|---|
| `/storage/content/portfolio/casamento-ana-lucas-cover-720.jpg.webp` | Image | 67 KiB | 0.09 s |
| `/storage/content/portfolio/lancamento-verano-cover-720.jpg.webp` | Image | 45 KiB | 0.09 s |
| `/storage/content/portfolio/mini-wedding-marina-cover-720.jpg.webp` | Image | 43 KiB | 0.09 s |
| `/build/assets/eb-garamond-600-normal-DHwxsLHv.woff2` | Font | 25 KiB | 0.08 s |
| `/build/assets/eb-garamond-500-normal-DehAIUv0.woff2` | Font | 25 KiB | 0.08 s |
| `/build/assets/eb-garamond-400-normal-BCNrxLz_.woff2` | Font | 24 KiB | 0.08 s |
| `/brand/lockup-on-light.webp` | Image | 14 KiB | 0.08 s |
| `/build/assets/app-8wBXH8kj.css` | Stylesheet | 10 KiB | 0.08 s |
| insight com falha | ganho estimado de LCP | bytes |
|---|---|---|
| Use efficient cache lifetimes | 0.00 s | — |
| Improve image delivery | 0.00 s | — |
| LCP request discovery | 0.00 s | — |
| Network dependency tree | 0.00 s | — |
| Render blocking requests | 0.00 s | — |
### portfolio — mobile
Elemento de LCP: `<img src="http://127.0.0.1:8000/storage/content/portfolio/casamento-ana-lucas-cover-…" srcset="/storage/content/portfolio/casamento-ana-lucas-cover-480.jpg 480w`
| fase | tempo |
|---|---|
| TTFB | 0.45 s |
| Load Delay | 0.66 s |
| Load Time | 0.04 s |
| Render Delay | 0.42 s |
Requests concluídas até o LCP, mais pesadas primeiro:
| recurso | tipo | transferido | fim |
|---|---|---|---|
| `/storage/content/portfolio/casamento-ana-lucas-cover-720.jpg.webp` | Image | 67 KiB | 0.09 s |
| `/storage/content/portfolio/lancamento-verano-cover-720.jpg.webp` | Image | 45 KiB | 0.09 s |
| `/storage/content/portfolio/mini-wedding-marina-cover-720.jpg.webp` | Image | 43 KiB | 0.09 s |
| `/build/assets/eb-garamond-600-normal-DHwxsLHv.woff2` | Font | 25 KiB | 0.08 s |
| `/build/assets/eb-garamond-500-normal-DehAIUv0.woff2` | Font | 25 KiB | 0.08 s |
| `/build/assets/eb-garamond-400-normal-BCNrxLz_.woff2` | Font | 24 KiB | 0.08 s |
| `/brand/lockup-on-light.webp` | Image | 14 KiB | 0.08 s |
| `/build/assets/app-8wBXH8kj.css` | Stylesheet | 10 KiB | 0.08 s |
| insight com falha | ganho estimado de LCP | bytes |
|---|---|---|
| Use efficient cache lifetimes | 0.00 s | — |
| Improve image delivery | 0.00 s | — |
| LCP request discovery | 0.00 s | — |
| Network dependency tree | 0.00 s | — |
| Render blocking requests | 0.00 s | — |
### servicos — desktop
Elemento de LCP: `<img src="http://127.0.0.1:8000/storage/content/services/casamentos-720.jpg.webp" srcset="/storage/content/services/casamentos-480.jpg 480w, /storage/content/se`
| fase | tempo |
|---|---|
| TTFB | 0.12 s |
| Load Delay | 0.14 s |
| Load Time | 0.01 s |
| Render Delay | 0.10 s |
Requests concluídas até o LCP, mais pesadas primeiro:
| recurso | tipo | transferido | fim |
|---|---|---|---|
| `/storage/content/services/casamentos-720.jpg.webp` | Image | 49 KiB | 0.08 s |
| `/storage/content/services/eventos-corporativos-720.jpg.webp` | Image | 39 KiB | 0.08 s |
| `/storage/content/services/celebracoes-intimistas-720.jpg.webp` | Image | 28 KiB | 0.08 s |
| `/build/assets/eb-garamond-600-normal-DHwxsLHv.woff2` | Font | 25 KiB | 0.07 s |
| `/build/assets/eb-garamond-500-normal-DehAIUv0.woff2` | Font | 25 KiB | 0.06 s |
| `/build/assets/eb-garamond-400-normal-BCNrxLz_.woff2` | Font | 24 KiB | 0.06 s |
| `/brand/lockup-on-light.webp` | Image | 14 KiB | 0.07 s |
| `/build/assets/app-8wBXH8kj.css` | Stylesheet | 10 KiB | 0.07 s |
| insight com falha | ganho estimado de LCP | bytes |
|---|---|---|
| Use efficient cache lifetimes | 0.00 s | — |
| Improve image delivery | 0.00 s | — |
| LCP request discovery | 0.00 s | — |
| Network dependency tree | 0.00 s | — |
| Render blocking requests | 0.00 s | — |
### servicos — mobile
Elemento de LCP: `<img src="http://127.0.0.1:8000/storage/content/services/casamentos-720.jpg.webp" srcset="/storage/content/services/casamentos-480.jpg 480w, /storage/content/se`
| fase | tempo |
|---|---|
| TTFB | 0.45 s |
| Load Delay | 1.08 s |
| Load Time | 0.07 s |
| Render Delay | 0.43 s |
Requests concluídas até o LCP, mais pesadas primeiro:
| recurso | tipo | transferido | fim |
|---|---|---|---|
| `/storage/content/services/casamentos-720.jpg.webp` | Image | 49 KiB | 0.08 s |
| `/storage/content/services/eventos-corporativos-720.jpg.webp` | Image | 39 KiB | 0.08 s |
| `/storage/content/services/celebracoes-intimistas-720.jpg.webp` | Image | 28 KiB | 0.08 s |
| `/build/assets/eb-garamond-600-normal-DHwxsLHv.woff2` | Font | 25 KiB | 0.07 s |
| `/build/assets/eb-garamond-500-normal-DehAIUv0.woff2` | Font | 25 KiB | 0.07 s |
| `/build/assets/eb-garamond-400-normal-BCNrxLz_.woff2` | Font | 24 KiB | 0.07 s |
| `/brand/lockup-on-light.webp` | Image | 14 KiB | 0.07 s |
| `/build/assets/app-8wBXH8kj.css` | Stylesheet | 10 KiB | 0.07 s |
| insight com falha | ganho estimado de LCP | bytes |
|---|---|---|
| Improve image delivery | 0.45 s | — |
| Use efficient cache lifetimes | 0.15 s | — |
| LCP request discovery | 0.00 s | — |
| Network dependency tree | 0.00 s | — |
| Render blocking requests | 0.00 s | — |
### sobre — desktop
Elemento de LCP: `<img src="http://127.0.0.1:8000/storage/content/about/about-image-720.jpg.webp" srcset="/storage/content/about/about-image-480.jpg 480w, /storage/content/about/`
| fase | tempo |
|---|---|
| TTFB | 0.12 s |
| Load Delay | 0.11 s |
| Load Time | 0.01 s |
| Render Delay | 0.12 s |
Requests concluídas até o LCP, mais pesadas primeiro:
| recurso | tipo | transferido | fim |
|---|---|---|---|
| `/build/assets/eb-garamond-600-normal-DHwxsLHv.woff2` | Font | 25 KiB | 0.06 s |
| `/build/assets/eb-garamond-500-normal-DehAIUv0.woff2` | Font | 25 KiB | 0.06 s |
| `/build/assets/eb-garamond-400-normal-BCNrxLz_.woff2` | Font | 24 KiB | 0.06 s |
| `/storage/content/about/about-image-720.jpg.webp` | Image | 22 KiB | 0.07 s |
| `/brand/lockup-on-light.webp` | Image | 14 KiB | 0.06 s |
| `/build/assets/app-8wBXH8kj.css` | Stylesheet | 10 KiB | 0.06 s |
| `/sobre` | Document | 5 KiB | 0.05 s |
| `/build/assets/app-Cr9w0NCu.js` | Script | 2 KiB | 0.06 s |
| insight com falha | ganho estimado de LCP | bytes |
|---|---|---|
| Use efficient cache lifetimes | 0.00 s | — |
| Improve image delivery | 0.00 s | — |
| LCP request discovery | 0.00 s | — |
| Network dependency tree | 0.00 s | — |
| Render blocking requests | 0.00 s | — |
### sobre — mobile
Elemento de LCP: `<img src="http://127.0.0.1:8000/storage/content/about/about-image-720.jpg.webp" srcset="/storage/content/about/about-image-480.jpg 480w, /storage/content/about/`
| fase | tempo |
|---|---|
| TTFB | 0.45 s |
| Load Delay | 0.77 s |
| Load Time | 0.02 s |
| Render Delay | 0.33 s |
Requests concluídas até o LCP, mais pesadas primeiro:
| recurso | tipo | transferido | fim |
|---|---|---|---|
| `/build/assets/eb-garamond-600-normal-DHwxsLHv.woff2` | Font | 25 KiB | 0.10 s |
| `/build/assets/eb-garamond-500-normal-DehAIUv0.woff2` | Font | 25 KiB | 0.10 s |
| `/build/assets/eb-garamond-400-normal-BCNrxLz_.woff2` | Font | 24 KiB | 0.10 s |
| `/storage/content/about/about-image-720.jpg.webp` | Image | 22 KiB | 0.11 s |
| `/brand/lockup-on-light.webp` | Image | 14 KiB | 0.10 s |
| `/build/assets/app-8wBXH8kj.css` | Stylesheet | 10 KiB | 0.10 s |
| `/sobre` | Document | 5 KiB | 0.09 s |
| `/build/assets/app-Cr9w0NCu.js` | Script | 2 KiB | 0.10 s |
| insight com falha | ganho estimado de LCP | bytes |
|---|---|---|
| Use efficient cache lifetimes | 0.00 s | — |
| Improve image delivery | 0.00 s | — |
| LCP request discovery | 0.00 s | — |
| Network dependency tree | 0.00 s | — |
| Render blocking requests | 0.00 s | — |

View File

@@ -0,0 +1,407 @@
# Lighthouse — local-pos
- Origem: `http://127.0.0.1:8000`
- Lighthouse: 12.8.2
- Navegador: `Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/151.0.0.0 Safari/537.36`
- Coletado em: 2026-08-10T16:20:48.372Z
- Execuções por página/preset: 3 (reportada a de LCP mediano)
- Commit: `65919d4` (o script gravou `2e43fde`, o HEAD no momento da coleta; a árvore medida é a que virou `65919d4`)
- Seeder: `ContentSeeder`
Metas SPEC §6.6: LCP ≤ 2,5 s · CLS ≤ 0,1 · INP ≤ 200 ms · zero erro de console.
| página | preset | perf | a11y | BP | SEO | LCP | FCP | CLS | TBT | TTFB servidor | LCP minmax |
|---|---|---|---|---|---|---|---|---|---|---|---|
| contato | desktop | 100 | 100 | 100 | 100 | 0.36 s | 0.25 s | 0.000 | 0 ms | 20 ms | 0.36 s 0.37 s |
| contato | mobile | 100 | 100 | 100 | 100 | 1.51 s | 0.92 s | 0.000 | 0 ms | 46 ms | 1.50 s 1.51 s |
| home | desktop | 100 | 100 | 100 | 100 | 0.67 s | 0.25 s | 0.000 | 0 ms | 87 ms | 0.67 s 0.67 s |
| home | mobile | 92 | 100 | 100 | 100 | 3.39 s | 0.92 s | 0.000 | 0 ms | 88 ms | 3.38 s 3.46 s |
| portfolio-detalhe | desktop | 100 | 100 | 100 | 100 | 0.79 s | 0.25 s | 0.000 | 0 ms | 74 ms | 0.79 s 0.79 s |
| portfolio-detalhe | mobile | 95 | 100 | 100 | 100 | 3.01 s | 0.91 s | 0.000 | 0 ms | 89 ms | 2.93 s 3.01 s |
| portfolio | desktop | 100 | 100 | 100 | 100 | 0.65 s | 0.25 s | 0.000 | 0 ms | 73 ms | 0.37 s 0.65 s |
| portfolio | mobile | 100 | 100 | 100 | 100 | 1.58 s | 0.91 s | 0.000 | 0 ms | 69 ms | 1.58 s 3.24 s |
| servicos | desktop | 100 | 100 | 100 | 100 | 0.36 s | 0.25 s | 0.000 | 0 ms | 121 ms | 0.36 s 0.59 s |
| servicos | mobile | 97 | 100 | 100 | 100 | 2.63 s | 0.90 s | 0.000 | 0 ms | 73 ms | 1.58 s 2.63 s |
| sobre | desktop | 100 | 100 | 100 | 100 | 0.37 s | 0.25 s | 0.000 | 0 ms | 97 ms | 0.36 s 0.45 s |
| sobre | mobile | 100 | 100 | 100 | 100 | 1.58 s | 0.91 s | 0.000 | 0 ms | 48 ms | 1.58 s 1.96 s |
## Decomposição do LCP
### contato — desktop
Elemento de LCP: `<h1 class="text-headline font-medium tracking-tight text-amare-text">`
| fase | tempo |
|---|---|
| TTFB | 0.12 s |
| Load Delay | 0.00 s |
| Load Time | 0.00 s |
| Render Delay | 0.24 s |
Requests concluídas até o LCP, mais pesadas primeiro:
| recurso | tipo | transferido | fim |
|---|---|---|---|
| `/build/assets/eb-garamond-600-normal-DHwxsLHv.woff2` | Font | 25 KiB | 0.03 s |
| `/build/assets/eb-garamond-500-normal-DehAIUv0.woff2` | Font | 25 KiB | 0.03 s |
| `/build/assets/eb-garamond-400-normal-BCNrxLz_.woff2` | Font | 24 KiB | 0.03 s |
| `/brand/lockup-on-light.webp` | Image | 14 KiB | 0.03 s |
| `/build/assets/app-Cswasu1n.css` | Stylesheet | 10 KiB | 0.03 s |
| `/contato` | Document | 5 KiB | 0.02 s |
| `/build/assets/app-Cr9w0NCu.js` | Script | 2 KiB | 0.03 s |
| insight com falha | ganho estimado de LCP | bytes |
|---|---|---|
| Use efficient cache lifetimes | 0.00 s | — |
| Improve image delivery | 0.00 s | — |
| Network dependency tree | 0.00 s | — |
| Render blocking requests | 0.00 s | — |
### contato — mobile
Elemento de LCP: `<h1 class="text-headline font-medium tracking-tight text-amare-text">`
| fase | tempo |
|---|---|
| TTFB | 0.45 s |
| Load Delay | 0.00 s |
| Load Time | 0.00 s |
| Render Delay | 1.05 s |
Requests concluídas até o LCP, mais pesadas primeiro:
| recurso | tipo | transferido | fim |
|---|---|---|---|
| `/build/assets/eb-garamond-600-normal-DHwxsLHv.woff2` | Font | 25 KiB | 0.06 s |
| `/build/assets/eb-garamond-500-normal-DehAIUv0.woff2` | Font | 25 KiB | 0.05 s |
| `/build/assets/eb-garamond-400-normal-BCNrxLz_.woff2` | Font | 24 KiB | 0.05 s |
| `/brand/lockup-on-light.webp` | Image | 14 KiB | 0.06 s |
| `/build/assets/app-Cswasu1n.css` | Stylesheet | 10 KiB | 0.06 s |
| `/contato` | Document | 5 KiB | 0.05 s |
| `/build/assets/app-Cr9w0NCu.js` | Script | 2 KiB | 0.06 s |
| insight com falha | ganho estimado de LCP | bytes |
|---|---|---|
| Use efficient cache lifetimes | 0.00 s | — |
| Improve image delivery | 0.00 s | — |
| Network dependency tree | 0.00 s | — |
| Render blocking requests | 0.00 s | — |
### home — desktop
Elemento de LCP: `<img src="http://127.0.0.1:8000/storage/content/og/og-default-960.jpg" srcset="/storage/content/og/og-default-480.jpg 480w, /storage/content/og/og-defaul…" size`
| fase | tempo |
|---|---|
| TTFB | 0.12 s |
| Load Delay | 0.33 s |
| Load Time | 0.03 s |
| Render Delay | 0.19 s |
Requests concluídas até o LCP, mais pesadas primeiro:
| recurso | tipo | transferido | fim |
|---|---|---|---|
| `/storage/content/og/og-default-960.jpg` | Image | 143 KiB | 0.10 s |
| `/storage/content/portfolio/casamento-ana-lucas-cover-960.jpg` | Image | 142 KiB | 0.11 s |
| `/storage/content/portfolio/lancamento-verano-cover-960.jpg` | Image | 106 KiB | 0.11 s |
| `/storage/content/portfolio/mini-wedding-marina-cover-960.jpg` | Image | 100 KiB | 0.11 s |
| `/build/assets/eb-garamond-600-normal-DHwxsLHv.woff2` | Font | 25 KiB | 0.10 s |
| `/build/assets/eb-garamond-500-normal-DehAIUv0.woff2` | Font | 25 KiB | 0.09 s |
| `/build/assets/eb-garamond-400-normal-BCNrxLz_.woff2` | Font | 24 KiB | 0.10 s |
| `/brand/lockup-on-light.webp` | Image | 14 KiB | 0.10 s |
| insight com falha | ganho estimado de LCP | bytes |
|---|---|---|
| Improve image delivery | 0.15 s | — |
| Use efficient cache lifetimes | 0.00 s | — |
| Network dependency tree | 0.00 s | — |
| Render blocking requests | 0.00 s | — |
### home — mobile
Elemento de LCP: `<img src="http://127.0.0.1:8000/storage/content/og/og-default-960.jpg" srcset="/storage/content/og/og-default-480.jpg 480w, /storage/content/og/og-defaul…" size`
| fase | tempo |
|---|---|
| TTFB | 0.45 s |
| Load Delay | 1.88 s |
| Load Time | 0.17 s |
| Render Delay | 0.88 s |
Requests concluídas até o LCP, mais pesadas primeiro:
| recurso | tipo | transferido | fim |
|---|---|---|---|
| `/storage/content/og/og-default-960.jpg` | Image | 143 KiB | 0.10 s |
| `/storage/content/portfolio/casamento-ana-lucas-cover-960.jpg` | Image | 142 KiB | 0.12 s |
| `/storage/content/portfolio/lancamento-verano-cover-960.jpg` | Image | 106 KiB | 0.12 s |
| `/storage/content/portfolio/mini-wedding-marina-cover-960.jpg` | Image | 100 KiB | 0.12 s |
| `/build/assets/eb-garamond-600-normal-DHwxsLHv.woff2` | Font | 25 KiB | 0.10 s |
| `/build/assets/eb-garamond-500-normal-DehAIUv0.woff2` | Font | 25 KiB | 0.10 s |
| `/build/assets/eb-garamond-400-normal-BCNrxLz_.woff2` | Font | 24 KiB | 0.10 s |
| `/brand/lockup-on-light.webp` | Image | 14 KiB | 0.10 s |
| insight com falha | ganho estimado de LCP | bytes |
|---|---|---|
| Improve image delivery | 0.90 s | — |
| Use efficient cache lifetimes | 0.15 s | — |
| Network dependency tree | 0.00 s | — |
| Render blocking requests | 0.00 s | — |
### portfolio-detalhe — desktop
Elemento de LCP: `<img src="http://127.0.0.1:8000/storage/content/portfolio/casamento-ana-lucas-cover-…" srcset="/storage/content/portfolio/casamento-ana-lucas-cover-480.jpg 480w`
| fase | tempo |
|---|---|
| TTFB | 0.12 s |
| Load Delay | 0.43 s |
| Load Time | 0.03 s |
| Render Delay | 0.21 s |
Requests concluídas até o LCP, mais pesadas primeiro:
| recurso | tipo | transferido | fim |
|---|---|---|---|
| `/storage/content/portfolio/casamento-ana-lucas-cover-1440.jpg` | Image | 287 KiB | 0.08 s |
| `/storage/content/portfolio/casamento-ana-lucas-gallery-1-960.jpg` | Image | 209 KiB | 0.09 s |
| `/storage/content/portfolio/casamento-ana-lucas-gallery-2-960.jpg` | Image | 172 KiB | 0.09 s |
| `/build/assets/eb-garamond-600-normal-DHwxsLHv.woff2` | Font | 25 KiB | 0.08 s |
| `/build/assets/eb-garamond-500-normal-DehAIUv0.woff2` | Font | 25 KiB | 0.08 s |
| `/build/assets/eb-garamond-400-normal-BCNrxLz_.woff2` | Font | 24 KiB | 0.08 s |
| `/brand/lockup-on-light.webp` | Image | 14 KiB | 0.08 s |
| `/build/assets/app-Cswasu1n.css` | Stylesheet | 10 KiB | 0.08 s |
| insight com falha | ganho estimado de LCP | bytes |
|---|---|---|
| Improve image delivery | 0.10 s | — |
| Use efficient cache lifetimes | 0.00 s | — |
| LCP request discovery | 0.00 s | — |
| Network dependency tree | 0.00 s | — |
| Render blocking requests | 0.00 s | — |
### portfolio-detalhe — mobile
Elemento de LCP: `<img src="http://127.0.0.1:8000/storage/content/portfolio/casamento-ana-lucas-cover-…" srcset="/storage/content/portfolio/casamento-ana-lucas-cover-480.jpg 480w`
| fase | tempo |
|---|---|
| TTFB | 0.45 s |
| Load Delay | 1.63 s |
| Load Time | 0.06 s |
| Render Delay | 0.87 s |
Requests concluídas até o LCP, mais pesadas primeiro:
| recurso | tipo | transferido | fim |
|---|---|---|---|
| `/storage/content/portfolio/casamento-ana-lucas-gallery-1-960.jpg` | Image | 209 KiB | 0.11 s |
| `/storage/content/portfolio/casamento-ana-lucas-gallery-2-960.jpg` | Image | 172 KiB | 0.11 s |
| `/storage/content/portfolio/casamento-ana-lucas-cover-960.jpg` | Image | 142 KiB | 0.10 s |
| `/build/assets/eb-garamond-600-normal-DHwxsLHv.woff2` | Font | 25 KiB | 0.10 s |
| `/build/assets/eb-garamond-500-normal-DehAIUv0.woff2` | Font | 25 KiB | 0.10 s |
| `/build/assets/eb-garamond-400-normal-BCNrxLz_.woff2` | Font | 24 KiB | 0.10 s |
| `/brand/lockup-on-light.webp` | Image | 14 KiB | 0.10 s |
| `/build/assets/app-Cswasu1n.css` | Stylesheet | 10 KiB | 0.10 s |
| insight com falha | ganho estimado de LCP | bytes |
|---|---|---|
| Improve image delivery | 0.90 s | — |
| Use efficient cache lifetimes | 0.15 s | — |
| LCP request discovery | 0.00 s | — |
| Network dependency tree | 0.00 s | — |
| Render blocking requests | 0.00 s | — |
### portfolio — desktop
Elemento de LCP: `<img src="http://127.0.0.1:8000/storage/content/portfolio/casamento-ana-lucas-cover-…" srcset="/storage/content/portfolio/casamento-ana-lucas-cover-480.jpg 480w`
| fase | tempo |
|---|---|
| TTFB | 0.12 s |
| Load Delay | 0.28 s |
| Load Time | 0.05 s |
| Render Delay | 0.19 s |
Requests concluídas até o LCP, mais pesadas primeiro:
| recurso | tipo | transferido | fim |
|---|---|---|---|
| `/storage/content/portfolio/casamento-ana-lucas-cover-960.jpg` | Image | 142 KiB | 0.10 s |
| `/storage/content/portfolio/lancamento-verano-cover-960.jpg` | Image | 106 KiB | 0.10 s |
| `/storage/content/portfolio/mini-wedding-marina-cover-960.jpg` | Image | 100 KiB | 0.10 s |
| `/build/assets/eb-garamond-600-normal-DHwxsLHv.woff2` | Font | 25 KiB | 0.08 s |
| `/build/assets/eb-garamond-500-normal-DehAIUv0.woff2` | Font | 25 KiB | 0.08 s |
| `/build/assets/eb-garamond-400-normal-BCNrxLz_.woff2` | Font | 24 KiB | 0.08 s |
| `/brand/lockup-on-light.webp` | Image | 14 KiB | 0.08 s |
| `/build/assets/app-Cswasu1n.css` | Stylesheet | 10 KiB | 0.08 s |
| insight com falha | ganho estimado de LCP | bytes |
|---|---|---|
| Improve image delivery | 0.20 s | — |
| Use efficient cache lifetimes | 0.00 s | — |
| LCP request discovery | 0.00 s | — |
| Network dependency tree | 0.00 s | — |
| Render blocking requests | 0.00 s | — |
### portfolio — mobile
Elemento de LCP: `<img src="http://127.0.0.1:8000/storage/content/portfolio/casamento-ana-lucas-cover-…" srcset="/storage/content/portfolio/casamento-ana-lucas-cover-480.jpg 480w`
| fase | tempo |
|---|---|
| TTFB | 0.45 s |
| Load Delay | 0.64 s |
| Load Time | 0.03 s |
| Render Delay | 0.46 s |
Requests concluídas até o LCP, mais pesadas primeiro:
| recurso | tipo | transferido | fim |
|---|---|---|---|
| `/storage/content/portfolio/casamento-ana-lucas-cover-960.jpg` | Image | 142 KiB | 0.09 s |
| `/storage/content/portfolio/lancamento-verano-cover-960.jpg` | Image | 106 KiB | 0.09 s |
| `/storage/content/portfolio/mini-wedding-marina-cover-960.jpg` | Image | 100 KiB | 0.09 s |
| `/build/assets/eb-garamond-600-normal-DHwxsLHv.woff2` | Font | 25 KiB | 0.08 s |
| `/build/assets/eb-garamond-500-normal-DehAIUv0.woff2` | Font | 25 KiB | 0.08 s |
| `/build/assets/eb-garamond-400-normal-BCNrxLz_.woff2` | Font | 24 KiB | 0.08 s |
| `/brand/lockup-on-light.webp` | Image | 14 KiB | 0.08 s |
| `/build/assets/app-Cswasu1n.css` | Stylesheet | 10 KiB | 0.08 s |
| insight com falha | ganho estimado de LCP | bytes |
|---|---|---|
| Use efficient cache lifetimes | 0.00 s | — |
| Improve image delivery | 0.00 s | — |
| LCP request discovery | 0.00 s | — |
| Network dependency tree | 0.00 s | — |
| Render blocking requests | 0.00 s | — |
### servicos — desktop
Elemento de LCP: `<img src="http://127.0.0.1:8000/storage/content/services/casamentos-960.jpg" srcset="/storage/content/services/casamentos-480.jpg 480w, /storage/content/servic…`
| fase | tempo |
|---|---|
| TTFB | 0.12 s |
| Load Delay | 0.18 s |
| Load Time | 0.01 s |
| Render Delay | 0.06 s |
Requests concluídas até o LCP, mais pesadas primeiro:
| recurso | tipo | transferido | fim |
|---|---|---|---|
| `/storage/content/services/casamentos-960.jpg` | Image | 107 KiB | 0.14 s |
| `/storage/content/services/eventos-corporativos-960.jpg` | Image | 100 KiB | 0.14 s |
| `/storage/content/services/celebracoes-intimistas-960.jpg` | Image | 83 KiB | 0.14 s |
| `/build/assets/eb-garamond-600-normal-DHwxsLHv.woff2` | Font | 25 KiB | 0.13 s |
| `/build/assets/eb-garamond-500-normal-DehAIUv0.woff2` | Font | 25 KiB | 0.13 s |
| `/build/assets/eb-garamond-400-normal-BCNrxLz_.woff2` | Font | 24 KiB | 0.13 s |
| `/brand/lockup-on-light.webp` | Image | 14 KiB | 0.13 s |
| `/build/assets/app-Cswasu1n.css` | Stylesheet | 10 KiB | 0.13 s |
| insight com falha | ganho estimado de LCP | bytes |
|---|---|---|
| Use efficient cache lifetimes | 0.00 s | — |
| Improve image delivery | 0.00 s | — |
| LCP request discovery | 0.00 s | — |
| Network dependency tree | 0.00 s | — |
| Render blocking requests | 0.00 s | — |
### servicos — mobile
Elemento de LCP: `<img src="http://127.0.0.1:8000/storage/content/services/casamentos-960.jpg" srcset="/storage/content/services/casamentos-480.jpg 480w, /storage/content/servic…`
| fase | tempo |
|---|---|
| TTFB | 0.45 s |
| Load Delay | 1.50 s |
| Load Time | 0.11 s |
| Render Delay | 0.56 s |
Requests concluídas até o LCP, mais pesadas primeiro:
| recurso | tipo | transferido | fim |
|---|---|---|---|
| `/storage/content/services/casamentos-960.jpg` | Image | 107 KiB | 0.09 s |
| `/storage/content/services/eventos-corporativos-960.jpg` | Image | 100 KiB | 0.09 s |
| `/storage/content/services/celebracoes-intimistas-960.jpg` | Image | 83 KiB | 0.09 s |
| `/build/assets/eb-garamond-600-normal-DHwxsLHv.woff2` | Font | 25 KiB | 0.08 s |
| `/build/assets/eb-garamond-500-normal-DehAIUv0.woff2` | Font | 25 KiB | 0.08 s |
| `/build/assets/eb-garamond-400-normal-BCNrxLz_.woff2` | Font | 24 KiB | 0.08 s |
| `/brand/lockup-on-light.webp` | Image | 14 KiB | 0.08 s |
| `/build/assets/app-Cswasu1n.css` | Stylesheet | 10 KiB | 0.08 s |
| insight com falha | ganho estimado de LCP | bytes |
|---|---|---|
| Improve image delivery | 0.60 s | — |
| Use efficient cache lifetimes | 0.00 s | — |
| LCP request discovery | 0.00 s | — |
| Network dependency tree | 0.00 s | — |
| Render blocking requests | 0.00 s | — |
### sobre — desktop
Elemento de LCP: `<img src="http://127.0.0.1:8000/storage/content/about/about-image-960.jpg" srcset="/storage/content/about/about-image-480.jpg 480w, /storage/content/about/ab…" `
| fase | tempo |
|---|---|
| TTFB | 0.13 s |
| Load Delay | 0.15 s |
| Load Time | 0.00 s |
| Render Delay | 0.09 s |
Requests concluídas até o LCP, mais pesadas primeiro:
| recurso | tipo | transferido | fim |
|---|---|---|---|
| `/storage/content/about/about-image-960.jpg` | Image | 60 KiB | 0.12 s |
| `/build/assets/eb-garamond-600-normal-DHwxsLHv.woff2` | Font | 25 KiB | 0.11 s |
| `/build/assets/eb-garamond-500-normal-DehAIUv0.woff2` | Font | 25 KiB | 0.10 s |
| `/build/assets/eb-garamond-400-normal-BCNrxLz_.woff2` | Font | 24 KiB | 0.10 s |
| `/brand/lockup-on-light.webp` | Image | 14 KiB | 0.11 s |
| `/build/assets/app-Cswasu1n.css` | Stylesheet | 10 KiB | 0.11 s |
| `/sobre` | Document | 4 KiB | 0.10 s |
| `/build/assets/app-Cr9w0NCu.js` | Script | 2 KiB | 0.11 s |
| insight com falha | ganho estimado de LCP | bytes |
|---|---|---|
| Use efficient cache lifetimes | 0.00 s | — |
| Improve image delivery | 0.00 s | — |
| LCP request discovery | 0.00 s | — |
| Network dependency tree | 0.00 s | — |
| Render blocking requests | 0.00 s | — |
### sobre — mobile
Elemento de LCP: `<img src="http://127.0.0.1:8000/storage/content/about/about-image-960.jpg" srcset="/storage/content/about/about-image-480.jpg 480w, /storage/content/about/ab…" `
| fase | tempo |
|---|---|
| TTFB | 0.45 s |
| Load Delay | 0.57 s |
| Load Time | 0.10 s |
| Render Delay | 0.46 s |
Requests concluídas até o LCP, mais pesadas primeiro:
| recurso | tipo | transferido | fim |
|---|---|---|---|
| `/storage/content/about/about-image-960.jpg` | Image | 60 KiB | 0.07 s |
| `/build/assets/eb-garamond-600-normal-DHwxsLHv.woff2` | Font | 25 KiB | 0.06 s |
| `/build/assets/eb-garamond-500-normal-DehAIUv0.woff2` | Font | 25 KiB | 0.06 s |
| `/build/assets/eb-garamond-400-normal-BCNrxLz_.woff2` | Font | 24 KiB | 0.06 s |
| `/brand/lockup-on-light.webp` | Image | 14 KiB | 0.06 s |
| `/build/assets/app-Cswasu1n.css` | Stylesheet | 10 KiB | 0.06 s |
| `/sobre` | Document | 4 KiB | 0.05 s |
| `/build/assets/app-Cr9w0NCu.js` | Script | 2 KiB | 0.06 s |
| insight com falha | ganho estimado de LCP | bytes |
|---|---|---|
| Use efficient cache lifetimes | 0.00 s | — |
| Improve image delivery | 0.00 s | — |
| LCP request discovery | 0.00 s | — |
| Network dependency tree | 0.00 s | — |
| Render blocking requests | 0.00 s | — |

View File

@@ -0,0 +1,205 @@
# Lighthouse — MAN-109
Medições versionadas porque a rodada anterior (PR #34) sobreviveu apenas como
uma tabela digitada à mão num comentário do Linear: `storage/app/lighthouse` é
gitignored, então não havia contra o quê comparar. Os relatórios brutos pesam
~48 MB por passada e continuam fora do repositório; o que fica versionado são os
resumos, que já carregam a decomposição do LCP e a lista de requests até o LCP.
## Como reproduzir
```bash
# Sobe a imagem de produção. O seed roda no host, não no container:
# ContentSeeder é no-op fora de local/staging/testing (database/seeders/ContentSeeder.php:44),
# então sob APP_ENV=production ele não semeia nada e o Lighthouse mede páginas vazias.
docker build -t amare-app:man109 .
php artisan migrate --force
php artisan db:seed --class=ContentSeeder --force
php artisan media:generate-variants # sem isso o LCP da home infla ~2,5 s
docker run -d --name amare-web -p 8000:8000 -e APP_ENV=production ... amare-app:man109
TARGET=local bash scripts/perf/lighthouse.sh storage/app/lighthouse
```
Cada página é auditada 3 vezes por preset e o relatório de LCP mediano é o
reportado — nunca a média. O LCP se move alguns décimos entre execuções na mesma
build, e uma única execução não sustenta comparação.
## Resultado
Preset mobile padrão do Lighthouse 12.8.2: throttling simulado de 150 ms de RTT,
~1,6 Mbps, CPU 4× mais lenta. Chrome estável do sistema (não o Chromium do
Playwright), imagem de produção local, seeder `ContentSeeder`.
Antes: commit `2e43fde`. As colunas intermediárias mostram cada correção
isoladamente, medida com uma passada completa antes da seguinte entrar.
| página | preset | antes | + fontes e marca | + webp e 720w | + sizes correto | perf antes → depois | bytes antes → depois |
|---|---|---|---|---|---|---|---|
| contato | desktop | 0.53 s | 0.36 s | 0.37 s | **0.36 s** | 100 → 100 | 262 → 106 KiB |
| contato | mobile | 2.55 s | 1.51 s | 1.51 s | **1.50 s** | 97 → 100 | 262 → 106 KiB |
| home | desktop | 0.87 s | 0.67 s | 0.53 s | **0.53 s** | 99 → 100 | 798 → 347 KiB |
| home | mobile | 4.58 s | 3.39 s | 2.87 s | **2.49 s** | 83 → 98 | 798 → 347 KiB |
| portfolio | desktop | 0.83 s | 0.65 s | 0.37 s | **0.36 s** | 99 → 100 | 609 → 259 KiB |
| portfolio | mobile | 3.98 s | 1.58 s | 2.64 s | **1.58 s** | 87 → 100 | 609 → 259 KiB |
| portfolio-detalhe | desktop | 0.97 s | 0.79 s | 0.66 s | **0.65 s** | 99 → 100 | 930 → 508 KiB |
| portfolio-detalhe | mobile | 3.98 s | 3.01 s | 2.71 s | **2.18 s** | 87 → 99 | 785 → 365 KiB |
| servicos | desktop | 0.77 s | 0.36 s | 0.37 s | **0.36 s** | 100 → 100 | 552 → 222 KiB |
| servicos | mobile | 3.68 s | 2.63 s | 1.58 s | **2.03 s** | 89 → 99 | 552 → 222 KiB |
| sobre | desktop | 0.61 s | 0.37 s | 0.37 s | **0.36 s** | 100 → 100 | 322 → 127 KiB |
| sobre | mobile | 3.01 s | 1.58 s | 1.60 s | **1.58 s** | 94 → 100 | 322 → 127 KiB |
FCP cai de 1,51 s para 0,91 s em todas as páginas no mobile. Acessibilidade,
boas práticas e SEO marcam 100 em todas as páginas nos dois presets, antes e
depois. CLS é 0,000 e TBT é 0 ms em todas — as metas de §6.6 para essas três
métricas já passavam e continuam passando.
**Contra a meta de LCP ≤ 2,5 s da §6.6: todas as páginas passam nos dois
presets.** Desktop com folga (máximo 0,65 s). No mobile o pior caso é a home a
2,49 s, ou seja **em cima da linha** — a pior das três execuções dela deu 2,57 s.
Tratar a home como aprovada por margem, não com folga.
Duas colunas intermediárias merecem leitura cuidadosa em vez de conclusão:
- `portfolio` mobile aparece pior na coluna do webp (2,64 s) do que na anterior
(1,58 s). É variância, não regressão: as três execuções daquela passada foram
1,59 / 2,64 / 2,78 s. A página é a mais instável do conjunto e a mediana pulou
de ponta. Na passada final as três deram 1,58 s.
- `servicos` mobile sobe de 1,58 s para 2,03 s da terceira para a quarta coluna,
pelo mesmo motivo (1,58 / 1,58 / 2,03).
É exatamente por isso que o script roda três vezes e reporta a mediana; ainda
assim, diferenças abaixo de meio segundo entre passadas não devem ser lidas como
efeito de uma correção.
Ressalva: a medição é local, então latência de origem e TLS não entram, e o
throttling de rede é simulado. Trate o LCP como piso, não como valor de campo.
## O que cada correção comprou
**Fontes servidas em dobro — 88 KiB fora do caminho crítico.** 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.
A prova está no log de rede da home antes da correção: 3 woff em prioridade
`VeryHigh` (88 KiB) — a prioridade mais alta da página, à frente do elemento de
LCP — somados a 3 woff2 em `High` (74 KiB) que só foram baixados porque estavam
em `<link rel="preload">`. 162 KiB de tráfego de fonte para 74 KiB de fonte útil.
woff2 é suportado por todo navegador que este site atende desde 2016, então as
regras woff não eram fallback e sim peso morto. O plugin `amare:fonts-woff2-only`
em `vite.config.js` 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 fora do caminho crítico.** O logotipo
era servido a 512 px de largura para renderizar em 48 px (lockup, cabeçalho e
rodapé) e 32 px (mark, home): 84 KiB + 42 KiB com `loading="eager"` em todas as
páginas. Reencodados a 3× do maior render — lockup 149×144 (14 KiB) e mark
191×96 (10 KiB) —, mantendo os mesmos nomes de arquivo para não invalidar cache.
As variantes `on-dark` foram reencodadas junto por consistência; nenhuma view as
usa hoje.
Isto absorve MAN-122: os ativos de marca foram reencodados sem alterar a forma
ou a cor percebida, reduzindo apenas o peso transferido.
Os arquivos versionados aqui são: `2026-08-10-local-antes.md` (commit `2e43fde`),
`2026-08-10-local-etapa-fontes-e-marca.md` (passada intermediária) e
`2026-08-10-local-depois.md` (estado final).
**Variantes WebP nas imagens de conteúdo.** Com fontes e marca resolvidas, o
elemento de LCP de toda página no mobile era a imagem do hero, e o Load Delay de
1,88 s era contenção de banda pura: 143 KiB de JPEG q82 a 960 px, com as três
capas do portfólio somando outros 348 KiB. `ResponsiveImage::generate()` passa a
escrever 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, então nada quebra em quem não decodifica webp,
e mídia antiga sem irmãos webp renderiza `<img>` puro como antes.
**`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. Com `calc(100vw - 3rem)` a home
passa a usar a variante de 720 (**74 KiB**, contra 143 KiB no início).
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.
Decomposição final do LCP da home no mobile:
| fase | tempo |
|---|---|
| TTFB | 0,45 s |
| Load Delay | 1,24 s |
| Load Time | 0,10 s |
| Render Delay | 0,69 s |
## O ganho de imagem só aparece depois do deploy regenerar as variantes
Toda a medição acima usou `FILESYSTEM_DISK=local`. Em staging e produção o disco
é `r2`, e lá as variantes `.webp` e a largura 720 **ainda não existem** para a
mídia já publicada. Até `media:generate-variants` rodar, `availableWebpVariants()`
volta vazio, o `<source>` é omitido e o `srcset` do formato original perde a
entrada de 720w — ou seja, nenhum dos dois ganhos de imagem aparece.
O serviço `migrate` do `docker-compose.deploy.yml` roda
`php artisan media:generate-variants` sem condição a cada deploy, depois de
`migrate` e do seed, e o comando só pula um caminho quando o arquivo original não
existe (`MediaGenerateVariantsCommand`) — regenera mesmo quando já há variantes.
Então o primeiro deploy desta branch produz as variantes novas por conta própria.
Enquanto isso não acontece, há um custo sem contrapartida: `x-media.image` faz
agora **8 chamadas `exists()` por imagem** (4 larguras × 2 formatos) contra o
object store, no lugar de 3. Na home são ~32 round trips remotos por request em
vez de ~12. Isso agrava a hipótese não validada abaixo em vez de melhorá-la, e é
mais um motivo para cachear esses metadados.
## Se a home precisar de mais folga
O Load Delay de 1,24 s ainda é contenção: as três capas do portfólio somam
155 KiB e, embora sejam `loading="lazy"` e prioridade `Low`, o navegador as busca
porque entram no limiar de lazy loading da viewport emulada. Os levers restantes,
em ordem de custo:
1. Baixar a qualidade webp de 80 para 75 (medido: 109 → 92 KiB na imagem do
hero a 960 px). Barato, mas mexe na qualidade de imagem de uma marca cujo
posicionamento é acabamento editorial — decisão de produto, não de engenharia.
2. Reduzir o Render Delay de 0,69 s, que agora é a segunda maior fatia e é
trabalho de main thread, não de rede.
3. FrankenPHP worker mode para o TTFB de 0,45 s. Tem gatilho objetivo em
SPEC §22 e não deve ser puxado antes dele.
## Cobertura que este trabalho não tem
Os testes browser não exercitam `srcset` nem `<picture>` com variantes geradas:
nem `ContentSeeder` nem `VisualContentSeeder` geram variantes. Nesses cenários,
`availableVariants()` volta vazio e o componente renderiza `<img>` puro. A
cobertura do caminho com variantes fica nos testes de feature
(`MediaImageComponentTest`).
## Staging
Não medido. A origem de staging responde `303` para
`blocked.teams.cloudflare.com` a partir da rede corporativa da Creditas
("O conteúdo deste site viola a Política de Segurança da Informação"), inclusive
em `/up`. O preflight de HTTP 200 do script barra a execução antes de gastar
minutos auditando páginas de bloqueio.
Duas hipóteses seguem **não validadas** porque só existem com
`FILESYSTEM_DISK=r2`, que é configuração de staging e produção:
- `x-media.image` chama `ResponsiveImage::availableVariants()`,
`availableWebpVariants()` (8 `exists()` somados) e `ResponsiveImage::dimensions()`
(que baixa o arquivo inteiro) a cada render, sem cache. No disco `local` são
leituras de filesystem; no `r2` são ~9 round trips remotos por imagem no lado
servidor, ~36 na home. Teste discriminante: comparar o TTFB de `/contato`
(zero imagens) com o de `/portfolio` (N imagens). Se o TTFB escalar com a
contagem de imagens, está confirmado. **Este é o item mais urgente da lista**,
porque as variantes webp multiplicaram o número de chamadas.
- A mídia vem de `R2_URL`, uma origem cross-origin, e não há `preconnect` no
`<head>` — DNS e TLS entram antes do LCP.
Para medir: rodar `TARGET=staging BASE_URL=<origem> bash scripts/perf/lighthouse.sh`
de uma rede sem o filtro corporativo.

Binary file not shown.

After

Width:  |  Height:  |  Size: 170 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 155 KiB

View File

@@ -0,0 +1,417 @@
# Atualização de conteúdo do site
Guia para quem cuida do conteúdo do site (textos, fotos, casos de portfólio,
depoimentos, serviços e dados de contato) sem precisar mexer em código.
O site é editado por um painel interno chamado **"admin"** — é uma tela web,
parecida com um formulário, onde cada bloco do site (textos da home, casos de
portfólio, depoimentos, etc.) vira uma "página" que você edita e salva.
> Antes de qualquer coisa, leia a seção **"A regra mais importante: o que
> torna algo visível no site"** — ela explica um comportamento que engana
> quase todo mundo na primeira vez.
## Sumário
- [Como entrar no painel](#como-entrar-no-painel)
- [A regra mais importante: o que torna algo visível no site](#a-regra-mais-importante-o-que-torna-algo-visível-no-site)
- [Editar os textos da home e das páginas institucionais](#editar-os-textos-da-home-e-das-páginas-institucionais)
- [Casos de portfólio: criar, editar, publicar e despublicar](#casos-de-portfólio-criar-editar-publicar-e-despublicar)
- [Depoimentos: adicionar e publicar](#depoimentos-adicionar-e-publicar)
- [Serviços: editar](#serviços-editar)
- [Contato: telefone, e-mail e redes sociais](#contato-telefone-e-mail-e-redes-sociais)
- [Imagens: formatos aceitos, tamanho e texto alternativo](#imagens-formatos-aceitos-tamanho-e-texto-alternativo)
- [Usuários: papéis, ativar e desativar contas](#usuários-papéis-ativar-e-desativar-contas)
- [O que NÃO mexer — e com quem falar](#o-que-não-mexer--e-com-quem-falar)
- [Seções da home que somem por completo se ficarem vazias](#seções-da-home-que-somem-por-completo-se-ficarem-vazias)
- [Sobre o arquivo depoimentos.md na raiz do repositório](#sobre-o-arquivo-depoimentosmd-na-raiz-do-repositório)
## Como entrar no painel
1. Acesse `/admin` no domínio do site (ex.: `https://seusite.com.br/admin`).
2. Se você não estiver logada, o painel mostra uma tela de login pedindo
e-mail e senha.
3. Faça login com o e-mail e senha da sua conta.
**Se você não conseguir entrar:**
- **Esqueceu a senha:** hoje o painel **não tem** um link de "esqueci minha
senha" self-service. Só uma pessoa com perfil **Administrador** consegue
trocar a senha de outra conta (em Usuários → editar a conta → campo
"Senha"). Se você é a única Administradora e está travada fora do painel,
vai precisar pedir para quem desenvolve o site trocar a senha diretamente
no banco de dados.
- **Mensagem de acesso negado / conta inativa:** sua conta pode estar com o
campo "Ativo" desligado. Só uma Administradora pode reativar isso em
Usuários (veja [O que NÃO mexer](#o-que-não-mexer--e-com-quem-falar) sobre
quem pode mexer em contas de usuário).
- **Perfil "Assistente" sem acesso a nada:** hoje, contas com o papel
"Assistente" conseguem fazer login, mas não conseguem ver nem editar
**nenhum** conteúdo — nem Configurações, nem Portfólio, nem Depoimentos,
nada. Isso é uma limitação atual do sistema, não um erro seu. Se você
precisa dar acesso de edição a alguém que não seja Administrador, avise
quem desenvolve o site — hoje não existe um meio-termo (“pode editar mas
não pode tudo”), só "Administrador com acesso total" ou "Assistente sem
acesso a nada".
## A regra mais importante: o que torna algo visível no site
Duas coisas controlam se algo aparece no site. Entender essas duas evita
99% das dúvidas de "publiquei e não apareceu" ou "não publiquei e apareceu".
### 1. O campo "Publicado em" não é um agendamento
Em Serviços, Portfólio e Depoimentos existe um campo chamado **"Publicado
em"**. A intuição normal seria pensar "se eu colocar uma data futura, ele só
aparece nessa data" — **isso está errado**. Assim que você preenche esse
campo com **qualquer** data/hora (passada, presente ou futura) e salva, o
item fica visível no site **imediatamente**. Não existe agendamento.
- **Para publicar:** preencha "Publicado em" com uma data qualquer e salve.
- **Para despublicar:** apague o conteúdo desse campo (deixe em branco) e
salve. Colocar uma data no futuro **não** esconde o item — ele continua
visível.
### 2. Home precisa de DOIS interruptores; páginas de listagem só de um
- Nas páginas de listagem completas — `/servicos` (todos os serviços) e
`/portfolio` (todos os casos) — basta o item estar **publicado** (campo
"Publicado em" preenchido) para aparecer.
- Já nos blocos de **Serviços** e **Portfólio dentro da home** (a página
inicial), o item só aparece se **as duas coisas** forem verdadeiras ao
mesmo tempo:
1. Estiver **publicado** ("Publicado em" preenchido), **e**
2. Tiver o interruptor **"Destaque"** ligado.
Publicar sozinho não é suficiente para aparecer na home — só coloca o item
na listagem completa. Você precisa também ligar "Destaque" se quiser que
apareça na home.
- **Depoimentos são a exceção**: o bloco de Depoimentos na home mostra
**todo** depoimento publicado, independente do interruptor "Destaque". Em
Depoimentos, "Destaque" só serve para filtrar a tabela dentro do painel —
não muda nada no que aparece para quem visita o site.
## Editar os textos da home e das páginas institucionais
No menu lateral, vá em **Configurações** (dentro do grupo "Conteúdo do
site"). É uma página só, dividida em seções. Edite o que quiser e clique em
salvar no final.
**Diferente de Portfólio, Depoimentos e Serviços, aqui não existe "Publicado
em".** Tudo o que você salva em Configurações entra no ar imediatamente —
não há passo extra de publicação nem como "rascunhar" uma mudança antes de
ela aparecer no site.
- **Marca e hero** — nome da marca, logo (+ texto alternativo, obrigatório
se houver logo), textos do topo da home ("hero"): eyebrow (frase pequena
acima do título), título, subtítulo, texto dos dois botões de chamada
(CTA), nota de rodapé do hero, e o **resumo institucional**
(`about_summary`). **Atenção:** nome da marca, título do hero, subtítulo
e texto do botão principal são **obrigatórios** — o formulário não deixa
salvar em branco. Já o eyebrow, o botão secundário e a nota do hero podem
ficar em branco (o site simplesmente não mostra essa parte se estiver
vazia).
- O **resumo institucional** é usado em **dois lugares** e se comporta
diferente em cada um: na seção "A Amare" da home, se ficar em branco o
site mostra uma frase padrão fixa no lugar; já na página **Sobre**
(`/sobre`), esse mesmo texto aparece como o parágrafo principal e, se
ficar em branco, **fica realmente vazio** ali (sem frase padrão). O
formulário **deixa salvar em branco sem avisar** — ou seja, não dá erro
nenhum, o parágrafo só fica vazio silenciosamente na página Sobre até
alguém notar. Por isso, nunca deixe o resumo institucional em branco;
depois de editar, confira a página `/sobre` para garantir que o texto
apareceu.
- A página **Sobre** também tem uma frase fixa no código
("A [nome da marca] atua em [cidade] com foco em planejamento
completo...") que **não é editável pelo painel** — ela só muda se você
editar Nome da marca ou Cidade (que entram nessa frase), o resto do
texto é fixo. Para mudar essa frase, é preciso pedir para quem
desenvolve o site.
- **Manifesto editorial** — título, texto de abertura ("lead") e corpo do
bloco "Manifesto" da home. Diferente de outros textos, se você deixar
esses campos em branco o site **não** esconde a seção — ele mostra um
texto padrão fixo no lugar. Ou seja: em branco aqui não é "some", é
"volta ao texto genérico".
- **Método** — introdução do bloco "Método" e até 4 passos (título +
descrição cada), reordenáveis arrastando. Se você apagar todos os passos,
o site mostra passos padrão pré-definidos em vez de ficar vazio.
- **Princípios** — lista de princípios (tags) mostrada na seção
institucional. Se ficar vazia, o site mostra uma lista padrão.
- **Página Sobre** — imagem da página "Sobre" (+ texto alternativo,
obrigatório se houver imagem).
- **SEO padrão** — título e descrição que aparecem quando o site é
compartilhado ou aparece no Google (título e descrição são obrigatórios),
e a imagem padrão de compartilhamento (que também é reaproveitada como a
imagem à direita no hero da home — ao trocar essa imagem, ela muda em
dois lugares ao mesmo tempo).
- **Analytics****não mexa aqui**, veja [O que NÃO mexer](#o-que-não-mexer--e-com-quem-falar).
## Casos de portfólio: criar, editar, publicar e despublicar
No menu, vá em **Portfólio**.
### Criar ou editar um caso
Clique em "Novo" (ou abra um caso existente) e preencha:
- **Título** e **Slug** — o slug é o pedacinho do endereço na internet
(ex.: `casamento-joana-pedro`). Se você deixar o slug em branco ao criar,
ele é gerado automaticamente a partir do título. **Depois de publicado,
evite mudar o slug** — isso quebra qualquer link já compartilhado para
aquele caso (redes sociais, WhatsApp, Google). Se precisar mesmo mudar,
veja [O que NÃO mexer](#o-que-não-mexer--e-com-quem-falar).
- **Resumo** — texto curto usado nas listagens.
- **Tipo de evento**, **Cidade**, **Local**, **Data do evento**.
- **Desafio**, **Solução**, **Resultado** — o texto do "case" propriamente
dito (Desafio e Solução são obrigatórios; Resultado é opcional).
- **Imagem de capa** (+ texto alternativo, obrigatório se houver imagem).
**Atenção:** diferente de quase toda outra imagem do painel, a capa de um
caso de portfólio é **sempre obrigatória** por trás dos panos — mas o
formulário **não bloqueia** o salvamento se você esquecer de anexá-la.
Se você tentar salvar um caso novo sem imagem de capa, o painel não avisa
"campo obrigatório": o salvamento simplesmente falha com um erro técnico
feio (erro de banco de dados), não a mensagem amigável que você vê para
título, resumo etc. Sempre anexe a imagem de capa antes de salvar um caso
novo.
- **Ordem** — número usado para ordenar os casos nas listagens.
- **Destaque** — liga/desliga a aparição desse caso na home (ver regra das
[duas chaves](#a-regra-mais-importante-o-que-torna-algo-visível-no-site)).
- **Publicado em** — data de publicação (ver
[regra de publicação](#a-regra-mais-importante-o-que-torna-algo-visível-no-site)).
- **Meta title** / **Meta description** — título e descrição específicos
desse caso para compartilhamento e Google (opcionais; se em branco, usa
o padrão configurado em Configurações).
### Galeria de fotos do caso
Depois de salvar o caso, abra-o novamente e procure a aba **"Galeria"**.
Ali você adiciona quantas fotos quiser, cada uma com:
- **Imagem** (obrigatória para criar o item da galeria). **Atenção:** assim
como a capa do caso, o formulário **não bloqueia** o salvamento se você
esquecer a imagem — ele deixa parecer que deu certo até você clicar em
salvar, e aí falha com um erro técnico de banco de dados em vez de avisar
"campo obrigatório". Sempre anexe a imagem antes de salvar um item da
galeria.
- **Texto alternativo****obrigatório assim que você anexa uma imagem**.
O sistema não deixa salvar uma foto sem essa descrição.
- **Legenda** (opcional) — texto que aparece junto da foto.
- **Ordem** — dá para arrastar as fotos na tabela para reordenar.
### Publicar e despublicar um caso
- **Publicar:** preencha "Publicado em" com qualquer data e salve. Aparece
imediatamente em `/portfolio` e na página própria do caso. Para também
aparecer na home, ligue "Destaque".
- **Despublicar:** apague o conteúdo de "Publicado em" e salve. Colocar
uma data futura **não** esconde o caso.
**"Excluir" não é a mesma coisa que despublicar.** Cada linha da tabela de
Portfólio (e também cada foto dentro da aba "Galeria") tem um botão
**"Excluir"**, além do botão de editar. Diferente de despublicar, Excluir
**apaga o caso para sempre**: não existe lixeira, não existe desfazer, e ao
excluir um caso todas as fotos da galeria dele são apagadas junto
automaticamente. Selecionar várias linhas na tabela também libera uma ação
de exclusão em massa, que apaga todas de uma vez com uma única confirmação.
Para esconder um caso do site, **sempre** use "apagar o Publicado em" — só
use Excluir quando tiver certeza de que quer destruir o registro
definitivamente.
## Depoimentos: adicionar e publicar
No menu, vá em **Depoimentos**.
- **Depoimento** — o texto do casal. Se você separar o texto em parágrafos
com uma linha em branco entre eles, o site respeita essa quebra e mostra
cada parágrafo separadamente.
- **Nome do autor** — ex.: "Jeniffer e Maick".
- **Contexto** — texto livre mostrado como "— contexto", ex.:
`Casamento · 06/12/2025`.
- **Foto** (+ texto alternativo, obrigatório se houver foto) — atenção: a
foto **existe no formulário mas hoje não aparece** na home; o campo é
preenchido para o futuro, mas visualmente ainda não é exibido.
- **Ordem** — ordena os depoimentos no bloco da home.
- **Destaque****não afeta o site público**. Serve só para filtrar a
tabela de depoimentos dentro do painel.
- **Publicado em** — este é o único interruptor que importa para
depoimentos aparecerem na home. Preencha e salve para publicar; apague e
salve para despublicar. Não existe listagem própria de depoimentos fora
da home.
**Atenção ao botão "Excluir"** em cada linha da tabela (e à exclusão em
massa ao selecionar várias linhas): ele é diferente de despublicar e apaga
o depoimento para sempre, sem lixeira e sem desfazer. Para tirar um
depoimento do ar, apague o "Publicado em" — não use Excluir a menos que
queira apagar o registro definitivamente.
## Serviços: editar
No menu, vá em **Serviços**.
- **Título** e **Slug** (mesmo comportamento de auto-geração e mesmo
cuidado ao mudar depois de publicado que o portfólio).
- **Resumo** e **Descrição**.
- **Imagem de capa** (+ texto alternativo, obrigatório se houver imagem).
- **Ordem**, **Destaque** e **Publicado em** funcionam exatamente como em
portfólio: publicado aparece em `/servicos`; publicado **e** destaque
aparece também na home.
**Atenção ao botão "Excluir"** em cada linha da tabela (e à exclusão em
massa ao selecionar várias linhas): ele é diferente de despublicar e apaga
o serviço para sempre, sem lixeira e sem desfazer. Para tirar um serviço do
ar, apague o "Publicado em" — não use Excluir a menos que queira apagar o
registro definitivamente.
## Contato: telefone, e-mail e redes sociais
Esses campos ficam em **Configurações → seção "Contato"**.
- **E-mail** e **Telefone** são obrigatórios — o formulário não deixa
salvá-los em branco. Eles aparecem no rodapé do site e na página
"Contato" como links clicáveis (e-mail abre o programa de e-mail;
telefone abre o discador do celular). **Importante:** esse mesmo e-mail
é também o endereço para onde o site envia toda mensagem enviada pelo
formulário de contato em `/contato` (o "briefing" que um visitante
preenche e envia). Ou seja, esse campo não é só um link de exibição —
ele precisa ser uma caixa de entrada de verdade, monitorada, porque é
para lá que vão os pedidos de orçamento e contato de clientes em
potencial. Trocar esse e-mail por um endereço que ninguém acompanha faz
o site parar de avisar sobre novos contatos, sem nenhum erro aparecer em
lugar nenhum do painel.
- **Importante sobre WhatsApp:** hoje existe **um único campo de
telefone**, e ele vira apenas um link `tel:` (ligação), **não** um botão
de WhatsApp. Se o número cadastrado for um número de WhatsApp, quem
clicar vai abrir o discador, não o WhatsApp. Se você precisa de um botão
específico de WhatsApp no site, isso é um pedido para quem desenvolve o
site — hoje o campo não faz isso sozinho.
- **Cidade** — opcional, aparece na página de Contato.
- **Redes sociais** — lista de "Rede" + "URL" (ex.: `instagram`
`https://instagram.com/suaempresa`). Adicione quantas quiser pelo botão
"Adicionar rede"; para remover uma, apague a linha inteira.
## Imagens: formatos aceitos, tamanho e texto alternativo
Vale para **toda** imagem enviada em qualquer tela do painel (logo, capas,
galeria de portfólio, fotos de depoimento, imagem da página Sobre, imagem
de SEO):
- **Formatos aceitos:** JPG, JPEG, PNG ou WEBP. Qualquer outro formato
(ex.: HEIC direto do iPhone, PDF, GIF) é recusado com uma mensagem de
erro — converta a imagem antes de enviar. **HEIC é o caso mais comum**,
porque é o formato padrão das fotos tiradas no iPhone. Duas formas
simples de resolver:
- **Fotos novas:** no iPhone, vá em Ajustes → Câmera → Formatos e
escolha "Mais Compatível" — a partir daí, novas fotos já são salvas em
JPEG em vez de HEIC.
- **Fotos que já estão em HEIC:** envie a foto para você mesma por
WhatsApp (ex.: em "Mensagens salvas" ou num grupo/conversa qualquer) e
baixe a versão recebida — o WhatsApp converte a imagem para JPEG
automaticamente ao enviar.
- **Tamanho máximo:** 10 MB por arquivo. Acima disso, o envio é recusado.
- **Texto alternativo é obrigatório sempre que houver imagem.** Isso não é
burocracia: é o texto que leitores de tela usam para descrever a imagem
para pessoas com deficiência visual, e também ajuda o Google a entender
do que se trata a foto. Sem uma imagem, o campo de texto alternativo
pode ficar em branco; assim que você anexa uma imagem, o sistema passa a
exigir o texto.
- **Exceção: a imagem de capa de um caso de portfólio e a imagem de um item
da galeria não são realmente opcionais**, ao contrário de toda outra
imagem do painel (logo, imagem da página Sobre, foto de depoimento,
imagem de SEO). Nesses dois casos específicos, o formulário não impede
você de salvar sem imagem, mas o salvamento falha de qualquer forma com
um erro técnico em vez de uma mensagem amigável de "campo obrigatório"
(ver detalhes nas seções [Casos de
portfólio](#casos-de-portfólio-criar-editar-publicar-e-despublicar) e
[Galeria de fotos do caso](#galeria-de-fotos-do-caso)). Sempre anexe uma
imagem antes de salvar um caso de portfólio ou um item de galeria.
- Você **não** precisa fazer nada além de enviar a imagem normalmente — o
sistema gera sozinho as versões menores usadas em celulares e tablets.
Não existe um botão ou comando manual que você precise rodar depois de
subir uma foto.
## Usuários: papéis, ativar e desativar contas
No menu, vá em **Usuários**. Esta tela só aparece, e só pode ser editada,
por contas com perfil **Administrador** — se você consegue ver este menu e
seguir o resto deste guia, você é Administradora.
Ao criar ou editar uma conta, os campos são:
- **Nome** e **E-mail** — identificação da pessoa; o e-mail também é o
usado para fazer login.
- **Papel****Administrador** ou **Assistente**. Como já visto em [Como
entrar no painel](#como-entrar-no-painel), hoje não existe meio-termo:
Administrador tem acesso total, e Assistente consegue fazer login mas
não vê nem edita **nenhum** conteúdo (nem Portfólio, nem Configurações,
nada). Trocar o Papel de alguém para Assistente remove todo o acesso
dela imediatamente.
- **Ativo** — desligar este interruptor bloqueia o login dessa conta por
completo, mesmo com e-mail e senha corretos.
- **Senha** — só é preciso preencher ao criar uma conta nova ou quando
você realmente quer trocar a senha de alguém; deixando em branco ao
editar uma conta existente, a senha atual não muda.
**Cuidado ao editar a sua própria conta.** O painel não impede uma
Administradora de trocar o próprio Papel para Assistente ou de desligar o
próprio "Ativo". Se isso acontecer, você perde o acesso imediatamente e,
como visto em [Como entrar no painel](#como-entrar-no-painel), hoje não
existe "esqueci minha senha" nem qualquer forma de uma Administradora
reverter isso sozinha — só sobra pedir para quem desenvolve o site mexer
diretamente no banco de dados. Ao mexer no seu próprio usuário, confira
duas vezes o que está mudando antes de salvar.
## O que NÃO mexer — e com quem falar
- **Seção "Analytics" em Configurações** (o interruptor e o campo de
script). É um campo técnico que injeta código de rastreamento no site.
Mexer errado aqui pode quebrar o carregamento do site inteiro. Peça para
quem desenvolve o site fazer essa alteração.
- **Slug de serviços e casos de portfólio**, depois de publicados. Mudar
quebra links já compartilhados (redes sociais, WhatsApp, resultados de
busca do Google). Se for realmente necessário mudar, avise quem
desenvolve o site para avaliar redirecionamento.
- **Usuários e permissões** (menu "Usuários") — só para quem **não** é
Administradora: essa tela só existe para contas com perfil
**Administrador**, então se você não consegue nem ver o menu "Usuários",
precisa de uma conta nova, de desativar alguém, ou de trocar uma senha,
peça a uma Administradora — ou, na ausência de uma, a quem desenvolve o
site. Se você **é** Administradora, essa tela é sua e está descrita em
[Usuários: papéis, ativar e desativar
contas](#usuários-papéis-ativar-e-desativar-contas).
- **Qualquer coisa fora do painel `/admin`** — arquivos de código, banco
de dados, comandos de terminal. Nada disso deve ser mexido para uma
atualização de conteúdo do dia a dia; se alguém pedir para você rodar um
comando técnico para "gerar imagens" ou algo do tipo, isso é tarefa de
engenharia, não de edição de conteúdo — fale com quem desenvolve o site.
## Seções da home que somem por completo se ficarem vazias
Estas três seções da home **desaparecem inteiramente** (sem nenhum aviso ou
espaço reservado) se não houver nenhum item que atenda aos critérios
abaixo:
| Seção da home | Desaparece quando... |
|---|---|
| **Serviços** | zero serviços estiverem, ao mesmo tempo, publicados **e** com "Destaque" ligado |
| **Portfólio** | zero casos estiverem, ao mesmo tempo, publicados **e** com "Destaque" ligado |
| **Depoimentos** | zero depoimentos estiverem publicados (o "Destaque" não importa aqui) |
Se você despublicar o último item de uma dessas categorias — ou esquecer de
ligar "Destaque" em qualquer serviço/caso — a home simplesmente fica sem
aquele bloco, sem mensagem de erro em lugar nenhum. Se um bloco "sumiu" da
home, o primeiro lugar para checar é exatamente essa combinação de
publicado + destaque.
## Sobre o arquivo `depoimentos.md` na raiz do repositório
Existe um arquivo chamado `depoimentos.md` na raiz do projeto com os cinco
depoimentos reais originais, copiados manualmente de onde vieram
(WhatsApp/redes sociais). Ele foi o material bruto usado, uma única vez,
para digitar os depoimentos dentro do sistema (e é citado em documentos
técnicos antigos como a origem desse conteúdo) — mas **hoje nenhum código
do site lê esse arquivo**. Editar, corrigir ou apagar `depoimentos.md` **não
muda nada** no site: o texto que aparece de verdade para quem visita o site
vive no banco de dados, e é editado exclusivamente pela tela **Depoimentos**
descrita [acima](#depoimentos-adicionar-e-publicar). Trate esse arquivo como
material histórico de referência, não como fonte de conteúdo.

Binary file not shown.

After

Width:  |  Height:  |  Size: 137 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 192 KiB

View File

@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-08-11

View File

@@ -0,0 +1,37 @@
## Context
A suíte browser contém uma camada exclusiva de comparação de pixels: um teste com baselines versionados, um trait para estabilizar capturas, exportação de diffs e configuração de CI para seeds e artefatos determinísticos. O restante da cobertura browser — smoke, acessibilidade, motion e fixtures de conteúdo — não depende dessa camada.
## Goals / Non-Goals
**Goals:**
- Remover integralmente a comparação de pixels, os baselines e a infraestrutura exclusiva de suporte.
- Manter o job browser em FrankenPHP e suas verificações funcionais, de acessibilidade e motion.
- Atualizar as fontes normativas e as specs ativas para que não exijam snapshots visuais.
**Non-Goals:**
- Não substituir a comparação de pixels por outro serviço ou ferramenta de regressão visual.
- Não remover `VisualContentSeeder`, fixtures JPEG, `APP_FROZEN_NOW` que tenham usos fora das capturas, screenshots documentais ou testes browser não relacionados.
- Não alterar APIs públicas, rotas, conteúdo nem comportamentos de interface.
## Decisions
1. **Eliminar a capability em vez de reduzir a matriz.** Todos os requisitos de `visual-regression` serão removidos, pois o produto não terá mais comparação de pixels. Alternativa rejeitada: conservar apenas algumas telas, pois ainda manteria baselines e o custo operacional indesejado.
2. **Preservar diagnósticos gerais de browser.** O CI manterá screenshots, logs e relatórios úteis a falhas funcionais; serão removidos apenas seed global, relógio congelado e artefatos específicos de baseline/diff. Alternativa rejeitada: retirar todos os diagnósticos, pois reduziria a capacidade de investigar falhas não visuais.
3. **Separar determinismo reutilizável da captura visual.** `VisualContentSeeder` e `APP_FROZEN_NOW` permanecem quando usados por outros testes ou pela aplicação; somente as referências exclusivas a screenshot são excluídas. Alternativa rejeitada: remover os nomes por associação, pois quebraria fixtures e cenários existentes.
## Risks / Trade-offs
- [Uma mudança puramente visual deixa de bloquear CI] → acessibilidade, smoke, motion e revisão humana continuam no fluxo de qualidade.
- [Remover preparação comum do job browser afeta testes restantes] → inspecionar usos de seed/relógio e executar a suíte browser contra FrankenPHP após a alteração.
- [Uma spec ativa ainda reintroduz snapshots] → remover as menções no delta de `enhance-public-motion` antes da validação OpenSpec.
## Migration Plan
Publicar a remoção junto com a atualização da documentação e do CI; não há migração de dados. O rollback é um revert do commit, restaurando os arquivos versionados e a configuração anterior.
## Open Questions
Nenhuma.

View File

@@ -0,0 +1,25 @@
## Why
As comparações pixel-a-pixel e seus baselines adicionam manutenção e infraestrutura exclusiva sem serem um gate de qualidade desejado. A qualidade do site continuará protegida por jornadas browser, acessibilidade, motion, smoke e screenshots documentais, sem dependência de snapshots versionados.
## What Changes
- Remove a capability `visual-regression` e todos os requisitos de baselines, snapshots e diffs visuais.
- Atualiza os quality gates para manter a cobertura browser funcional, de acessibilidade e motion sem comparações de pixels.
- Remove a infraestrutura de testes, CI e documentação usada exclusivamente para regressão visual.
- **BREAKING** para contribuidores: o comando `composer visual:update` deixa de existir.
## Capabilities
### New Capabilities
Nenhuma.
### Modified Capabilities
- `visual-regression`: Remover os requisitos da capability descontinuada.
- `quality-gates`: Remover a exigência de executar e publicar diagnósticos de snapshots visuais.
## Impact
Afeta os testes browser e seus helpers exclusivos, baselines rastreados, configuração de Composer e CI, `.gitignore`, `SPEC.md` e a documentação ativa de motion. Não altera rotas, APIs, dados de conteúdo, fixtures reutilizadas, nem a cobertura funcional, de acessibilidade ou motion.

View File

@@ -0,0 +1,21 @@
## MODIFIED Requirements
### Requirement: Browser tests run against FrankenPHP-served application
The system SHALL execute browser tests using Pest Browser/Playwright against an application served by FrankenPHP in CI. The `browser` job MUST cover the E2E journeys available in the current phase, the automated accessibility checks, motion behavior and smoke checks for public routes (SPEC §13.4, §13.7 and §14.1), without pixel comparison.
#### Scenario: Browser job validates served application
- **WHEN** the `browser` CI job runs
- **THEN** tests execute against the built application artifact or equivalent production-like image
#### Scenario: Browser job covers functional, accessibility and motion assertions
- **WHEN** the `browser` CI job runs
- **THEN** it MUST execute the functional browser, accessibility, motion and smoke suites
- **AND** a failing functional assertion or a critical/serious accessibility issue MUST block merge
#### Scenario: Browser failures publish diagnostics
- **WHEN** a browser test fails in CI
- **THEN** the job MUST publish screenshots, application logs and browser logs as diagnostics

View File

@@ -0,0 +1,13 @@
## REMOVED Requirements
### Requirement: Public screens have desktop and mobile visual baselines
**Reason**: O projeto não manterá mais comparação de pixels nem baselines versionados para telas públicas.
**Migration**: Remover as asserções de screenshot e os arquivos baseline; manter testes browser funcionais, de acessibilidade, motion e smoke.
### Requirement: Visual runs are deterministic
**Reason**: Não haverá execução de captura para comparação visual.
**Migration**: Preservar seeds e relógio congelado apenas onde forem necessários por outros testes.
### Requirement: Baseline updates are explicit and reviewed
**Reason**: O comando e o fluxo de atualização de baselines foram descontinuados junto com a capability.
**Migration**: Remover `composer visual:update` e qualquer configuração de CI dedicada a regenerar, transportar ou publicar baselines e diffs.

View File

@@ -0,0 +1,19 @@
## 1. Contratos normativos
- [x] 1.1 Remover os requisitos e critérios de regressão visual de `SPEC.md`, ADR-005 e do delta ativo `enhance-public-motion`.
- [x] 1.2 Sincronizar os deltas da mudança para remover a capability `visual-regression` e atualizar `quality-gates`.
## 2. Infraestrutura de regressão visual
- [x] 2.1 Remover o teste browser, os 16 baselines, o trait de screenshot, o exportador de diffs e seu teste unitário.
- [x] 2.2 Simplificar o `TestCase` e remover o script Composer e a regra de ignore exclusivos de baselines.
## 3. Browser CI e documentação operacional
- [x] 3.1 Remover seed global, relógio congelado e cópias de snapshots/diffs exclusivos de baseline do job browser, preservando diagnósticos funcionais.
- [x] 3.2 Atualizar a documentação operacional que referencia o fluxo descontinuado sem afetar fixtures ou congelamento usados fora dele.
## 4. Verificação e arquivamento
- [x] 4.1 Confirmar ausência dos símbolos e comandos removidos no código ativo e executar os gates de qualidade aplicáveis.
- [x] 4.2 Validar OpenSpec estritamente, marcar as tarefas e arquivar a mudança sincronizada.

View File

@@ -1,23 +1,23 @@
## 1. Auth and strict types parity
- [ ] 1.1 Add `MustVerifyEmail` to `User` and require verified + active in `canAccessPanel`; update seed so admin/assistant are verified; feature tests for unverified denial and verified access
- [ ] 1.2 Confirm Filament/Laravel password reset is enabled; add feature tests for registered vs unknown email without account enumeration
- [ ] 1.3 Add `declare(strict_types=1);` to project-owned PHP files missing it (e.g. `AdminPanelProvider`); architecture/unit regression as needed
- [ ] 1.4 Run `composer pint`, `composer phpstan`, and `composer test:feature` for auth changes
- [x] 1.1 Add `MustVerifyEmail` to `User` and require verified + active in `canAccessPanel`; update seed so admin/assistant are verified; feature tests for unverified denial and verified access
- [x] 1.2 Confirm Filament/Laravel password reset is enabled; add feature tests for registered vs unknown email without account enumeration — required a custom `App\Filament\Pages\Auth\RequestPasswordReset` overriding Filament's stock page, which discloses account existence via a distinguishable danger notification on `Password::INVALID_USER`
- [x] 1.3 Add `declare(strict_types=1);` to project-owned PHP files missing it (e.g. `AdminPanelProvider`); architecture/unit regression as needed — 15 files total: `AdminPanelProvider`, `Controller`, and 13 Filament Resource Pages classes
- [x] 1.4 Run `composer pint`, `composer phpstan`, and `composer test:feature` for auth changes — all green
## 2. Local runtime and PHP 8.4 alignment
- [ ] 2.1 Extend `docker-compose.yml` with FrankenPHP `app` service (build Dockerfile, depend on healthy postgres, publish 8000); document in README
- [ ] 2.2 Align README/docs to PHP 8.4 canonical (keep Composer `^8.3`); verify Dockerfile/CI already on 8.4
- [ ] 2.3 Smoke local compose: `docker compose up -d``GET /up` returns 200
- [ ] 2.4 Run `composer quality` after compose/docs changes
- [x] 2.1 Extend `docker-compose.yml` with FrankenPHP `app` service (build Dockerfile, depend on healthy postgres, publish 8000); document in README
- [x] 2.2 Align README/docs to PHP 8.4 canonical (keep Composer `^8.3`); verify Dockerfile/CI already on 8.4
- [x] 2.3 Smoke local compose: `docker compose up -d``GET /up` returns 200 — run as an isolated `-p fase0smoke` project (separate container names/ports via a `!override` compose overlay, kept outside the repo) so it didn't collide with the `amare-postgres` container already running for a concurrent sibling worktree session. `depends_on: condition: service_healthy` correctly gated `app` on Postgres's healthcheck, `curl localhost:18000/up` returned `200`, and `docker exec ... php artisan migrate --force` succeeded — proving `DB_HOST: postgres` resolves the `app` container to the `postgres` service by Compose's service-name DNS, not just that the image boots. Torn down afterwards (`down -v` + image removal); the shared sibling `amare-postgres` container was untouched throughout.
- [x] 2.4 Run `composer quality` after compose/docs changes — pint/phpstan/test:feature all green locally (browser suite is CI-only, per AGENTS.md)
## 3. Quality gates: npm audit and coverage
- [ ] 3.1 Add npm audit step to `composer quality` and CI `static` (policy: production deps; document any allowlist)
- [ ] 3.2 Enable Domain/Application coverage in CI `unit` with 80% fail threshold; exclude views/migrations/framework
- [ ] 3.3 Add/adjust unit tests if current Domain/Application coverage is below threshold
- [ ] 3.4 Verify CI `static` and `unit` fail appropriately on intentional audit/coverage breakage in a branch experiment or equivalent proof
- [x] 3.1 Add npm audit step to `composer quality` and CI `static` (policy: production deps; document any allowlist)`npm audit --omit=dev --audit-level=high`, rationale documented inline in `ci.yml`; currently a vacuous forward guard since `package.json` has no runtime `dependencies`
- [x] 3.2 Enable Domain/Application coverage in CI `unit` with 80% fail threshold; exclude views/migrations/framework — scoped via a dedicated `phpunit.coverage.xml` (not the project-wide `phpunit.xml`), run as `Unit,Architecture,Feature` because the `Application/Queries/Marketing` classes are only exercised via Feature/HTTP tests; measured locally with `pcov` at 98.1%, well above the 80% gate
- [x] 3.3 Add/adjust unit tests if current Domain/Application coverage is below threshold — no-op: measured coverage (98.1%) already clears 80% with existing Feature-suite coverage of the Marketing queries plus existing `PageMeta`/`HomeContent` unit tests
- [ ] 3.4 Verify CI `static` and `unit` fail appropriately on intentional audit/coverage breakage in a branch experiment or equivalent proof — blocked: no push/PR in this task's scope, so no real CI run exists to break intentionally; defer to a follow-up once a PR is open
## 4. Staging/production Compose and Dokploy prep
@@ -38,6 +38,6 @@
- [ ] 6.1 Perform first successful staging deploy of a `main` SHA and capture evidence (workflow URL, smoke output)
- [ ] 6.2 Verify rollback to previous SHA works once on staging
- [ ] 6.3 Update `SPEC.md` §18 Fase 0 checkboxes only for items with evidence; note remaining deferred items if any
- [x] 6.3 Update `SPEC.md` §18 Fase 0 checkboxes only for items with evidence; note remaining deferred items if any — flipped L2338 (FrankenPHP/Compose) and the auth/npm-audit/coverage bullets to `[x]`; left the staging hello-world bullet and the phase exit-criterion line unchecked (Dokploy deploy still failing, out of scope here)
- [ ] 6.4 Run full `composer quality` and confirm all five CI jobs + staging deploy path green
- [ ] 6.5 Report in SPEC §24 format; archive this change only after remaining parity tasks (13) also complete

View File

@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-08-06

View File

@@ -0,0 +1,50 @@
## Context
O site público usa Blade/Tailwind e já contém um runtime pequeno em `resources/js/motion.js`: abertura focal da home, `page-open`, reveals com `IntersectionObserver` e progresso do índice. A cobertura e a marcação são parciais, os reveals não têm grupos/direções explícitas e a matriz browser se concentra nas quatro páginas visuais originais. A mudança cruza templates, CSS, JavaScript e testes, mas não envolve estado de aplicação, dados ou dependências.
## Goals / Non-Goals
**Goals:**
- Tornar a coreografia Heritage Editorial consistente em toda página pública visual.
- Manter texto legível durante toda entrada, animando somente `transform` e recorte de mídia.
- Garantir enhancement progressivo: estado final imediato sem JavaScript, observer ou com movimento reduzido.
- Preservar interação, foco, validação e ausência de overflow em desktop/mobile.
- Cobrir marcação, comportamento real, acessibilidade e console.
**Non-Goals:**
- Não criar transições de rota, parallax, animação contínua ou repetição ao voltar no scroll.
- Não mudar copy, dados de clientes, estrutura de layout, CMS, controllers, banco, APIs ou Filament.
- Não adicionar biblioteca de animação nem itens de SPEC.md §4.2.
## Decisions
1. **Contrato declarativo em atributos `data-*`.** Templates usarão `data-motion="page-open"`, `data-motion-beat`, `data-reveal-group`, `data-reveal` e `data-reveal-from="up|left|right"`. CSS define o estado visual e JS apenas ativa classes. Alternativa rejeitada: acoplar seletores a classes de layout, por tornar a coreografia frágil a ajustes visuais.
2. **Enhancement opt-in pelo elemento raiz.** O HTML inicial permanece no estado final; o runtime adiciona `html[data-motion="enhance"]` somente quando movimento é permitido e os recursos necessários existem. Sem JS, sem `IntersectionObserver`, ou em `reduce`, o atributo não é aplicado/removido e todo conteúdo fica final. Alternativa rejeitada: estado inicial oculto no HTML/CSS, pois pode prender conteúdo fora da tela.
3. **Transformação sem fade de texto.** Entradas duram cerca de 500 ms com `cubic-bezier(0.16, 1, 0.3, 1)`, deslocamento vertical de 12 px e lateral de 24 px no desktop/16 px no mobile. Grupos recebem stagger de 90 ms limitado a 270 ms. Mídia pode combinar transform com `clip-path`; texto não usa opacidade. Alternativa rejeitada: fade geral, que reduz contraste durante axe e legibilidade percebida.
4. **Observer de execução única.** Cada reveal intersectado recebe o estado final e é removido do observer. Grupos calculam índice limitado para o delay; depoimentos definem explicitamente `left`/`right` pela coluna, preservando alternância no mobile. Alternativa rejeitada: reanimar em toda rolagem, por criar fadiga e instabilidade.
5. **Feedback separado das entradas.** CTAs, links, navegação e controles recebem transições curtas (100200 ms) em cor/transform/borda. `:focus-visible` continua prioritário; alertas e mensagens de erro não entram no contrato de motion. Alternativa rejeitada: feedback via JS, desnecessário para estados CSS.
6. **Progresso com coalescência por frame.** Eventos de scroll apenas agendam uma atualização por `requestAnimationFrame`; o cálculo existente é preservado. Alternativa rejeitada: calcular em todo evento, que multiplica leituras/escritas durante scroll.
7. **Testes em camadas.** Feature tests comprovam contratos Blade/CSS/JS e renderização de erros; browser tests comprovam entrada, stagger, direções, reduced motion, fallback, interação, overflow e console. Axe e console incluem sobre, contato, privacidade e 404; demais erros ficam em renderização feature.
## Risks / Trade-offs
- [Conteúdo pisca entre estado final e início do enhancement] → inicializar no primeiro módulo Vite, limitar transformações a distâncias pequenas e nunca ocultar texto.
- [Transforms laterais causam overflow horizontal] → limitar distância por breakpoint, manter recorte no contêiner público e testar `scrollWidth` em ambos viewports.
- [Muitos observers/estilos inline] → usar um único observer, custom property de índice limitada e `unobserve` imediato.
- [Páginas de erro não carregam o runtime em todos os contextos] → o estado final é seguro; feature tests cobrem os cinco templates.
## Migration Plan
Publicar CSS, JS e templates no mesmo bundle/commit; não há migração de dados. Rollback consiste em reverter esses assets e atributos, sem compatibilidade de schema ou limpeza operacional.
## Open Questions
Nenhuma; direção, alternância mobile, execução única e limites de escopo foram aprovados no plano.

View File

@@ -0,0 +1,30 @@
## Why
O site público já possui uma abertura focal na home, mas o restante da experiência ainda muda de estado de forma desigual entre páginas e seções. WEB-01, WEB-07 e os requisitos transversais de acessibilidade pedem uma coreografia editorial consistente, progressiva e dispensável, sem ocultar conteúdo nem comprometer interação, performance ou movimento reduzido.
## What Changes
- Evoluir o contrato de motion do site público com abertura de página, sequência editorial inicial e reveals direcionais executados uma única vez.
- Aplicar a coreografia à home, serviços, portfólio, detalhe, sobre, contato, privacidade e páginas de erro 404/419/429/500/503.
- Alternar depoimentos entre esquerda e direita, filtrando citações vazias antes de renderizar a sequência.
- Adicionar feedback curto e não bloqueante a links, CTAs, navegação e controles de formulário, preservando foco e mensagens de validação.
- Garantir estado final imediato sem JavaScript, sem `IntersectionObserver` e com `prefers-reduced-motion: reduce`.
- Limitar o progresso/índice da home a uma atualização por frame e ampliar testes de marcação, browser, acessibilidade e console.
- Não objetivos: não criar transições entre rotas, alterar conteúdo ou layout estrutural, adicionar biblioteca de animação, modificar CMS/API/banco/Filament, nem introduzir itens fora do MVP listados em SPEC.md §4.2.
## Capabilities
### New Capabilities
- `public-site-motion`: Coreografia progressiva, direções, timings, execução única, microfeedback, performance e fallbacks do motion público.
### Modified Capabilities
- `design-tokens`: Especificar tokens compartilhados de duração, stagger, distância e easing para motion editorial.
- `public-site-pages`: Cobrir todas as páginas públicas visuais e templates de erro com o contrato compartilhado de abertura e reveal.
- `testimonials`: Filtrar citações vazias e alternar explicitamente a direção de entrada dos depoimentos renderizados.
- `web-accessibility`: Ampliar axe/console e validar conteúdo imediatamente utilizável com movimento reduzido ou enhancement indisponível.
## Impact
Afeta somente templates Blade públicos, `resources/js/motion.js`, tokens/regras CSS e testes Pest/Pest Browser. Não altera APIs, banco, controllers, CMS, dependências, endpoints técnicos (`sitemap.xml`, `robots.txt`) ou Filament.

View File

@@ -0,0 +1,9 @@
## ADDED Requirements
### Requirement: Editorial motion tokens are centralized
The system SHALL define shared public motion tokens for an approximately 500 ms entrance, short interaction feedback, exponential ease-out, 90 ms stagger capped at 270 ms, 12 px vertical distance, and 24 px desktop / 16 px mobile lateral distance. Public motion CSS MUST consume these tokens instead of duplicating arbitrary values.
#### Scenario: Motion runtime uses shared values
- **WHEN** page openings, reveals, or interaction feedback are styled
- **THEN** duration, easing, stagger, and distance MUST be derived from centralized tokens

View File

@@ -0,0 +1,74 @@
## ADDED Requirements
### Requirement: Public pages use shared progressive motion choreography
The public site SHALL expose a declarative motion contract using `data-motion="page-open"`, `data-motion-beat`, `data-reveal-group`, `data-reveal`, and `data-reveal-from="up|left|right"`. Home, services, portfolio index/detail, about, contact, privacy, and branded 404/419/429/500/503 pages MUST use the contract without introducing route transitions.
#### Scenario: Visual public route exposes motion hooks
- **WHEN** a visitor loads any visual public route or branded error page
- **THEN** the rendered page MUST expose a page opening hook and appropriate reveal hooks
#### Scenario: Technical endpoints remain outside motion
- **WHEN** a visitor requests `sitemap.xml` or `robots.txt`
- **THEN** the response MUST NOT depend on the public motion runtime
### Requirement: Editorial entrance timing and direction are consistent
Entrances SHALL use shared tokens for an approximately 500 ms duration, 90 ms stagger capped at 270 ms, 12 px vertical distance, and 24 px desktop / 16 px mobile lateral distance. Text MUST remain fully visible; motion MUST use transforms and MAY use media clipping without opacity fades for text.
#### Scenario: Reveal group computes capped stagger
- **WHEN** more than four reveal items are rendered in one group
- **THEN** the applied delay MUST NOT exceed 270 ms
#### Scenario: Text enters without opacity fade
- **WHEN** page opening or scroll reveal motion runs
- **THEN** text MUST remain visible throughout the transition
### Requirement: Reveals execute once without blocking interaction
Each scroll reveal SHALL activate once, reach its final state, and be removed from observation. Visitors MUST be able to activate links and form controls while entrance motion is running.
#### Scenario: Revealed content leaves observer
- **WHEN** a reveal target intersects the configured viewport threshold
- **THEN** it MUST receive the final state and be unobserved
- **AND** scrolling away and back MUST NOT replay the reveal
#### Scenario: CTA remains interactive during entrance
- **WHEN** a visitor activates a CTA while its entrance is in progress
- **THEN** navigation MUST proceed without waiting for animation completion
### Requirement: Motion enhancement has immediate safe fallbacks
Without JavaScript, without `IntersectionObserver`, or when `prefers-reduced-motion: reduce` is active, the site SHALL render every motion target immediately in its final usable state. Non-essential animation and transition MUST NOT run under reduced motion.
#### Scenario: JavaScript is unavailable
- **WHEN** a public page renders without executing JavaScript
- **THEN** all content MUST be visible and usable in its final state
#### Scenario: IntersectionObserver is unavailable
- **WHEN** JavaScript runs but `IntersectionObserver` is not supported
- **THEN** all reveal targets MUST remain in their final state
#### Scenario: Reduced motion is preferred
- **WHEN** the browser reports `prefers-reduced-motion: reduce`
- **THEN** page openings, reveals, media clips, and non-essential feedback transitions MUST NOT run
### Requirement: Public interaction feedback is short and accessible
CTAs, links, navigation, and form controls SHALL provide short feedback using shared tokens while preserving visible `:focus-visible` indication. Alerts and validation messages MUST NOT be animated by this contract.
#### Scenario: Keyboard focus remains visible
- **WHEN** a visitor focuses an interactive element with the keyboard
- **THEN** its focus indicator MUST remain visible and MUST NOT be displaced by motion feedback
#### Scenario: Validation message appears without motion
- **WHEN** server validation renders an error message
- **THEN** the message MUST appear in its final position without entrance animation
### Requirement: Home progress updates at most once per animation frame
Scroll-driven chapter index and progress updates on the home SHALL be coalesced through `requestAnimationFrame`, with at most one pending update per frame.
#### Scenario: Multiple scroll events occur in one frame
- **WHEN** multiple scroll events fire before the next animation frame
- **THEN** the home progress calculation MUST execute only once for that frame

View File

@@ -0,0 +1,14 @@
## ADDED Requirements
### Requirement: Visual public and error pages expose the shared motion contract
The system SHALL apply the shared progressive opening and reveal contract to home, services, portfolio index/detail, about, contact, privacy, and branded 404/419/429/500/503 pages. The contract MUST NOT change page copy, content ordering, route behavior, or structural layout.
#### Scenario: Institutional page uses shared opening
- **WHEN** a visitor loads `/sobre`, `/contato`, or `/privacidade`
- **THEN** the page MUST expose the shared page opening hook and section reveal hooks
#### Scenario: Error page remains branded and progressively enhanced
- **WHEN** Laravel renders a branded 404, 419, 429, 500, or 503 response
- **THEN** the page MUST expose the shared opening hook
- **AND** all error guidance and navigation MUST remain immediately usable without enhancement

View File

@@ -0,0 +1,43 @@
## MODIFIED Requirements
### Requirement: Testimonials are managed with publication control
The system SHALL allow admins to manage testimonials (SPEC WEB-04) with quote text (including multi-paragraph content), author name, optional context (event type and/or date), optional photo with alt text, sort order, featured flag, and `published_at`. Public rendering MUST preserve paragraph breaks from the stored quote, MUST omit blank/whitespace-only quotes before creating the rendered sequence, and MUST assign alternating `left`/`right` reveal directions according to the remaining rendered column order at desktop and mobile viewports. Testimonials sourced from real clients MUST NOT be published to production without authorization; development seeds MAY include the authorized-pending real quotes marked for review.
#### Scenario: Unpublished testimonial is excluded
- **WHEN** a testimonial has `published_at` null
- **THEN** the `published()` scope MUST exclude it
#### Scenario: Published testimonial is queryable
- **WHEN** an admin sets `published_at` with required quote and author name
- **THEN** the testimonial MUST be included in the `published()` scope
#### Scenario: Assistant cannot manage testimonials
- **WHEN** an assistant attempts to access the testimonials Resource
- **THEN** access MUST be denied with HTTP 403
#### Scenario: Featured testimonials are filterable
- **WHEN** content is queried with featured filter
- **THEN** records with `is_featured` true MUST be retrievable independently of sort order
#### Scenario: Multi-paragraph quotes render as paragraphs
- **GIVEN** a published testimonial whose quote contains blank-line separated paragraphs
- **WHEN** the home testimonials section is rendered
- **THEN** each paragraph MUST appear as distinct block text rather than a single collapsed line
#### Scenario: Blank quotes do not affect alternation
- **GIVEN** the published testimonial collection contains a blank quote between two non-blank quotes
- **WHEN** the home testimonials section is rendered
- **THEN** the blank testimonial MUST be omitted
- **AND** the two rendered testimonials MUST receive alternating `left` and `right` directions based on their rendered order
#### Scenario: Direction alternation remains on mobile
- **WHEN** testimonials are viewed at a mobile viewport
- **THEN** their explicit `left` and `right` directions MUST be preserved with the reduced mobile distance

View File

@@ -0,0 +1,51 @@
## MODIFIED Requirements
### Requirement: Public routes have no critical or serious accessibility issues
The system SHALL run automated accessibility checks on the public routes covered by the browser suite (SPEC §6.5, §13.8) after the Heritage Editorial motion enhancement. A critical or serious issue MUST fail the suite.
#### Scenario: Critical issue blocks the suite
- **WHEN** the automated accessibility check reports a critical or serious issue on a covered route
- **THEN** the browser suite MUST fail and report the offending rule and selector
#### Scenario: Covered routes are checked
- **WHEN** the accessibility suite runs
- **THEN** home, services listing, portfolio listing, case detail, about, contact, privacy and branded 404 MUST each be checked
### Requirement: Reduced motion preference is honored
The system SHALL suppress non-essential animation and transition when the user agent reports `prefers-reduced-motion: reduce`, including page openings, editorial reveals, image hover scales, interaction feedback and menu transitions. All affected content MUST render in its final visible and interactive state.
#### Scenario: Reduced motion disables transitions
- **GIVEN** the browser reports `prefers-reduced-motion: reduce`
- **WHEN** a public page is loaded
- **THEN** decorative transitions and animations MUST NOT run
- **AND** every motion target MUST be in its final visible and interactive state
### Requirement: Public pages emit no console errors
Covered public routes SHALL load without JavaScript console errors in a real browser (SPEC §13.8, §19), including home, services listing, portfolio listing, case detail, about, contact, privacy, branded 404, and pages that load the mobile navigation or motion runtime.
#### Scenario: Console stays clean on covered routes
- **WHEN** a covered public route is loaded in the browser suite
- **THEN** the console MUST contain no error-level messages
## ADDED Requirements
### Requirement: Motion enhancement failure does not hide or disable content
The public site SHALL remain readable, keyboard-operable, and interactive when JavaScript is disabled or `IntersectionObserver` is unavailable. Motion targets MUST default to their final state without relying on timeout recovery.
#### Scenario: JavaScript disabled keeps public page usable
- **WHEN** a visitor loads a public page with JavaScript disabled
- **THEN** headings, copy, navigation, CTAs, and form controls MUST be visible and operable
#### Scenario: Observer unavailable keeps reveals final
- **WHEN** the motion runtime executes without `IntersectionObserver`
- **THEN** reveal targets MUST remain in the final state

View File

@@ -0,0 +1,24 @@
## 1. Motion contract and runtime
- [x] 1.1 Extend feature tests first for motion tokens, declarative hooks, no-opacity text, safe fallbacks, one-shot observer, capped stagger and requestAnimationFrame coalescing
- [x] 1.2 Add centralized motion tokens and CSS states for page openings, grouped directional reveals, media clips and accessible interaction feedback
- [x] 1.3 Refactor `resources/js/motion.js` to enhance only when supported, initialize page beats/groups, unobserve revealed targets and coalesce progress updates per frame
## 2. Public template coverage
- [x] 2.1 Add failing markup coverage for home, services, portfolio index/detail, about, contact, privacy and branded 404/419/429/500/503 pages
- [x] 2.2 Apply page-open, beat, reveal-group and directional reveal hooks across all visual public templates without changing content or layout structure
- [x] 2.3 Add failing testimonial coverage, filter blank quotes before the rendered sequence and alternate explicit left/right directions on desktop and mobile
## 3. Browser and accessibility coverage
- [x] 3.1 Add browser tests for real opening/reveal state, 90 ms capped stagger, one-shot observation and interaction during motion
- [x] 3.2 Add desktop/mobile tests for directional distance, reduced motion final state, no-JS/observer fallback, horizontal overflow and clean console
- [x] 3.3 Extend axe and console route matrices to about, contact, privacy and branded 404
- [x] 3.4 Extend axe and console route matrices to about, contact, privacy and branded 404
## 4. Verification and delivery
- [x] 4.1 Run focused feature/browser tests, build, Pint, isolated-cache PHPStan and strict OpenSpec validation
- [x] 4.2 Run `composer quality` and visually review desktop/mobile with normal and reduced motion
- [ ] 4.3 Stage only scoped files, commit conventionally, open a PR with screenshots and verification evidence, watch CI to green, merge and remove the worktree

View File

@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-08-11

View File

@@ -0,0 +1,44 @@
## Context
O commit de base ja separa as imagens hero de Open Graph, oferece variantes responsivas, fallbacks tonais e preserva o formulario de briefing. As views publicas ainda usam algumas grades simetricas, especialmente nas colecoes de portfolio. A referencia aprovada define composicao e materialidade, nunca novo acervo ou copy.
## Goals / Non-Goals
**Goals:**
- Aplicar uma cadencia de campos tonais, regras e proporcoes alternadas nas paginas narrativas.
- Manter uma ordem DOM linear e completa em telas pequenas.
- Reforcar os contratos existentes de acessibilidade, motion progressivo, performance de imagem e SEO.
**Non-Goals:**
- Alterar dados CMS, contratos de rota, controller, metadata, uploads, variantes ou o formulario.
- Usar imagens externas, criar conteudo comercial, adicionar dependencias, atualizar snapshots ou reintroduzir regressao visual.
## Decisions
### Usar variacoes de `col-span` e `aspect-ratio` nas galerias existentes
As imagens continuam no mesmo componente responsivo, mas a primeira imagem e os itens pares recebem encaixes editoriais no desktop. A alternativa seria uma galeria JavaScript ou masonry; foi descartada por prejudicar a ordem de leitura e adicionar comportamento sem necessidade.
### Aplicar assimetria pela composicao, nao por posicionamento absoluto
Grid responsivo, margens e campos tonais criam o deslocamento sem tirar conteudo do fluxo. Isso preserva foco, leitura mobile e fallback sem JavaScript. Posicionamento absoluto foi descartado por aumentar risco de sobreposicao e overflow.
### Reutilizar motion de transformacao e clip ja existente
As novas regioes conservam `data-reveal` e grupos existentes. Nenhum texto passa a depender de opacidade; `prefers-reduced-motion` continua exibindo o estado final imediatamente.
## Risks / Trade-offs
- [Ritmo visual pode parecer irregular com poucos cases] -> regras e metadados mantem uma leitura coerente mesmo com um unico item.
- [Classes responsivas podem afetar o fluxo em mobile] -> DOM linear e classes de uma coluna continuam sendo a base abaixo de `md`.
- [Mudar CSS pode gerar diferencas de ambiente] -> validar markup, acessibilidade funcional e build; snapshots permanecem fora do escopo.
## Migration Plan
Nao ha migracao de dados. A mudanca e retrocompativel: sem imagens, os fallbacks tonais existentes continuam ativos; com imagens, o mesmo `srcset` responsivo e usado. Reverter o commit restaura somente apresentacao.
## Open Questions
Nenhuma. Fotografia autorizada de producao permanece uma dependencia de conteudo futura.

View File

@@ -0,0 +1,30 @@
## Why
O site público já possui os conteúdos, fluxos e a base Heritage Editorial, mas algumas páginas ainda repetem grades regulares que enfraquecem a cadência da referência aprovada. A recomposição consolida uma leitura editorial contínua sem alterar dados CMS, SEO ou a jornada de briefing.
## What Changes
- Recompõe as superfícies públicas narrativas com campos tonais, regras finas, assimetria desktop e sequência vertical legível em mobile.
- Organiza a home nos capítulos editoriais existentes, com portfólio em campo oliva e recortes de imagens em proporções alternadas.
- Reestiliza listagens e detalhe de portfólio para que imagens e blocos de leitura não dependam de uma grade de cartões repetida.
- Mantém contato, privacidade e erros em tratamento sóbrio; preserva menu móvel, foco, formulário, SEO, motion progressivo e conteúdo do CMS.
- Documenta que heros fotográficos e sua administração já estão presentes na base atual, sem acoplar mídia editorial à imagem Open Graph.
## Capabilities
### New Capabilities
- Nenhuma.
### Modified Capabilities
- `public-site-pages`: composição e cadência visual das rotas públicas passam a exigir ritmos editoriais assimétricos.
- `content-media`: a apresentação de mídia de portfólio passa a suportar proporções editoriais alternadas sem mudar armazenamento ou variantes.
## Impact
Afeta Blade e estilos do site público, testes de estrutura/renderização e a documentação OpenSpec. Não altera rotas, controladores, dados de conteúdo, dependências ou contratos de SEO.
## Non-goals
Não introduz itens excluídos por SPEC.md §4.2, novos canais, prova comercial, conteúdo de produção, imagens externas, dependências de animação, snapshots visuais ou mudanças no Filament.

View File

@@ -0,0 +1,16 @@
## MODIFIED Requirements
### Requirement: Editorial image treatment remains self-hosted and deterministic
Public photography SHALL continue to use validated self-hosted uploads and responsive variants. Editorial layouts MUST use CSS-only tonal treatment and alternating aspect ratios for portfolio media while retaining document order and the image component's `srcset`, `sizes`, dimensions and loading behavior. The system MUST NOT introduce external image CDN dependencies that compromise browser-test reliability.
#### Scenario: Public pages do not depend on external stock hosts
- **WHEN** the visual or browser suite loads covered public routes
- **THEN** content images MUST resolve from the application media disk or static fixtures
- **AND** MUST NOT require network access to third-party stock hosts
#### Scenario: Editorial portfolio media retains responsive delivery
- **WHEN** a portfolio listing or gallery applies an editorial image proportion
- **THEN** the rendered image MUST still expose the responsive media component markup
- **AND** image order and lazy-loading behavior MUST remain intact

View File

@@ -0,0 +1,69 @@
## MODIFIED Requirements
### Requirement: Home renders the editorial structure from CMS content
The home page SHALL render, in order: header/navigation, hero, manifesto, featured services summary, featured portfolio selection, working method (four steps), testimonials, Amare positioning/profile, final contact CTA, and footer with contact, social links and legal links (WEB-01). Hero copy, brand name, manifesto, method and principles MUST come from `site_settings` (with editorial defaults when optional fields are empty); services, cases and testimonials MUST come from published records. The home MUST follow the Heritage Editorial composition: asymmetric spreads and tonal fields on desktop, with portfolio images in alternating editorial proportions, and a linear complete sequence on mobile rather than rounded card grids.
#### Scenario: Published content is displayed in configured order
- **GIVEN** published services, cases and testimonials exist
- **WHEN** a visitor loads the home
- **THEN** the published content MUST be displayed following the `sort_order` and featured flags
- **AND** the hero MUST show the values stored in `site_settings`
- **AND** the manifesto, method and positioning sections MUST be present
- **AND** the portfolio section MUST use an olive tonal field with editorial image proportions
#### Scenario: CTA leads to the contact placeholder page
- **WHEN** a visitor activates the primary or final CTA on the home
- **THEN** the visitor MUST be taken to the `contact` route
- **AND** no lead record MUST be created
#### Scenario: Empty catalog sections are omitted
- **GIVEN** no published services, cases or testimonials
- **WHEN** a visitor loads the home
- **THEN** the response MUST be 200
- **AND** the services, portfolio and testimonials sections MUST be omitted instead of rendering empty containers
- **AND** hero, manifesto, method, positioning and final CTA MUST still render
#### Scenario: Home has no console errors
- **WHEN** the home is loaded in a real browser at desktop and mobile viewports
- **THEN** the browser console MUST contain no JavaScript errors
### Requirement: Listing and detail pages exist for catalog content
The system SHALL render a services listing (WEB-02) and a portfolio listing plus case detail (WEB-03) using the Heritage Editorial visual language. The case detail MUST present summary, event type, optional city/venue/date, challenge, solution, optional result, cover image and the ordered gallery. Portfolio listings and galleries MUST use alternating editorial image proportions on desktop while retaining DOM order and a single-column readable sequence on mobile.
#### Scenario: Services listing shows published services
- **WHEN** a visitor loads `/servicos`
- **THEN** every published service MUST be listed with title and summary in `sort_order`
- **AND** the listing MUST use the public editorial layout (not an unrelated visual system)
#### Scenario: Gallery respects stored order
- **GIVEN** a published case with multiple gallery images
- **WHEN** a visitor loads the case detail
- **THEN** the images MUST be rendered ordered by `sort_order`
- **AND** desktop presentation MUST alternate editorial image proportions without changing that order
#### Scenario: Listings paginate open-ended growth
- **WHEN** the number of published cases exceeds the page size
- **THEN** `/portfolio` MUST paginate instead of rendering all records
### Requirement: Institutional and error pages have brand identity
The system SHALL provide the Sobre and Política de privacidade pages and branded error pages (WEB-07) using the Heritage Editorial public layout, including the brand mark when available. Contact, privacy and error surfaces MUST remain sober tonal layouts without decorative photography. The 404 page MUST use the public layout, and the 500 page MUST NOT expose stack traces or internal details when `APP_DEBUG` is false.
#### Scenario: Unknown URL renders branded 404
- **WHEN** a visitor requests a non-existent public URL
- **THEN** the response status MUST be 404
- **AND** the page MUST use the public layout and offer navigation back to the home
#### Scenario: Server error hides internals in production
- **GIVEN** `APP_DEBUG` is false
- **WHEN** an unhandled exception occurs on a public route
- **THEN** the response MUST be a generic branded error page
- **AND** MUST NOT contain a stack trace, file path, or environment variable

View File

@@ -0,0 +1,10 @@
## 1. Editorial composition
- [x] 1.1 Add regression coverage for alternating public portfolio composition and sober functional surfaces.
- [x] 1.2 Recompose home portfolio, portfolio listing and case gallery with responsive editorial proportions while preserving order and responsive media delivery.
- [x] 1.3 Refine public content-page rhythm with tonal fields and desktop offsets without changing routes, CMS content or briefing behavior.
## 2. Verification
- [x] 2.1 Run focused feature and browser checks for public routes, motion, accessibility and navigation.
- [x] 2.2 Run formatting, static analysis, frontend build and strict OpenSpec validation without updating snapshots.

View File

@@ -3,7 +3,7 @@ schema: spec-driven
context: |
Fonte de verdade: SPEC.md na raiz. Precedência: instrução do dono do produto > SPEC.md > ADRs > testes > convenções.
Produto: plataforma de assessoria de eventos, single-tenant, MVP. UI em pt-BR, timezone America/Sao_Paulo, atuação em São Paulo (capital), BRL.
Stack: Laravel 13, Filament 5 (/admin), Livewire 4 + Blade + Alpine + Tailwind (site público),
Stack: Laravel 13, Filament 5 (/admin), Blade + Tailwind + JS vanilla progressivo (site público; sem framework reativo — Livewire 4 é dependência do Filament, ver SPEC ADR-015),
PostgreSQL, FrankenPHP regular mode (sem worker mode), Vite, Pest 4 + Pest Browser, database queue.
Arquitetura: monólito modular. Interface -> Application (Actions/Queries) -> Domain (Enums/VOs) -> Infrastructure.
Domain não depende de Filament/Livewire. strict_types em todo PHP próprio.

View File

@@ -127,7 +127,7 @@ The system SHALL provide optimized Amare brand logo assets derived from the offi
### Requirement: Editorial image treatment remains self-hosted and deterministic
Public photography SHALL continue to use validated self-hosted uploads and responsive variants. Decorative saturation/contrast treatment for editorial mood MUST be applied via CSS on self-hosted images and MUST NOT introduce external image CDN dependencies that break deterministic visual tests.
Public photography SHALL continue to use validated self-hosted uploads and responsive variants. Decorative saturation/contrast treatment for editorial mood MUST be applied via CSS on self-hosted images and MUST NOT introduce external image CDN dependencies that compromise browser-test reliability.
#### Scenario: Public pages do not depend on external stock hosts

View File

@@ -1,7 +1,7 @@
# quality-gates Specification
## Purpose
Define Composer quality scripts, architecture boundaries, PostgreSQL-backed feature tests, and the five blocking CI jobs including browser visual and accessibility coverage.
Define Composer quality scripts, architecture boundaries, PostgreSQL-backed feature tests, and the five blocking CI jobs including browser functional, accessibility and motion coverage.
## Requirements
### Requirement: Standardized Composer test scripts exist
@@ -53,20 +53,20 @@ The system SHALL configure `phpunit.xml` so that the Feature test suite uses Pos
### Requirement: Browser tests run against FrankenPHP-served application
The system SHALL execute browser tests using Pest Browser/Playwright against an application served by FrankenPHP in CI. The `browser` job MUST cover the E2E journeys available in the current phase, the visual regression assertions and the automated accessibility checks for public routes, and MUST run in assertion mode without regenerating baselines (SPEC §13.4, §13.5, §13.8, §14.1).
The system SHALL execute browser tests using Pest Browser/Playwright against an application served by FrankenPHP in CI. The `browser` job MUST cover the E2E journeys available in the current phase, automated accessibility checks, motion behavior and smoke checks for public routes (SPEC §13.4, §13.7 and §14.1), without pixel comparison.
#### Scenario: Browser job validates served application
- **WHEN** the `browser` CI job runs
- **THEN** tests execute against the built application artifact or equivalent production-like image
#### Scenario: Browser job covers visual and accessibility assertions
#### Scenario: Browser job covers functional, accessibility and motion assertions
- **WHEN** the `browser` CI job runs
- **THEN** it MUST execute the visual regression suite and the accessibility suite
- **AND** a failing snapshot or a critical/serious accessibility issue MUST block merge
- **THEN** it MUST execute the functional browser, accessibility, motion and smoke suites
- **AND** a failing functional assertion or a critical/serious accessibility issue MUST block merge
#### Scenario: Browser failures publish diagnostics
- **WHEN** a browser test fails in CI
- **THEN** the job MUST publish screenshots, snapshot diffs, application logs and browser logs as artifacts
- **THEN** the job MUST publish application logs and browser logs as diagnostics

View File

@@ -1,60 +0,0 @@
# visual-regression Specification
## Purpose
Define deterministic visual baselines for public screens and the explicit baseline update workflow.
## Requirements
### Requirement: Public screens have desktop and mobile visual baselines
The system SHALL keep versioned screenshot baselines for the public screens available in this phase (SPEC §13.5): Home, Serviços, Portfólio and Detalhe do portfólio, at 1440×1000 desktop and 390×844 mobile, under the Heritage Editorial identity. A rendering change that alters those screens MUST fail the browser suite until the diff is reviewed and baselines are explicitly updated.
#### Scenario: Unintended visual change fails the suite
- **GIVEN** approved baselines exist
- **WHEN** a code change alters the rendering of a covered screen
- **THEN** the visual assertion MUST fail and report the diff
#### Scenario: Both viewports are covered
- **WHEN** the visual suite runs
- **THEN** each covered screen MUST be asserted at 1440×1000 and 390×844
#### Scenario: Heritage Editorial identity is captured
- **WHEN** approved baselines for the home are reviewed after this change
- **THEN** they MUST reflect EB Garamond typography, olive/paper palette and sharp-edged editorial layout rather than the previous gold/rounded placeholder look
### Requirement: Visual runs are deterministic
Visual runs SHALL be deterministic per SPEC §13.5: fixed Chromium and Linux image, fixed viewport, timezone `America/Fortaleza`, locale `pt-BR`, self-hosted fonts installed/bundled for the suite, frozen clock, deterministic seed (including real testimonial subset and São Paulo settings), animations and transitions disabled, and no dependency on external network.
#### Scenario: Repeated run without code change produces no diff
- **WHEN** the visual suite runs twice against the same commit and seed
- **THEN** both runs MUST pass with no pixel diff
#### Scenario: Time-dependent content does not cause drift
- **GIVEN** the clock is frozen and the seed is deterministic
- **WHEN** the suite runs on a different calendar day
- **THEN** rendered dates MUST remain identical to the baseline
#### Scenario: Motion is disabled during capture
- **WHEN** a screenshot is captured
- **THEN** CSS animations and transitions MUST be disabled
### Requirement: Baseline updates are explicit and reviewed
Baselines SHALL only be updated through the explicit `composer visual:update` command, and the resulting diff MUST be reviewed by a human before merge. Baselines MUST NOT be regenerated automatically to make CI pass.
#### Scenario: CI does not regenerate baselines
- **WHEN** the `browser` CI job runs
- **THEN** it MUST run in assertion mode
- **AND** MUST NOT write new baselines
#### Scenario: Developer updates baselines intentionally
- **WHEN** a developer runs `composer visual:update`
- **THEN** the updated baseline files MUST be written to the versioned baseline directory for review

57
phpunit.coverage.xml Normal file
View File

@@ -0,0 +1,57 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
Coverage-scoped PHPUnit configuration.
Used only by the CI `unit` job's coverage gate (composer test:coverage), via
`pest -c phpunit.coverage.xml`. Kept separate from phpunit.xml so the
project-wide <source> block used by every other test/coverage invocation
stays untouched (it still covers all of app/).
Scope: app/Domain and app/Application only, per SPEC.md L2351. app/Domain
currently holds only `DomainModule` (a placeholder with zero executable
lines), so in practice this gate measures app/Application until Domain
gains real logic.
-->
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="vendor/phpunit/phpunit/phpunit.xsd"
bootstrap="vendor/autoload.php"
colors="true"
>
<testsuites>
<testsuite name="Unit">
<directory>tests/Unit</directory>
</testsuite>
<testsuite name="Architecture">
<directory>tests/Architecture</directory>
</testsuite>
<testsuite name="Feature">
<directory>tests/Feature</directory>
</testsuite>
</testsuites>
<source>
<include>
<directory>app/Domain</directory>
<directory>app/Application</directory>
</include>
</source>
<php>
<env name="APP_KEY" value="base64:NXm/6jIyFcDGHoMKGc5QZuSaq0dRZFYPg1Isuy1fNvE="/>
<env name="APP_MAINTENANCE_DRIVER" value="file"/>
<env name="BCRYPT_ROUNDS" value="4"/>
<env name="BROADCAST_CONNECTION" value="null"/>
<env name="CACHE_STORE" value="database"/>
<env name="DB_CONNECTION" value="pgsql"/>
<env name="DB_HOST" value="127.0.0.1"/>
<env name="DB_PORT" value="5432"/>
<env name="DB_DATABASE" value="amare_test"/>
<env name="DB_USERNAME" value="amare"/>
<env name="DB_PASSWORD" value="secret"/>
<env name="DB_URL" value=""/>
<env name="MAIL_MAILER" value="array"/>
<env name="QUEUE_CONNECTION" value="database"/>
<env name="SESSION_DRIVER" value="database"/>
<env name="PULSE_ENABLED" value="false"/>
<env name="TELESCOPE_ENABLED" value="false"/>
<env name="NIGHTWATCH_ENABLED" value="false"/>
</php>
</phpunit>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 41 KiB

After

Width:  |  Height:  |  Size: 9.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 82 KiB

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

After

Width:  |  Height:  |  Size: 6.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 41 KiB

After

Width:  |  Height:  |  Size: 9.8 KiB

View File

@@ -59,6 +59,27 @@
--ease-amare: var(--amare-ease-standard);
--default-transition-duration: var(--amare-duration-normal);
/* Neutralised, not omitted. Tailwind's Vite plugin shares one context
* across every entry in the build, so the Filament theme entry's
* `@source app/Filament/**` makes Filament's `shadow-*` usage visible and
* Tailwind then emits its DEFAULT --shadow-sm/md/lg into this public
* bundle too verified by diffing the built app.css with and without
* that entry. Nothing public uses a shadow utility today, so rendering is
* unchanged either way, but leaving real shadow values defined here would
* let a future `shadow-sm` on a public element silently violate
* DESIGN.md's Tonal Layer Rule, and HeritageEditorialTokensTest only
* inspects source files, so it would not catch it. Zeroing them keeps the
* rule true in the artifact that actually ships. */
--shadow-2xs: 0 0 #0000;
--shadow-xs: 0 0 #0000;
--shadow-sm: 0 0 #0000;
--shadow: 0 0 #0000;
--shadow-md: 0 0 #0000;
--shadow-lg: 0 0 #0000;
--shadow-xl: 0 0 #0000;
--shadow-2xl: 0 0 #0000;
--shadow-inner: 0 0 #0000;
}
@layer base {
@@ -119,140 +140,86 @@
overflow: hidden;
}
/* Dossiê vivo — content visible by default; enhance only when opted in */
[data-chapter-index] {
display: none;
}
[data-chapter-progress] {
display: none;
pointer-events: none;
}
[data-chapter-index] a[aria-current="true"] {
color: var(--amare-color-accent-deep);
}
[data-chapter-index] a[aria-current="true"]::before {
content: '';
position: absolute;
left: 0;
top: 0.35em;
bottom: 0.35em;
width: 1px;
background: var(--amare-color-accent);
}
[data-chapter-progress] > span {
display: block;
height: 100%;
width: var(--chapter-progress, 0%);
background: var(--amare-color-accent);
transform-origin: left center;
}
@media (min-width: 1280px) {
[data-chapter-index] {
display: flex;
position: fixed;
top: 50%;
right: max(1rem, calc((100vw - var(--amare-container-max)) / 2 - 7.5rem));
z-index: 30;
max-width: 6.5rem;
translate: 0 -50%;
flex-direction: column;
gap: 0.75rem;
}
[data-chapter-index] a {
position: relative;
display: inline-flex;
align-items: center;
min-height: 2.75rem;
padding-left: 0.75rem;
font-size: var(--amare-text-xs);
font-weight: 600;
letter-spacing: 0.12em;
text-transform: uppercase;
color: var(--amare-color-muted);
text-decoration: none;
overflow-wrap: break-word;
transition: color var(--amare-duration-fast) var(--amare-ease-standard);
}
[data-chapter-index] a:hover {
color: var(--amare-color-accent);
}
}
@media (max-width: 1279px) {
[data-chapter-progress] {
display: block;
position: fixed;
top: 0;
left: 0;
z-index: 45;
width: 100%;
height: 2px;
background: transparent;
}
main {
overflow-x: clip;
}
@media (prefers-reduced-motion: no-preference) {
:where(a, button, input, textarea, select) {
transition-duration: var(--amare-motion-feedback);
transition-property: color, background-color, border-color, transform;
transition-timing-function: var(--amare-ease-arrival);
}
:where(a, button:not(:disabled)):active {
transform: translateY(1px);
}
/* Transform/clip only — never fade text opacity (axe + WCAG mid-transition). */
html[data-motion="enhance"] [data-motion="dossie-hero"] [data-motion-beat="seal"],
html[data-motion="enhance"] [data-motion="dossie-hero"] [data-motion-beat="cta"] {
transform: translateY(0.5rem);
html[data-motion="enhance"] [data-motion="page-open"]:not(.is-active) [data-motion-beat] {
transform: translateY(var(--amare-motion-distance-y));
}
html[data-motion="enhance"] [data-motion="dossie-hero"] [data-motion-beat="title"] {
transform: translateY(0.4rem);
}
html[data-motion="enhance"] [data-motion="dossie-hero"] [data-motion-beat="media"] {
html[data-motion="enhance"] [data-motion="page-open"]:not(.is-active) [data-motion-beat="media"] {
clip-path: inset(4% 0 0 0);
}
html[data-motion="enhance"] [data-motion="dossie-hero"].is-active [data-motion-beat] {
html[data-motion="enhance"] [data-motion="page-open"].is-active [data-motion-beat] {
transform: none;
clip-path: inset(0 0 0 0);
transition:
transform var(--amare-duration-slow) var(--amare-ease-arrival),
clip-path var(--amare-duration-focal) var(--amare-ease-arrival);
transform var(--amare-motion-enter) var(--amare-ease-arrival),
clip-path var(--amare-motion-enter) var(--amare-ease-arrival);
transition-delay: min(
calc(var(--motion-index, 0) * var(--amare-motion-stagger)),
var(--amare-motion-stagger-cap)
);
}
html[data-motion="enhance"] [data-motion="dossie-hero"].is-active [data-motion-beat="seal"] {
transition-delay: 0ms;
html[data-motion="enhance"] [data-reveal] {
--motion-translate-x: 0;
--motion-translate-y: var(--amare-motion-distance-y);
}
html[data-motion="enhance"] [data-motion="dossie-hero"].is-active [data-motion-beat="title"] {
transition-delay: 80ms;
html[data-motion="enhance"] [data-reveal-from="left"] {
--motion-translate-x: calc(-1 * var(--amare-motion-distance-x-mobile));
--motion-translate-y: 0;
}
html[data-motion="enhance"] [data-motion="dossie-hero"].is-active [data-motion-beat="media"] {
transition-delay: 120ms;
html[data-motion="enhance"] [data-reveal-from="right"] {
--motion-translate-x: var(--amare-motion-distance-x-mobile);
--motion-translate-y: 0;
}
html[data-motion="enhance"] [data-motion="dossie-hero"].is-active [data-motion-beat="cta"] {
transition-delay: 220ms;
@media (min-width: 768px) {
html[data-motion="enhance"] [data-reveal-from="left"] {
--motion-translate-x: calc(-1 * var(--amare-motion-distance-x));
}
html[data-motion="enhance"] [data-reveal-from="right"] {
--motion-translate-x: var(--amare-motion-distance-x);
}
}
html[data-motion="enhance"] [data-reveal]:not(.is-revealed) {
transform: translateY(0.5rem);
transform: translate3d(var(--motion-translate-x), var(--motion-translate-y), 0);
}
html[data-motion="enhance"] [data-reveal][data-reveal-media]:not(.is-revealed) {
clip-path: inset(3% 0 0 0);
}
html[data-motion="enhance"] [data-reveal-group] [data-reveal].is-revealed,
html[data-motion="enhance"] [data-reveal].is-revealed {
transform: none;
transition: transform var(--amare-duration-slow) var(--amare-ease-arrival);
}
html[data-motion="enhance"] [data-motion="page-open"]:not(.is-active) {
transform: translateY(0.4rem);
}
html[data-motion="enhance"] [data-motion="page-open"].is-active {
transform: none;
transition: transform var(--amare-duration-normal) var(--amare-ease-arrival);
clip-path: inset(0 0 0 0);
transition:
transform var(--amare-motion-enter) var(--amare-ease-arrival),
clip-path var(--amare-motion-enter) var(--amare-ease-arrival);
transition-delay: min(
calc(var(--motion-index, 0) * var(--amare-motion-stagger)),
var(--amare-motion-stagger-cap)
);
}
}
}

View File

@@ -0,0 +1,62 @@
@import '../../../../vendor/filament/filament/resources/css/theme.css';
@source '../../../../app/Filament/**/*';
@source '../../../views/filament/**/*';
/*
* Heritage Editorial parity for the admin panel same rules as
* resources/css/tokens.css, expressed through Filament's own theming layer
* instead of touching its component CSS. Filament's public site tokens (and
* the test that pins them, HeritageEditorialTokensTest) must never gain
* shadow-shaped tokens, so this Filament-scoped file is the only legal home
* for the "flat surfaces, no card shadows" half of that rule.
*/
@theme {
/* Sharp Edge Rule no rounded corners anywhere in the panel. Filament's
* component CSS overwhelmingly uses `rounded-{sm,md,lg,xl}` (mirroring
* tokens.css's own four stops), but ~227 rules across the import graph
* use the bare `rounded` utility, which Tailwind resolves against the
* suffixless `--radius` key easy to miss since tokens.css has no
* bare-`--amare-radius` equivalent to copy from. */
--radius: 0;
--radius-sm: 0;
--radius-md: 0;
--radius-lg: 0;
--radius-xl: 0;
/* Tonal Layer Rule — flat surfaces, no card shadows. */
--shadow-2xs: 0 0 #0000;
--shadow-xs: 0 0 #0000;
--shadow-sm: 0 0 #0000;
--shadow: 0 0 #0000;
--shadow-md: 0 0 #0000;
--shadow-lg: 0 0 #0000;
--shadow-xl: 0 0 #0000;
--shadow-2xl: 0 0 #0000;
--shadow-inner: 0 0 #0000;
}
/*
* `@theme` above reaches every *source* Filament component rule that resolves
* `rounded-*`/`shadow-*` against `var(--radius-*)`/the default shadow tokens,
* because `theme.css` imports the uncompiled CSS for every Filament
* sub-package. It cannot reach CSS that was already compiled to literal
* values before this build runs. Two such fragments exist:
*
* - vendor/filament/support/dist/index.css ships vendored Tippy.js tooltip
* styles (`.tippy-box` / `.tippy-box[data-theme~="light"]`) with a literal
* `border-radius: 4px` and `box-shadow: 0 0 20px ...`. Tooltips render on
* every panel page, so they get an explicit override below.
* - vendor/filament/forms/dist/index.css also ships vendored noUiSlider,
* FilePond, and EasyMDE/CodeMirror CSS with their own hardcoded
* radius/shadow rules (verified: `grep -rEo '[A-Za-z]+::make\(' app/Filament`
* turns up no slider, FileUpload, MarkdownEditor, or RichEditor field in
* this app), so none of those fragments ever render and they're left alone.
*/
.tippy-box {
border-radius: 0;
}
.tippy-box[data-theme~='light'] {
box-shadow: 0 0 #0000;
}

View File

@@ -59,6 +59,15 @@
--amare-duration-focal: 720ms;
--amare-ease-standard: cubic-bezier(0.4, 0, 0.2, 1);
--amare-ease-arrival: cubic-bezier(0.16, 1, 0.3, 1);
/* Editorial motion */
--amare-motion-enter: 500ms;
--amare-motion-feedback: 150ms;
--amare-motion-stagger: 90ms;
--amare-motion-stagger-cap: 270ms;
--amare-motion-distance-y: 12px;
--amare-motion-distance-x: 24px;
--amare-motion-distance-x-mobile: 16px;
}
@media (prefers-reduced-motion: reduce) {
@@ -67,6 +76,13 @@
--amare-duration-normal: 0.01ms;
--amare-duration-slow: 0.01ms;
--amare-duration-focal: 0.01ms;
--amare-motion-enter: 0.01ms;
--amare-motion-feedback: 0.01ms;
--amare-motion-stagger: 0ms;
--amare-motion-stagger-cap: 0ms;
--amare-motion-distance-y: 0px;
--amare-motion-distance-x: 0px;
--amare-motion-distance-x-mobile: 0px;
}
*,

View File

@@ -5,7 +5,7 @@ function prefersReducedMotion() {
}
function enableEnhancement() {
if (prefersReducedMotion()) {
if (prefersReducedMotion() || typeof IntersectionObserver !== 'function') {
document.documentElement.removeAttribute('data-motion');
return false;
}
@@ -14,24 +14,6 @@ function enableEnhancement() {
return true;
}
function activateHero(enhance) {
const hero = document.querySelector('[data-motion="dossie-hero"]');
if (!hero) {
return;
}
const activate = () => hero.classList.add('is-active');
if (!enhance) {
activate();
return;
}
requestAnimationFrame(() => {
requestAnimationFrame(activate);
});
}
function activatePageOpen(enhance) {
document.querySelectorAll('[data-motion="page-open"]').forEach((node) => {
if (!enhance) {
@@ -45,6 +27,32 @@ function activatePageOpen(enhance) {
});
}
function initializeMotionDelays() {
const beatOrder = new Map([
['seal', 0],
['heading', 0],
['title', 1],
['media', 2],
['cta', 3],
]);
document.querySelectorAll('[data-motion="page-open"]').forEach((page) => {
page.querySelectorAll('[data-motion-beat]').forEach((beat, index) => {
const namedIndex = beatOrder.get(beat.dataset.motionBeat);
beat.style.setProperty('--motion-index', String(Math.min(namedIndex ?? index, 3)));
});
});
document.querySelectorAll('[data-reveal-group]').forEach((group) => {
const nodes = Array.from(group.querySelectorAll('[data-reveal]'))
.filter((node) => node.closest('[data-reveal-group]') === group);
nodes.forEach((node, index) => {
node.style.setProperty('--motion-index', String(Math.min(index, 3)));
});
});
}
function observeReveals(enhance) {
const nodes = Array.from(document.querySelectorAll('[data-reveal]'));
if (nodes.length === 0) {
@@ -76,77 +84,15 @@ function observeReveals(enhance) {
nodes.forEach((node) => observer.observe(node));
}
function setupChapterIndex() {
const index = document.querySelector('[data-chapter-index]');
const progress = document.querySelector('[data-chapter-progress] span');
const chapters = Array.from(document.querySelectorAll('[data-chapter]'));
if (!index || chapters.length === 0) {
return;
}
const links = Array.from(index.querySelectorAll('a[href^="#"]'));
const setActive = (id) => {
links.forEach((link) => {
const isCurrent = link.getAttribute('href') === `#${id}`;
if (isCurrent) {
link.setAttribute('aria-current', 'true');
} else {
link.removeAttribute('aria-current');
}
});
};
const updateProgress = (ratio) => {
if (!progress) {
return;
}
const clamped = Math.min(1, Math.max(0, ratio));
progress.style.setProperty('--chapter-progress', `${(clamped * 100).toFixed(2)}%`);
};
const sync = () => {
const marker = window.scrollY + Math.min(window.innerHeight * 0.35, 280);
let current = chapters[0];
chapters.forEach((chapter) => {
if (chapter.offsetTop <= marker) {
current = chapter;
}
});
const heading = current.querySelector('[id$="-heading"]') || document.getElementById(`${current.dataset.chapter}-heading`);
const headingId = heading?.id
|| current.getAttribute('aria-labelledby')
|| `${current.dataset.chapter}-heading`;
setActive(headingId);
const doc = document.documentElement;
const max = Math.max(1, doc.scrollHeight - window.innerHeight);
updateProgress(window.scrollY / max);
};
setActive(chapters[0].getAttribute('aria-labelledby') || 'hero-heading');
sync();
window.addEventListener('scroll', sync, { passive: true });
window.addEventListener('resize', sync);
}
function bootMotion() {
initializeMotionDelays();
const enhance = enableEnhancement();
activateHero(enhance);
activatePageOpen(enhance);
observeReveals(enhance);
setupChapterIndex();
window.matchMedia(MOTION_QUERY).addEventListener('change', (event) => {
if (event.matches) {
document.documentElement.removeAttribute('data-motion');
document.querySelectorAll('[data-motion="dossie-hero"], [data-motion="page-open"]').forEach((node) => {
document.querySelectorAll('[data-motion="page-open"]').forEach((node) => {
node.classList.add('is-active');
});
document.querySelectorAll('[data-reveal]').forEach((node) => {

View File

@@ -20,9 +20,25 @@
$kind = $mark ? 'mark' : 'lockup';
$staticSrc = asset("brand/{$kind}-{$variant}.webp");
$src = filled($uploadedPath)
$usesUploadedLogo = filled($uploadedPath);
$src = $usesUploadedLogo
? \Illuminate\Support\Facades\Storage::disk('public')->url($uploadedPath)
: $staticSrc;
// Reserve the box before the image arrives (SPEC §6.4). The intrinsic size
// of the shipped assets is known and fixed; an uploaded logo has arbitrary
// dimensions, so it gets no attributes rather than wrong ones. The CSS
// classes still govern the rendered size in both cases — width/height only
// give the browser the aspect ratio to reserve.
//
// The webp assets are encoded at 3x the largest rendered size (lockup at
// h-12 = 48 px, mark at h-8 = 32 px), which is why these are 149x144 and
// 191x96 rather than the 512-wide originals kept as png fallbacks. See
// tests/Feature/PublicSite/BrandAssetBudgetTest.php.
$intrinsic = $usesUploadedLogo
? []
: ($mark ? ['width' => 191, 'height' => 96] : ['width' => 149, 'height' => 144]);
@endphp
<img
@@ -32,5 +48,5 @@
'class' => trim('brand-logo '.$class),
'decoding' => 'async',
'loading' => 'eager',
]) }}
] + $intrinsic) }}
/>

View File

@@ -1,15 +0,0 @@
@props([
'chapters' => [],
])
@if (count($chapters) > 0)
<nav data-chapter-index aria-label="Índice de capítulos">
@foreach ($chapters as $chapter)
<a href="#{{ $chapter['id'] }}">{{ $chapter['label'] }}</a>
@endforeach
</nav>
<div data-chapter-progress aria-hidden="true">
<span></span>
</div>
@endif

View File

@@ -1,12 +1,18 @@
@props([
'settings',
'editorial' => false,
])
<section aria-labelledby="final-cta-heading" class="border-t border-amare-border bg-amare-bg-deep py-20" data-chapter="final-cta">
<div class="container-amare space-y-6 text-center" data-reveal>
<p class="text-xs font-semibold uppercase tracking-[0.14em] text-amare-accent">Próximo passo</p>
<section aria-labelledby="final-cta-heading" @class([
'border-t border-amare-border bg-amare-bg-deep py-20',
'home-chapter' => $editorial,
]) data-chapter="final-cta" data-reveal-group>
<div class="container-amare max-w-4xl space-y-6 py-4" data-reveal data-reveal-from="up">
@unless ($editorial)
<p class="text-xs font-semibold uppercase tracking-[0.14em] text-amare-accent">Próximo passo</p>
@endunless
<h2 id="final-cta-heading" class="text-headline font-medium text-amare-text">Do casamento ao evento corporativo, tudo começa com uma boa conversa.</h2>
<p class="mx-auto max-w-2xl text-amare-muted">
<p class="max-w-2xl text-amare-muted">
Compartilhe as primeiras informações do seu evento. A Amare retorna para entender o contexto e orientar os próximos passos.
</p>
<div>

View File

@@ -4,12 +4,16 @@
<section
aria-labelledby="hero-heading"
class="border-b border-amare-border bg-amare-bg"
class="home-chapter min-h-[100dvh] border-b border-amare-border bg-amare-bg"
data-chapter="hero"
data-motion="dossie-hero"
data-motion="page-open"
>
<div class="container-amare grid gap-12 py-20 md:grid-cols-[minmax(0,1.1fr)_minmax(0,0.9fr)] md:items-center md:py-28">
<div class="space-y-8">
<div @class([
'container-amare grid min-h-[100dvh] gap-12 py-20 md:py-10',
'md:grid-cols-[minmax(0,0.9fr)_minmax(0,1.1fr)] md:items-stretch' => filled($settings->hero_image_path),
'md:max-w-4xl md:items-center' => blank($settings->hero_image_path),
]) data-reveal-group @if (blank($settings->hero_image_path)) data-tonal-hero @endif>
<div class="flex flex-col justify-center space-y-8 md:py-12">
<div data-motion-beat="seal" class="flex items-center gap-4">
<x-brand.logo mark variant="on-light" class="h-8 w-auto" alt="" />
@if (filled($settings->hero_eyebrow))
@@ -26,19 +30,12 @@
@endif
<div data-motion-beat="cta" class="flex flex-wrap items-center gap-4">
<a
href="{{ route('contact') }}"
data-testid="home-primary-cta"
class="inline-flex min-h-11 items-center bg-amare-accent px-5 py-3 text-sm font-semibold text-amare-accent-text transition-colors duration-(--amare-duration-normal) ease-(--amare-ease-standard) hover:bg-amare-accent-hover"
>
<a href="{{ route('contact') }}" data-testid="home-primary-cta" class="inline-flex min-h-11 items-center bg-amare-accent px-5 py-3 text-sm font-semibold text-amare-accent-text transition-colors duration-(--amare-duration-normal) ease-(--amare-ease-standard) hover:bg-amare-accent-hover">
{{ $settings->hero_cta_label ?: 'Solicitar proposta' }}
</a>
@if (filled($settings->hero_secondary_cta_label))
<a
href="{{ route('portfolio.index') }}"
class="inline-flex min-h-11 items-center text-sm font-semibold text-amare-accent transition-colors hover:text-amare-accent-deep"
>
<a href="{{ route('portfolio.index') }}" class="inline-flex min-h-11 items-center text-sm font-semibold text-amare-accent transition-colors hover:text-amare-accent-deep">
<span class="border-b border-amare-accent pb-1">{{ $settings->hero_secondary_cta_label }}</span>
</a>
@endif
@@ -49,16 +46,9 @@
@endif
</div>
@if (filled($settings->default_og_image_path))
<div data-motion-beat="media" class="min-h-72 overflow-hidden bg-amare-bg-deep">
<x-media.image
:path="$settings->default_og_image_path"
:alt="$settings->default_og_image_alt ?: $settings->brand_name"
loading="eager"
fetchpriority="high"
sizes="(max-width: 768px) 100vw, 40vw"
class="img-editorial h-full w-full object-cover"
/>
@if (filled($settings->hero_image_path))
<div data-split-hero data-motion-beat="media" class="min-h-80 overflow-hidden bg-amare-bg-deep md:min-h-full" data-reveal-media>
<x-media.image :path="$settings->hero_image_path" :alt="$settings->hero_image_alt ?: $settings->brand_name" loading="eager" fetchpriority="high" sizes="(max-width: 768px) 100vw, 55vw" class="img-editorial h-full w-full object-cover" />
</div>
@endif
</div>

Some files were not shown because too many files have changed in this diff Show More