Compare commits

..

2 Commits

104 changed files with 191 additions and 5392 deletions

View File

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

View File

@@ -14,7 +14,7 @@ env:
APP_KEY: base64:NXm/6jIyFcDGHoMKGc5QZuSaq0dRZFYPg1Isuy1fNvE= APP_KEY: base64:NXm/6jIyFcDGHoMKGc5QZuSaq0dRZFYPg1Isuy1fNvE=
APP_LOCALE: pt_BR APP_LOCALE: pt_BR
APP_FALLBACK_LOCALE: pt_BR APP_FALLBACK_LOCALE: pt_BR
APP_TIMEZONE: America/Sao_Paulo APP_TIMEZONE: America/Fortaleza
BCRYPT_ROUNDS: 4 BCRYPT_ROUNDS: 4
CACHE_STORE: database CACHE_STORE: database
DB_CONNECTION: pgsql DB_CONNECTION: pgsql
@@ -51,36 +51,10 @@ jobs:
- run: composer pint:check - run: composer pint:check
- run: composer phpstan - run: composer phpstan
- run: composer audit - 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: unit:
name: unit name: unit
runs-on: ubuntu-latest 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: steps:
- uses: actions/checkout@v5 - uses: actions/checkout@v5
- run: cp .env.example .env - run: cp .env.example .env
@@ -89,7 +63,7 @@ jobs:
with: with:
php-version: "8.4" php-version: "8.4"
extensions: dom, curl, libxml, mbstring, zip, pcntl, pdo, pdo_pgsql, bcmath, intl, sodium, gd extensions: dom, curl, libxml, mbstring, zip, pcntl, pdo, pdo_pgsql, bcmath, intl, sodium, gd
coverage: pcov coverage: none
- uses: actions/cache@v5 - uses: actions/cache@v5
with: with:
@@ -97,24 +71,10 @@ jobs:
key: composer-${{ runner.os }}-${{ hashFiles('**/composer.lock') }} key: composer-${{ runner.os }}-${{ hashFiles('**/composer.lock') }}
restore-keys: composer-${{ runner.os }}- 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: composer install --no-interaction --prefer-dist
- run: npm ci - run: npm ci
- run: npm run build - run: npm run build
- run: composer test:unit - 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: feature:
name: feature name: feature
@@ -221,7 +181,7 @@ jobs:
-e APP_URL=http://127.0.0.1:8000 \ -e APP_URL=http://127.0.0.1:8000 \
-e APP_LOCALE=pt_BR \ -e APP_LOCALE=pt_BR \
-e APP_FALLBACK_LOCALE=pt_BR \ -e APP_FALLBACK_LOCALE=pt_BR \
-e APP_TIMEZONE=America/Sao_Paulo \ -e APP_TIMEZONE=America/Fortaleza \
-e APP_FROZEN_NOW="${APP_FROZEN_NOW}" \ -e APP_FROZEN_NOW="${APP_FROZEN_NOW}" \
-e DB_CONNECTION=pgsql \ -e DB_CONNECTION=pgsql \
-e DB_HOST=host.docker.internal \ -e DB_HOST=host.docker.internal \

View File

@@ -24,7 +24,7 @@ try {
new PDO($dsn, $config["DB_USERNAME"], $config["DB_PASSWORD"], [PDO::ATTR_TIMEOUT => 3]); new PDO($dsn, $config["DB_USERNAME"], $config["DB_PASSWORD"], [PDO::ATTR_TIMEOUT => 3]);
} catch (PDOException $e) { } 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, "\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 postgres\n"); fwrite(STDERR, "Start it with: docker compose up -d\n");
fwrite(STDERR, "Then retry the push.\n"); fwrite(STDERR, "Then retry the push.\n");
exit(1); exit(1);
} }

View File

@@ -7,12 +7,11 @@ This is a Laravel 13 application for an event-planning consultancy. Application
## Build, Test, and Development Commands ## Build, Test, and Development Commands
- `composer setup` installs PHP and npm dependencies, creates `.env`, migrates, and builds assets. - `composer setup` installs PHP and npm dependencies, creates `.env`, migrates, and builds assets.
- `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. - `docker compose up -d` starts the local PostgreSQL service.
- `composer dev` runs Laravel, the queue listener, logs, and Vite together. - `composer dev` runs Laravel, the queue listener, logs, and Vite together.
- `npm run build` creates the production frontend bundle. - `npm run build` creates the production frontend bundle.
- `composer quality` runs formatting checks, PHPStan level 5, PHP + npm dependency audits, and every test suite. - `composer quality` runs formatting checks, PHPStan level 5, dependency audit, and every test suite.
- `composer test:unit`, `composer test:feature`, or `composer test:browser` run focused suites. - `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`. Feature and browser tests require the `amare_test` PostgreSQL database configured in `phpunit.xml`.
@@ -25,7 +24,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. 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-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 postgres` 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` 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 ## Coding Style & Naming Conventions
@@ -42,23 +41,3 @@ History follows Conventional Commit-style subjects, for example `feat: Fase 0
## Security & Configuration ## 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. 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.

View File

@@ -1,81 +0,0 @@
# 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.
## Visual regression — read before touching baselines
- Baselines are committed `.snap` files under `tests/.pest/snapshots/Browser/VisualRegressionTest/`.
- `tests/Browser/Screenshots/` is gitignored — it only holds diff output.
- `composer visual:update` is the sanctioned command, but on macOS it writes baselines CI rejects. Use `scripts/test/visual-update-ci.sh`, which runs it inside the Linux runner built from `docker/ci-runner.Dockerfile`.
- **Baselines are Linux-parity artifacts.** Pest Browser does not use FrankenPHP or `artisan serve` — it serves the Laravel kernel from an in-process Amp server (`vendor/pestphp/pest-plugin-browser/src/Drivers/LaravelHttpServer.php`), so the FrankenPHP container the `browser` job starts is only a health check. What makes a baseline reproducible is the machine that renders it: Ubuntu 24.04, Playwright's Chromium, and Playwright's font packages (`StableScreenshot` forces `Arial`, which fontconfig resolves to Liberation Sans on Linux). Commit `4578457` exists because macOS renders text differently.
Determinism relies on three cooperating pieces:
- `APP_FROZEN_NOW``CarbonImmutable::setTestNow()` in `AppServiceProvider::freezeClockWhenConfigured()` (no-op in production).
- `Database\Seeders\VisualContentSeeder::FROZEN_NOW` — the value the browser tests and the CI job both pin to.
- `Tests\Support\StableScreenshot` — forces Arial, disables transitions/animations, scrolls the page to settle lazy images, and avoids the flaky `networkidle` wait.
## 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,10 +23,6 @@ RUN npm ci
COPY vite.config.js ./ COPY vite.config.js ./
COPY resources ./resources COPY resources ./resources
COPY public ./public 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 RUN npm run build
FROM dunglas/frankenphp:1-php${PHP_VERSION}-bookworm AS runtime FROM dunglas/frankenphp:1-php${PHP_VERSION}-bookworm AS runtime

View File

@@ -4,7 +4,7 @@ Aplicação Laravel 13 para assessoria de eventos (Fase 0 — Fundação).
## Requisitos ## Requisitos
- PHP 8.4 com extensões `pdo_pgsql`, `intl`, `mbstring`, `zip`, `sodium` (canônico do `Dockerfile` e do CI; `composer.json` aceita `^8.3`) - PHP 8.5+ com extensões `pdo_pgsql`, `intl`, `mbstring`, `zip`, `sodium`
- Composer 2.x - Composer 2.x
- Node.js 22+ e npm - Node.js 22+ e npm
- Docker e Docker Compose - Docker e Docker Compose
@@ -18,10 +18,10 @@ cp .env.example .env
php artisan key:generate php artisan key:generate
``` ```
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`): 2. Suba o PostgreSQL:
```bash ```bash
docker compose up -d postgres docker compose up -d
``` ```
3. Instale dependências e rode migrations: 3. Instale dependências e rode migrations:
@@ -49,24 +49,12 @@ Painel interno: `/admin`
Healthcheck: `GET /up` 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áveis principais
| Variável | Valor local | | Variável | Valor local |
|---|---| |---|---|
| `APP_LOCALE` | `pt_BR` | | `APP_LOCALE` | `pt_BR` |
| `APP_TIMEZONE` | `America/Sao_Paulo` | | `APP_TIMEZONE` | `America/Fortaleza` |
| `DB_CONNECTION` | `pgsql` | | `DB_CONNECTION` | `pgsql` |
| `SESSION_DRIVER` | `database` | | `SESSION_DRIVER` | `database` |
| `CACHE_STORE` | `database` | | `CACHE_STORE` | `database` |
@@ -75,7 +63,7 @@ O serviço `app` depende de `postgres` estar saudável (`depends_on: condition:
## Comandos de qualidade ## Comandos de qualidade
```bash ```bash
composer quality # Pint + PHPStan + audit (composer + npm) + testes composer quality # Pint + PHPStan + audit + testes
composer test:unit # Unit + Architecture composer test:unit # Unit + Architecture
composer test:feature # Feature + Livewire + Filament composer test:feature # Feature + Livewire + Filament
composer test:browser # E2E browser composer test:browser # E2E browser
@@ -137,7 +125,6 @@ Após `php artisan db:seed`:
- [SPEC.md](SPEC.md) — especificação do produto - [SPEC.md](SPEC.md) — especificação do produto
- [docs/adr/](docs/adr/) — ADRs aceitas - [docs/adr/](docs/adr/) — ADRs aceitas
- [docs/conventions/php-strict-types.md](docs/conventions/php-strict-types.md) — convenção de strict types - [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 - [docs/deployment/dokploy.md](docs/deployment/dokploy.md) — deploy staging/produção no Dokploy + GHCR
## Deploy (Dokploy) ## Deploy (Dokploy)

112
SPEC.md
View File

@@ -20,7 +20,7 @@
| Princípio principal | YAGNI — implementar somente o necessário para validar o produto | | Princípio principal | YAGNI — implementar somente o necessário para validar o produto |
| Arquitetura | Monólito modular Laravel | | Arquitetura | Monólito modular Laravel |
| Área interna | Filament | | Área interna | Filament |
| Área pública | Blade + JS vanilla progressivo (Livewire é dependência do Filament, não usada no site público — ver ADR-015) | | Área pública | Livewire + Blade |
| Servidor de aplicação | FrankenPHP em modo regular | | Servidor de aplicação | FrankenPHP em modo regular |
| Banco de dados | PostgreSQL | | Banco de dados | PostgreSQL |
| Testes | Pest, Pest Browser/Playwright e testes visuais | | Testes | Pest, Pest Browser/Playwright e testes visuais |
@@ -194,24 +194,11 @@ Não instalar sistema de permissões granular no MVP.
## 4. Escopo ## 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 ### 4.1 Incluído no MVP
No recorte do lançamento (Fases 01):
- site público; - site público;
- CMS interno do site; - CMS interno do site;
- formulário de briefing; - formulário de briefing;
- usuários internos e papéis simples;
- SEO básico;
- acessibilidade e testes visuais;
- CI/CD e deploy em contêiner com FrankenPHP.
Adiados para depois do lançamento (Fases 25, ver ADR-016):
- CRM de leads; - CRM de leads;
- conversão de lead em evento; - conversão de lead em evento;
- cadastro e visão consolidada de eventos; - cadastro e visão consolidada de eventos;
@@ -221,10 +208,12 @@ Adiados para depois do lançamento (Fases 25, ver ADR-016):
- pagamentos inseridos manualmente; - pagamentos inseridos manualmente;
- documentos vinculados a leads e eventos; - documentos vinculados a leads e eventos;
- dashboard orientado a exceções; - dashboard orientado a exceções;
- usuários internos e papéis simples;
- notificações internas e por e-mail para novos leads; - notificações internas e por e-mail para novos leads;
- auditoria de ações críticas. - auditoria de ações críticas;
- SEO básico;
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. - acessibilidade e testes visuais;
- CI/CD e deploy em contêiner com FrankenPHP.
### 4.2 Fora do MVP ### 4.2 Fora do MVP
@@ -326,8 +315,8 @@ Administração
- Dashboard: `Filament Page` customizada com widgets orientados a exceção. - Dashboard: `Filament Page` customizada com widgets orientados a exceção.
- Detalhe do evento: página customizada do Resource com resumo operacional. - Detalhe do evento: página customizada do Resource com resumo operacional.
- Briefing público: Blade + Controller (`POST /contato`), ver §11.2. - Briefing público: componente Livewire próprio.
- Home: Blade com componentes de design reutilizáveis. - Home: Blade/Livewire com componentes de design reutilizáveis.
### 5.4 Decisões de UX YAGNI ### 5.4 Decisões de UX YAGNI
@@ -1564,7 +1553,7 @@ Constraints de banco devem proteger:
| Runtime | PHP com versão minor fixada no Docker | | Runtime | PHP com versão minor fixada no Docker |
| Framework | Laravel 13 | | Framework | Laravel 13 |
| Admin | Filament 5 | | Admin | Filament 5 |
| UI pública | Blade + Tailwind + JS vanilla progressivo (sem framework reativo; Livewire 4 confinado ao Filament — ver ADR-015 e §22) | | UI pública | Livewire 4 + Blade + Alpine + Tailwind |
| Banco | PostgreSQL | | Banco | PostgreSQL |
| Servidor | FrankenPHP + Caddy | | Servidor | FrankenPHP + Caddy |
| Assets | Vite | | Assets | Vite |
@@ -1785,21 +1774,21 @@ Usar componentes/Widgets menores e testáveis.
--- ---
## 11. Site público e interatividade ## 11. Livewire e site público
### 11.1 Estado atual e componentes candidatos ### 11.1 Componentes sugeridos
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`. - `ContactBriefingForm`;
- `FeaturedPortfolioCases` se houver necessidade de consulta dinâmica;
- `PublishedServices` se houver necessidade de consulta dinâmica.
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 todas as seções estáticas em componentes Livewire. Usar Blade quando não houver estado ou interação.
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 ### 11.2 Formulário de briefing
> **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. > **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.
O formulário deve: O componente deve:
- ter estado tipado ou Form Object quando útil; - ter estado tipado ou Form Object quando útil;
- validar no servidor; - validar no servidor;
@@ -1808,16 +1797,15 @@ O formulário deve:
- preservar acessibilidade; - preservar acessibilidade;
- limpar dados após sucesso; - limpar dados após sucesso;
- evitar exposição de exceção; - evitar exposição de exceção;
- suportar teste de submissão sem navegador (feature test); - suportar teste Livewire sem browser;
- suportar jornada E2E em navegador real. - suportar jornada E2E em navegador real.
### 11.3 JavaScript ### 11.3 JavaScript
- manter o site público em JS vanilla progressivo; - usar Alpine apenas para interações pequenas;
- não introduzir framework SPA; - não introduzir framework SPA;
- não usar dependência JS quando CSS e HTML resolverem; - não usar dependência JS quando CSS/HTML/Livewire resolverem;
- toda interação crítica deve funcionar sem estado global complexo; - toda interação crítica deve funcionar sem estado global complexo.
- Alpine só entra junto com Livewire, se o gatilho de §22 disparar.
--- ---
@@ -2336,8 +2324,6 @@ Para visual e browser tests:
## 18. Backlog de implementação ## 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. O agente deve implementar na sequência, salvo instrução explícita.
### Fase 0 — Fundação ### Fase 0 — Fundação
@@ -2349,7 +2335,7 @@ O agente deve implementar na sequência, salvo instrução explícita.
- [x] Filament instalado e autenticado; - [x] Filament instalado e autenticado;
- [x] Livewire configurado; - [x] Livewire configurado;
- [x] Tailwind/Vite; - [x] Tailwind/Vite;
- [x] FrankenPHP e Docker Compose local; - [~] FrankenPHP e Docker Compose local (imagem pronta; serviço de aplicação local pendente);
- [x] papéis admin/assistant; - [x] papéis admin/assistant;
- [x] Pint; - [x] Pint;
- [x] PHPStan/Larastan; - [x] PHPStan/Larastan;
@@ -2360,11 +2346,11 @@ O agente deve implementar na sequência, salvo instrução explícita.
- [x] design tokens mínimos; - [x] design tokens mínimos;
- [x] healthcheck; - [x] healthcheck;
- [x] seed de admin local. - [x] seed de admin local.
- [x] verificação de e-mail e reset seguro (MustVerifyEmail); - [ ] verificação de e-mail e reset seguro (MustVerifyEmail);
- [x] npm audit no `composer quality` e no job `static`; - [ ] npm audit no `composer quality` e no job `static`;
- [x] gate de cobertura `Domain`/`Application` ≥ 80%; - [ ] gate de cobertura `Domain`/`Application` ≥ 80%;
- [x] serviço de aplicação FrankenPHP no Compose local; - [ ] 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`. - [ ] hello-world implantado em staging (critério de saída).
> 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. > 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.
@@ -2388,7 +2374,7 @@ O agente deve implementar na sequência, salvo instrução explícita.
**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 e site público aprovado visualmente (baselines em `tests/.pest/snapshots/`; aprovação humana do diff visual no PR).
### Fase 2 — Leads — ADIADA (ADR-016) ### Fase 2 — Leads
- [ ] migration/model/factory de Lead; - [ ] migration/model/factory de Lead;
- [ ] LeadActivity; - [ ] LeadActivity;
@@ -2405,7 +2391,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. **Critério de saída:** jornada visitante → lead → tratamento interna totalmente verde.
### Fase 3 — Eventos e tarefas — ADIADA (ADR-016) ### Fase 3 — Eventos e tarefas
- [ ] Event; - [ ] Event;
- [ ] EventTask; - [ ] EventTask;
@@ -2420,7 +2406,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. **Critério de saída:** lead é convertido e evento pode ser administrado.
### Fase 4 — Fornecedores e financeiro — ADIADA (ADR-016) ### Fase 4 — Fornecedores e financeiro
- [ ] Vendor; - [ ] Vendor;
- [ ] EventVendor; - [ ] EventVendor;
@@ -2436,7 +2422,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. **Critério de saída:** assessora acompanha orçamento e pagamentos sem integração bancária.
### Fase 5 — Documentos, hardening e lançamento — ADIADA (ADR-016) ### Fase 5 — Documentos, hardening e lançamento
- [ ] documentos privados; - [ ] documentos privados;
- [ ] auditoria completa; - [ ] auditoria completa;
@@ -2514,7 +2500,7 @@ Toda operação financeira deve:
| ADR | Decisão | Status | | ADR | Decisão | Status |
|---|---|---| |---|---|---|
| ADR-001 | Monólito modular Laravel, sem microserviços | Aceita | | ADR-001 | Monólito modular Laravel, sem microserviços | Aceita |
| ADR-002 | Filament para área interna (Livewire é dependência interna do Filament); site público em Blade — ver ADR-015 | Aceita | | ADR-002 | Filament para área interna e Livewire/Blade para área pública | Aceita |
| ADR-003 | PostgreSQL como único banco transacional | Aceita | | ADR-003 | PostgreSQL como único banco transacional | Aceita |
| ADR-004 | Pagamentos somente manuais | Aceita | | ADR-004 | Pagamentos somente manuais | Aceita |
| ADR-005 | Pest unifica unit, feature, browser e visual | Aceita | | ADR-005 | Pest unifica unit, feature, browser e visual | Aceita |
@@ -2527,8 +2513,6 @@ Toda operação financeira deve:
| ADR-012 | E-mail transacional via Resend (mailer nativo Laravel) | Aceita | | ADR-012 | E-mail transacional via Resend (mailer nativo Laravel) | Aceita |
| ADR-013 | Design system Heritage Editorial para o site público | 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-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 |
--- ---
@@ -2545,7 +2529,6 @@ Toda operação financeira deve:
| API pública | existe consumidor real e contrato de integração | | API pública | existe consumidor real e contrato de integração |
| Kanban | tabela de leads demonstra limitação frequente observada | | Kanban | tabela de leads demonstra limitação frequente observada |
| Editor de checklist | diferentes tipos de evento exigem manutenção frequente do seed | | 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 |
--- ---
@@ -2553,39 +2536,24 @@ Toda operação financeira deve:
O MVP está concluído somente quando: 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; - site público está publicado e visualmente aprovado;
- assessora edita os conteúdos essenciais sem desenvolvedor; - assessora edita os conteúdos essenciais sem desenvolvedor;
- briefing envia o pedido de proposta de forma segura, com proteção contra abuso e aceite de privacidade registrado; - briefing cria leads de forma segura;
- 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; - pipeline e próxima ação funcionam;
- briefing cria leads de forma segura e persistente;
- lead é convertido uma única vez em evento; - lead é convertido uma única vez em evento;
- checklist é criado automaticamente; - checklist é criado automaticamente;
- evento possui visão consolidada; - evento possui visão consolidada;
- fornecedores podem ser cadastrados e vinculados; - fornecedores podem ser cadastrados e vinculados;
- orçamento e pagamentos manuais possuem totais consistentes; - orçamento e pagamentos manuais possuem totais consistentes;
- dashboard mostra pendências do dia; - dashboard mostra pendências do dia;
- usuários e Policies estão corretos;
- auditoria registra operações críticas; - auditoria registra operações críticas;
- jornadas E2E das fases correspondentes estão verdes. - 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.
--- ---

View File

@@ -1,85 +0,0 @@
<?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

@@ -1,181 +0,0 @@
<?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

@@ -1,43 +0,0 @@
<?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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,38 +1,11 @@
<?php <?php
declare(strict_types=1);
namespace App\Filament\Resources\Users\Pages; namespace App\Filament\Resources\Users\Pages;
use App\Filament\Resources\Users\UserResource; use App\Filament\Resources\Users\UserResource;
use App\Models\User;
use Filament\Resources\Pages\CreateRecord; use Filament\Resources\Pages\CreateRecord;
use Illuminate\Database\Eloquent\Model;
class CreateUser extends CreateRecord class CreateUser extends CreateRecord
{ {
protected static string $resource = UserResource::class; 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,7 +1,5 @@
<?php <?php
declare(strict_types=1);
namespace App\Filament\Resources\Users\Pages; namespace App\Filament\Resources\Users\Pages;
use App\Filament\Resources\Users\UserResource; use App\Filament\Resources\Users\UserResource;

View File

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

View File

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

View File

@@ -4,7 +4,6 @@ declare(strict_types=1);
namespace App\Http\Controllers\PublicSite; namespace App\Http\Controllers\PublicSite;
use App\Domain\Contact\MarketingOrigin;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Http\Requests\PublicSite\ContactBriefingRequest; use App\Http\Requests\PublicSite\ContactBriefingRequest;
use App\Mail\ContactBriefing; use App\Mail\ContactBriefing;
@@ -35,7 +34,7 @@ final class ContactController extends Controller
$this->rememberSubmission($validated); $this->rememberSubmission($validated);
$settings = SiteSetting::instance(); $settings = SiteSetting::instance();
$fields = $this->buildFields($validated, $this->resolveMarketingOrigin($request)); $fields = $this->buildFields($validated);
$this->dispatchEmails($settings, $validated['nome'], $validated['email'], $fields); $this->dispatchEmails($settings, $validated['nome'], $validated['email'], $fields);
@@ -45,7 +44,7 @@ final class ContactController extends Controller
/** /**
* @param array<string, mixed> $validated * @param array<string, mixed> $validated
*/ */
private function buildFields(array $validated, ?string $marketingOrigin): array private function buildFields(array $validated): array
{ {
return [ return [
'Nome' => (string) $validated['nome'], 'Nome' => (string) $validated['nome'],
@@ -57,32 +56,9 @@ final class ContactController extends Controller
'Número estimado de convidados' => isset($validated['convidados']) ? (string) $validated['convidados'] : null, '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, 'Serviço de interesse' => isset($validated['servico_interesse']) ? (string) $validated['servico_interesse'] : null,
'Mensagem' => (string) $validated['mensagem'], '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 * @param array<string, mixed> $validated
*/ */

View File

@@ -1,85 +0,0 @@
<?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,26 +4,10 @@ declare(strict_types=1);
namespace App\Http\Requests\PublicSite; namespace App\Http\Requests\PublicSite;
use App\Domain\Contact\BrazilianPhoneNumber;
use Illuminate\Foundation\Http\FormRequest; use Illuminate\Foundation\Http\FormRequest;
final class ContactBriefingRequest extends 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>> * @return array<string, array<int, string>>
*/ */

View File

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

View File

@@ -1,11 +1,7 @@
<?php <?php
declare(strict_types=1);
namespace App\Providers\Filament; namespace App\Providers\Filament;
use App\Filament\Pages\Auth\RequestPasswordReset;
use Filament\FontProviders\LocalFontProvider;
use Filament\Http\Middleware\Authenticate; use Filament\Http\Middleware\Authenticate;
use Filament\Http\Middleware\AuthenticateSession; use Filament\Http\Middleware\AuthenticateSession;
use Filament\Http\Middleware\DisableBladeIconComponents; use Filament\Http\Middleware\DisableBladeIconComponents;
@@ -13,7 +9,7 @@ use Filament\Http\Middleware\DispatchServingFilamentEvent;
use Filament\Pages\Dashboard; use Filament\Pages\Dashboard;
use Filament\Panel; use Filament\Panel;
use Filament\PanelProvider; use Filament\PanelProvider;
use Filament\View\PanelsRenderHook; use Filament\Support\Colors\Color;
use Filament\Widgets\AccountWidget; use Filament\Widgets\AccountWidget;
use Filament\Widgets\FilamentInfoWidget; use Filament\Widgets\FilamentInfoWidget;
use Illuminate\Cookie\Middleware\AddQueuedCookiesToResponse; use Illuminate\Cookie\Middleware\AddQueuedCookiesToResponse;
@@ -25,137 +21,6 @@ use Illuminate\View\Middleware\ShareErrorsFromSession;
class AdminPanelProvider extends PanelProvider 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 public function panel(Panel $panel): Panel
{ {
return $panel return $panel
@@ -163,47 +28,9 @@ class AdminPanelProvider extends PanelProvider
->id('admin') ->id('admin')
->path('admin') ->path('admin')
->login() ->login()
->passwordReset(requestAction: RequestPasswordReset::class)
->brandName('Amare Assessoria')
->brandLogo(fn (): string => asset('brand/lockup-on-light.webp'))
->brandLogoHeight('2rem')
->colors([ ->colors([
'primary' => $this->primaryColor(), 'primary' => Color::Amber,
'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') ->discoverResources(in: app_path('Filament/Resources'), for: 'App\Filament\Resources')
->discoverPages(in: app_path('Filament/Pages'), for: 'App\Filament\Pages') ->discoverPages(in: app_path('Filament/Pages'), for: 'App\Filament\Pages')
->pages([ ->pages([

View File

@@ -12,16 +12,8 @@ use Throwable;
final class ResponsiveImage final class ResponsiveImage
{ {
/** /** @var list<int> */
* 720 sits between the original 480 and 960 because that is where the common public const WIDTHS = [480, 960, 1440];
* 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 public static function generate(string $path, ?string $disk = null): void
{ {
@@ -55,26 +47,6 @@ final class ResponsiveImage
}; };
$filesystem->put($variantPath, (string) $encoded); $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)
);
} }
} }
@@ -83,10 +55,10 @@ final class ResponsiveImage
$filesystem = self::filesystem($disk); $filesystem = self::filesystem($disk);
foreach (self::WIDTHS as $width) { foreach (self::WIDTHS as $width) {
foreach ([self::variantPath($path, $width), self::webpVariantPath($path, $width)] as $variantPath) { $variantPath = self::variantPath($path, $width);
if ($filesystem->exists($variantPath)) {
$filesystem->delete($variantPath); if ($filesystem->exists($variantPath)) {
} $filesystem->delete($variantPath);
} }
} }
} }
@@ -121,17 +93,6 @@ final class ResponsiveImage
return $directory === '' ? $variantName : $directory.'/'.$variantName; 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}> * @return list<array{path: string, width: int}>
*/ */
@@ -154,32 +115,6 @@ final class ResponsiveImage
return $variants; 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
{
$filesystem = self::filesystem($disk);
$variants = [];
foreach (self::WIDTHS as $width) {
$variantPath = self::webpVariantPath($path, $width);
if ($filesystem->exists($variantPath)) {
$variants[] = [
'path' => $variantPath,
'width' => $width,
];
}
}
return $variants;
}
/** /**
* @return array{width: int, height: int}|null * @return array{width: int, height: int}|null
*/ */

View File

@@ -1,6 +1,5 @@
<?php <?php
use App\Http\Middleware\CaptureMarketingOrigin;
use Illuminate\Foundation\Application; use Illuminate\Foundation\Application;
use Illuminate\Foundation\Configuration\Exceptions; use Illuminate\Foundation\Configuration\Exceptions;
use Illuminate\Foundation\Configuration\Middleware; use Illuminate\Foundation\Configuration\Middleware;
@@ -15,13 +14,6 @@ return Application::configure(basePath: dirname(__DIR__))
->withMiddleware(function (Middleware $middleware): void { ->withMiddleware(function (Middleware $middleware): void {
// Trust Traefik/Dokploy (and local reverse proxies) for X-Forwarded-* headers. // Trust Traefik/Dokploy (and local reverse proxies) for X-Forwarded-* headers.
$middleware->trustProxies(at: '*'); $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 { ->withExceptions(function (Exceptions $exceptions): void {
$exceptions->shouldRenderJsonWhen( $exceptions->shouldRenderJsonWhen(

View File

@@ -65,9 +65,6 @@
"test:browser": [ "test:browser": [
"@php artisan test --testsuite=Browser" "@php artisan test --testsuite=Browser"
], ],
"test:coverage": [
"vendor/bin/pest -c phpunit.coverage.xml --testsuite=Unit,Architecture,Feature --coverage --min=80"
],
"pint": [ "pint": [
"vendor/bin/pint" "vendor/bin/pint"
], ],
@@ -84,7 +81,6 @@
"@pint:check", "@pint:check",
"@phpstan", "@phpstan",
"composer audit --no-interaction", "composer audit --no-interaction",
"npm audit --omit=dev --audit-level=high",
"@test" "@test"
], ],
"visual:update": [ "visual:update": [

View File

@@ -65,7 +65,7 @@ return [
| |
*/ */
'timezone' => env('APP_TIMEZONE', 'America/Sao_Paulo'), 'timezone' => env('APP_TIMEZONE', 'America/Fortaleza'),
/* /*
|-------------------------------------------------------------------------- |--------------------------------------------------------------------------

View File

@@ -1,42 +0,0 @@
<?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

@@ -11,7 +11,6 @@ use App\Models\SiteSetting;
use App\Support\PublicImageUploadRules; use App\Support\PublicImageUploadRules;
use Illuminate\Database\Seeder; use Illuminate\Database\Seeder;
use Illuminate\Support\Carbon; use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\App;
use Illuminate\Support\Facades\File; use Illuminate\Support\Facades\File;
use Illuminate\Support\Facades\Storage; use Illuminate\Support\Facades\Storage;
@@ -19,32 +18,8 @@ class ContentSeeder extends Seeder
{ {
private const SEED_TIMESTAMP = '2026-01-15 10:00:00'; 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 public function run(): void
{ {
if (! App::environment(['local', 'staging', 'testing'])) {
return;
}
$this->seedSiteSettings(); $this->seedSiteSettings();
$this->seedServices(); $this->seedServices();
$this->seedPortfolioCases(); $this->seedPortfolioCases();

View File

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

View File

@@ -1,12 +1,3 @@
> **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. 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. 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!!! ❤️ Que você possa transformar o grande dia das noivinhas sempre com essa sua leveza!!! ❤️

View File

@@ -14,13 +14,7 @@ services:
restart: "no" restart: "no"
env_file: env_file:
- .env - .env
# Exec-array form on a single line, deliberately. A folded block scalar command: ["sh", "-c", "php artisan migrate --force --no-interaction && php artisan db:seed --class=ContentSeeder --force; php artisan media:generate-variants --force; true"]
# (`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: networks:
- dokploy-network - dokploy-network

View File

@@ -18,34 +18,5 @@ services:
retries: 10 retries: 10
start_period: 10s 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: volumes:
amare_postgres_data: amare_postgres_data:

View File

@@ -5,20 +5,5 @@
:8000 { :8000 {
root * /app/public root * /app/public
encode gzip zstd 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 php_server
} }

View File

@@ -1,61 +0,0 @@
# Linux runner that reproduces CI's rendering environment for the visual
# regression baselines. Not part of the application image and never deployed.
#
# Why this exists: the baselines are pixel artifacts of the machine that
# rendered them. Pest Browser serves the Laravel kernel from an in-process Amp
# server (vendor/pestphp/pest-plugin-browser/src/Drivers/LaravelHttpServer.php),
# so FrankenPHP is not in the picture — what differs between a developer's Mac
# and CI is the OS, the Chromium build and the font stack. Regenerating on
# macOS produces baselines CI rejects, which is the whole reason commit
# 4578457 exists. Before this file the recipe lived only as a checklist in
# tasks.md and the image had to be reconstructed by archaeology.
#
# Mirrors the `browser` job in .github/workflows/ci.yml: Ubuntu 24.04,
# PHP 8.4 with the same extension list, Node 22, and Playwright's own system
# dependencies (which is where fonts-liberation comes from — StableScreenshot
# forces `Arial`, and on Linux fontconfig resolves that to the
# metric-compatible Liberation Sans).
#
# Driven by scripts/test/visual-update-ci.sh; see that script for usage.
FROM ubuntu:24.04
ENV DEBIAN_FRONTEND=noninteractive \
PLAYWRIGHT_BROWSERS_PATH=/opt/playwright-browsers
RUN apt-get update \
&& apt-get install -y --no-install-recommends \
ca-certificates \
curl \
git \
gnupg \
software-properties-common \
unzip \
&& add-apt-repository -y ppa:ondrej/php \
&& apt-get update \
&& apt-get install -y --no-install-recommends \
php8.4-cli \
php8.4-bcmath \
php8.4-curl \
php8.4-gd \
php8.4-intl \
php8.4-mbstring \
php8.4-pgsql \
php8.4-sqlite3 \
php8.4-xml \
php8.4-zip \
&& curl -fsSL https://deb.nodesource.com/setup_22.x | bash - \
&& apt-get install -y --no-install-recommends nodejs \
&& rm -rf /var/lib/apt/lists/*
COPY --from=composer:2 /usr/bin/composer /usr/bin/composer
# System libraries and fonts only. The browser binary itself is installed at
# run time so its revision matches whatever playwright version package-lock
# resolves, exactly as CI's `npx playwright install chromium --with-deps` does.
RUN npx --yes playwright@1.62 install-deps chromium \
&& rm -rf /root/.npm
WORKDIR /app
CMD ["bash"]

View File

@@ -1,51 +0,0 @@
# 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

@@ -1,26 +0,0 @@
# 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

@@ -1,15 +0,0 @@
# 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

@@ -67,7 +67,7 @@ APP_URL=https://staging.example.com
APP_LOCALE=pt_BR APP_LOCALE=pt_BR
APP_FALLBACK_LOCALE=pt_BR APP_FALLBACK_LOCALE=pt_BR
APP_TIMEZONE=America/Sao_Paulo APP_TIMEZONE=America/Fortaleza
DB_CONNECTION=pgsql DB_CONNECTION=pgsql
DB_HOST=<dokploy-postgres-internal-host> # Internal Host from Dokploy UI (requires dokploy-network) DB_HOST=<dokploy-postgres-internal-host> # Internal Host from Dokploy UI (requires dokploy-network)
@@ -219,11 +219,7 @@ echo (\$valid ? 'ok' : 'invalid').PHP_EOL;
Expected output: `ok`. Expected output: `ok`.
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 `DatabaseSeeder` or `ContentSeeder` in staging or production: they include local credentials and/or broad demo-content effects. Deployment workflows intentionally remain migrate-only; loading these testimonials is a deliberate manual operation in each environment.
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 ## Backup and restore

View File

@@ -1,409 +0,0 @@
# 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

@@ -1,407 +0,0 @@
# 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

@@ -1,407 +0,0 @@
# 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

@@ -1,207 +0,0 @@
# 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 16 baselines visuais foram regenerados no runner Linux
(`scripts/test/visual-update-ci.sh`), e o diff é imperceptível a 2× de zoom —
mesma forma, mesma cor, só menos bytes.
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 de regressão visual nunca exercitam `srcset` nem `<picture>`: nem
`ContentSeeder` nem `VisualContentSeeder` geram variantes, e `media:generate-variants`
não roda no runner visual. Naquele ambiente `availableVariants()` volta vazio e o
componente renderiza `<img>` puro — foi por isso que os 16 baselines não mudaram
com a introdução do `<picture>`. A cobertura do caminho com variantes fica nos
testes de feature (`MediaImageComponentTest`), não nos baselines.
## 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.

View File

@@ -1,417 +0,0 @@
# 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.

View File

@@ -1,23 +1,23 @@
## 1. Auth and strict types parity ## 1. Auth and strict types parity
- [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 - [ ] 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` - [ ] 1.2 Confirm Filament/Laravel password reset is enabled; add feature tests for registered vs unknown email without account enumeration
- [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 - [ ] 1.3 Add `declare(strict_types=1);` to project-owned PHP files missing it (e.g. `AdminPanelProvider`); architecture/unit regression as needed
- [x] 1.4 Run `composer pint`, `composer phpstan`, and `composer test:feature` for auth changes — all green - [ ] 1.4 Run `composer pint`, `composer phpstan`, and `composer test:feature` for auth changes
## 2. Local runtime and PHP 8.4 alignment ## 2. Local runtime and PHP 8.4 alignment
- [x] 2.1 Extend `docker-compose.yml` with FrankenPHP `app` service (build Dockerfile, depend on healthy postgres, publish 8000); document in README - [ ] 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 - [ ] 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. - [ ] 2.3 Smoke local compose: `docker compose up -d``GET /up` returns 200
- [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) - [ ] 2.4 Run `composer quality` after compose/docs changes
## 3. Quality gates: npm audit and coverage ## 3. Quality gates: npm audit and coverage
- [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` - [ ] 3.1 Add npm audit step to `composer quality` and CI `static` (policy: production deps; document any allowlist)
- [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 - [ ] 3.2 Enable Domain/Application coverage in CI `unit` with 80% fail threshold; exclude views/migrations/framework
- [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.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 — 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 - [ ] 3.4 Verify CI `static` and `unit` fail appropriately on intentional audit/coverage breakage in a branch experiment or equivalent proof
## 4. Staging/production Compose and Dokploy prep ## 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.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.2 Verify rollback to previous SHA works once on staging
- [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.3 Update `SPEC.md` §18 Fase 0 checkboxes only for items with evidence; note remaining deferred items if any
- [ ] 6.4 Run full `composer quality` and confirm all five CI jobs + staging deploy path green - [ ] 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 - [ ] 6.5 Report in SPEC §24 format; archive this change only after remaining parity tasks (13) also complete

View File

@@ -3,7 +3,7 @@ schema: spec-driven
context: | context: |
Fonte de verdade: SPEC.md na raiz. Precedência: instrução do dono do produto > SPEC.md > ADRs > testes > convenções. 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. 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), Blade + Tailwind + JS vanilla progressivo (site público; sem framework reativo — Livewire 4 é dependência do Filament, ver SPEC ADR-015), Stack: Laravel 13, Filament 5 (/admin), Livewire 4 + Blade + Alpine + Tailwind (site público),
PostgreSQL, FrankenPHP regular mode (sem worker mode), Vite, Pest 4 + Pest Browser, database queue. 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. 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. Domain não depende de Filament/Livewire. strict_types em todo PHP próprio.

View File

@@ -26,7 +26,7 @@ The system SHALL keep versioned screenshot baselines for the public screens avai
### Requirement: Visual runs are deterministic ### Requirement: Visual runs are deterministic
Visual runs SHALL be deterministic per SPEC §13.5: fixed Chromium and Linux image, fixed viewport, timezone `America/Sao_Paulo`, 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. 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 #### Scenario: Repeated run without code change produces no diff

View File

@@ -1,57 +0,0 @@
<?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: 9.0 KiB

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 82 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.3 KiB

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.8 KiB

After

Width:  |  Height:  |  Size: 41 KiB

View File

@@ -59,27 +59,6 @@
--ease-amare: var(--amare-ease-standard); --ease-amare: var(--amare-ease-standard);
--default-transition-duration: var(--amare-duration-normal); --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 { @layer base {

View File

@@ -1,62 +0,0 @@
@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

@@ -20,25 +20,9 @@
$kind = $mark ? 'mark' : 'lockup'; $kind = $mark ? 'mark' : 'lockup';
$staticSrc = asset("brand/{$kind}-{$variant}.webp"); $staticSrc = asset("brand/{$kind}-{$variant}.webp");
$usesUploadedLogo = filled($uploadedPath); $src = filled($uploadedPath)
$src = $usesUploadedLogo
? \Illuminate\Support\Facades\Storage::disk('public')->url($uploadedPath) ? \Illuminate\Support\Facades\Storage::disk('public')->url($uploadedPath)
: $staticSrc; : $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 @endphp
<img <img
@@ -48,5 +32,5 @@
'class' => trim('brand-logo '.$class), 'class' => trim('brand-logo '.$class),
'decoding' => 'async', 'decoding' => 'async',
'loading' => 'eager', 'loading' => 'eager',
] + $intrinsic) }} ]) }}
/> />

View File

@@ -60,7 +60,7 @@
:alt="$settings->default_og_image_alt ?: $settings->brand_name" :alt="$settings->default_og_image_alt ?: $settings->brand_name"
loading="eager" loading="eager"
fetchpriority="high" fetchpriority="high"
sizes="(max-width: 768px) calc(100vw - 3rem), 40vw" sizes="(max-width: 768px) 100vw, 40vw"
class="img-editorial h-full w-full object-cover" class="img-editorial h-full w-full object-cover"
/> />
</div> </div>

View File

@@ -20,7 +20,7 @@
<x-media.image <x-media.image
:path="$case->cover_image_path" :path="$case->cover_image_path"
:alt="$case->cover_image_alt ?: $case->title" :alt="$case->cover_image_alt ?: $case->title"
sizes="(max-width: 768px) calc(100vw - 3rem), 50vw" sizes="(max-width: 768px) 100vw, 50vw"
class="img-editorial aspect-[4/3] w-full object-cover" class="img-editorial aspect-[4/3] w-full object-cover"
/> />
@endif @endif

View File

@@ -1,11 +1,7 @@
@props([ @props([
'path', 'path',
'alt', 'alt',
// No image on the site spans the full viewport: every one of them sits inside 'sizes' => '(max-width: 768px) 100vw, 960px',
// `container-amare`, which reserves 1.5rem of padding on each side. Claiming
// 100vw made a 412 px viewport at DPR 1.75 ask for 721 px and jump to the
// 960 variant for a box it draws at 637 px.
'sizes' => '(max-width: 768px) calc(100vw - 3rem), 960px',
'loading' => 'lazy', 'loading' => 'lazy',
'fetchpriority' => null, 'fetchpriority' => null,
'width' => null, 'width' => null,
@@ -22,25 +18,15 @@
$diskName = $disk ?? PublicImageUploadRules::disk(); $diskName = $disk ?? PublicImageUploadRules::disk();
$filesystem = Storage::disk($diskName); $filesystem = Storage::disk($diskName);
$src = $filesystem->url($path); $src = $filesystem->url($path);
$variants = ResponsiveImage::availableVariants($path, $diskName);
$toSrcset = fn (array $variants): string => collect($variants) $srcset = collect($variants)
->map(fn (array $variant): string => $filesystem->url($variant['path']).' '.$variant['width'].'w') ->map(fn (array $variant): string => $filesystem->url($variant['path']).' '.$variant['width'].'w')
->implode(', '); ->implode(', ');
$variants = ResponsiveImage::availableVariants($path, $diskName);
$srcset = $toSrcset($variants);
if ($srcset === '' && $filesystem->exists($path)) { if ($srcset === '' && $filesystem->exists($path)) {
$srcset = null; $srcset = null;
} }
// Offered ahead of the original format because webp carries the same picture
// for roughly a third of the bytes, and the MAN-109 audit found the hero
// image to be the LCP element on every page at mobile widths. Media uploaded
// before `media:generate-variants` learned to emit webp has no siblings, so
// the <source> is skipped rather than pointed at nothing.
$webpSrcset = $toSrcset(ResponsiveImage::availableWebpVariants($path, $diskName));
$dimensions = ($width === null || $height === null) $dimensions = ($width === null || $height === null)
? ResponsiveImage::dimensions($path, $diskName) ? ResponsiveImage::dimensions($path, $diskName)
: null; : null;
@@ -50,13 +36,6 @@
$loadingValue = $loading; $loadingValue = $loading;
@endphp @endphp
{{-- `display: contents` keeps <picture> out of the layout: the callers style the
<img> with classes like `h-full w-full object-cover` that resolve against the
grid or flex parent, and an inline wrapper would break that. --}}
@if ($webpSrcset !== '')
<picture class="contents">
<source type="image/webp" srcset="{{ $webpSrcset }}" sizes="{{ $sizes }}">
@endif
<img <img
src="{{ $src }}" src="{{ $src }}"
@if ($srcset) srcset="{{ $srcset }}" sizes="{{ $sizes }}" @endif @if ($srcset) srcset="{{ $srcset }}" sizes="{{ $sizes }}" @endif
@@ -68,6 +47,3 @@
@if ($class) class="{{ $class }}" @endif @if ($class) class="{{ $class }}" @endif
{{ $attributes->except(['path', 'alt', 'sizes', 'loading', 'width', 'height', 'disk', 'class']) }} {{ $attributes->except(['path', 'alt', 'sizes', 'loading', 'width', 'height', 'disk', 'class']) }}
> >
@if ($webpSrcset !== '')
</picture>
@endif

View File

@@ -25,7 +25,7 @@
:path="$siteSettings->about_image_path" :path="$siteSettings->about_image_path"
:alt="$siteSettings->about_image_alt ?: $siteSettings->brand_name" :alt="$siteSettings->about_image_alt ?: $siteSettings->brand_name"
loading="lazy" loading="lazy"
sizes="(max-width: 768px) calc(100vw - 3rem), 50vw" sizes="(max-width: 768px) 100vw, 50vw"
class="img-editorial aspect-[4/3] w-full object-cover" class="img-editorial aspect-[4/3] w-full object-cover"
/> />
</div> </div>

View File

@@ -20,7 +20,7 @@
<x-media.image <x-media.image
:path="$case->cover_image_path" :path="$case->cover_image_path"
:alt="$case->cover_image_alt ?: $case->title" :alt="$case->cover_image_alt ?: $case->title"
sizes="(max-width: 768px) calc(100vw - 3rem), 50vw" sizes="(max-width: 768px) 100vw, 50vw"
class="img-editorial aspect-[4/3] w-full object-cover transition-transform duration-(--amare-duration-slow) ease-(--amare-ease-standard) motion-safe:hover:scale-[1.02]" class="img-editorial aspect-[4/3] w-full object-cover transition-transform duration-(--amare-duration-slow) ease-(--amare-ease-standard) motion-safe:hover:scale-[1.02]"
/> />
</a> </a>

View File

@@ -22,7 +22,7 @@
:path="$case->cover_image_path" :path="$case->cover_image_path"
:alt="$case->cover_image_alt ?: $case->title" :alt="$case->cover_image_alt ?: $case->title"
loading="eager" loading="eager"
sizes="(max-width: 1024px) calc(100vw - 3rem), 1120px" sizes="(max-width: 1024px) 100vw, 1120px"
class="img-editorial aspect-[16/9] w-full object-cover" class="img-editorial aspect-[16/9] w-full object-cover"
data-motion-beat="media" data-motion-beat="media"
/> />
@@ -59,7 +59,7 @@
<x-media.image <x-media.image
:path="$image->path" :path="$image->path"
:alt="$image->alt_text" :alt="$image->alt_text"
sizes="(max-width: 768px) calc(100vw - 3rem), 50vw" sizes="(max-width: 768px) 100vw, 50vw"
class="img-editorial aspect-[4/3] w-full object-cover" class="img-editorial aspect-[4/3] w-full object-cover"
/> />
@if (filled($image->caption)) @if (filled($image->caption))

View File

@@ -25,7 +25,7 @@
<x-media.image <x-media.image
:path="$service->cover_image_path" :path="$service->cover_image_path"
:alt="$service->cover_image_alt ?: $service->title" :alt="$service->cover_image_alt ?: $service->title"
sizes="(max-width: 768px) calc(100vw - 3rem), 40vw" sizes="(max-width: 768px) 100vw, 40vw"
class="img-editorial aspect-[16/10] w-full object-cover" class="img-editorial aspect-[16/10] w-full object-cover"
/> />
@endif @endif

View File

@@ -1,137 +0,0 @@
#!/usr/bin/env bash
#
# Lighthouse run against the production image, reproducing the measurement
# behind MAN-109. Not a CI gate — SPEC.md §14.1 pins the five blocking jobs and
# §22 governs when new capability is added. This is the recipe so the numbers
# can be reproduced instead of remembered.
#
# Usage:
# scripts/perf/lighthouse.sh [output-dir]
#
# Environment:
# BASE_URL origin to audit (default http://127.0.0.1:8000)
# TARGET label for the output subtree (default local)
# RUNS runs per page and preset (default 3)
# PRESETS space-separated preset list (default "mobile desktop")
#
# Lighthouse LCP moves by a few tenths of a second between runs on the same
# build, so a single run cannot support a before/after comparison. Every page
# is audited RUNS times per preset and the run holding the median LCP is the
# one reported — averaging across runs would describe a page that never
# existed.
#
# Expects a site already answering on $BASE_URL. To raise one from scratch:
#
# docker build -t amare-app:ci .
# docker run -d --name amare-web -p 8000:8000 \
# -e APP_ENV=production -e APP_DEBUG=false -e APP_KEY="$APP_KEY" \
# -e DB_CONNECTION=pgsql -e DB_HOST=host.docker.internal -e DB_PORT=5432 \
# -e DB_DATABASE=amare -e DB_USERNAME=amare -e DB_PASSWORD=secret \
# -e SESSION_DRIVER=database -e CACHE_STORE=database -e QUEUE_CONNECTION=database \
# --add-host=host.docker.internal:host-gateway \
# -v "$(pwd)/storage/app/public:/app/storage/app/public" \
# amare-app:ci
#
# Seed content and generate the responsive variants first, and run both from
# the host rather than inside the container. ContentSeeder guards itself with
# an allow-list of local/staging/testing (database/seeders/ContentSeeder.php),
# so under the container's APP_ENV=production it is a deliberate no-op and
# Lighthouse would end up measuring empty pages. Skipping
# `media:generate-variants` inflates LCP by roughly 2.5 s on the home page,
# because the originals are served at full size:
#
# php artisan db:seed --class=ContentSeeder --force
# php artisan media:generate-variants
#
set -euo pipefail
BASE_URL="${BASE_URL:-http://127.0.0.1:8000}"
BASE_URL="${BASE_URL%/}"
TARGET="${TARGET:-local}"
RUNS="${RUNS:-3}"
PRESETS="${PRESETS:-mobile desktop}"
OUT_DIR="${1:-storage/app/lighthouse}/${TARGET}"
# Lighthouse needs a Chrome binary. Playwright's is already on disk after
# `npx playwright install chromium`; fall back to a system Chrome.
if [[ -z "${CHROME_PATH:-}" ]]; then
PLAYWRIGHT_CHROME=$(find "${HOME}/Library/Caches/ms-playwright" "${HOME}/.cache/ms-playwright" \
-maxdepth 3 -name 'Google Chrome for Testing' -type f 2>/dev/null | head -1 || true)
if [[ -n "${PLAYWRIGHT_CHROME}" ]]; then
export CHROME_PATH="${PLAYWRIGHT_CHROME}"
fi
fi
mkdir -p "${OUT_DIR}"
PAGES=(
"/:home"
"/servicos:servicos"
"/portfolio:portfolio"
"/portfolio/casamento-ana-lucas:portfolio-detalhe"
"/sobre:sobre"
"/contato:contato"
)
# A route that answers 404 still produces a Lighthouse report, and the error
# page is light enough to score well — the heaviest route on the site would be
# reported as excellent and nobody would notice. Refuse to measure anything
# that is not a 200 before spending minutes on the audit.
echo "preflight against ${BASE_URL}"
for entry in "${PAGES[@]}"; do
path="${entry%%:*}"
code="$(curl -sS -o /dev/null -w '%{http_code}' --max-time 20 "${BASE_URL}${path}" 2>/dev/null || true)"
if [[ "${code}" != "200" ]]; then
echo "preflight failed: ${BASE_URL}${path} answered HTTP ${code:-<none>}, expected 200" >&2
exit 1
fi
echo " ok ${path}"
done
for preset in ${PRESETS}; do
# The mobile preset is Lighthouse's default and rejects an explicit
# --preset flag, so only desktop is passed through.
preset_flags=()
if [[ "${preset}" != "mobile" ]]; then
preset_flags+=("--preset=${preset}")
fi
for entry in "${PAGES[@]}"; do
path="${entry%%:*}"
name="${entry##*:}"
for run in $(seq 1 "${RUNS}"); do
echo "auditing ${name} ${preset} run ${run}/${RUNS} (${BASE_URL}${path})"
# Default preset: simulated mobile throttling, 150 ms RTT,
# ~1.6 Mbps, 4x CPU slowdown.
# ${array[@]+...} keeps `set -u` from treating an empty array as
# unbound, which bash 3.2 (the macOS system bash) still does.
npx --yes lighthouse@12 "${BASE_URL}${path}" \
--quiet \
${preset_flags[@]+"${preset_flags[@]}"} \
--output=json --output=html \
--output-path="${OUT_DIR}/${name}-${preset}-run${run}" \
--chrome-flags="--headless=new --no-sandbox"
done
done
done
echo
echo "reports written to ${OUT_DIR}"
# The seeder is recorded because it decides the byte weight of every hero
# image: a before/after comparison across different fixtures measures nothing.
node scripts/perf/summarize-lighthouse.mjs "${OUT_DIR}" \
--target="${TARGET}" \
--base-url="${BASE_URL}" \
--commit="$(git rev-parse --short HEAD 2>/dev/null || echo unknown)$(git diff --quiet HEAD 2>/dev/null || echo '+alterações não commitadas')" \
--seeder="${SEEDER:-ContentSeeder}" \
> "${OUT_DIR}/summary.md"
echo "summary written to ${OUT_DIR}/summary.md"
echo "SPEC.md §6.6 targets: LCP <= 2.5s, CLS <= 0.1, INP <= 200ms, 0 console errors"

View File

@@ -1,249 +0,0 @@
#!/usr/bin/env node
//
// Turns a directory of Lighthouse JSON reports into one reviewable markdown
// summary. Written because the numbers behind MAN-109 previously survived only
// as a hand-typed table in a Linear comment: `storage/app/lighthouse` is
// gitignored, so there was nothing to diff a later run against.
//
// For each page and preset the run holding the median LCP is the one reported.
// Metrics are never averaged across runs — every figure in the table comes
// from the same single navigation, so the LCP phase breakdown adds up.
//
// Usage:
// node scripts/perf/summarize-lighthouse.mjs <report-dir> [--target=local]
// [--base-url=...] [--commit=...] [--seeder=...]
import { readdirSync, readFileSync } from 'node:fs';
import { join } from 'node:path';
const [dir, ...rest] = process.argv.slice(2);
if (!dir) {
console.error('usage: summarize-lighthouse.mjs <report-dir> [--target=...]');
process.exit(1);
}
const flags = new Map(
rest
.filter((argument) => argument.startsWith('--'))
.map((argument) => {
const [key, ...value] = argument.replace(/^--/, '').split('=');
return [key, value.join('=')];
}),
);
const ms = (value) => (typeof value === 'number' ? `${(value / 1000).toFixed(2)} s` : '—');
const kib = (value) => (typeof value === 'number' ? `${Math.round(value / 1024)} KiB` : '—');
const score = (value) => (typeof value === 'number' ? Math.round(value * 100) : '—');
const numeric = (audit) => (typeof audit?.numericValue === 'number' ? audit.numericValue : null);
/** Every nested details table in an audit, flattened. */
function nestedItems(audit) {
const items = audit?.details?.items ?? [];
return items.flatMap((item) => (Array.isArray(item?.items) ? item.items : [item]));
}
function lcpElement(lhr) {
const item = nestedItems(lhr.audits?.['largest-contentful-paint-element']).find((entry) => entry?.node);
const snippet = item?.node?.snippet ?? item?.node?.selector ?? null;
return snippet ? snippet.replace(/\s+/g, ' ').slice(0, 160) : null;
}
function lcpPhases(lhr) {
return nestedItems(lhr.audits?.['largest-contentful-paint-element'])
.filter((entry) => typeof entry?.phase === 'string')
.map((entry) => ({ phase: entry.phase, timing: entry.timing ?? null }));
}
/** Requests that finished before LCP, heaviest first — the contention evidence. */
function requestsBeforeLcp(lhr, lcp) {
const requests = lhr.audits?.['network-requests']?.details?.items ?? [];
return requests
.filter((request) => typeof request.networkEndTime === 'number' && (lcp === null || request.networkEndTime <= lcp + 50))
.filter((request) => (request.transferSize ?? 0) > 1024)
.sort((a, b) => (b.transferSize ?? 0) - (a.transferSize ?? 0))
.slice(0, 8)
.map((request) => ({
url: String(request.url ?? '').replace(/^https?:\/\/[^/]+/, ''),
type: request.resourceType ?? '—',
transferSize: request.transferSize ?? null,
endTime: request.networkEndTime ?? null,
}));
}
/** Failing LH12 insights, with whatever savings estimate they carry. */
function insights(lhr) {
return Object.entries(lhr.audits ?? {})
.filter(([id, audit]) => id.endsWith('-insight') && typeof audit.score === 'number' && audit.score < 1)
.map(([id, audit]) => ({
id,
title: audit.title ?? id,
lcpSavings: audit.metricSavings?.LCP ?? null,
byteSavings: audit.details?.overallSavingsBytes ?? null,
}))
.sort((a, b) => (b.lcpSavings ?? 0) - (a.lcpSavings ?? 0));
}
const reports = new Map();
for (const file of readdirSync(dir).filter((name) => name.endsWith('.report.json')).sort()) {
const match = /^(.+)-(mobile|desktop)-run(\d+)\.report\.json$/.exec(file);
if (!match) {
continue;
}
const [, page, preset] = match;
const key = `${page}::${preset}`;
const lhr = JSON.parse(readFileSync(join(dir, file), 'utf8'));
if (!reports.has(key)) {
reports.set(key, []);
}
reports.get(key).push({ file, lhr });
}
if (reports.size === 0) {
console.error(`no Lighthouse JSON reports found in ${dir}`);
process.exit(1);
}
/** The run whose LCP is the median — reported whole, never blended. */
function medianRun(runs) {
const sorted = [...runs].sort(
(a, b) => (numeric(a.lhr.audits?.['largest-contentful-paint']) ?? Infinity)
- (numeric(b.lhr.audits?.['largest-contentful-paint']) ?? Infinity),
);
return sorted[Math.floor((sorted.length - 1) / 2)];
}
const rows = [];
const details = [];
for (const [key, runs] of [...reports.entries()].sort()) {
const [page, preset] = key.split('::');
const chosen = medianRun(runs);
const { lhr } = chosen;
const lcp = numeric(lhr.audits?.['largest-contentful-paint']);
const spread = runs
.map((run) => numeric(run.lhr.audits?.['largest-contentful-paint']))
.filter((value) => value !== null)
.sort((a, b) => a - b);
rows.push({
page,
preset,
performance: score(lhr.categories?.performance?.score),
accessibility: score(lhr.categories?.accessibility?.score),
bestPractices: score(lhr.categories?.['best-practices']?.score),
seo: score(lhr.categories?.seo?.score),
lcp,
cls: numeric(lhr.audits?.['cumulative-layout-shift']),
tbt: numeric(lhr.audits?.['total-blocking-time']),
fcp: numeric(lhr.audits?.['first-contentful-paint']),
ttfb: numeric(lhr.audits?.['server-response-time']),
runs: runs.length,
spread: spread.length > 1 ? [spread[0], spread[spread.length - 1]] : null,
file: chosen.file,
});
details.push({
page,
preset,
element: lcpElement(lhr),
phases: lcpPhases(lhr),
requests: requestsBeforeLcp(lhr, lcp),
insights: insights(lhr),
});
}
const first = reports.values().next().value[0].lhr;
const out = [];
out.push(`# Lighthouse — ${flags.get('target') ?? 'local'}`);
out.push('');
out.push(`- Origem: \`${flags.get('base-url') ?? '—'}\``);
out.push(`- Lighthouse: ${first.lighthouseVersion ?? '—'}`);
// Which Chrome ran the audit is part of the measurement: the stable system
// Chrome and Playwright's Chromium do not produce interchangeable numbers.
out.push(`- Navegador: \`${first.environment?.hostUserAgent ?? '—'}\``);
out.push(`- Coletado em: ${first.fetchTime ?? '—'}`);
out.push(`- Execuções por página/preset: ${rows[0]?.runs ?? '—'} (reportada a de LCP mediano)`);
if (flags.get('commit')) {
out.push(`- Commit: \`${flags.get('commit')}\``);
}
if (flags.get('seeder')) {
out.push(`- Seeder: \`${flags.get('seeder')}\``);
}
out.push('');
out.push('Metas SPEC §6.6: LCP ≤ 2,5 s · CLS ≤ 0,1 · INP ≤ 200 ms · zero erro de console.');
out.push('');
out.push('| página | preset | perf | a11y | BP | SEO | LCP | FCP | CLS | TBT | TTFB servidor | LCP minmax |');
out.push('|---|---|---|---|---|---|---|---|---|---|---|---|');
for (const row of rows) {
const spread = row.spread ? `${ms(row.spread[0])} ${ms(row.spread[1])}` : '—';
out.push(
`| ${row.page} | ${row.preset} | ${row.performance} | ${row.accessibility} | ${row.bestPractices} `
+ `| ${row.seo} | ${ms(row.lcp)} | ${ms(row.fcp)} | ${typeof row.cls === 'number' ? row.cls.toFixed(3) : '—'} `
+ `| ${typeof row.tbt === 'number' ? `${Math.round(row.tbt)} ms` : '—'} `
+ `| ${typeof row.ttfb === 'number' ? `${Math.round(row.ttfb)} ms` : '—'} | ${spread} |`,
);
}
out.push('');
out.push('## Decomposição do LCP');
for (const detail of details) {
out.push('');
out.push(`### ${detail.page}${detail.preset}`);
out.push('');
out.push(`Elemento de LCP: ${detail.element ? `\`${detail.element}\`` : '—'}`);
if (detail.phases.length > 0) {
out.push('');
out.push('| fase | tempo |');
out.push('|---|---|');
for (const phase of detail.phases) {
out.push(`| ${phase.phase} | ${ms(phase.timing)} |`);
}
}
if (detail.requests.length > 0) {
out.push('');
out.push('Requests concluídas até o LCP, mais pesadas primeiro:');
out.push('');
out.push('| recurso | tipo | transferido | fim |');
out.push('|---|---|---|---|');
for (const request of detail.requests) {
out.push(`| \`${request.url}\` | ${request.type} | ${kib(request.transferSize)} | ${ms(request.endTime)} |`);
}
}
if (detail.insights.length > 0) {
out.push('');
out.push('| insight com falha | ganho estimado de LCP | bytes |');
out.push('|---|---|---|');
for (const insight of detail.insights) {
out.push(`| ${insight.title} | ${ms(insight.lcpSavings)} | ${kib(insight.byteSavings)} |`);
}
}
}
out.push('');
console.log(out.join('\n'));

View File

@@ -1,94 +0,0 @@
#!/usr/bin/env bash
#
# Regenerates the visual regression baselines inside a Linux container that
# matches CI's rendering environment.
#
# `composer visual:update` run on macOS writes baselines CI will reject: the
# snapshots are pixels, and the Chromium build plus the font stack differ
# between the two systems. Pest Browser serves the application from an
# in-process Amp server, so no FrankenPHP container is involved — the only
# thing that has to match is the machine running the browser.
#
# SPEC.md §1.1 and openspec/specs/visual-regression/spec.md require the diff to
# be reviewed by a human before merge. This script updates baselines; it does
# not approve them.
#
# Usage:
# scripts/test/visual-update-ci.sh [--assert] [-- <extra pest args>]
#
# --assert run the suite in assertion mode instead of updating, to
# confirm the freshly written baselines actually pass
#
# Environment:
# DB_HOST host reachable from inside the container (default host.docker.internal)
# DB_PORT PostgreSQL port on that host (default 5433)
# DB_DATABASE database for the run (default amare_test)
# IMAGE runner image tag (default amare-ci-runner:local)
set -euo pipefail
MODE="update"
if [[ "${1:-}" == "--assert" ]]; then
MODE="assert"
shift
fi
if [[ "${1:-}" == "--" ]]; then
shift
fi
IMAGE="${IMAGE:-amare-ci-runner:local}"
DB_HOST="${DB_HOST:-host.docker.internal}"
DB_PORT="${DB_PORT:-5433}"
DB_DATABASE="${DB_DATABASE:-amare_test}"
DB_USERNAME="${DB_USERNAME:-amare}"
DB_PASSWORD="${DB_PASSWORD:-secret}"
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
if ! docker image inspect "${IMAGE}" >/dev/null 2>&1; then
echo "building ${IMAGE}"
docker build -f "${REPO_ROOT}/docker/ci-runner.Dockerfile" -t "${IMAGE}" "${REPO_ROOT}"
fi
PEST_FLAGS="--testsuite=Browser"
if [[ "${MODE}" == "update" ]]; then
PEST_FLAGS="${PEST_FLAGS} --update-snapshots"
fi
# node_modules and the Playwright browser cache live in named volumes rather
# than in the bind mount: the host copy is built for macOS and its native
# binaries (rollup, esbuild, playwright) would not run here. Both volumes are
# reused across runs so only the first one pays the install.
docker run --rm \
-v "${REPO_ROOT}:/app" \
-v amare-ci-node-modules:/app/node_modules \
-v amare-ci-playwright:/opt/playwright-browsers \
--add-host=host.docker.internal:host-gateway \
-e DB_CONNECTION=pgsql \
-e DB_HOST="${DB_HOST}" \
-e DB_PORT="${DB_PORT}" \
-e DB_DATABASE="${DB_DATABASE}" \
-e DB_USERNAME="${DB_USERNAME}" \
-e DB_PASSWORD="${DB_PASSWORD}" \
-e PEST_FLAGS="${PEST_FLAGS}" \
-e PEST_EXTRA="$*" \
"${IMAGE}" \
bash -euo pipefail -c '
# composer install is skipped when vendor/ came in through the bind
# mount: the dependencies are pure PHP, so the host copy is valid here.
if [ ! -f vendor/autoload.php ]; then
composer install --no-interaction --prefer-dist
fi
if [ ! -x node_modules/.bin/playwright ]; then
npm ci
fi
npx playwright install chromium
npm run build
php artisan migrate --force
php artisan db:seed --class=VisualContentSeeder --force
php artisan storage:link --force
php artisan test ${PEST_FLAGS} ${PEST_EXTRA}
'

View File

@@ -1,19 +1,79 @@
# Fix visual regression baselines (CI-parity) # Frontend Audit — Amare site
- [x] 1. Worktree `fix/visual-baselines-ci` a partir de `origin/main` (.worktrees/visual-baselines-ci @ 0aee15e) ## T0 — Baseline
- [x] 2. Postgres `amare_test` up (amare-postgres reutilizado, DB criado) - [x] Rodar `composer quality` p/ registrar estado verde atual (110 passed após composer install)
- [x] 3. Build `amare-app:ci` + container amare-web com env idêntica ao job browser (APP_FROZEN_NOW etc.) — /up ok - [x] Registrado estado baseline; Lighthouse final consolidado no T6
- [x] 4. Build imagem amare-ci-runner (ubuntu:24.04 + PHP 8.4 + node 22 + playwright deps) - [x] Registrar achados baseline (audit completo em frontend-audit.md + relatório subagent)
- [x] 5. Regenerar 16 snapshots via `php artisan test --testsuite=Browser --update-snapshots` dentro do ci-runner (network host)
- [x] 6. Verificar: re-run Browser suite verde (35/35)
- [x] 7. Commit .snap, push, PR #24, CI verde; bump commonmark 2.9.0 p/ auditoria; merged
# PR #23 — cadência editorial à home (sync + merge) ## T1 — Formulário de contato (e-mail, sem CRM)
- [x] Form em /contato (campos, LGPD obrigatório)
- [x] Rota POST /contato + validação + honeypot + throttle
- [x] Anti duplo-envio, estados sucesso/erro/rede, preservação de dados
- [x] E-mail via MAIL_MAILER (Resend prod / log local)
- [x] A11y do form (labels, erros ligados)
- [x] Testes feature + atualizar PublicPagesTest — Feature suite: 97 passed
- [x] 1. Reset worktree home-editorial-cadence p/ origin/feat/home-editorial-cadence (8ae5824, incl. reconcile) ## T2 — A11y/navegação
- [x] 2. Merge origin/main (PR #24): resolver conflitos home snaps (theirs); 14 snaps não-home via auto-merge - [x] Menu mobile: Escape, retorno de foco, fallback sem-JS (app.js + noscript)
- [x] 3. Rebuild amare-app:ci com código PR #23; container amare-web recriado - [x] Touch targets ≥44px (header, footer, CTAs, capítulos)
- [x] 4. Regenerar home desktop/mobile snaps no ci-runner (--update-snapshots); commit d9751b1 - [x] Footer: ul/li + address
- [x] 5. Verificação: Browser suite 37 passed (156 assertions) — inclui HomeEditorialCadenceTest/MotionTest
- [x] 6. Push; CI 5/5 verde (run 31230461189) ## T3 — SEO/metadados
- [x] 7. Merge squash PR #23 (commit 42b282c1); worktree/branch/remota limpos - [x] Títulos "Página · Amare" (PageMeta::withBrandSuffix)
- [x] og:locale, og:site_name, Twitter cards
- [x] Favicon real + link, theme-color
- [x] Metadados corretos em 404/500 (PageMeta::forErrorPage)
- [x] noindex fora de produção (robots meta condicional)
## T4 — Conteúdo/posicionamento
- [x] Remover welcome.blade.php, home/cases, home/proof
- [x] Dedup heading final-CTA vs h1 contato; dedup disclaimers
- [x] CTA consistente mobile/desktop
- [x] Corrigir copy "Em breve..." nas listas
- [x] Rebalancear copy corporativo/social
## T5 — Performance
- [x] fetchpriority + dimensões hero (LCP)
- [x] Guarda CLS no media/image
- [x] Cache manifest de fontes
- [x] Escape JSON-LD
## T6 — Validação final
- [x] pint, phpstan, testes unit/feature/architecture — `composer quality` verde (135 testes)
- [x] Browser tests + visual regression (18 passaram; 8 baselines `.snap` atualizadas com `--update-snapshots`)
- [x] Lighthouse final (home mobile: A11y 100, Best Practices 100, SEO 69 só por noindex intencional fora de prod; contato desktop: A11y 100, BP 100)
- [x] E2E form: submit → sucesso role=status; 2 e-mails (novo briefing + confirmação) logados via MAIL_MAILER=log; fila drenada
- [x] Mobile: sem overflow horizontal nas 7 rotas (390px); menu mobile abre/fecha + Escape + retorno de foco
- [x] Relatório de entrega
## T7 — Hardening Public Blades
- [x] Chunk 1: CSS overflow resilience (`overflow-wrap` body + `[data-chapter-index]` wrap safety, browser test)
- [x] Chunk 2: Hero + final-CTA fallbacks + `leading-tight` (TDD)
- [x] Chunk 3a: Skip blank-quote testimonials (TDD)
- [x] Chunk 3b: Guard empty `event_type` + `break-words` meta line (TDD)
- [x] Chunk 4: Extend `PageMeta::forErrorPage` + branded 419/429/503 error views (TDD)
- [x] Chunk 5: `app.js` defensive hardening — bfcache submit reset, "Enviando…" label swap, aria-busy, mobile-menu focus trap + Escape
- [x] Chunk 6: Quality gates — Pint passed, PHPStan clean, 104 feature tests passed (587 assertions), Vite build succeeded
### Deviations from plan
- `PageMeta::forErrorPage` landed on `main` during implementation; rebased and extended it for 419/429/503.
- Contact form landed on `main`; submit-state browser coverage was restored.
- `event_type: null` violates NOT NULL column — test uses `''` instead (guard covers both).
# Browser CI Gate — estabilidade e diagnosticabilidade
## BG — Causa raiz
- [x] Diagnosticar 3 runs: mismatch visual (8 testes), acessibilidade (1), timeout networkidle 5s + overflow real
- [x] Reproduzir local: `waitForLoadState('networkidle')` fragil (conexões longas), default 5s
- [x] Reproduzir warning `file_get_contents(.env)` em TODO teste (CI sem .env)
## BG — Fixes
- [x] Helper `StableScreenshot` (tests/Support/): drop networkidle → readyState + fonts + settle
- [x] Exportar PNGs standalone (diff/expected/actual) no mismatch — visíveis nos artifacts
- [x] CI: `cp .env.example .env` nos jobs de teste; checkout/cache v5
- [x] Gitignore tests/Browser/Screenshots
- [x] Unit test MismatchScreenshotExporter; pint/phpstan/unit/feature verdes
- [x] Push + PR + CI browser verde (PR #15 merge f09ea83, run 31098941038)
- [x] Refresh baselines de CI se necessário (não necessário — browser passou com baselines atuais)

View File

@@ -50,7 +50,7 @@ foreach ($screens as $screen => $path) {
'reducedMotion' => 'reduce', 'reducedMotion' => 'reduce',
]) ])
->withLocale('pt-BR') ->withLocale('pt-BR')
->withTimezone('America/Sao_Paulo') ->withTimezone('America/Fortaleza')
->resize($width, $height), ->resize($width, $height),
resetScroll: $path === '/', resetScroll: $path === '/',
); );

View File

@@ -5,10 +5,8 @@ declare(strict_types=1);
namespace Tests\Feature\Auth; namespace Tests\Feature\Auth;
use App\Enums\UserRole; use App\Enums\UserRole;
use App\Filament\Resources\Users\Pages\CreateUser;
use App\Filament\Resources\Users\Pages\ListUsers; use App\Filament\Resources\Users\Pages\ListUsers;
use App\Models\User; use App\Models\User;
use Database\Seeders\DatabaseSeeder;
use Filament\Auth\Pages\Login; use Filament\Auth\Pages\Login;
use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire; use Livewire\Livewire;
@@ -65,21 +63,6 @@ class InternalAuthenticationTest extends TestCase
$this->assertGuest(); $this->assertGuest();
} }
public function test_unverified_user_is_denied_panel_access(): void
{
$unverifiedUser = User::factory()->admin()->unverified()->create([
'email' => 'unverified@example.com',
]);
Livewire::test(Login::class)
->set('data.email', $unverifiedUser->email)
->set('data.password', 'password')
->call('authenticate')
->assertHasFormErrors(['email']);
$this->assertGuest();
}
public function test_assistant_cannot_access_user_management(): void public function test_assistant_cannot_access_user_management(): void
{ {
$assistant = User::factory()->assistant()->create(); $assistant = User::factory()->assistant()->create();
@@ -104,72 +87,4 @@ class InternalAuthenticationTest extends TestCase
{ {
$this->assertSame(['admin', 'assistant'], array_column(UserRole::cases(), 'value')); $this->assertSame(['admin', 'assistant'], array_column(UserRole::cases(), 'value'));
} }
/**
* Regression test for the admin-managed-verification decision: a user
* created through the panel's own CreateUser form (not the factory,
* which sets email_verified_at directly) must come out verified and
* able to log in UserForm has no email_verified_at field, so without
* CreateUser::handleRecordCreation setting it explicitly, every
* admin-created user would be permanently locked out with no in-app
* recovery path (the password-reset callback silently skips users that
* fail canAccessPanel()).
*/
public function test_admin_created_user_is_verified_and_can_log_in(): void
{
$admin = User::factory()->admin()->create();
$this->actingAs($admin);
Livewire::test(CreateUser::class)
->set('data.name', 'Novo Assistente')
->set('data.email', 'novo-assistente@example.com')
->set('data.role', UserRole::Assistant->value)
->set('data.is_active', true)
->set('data.password', 'password')
->call('create')
->assertHasNoFormErrors();
$created = User::query()->where('email', 'novo-assistente@example.com')->firstOrFail();
$this->assertTrue($created->hasVerifiedEmail());
// Log the creating admin back out — Login::mount() redirects an
// already-authenticated user away instead of rendering the form.
auth()->logout();
Livewire::test(Login::class)
->set('data.email', $created->email)
->set('data.password', 'password')
->call('authenticate')
->assertHasNoFormErrors()
->assertRedirect(route('filament.admin.pages.dashboard'));
$this->assertAuthenticatedAs($created);
}
/**
* Regression test for DatabaseSeeder's local admin/assistant: both must
* come out verified. `User::query()->updateOrCreate(...)` alone would
* silently drop `email_verified_at` it isn't in User's #[Fillable]
* list but `php artisan db:seed` (which is what `$this->seed()` below
* runs, and what `composer setup` runs too) wraps seeder execution in
* `Illuminate\Database\Eloquent\Model::unguarded()`
* (see Illuminate\Database\Console\Seeds\SeedCommand), which lifts mass-
* assignment guarding for the duration of the seed. That's what makes
* DatabaseSeeder's plain `updateOrCreate([...], ['email_verified_at' =>
* now(), ...])` work without a `forceFill()` confirmed by exercising
* the actual `db:seed` path here rather than calling the seeder's
* `updateOrCreate` call inline.
*/
public function test_seeded_local_admin_and_assistant_are_verified(): void
{
$this->seed(DatabaseSeeder::class);
$admin = User::query()->where('email', 'admin@amare.local')->firstOrFail();
$assistant = User::query()->where('email', 'assistant@amare.local')->firstOrFail();
$this->assertTrue($admin->hasVerifiedEmail());
$this->assertTrue($assistant->hasVerifiedEmail());
}
} }

View File

@@ -1,92 +0,0 @@
<?php
declare(strict_types=1);
namespace Tests\Feature\Auth;
use App\Filament\Pages\Auth\RequestPasswordReset;
use App\Models\User;
use Filament\Auth\Notifications\ResetPassword;
use Filament\Notifications\Notification as FilamentNotification;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Notification as MailNotification;
use Illuminate\Support\Facades\Password;
use Livewire\Livewire;
use Tests\TestCase;
/**
* SPEC 12.1 / ADM-01 "reset seguro": the request-reset page must not let a
* caller distinguish a registered email from an unregistered one. Filament's
* stock page fails this two ways a distinguishable danger notification for
* both Password::INVALID_USER (no such user) and Password::RESET_THROTTLED
* (only ever returned for a user that exists, per
* Illuminate\Auth\Passwords\PasswordBroker::sendResetLink). App\Filament\
* Pages\Auth\RequestPasswordReset normalizes both to the same "sent"
* response. Assertions for the two emails live in separate test methods
* because the page's own rate limiter allows only 2 requests per IP before
* an unrelated "throttled" notification kicks in.
*/
class PasswordResetRequestTest extends TestCase
{
use RefreshDatabase;
public function test_reset_request_for_registered_email_sends_notification_and_reports_success(): void
{
MailNotification::fake();
$user = User::factory()->admin()->create([
'email' => 'registered@example.com',
]);
Livewire::test(RequestPasswordReset::class)
->set('data.email', $user->email)
->call('request')
->assertNotified($this->expectedSentNotification());
MailNotification::assertSentTo($user, ResetPassword::class);
}
public function test_reset_request_for_unknown_email_reports_the_same_success_response(): void
{
MailNotification::fake();
Livewire::test(RequestPasswordReset::class)
->set('data.email', 'nobody@example.com')
->call('request')
->assertNotified($this->expectedSentNotification());
MailNotification::assertNothingSent();
}
public function test_repeated_reset_requests_for_a_registered_email_keep_reporting_success(): void
{
MailNotification::fake();
$user = User::factory()->admin()->create([
'email' => 'registered-repeat@example.com',
]);
$page = Livewire::test(RequestPasswordReset::class);
// First request creates a reset token; the second, within Laravel's
// default 60s broker throttle, would surface Password::RESET_THROTTLED
// instead of Password::RESET_LINK_SENT without the override above.
// The email field is re-set before each call because a successful
// `request()` resets the form (see the base page's `$this->form->fill()`).
$page->set('data.email', $user->email)
->call('request')
->assertNotified(Password::RESET_LINK_SENT);
$page->set('data.email', $user->email)
->call('request')
->assertNotified(Password::RESET_LINK_SENT);
}
private function expectedSentNotification(): FilamentNotification
{
return FilamentNotification::make()
->title(__(Password::RESET_LINK_SENT))
->body(__('filament-panels::auth/pages/password-reset/request-password-reset.notifications.sent.body'))
->success();
}
}

View File

@@ -1,71 +0,0 @@
<?php
declare(strict_types=1);
namespace Tests\Feature\Auth;
use App\Models\User;
use Filament\Panel;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use Tests\TestCase;
class VerifyExistingUserEmailsMigrationTest extends TestCase
{
use RefreshDatabase;
public function test_it_verifies_users_that_predate_must_verify_email(): void
{
$user = User::factory()->unverified()->create();
$this->assertFalse($user->hasVerifiedEmail(), 'Precondition: the user must start unverified.');
$this->assertFalse(
$user->canAccessPanel(app(Panel::class)),
'Precondition: an unverified user must be denied the panel, otherwise this migration guards nothing.'
);
$this->runBackfillMigration();
$this->assertTrue($user->fresh()->hasVerifiedEmail());
}
public function test_it_leaves_already_verified_users_untouched(): void
{
$verifiedAt = now()->subMonth()->startOfSecond();
$user = User::factory()->create(['email_verified_at' => $verifiedAt]);
$this->runBackfillMigration();
$this->assertTrue(
$verifiedAt->equalTo($user->fresh()->email_verified_at),
'The backfill must not move an existing verification timestamp.'
);
}
public function test_it_uses_created_at_rather_than_the_migration_run_time(): void
{
$createdAt = now()->subYear()->startOfSecond();
$user = User::factory()->unverified()->create();
DB::table('users')->where('id', $user->id)->update(['created_at' => $createdAt]);
$this->runBackfillMigration();
$this->assertTrue(
$createdAt->equalTo($user->fresh()->email_verified_at),
'Verification should be dated to when the account was created, not to when the migration ran.'
);
}
/**
* The migration is an anonymous class, so it is required and invoked
* directly: RefreshDatabase has already run every migration before the
* test body executes, so re-running it through Artisan would be a no-op.
*/
private function runBackfillMigration(): void
{
$migration = require database_path('migrations/2026_08_10_120000_verify_existing_user_emails.php');
$migration->up();
}
}

View File

@@ -1,110 +0,0 @@
<?php
declare(strict_types=1);
namespace Tests\Feature\Deployment;
use Illuminate\Support\Facades\Artisan;
use Tests\TestCase;
/**
* The `migrate` one-shot service in docker-compose.deploy.yml chains artisan
* calls with `&&`, and every other service waits on it via
* `condition: service_completed_successfully`. An unknown option makes artisan
* exit non-zero, the one-shot fails, and web/queue/scheduler never start the
* whole stack stays down with no application-level error to read.
*
* That is exactly how `media:generate-variants --force` took staging down: the
* command defines no options at all.
*/
class DeployComposeArtisanCommandsTest extends TestCase
{
/**
* A folded (`>`) or literal (`|`) block scalar preserves the newline before
* any line indented deeper than the first one. `sh -c` then receives a
* multi-line string and aborts with `sh: 2: Syntax error: "&&" unexpected`
* before running a single command the `migrate` one-shot fails, and
* because every other service waits on
* `condition: service_completed_successfully`, the whole stack stays down.
*
* This has now broken staging twice: fixed once in 5949fad by switching to
* the exec-array form, then reintroduced by 58f24a6. Hence a guard.
*/
public function test_deploy_compose_never_uses_a_block_scalar_for_command(): void
{
$contents = (string) file_get_contents(base_path('docker-compose.deploy.yml'));
preg_match_all('/^\s*command:\s*([>|][-+]?\d*)\s*$/m', $contents, $matches);
$this->assertSame(
[],
$matches[1],
'docker-compose.deploy.yml declares `command:` as a YAML block scalar ('.implode(', ', $matches[1]).'). '
.'Folding keeps the newline before every line indented deeper than the first, so `sh -c` gets a multi-line '
.'string and dies with `sh: 2: Syntax error: "&&" unexpected` before running anything. '
.'Use the single-line exec-array form: command: ["sh", "-c", "a && b && c"].'
);
}
public function test_every_artisan_call_in_the_deploy_compose_is_valid(): void
{
$invocations = $this->artisanInvocationsInDeployCompose();
$this->assertNotEmpty(
$invocations,
'Expected docker-compose.deploy.yml to invoke artisan; the parser found nothing, so this guard is not actually checking anything.'
);
$registered = Artisan::all();
foreach ($invocations as [$name, $options]) {
$this->assertArrayHasKey(
$name,
$registered,
"docker-compose.deploy.yml calls `php artisan {$name}`, which is not a registered command."
);
$command = $registered[$name];
// Options like --no-interaction and --env belong to the console
// application rather than the command, and only appear in the
// command's definition once the two are merged.
$command->mergeApplicationDefinition();
$definition = $command->getDefinition();
foreach ($options as $option) {
$this->assertTrue(
$definition->hasOption($option),
"docker-compose.deploy.yml calls `php artisan {$name} --{$option}`, but that command defines no `--{$option}` option. "
.'Artisan exits non-zero on an unknown option, which fails the migrate one-shot and prevents the whole stack from starting.'
);
}
}
}
/**
* @return list<array{0: string, 1: list<string>}>
*/
private function artisanInvocationsInDeployCompose(): array
{
// Read the file as text rather than parsing YAML: the commands are
// folded block scalars, so a call can wrap across lines, and the repo
// ships no YAML parser. Collapsing whitespace makes the wrapping
// irrelevant and keeps the guard dependency-free.
$contents = (string) file_get_contents(base_path('docker-compose.deploy.yml'));
$flattened = (string) preg_replace('/\s+/', ' ', $contents);
preg_match_all('/php artisan ([\w:.-]+)((?: --[\w-]+(?:=\S+)?)*)/', $flattened, $matches, PREG_SET_ORDER);
$invocations = [];
foreach ($matches as $match) {
preg_match_all('/--([\w-]+)/', $match[2], $optionMatches);
$invocations[] = [$match[1], $optionMatches[1]];
}
return $invocations;
}
}

View File

@@ -1,196 +0,0 @@
<?php
declare(strict_types=1);
namespace Tests\Feature\Filament;
use App\Providers\Filament\AdminPanelProvider;
use Filament\Facades\Filament;
use Filament\FontProviders\LocalFontProvider;
use Filament\Support\Colors\Color;
use ReflectionMethod;
use Tests\TestCase;
/**
* Pins the Heritage Editorial parity applied to the admin panel (MAN-118)
* the same way HeritageEditorialTokensTest pins the public site's tokens, so
* a future edit to AdminPanelProvider/the Filament theme entry cannot
* silently drift back toward Filament's stock Amber/Zinc/dark-mode defaults.
*/
class AdminPanelBrandParityTest extends TestCase
{
/**
* @return array<int, string>
*/
private function invokeColorRamp(string $method): array
{
$reflection = new ReflectionMethod(AdminPanelProvider::class, $method);
$reflection->setAccessible(true);
return $reflection->invoke(new AdminPanelProvider($this->app));
}
public function test_primary_ramp_pins_oliva_heranca_and_oliva_profundo(): void
{
$primary = $this->invokeColorRamp('primaryColor');
$this->assertSame('#556B2F', $primary[600]);
$this->assertSame('#3E5219', $primary[700]);
$this->assertSame('#8B9D77', $primary[300]);
// Deliberately NOT pure white: BadgeComponent/badge.css apply
// `bg-color-50` directly as a badge's background, and a pure-white
// 50 would render a "primary" badge as an invisible pill against
// the Papel Marfim (#FBF9F4) panel body.
$this->assertSame('#F3F5EC', $primary[50]);
$this->assertNotSame('#FFFFFF', $primary[50]);
}
public function test_gray_ramp_pins_the_paper_and_ink_tokens(): void
{
$gray = $this->invokeColorRamp('grayColor');
$this->assertSame('#FBF9F4', $gray[50]);
$this->assertSame('#F0EEE9', $gray[100]);
$this->assertSame('#E4E2DD', $gray[200]);
$this->assertSame('#C5C8B8', $gray[300]);
$this->assertSame('#5D6155', $gray[500]);
$this->assertSame('#1B1C19', $gray[900]);
}
public function test_semantic_ramps_pin_the_existing_amare_hexes_at_shade_600(): void
{
$this->assertSame('#991B1B', $this->invokeColorRamp('dangerColor')[600]);
$this->assertSame('#92400E', $this->invokeColorRamp('warningColor')[600]);
$this->assertSame('#166534', $this->invokeColorRamp('successColor')[600]);
}
public function test_oliva_heranca_and_tinta_oliva_pairs_stay_wcag_aa(): void
{
$primary = $this->invokeColorRamp('primaryColor');
$gray = $this->invokeColorRamp('grayColor');
// White CTA text on the Oliva Herança solid-button background.
$this->assertGreaterThanOrEqual(
Color::WCAG_AA_TEXT,
Color::calculateContrastRatio($primary[600], '#FFFFFF'),
);
// Tinta Oliva body copy on the Papel Marfim panel background.
$this->assertGreaterThanOrEqual(
Color::WCAG_AA_TEXT,
Color::calculateContrastRatio($gray[900], $gray[50]),
);
// Badge text (BadgeComponent resolves this to shade 500 for this
// ramp) against the badge's own bg-color-50 surface.
$this->assertGreaterThanOrEqual(
Color::WCAG_AA_TEXT,
Color::calculateContrastRatio($primary[50], $primary[500]),
);
}
public function test_admin_panel_disables_dark_mode(): void
{
$this->assertFalse(Filament::getPanel('admin')->hasDarkMode());
}
public function test_admin_panel_uses_the_amare_brand_name(): void
{
$this->assertSame('Amare Assessoria', Filament::getPanel('admin')->getBrandName());
}
public function test_admin_panel_self_hosts_eb_garamond_via_local_font_provider(): void
{
$panel = Filament::getPanel('admin');
// Passed bare, not pre-quoted: Filament's own base layout wraps
// getFontFamily() in single quotes itself
// (`--font-family: '{!! filament()->getFontFamily() !!}';`), so a
// value of "'EB Garamond'" here would render as the broken
// `--font-family: ''EB Garamond'';` and silently fail to parse as a
// font-family, falling back to ui-sans-serif — verified against the
// actual rendered /admin/login output below, not assumed.
$this->assertSame('EB Garamond', $panel->getFontFamily());
$this->assertSame(LocalFontProvider::class, $panel->getFontProvider());
// font-mono / font-serif utilities (e.g. KeyValue in
// ManageSiteSettings) must not silently fall back to a system stack.
$this->assertSame('EB Garamond', $panel->getMonoFontFamily());
$this->assertSame(LocalFontProvider::class, $panel->getMonoFontProvider());
$this->assertSame('EB Garamond', $panel->getSerifFontFamily());
$this->assertSame(LocalFontProvider::class, $panel->getSerifFontProvider());
}
public function test_admin_login_page_renders_the_self_hosted_face_and_brand(): void
{
// The only assertion that actually exercises ->viteTheme(), the
// HEAD_END render hook, and ->brandLogo() end to end — the panel
// config getters above can't catch a broken manifest reference, a
// render hook that throws, or a brandLogo closure that fails outside
// a real request. Requires `npm run build` to have populated
// public/build (CI's `test` job does this before running tests).
$response = $this->get('/admin/login');
$response->assertOk();
// Filament's base layout emits `--font-family: '<value>';` — a
// single-quoted value, since it does the wrapping itself.
$response->assertSee("--font-family: 'EB Garamond';", escape: false);
$response->assertSee("--mono-font-family: 'EB Garamond';", escape: false);
$response->assertSee("--serif-font-family: 'EB Garamond';", escape: false);
$response->assertSee('brand/lockup-on-light.webp', escape: false);
// The actual requirement behind pinning LocalFontProvider: no
// external font request leaks into the panel's <head>.
$response->assertDontSee('fonts.bunny.net');
$response->assertDontSee('fonts.googleapis.com');
}
public function test_admin_panel_is_wired_to_its_own_vite_theme_entry(): void
{
$this->assertSame(
'resources/css/filament/admin/theme.css',
Filament::getPanel('admin')->getViteTheme(),
);
}
public function test_admin_theme_entry_zeroes_radius_and_shadow_tokens(): void
{
$theme = (string) file_get_contents(resource_path('css/filament/admin/theme.css'));
$this->assertStringContainsString(
"@import '../../../../vendor/filament/filament/resources/css/theme.css'",
$theme,
);
$this->assertStringContainsString('--radius: 0', $theme);
$this->assertStringContainsString('--radius-sm: 0', $theme);
$this->assertStringContainsString('--radius-md: 0', $theme);
$this->assertStringContainsString('--radius-lg: 0', $theme);
$this->assertStringContainsString('--radius-xl: 0', $theme);
$this->assertStringContainsString('--shadow: 0 0 #0000', $theme);
$this->assertStringContainsString('--shadow-sm: 0 0 #0000', $theme);
$this->assertStringContainsString('--shadow-md: 0 0 #0000', $theme);
$this->assertStringContainsString('--shadow-lg: 0 0 #0000', $theme);
// Manual override for the vendored Tippy.js tooltip CSS that ships
// already-compiled in vendor/filament/support/dist/index.css and
// therefore never sees the @theme overrides above.
$this->assertStringContainsString('.tippy-box', $theme);
}
public function test_vite_builds_the_admin_theme_entry(): void
{
$vite = (string) file_get_contents(base_path('vite.config.js'));
$this->assertStringContainsString("'resources/css/filament/admin/theme.css'", $vite);
}
public function test_admin_panel_brand_logo_reuses_the_public_lockup_asset(): void
{
$panel = Filament::getPanel('admin');
$this->assertSame(asset('brand/lockup-on-light.webp'), $panel->getBrandLogo());
$this->assertFileExists(base_path('public/brand/lockup-on-light.webp'));
}
}

View File

@@ -1,263 +0,0 @@
<?php
declare(strict_types=1);
namespace Tests\Feature\Marketing;
use App\Models\PortfolioCase;
use App\Models\PortfolioImage;
use App\Models\Service;
use App\Models\SiteSetting;
use App\Models\Testimonial;
use Database\Seeders\ContentSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Storage;
use PHPUnit\Framework\Attributes\DataProvider;
use Tests\TestCase;
class ContentSeederProductionGatingTest extends TestCase
{
use RefreshDatabase;
public function test_content_seeder_is_a_noop_on_an_empty_production_database(): void
{
Storage::fake('public');
$this->clearContentTables();
$this->app->detectEnvironment(fn (): string => 'production');
$this->runContentSeeder();
$this->assertSame(0, SiteSetting::query()->count());
$this->assertSame(0, Service::query()->count());
$this->assertSame(0, PortfolioCase::query()->count());
$this->assertSame(0, PortfolioImage::query()->count());
$this->assertSame(0, Testimonial::query()->count());
Storage::disk('public')->assertMissing('content/about/about-image.jpg');
Storage::disk('public')->assertMissing('content/og/og-default.jpg');
}
public function test_content_seeder_does_not_clobber_owner_edited_content_in_production(): void
{
Storage::fake('public');
$siteSetting = SiteSetting::query()->create([
'brand_name' => 'Nome escolhido pela dona',
'hero_eyebrow' => 'Eyebrow original',
'hero_title' => 'Título original',
'hero_subtitle' => 'Subtítulo original',
'hero_cta_label' => 'CTA original',
'hero_secondary_cta_label' => 'CTA secundário original',
'hero_note' => 'Nota original',
'about_summary' => 'Resumo original',
'manifesto_title' => 'Manifesto original',
'manifesto_lead' => 'Lead original',
'manifesto_body' => 'Corpo original',
'method_intro' => 'Intro original',
'method_steps' => [['title' => 'Passo único', 'body' => 'Descrição']],
'principles' => ['Princípio único'],
'email' => 'dona@amare.example',
'phone' => '(11) 90000-0000',
'city' => 'Fortaleza - CE',
'social_links' => ['instagram' => 'https://instagram.com/dona'],
'default_meta_title' => 'Meta original',
'default_meta_description' => 'Meta descrição original',
'analytics_enabled' => true,
'analytics_script' => '<script>console.log("dona")</script>',
]);
$service = Service::query()->create([
'title' => 'Serviço editado pela dona',
'slug' => 'casamentos',
'summary' => 'Resumo editado',
'description' => 'Descrição editada',
'sort_order' => 99,
'is_featured' => false,
'published_at' => null,
]);
$case = PortfolioCase::query()->create([
'title' => 'Caso despublicado pela dona',
'slug' => 'casamento-ana-lucas',
'summary' => 'Resumo editado',
'event_type' => 'Casamento',
'city' => 'Fortaleza',
'venue' => 'Local editado',
'event_date' => '2024-01-01',
'challenge' => 'Desafio editado',
'solution' => 'Solução editada',
'result' => 'Resultado editado',
'cover_image_path' => 'content/portfolio/dona-cover.jpg',
'cover_image_alt' => 'Capa editada',
'is_featured' => false,
'sort_order' => 99,
'published_at' => null,
]);
$image = PortfolioImage::query()->create([
'portfolio_case_id' => $case->id,
'path' => 'content/portfolio/foto-real-da-dona.jpg',
'alt_text' => 'Foto real enviada pela dona',
'caption' => 'Momento real',
'sort_order' => 1,
]);
$testimonial = Testimonial::query()->create([
'quote' => 'Depoimento real não relacionado.',
'author_name' => 'Jeniffer e Maick',
'context' => 'Casamento · contexto original',
'sort_order' => 1,
'is_featured' => false,
'published_at' => null,
]);
$siteSettingBefore = $siteSetting->fresh()?->getAttributes();
$serviceBefore = $service->fresh()?->getAttributes();
$caseBefore = $case->fresh()?->getAttributes();
$imageBefore = $image->fresh()?->getAttributes();
$testimonialBefore = $testimonial->fresh()?->getAttributes();
$countsBefore = $this->contentCounts();
$this->app->detectEnvironment(fn (): string => 'production');
// Run twice: production gating must be stable across repeated deploys.
$this->runContentSeeder();
$this->runContentSeeder();
// Compare against counts captured just above, not hardcoded literals:
// this table may carry rows created by other tests in the same
// process (RefreshDatabase isolates per test method, not per model
// globally), so the meaningful assertion is "the seeder created
// nothing", not "there is exactly one row".
$this->assertSame($countsBefore, $this->contentCounts());
$this->assertSame($siteSettingBefore, $siteSetting->fresh()?->getAttributes());
$this->assertSame($serviceBefore, $service->fresh()?->getAttributes());
$this->assertSame($caseBefore, $case->fresh()?->getAttributes());
$this->assertSame($imageBefore, $image->fresh()?->getAttributes());
$this->assertSame($testimonialBefore, $testimonial->fresh()?->getAttributes());
Storage::disk('public')->assertMissing('content/about/about-image.jpg');
Storage::disk('public')->assertMissing('content/og/og-default.jpg');
}
/**
* @return array<string, array{0: string}>
*/
public static function allowListedEnvironments(): array
{
return [
'local' => ['local'],
'staging' => ['staging'],
'testing' => ['testing'],
];
}
#[DataProvider('allowListedEnvironments')]
public function test_content_seeder_still_seeds_demo_content_on_allow_listed_environments(string $environment): void
{
Storage::fake('public');
$this->clearContentTables();
$this->app->detectEnvironment(fn (): string => $environment);
$this->runContentSeeder();
$this->assertSame(1, SiteSetting::query()->count());
$this->assertSame(3, Service::query()->count());
$this->assertSame(3, PortfolioCase::query()->count());
$this->assertSame(5, Testimonial::query()->count());
}
/**
* @return array<string, array{0: string}>
*/
public static function nonAllowListedEnvironments(): array
{
return [
'production' => ['production'],
'empty string' => [''],
'mixed case Production' => ['Production'],
'upper case PRODUCTION' => ['PRODUCTION'],
'trailing whitespace' => ['staging '],
'unrecognized arbitrary value' => ['whatever-typo'],
];
}
/**
* The guard is an allow-list of known-safe environments, not a deny-list
* of the single literal 'production'. Any value that is not exactly
* 'local', 'staging', or 'testing' including a blank APP_ENV, a
* different case, stray whitespace, or a plain typo must fail closed
* (no-op) rather than fail open (seed/overwrite data).
*/
#[DataProvider('nonAllowListedEnvironments')]
public function test_content_seeder_is_a_noop_on_any_non_allow_listed_environment(string $environment): void
{
Storage::fake('public');
$this->clearContentTables();
$this->app->detectEnvironment(fn (): string => $environment);
$this->runContentSeeder();
$this->assertSame(0, SiteSetting::query()->count());
$this->assertSame(0, Service::query()->count());
$this->assertSame(0, PortfolioCase::query()->count());
$this->assertSame(0, PortfolioImage::query()->count());
$this->assertSame(0, Testimonial::query()->count());
}
/**
* Explicit clean slate for scenarios whose assertions depend on an
* absolute, empty starting state ("empty production database" / "fresh
* demo seed produces exactly N records").
*
* This compensates for a pre-existing, out-of-scope test-isolation gap in
* this suite: RefreshDatabase is expected to roll back every test's
* writes, but a `SiteSetting` row created by `HomePageTest` (via
* `SiteSetting::instance()`) has been observed to survive into
* whichever test runs next when the full Feature suite executes
* sequentially (reproduced with a minimal probe test asserting
* `SiteSetting::query()->count() === 0`, which passes under `--filter`
* but fails as part of `--testsuite=Feature`). Prime suspect: `tests/Pest.php`
* applies `RefreshDatabase` suite-wide via
* `pest()->extend(TestCase::class)->use(RefreshDatabase::class)->in('Feature')`
* while several class-based tests (e.g. `HomePageTest`) also declare
* `use RefreshDatabase;` themselves double application is worth
* checking first. Not fixed here: it is unrelated to MAN-103 and would
* grow this diff well beyond the seeder-gating fix.
*/
private function clearContentTables(): void
{
PortfolioImage::query()->delete();
PortfolioCase::query()->delete();
Service::query()->delete();
Testimonial::query()->delete();
SiteSetting::query()->delete();
}
/**
* @return array{siteSettings: int, services: int, portfolioCases: int, portfolioImages: int, testimonials: int}
*/
private function contentCounts(): array
{
return [
'siteSettings' => SiteSetting::query()->count(),
'services' => Service::query()->count(),
'portfolioCases' => PortfolioCase::query()->count(),
'portfolioImages' => PortfolioImage::query()->count(),
'testimonials' => Testimonial::query()->count(),
];
}
private function runContentSeeder(): void
{
$this->artisan('db:seed', [
'--class' => ContentSeeder::class,
'--force' => true,
'--no-interaction' => true,
])->assertExitCode(0);
}
}

View File

@@ -1,83 +0,0 @@
<?php
declare(strict_types=1);
namespace Tests\Feature\PublicSite;
use Tests\TestCase;
/**
* The brand logo is eager-loaded into the header and footer of every public
* page, so its weight lands on the critical path of the whole site. It used to
* ship at 512 px wide 82 KiB of lockup plus 41 KiB of mark to render at 48
* and 32 px which the MAN-109 Lighthouse audit measured as the largest
* avoidable block ahead of the LCP element.
*
* A byte budget rather than a CI performance gate: SPEC §14.1 pins the five
* blocking jobs and §22 governs when new capability is added, but nothing stops
* a future asset swap from silently undoing this. The budget is deliberately
* loose roughly double what the current encoding costs so it catches a
* regression in kind (a full-size original dropped back in) and not normal
* re-encoding noise.
*/
class BrandAssetBudgetTest extends TestCase
{
/**
* Max transfer size per shipped webp, in bytes.
*/
private const WEBP_BUDGET = 30 * 1024;
/**
* Intrinsic size the assets are encoded at: 3x the largest rendered box
* (lockup at h-12 = 48 px, mark at h-8 = 32 px). The brand.logo component
* declares these as width/height to reserve the layout box, so a re-encode
* at a different size has to update both or the reserved box lies.
*
* @var array<string, array{width: int, height: int}>
*/
private const INTRINSIC = [
'lockup-on-light' => ['width' => 149, 'height' => 144],
'lockup-on-dark' => ['width' => 149, 'height' => 144],
'mark-on-light' => ['width' => 191, 'height' => 96],
'mark-on-dark' => ['width' => 191, 'height' => 96],
];
public function test_brand_webp_assets_stay_within_their_byte_budget(): void
{
foreach (array_keys(self::INTRINSIC) as $name) {
$path = public_path("brand/{$name}.webp");
$this->assertFileExists($path);
$this->assertLessThanOrEqual(
self::WEBP_BUDGET,
filesize($path),
"public/brand/{$name}.webp is over the critical-path budget of "
.(self::WEBP_BUDGET / 1024).' KiB. It is eager-loaded on every public page; '
.'re-encode it at the rendered size instead of raising the budget.'
);
}
}
public function test_brand_webp_assets_match_the_dimensions_the_component_declares(): void
{
$component = (string) file_get_contents(
resource_path('views/components/brand/logo.blade.php')
);
foreach (self::INTRINSIC as $name => $expected) {
$size = getimagesize(public_path("brand/{$name}.webp"));
$this->assertIsArray($size, "public/brand/{$name}.webp is not a readable image.");
$this->assertSame($expected['width'], $size[0], "public/brand/{$name}.webp width changed.");
$this->assertSame($expected['height'], $size[1], "public/brand/{$name}.webp height changed.");
}
foreach ([['width' => 191, 'height' => 96], ['width' => 149, 'height' => 144]] as $pair) {
$this->assertStringContainsString(
"'width' => {$pair['width']}, 'height' => {$pair['height']}",
$component,
'brand/logo.blade.php must reserve the box using the real intrinsic size of the shipped asset.'
);
}
}
}

View File

@@ -1,254 +0,0 @@
<?php
declare(strict_types=1);
namespace Tests\Feature\PublicSite;
use App\Domain\Contact\MarketingOrigin;
use App\Mail\ContactBriefing;
use App\Mail\ContactBriefingConfirmation;
use App\Models\SiteSetting;
use Illuminate\Foundation\Http\Middleware\PreventRequestForgery;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Routing\Middleware\ThrottleRequests;
use Illuminate\Support\Facades\Mail;
use Tests\TestCase;
/**
* Covers MAN-105 simple marketing-origin tracking that rides along
* with the briefing e-mail (SPEC.md WEB-05). No Lead model, no CRM
* surface: the origin never outlives the session/e-mail round trip.
*/
class ContactBriefingOriginTest extends TestCase
{
use RefreshDatabase;
protected function setUp(): void
{
parent::setUp();
$this->withoutMiddleware([ThrottleRequests::class, PreventRequestForgery::class]);
}
/**
* @param array<string, mixed> $overrides
* @return array<string, mixed>
*/
private function validPayload(array $overrides = []): array
{
return array_merge([
'nome' => 'Maria Silva',
'email' => 'maria@example.com',
'telefone' => '(11) 98888-7777',
'tipo_evento' => 'Casamento',
'data_periodo' => 'novembro de 2027',
'cidade' => 'São Paulo',
'convidados' => '120',
'servico_interesse' => 'Planejamento completo',
'mensagem' => 'Queremos um casamento ao ar livre para 120 convidados.',
'privacidade' => '1',
'empresa' => '',
], $overrides);
}
public function test_utm_parameters_present_on_arrival_reach_the_briefing_email(): void
{
SiteSetting::instance();
Mail::fake();
$this->get('/contato?utm_source=instagram&utm_medium=social&utm_campaign=lancamento-2026')
->assertOk();
$this->post(route('contact.store'), $this->validPayload())
->assertRedirect(route('contact'))
->assertSessionHas('status', 'briefing-sent');
Mail::assertQueued(ContactBriefing::class, function (ContactBriefing $mail): bool {
$origin = $mail->fields['Origem de marketing'];
return $origin === 'origem: instagram | mídia: social | campanha: lancamento-2026';
});
}
public function test_no_utm_or_referrer_still_submits_and_omits_the_origin(): void
{
SiteSetting::instance();
Mail::fake();
// No GET beforehand: posting cold, exactly like a direct/no-JS submission.
$response = $this->post(route('contact.store'), $this->validPayload());
$response
->assertRedirect(route('contact'))
->assertSessionHas('status', 'briefing-sent');
Mail::assertQueued(ContactBriefing::class, function (ContactBriefing $mail): bool {
return array_key_exists('Origem de marketing', $mail->fields)
&& $mail->fields['Origem de marketing'] === null;
});
}
public function test_referrer_only_visit_records_only_the_referring_site_as_origin(): void
{
SiteSetting::instance();
Mail::fake();
// The query string is a personalized campaign link (carries an
// identifiable e-mail/subscriber id) — only the site identity may
// be captured as marketing origin, never the query string itself.
$this->withHeader('referer', 'https://mail.example.com/click?email=maria%40example.com&subscriber_id=42')
->get('/contato')
->assertOk();
$this->post(route('contact.store'), $this->validPayload())
->assertRedirect(route('contact'))
->assertSessionHas('status', 'briefing-sent');
Mail::assertQueued(ContactBriefing::class, function (ContactBriefing $mail): bool {
return $mail->fields['Origem de marketing'] === 'https://mail.example.com';
});
}
public function test_first_touch_wins_when_later_navigation_has_no_utm(): void
{
SiteSetting::instance();
Mail::fake();
$this->get('/contato?utm_source=google&utm_medium=cpc')
->assertOk();
// Navigate elsewhere with no UTM parameters at all before submitting.
$this->get('/servicos')->assertOk();
$this->get('/sobre')->assertOk();
$this->post(route('contact.store'), $this->validPayload())
->assertRedirect(route('contact'))
->assertSessionHas('status', 'briefing-sent');
Mail::assertQueued(ContactBriefing::class, function (ContactBriefing $mail): bool {
return $mail->fields['Origem de marketing'] === 'origem: google | mídia: cpc';
});
}
public function test_first_touch_wins_when_later_navigation_carries_a_competing_utm(): void
{
SiteSetting::instance();
Mail::fake();
$this->get('/contato?utm_source=google&utm_medium=cpc')
->assertOk();
// A second page load with its own, different UTM parameters must
// not overwrite what was captured on first touch.
$this->get('/contato?utm_source=facebook&utm_medium=social')
->assertOk();
$this->post(route('contact.store'), $this->validPayload())
->assertRedirect(route('contact'))
->assertSessionHas('status', 'briefing-sent');
Mail::assertQueued(ContactBriefing::class, function (ContactBriefing $mail): bool {
return $mail->fields['Origem de marketing'] === 'origem: google | mídia: cpc';
});
}
public function test_confirmation_email_never_exposes_the_marketing_origin(): void
{
$settings = SiteSetting::instance();
Mail::fake();
$this->get('/contato?utm_source=instagram&utm_campaign=segredo-interno')
->assertOk();
$this->post(route('contact.store'), $this->validPayload())
->assertRedirect(route('contact'))
->assertSessionHas('status', 'briefing-sent');
Mail::assertQueued(ContactBriefingConfirmation::class, function (ContactBriefingConfirmation $mail): bool {
$rendered = $mail->render();
return ! str_contains($rendered, 'instagram') && ! str_contains($rendered, 'segredo-interno');
});
// The internal briefing mail is the only one carrying the origin.
Mail::assertQueued(ContactBriefing::class, function (ContactBriefing $mail) use ($settings): bool {
return $mail->hasTo($settings->email)
&& str_contains((string) $mail->fields['Origem de marketing'], 'segredo-interno');
});
}
public function test_fail_open_when_stored_marketing_origin_is_corrupted(): void
{
SiteSetting::instance();
Mail::fake();
// Simulates the session value having been tampered with or
// corrupted (e.g. by an unrelated bug) into something that is not
// the array shape the controller expects. The acceptance
// criterion is that this never blocks the submission.
$response = $this->withSession([MarketingOrigin::SESSION_KEY => 'lixo'])
->post(route('contact.store'), $this->validPayload());
$response
->assertRedirect(route('contact'))
->assertSessionHas('status', 'briefing-sent');
Mail::assertQueued(ContactBriefing::class, function (ContactBriefing $mail): bool {
return array_key_exists('Origem de marketing', $mail->fields)
&& $mail->fields['Origem de marketing'] === null;
});
}
public function test_hostile_marketing_origin_values_are_neutralised(): void
{
SiteSetting::instance();
Mail::fake();
$overlongCampaign = str_repeat('a', 300);
$this->get('/contato?'.http_build_query([
'utm_source' => '<script>alert(1)</script>',
'utm_campaign' => $overlongCampaign,
]))->assertOk();
$this->post(route('contact.store'), $this->validPayload())
->assertRedirect(route('contact'))
->assertSessionHas('status', 'briefing-sent');
$capturedOrigin = null;
Mail::assertQueued(ContactBriefing::class, function (ContactBriefing $mail) use (&$capturedOrigin): bool {
$capturedOrigin = $mail->fields['Origem de marketing'];
return true;
});
self::assertIsString($capturedOrigin);
self::assertStringNotContainsString('<script', $capturedOrigin);
self::assertStringNotContainsString('<', $capturedOrigin);
self::assertStringNotContainsString('>', $capturedOrigin);
self::assertMatchesRegularExpression('/campanha: (a+)/', $capturedOrigin);
preg_match('/campanha: (a+)/', $capturedOrigin, $matches);
self::assertSame(100, mb_strlen($matches[1]));
// Rendering into the HTML e-mail must not reintroduce markup either.
Mail::assertQueued(ContactBriefing::class, function (ContactBriefing $mail): bool {
$rendered = $mail->render();
return ! str_contains($rendered, '<script>alert(1)</script>');
});
}
public function test_technical_routes_do_not_consume_the_first_touch_slot(): void
{
$this->get('/sitemap.xml?utm_source=crawler&utm_medium=bot')->assertOk();
$this->get('/robots.txt?utm_source=crawler')->assertOk();
$this->assertNull(session(MarketingOrigin::SESSION_KEY));
$this->get('/?utm_source=instagram&utm_medium=perfil')->assertOk();
$this->assertSame('instagram', session(MarketingOrigin::SESSION_KEY)['utm_source'] ?? null);
}
}

View File

@@ -89,31 +89,6 @@ class ContactBriefingTest extends TestCase
}); });
} }
public function test_submission_normalizes_email_and_phone_before_reaching_mailables(): void
{
$settings = SiteSetting::instance();
Mail::fake();
$response = $this->post(route('contact.store'), $this->validPayload([
'email' => ' Maria.Silva@EXAMPLE.com ',
'telefone' => '+55 (11) 98888-7777',
]));
$response
->assertRedirect(route('contact'))
->assertSessionHas('status', 'briefing-sent');
Mail::assertQueued(ContactBriefing::class, function (ContactBriefing $mail) use ($settings): bool {
return $mail->hasTo($settings->email)
&& $mail->fields['E-mail'] === 'maria.silva@example.com'
&& $mail->fields['Telefone/WhatsApp'] === '(11) 98888-7777';
});
Mail::assertQueued(ContactBriefingConfirmation::class, function (ContactBriefingConfirmation $mail): bool {
return $mail->hasTo('maria.silva@example.com');
});
}
public function test_honeypot_submission_is_dropped_without_sending_emails(): void public function test_honeypot_submission_is_dropped_without_sending_emails(): void
{ {
SiteSetting::instance(); SiteSetting::instance();

View File

@@ -1,82 +0,0 @@
<?php
declare(strict_types=1);
namespace Tests\Feature\PublicSite;
use Tests\TestCase;
/**
* Bunny serves every EB Garamond weight as both woff2 and woff, and the fonts
* plugin emitted an `@font-face` rule for each woff2 first, woff second. Two
* rules with the same family, weight, style and unicode-range mean the later
* one wins, so browsers rendered from the woff files and discarded the
* preloaded woff2.
*
* The MAN-109 audit measured the cost on the home page: 88 KiB of woff at
* VeryHigh priority, ahead of the LCP image, plus 74 KiB of preloaded woff2
* that was fetched and never used. The `amare:fonts-woff2-only` plugin in
* vite.config.js strips the woff rules; these assertions fail if a plugin
* upgrade brings them back.
*
* Reads the build output because that is where the defect lived the source
* config looked correct the whole time. The `feature` job runs `npm run build`
* before the suite (.github/workflows/ci.yml).
*/
class FontDeliveryTest extends TestCase
{
public function test_build_ships_woff2_only(): void
{
$manifestPath = public_path('build/fonts-manifest.json');
$this->assertFileExists(
$manifestPath,
'Run `npm run build` before this suite — the font manifest is a build artifact.'
);
$manifest = json_decode((string) file_get_contents($manifestPath), true);
$this->assertIsArray($manifest);
$this->assertNotEmpty($manifest['preloads'] ?? [], 'The critical font weights must still be preloaded.');
foreach ($manifest['preloads'] as $preload) {
$this->assertStringEndsWith('.woff2', (string) ($preload['file'] ?? ''));
}
foreach ($manifest['families'] ?? [] as $alias => $family) {
foreach ($family['variants'] ?? [] as $key => $variant) {
$this->assertNotEmpty($variant['files'] ?? [], "{$alias} {$key} lost every font file.");
foreach ($variant['files'] as $file) {
$this->assertSame(
'woff2',
$file['format'] ?? null,
"{$alias} {$key} declares a {$file['format']} file. Only woff2 is served — a second "
.'format with the same family, weight and unicode-range overrides the woff2 rule and '
.'doubles the font traffic on the critical path.'
);
}
}
}
$stylesheet = $manifest['style']['file'] ?? null;
$this->assertIsString($stylesheet);
$css = (string) file_get_contents(public_path('build/'.$stylesheet));
$this->assertStringContainsString('format("woff2")', $css);
$this->assertStringNotContainsString('format("woff")', $css);
}
public function test_no_woff_files_are_emitted_into_the_build(): void
{
$emitted = glob(public_path('build/assets/*.woff')) ?: [];
$this->assertSame(
[],
array_map('basename', $emitted),
'The build emitted legacy woff files. They are never requested by a browser that supports woff2, '
.'which is every browser this site targets.'
);
}
}

View File

@@ -49,25 +49,6 @@ class HeritageEditorialTokensTest extends TestCase
$this->assertStringContainsString('font-family: var(--amare-font-serif)', $appCss); $this->assertStringContainsString('font-family: var(--amare-font-serif)', $appCss);
} }
public function test_public_theme_mapping_neutralises_shadow_tokens(): void
{
$appCss = (string) file_get_contents(resource_path('css/app.css'));
// Tailwind's Vite plugin shares one context across every build entry, so
// the Filament theme entry makes Filament's `shadow-*` usage visible and
// Tailwind emits its DEFAULT shadow values into the public bundle too.
// The public theme must pin them to transparent, or DESIGN.md's Tonal
// Layer Rule ("flat surfaces, no card shadows") stops being true in the
// artifact that actually ships.
foreach (['--shadow-2xs', '--shadow-xs', '--shadow-sm', '--shadow', '--shadow-md', '--shadow-lg', '--shadow-xl', '--shadow-2xl', '--shadow-inner'] as $token) {
$this->assertMatchesRegularExpression(
'/'.preg_quote($token, '/').':\s*0 0 #0000\s*;/',
$appCss,
"resources/css/app.css must pin {$token} to a transparent value so no public element can render a card shadow."
);
}
}
public function test_vite_self_hosts_eb_garamond(): void public function test_vite_self_hosts_eb_garamond(): void
{ {
$vite = (string) file_get_contents(base_path('vite.config.js')); $vite = (string) file_get_contents(base_path('vite.config.js'));

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