Compare commits
4 Commits
fix/pr23-c
...
2b4e6a7d1c
| Author | SHA1 | Date | |
|---|---|---|---|
| 2b4e6a7d1c | |||
| f5830930bc | |||
| 8343867072 | |||
| 246410803d |
26
.github/workflows/ci.yml
vendored
@@ -31,8 +31,7 @@ jobs:
|
||||
name: static
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- run: cp .env.example .env
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: shivammathur/setup-php@v2
|
||||
with:
|
||||
@@ -40,7 +39,7 @@ jobs:
|
||||
extensions: dom, curl, libxml, mbstring, zip, pcntl, pdo, pdo_pgsql, bcmath, intl, sodium, gd
|
||||
coverage: none
|
||||
|
||||
- uses: actions/cache@v5
|
||||
- uses: actions/cache@v4
|
||||
with:
|
||||
path: ~/.composer/cache/files
|
||||
key: composer-${{ runner.os }}-${{ hashFiles('**/composer.lock') }}
|
||||
@@ -56,8 +55,7 @@ jobs:
|
||||
name: unit
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- run: cp .env.example .env
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: shivammathur/setup-php@v2
|
||||
with:
|
||||
@@ -65,7 +63,7 @@ jobs:
|
||||
extensions: dom, curl, libxml, mbstring, zip, pcntl, pdo, pdo_pgsql, bcmath, intl, sodium, gd
|
||||
coverage: none
|
||||
|
||||
- uses: actions/cache@v5
|
||||
- uses: actions/cache@v4
|
||||
with:
|
||||
path: ~/.composer/cache/files
|
||||
key: composer-${{ runner.os }}-${{ hashFiles('**/composer.lock') }}
|
||||
@@ -94,8 +92,7 @@ jobs:
|
||||
--health-timeout 5s
|
||||
--health-retries 10
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- run: cp .env.example .env
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: shivammathur/setup-php@v2
|
||||
with:
|
||||
@@ -103,13 +100,13 @@ jobs:
|
||||
extensions: dom, curl, libxml, mbstring, zip, pcntl, pdo, pdo_pgsql, bcmath, intl, sodium, gd
|
||||
coverage: none
|
||||
|
||||
- uses: actions/cache@v5
|
||||
- uses: actions/cache@v4
|
||||
with:
|
||||
path: ~/.composer/cache/files
|
||||
key: composer-${{ runner.os }}-${{ hashFiles('**/composer.lock') }}
|
||||
restore-keys: composer-${{ runner.os }}-
|
||||
|
||||
- uses: actions/cache@v5
|
||||
- uses: actions/cache@v4
|
||||
with:
|
||||
path: ~/.npm
|
||||
key: npm-${{ runner.os }}-${{ hashFiles('**/package-lock.json') }}
|
||||
@@ -139,8 +136,7 @@ jobs:
|
||||
--health-timeout 5s
|
||||
--health-retries 10
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- run: cp .env.example .env
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: shivammathur/setup-php@v2
|
||||
with:
|
||||
@@ -148,13 +144,13 @@ jobs:
|
||||
extensions: dom, curl, libxml, mbstring, zip, pcntl, pdo, pdo_pgsql, bcmath, intl, sodium, gd
|
||||
coverage: none
|
||||
|
||||
- uses: actions/cache@v5
|
||||
- uses: actions/cache@v4
|
||||
with:
|
||||
path: ~/.composer/cache/files
|
||||
key: composer-${{ runner.os }}-${{ hashFiles('**/composer.lock') }}
|
||||
restore-keys: composer-${{ runner.os }}-
|
||||
|
||||
- uses: actions/cache@v5
|
||||
- uses: actions/cache@v4
|
||||
with:
|
||||
path: ~/.npm
|
||||
key: npm-${{ runner.os }}-${{ hashFiles('**/package-lock.json') }}
|
||||
@@ -228,7 +224,7 @@ jobs:
|
||||
name: container
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Build production image
|
||||
run: docker build -t amare-app:ci .
|
||||
|
||||
1
.gitignore
vendored
@@ -18,7 +18,6 @@
|
||||
/public/fonts-manifest.dev.json
|
||||
/public/hot
|
||||
/public/storage
|
||||
/tests/Browser/Screenshots
|
||||
/storage/*.key
|
||||
/storage/pail
|
||||
/vendor
|
||||
|
||||
104
SPEC.md
@@ -14,8 +14,7 @@
|
||||
| Estágio | MVP |
|
||||
| Status da especificação | Aprovada para implementação |
|
||||
| Idioma da interface | Português do Brasil (`pt-BR`) |
|
||||
| Timezone padrão | `America/Sao_Paulo` |
|
||||
| Cidade de atuação | São Paulo (capital) |
|
||||
| Timezone padrão | `America/Fortaleza` |
|
||||
| Moeda | BRL, sem conversão entre moedas |
|
||||
| Princípio principal | YAGNI — implementar somente o necessário para validar o produto |
|
||||
| Arquitetura | Monólito modular Laravel |
|
||||
@@ -45,8 +44,6 @@ Em caso de conflito, seguir esta ordem:
|
||||
|
||||
O agente **NÃO DEVE** alterar silenciosamente uma decisão deste documento. Uma alteração de escopo ou arquitetura deve atualizar esta especificação ou criar uma ADR.
|
||||
|
||||
Mudanças incrementais são planejadas em `openspec/changes/` e, após arquivadas, este documento DEVE ser revalidado para incorporar decisões ratificadas (back-sync). Este arquivo permanece a fonte de verdade do produto.
|
||||
|
||||
---
|
||||
|
||||
## 1. Contrato de operação para agentes
|
||||
@@ -352,14 +349,13 @@ A home DEVE conter, nesta ordem aproximada:
|
||||
|
||||
1. header e navegação;
|
||||
2. hero com proposta de valor e CTA;
|
||||
3. manifesto da marca;
|
||||
4. resumo dos serviços em destaque;
|
||||
5. casos selecionados do portfólio;
|
||||
6. método de trabalho (4 passos);
|
||||
3. prova visual por eventos em destaque;
|
||||
4. resumo dos serviços;
|
||||
5. método de trabalho;
|
||||
6. casos selecionados;
|
||||
7. depoimentos;
|
||||
8. posicionamento/perfil;
|
||||
9. CTA final para briefing;
|
||||
10. footer com contato, redes e links legais.
|
||||
8. CTA final para briefing;
|
||||
9. footer com contato, redes e links legais.
|
||||
|
||||
A ordem pode variar apenas se a revisão de UX justificar a mudança.
|
||||
|
||||
@@ -378,15 +374,6 @@ Centralizar tokens de:
|
||||
|
||||
Não espalhar valores visuais arbitrários por componentes.
|
||||
|
||||
O MVP adota o sistema de design **Heritage Editorial** (ver DESIGN.md e ADR-013):
|
||||
|
||||
- tipografia serifada auto-hospedada (EB Garamond) com escala de display a label;
|
||||
- paleta papel/oliva/sálvia/tinta com superfícies tonais; hierarquia sem sombras de card;
|
||||
- raio de borda zero para superfícies interativas e de conteúdo;
|
||||
- largura de container 1120px e ritmo de espaçamento de 8px;
|
||||
- `prefers-reduced-motion` respeitado;
|
||||
- contraste WCAG AA (tinta sobre papel e oliva sobre papel).
|
||||
|
||||
### 6.4 Requisitos de mídia
|
||||
|
||||
- Imagens públicas DEVERÃO possuir texto alternativo.
|
||||
@@ -396,7 +383,6 @@ O MVP adota o sistema de design **Heritage Editorial** (ver DESIGN.md e ADR-013)
|
||||
- Dimensões DEVERÃO ser reservadas para evitar layout shift.
|
||||
- Não armazenar blobs de imagem no PostgreSQL.
|
||||
- Não depender do disco efêmero do contêiner em produção.
|
||||
- O logotipo da marca DEVE possuir texto alternativo e variantes claro/escuro; `site_settings.logo_path` sobrescreve o asset padrão quando preenchido.
|
||||
|
||||
### 6.5 Acessibilidade
|
||||
|
||||
@@ -548,10 +534,12 @@ Campos públicos:
|
||||
- validar no servidor;
|
||||
- usar honeypot e rate limiting;
|
||||
- impedir duplo envio acidental;
|
||||
- criar Lead com status `new`;
|
||||
- registrar origem `website`;
|
||||
- notificar administradores;
|
||||
- exibir sucesso sem revelar dados internos;
|
||||
- enviar e-mail de confirmação quando o serviço de e-mail estiver configurado;
|
||||
- exibir sucesso sem revelar dados internos.
|
||||
|
||||
> **Estado atual (Fases 0–1):** o formulário usa Blade + Controller e envia apenas e-mails informativos (para a assessoria e confirmação ao visitante), sem criar Lead. A criação de Lead (status `new`, origem `website`), a notificação aos administradores e o registro de aceite de privacidade entram na **Fase 2**, quando o formulário passa a criar o Lead via `CaptureWebsiteLead`. A falha de e-mail não pode apagar dados já criados.
|
||||
- falha no e-mail não pode apagar o lead já criado.
|
||||
|
||||
**Aceite:**
|
||||
|
||||
@@ -1265,19 +1253,10 @@ Singleton:
|
||||
|
||||
- `id` bigint PK;
|
||||
- `brand_name`;
|
||||
- `logo_path` nullable;
|
||||
- `logo_alt` nullable;
|
||||
- `hero_eyebrow` nullable;
|
||||
- `hero_title`;
|
||||
- `hero_subtitle`;
|
||||
- `hero_cta_label`;
|
||||
- `hero_cta_secondary_label` nullable;
|
||||
- `hero_note` nullable;
|
||||
- `manifesto_title`;
|
||||
- `manifesto_lead`;
|
||||
- `manifesto_body`;
|
||||
- `method_steps` jsonb (4 passos tipados);
|
||||
- `principles` jsonb (lista tipada);
|
||||
- `about_summary` nullable;
|
||||
- `email`;
|
||||
- `phone`;
|
||||
@@ -1286,8 +1265,7 @@ Singleton:
|
||||
- `default_meta_title`;
|
||||
- `default_meta_description`;
|
||||
- `default_og_image_path` nullable;
|
||||
- `default_og_image_alt` nullable;
|
||||
- `analytics_enabled` boolean default false;
|
||||
- analytics fields nullable;
|
||||
- timestamps.
|
||||
|
||||
#### `services`
|
||||
@@ -1558,7 +1536,7 @@ Constraints de banco devem proteger:
|
||||
| Servidor | FrankenPHP + Caddy |
|
||||
| Assets | Vite |
|
||||
| Testes | Pest 4 + Pest Browser/Playwright |
|
||||
| Arquivos | Laravel Filesystem + Cloudflare R2 (S3-compatible) em produção |
|
||||
| Arquivos | Laravel Filesystem + storage S3-compatible em produção |
|
||||
| Fila | Database queue |
|
||||
| Scheduler | Laravel Scheduler em processo separado |
|
||||
|
||||
@@ -1786,8 +1764,6 @@ Não transformar todas as seções estáticas em componentes Livewire. Usar Blad
|
||||
|
||||
### 11.2 Formulário de briefing
|
||||
|
||||
> **Estado atual (Fases 0–1):** 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 componente deve:
|
||||
|
||||
- ter estado tipado ou Form Object quando útil;
|
||||
@@ -1994,7 +1970,7 @@ Determinismo obrigatório:
|
||||
|
||||
- Chromium e imagem Linux fixos;
|
||||
- viewport fixo;
|
||||
- timezone `America/Sao_Paulo`;
|
||||
- timezone `America/Fortaleza`;
|
||||
- locale `pt-BR`;
|
||||
- fontes instaladas na imagem;
|
||||
- relógio congelado;
|
||||
@@ -2076,7 +2052,7 @@ quality → Pint check + PHPStan/Larastan + audits + testes
|
||||
|
||||
| Job | Responsabilidade | Bloqueia merge |
|
||||
|---|---|---:|
|
||||
| `static` | Pint, PHPStan/Larastan, Composer validate, Composer e npm audit | Sim |
|
||||
| `static` | Pint, PHPStan/Larastan, Composer validate e audits | Sim |
|
||||
| `unit` | Unitários, arquitetura e cobertura | Sim |
|
||||
| `feature` | PostgreSQL, migrations, Livewire, Filament e integração | Sim |
|
||||
| `browser` | Vite, FrankenPHP, E2E, smoke, acessibilidade e visual | Sim |
|
||||
@@ -2097,11 +2073,11 @@ quality → Pint check + PHPStan/Larastan + audits + testes
|
||||
### 14.3 Branches e ambientes
|
||||
|
||||
- PR: testes e preview opcional;
|
||||
- `main`: build imutável por SHA publicado no GHCR e deploy automático em staging via Dokploy;
|
||||
- staging: Dokploy Compose executa migração, healthcheck `/up` e smoke pós-deploy (`/up`, `/`, `/admin/login`);
|
||||
- produção: promoção da mesma imagem aprovada, sem rebuild (retag do digest em `:production`);
|
||||
- produção requer aprovação humana explícita no MVP (`workflow_dispatch` com confirmação);
|
||||
- rollback usa imagem anterior (SHA anterior, sem rebuild);
|
||||
- `main`: build imutável por SHA e deploy automático em staging;
|
||||
- staging: migration, cache warmup e smoke pós-deploy;
|
||||
- produção: promoção da mesma imagem aprovada, sem rebuild;
|
||||
- produção requer aprovação humana explícita no MVP;
|
||||
- rollback usa imagem anterior;
|
||||
- migrations devem ser backward-compatible quando possível.
|
||||
|
||||
### 14.4 Definition of Done
|
||||
@@ -2183,7 +2159,7 @@ APP_DEBUG=false
|
||||
APP_URL
|
||||
APP_LOCALE=pt_BR
|
||||
APP_FALLBACK_LOCALE=pt_BR
|
||||
APP_TIMEZONE=America/Sao_Paulo
|
||||
APP_TIMEZONE=America/Fortaleza
|
||||
|
||||
DB_CONNECTION=pgsql
|
||||
DB_HOST
|
||||
@@ -2196,16 +2172,19 @@ CACHE_STORE=database ou file conforme ambiente
|
||||
QUEUE_CONNECTION=database
|
||||
SESSION_DRIVER=database ou cookie conforme decisão
|
||||
|
||||
FILESYSTEM_DISK=r2 em produção
|
||||
R2_ACCESS_KEY_ID
|
||||
R2_SECRET_ACCESS_KEY
|
||||
R2_BUCKET
|
||||
R2_ENDPOINT
|
||||
R2_URL (domínio próprio opcional)
|
||||
AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, AWS_DEFAULT_REGION, AWS_BUCKET, AWS_ENDPOINT, AWS_USE_PATH_STYLE_ENDPOINT — opcionais, apenas se o disk s3 for usado
|
||||
FILESYSTEM_DISK=s3 em produção
|
||||
AWS_ACCESS_KEY_ID
|
||||
AWS_SECRET_ACCESS_KEY
|
||||
AWS_DEFAULT_REGION
|
||||
AWS_BUCKET
|
||||
AWS_ENDPOINT opcional
|
||||
AWS_USE_PATH_STYLE_ENDPOINT opcional
|
||||
|
||||
MAIL_MAILER=resend em produção (mailer nativo Laravel)
|
||||
RESEND_API_KEY
|
||||
MAIL_MAILER
|
||||
MAIL_HOST
|
||||
MAIL_PORT
|
||||
MAIL_USERNAME
|
||||
MAIL_PASSWORD
|
||||
MAIL_FROM_ADDRESS
|
||||
MAIL_FROM_NAME
|
||||
```
|
||||
@@ -2300,7 +2279,7 @@ O seed deve criar:
|
||||
- configurações do site;
|
||||
- 3 serviços;
|
||||
- 3 casos de portfólio;
|
||||
- depoimentos reais: os 5 casais de `depoimentos.md` (Jeniffer e Maick, Quesia e Jhonata, Milena e Weslley, Raquel e Pedro, Victoria e Pedro), preservando texto e datas; autores fictícios de demonstração removidos; em produção permanecem não publicados até autorização explícita de publicação;
|
||||
- 3 depoimentos;
|
||||
- leads em estados variados;
|
||||
- 2 eventos futuros e 1 concluído;
|
||||
- tarefas vencidas e futuras;
|
||||
@@ -2335,7 +2314,7 @@ O agente deve implementar na sequência, salvo instrução explícita.
|
||||
- [x] Filament instalado e autenticado;
|
||||
- [x] Livewire configurado;
|
||||
- [x] Tailwind/Vite;
|
||||
- [~] FrankenPHP e Docker Compose local (imagem pronta; serviço de aplicação local pendente);
|
||||
- [x] FrankenPHP e Docker Compose local;
|
||||
- [x] papéis admin/assistant;
|
||||
- [x] Pint;
|
||||
- [x] PHPStan/Larastan;
|
||||
@@ -2346,13 +2325,6 @@ O agente deve implementar na sequência, salvo instrução explícita.
|
||||
- [x] design tokens mínimos;
|
||||
- [x] healthcheck;
|
||||
- [x] seed de admin local.
|
||||
- [ ] verificação de e-mail e reset seguro (MustVerifyEmail);
|
||||
- [ ] npm audit no `composer quality` e no job `static`;
|
||||
- [ ] gate de cobertura `Domain`/`Application` ≥ 80%;
|
||||
- [ ] serviço de aplicação FrankenPHP no Compose local;
|
||||
- [ ] hello-world implantado em staging (critério de saída).
|
||||
|
||||
> 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.
|
||||
|
||||
**Critério de saída:** pipeline verde e hello-world implantado em staging.
|
||||
|
||||
@@ -2509,10 +2481,6 @@ Toda operação financeira deve:
|
||||
| ADR-008 | Database queue; Redis adiado | Aceita |
|
||||
| ADR-009 | Dinheiro em BRL armazenado como centavos inteiros | Aceita |
|
||||
| ADR-010 | Home com estrutura fixa e CMS tipado, sem page builder | Aceita |
|
||||
| ADR-011 | Cloudflare R2 (S3-compatible) como storage de objetos em produção | 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-014 | Deploy via Dokploy Compose com imagem imutável por SHA no GHCR | Aceita |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -85,7 +85,7 @@ final readonly class PageMeta
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the metadata for branded error pages.
|
||||
* Build the metadata for branded error pages (404/500).
|
||||
*
|
||||
* Error pages carry no canonical, are excluded from search indexes and use
|
||||
* the page name suffixed with the brand name as their title.
|
||||
@@ -94,13 +94,9 @@ final readonly class PageMeta
|
||||
SiteSetting $settings,
|
||||
int $status = 404,
|
||||
): self {
|
||||
[$title, $description] = match ($status) {
|
||||
500 => ['Algo deu errado', 'Não foi possível concluir o pedido. Tente novamente em instantes.'],
|
||||
419 => ['Sessão expirada', 'Sua sessão expirou. Volte e tente enviar novamente.'],
|
||||
429 => ['Muitas solicitações', 'Você enviou muitas solicitações em pouco tempo. Aguarde um instante e tente novamente.'],
|
||||
503 => ['Em manutenção', 'Estamos realizando uma breve manutenção. Tente novamente em instantes.'],
|
||||
default => ['Página não encontrada', 'A página que você procura não existe ou foi movida.'],
|
||||
};
|
||||
[$title, $description] = $status === 500
|
||||
? ['Algo deu errado', 'Não foi possível concluir o pedido. Tente novamente em instantes.']
|
||||
: ['Página não encontrada', 'A página que você procura não existe ou foi movida.'];
|
||||
|
||||
return new self(
|
||||
title: trim($title).' - '.$settings->brand_name,
|
||||
|
||||
@@ -56,9 +56,6 @@ final class MediaGenerateVariantsCommand extends Command
|
||||
if ($settings && filled($settings->default_og_image_path)) {
|
||||
$paths[] = (string) $settings->default_og_image_path;
|
||||
}
|
||||
if ($settings && filled($settings->about_image_path)) {
|
||||
$paths[] = (string) $settings->about_image_path;
|
||||
}
|
||||
|
||||
foreach (Service::query()->whereNotNull('cover_image_path')->pluck('cover_image_path') as $path) {
|
||||
$paths[] = (string) $path;
|
||||
|
||||
@@ -228,12 +228,6 @@ class ManageSiteSettings extends Page
|
||||
->addActionLabel('Adicionar rede'),
|
||||
])
|
||||
->columns(2),
|
||||
Section::make('Página Sobre')
|
||||
->schema([
|
||||
PublicImageUploadRules::fileUpload('about_image_path', 'Imagem da página Sobre', 'content/about'),
|
||||
PublicImageUploadRules::altTextField('about_image_alt', 'about_image_path'),
|
||||
])
|
||||
->columns(2),
|
||||
Section::make('SEO padrão')
|
||||
->schema([
|
||||
TextInput::make('default_meta_title')
|
||||
|
||||
@@ -16,8 +16,6 @@ use Illuminate\Database\Eloquent\Model;
|
||||
* @property bool $analytics_enabled
|
||||
* @property string|null $default_og_image_path
|
||||
* @property string|null $default_og_image_alt
|
||||
* @property string|null $about_image_path
|
||||
* @property string|null $about_image_alt
|
||||
* @property string|null $logo_path
|
||||
* @property string|null $logo_alt
|
||||
*/
|
||||
@@ -32,8 +30,6 @@ use Illuminate\Database\Eloquent\Model;
|
||||
'hero_secondary_cta_label',
|
||||
'hero_note',
|
||||
'about_summary',
|
||||
'about_image_path',
|
||||
'about_image_alt',
|
||||
'manifesto_title',
|
||||
'manifesto_lead',
|
||||
'manifesto_body',
|
||||
|
||||
12
composer.lock
generated
@@ -2802,16 +2802,16 @@
|
||||
},
|
||||
{
|
||||
"name": "league/commonmark",
|
||||
"version": "2.9.0",
|
||||
"version": "2.8.3",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/thephpleague/commonmark.git",
|
||||
"reference": "5703d83ba3da3b2e356a5fedc848ed6d8ffb6529"
|
||||
"reference": "1902f60f984235023acbe03db6ad614a37b3c3e7"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/thephpleague/commonmark/zipball/5703d83ba3da3b2e356a5fedc848ed6d8ffb6529",
|
||||
"reference": "5703d83ba3da3b2e356a5fedc848ed6d8ffb6529",
|
||||
"url": "https://api.github.com/repos/thephpleague/commonmark/zipball/1902f60f984235023acbe03db6ad614a37b3c3e7",
|
||||
"reference": "1902f60f984235023acbe03db6ad614a37b3c3e7",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
@@ -2848,7 +2848,7 @@
|
||||
"type": "library",
|
||||
"extra": {
|
||||
"branch-alias": {
|
||||
"dev-main": "2.10-dev"
|
||||
"dev-main": "2.9-dev"
|
||||
}
|
||||
},
|
||||
"autoload": {
|
||||
@@ -2905,7 +2905,7 @@
|
||||
"type": "tidelift"
|
||||
}
|
||||
],
|
||||
"time": "2026-08-03T13:42:31+00:00"
|
||||
"time": "2026-07-12T15:29:16+00:00"
|
||||
},
|
||||
{
|
||||
"name": "league/config",
|
||||
|
||||
|
Before Width: | Height: | Size: 151 KiB |
|
Before Width: | Height: | Size: 436 KiB |
|
Before Width: | Height: | Size: 254 KiB |
|
Before Width: | Height: | Size: 258 KiB |
|
Before Width: | Height: | Size: 622 KiB |
|
Before Width: | Height: | Size: 499 KiB |
|
Before Width: | Height: | Size: 244 KiB |
|
Before Width: | Height: | Size: 235 KiB |
|
Before Width: | Height: | Size: 239 KiB |
|
Before Width: | Height: | Size: 236 KiB |
|
Before Width: | Height: | Size: 360 KiB |
|
Before Width: | Height: | Size: 330 KiB |
|
Before Width: | Height: | Size: 302 KiB |
|
Before Width: | Height: | Size: 337 KiB |
@@ -1,25 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('site_settings', function (Blueprint $table): void {
|
||||
$table->string('about_image_path')->nullable()->after('about_summary');
|
||||
$table->string('about_image_alt')->nullable()->after('about_image_path');
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('site_settings', function (Blueprint $table): void {
|
||||
$table->dropColumn(['about_image_path', 'about_image_alt']);
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -8,7 +8,6 @@ use App\Models\PortfolioCase;
|
||||
use App\Models\PortfolioImage;
|
||||
use App\Models\Service;
|
||||
use App\Models\SiteSetting;
|
||||
use App\Support\PublicImageUploadRules;
|
||||
use Illuminate\Database\Seeder;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\File;
|
||||
@@ -37,8 +36,6 @@ class ContentSeeder extends Seeder
|
||||
'hero_secondary_cta_label' => 'Conheça nosso olhar',
|
||||
'hero_note' => 'Planejamento cuidadoso, comunicação clara e execução segura — do primeiro encontro ao último detalhe.',
|
||||
'about_summary' => 'Assessoria boutique especializada em experiências memoráveis em São Paulo.',
|
||||
'about_image_path' => $this->copyFixture('about-image.jpg', 'content/about/about-image.jpg'),
|
||||
'about_image_alt' => 'Mesa de planejamento com caderno, café e guardanapos de pano',
|
||||
'manifesto_title' => 'Sofisticação que também se traduz em organização.',
|
||||
'manifesto_lead' => 'Um evento memorável não nasce apenas de uma boa estética. Ele depende de decisões bem conduzidas, fornecedores alinhados e atenção constante ao que realmente importa.',
|
||||
'manifesto_body' => 'A Amare combina sensibilidade e precisão para criar encontros coerentes com cada cliente, marca e ocasião — sem fórmulas prontas, excessos ou ruído.',
|
||||
@@ -94,7 +91,7 @@ class ContentSeeder extends Seeder
|
||||
['slug' => $service['slug']],
|
||||
[
|
||||
...$service,
|
||||
'cover_image_path' => $this->copyFixture('service-'.$service['slug'].'.jpg', 'content/services/'.$service['slug'].'.jpg'),
|
||||
'cover_image_path' => $this->copyFixture('service-cover.jpg', 'content/services/'.$service['slug'].'.jpg'),
|
||||
'cover_image_alt' => 'Capa do serviço '.$service['title'],
|
||||
'published_at' => Carbon::parse(self::SEED_TIMESTAMP),
|
||||
],
|
||||
@@ -151,7 +148,7 @@ class ContentSeeder extends Seeder
|
||||
['slug' => $caseData['slug']],
|
||||
[
|
||||
...$caseData,
|
||||
'cover_image_path' => $this->copyFixture('case-'.$caseData['slug'].'.jpg', 'content/portfolio/'.$caseData['slug'].'-cover.jpg'),
|
||||
'cover_image_path' => $this->copyFixture('portfolio-cover.jpg', 'content/portfolio/'.$caseData['slug'].'-cover.jpg'),
|
||||
'cover_image_alt' => 'Capa do caso '.$caseData['title'],
|
||||
'is_featured' => true,
|
||||
'published_at' => Carbon::parse(self::SEED_TIMESTAMP),
|
||||
@@ -163,7 +160,7 @@ class ContentSeeder extends Seeder
|
||||
foreach ([1, 2] as $index) {
|
||||
PortfolioImage::query()->create([
|
||||
'portfolio_case_id' => $case->id,
|
||||
'path' => $this->copyFixture('gallery-'.$caseData['slug'].'-'.$index.'.jpg', 'content/portfolio/'.$caseData['slug'].'-gallery-'.$index.'.jpg'),
|
||||
'path' => $this->copyFixture('gallery.jpg', 'content/portfolio/'.$caseData['slug'].'-gallery-'.$index.'.jpg'),
|
||||
'alt_text' => 'Galeria '.$caseData['title'].' '.$index,
|
||||
'caption' => $index === 1 ? 'Detalhe da decoração' : null,
|
||||
'sort_order' => $index,
|
||||
@@ -174,10 +171,8 @@ class ContentSeeder extends Seeder
|
||||
|
||||
private function copyFixture(string $fixtureName, string $destination): string
|
||||
{
|
||||
$source = base_path('database/fixtures/images/'.$fixtureName);
|
||||
$disk = PublicImageUploadRules::disk();
|
||||
|
||||
Storage::disk($disk)->put($destination, File::get($source), 'public');
|
||||
$source = base_path('tests/fixtures/images/'.$fixtureName);
|
||||
Storage::disk('public')->put($destination, File::get($source));
|
||||
|
||||
return $destination;
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ use App\Models\SiteSetting;
|
||||
use App\Models\Testimonial;
|
||||
use Illuminate\Database\Seeder;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\File;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
/**
|
||||
@@ -43,8 +44,6 @@ class VisualContentSeeder extends Seeder
|
||||
'hero_secondary_cta_label' => 'Conheça nosso olhar',
|
||||
'hero_note' => 'Planejamento cuidadoso, comunicação clara e execução segura — do primeiro encontro ao último detalhe.',
|
||||
'about_summary' => 'Assessoria boutique especializada em experiências memoráveis em São Paulo.',
|
||||
'about_image_path' => $this->writeSolidJpeg('visual/about/about-image.jpg', 1200, 900, [232, 228, 218]),
|
||||
'about_image_alt' => 'Imagem editorial da página Sobre',
|
||||
'manifesto_title' => 'Sofisticação que também se traduz em organização.',
|
||||
'manifesto_lead' => 'Um evento memorável não nasce apenas de uma boa estética. Ele depende de decisões bem conduzidas, fornecedores alinhados e atenção constante ao que realmente importa.',
|
||||
'manifesto_body' => 'A Amare combina sensibilidade e precisão para criar encontros coerentes com cada cliente, marca e ocasião — sem fórmulas prontas, excessos ou ruído.',
|
||||
@@ -59,7 +58,7 @@ class VisualContentSeeder extends Seeder
|
||||
],
|
||||
'default_meta_title' => 'Amare Assessoria de Eventos',
|
||||
'default_meta_description' => 'Assessoria premium para casamentos e eventos corporativos em São Paulo.',
|
||||
'default_og_image_path' => $this->writeSolidJpeg('visual/og/og-default.jpg', 1600, 1067, [85, 107, 47]),
|
||||
'default_og_image_path' => $this->copyFixture('og-default.jpg', 'visual/og/og-default.jpg'),
|
||||
'default_og_image_alt' => 'Identidade visual da Amare Assessoria de Eventos',
|
||||
'analytics_enabled' => false,
|
||||
'analytics_script' => null,
|
||||
@@ -90,7 +89,7 @@ class VisualContentSeeder extends Seeder
|
||||
['slug' => $service['slug']],
|
||||
[
|
||||
...$service,
|
||||
'cover_image_path' => $this->writeSolidJpeg('visual/services/'.$service['slug'].'.jpg', 1600, 1000, [196, 200, 184]),
|
||||
'cover_image_path' => $this->copyFixture('service-cover.jpg', 'visual/services/'.$service['slug'].'.jpg'),
|
||||
'cover_image_alt' => 'Capa do serviço '.$service['title'],
|
||||
'published_at' => Carbon::parse(self::SEED_TIMESTAMP),
|
||||
],
|
||||
@@ -134,7 +133,7 @@ class VisualContentSeeder extends Seeder
|
||||
['slug' => $caseData['slug']],
|
||||
[
|
||||
...$caseData,
|
||||
'cover_image_path' => $this->writeSolidJpeg('visual/portfolio/'.$caseData['slug'].'-cover.jpg', 1600, 1200, [240, 238, 233]),
|
||||
'cover_image_path' => $this->copyFixture('portfolio-cover.jpg', 'visual/portfolio/'.$caseData['slug'].'-cover.jpg'),
|
||||
'cover_image_alt' => 'Capa do caso '.$caseData['title'],
|
||||
'is_featured' => true,
|
||||
'published_at' => Carbon::parse(self::SEED_TIMESTAMP),
|
||||
@@ -145,7 +144,7 @@ class VisualContentSeeder extends Seeder
|
||||
|
||||
PortfolioImage::query()->create([
|
||||
'portfolio_case_id' => $case->id,
|
||||
'path' => $this->writeSolidJpeg('visual/portfolio/'.$caseData['slug'].'-gallery-1.jpg', 1600, 1200, [228, 226, 221]),
|
||||
'path' => $this->copyFixture('gallery.jpg', 'visual/portfolio/'.$caseData['slug'].'-gallery-1.jpg'),
|
||||
'alt_text' => 'Galeria '.$caseData['title'].' 1',
|
||||
'caption' => 'Detalhe da decoração',
|
||||
'sort_order' => 1,
|
||||
@@ -187,21 +186,10 @@ class VisualContentSeeder extends Seeder
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array{0: int, 1: int, 2: int} $rgb
|
||||
*/
|
||||
private function writeSolidJpeg(string $destination, int $width, int $height, array $rgb): string
|
||||
private function copyFixture(string $fixtureName, string $destination): string
|
||||
{
|
||||
$image = imagecreatetruecolor($width, $height);
|
||||
$color = imagecolorallocate($image, $rgb[0], $rgb[1], $rgb[2]);
|
||||
imagefilledrectangle($image, 0, 0, $width, $height, $color);
|
||||
|
||||
ob_start();
|
||||
imagejpeg($image, null, 90);
|
||||
$binary = (string) ob_get_clean();
|
||||
imagedestroy($image);
|
||||
|
||||
Storage::disk('public')->put($destination, $binary);
|
||||
$source = base_path('tests/fixtures/images/'.$fixtureName);
|
||||
Storage::disk('public')->put($destination, File::get($source));
|
||||
|
||||
return $destination;
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ services:
|
||||
restart: "no"
|
||||
env_file:
|
||||
- .env
|
||||
command: ["sh", "-c", "php artisan migrate --force --no-interaction && php artisan db:seed --class=ContentSeeder --force; php artisan media:generate-variants --force; true"]
|
||||
command: ["php", "artisan", "migrate", "--force", "--no-interaction"]
|
||||
networks:
|
||||
- dokploy-network
|
||||
|
||||
|
||||
|
Before Width: | Height: | Size: 170 KiB |
|
Before Width: | Height: | Size: 155 KiB |
|
Before Width: | Height: | Size: 137 KiB |
|
Before Width: | Height: | Size: 192 KiB |
@@ -1,2 +0,0 @@
|
||||
schema: spec-driven
|
||||
created: 2026-08-06
|
||||
@@ -1,51 +0,0 @@
|
||||
## Context
|
||||
|
||||
O site público usa Blade/Tailwind e já contém um runtime pequeno em `resources/js/motion.js`: abertura focal da home, `page-open`, reveals com `IntersectionObserver` e progresso do índice. A cobertura e a marcação são parciais, os reveals não têm grupos/direções explícitas e a matriz browser se concentra nas quatro páginas visuais originais. A mudança cruza templates, CSS, JavaScript e testes, mas não envolve estado de aplicação, dados ou dependências.
|
||||
|
||||
## Goals / Non-Goals
|
||||
|
||||
**Goals:**
|
||||
|
||||
- Tornar a coreografia Heritage Editorial consistente em toda página pública visual.
|
||||
- Manter texto legível durante toda entrada, animando somente `transform` e recorte de mídia.
|
||||
- Garantir enhancement progressivo: estado final imediato sem JavaScript, observer ou com movimento reduzido.
|
||||
- Preservar interação, foco, validação e ausência de overflow em desktop/mobile.
|
||||
- Cobrir marcação, comportamento real, acessibilidade, console e capturas determinísticas.
|
||||
|
||||
**Non-Goals:**
|
||||
|
||||
- Não criar transições de rota, parallax, animação contínua ou repetição ao voltar no scroll.
|
||||
- Não mudar copy, dados de clientes, estrutura de layout, CMS, controllers, banco, APIs ou Filament.
|
||||
- Não adicionar biblioteca de animação nem itens de SPEC.md §4.2.
|
||||
|
||||
## Decisions
|
||||
|
||||
1. **Contrato declarativo em atributos `data-*`.** Templates usarão `data-motion="page-open"`, `data-motion-beat`, `data-reveal-group`, `data-reveal` e `data-reveal-from="up|left|right"`. CSS define o estado visual e JS apenas ativa classes. Alternativa rejeitada: acoplar seletores a classes de layout, por tornar a coreografia frágil a ajustes visuais.
|
||||
|
||||
2. **Enhancement opt-in pelo elemento raiz.** O HTML inicial permanece no estado final; o runtime adiciona `html[data-motion="enhance"]` somente quando movimento é permitido e os recursos necessários existem. Sem JS, sem `IntersectionObserver`, ou em `reduce`, o atributo não é aplicado/removido e todo conteúdo fica final. Alternativa rejeitada: estado inicial oculto no HTML/CSS, pois pode prender conteúdo fora da tela.
|
||||
|
||||
3. **Transformação sem fade de texto.** Entradas duram cerca de 500 ms com `cubic-bezier(0.16, 1, 0.3, 1)`, deslocamento vertical de 12 px e lateral de 24 px no desktop/16 px no mobile. Grupos recebem stagger de 90 ms limitado a 270 ms. Mídia pode combinar transform com `clip-path`; texto não usa opacidade. Alternativa rejeitada: fade geral, que reduz contraste durante axe e legibilidade percebida.
|
||||
|
||||
4. **Observer de execução única.** Cada reveal intersectado recebe o estado final e é removido do observer. Grupos calculam índice limitado para o delay; depoimentos definem explicitamente `left`/`right` pela coluna, preservando alternância no mobile. Alternativa rejeitada: reanimar em toda rolagem, por criar fadiga e instabilidade.
|
||||
|
||||
5. **Feedback separado das entradas.** CTAs, links, navegação e controles recebem transições curtas (100–200 ms) em cor/transform/borda. `:focus-visible` continua prioritário; alertas e mensagens de erro não entram no contrato de motion. Alternativa rejeitada: feedback via JS, desnecessário para estados CSS.
|
||||
|
||||
6. **Progresso com coalescência por frame.** Eventos de scroll apenas agendam uma atualização por `requestAnimationFrame`; o cálculo existente é preservado. Alternativa rejeitada: calcular em todo evento, que multiplica leituras/escritas durante scroll.
|
||||
|
||||
7. **Testes em camadas.** Feature tests comprovam contratos Blade/CSS/JS e renderização de erros; browser tests comprovam entrada, stagger, direções, reduced motion, fallback, interação, overflow e console. Axe/snapshots passam a incluir sobre, contato, privacidade e 404; demais erros ficam em renderização feature.
|
||||
|
||||
## Risks / Trade-offs
|
||||
|
||||
- [Conteúdo pisca entre estado final e início do enhancement] → inicializar no primeiro módulo Vite, limitar transformações a distâncias pequenas e nunca ocultar texto.
|
||||
- [Transforms laterais causam overflow horizontal] → limitar distância por breakpoint, manter recorte no contêiner público e testar `scrollWidth` em ambos viewports.
|
||||
- [Snapshots ficam instáveis] → continuar capturando com `reducedMotion: reduce` e transições desabilitadas.
|
||||
- [Muitos observers/estilos inline] → usar um único observer, custom property de índice limitada e `unobserve` imediato.
|
||||
- [Páginas de erro não carregam o runtime em todos os contextos] → o estado final é o baseline seguro; feature tests cobrem os cinco templates.
|
||||
|
||||
## Migration Plan
|
||||
|
||||
Publicar CSS, JS e templates no mesmo bundle/commit; não há migração de dados. Rollback consiste em reverter esses assets e atributos, sem compatibilidade de schema ou limpeza operacional.
|
||||
|
||||
## Open Questions
|
||||
|
||||
Nenhuma; direção, alternância mobile, execução única e limites de escopo foram aprovados no plano.
|
||||
@@ -1,31 +0,0 @@
|
||||
## Why
|
||||
|
||||
O site público já possui uma abertura focal na home, mas o restante da experiência ainda muda de estado de forma desigual entre páginas e seções. WEB-01, WEB-07 e os requisitos transversais de acessibilidade pedem uma coreografia editorial consistente, progressiva e dispensável, sem ocultar conteúdo nem comprometer interação, performance ou movimento reduzido.
|
||||
|
||||
## What Changes
|
||||
|
||||
- Evoluir o contrato de motion do site público com abertura de página, sequência editorial inicial e reveals direcionais executados uma única vez.
|
||||
- Aplicar a coreografia à home, serviços, portfólio, detalhe, sobre, contato, privacidade e páginas de erro 404/419/429/500/503.
|
||||
- Alternar depoimentos entre esquerda e direita, filtrando citações vazias antes de renderizar a sequência.
|
||||
- Adicionar feedback curto e não bloqueante a links, CTAs, navegação e controles de formulário, preservando foco e mensagens de validação.
|
||||
- Garantir estado final imediato sem JavaScript, sem `IntersectionObserver` e com `prefers-reduced-motion: reduce`.
|
||||
- Limitar o progresso/índice da home a uma atualização por frame e ampliar testes de marcação, browser, acessibilidade, console e regressão visual.
|
||||
- Não objetivos: não criar transições entre rotas, alterar conteúdo ou layout estrutural, adicionar biblioteca de animação, modificar CMS/API/banco/Filament, nem introduzir itens fora do MVP listados em SPEC.md §4.2.
|
||||
|
||||
## Capabilities
|
||||
|
||||
### New Capabilities
|
||||
|
||||
- `public-site-motion`: Coreografia progressiva, direções, timings, execução única, microfeedback, performance e fallbacks do motion público.
|
||||
|
||||
### Modified Capabilities
|
||||
|
||||
- `design-tokens`: Especificar tokens compartilhados de duração, stagger, distância e easing para motion editorial.
|
||||
- `public-site-pages`: Cobrir todas as páginas públicas visuais e templates de erro com o contrato compartilhado de abertura e reveal.
|
||||
- `testimonials`: Filtrar citações vazias e alternar explicitamente a direção de entrada dos depoimentos renderizados.
|
||||
- `visual-regression`: Ampliar a matriz visual para sobre, contato, privacidade e 404, mantendo capturas determinísticas sem animação.
|
||||
- `web-accessibility`: Ampliar axe/console e validar conteúdo imediatamente utilizável com movimento reduzido ou enhancement indisponível.
|
||||
|
||||
## Impact
|
||||
|
||||
Afeta somente templates Blade públicos, `resources/js/motion.js`, tokens/regras CSS e testes Pest/Pest Browser. Não altera APIs, banco, controllers, CMS, dependências, endpoints técnicos (`sitemap.xml`, `robots.txt`) ou Filament.
|
||||
@@ -1,9 +0,0 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Editorial motion tokens are centralized
|
||||
|
||||
The system SHALL define shared public motion tokens for an approximately 500 ms entrance, short interaction feedback, exponential ease-out, 90 ms stagger capped at 270 ms, 12 px vertical distance, and 24 px desktop / 16 px mobile lateral distance. Public motion CSS MUST consume these tokens instead of duplicating arbitrary values.
|
||||
|
||||
#### Scenario: Motion runtime uses shared values
|
||||
- **WHEN** page openings, reveals, or interaction feedback are styled
|
||||
- **THEN** duration, easing, stagger, and distance MUST be derived from centralized tokens
|
||||
@@ -1,74 +0,0 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Public pages use shared progressive motion choreography
|
||||
|
||||
The public site SHALL expose a declarative motion contract using `data-motion="page-open"`, `data-motion-beat`, `data-reveal-group`, `data-reveal`, and `data-reveal-from="up|left|right"`. Home, services, portfolio index/detail, about, contact, privacy, and branded 404/419/429/500/503 pages MUST use the contract without introducing route transitions.
|
||||
|
||||
#### Scenario: Visual public route exposes motion hooks
|
||||
- **WHEN** a visitor loads any visual public route or branded error page
|
||||
- **THEN** the rendered page MUST expose a page opening hook and appropriate reveal hooks
|
||||
|
||||
#### Scenario: Technical endpoints remain outside motion
|
||||
- **WHEN** a visitor requests `sitemap.xml` or `robots.txt`
|
||||
- **THEN** the response MUST NOT depend on the public motion runtime
|
||||
|
||||
### Requirement: Editorial entrance timing and direction are consistent
|
||||
|
||||
Entrances SHALL use shared tokens for an approximately 500 ms duration, 90 ms stagger capped at 270 ms, 12 px vertical distance, and 24 px desktop / 16 px mobile lateral distance. Text MUST remain fully visible; motion MUST use transforms and MAY use media clipping without opacity fades for text.
|
||||
|
||||
#### Scenario: Reveal group computes capped stagger
|
||||
- **WHEN** more than four reveal items are rendered in one group
|
||||
- **THEN** the applied delay MUST NOT exceed 270 ms
|
||||
|
||||
#### Scenario: Text enters without opacity fade
|
||||
- **WHEN** page opening or scroll reveal motion runs
|
||||
- **THEN** text MUST remain visible throughout the transition
|
||||
|
||||
### Requirement: Reveals execute once without blocking interaction
|
||||
|
||||
Each scroll reveal SHALL activate once, reach its final state, and be removed from observation. Visitors MUST be able to activate links and form controls while entrance motion is running.
|
||||
|
||||
#### Scenario: Revealed content leaves observer
|
||||
- **WHEN** a reveal target intersects the configured viewport threshold
|
||||
- **THEN** it MUST receive the final state and be unobserved
|
||||
- **AND** scrolling away and back MUST NOT replay the reveal
|
||||
|
||||
#### Scenario: CTA remains interactive during entrance
|
||||
- **WHEN** a visitor activates a CTA while its entrance is in progress
|
||||
- **THEN** navigation MUST proceed without waiting for animation completion
|
||||
|
||||
### Requirement: Motion enhancement has immediate safe fallbacks
|
||||
|
||||
Without JavaScript, without `IntersectionObserver`, or when `prefers-reduced-motion: reduce` is active, the site SHALL render every motion target immediately in its final usable state. Non-essential animation and transition MUST NOT run under reduced motion.
|
||||
|
||||
#### Scenario: JavaScript is unavailable
|
||||
- **WHEN** a public page renders without executing JavaScript
|
||||
- **THEN** all content MUST be visible and usable in its final state
|
||||
|
||||
#### Scenario: IntersectionObserver is unavailable
|
||||
- **WHEN** JavaScript runs but `IntersectionObserver` is not supported
|
||||
- **THEN** all reveal targets MUST remain in their final state
|
||||
|
||||
#### Scenario: Reduced motion is preferred
|
||||
- **WHEN** the browser reports `prefers-reduced-motion: reduce`
|
||||
- **THEN** page openings, reveals, media clips, and non-essential feedback transitions MUST NOT run
|
||||
|
||||
### Requirement: Public interaction feedback is short and accessible
|
||||
|
||||
CTAs, links, navigation, and form controls SHALL provide short feedback using shared tokens while preserving visible `:focus-visible` indication. Alerts and validation messages MUST NOT be animated by this contract.
|
||||
|
||||
#### Scenario: Keyboard focus remains visible
|
||||
- **WHEN** a visitor focuses an interactive element with the keyboard
|
||||
- **THEN** its focus indicator MUST remain visible and MUST NOT be displaced by motion feedback
|
||||
|
||||
#### Scenario: Validation message appears without motion
|
||||
- **WHEN** server validation renders an error message
|
||||
- **THEN** the message MUST appear in its final position without entrance animation
|
||||
|
||||
### Requirement: Home progress updates at most once per animation frame
|
||||
|
||||
Scroll-driven chapter index and progress updates on the home SHALL be coalesced through `requestAnimationFrame`, with at most one pending update per frame.
|
||||
|
||||
#### Scenario: Multiple scroll events occur in one frame
|
||||
- **WHEN** multiple scroll events fire before the next animation frame
|
||||
- **THEN** the home progress calculation MUST execute only once for that frame
|
||||
@@ -1,14 +0,0 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Visual public and error pages expose the shared motion contract
|
||||
|
||||
The system SHALL apply the shared progressive opening and reveal contract to home, services, portfolio index/detail, about, contact, privacy, and branded 404/419/429/500/503 pages. The contract MUST NOT change page copy, content ordering, route behavior, or structural layout.
|
||||
|
||||
#### Scenario: Institutional page uses shared opening
|
||||
- **WHEN** a visitor loads `/sobre`, `/contato`, or `/privacidade`
|
||||
- **THEN** the page MUST expose the shared page opening hook and section reveal hooks
|
||||
|
||||
#### Scenario: Error page remains branded and progressively enhanced
|
||||
- **WHEN** Laravel renders a branded 404, 419, 429, 500, or 503 response
|
||||
- **THEN** the page MUST expose the shared opening hook
|
||||
- **AND** all error guidance and navigation MUST remain immediately usable without enhancement
|
||||
@@ -1,43 +0,0 @@
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: Testimonials are managed with publication control
|
||||
|
||||
The system SHALL allow admins to manage testimonials (SPEC WEB-04) with quote text (including multi-paragraph content), author name, optional context (event type and/or date), optional photo with alt text, sort order, featured flag, and `published_at`. Public rendering MUST preserve paragraph breaks from the stored quote, MUST omit blank/whitespace-only quotes before creating the rendered sequence, and MUST assign alternating `left`/`right` reveal directions according to the remaining rendered column order at desktop and mobile viewports. Testimonials sourced from real clients MUST NOT be published to production without authorization; development seeds MAY include the authorized-pending real quotes marked for review.
|
||||
|
||||
#### Scenario: Unpublished testimonial is excluded
|
||||
|
||||
- **WHEN** a testimonial has `published_at` null
|
||||
- **THEN** the `published()` scope MUST exclude it
|
||||
|
||||
#### Scenario: Published testimonial is queryable
|
||||
|
||||
- **WHEN** an admin sets `published_at` with required quote and author name
|
||||
- **THEN** the testimonial MUST be included in the `published()` scope
|
||||
|
||||
#### Scenario: Assistant cannot manage testimonials
|
||||
|
||||
- **WHEN** an assistant attempts to access the testimonials Resource
|
||||
- **THEN** access MUST be denied with HTTP 403
|
||||
|
||||
#### Scenario: Featured testimonials are filterable
|
||||
|
||||
- **WHEN** content is queried with featured filter
|
||||
- **THEN** records with `is_featured` true MUST be retrievable independently of sort order
|
||||
|
||||
#### Scenario: Multi-paragraph quotes render as paragraphs
|
||||
|
||||
- **GIVEN** a published testimonial whose quote contains blank-line separated paragraphs
|
||||
- **WHEN** the home testimonials section is rendered
|
||||
- **THEN** each paragraph MUST appear as distinct block text rather than a single collapsed line
|
||||
|
||||
#### Scenario: Blank quotes do not affect alternation
|
||||
|
||||
- **GIVEN** the published testimonial collection contains a blank quote between two non-blank quotes
|
||||
- **WHEN** the home testimonials section is rendered
|
||||
- **THEN** the blank testimonial MUST be omitted
|
||||
- **AND** the two rendered testimonials MUST receive alternating `left` and `right` directions based on their rendered order
|
||||
|
||||
#### Scenario: Direction alternation remains on mobile
|
||||
|
||||
- **WHEN** testimonials are viewed at a mobile viewport
|
||||
- **THEN** their explicit `left` and `right` directions MUST be preserved with the reduced mobile distance
|
||||
@@ -1,26 +0,0 @@
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: Public screens have desktop and mobile visual baselines
|
||||
|
||||
The system SHALL keep versioned screenshot baselines for the public screens available in this phase (SPEC §13.5): Home, Serviços, Portfólio, Detalhe do portfólio, Sobre, Contato, Privacidade and branded 404, at 1440×1000 desktop and 390×844 mobile, under the Heritage Editorial identity. A rendering change that alters those screens MUST fail the browser suite until the diff is reviewed and baselines are explicitly updated.
|
||||
|
||||
#### Scenario: Unintended visual change fails the suite
|
||||
|
||||
- **GIVEN** approved baselines exist
|
||||
- **WHEN** a code change alters the rendering of a covered screen
|
||||
- **THEN** the visual assertion MUST fail and report the diff
|
||||
|
||||
#### Scenario: Both viewports are covered
|
||||
|
||||
- **WHEN** the visual suite runs
|
||||
- **THEN** each covered screen MUST be asserted at 1440×1000 and 390×844
|
||||
|
||||
#### Scenario: Heritage Editorial identity is captured
|
||||
|
||||
- **WHEN** approved baselines for the home are reviewed after this change
|
||||
- **THEN** they MUST reflect EB Garamond typography, olive/paper palette and sharp-edged editorial layout rather than the previous gold/rounded placeholder look
|
||||
|
||||
#### Scenario: Motion does not destabilize new baselines
|
||||
|
||||
- **WHEN** Sobre, Contato, Privacidade, or 404 is captured
|
||||
- **THEN** the browser MUST use the final reduced-motion state before asserting the screenshot
|
||||
@@ -1,51 +0,0 @@
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: Public routes have no critical or serious accessibility issues
|
||||
|
||||
The system SHALL run automated accessibility checks on the public routes covered by the browser suite (SPEC §6.5, §13.8) after the Heritage Editorial motion enhancement. A critical or serious issue MUST fail the suite.
|
||||
|
||||
#### Scenario: Critical issue blocks the suite
|
||||
|
||||
- **WHEN** the automated accessibility check reports a critical or serious issue on a covered route
|
||||
- **THEN** the browser suite MUST fail and report the offending rule and selector
|
||||
|
||||
#### Scenario: Covered routes are checked
|
||||
|
||||
- **WHEN** the accessibility suite runs
|
||||
- **THEN** home, services listing, portfolio listing, case detail, about, contact, privacy and branded 404 MUST each be checked
|
||||
|
||||
### Requirement: Reduced motion preference is honored
|
||||
|
||||
The system SHALL suppress non-essential animation and transition when the user agent reports `prefers-reduced-motion: reduce`, including page openings, editorial reveals, image hover scales, interaction feedback and menu transitions. All affected content MUST render in its final visible and interactive state.
|
||||
|
||||
#### Scenario: Reduced motion disables transitions
|
||||
|
||||
- **GIVEN** the browser reports `prefers-reduced-motion: reduce`
|
||||
- **WHEN** a public page is loaded
|
||||
- **THEN** decorative transitions and animations MUST NOT run
|
||||
- **AND** every motion target MUST be in its final visible and interactive state
|
||||
|
||||
### Requirement: Public pages emit no console errors
|
||||
|
||||
Covered public routes SHALL load without JavaScript console errors in a real browser (SPEC §13.8, §19), including home, services listing, portfolio listing, case detail, about, contact, privacy, branded 404, and pages that load the mobile navigation or motion runtime.
|
||||
|
||||
#### Scenario: Console stays clean on covered routes
|
||||
|
||||
- **WHEN** a covered public route is loaded in the browser suite
|
||||
- **THEN** the console MUST contain no error-level messages
|
||||
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Motion enhancement failure does not hide or disable content
|
||||
|
||||
The public site SHALL remain readable, keyboard-operable, and interactive when JavaScript is disabled or `IntersectionObserver` is unavailable. Motion targets MUST default to their final state without relying on timeout recovery.
|
||||
|
||||
#### Scenario: JavaScript disabled keeps public page usable
|
||||
|
||||
- **WHEN** a visitor loads a public page with JavaScript disabled
|
||||
- **THEN** headings, copy, navigation, CTAs, and form controls MUST be visible and operable
|
||||
|
||||
#### Scenario: Observer unavailable keeps reveals final
|
||||
|
||||
- **WHEN** the motion runtime executes without `IntersectionObserver`
|
||||
- **THEN** reveal targets MUST remain in the final state
|
||||
@@ -1,24 +0,0 @@
|
||||
## 1. Motion contract and runtime
|
||||
|
||||
- [x] 1.1 Extend feature tests first for motion tokens, declarative hooks, no-opacity text, safe fallbacks, one-shot observer, capped stagger and requestAnimationFrame coalescing
|
||||
- [x] 1.2 Add centralized motion tokens and CSS states for page openings, grouped directional reveals, media clips and accessible interaction feedback
|
||||
- [x] 1.3 Refactor `resources/js/motion.js` to enhance only when supported, initialize page beats/groups, unobserve revealed targets and coalesce progress updates per frame
|
||||
|
||||
## 2. Public template coverage
|
||||
|
||||
- [x] 2.1 Add failing markup coverage for home, services, portfolio index/detail, about, contact, privacy and branded 404/419/429/500/503 pages
|
||||
- [x] 2.2 Apply page-open, beat, reveal-group and directional reveal hooks across all visual public templates without changing content or layout structure
|
||||
- [x] 2.3 Add failing testimonial coverage, filter blank quotes before the rendered sequence and alternate explicit left/right directions on desktop and mobile
|
||||
|
||||
## 3. Browser and accessibility coverage
|
||||
|
||||
- [x] 3.1 Add browser tests for real opening/reveal state, 90 ms capped stagger, one-shot observation and interaction during motion
|
||||
- [x] 3.2 Add desktop/mobile tests for directional distance, reduced motion final state, no-JS/observer fallback, horizontal overflow and clean console
|
||||
- [x] 3.3 Extend axe and console route matrices to about, contact, privacy and branded 404
|
||||
- [x] 3.4 Extend deterministic desktop/mobile visual snapshots to about, contact, privacy and branded 404 and review the generated diffs
|
||||
|
||||
## 4. Verification and delivery
|
||||
|
||||
- [x] 4.1 Run focused feature/browser tests, build, Pint, isolated-cache PHPStan and strict OpenSpec validation
|
||||
- [x] 4.2 Run `composer quality` and visually review desktop/mobile with normal and reduced motion
|
||||
- [ ] 4.3 Stage only scoped files, commit conventionally, open a PR with screenshots and verification evidence, watch CI to green, merge and remove the worktree
|
||||
@@ -2,7 +2,7 @@ schema: spec-driven
|
||||
|
||||
context: |
|
||||
Fonte de verdade: SPEC.md na raiz. Precedência: instrução do dono do produto > SPEC.md > ADRs > testes > convenções.
|
||||
Produto: plataforma de assessoria de eventos, single-tenant, MVP. UI em pt-BR, timezone America/Sao_Paulo, atuação em São Paulo (capital), BRL.
|
||||
Produto: plataforma de assessoria de eventos, single-tenant, MVP. UI em pt-BR, timezone America/Fortaleza, BRL.
|
||||
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.
|
||||
Arquitetura: monólito modular. Interface -> Application (Actions/Queries) -> Domain (Enums/VOs) -> Infrastructure.
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# container-runtime Specification
|
||||
|
||||
## Purpose
|
||||
Define the production container image for the application: a reproducible multi-stage FrankenPHP build serving the public directory, run in regular mode as a non-root user, shared by web/queue/scheduler processes, with no secrets in layers and a healthcheck on `/up`.
|
||||
TBD - created by archiving change setup-foundation. Update Purpose after archive.
|
||||
## Requirements
|
||||
### Requirement: Production image uses multi-stage FrankenPHP build
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# design-tokens Specification
|
||||
|
||||
## Purpose
|
||||
Centralize the public site's design tokens so the interface implements the Heritage Editorial system from DESIGN.md (EB Garamond, 8px rhythm, zero radius, 1120px container, paper/olive/sage/ink palette), honoring reduced motion and WCAG AA contrast without card-shadow hierarchy.
|
||||
TBD - created by archiving change setup-foundation. Update Purpose after archive.
|
||||
## Requirements
|
||||
### Requirement: Design tokens are centralized for the public site
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# health-check Specification
|
||||
|
||||
## Purpose
|
||||
Expose a public `GET /up` healthcheck that responds quickly and without authentication, exposes no secrets, and fails when the application cannot boot, so orchestrators and CI can verify availability.
|
||||
TBD - created by archiving change setup-foundation. Update Purpose after archive.
|
||||
## Requirements
|
||||
### Requirement: Public health endpoint responds without authentication
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# internal-authentication Specification
|
||||
|
||||
## Purpose
|
||||
Provide session-based authentication for the internal Filament panel at `/admin`, limited to two roles (admin and assistant), with unique emails, password reset without enumeration, inactive users denied, and user management restricted to admins.
|
||||
TBD - created by archiving change setup-foundation. Update Purpose after archive.
|
||||
## Requirements
|
||||
### Requirement: Internal users authenticate via Filament panel
|
||||
|
||||
|
||||
@@ -18,13 +18,6 @@
|
||||
--text-3xl: var(--amare-text-3xl);
|
||||
--text-4xl: var(--amare-text-4xl);
|
||||
|
||||
--text-display: clamp(3rem, 7.7vw, 5.875rem);
|
||||
--text-display--line-height: 0.94;
|
||||
--text-display--letter-spacing: -0.01em;
|
||||
|
||||
--text-headline: clamp(2.375rem, 5.2vw, 4rem);
|
||||
--text-headline--line-height: 1.02;
|
||||
|
||||
--spacing-1: var(--amare-space-1);
|
||||
--spacing-2: var(--amare-space-2);
|
||||
--spacing-3: var(--amare-space-3);
|
||||
@@ -66,7 +59,6 @@
|
||||
background-color: var(--amare-color-bg);
|
||||
color: var(--amare-color-text);
|
||||
font-family: var(--amare-font-serif);
|
||||
overflow-wrap: break-word;
|
||||
}
|
||||
|
||||
[id$='-heading'] {
|
||||
@@ -109,43 +101,6 @@
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.home-chapters {
|
||||
counter-reset: home-chapter -1;
|
||||
}
|
||||
|
||||
.home-chapter {
|
||||
counter-increment: home-chapter;
|
||||
}
|
||||
|
||||
.home-folio {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
color: var(--amare-color-accent);
|
||||
font-size: var(--amare-text-xs);
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.14em;
|
||||
line-height: 1;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.home-folio--inverse {
|
||||
color: color-mix(in srgb, var(--amare-color-accent-text) 82%, transparent);
|
||||
}
|
||||
|
||||
.home-folio__separator {
|
||||
color: var(--amare-color-sage);
|
||||
}
|
||||
|
||||
.home-folio--inverse .home-folio__separator {
|
||||
color: color-mix(in srgb, var(--amare-color-accent-text) 55%, transparent);
|
||||
}
|
||||
|
||||
.home-folio__number::before {
|
||||
content: counter(home-chapter, decimal-leading-zero);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
.main-nav {
|
||||
transition: opacity var(--amare-duration-normal) var(--amare-ease-standard);
|
||||
@@ -156,10 +111,6 @@
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
main {
|
||||
overflow-x: clip;
|
||||
}
|
||||
|
||||
/* Dossiê vivo — content visible by default; enhance only when opted in */
|
||||
[data-chapter-index] {
|
||||
display: none;
|
||||
@@ -217,7 +168,6 @@
|
||||
text-transform: uppercase;
|
||||
color: var(--amare-color-muted);
|
||||
text-decoration: none;
|
||||
overflow-wrap: break-word;
|
||||
transition: color var(--amare-duration-fast) var(--amare-ease-standard);
|
||||
}
|
||||
|
||||
@@ -240,81 +190,60 @@
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
:where(a, button, input, textarea, select) {
|
||||
transition-duration: var(--amare-motion-feedback);
|
||||
transition-property: color, background-color, border-color, transform;
|
||||
transition-timing-function: var(--amare-ease-arrival);
|
||||
}
|
||||
|
||||
:where(a, button:not(:disabled)):active {
|
||||
transform: translateY(1px);
|
||||
}
|
||||
|
||||
/* Transform/clip only — never fade text opacity (axe + WCAG mid-transition). */
|
||||
html[data-motion="enhance"] [data-motion="page-open"]:not(.is-active) [data-motion-beat] {
|
||||
transform: translateY(var(--amare-motion-distance-y));
|
||||
html[data-motion="enhance"] [data-motion="dossie-hero"] [data-motion-beat="seal"],
|
||||
html[data-motion="enhance"] [data-motion="dossie-hero"] [data-motion-beat="cta"] {
|
||||
transform: translateY(0.5rem);
|
||||
}
|
||||
|
||||
html[data-motion="enhance"] [data-motion="page-open"]:not(.is-active) [data-motion-beat="media"] {
|
||||
html[data-motion="enhance"] [data-motion="dossie-hero"] [data-motion-beat="title"] {
|
||||
transform: translateY(0.4rem);
|
||||
}
|
||||
|
||||
html[data-motion="enhance"] [data-motion="dossie-hero"] [data-motion-beat="media"] {
|
||||
clip-path: inset(4% 0 0 0);
|
||||
}
|
||||
|
||||
html[data-motion="enhance"] [data-motion="page-open"].is-active [data-motion-beat] {
|
||||
html[data-motion="enhance"] [data-motion="dossie-hero"].is-active [data-motion-beat] {
|
||||
transform: none;
|
||||
clip-path: inset(0 0 0 0);
|
||||
transition:
|
||||
transform var(--amare-motion-enter) var(--amare-ease-arrival),
|
||||
clip-path var(--amare-motion-enter) var(--amare-ease-arrival);
|
||||
transition-delay: min(
|
||||
calc(var(--motion-index, 0) * var(--amare-motion-stagger)),
|
||||
var(--amare-motion-stagger-cap)
|
||||
);
|
||||
transform var(--amare-duration-slow) var(--amare-ease-arrival),
|
||||
clip-path var(--amare-duration-focal) var(--amare-ease-arrival);
|
||||
}
|
||||
|
||||
html[data-motion="enhance"] [data-reveal] {
|
||||
--motion-translate-x: 0;
|
||||
--motion-translate-y: var(--amare-motion-distance-y);
|
||||
html[data-motion="enhance"] [data-motion="dossie-hero"].is-active [data-motion-beat="seal"] {
|
||||
transition-delay: 0ms;
|
||||
}
|
||||
|
||||
html[data-motion="enhance"] [data-reveal-from="left"] {
|
||||
--motion-translate-x: calc(-1 * var(--amare-motion-distance-x-mobile));
|
||||
--motion-translate-y: 0;
|
||||
html[data-motion="enhance"] [data-motion="dossie-hero"].is-active [data-motion-beat="title"] {
|
||||
transition-delay: 80ms;
|
||||
}
|
||||
|
||||
html[data-motion="enhance"] [data-reveal-from="right"] {
|
||||
--motion-translate-x: var(--amare-motion-distance-x-mobile);
|
||||
--motion-translate-y: 0;
|
||||
html[data-motion="enhance"] [data-motion="dossie-hero"].is-active [data-motion-beat="media"] {
|
||||
transition-delay: 120ms;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
html[data-motion="enhance"] [data-reveal-from="left"] {
|
||||
--motion-translate-x: calc(-1 * var(--amare-motion-distance-x));
|
||||
}
|
||||
|
||||
html[data-motion="enhance"] [data-reveal-from="right"] {
|
||||
--motion-translate-x: var(--amare-motion-distance-x);
|
||||
}
|
||||
html[data-motion="enhance"] [data-motion="dossie-hero"].is-active [data-motion-beat="cta"] {
|
||||
transition-delay: 220ms;
|
||||
}
|
||||
|
||||
html[data-motion="enhance"] [data-reveal]:not(.is-revealed) {
|
||||
transform: translate3d(var(--motion-translate-x), var(--motion-translate-y), 0);
|
||||
transform: translateY(0.5rem);
|
||||
}
|
||||
|
||||
html[data-motion="enhance"] [data-reveal][data-reveal-media]:not(.is-revealed) {
|
||||
clip-path: inset(3% 0 0 0);
|
||||
}
|
||||
|
||||
html[data-motion="enhance"] [data-reveal-group] [data-reveal].is-revealed,
|
||||
html[data-motion="enhance"] [data-reveal].is-revealed {
|
||||
transform: none;
|
||||
clip-path: inset(0 0 0 0);
|
||||
transition:
|
||||
transform var(--amare-motion-enter) var(--amare-ease-arrival),
|
||||
clip-path var(--amare-motion-enter) var(--amare-ease-arrival);
|
||||
transition-delay: min(
|
||||
calc(var(--motion-index, 0) * var(--amare-motion-stagger)),
|
||||
var(--amare-motion-stagger-cap)
|
||||
);
|
||||
transition: transform var(--amare-duration-slow) var(--amare-ease-arrival);
|
||||
}
|
||||
|
||||
html[data-motion="enhance"] [data-motion="page-open"]:not(.is-active) {
|
||||
transform: translateY(0.4rem);
|
||||
}
|
||||
|
||||
html[data-motion="enhance"] [data-motion="page-open"].is-active {
|
||||
transform: none;
|
||||
transition: transform var(--amare-duration-normal) var(--amare-ease-arrival);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -59,15 +59,6 @@
|
||||
--amare-duration-focal: 720ms;
|
||||
--amare-ease-standard: cubic-bezier(0.4, 0, 0.2, 1);
|
||||
--amare-ease-arrival: cubic-bezier(0.16, 1, 0.3, 1);
|
||||
|
||||
/* Editorial motion */
|
||||
--amare-motion-enter: 500ms;
|
||||
--amare-motion-feedback: 150ms;
|
||||
--amare-motion-stagger: 90ms;
|
||||
--amare-motion-stagger-cap: 270ms;
|
||||
--amare-motion-distance-y: 12px;
|
||||
--amare-motion-distance-x: 24px;
|
||||
--amare-motion-distance-x-mobile: 16px;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
@@ -76,13 +67,6 @@
|
||||
--amare-duration-normal: 0.01ms;
|
||||
--amare-duration-slow: 0.01ms;
|
||||
--amare-duration-focal: 0.01ms;
|
||||
--amare-motion-enter: 0.01ms;
|
||||
--amare-motion-feedback: 0.01ms;
|
||||
--amare-motion-stagger: 0ms;
|
||||
--amare-motion-stagger-cap: 0ms;
|
||||
--amare-motion-distance-y: 0px;
|
||||
--amare-motion-distance-x: 0px;
|
||||
--amare-motion-distance-x-mobile: 0px;
|
||||
}
|
||||
|
||||
*,
|
||||
|
||||
@@ -6,7 +6,6 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
|
||||
if (menuButton && navigation) {
|
||||
const isDesktop = () => window.matchMedia('(min-width: 768px)').matches;
|
||||
const isMenuOpen = () => navigation.classList.contains('is-open');
|
||||
|
||||
const setOpen = (isOpen, { returnFocus = false } = {}) => {
|
||||
navigation.classList.toggle('is-open', isOpen);
|
||||
@@ -40,27 +39,6 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
navigation.addEventListener('keydown', (event) => {
|
||||
if (event.key === 'Escape') {
|
||||
setOpen(false, { returnFocus: true });
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.key === 'Tab' && !isDesktop() && isMenuOpen()) {
|
||||
const focusables = navigation.querySelectorAll('a[href], button:not([disabled])');
|
||||
|
||||
if (focusables.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const first = focusables[0];
|
||||
const last = focusables[focusables.length - 1];
|
||||
|
||||
if (event.shiftKey && document.activeElement === first) {
|
||||
event.preventDefault();
|
||||
last.focus();
|
||||
} else if (!event.shiftKey && document.activeElement === last) {
|
||||
event.preventDefault();
|
||||
first.focus();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -75,33 +53,12 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
}
|
||||
|
||||
document.querySelectorAll('form[data-contact-form]').forEach((form) => {
|
||||
const submitButton = form.querySelector('[data-submit-button]');
|
||||
|
||||
const resetSubmitState = () => {
|
||||
form.removeAttribute('aria-busy');
|
||||
form.removeAttribute('aria-disabled');
|
||||
|
||||
if (submitButton) {
|
||||
submitButton.disabled = false;
|
||||
submitButton.textContent = submitButton.dataset.defaultLabel ?? 'Enviar briefing';
|
||||
submitButton.removeAttribute('aria-busy');
|
||||
}
|
||||
};
|
||||
|
||||
form.addEventListener('submit', () => {
|
||||
if (submitButton) {
|
||||
submitButton.dataset.defaultLabel ??= submitButton.textContent.trim();
|
||||
submitButton.textContent = 'Enviando…';
|
||||
submitButton.disabled = true;
|
||||
submitButton.setAttribute('aria-busy', 'true');
|
||||
}
|
||||
const button = form.querySelector('[data-submit-button]');
|
||||
|
||||
form.setAttribute('aria-busy', 'true');
|
||||
});
|
||||
|
||||
window.addEventListener('pageshow', (event) => {
|
||||
if (event.persisted) {
|
||||
resetSubmitState();
|
||||
if (button) {
|
||||
button.disabled = true;
|
||||
button.setAttribute('aria-busy', 'true');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,7 +5,7 @@ function prefersReducedMotion() {
|
||||
}
|
||||
|
||||
function enableEnhancement() {
|
||||
if (prefersReducedMotion() || typeof IntersectionObserver !== 'function') {
|
||||
if (prefersReducedMotion()) {
|
||||
document.documentElement.removeAttribute('data-motion');
|
||||
return false;
|
||||
}
|
||||
@@ -14,6 +14,24 @@ function enableEnhancement() {
|
||||
return true;
|
||||
}
|
||||
|
||||
function activateHero(enhance) {
|
||||
const hero = document.querySelector('[data-motion="dossie-hero"]');
|
||||
if (!hero) {
|
||||
return;
|
||||
}
|
||||
|
||||
const activate = () => hero.classList.add('is-active');
|
||||
|
||||
if (!enhance) {
|
||||
activate();
|
||||
return;
|
||||
}
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
requestAnimationFrame(activate);
|
||||
});
|
||||
}
|
||||
|
||||
function activatePageOpen(enhance) {
|
||||
document.querySelectorAll('[data-motion="page-open"]').forEach((node) => {
|
||||
if (!enhance) {
|
||||
@@ -27,32 +45,6 @@ function activatePageOpen(enhance) {
|
||||
});
|
||||
}
|
||||
|
||||
function initializeMotionDelays() {
|
||||
const beatOrder = new Map([
|
||||
['seal', 0],
|
||||
['heading', 0],
|
||||
['title', 1],
|
||||
['media', 2],
|
||||
['cta', 3],
|
||||
]);
|
||||
|
||||
document.querySelectorAll('[data-motion="page-open"]').forEach((page) => {
|
||||
page.querySelectorAll('[data-motion-beat]').forEach((beat, index) => {
|
||||
const namedIndex = beatOrder.get(beat.dataset.motionBeat);
|
||||
beat.style.setProperty('--motion-index', String(Math.min(namedIndex ?? index, 3)));
|
||||
});
|
||||
});
|
||||
|
||||
document.querySelectorAll('[data-reveal-group]').forEach((group) => {
|
||||
const nodes = Array.from(group.querySelectorAll('[data-reveal]'))
|
||||
.filter((node) => node.closest('[data-reveal-group]') === group);
|
||||
|
||||
nodes.forEach((node, index) => {
|
||||
node.style.setProperty('--motion-index', String(Math.min(index, 3)));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function observeReveals(enhance) {
|
||||
const nodes = Array.from(document.querySelectorAll('[data-reveal]'));
|
||||
if (nodes.length === 0) {
|
||||
@@ -94,7 +86,6 @@ function setupChapterIndex() {
|
||||
}
|
||||
|
||||
const links = Array.from(index.querySelectorAll('a[href^="#"]'));
|
||||
let framePending = false;
|
||||
|
||||
const setActive = (id) => {
|
||||
links.forEach((link) => {
|
||||
@@ -138,28 +129,16 @@ function setupChapterIndex() {
|
||||
updateProgress(window.scrollY / max);
|
||||
};
|
||||
|
||||
const scheduleSync = () => {
|
||||
if (framePending) {
|
||||
return;
|
||||
}
|
||||
|
||||
framePending = true;
|
||||
requestAnimationFrame(() => {
|
||||
framePending = false;
|
||||
sync();
|
||||
});
|
||||
};
|
||||
|
||||
setActive(chapters[0].getAttribute('aria-labelledby') || 'hero-heading');
|
||||
sync();
|
||||
|
||||
window.addEventListener('scroll', scheduleSync, { passive: true });
|
||||
window.addEventListener('resize', scheduleSync);
|
||||
window.addEventListener('scroll', sync, { passive: true });
|
||||
window.addEventListener('resize', sync);
|
||||
}
|
||||
|
||||
function bootMotion() {
|
||||
initializeMotionDelays();
|
||||
const enhance = enableEnhancement();
|
||||
activateHero(enhance);
|
||||
activatePageOpen(enhance);
|
||||
observeReveals(enhance);
|
||||
setupChapterIndex();
|
||||
@@ -167,7 +146,7 @@ function bootMotion() {
|
||||
window.matchMedia(MOTION_QUERY).addEventListener('change', (event) => {
|
||||
if (event.matches) {
|
||||
document.documentElement.removeAttribute('data-motion');
|
||||
document.querySelectorAll('[data-motion="page-open"]').forEach((node) => {
|
||||
document.querySelectorAll('[data-motion="dossie-hero"], [data-motion="page-open"]').forEach((node) => {
|
||||
node.classList.add('is-active');
|
||||
});
|
||||
document.querySelectorAll('[data-reveal]').forEach((node) => {
|
||||
|
||||
@@ -1,19 +1,11 @@
|
||||
@props([
|
||||
'settings',
|
||||
'editorial' => false,
|
||||
])
|
||||
|
||||
<section aria-labelledby="final-cta-heading" @class([
|
||||
'border-t border-amare-border bg-amare-bg-deep py-20',
|
||||
'home-chapter' => $editorial,
|
||||
]) data-chapter="final-cta" data-reveal-group>
|
||||
<div class="container-amare space-y-6 text-center" data-reveal data-reveal-from="up">
|
||||
@if ($editorial)
|
||||
<x-home.folio label="Próximo passo" class="justify-center" />
|
||||
@else
|
||||
<p class="text-xs font-semibold uppercase tracking-[0.14em] text-amare-accent">Próximo passo</p>
|
||||
@endif
|
||||
<h2 id="final-cta-heading" class="text-headline font-medium text-amare-text">Do casamento ao evento corporativo, tudo começa com uma boa conversa.</h2>
|
||||
<section aria-labelledby="final-cta-heading" class="border-t border-amare-border bg-amare-bg-deep py-20" data-chapter="final-cta">
|
||||
<div class="container-amare space-y-6 text-center" data-reveal>
|
||||
<p class="text-xs font-semibold uppercase tracking-[0.14em] text-amare-accent">Próximo passo</p>
|
||||
<h2 id="final-cta-heading" class="text-3xl font-medium text-amare-text md:text-4xl">Do casamento ao evento corporativo, tudo começa com uma boa conversa.</h2>
|
||||
<p class="mx-auto max-w-2xl text-amare-muted">
|
||||
Compartilhe as primeiras informações do seu evento. A Amare retorna para entender o contexto e orientar os próximos passos.
|
||||
</p>
|
||||
@@ -22,7 +14,7 @@
|
||||
href="{{ route('contact') }}"
|
||||
class="inline-flex min-h-11 items-center bg-amare-accent px-5 py-3 text-sm font-semibold uppercase tracking-[0.12em] text-amare-accent-text transition-colors duration-(--amare-duration-normal) ease-(--amare-ease-standard) hover:bg-amare-accent-deep"
|
||||
>
|
||||
{{ $settings->hero_cta_label ?: 'Solicitar proposta' }}
|
||||
{{ $settings->hero_cta_label }}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
@props([
|
||||
'label',
|
||||
'tone' => 'default',
|
||||
])
|
||||
|
||||
@php
|
||||
$tone = $tone === 'inverse' ? 'inverse' : 'default';
|
||||
@endphp
|
||||
|
||||
<p
|
||||
{{ $attributes->class([
|
||||
'home-folio',
|
||||
'home-folio--inverse' => $tone === 'inverse',
|
||||
]) }}
|
||||
data-home-folio
|
||||
>
|
||||
<span data-home-folio-label>{{ $label }}</span>
|
||||
<span class="home-folio__separator" aria-hidden="true">·</span>
|
||||
<span class="home-folio__number" data-home-folio-number aria-hidden="true"></span>
|
||||
</p>
|
||||
@@ -4,15 +4,11 @@
|
||||
|
||||
<section
|
||||
aria-labelledby="hero-heading"
|
||||
class="home-chapter border-b border-amare-border bg-amare-bg"
|
||||
class="border-b border-amare-border bg-amare-bg"
|
||||
data-chapter="hero"
|
||||
data-motion="page-open"
|
||||
data-motion="dossie-hero"
|
||||
>
|
||||
<div class="container-amare pt-8 md:pt-10">
|
||||
<x-home.folio label="Capa" data-motion-beat="folio" />
|
||||
</div>
|
||||
|
||||
<div class="container-amare grid gap-12 pb-20 pt-10 md:grid-cols-[minmax(0,1.1fr)_minmax(0,0.9fr)] md:items-center md:pb-28 md:pt-14" data-reveal-group>
|
||||
<div class="container-amare grid gap-12 py-20 md:grid-cols-[minmax(0,1.1fr)_minmax(0,0.9fr)] md:items-center md:py-28">
|
||||
<div class="space-y-8">
|
||||
<div data-motion-beat="seal" class="flex items-center gap-4">
|
||||
<x-brand.logo mark variant="on-light" class="h-8 w-auto" alt="" />
|
||||
@@ -21,8 +17,8 @@
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<h1 id="hero-heading" data-motion-beat="title" class="max-w-3xl text-display font-medium text-amare-text">
|
||||
{{ $settings->hero_title ?: 'Celebrações com propósito' }}
|
||||
<h1 id="hero-heading" data-motion-beat="title" class="max-w-3xl text-4xl font-medium leading-none text-amare-text md:text-5xl">
|
||||
{{ $settings->hero_title }}
|
||||
</h1>
|
||||
|
||||
@if (filled($settings->hero_subtitle))
|
||||
@@ -35,7 +31,7 @@
|
||||
data-testid="home-primary-cta"
|
||||
class="inline-flex min-h-11 items-center bg-amare-accent px-5 py-3 text-sm font-semibold text-amare-accent-text transition-colors duration-(--amare-duration-normal) ease-(--amare-ease-standard) hover:bg-amare-accent-hover"
|
||||
>
|
||||
{{ $settings->hero_cta_label ?: 'Solicitar proposta' }}
|
||||
{{ $settings->hero_cta_label }}
|
||||
</a>
|
||||
|
||||
@if (filled($settings->hero_secondary_cta_label))
|
||||
|
||||
@@ -8,15 +8,11 @@
|
||||
$body = $settings->manifesto_body ?: 'A Amare combina sensibilidade e precisão para criar encontros coerentes com cada cliente, marca e ocasião — sem fórmulas prontas, excessos ou ruído.';
|
||||
@endphp
|
||||
|
||||
<section aria-labelledby="manifesto-heading" class="home-chapter border-b border-amare-border bg-amare-bg-deep py-20" data-chapter="manifesto">
|
||||
<div class="container-amare grid gap-8 md:grid-cols-[minmax(0,0.8fr)_minmax(0,1.2fr)]" data-reveal-group>
|
||||
<div class="space-y-5 md:pr-8" data-reveal data-reveal-from="up">
|
||||
<x-home.folio label="Manifesto" />
|
||||
<span class="block h-px w-16 bg-amare-border" aria-hidden="true"></span>
|
||||
<p class="max-w-52 text-lg leading-snug text-amare-text">Humana no cuidado. Precisa na entrega.</p>
|
||||
</div>
|
||||
<div class="space-y-6" data-reveal data-reveal-from="up">
|
||||
<h2 id="manifesto-heading" class="max-w-3xl text-headline font-medium text-amare-text">{{ $title }}</h2>
|
||||
<section aria-labelledby="manifesto-heading" class="border-b border-amare-border bg-amare-bg-deep py-20" data-chapter="manifesto">
|
||||
<div class="container-amare grid gap-8 md:grid-cols-[minmax(0,0.8fr)_minmax(0,1.2fr)]" data-reveal>
|
||||
<p class="text-sm font-medium uppercase tracking-[0.18em] text-amare-accent">Manifesto</p>
|
||||
<div class="space-y-6">
|
||||
<h2 id="manifesto-heading" class="max-w-3xl text-3xl font-medium leading-tight text-amare-text md:text-4xl">{{ $title }}</h2>
|
||||
<p class="max-w-2xl text-xl leading-relaxed text-amare-text">{{ $lead }}</p>
|
||||
<p class="max-w-2xl text-amare-text-muted">{{ $body }}</p>
|
||||
</div>
|
||||
|
||||
@@ -7,24 +7,17 @@
|
||||
$intro = $settings->method_intro ?: 'Clareza em cada etapa. Tranquilidade durante todo o processo.';
|
||||
@endphp
|
||||
|
||||
<section aria-labelledby="method-heading" class="home-chapter border-b border-amare-border bg-amare-bg-archive py-20" data-chapter="method">
|
||||
<div class="container-amare grid gap-10 md:grid-cols-[minmax(0,0.8fr)_minmax(0,1.2fr)] md:items-start" data-reveal-group>
|
||||
<div class="space-y-4" data-reveal data-reveal-from="up">
|
||||
<x-home.folio label="Método" />
|
||||
<h2 id="method-heading" class="text-headline font-medium text-amare-text">Cuidado orientado por processo.</h2>
|
||||
<section aria-labelledby="method-heading" class="border-b border-amare-border bg-amare-bg-archive py-20" data-chapter="method">
|
||||
<div class="container-amare grid gap-10 md:grid-cols-[minmax(0,0.8fr)_minmax(0,1.2fr)] md:items-start">
|
||||
<div class="space-y-3" data-reveal>
|
||||
<p class="text-sm font-medium uppercase tracking-[0.18em] text-amare-accent">Método</p>
|
||||
<h2 id="method-heading" class="text-3xl font-medium text-amare-text">Cuidado orientado por processo.</h2>
|
||||
<p class="text-amare-text-muted">{{ $intro }}</p>
|
||||
<x-brand.logo
|
||||
mark
|
||||
variant="on-light"
|
||||
class="mt-8 h-8 w-auto opacity-60"
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<ol class="grid gap-5 border-t border-amare-border">
|
||||
@foreach ($steps as $index => $step)
|
||||
<li class="grid gap-2 border-b border-amare-border py-5 md:grid-cols-[4rem_minmax(0,1fr)]" data-reveal data-reveal-from="up">
|
||||
<li class="grid gap-2 border-b border-amare-border py-5 md:grid-cols-[4rem_minmax(0,1fr)]" data-reveal>
|
||||
<span class="text-sm text-amare-accent">{{ str_pad((string) ($index + 1), 2, '0', STR_PAD_LEFT) }}</span>
|
||||
<div class="space-y-2">
|
||||
<h3 class="text-2xl font-medium text-amare-text">{{ $step['title'] ?? '' }}</h3>
|
||||
|
||||
@@ -3,19 +3,19 @@
|
||||
])
|
||||
|
||||
@if ($cases->isNotEmpty())
|
||||
<section aria-labelledby="portfolio-heading" class="home-chapter border-b border-amare-accent-deep bg-amare-accent-deep py-20 text-amare-accent-text" data-chapter="portfolio">
|
||||
<div class="container-amare space-y-10" data-reveal-group>
|
||||
<div class="grid gap-4 md:grid-cols-[minmax(0,0.8fr)_minmax(0,1.2fr)]" data-reveal data-reveal-from="up">
|
||||
<x-home.folio label="Portfólio" tone="inverse" />
|
||||
<section aria-labelledby="portfolio-heading" class="border-b border-amare-accent-deep bg-amare-accent-deep py-20 text-amare-accent-text" data-chapter="portfolio">
|
||||
<div class="container-amare space-y-10">
|
||||
<div class="grid gap-4 md:grid-cols-[minmax(0,0.8fr)_minmax(0,1.2fr)]" data-reveal>
|
||||
<p class="text-sm font-medium uppercase tracking-[0.18em] text-amare-accent-text/80">Portfólio</p>
|
||||
<div class="space-y-3">
|
||||
<h2 id="portfolio-heading" class="text-headline font-medium">Celebrações que ganham forma com intenção.</h2>
|
||||
<h2 id="portfolio-heading" class="text-3xl font-medium md:text-4xl">Celebrações que ganham forma com intenção.</h2>
|
||||
<p class="max-w-2xl text-amare-accent-text/80">Recortes de eventos conduzidos com escuta, direção e presença em cada etapa.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-10 md:grid-cols-2">
|
||||
@foreach ($cases as $case)
|
||||
<article class="space-y-4 border-t border-amare-accent-text/30 pt-4" data-reveal data-reveal-from="up">
|
||||
<article class="space-y-4 border-t border-amare-accent-text/30 pt-4" data-reveal>
|
||||
@if (filled($case->cover_image_path))
|
||||
<x-media.image
|
||||
:path="$case->cover_image_path"
|
||||
|
||||
@@ -7,17 +7,13 @@
|
||||
$principles = filled($settings->principles) ? $settings->principles : \App\Models\SiteSetting::defaultPrinciples();
|
||||
@endphp
|
||||
|
||||
<section aria-labelledby="positioning-heading" class="home-chapter border-b border-amare-border py-20" data-chapter="positioning">
|
||||
<div class="container-amare grid gap-10 md:grid-cols-[minmax(0,0.8fr)_minmax(0,1.2fr)]" data-reveal-group>
|
||||
<div class="space-y-4" data-reveal data-reveal-from="up">
|
||||
<x-home.folio label="A Amare" />
|
||||
<div class="flex items-center gap-4">
|
||||
<span class="h-px w-10 bg-amare-border" aria-hidden="true"></span>
|
||||
<p class="text-sm uppercase tracking-[0.12em] text-amare-text-muted">Assessoria boutique · São Paulo</p>
|
||||
<section aria-labelledby="positioning-heading" class="border-b border-amare-border py-20" data-chapter="positioning">
|
||||
<div class="container-amare grid gap-10 md:grid-cols-[minmax(0,0.8fr)_minmax(0,1.2fr)]">
|
||||
<div class="space-y-3" data-reveal>
|
||||
<p class="text-sm font-medium uppercase tracking-[0.18em] text-amare-accent">A Amare</p>
|
||||
<h2 id="positioning-heading" class="text-3xl font-medium text-amare-text">Presença que organiza o essencial.</h2>
|
||||
</div>
|
||||
<h2 id="positioning-heading" class="text-headline font-medium text-amare-text">Presença que organiza o essencial.</h2>
|
||||
</div>
|
||||
<div class="space-y-8" data-reveal data-reveal-from="up">
|
||||
<div class="space-y-8" data-reveal>
|
||||
<p class="max-w-2xl text-xl leading-relaxed text-amare-text">{{ $summary }}</p>
|
||||
<ul class="grid gap-3 border-t border-amare-border">
|
||||
@foreach ($principles as $principle)
|
||||
|
||||
@@ -3,22 +3,17 @@
|
||||
])
|
||||
|
||||
@if ($services->isNotEmpty())
|
||||
<section aria-labelledby="services-heading" class="home-chapter border-b border-amare-border py-20" data-chapter="services">
|
||||
<div class="container-amare space-y-10" data-reveal-group>
|
||||
<div class="grid gap-6 md:grid-cols-12 md:items-end" data-reveal data-reveal-from="up">
|
||||
<div class="space-y-3 md:col-span-4">
|
||||
<x-home.folio label="Serviços" />
|
||||
<h2 id="services-heading" class="text-3xl font-medium text-amare-text">Serviços</h2>
|
||||
</div>
|
||||
<div class="space-y-4 md:col-span-6 md:col-start-7">
|
||||
<p class="max-w-xl text-amare-text-muted">Assessoria sob medida para decisões importantes e celebrações bem conduzidas.</p>
|
||||
<p class="border-l border-amare-border pl-4 text-sm uppercase tracking-[0.12em] text-amare-accent">Do íntimo ao corporativo</p>
|
||||
</div>
|
||||
<section aria-labelledby="services-heading" class="border-b border-amare-border py-20" data-chapter="services">
|
||||
<div class="container-amare space-y-10">
|
||||
<div class="max-w-2xl space-y-3" data-reveal>
|
||||
<p class="text-sm font-medium uppercase tracking-[0.18em] text-amare-accent">Atuação</p>
|
||||
<h2 id="services-heading" class="text-3xl font-medium text-amare-text">Serviços</h2>
|
||||
<p class="text-amare-text-muted">Assessoria sob medida para decisões importantes e celebrações bem conduzidas.</p>
|
||||
</div>
|
||||
|
||||
<ol class="border-t border-amare-border">
|
||||
@foreach ($services as $index => $service)
|
||||
<li class="grid gap-3 border-b border-amare-border py-5 md:grid-cols-[4rem_minmax(0,0.8fr)_minmax(0,1.2fr)] md:gap-6" data-reveal data-reveal-from="up">
|
||||
<li class="grid gap-3 border-b border-amare-border py-5 md:grid-cols-[4rem_minmax(0,0.8fr)_minmax(0,1.2fr)] md:gap-6" data-reveal>
|
||||
<span class="text-sm text-amare-accent">{{ str_pad((string) ($index + 1), 2, '0', STR_PAD_LEFT) }}</span>
|
||||
<h3 class="text-2xl font-medium text-amare-text">{{ $service->title }}</h3>
|
||||
<p class="text-amare-text-muted">{{ $service->summary }}</p>
|
||||
|
||||
@@ -2,18 +2,11 @@
|
||||
'testimonials',
|
||||
])
|
||||
|
||||
@php
|
||||
$testimonials = $testimonials
|
||||
->filter(fn ($testimonial): bool => filled(trim((string) $testimonial->quote)))
|
||||
->values();
|
||||
@endphp
|
||||
|
||||
@if ($testimonials->isNotEmpty())
|
||||
<section aria-labelledby="testimonials-heading" class="home-chapter border-b border-amare-border bg-amare-bg py-16" data-chapter="testimonials">
|
||||
<div class="container-amare space-y-8" data-reveal-group>
|
||||
<div class="max-w-2xl space-y-3" data-reveal data-reveal-from="up">
|
||||
<x-home.folio label="Depoimentos" />
|
||||
<h2 id="testimonials-heading" class="text-3xl font-medium text-amare-text">Depoimentos</h2>
|
||||
<section aria-labelledby="testimonials-heading" class="border-b border-amare-border bg-amare-bg-muted py-16" data-chapter="testimonials">
|
||||
<div class="container-amare space-y-8">
|
||||
<div class="max-w-2xl space-y-3" data-reveal>
|
||||
<h2 id="testimonials-heading" class="text-3xl font-semibold text-amare-text">Depoimentos</h2>
|
||||
<p class="text-amare-text-muted">Quem celebrou com a Amare conta como foi a experiência.</p>
|
||||
</div>
|
||||
|
||||
@@ -23,16 +16,13 @@
|
||||
$paragraphs = preg_split('/\n\s*\n/', trim((string) $testimonial->quote)) ?: [];
|
||||
$paragraphs = array_values(array_filter(array_map('trim', $paragraphs), fn (string $p): bool => $p !== ''));
|
||||
@endphp
|
||||
<blockquote class="space-y-4 border-t border-amare-border pt-4" data-reveal data-reveal-from="{{ $loop->odd ? 'left' : 'right' }}">
|
||||
<div class="grid grid-cols-[1.5rem_minmax(0,1fr)] gap-2">
|
||||
<span class="text-3xl leading-none text-amare-sage" aria-hidden="true">“</span>
|
||||
<div class="space-y-3 text-lg text-amare-text">
|
||||
@foreach ($paragraphs as $paragraph)
|
||||
<p>{{ $paragraph }}</p>
|
||||
@endforeach
|
||||
</div>
|
||||
<blockquote class="space-y-4 border-t border-amare-border pt-4" data-reveal>
|
||||
<div class="space-y-3 text-lg text-amare-text">
|
||||
@foreach ($paragraphs as $index => $paragraph)
|
||||
<p>@if ($index === 0)“@endif{{ $paragraph }}@if ($index === count($paragraphs) - 1)”@endif</p>
|
||||
@endforeach
|
||||
</div>
|
||||
<footer class="pl-8 text-sm text-amare-text-muted">
|
||||
<footer class="text-sm text-amare-text-muted">
|
||||
<cite class="not-italic font-semibold text-amare-text">{{ $testimonial->author_name }}</cite>
|
||||
@if (filled($testimonial->context))
|
||||
<span> — {{ $testimonial->context }}</span>
|
||||
|
||||
@@ -33,7 +33,7 @@
|
||||
|
||||
$resolvedWidth = $width ?? $dimensions['width'] ?? null;
|
||||
$resolvedHeight = $height ?? $dimensions['height'] ?? null;
|
||||
$loadingValue = $loading;
|
||||
$loadingValue = $loading === 'eager' ? 'eager' : 'lazy';
|
||||
@endphp
|
||||
|
||||
<img
|
||||
|
||||
@@ -15,11 +15,11 @@
|
||||
@extends('layouts.public')
|
||||
|
||||
@section('content')
|
||||
<div class="container-amare max-w-2xl space-y-6 py-20 text-center" data-motion="page-open" data-reveal-group>
|
||||
<p class="text-xs font-semibold uppercase tracking-[0.14em] text-amare-accent" data-motion-beat="seal">Erro 404</p>
|
||||
<h1 class="text-4xl font-medium tracking-tight text-amare-text" data-motion-beat="title">Página não encontrada</h1>
|
||||
<p class="text-amare-muted" data-reveal data-reveal-from="up">O endereço que você tentou abrir não existe ou foi movido.</p>
|
||||
<p data-reveal data-reveal-from="up">
|
||||
<div class="container-amare max-w-2xl space-y-6 py-20 text-center">
|
||||
<p class="text-xs font-semibold uppercase tracking-[0.14em] text-amare-accent">Erro 404</p>
|
||||
<h1 class="text-4xl font-medium tracking-tight text-amare-text">Página não encontrada</h1>
|
||||
<p class="text-amare-muted">O endereço que você tentou abrir não existe ou foi movido.</p>
|
||||
<p>
|
||||
<a href="{{ route('home') }}" class="inline-flex min-h-11 items-center bg-amare-accent px-5 py-3 text-sm font-semibold uppercase tracking-[0.12em] text-amare-accent-text transition-colors hover:bg-amare-accent-deep">
|
||||
Voltar para a home
|
||||
</a>
|
||||
|
||||
@@ -1,28 +0,0 @@
|
||||
@php
|
||||
try {
|
||||
$errorSettings = \App\Models\SiteSetting::instance();
|
||||
$pageMeta = \App\Application\Data\PageMeta::forErrorPage($errorSettings, 419);
|
||||
} catch (\Throwable $e) {
|
||||
$pageMeta = new \App\Application\Data\PageMeta(
|
||||
title: 'Sessão expirada - Amare Assessoria',
|
||||
description: 'Sua sessão expirou. Volte e tente enviar novamente.',
|
||||
canonical: '',
|
||||
robots: 'noindex, nofollow',
|
||||
);
|
||||
}
|
||||
@endphp
|
||||
|
||||
@extends('layouts.public')
|
||||
|
||||
@section('content')
|
||||
<div class="container-amare max-w-2xl space-y-6 py-20 text-center" data-motion="page-open" data-reveal-group>
|
||||
<p class="text-xs font-semibold uppercase tracking-[0.14em] text-amare-accent" data-motion-beat="seal">Erro 419</p>
|
||||
<h1 class="text-4xl font-medium tracking-tight text-amare-text" data-motion-beat="title">Sessão expirada</h1>
|
||||
<p class="text-amare-muted" data-reveal data-reveal-from="up">Sua sessão expirou. Volte e tente enviar novamente.</p>
|
||||
<p data-reveal data-reveal-from="up">
|
||||
<a href="{{ route('home') }}" class="inline-flex min-h-11 items-center bg-amare-accent px-5 py-3 text-sm font-semibold uppercase tracking-[0.12em] text-amare-accent-text transition-colors hover:bg-amare-accent-deep">
|
||||
Voltar para a home
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
@endsection
|
||||
@@ -1,28 +0,0 @@
|
||||
@php
|
||||
try {
|
||||
$errorSettings = \App\Models\SiteSetting::instance();
|
||||
$pageMeta = \App\Application\Data\PageMeta::forErrorPage($errorSettings, 429);
|
||||
} catch (\Throwable $e) {
|
||||
$pageMeta = new \App\Application\Data\PageMeta(
|
||||
title: 'Muitas solicitações - Amare Assessoria',
|
||||
description: 'Você enviou muitas solicitações em pouco tempo. Aguarde um instante e tente novamente.',
|
||||
canonical: '',
|
||||
robots: 'noindex, nofollow',
|
||||
);
|
||||
}
|
||||
@endphp
|
||||
|
||||
@extends('layouts.public')
|
||||
|
||||
@section('content')
|
||||
<div class="container-amare max-w-2xl space-y-6 py-20 text-center" data-motion="page-open" data-reveal-group>
|
||||
<p class="text-xs font-semibold uppercase tracking-[0.14em] text-amare-accent" data-motion-beat="seal">Erro 429</p>
|
||||
<h1 class="text-4xl font-medium tracking-tight text-amare-text" data-motion-beat="title">Muitas solicitações</h1>
|
||||
<p class="text-amare-muted" data-reveal data-reveal-from="up">Você enviou muitas solicitações em pouco tempo. Aguarde um instante e tente novamente.</p>
|
||||
<p data-reveal data-reveal-from="up">
|
||||
<a href="{{ route('home') }}" class="inline-flex min-h-11 items-center bg-amare-accent px-5 py-3 text-sm font-semibold uppercase tracking-[0.12em] text-amare-accent-text transition-colors hover:bg-amare-accent-deep">
|
||||
Voltar para a home
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
@endsection
|
||||
@@ -15,11 +15,11 @@
|
||||
@extends('layouts.public')
|
||||
|
||||
@section('content')
|
||||
<div class="container-amare max-w-2xl space-y-6 py-20 text-center" data-motion="page-open" data-reveal-group>
|
||||
<p class="text-xs font-semibold uppercase tracking-[0.14em] text-amare-accent" data-motion-beat="seal">Erro 500</p>
|
||||
<h1 class="text-4xl font-medium tracking-tight text-amare-text" data-motion-beat="title">Algo deu errado</h1>
|
||||
<p class="text-amare-muted" data-reveal data-reveal-from="up">Não foi possível concluir o pedido agora. Tente novamente em instantes.</p>
|
||||
<p data-reveal data-reveal-from="up">
|
||||
<div class="container-amare max-w-2xl space-y-6 py-20 text-center">
|
||||
<p class="text-xs font-semibold uppercase tracking-[0.14em] text-amare-accent">Erro 500</p>
|
||||
<h1 class="text-4xl font-medium tracking-tight text-amare-text">Algo deu errado</h1>
|
||||
<p class="text-amare-muted">Não foi possível concluir o pedido agora. Tente novamente em instantes.</p>
|
||||
<p>
|
||||
<a href="{{ route('home') }}" class="inline-flex min-h-11 items-center bg-amare-accent px-5 py-3 text-sm font-semibold uppercase tracking-[0.12em] text-amare-accent-text transition-colors hover:bg-amare-accent-deep">
|
||||
Voltar para a home
|
||||
</a>
|
||||
|
||||
@@ -1,28 +0,0 @@
|
||||
@php
|
||||
try {
|
||||
$errorSettings = \App\Models\SiteSetting::instance();
|
||||
$pageMeta = \App\Application\Data\PageMeta::forErrorPage($errorSettings, 503);
|
||||
} catch (\Throwable $e) {
|
||||
$pageMeta = new \App\Application\Data\PageMeta(
|
||||
title: 'Em manutenção - Amare Assessoria',
|
||||
description: 'Estamos realizando uma breve manutenção. Tente novamente em instantes.',
|
||||
canonical: '',
|
||||
robots: 'noindex, nofollow',
|
||||
);
|
||||
}
|
||||
@endphp
|
||||
|
||||
@extends('layouts.public')
|
||||
|
||||
@section('content')
|
||||
<div class="container-amare max-w-2xl space-y-6 py-20 text-center" data-motion="page-open" data-reveal-group>
|
||||
<p class="text-xs font-semibold uppercase tracking-[0.14em] text-amare-accent" data-motion-beat="seal">Erro 503</p>
|
||||
<h1 class="text-4xl font-medium tracking-tight text-amare-text" data-motion-beat="title">Em manutenção</h1>
|
||||
<p class="text-amare-muted" data-reveal data-reveal-from="up">Estamos realizando uma breve manutenção. Tente novamente em instantes.</p>
|
||||
<p data-reveal data-reveal-from="up">
|
||||
<a href="{{ route('home') }}" class="inline-flex min-h-11 items-center bg-amare-accent px-5 py-3 text-sm font-semibold uppercase tracking-[0.12em] text-amare-accent-text transition-colors hover:bg-amare-accent-deep">
|
||||
Voltar para a home
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
@endsection
|
||||
@@ -95,6 +95,7 @@
|
||||
<li><a href="{{ route('portfolio.index') }}" class="inline-flex min-h-11 items-center text-amare-muted transition-colors hover:text-amare-accent">Portfólio</a></li>
|
||||
<li><a href="{{ route('about') }}" class="inline-flex min-h-11 items-center text-amare-muted transition-colors hover:text-amare-accent">A Amare</a></li>
|
||||
<li><a href="{{ route('contact') }}" class="inline-flex min-h-11 items-center text-amare-muted transition-colors hover:text-amare-accent">Contato</a></li>
|
||||
<li><a href="{{ route('privacy') }}" class="inline-flex min-h-11 items-center text-amare-muted transition-colors hover:text-amare-accent">Política de privacidade</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
@@ -106,18 +107,18 @@
|
||||
@endif
|
||||
@if ($siteSettings->email)
|
||||
<p>
|
||||
<a href="mailto:{{ $siteSettings->email }}" class="inline-flex max-w-full min-h-11 items-center text-amare-muted transition-colors hover:text-amare-accent"><span class="min-w-0 break-words">{{ $siteSettings->email }}</span></a>
|
||||
<a href="mailto:{{ $siteSettings->email }}" class="inline-flex min-h-11 items-center text-amare-muted transition-colors hover:text-amare-accent">{{ $siteSettings->email }}</a>
|
||||
</p>
|
||||
@endif
|
||||
@if ($siteSettings->phone)
|
||||
<p>
|
||||
<a href="tel:{{ preg_replace('/\D/', '', (string) $siteSettings->phone) }}" class="inline-flex max-w-full min-h-11 items-center text-amare-muted transition-colors hover:text-amare-accent"><span class="min-w-0 break-words">{{ $siteSettings->phone }}</span></a>
|
||||
<a href="tel:{{ preg_replace('/\D/', '', (string) $siteSettings->phone) }}" class="inline-flex min-h-11 items-center text-amare-muted transition-colors hover:text-amare-accent">{{ $siteSettings->phone }}</a>
|
||||
</p>
|
||||
@endif
|
||||
@foreach ($siteSettings->social_links ?? [] as $network => $url)
|
||||
@if (filled($url))
|
||||
<p>
|
||||
<a href="{{ $url }}" class="inline-flex max-w-full min-h-11 items-center text-amare-muted transition-colors hover:text-amare-accent" rel="noopener noreferrer" target="_blank"><span class="min-w-0 break-words">{{ ucfirst((string) $network) }}</span></a>
|
||||
<a href="{{ $url }}" class="inline-flex min-h-11 items-center text-amare-muted transition-colors hover:text-amare-accent" rel="noopener noreferrer" target="_blank">{{ ucfirst((string) $network) }}</a>
|
||||
</p>
|
||||
@endif
|
||||
@endforeach
|
||||
|
||||
@@ -10,31 +10,19 @@
|
||||
|
||||
<div class="flex min-h-[calc(100dvh-14rem)] flex-col border-b border-amare-border bg-amare-bg" data-motion="page-open">
|
||||
<div class="container-amare grid flex-1 content-start gap-12 py-16 md:grid-cols-[minmax(0,1.1fr)_minmax(0,0.9fr)] md:py-24">
|
||||
<div class="space-y-6" data-motion-beat="heading">
|
||||
<div class="space-y-6">
|
||||
<p class="text-xs font-semibold uppercase tracking-[0.14em] text-amare-accent">A Amare</p>
|
||||
<h1 class="text-headline font-medium tracking-tight text-amare-text">Humana no cuidado. Precisa na entrega.</h1>
|
||||
<h1 class="text-4xl font-medium tracking-tight text-amare-text md:text-5xl">Humana no cuidado. Precisa na entrega.</h1>
|
||||
<p class="text-lg text-amare-muted">{{ $siteSettings->about_summary }}</p>
|
||||
<p class="text-amare-muted">
|
||||
A {{ $siteSettings->brand_name }} atua em {{ $city }} com foco em planejamento completo,
|
||||
presença no dia do evento e uma condução serena do início ao fim.
|
||||
</p>
|
||||
|
||||
@if (filled($siteSettings->about_image_path))
|
||||
<div class="overflow-hidden bg-amare-bg-deep">
|
||||
<x-media.image
|
||||
:path="$siteSettings->about_image_path"
|
||||
:alt="$siteSettings->about_image_alt ?: $siteSettings->brand_name"
|
||||
loading="lazy"
|
||||
sizes="(max-width: 768px) 100vw, 50vw"
|
||||
class="img-editorial aspect-[4/3] w-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<ul class="space-y-4 border-t border-amare-border pt-6" aria-label="Princípios da Amare" data-reveal-group>
|
||||
<ul class="space-y-4 border-t border-amare-border pt-6" aria-label="Princípios da Amare">
|
||||
@foreach ($principles as $index => $principle)
|
||||
<li class="grid grid-cols-[3rem_minmax(0,1fr)] gap-4 border-b border-amare-border pb-4 text-amare-text" data-reveal data-reveal-from="up">
|
||||
<li class="grid grid-cols-[3rem_minmax(0,1fr)] gap-4 border-b border-amare-border pb-4 text-amare-text">
|
||||
<span class="text-sm font-semibold uppercase tracking-[0.14em] text-amare-accent">{{ str_pad((string) ($index + 1), 2, '0', STR_PAD_LEFT) }}</span>
|
||||
<span>{{ $principle }}</span>
|
||||
</li>
|
||||
@@ -42,6 +30,4 @@
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<x-home.final-cta :settings="$siteSettings" />
|
||||
@endsection
|
||||
|
||||
@@ -1,32 +1,32 @@
|
||||
@extends('layouts.public')
|
||||
|
||||
@section('content')
|
||||
<section class="flex min-h-[calc(100dvh-14rem)] flex-col border-b border-amare-border bg-amare-bg" data-motion="page-open">
|
||||
<section class="flex min-h-[calc(100dvh-14rem)] flex-col border-b border-amare-border bg-amare-bg">
|
||||
<div class="container-amare grid flex-1 content-start gap-12 py-16 md:grid-cols-[minmax(0,1.2fr)_minmax(0,0.8fr)] md:py-24">
|
||||
<div class="space-y-6" data-motion-beat="heading">
|
||||
<div class="space-y-6">
|
||||
<p class="text-xs font-semibold uppercase tracking-[0.14em] text-amare-accent">Vamos conversar</p>
|
||||
<h1 class="text-headline font-medium tracking-tight text-amare-text">Todo grande encontro começa com uma boa conversa.</h1>
|
||||
<h1 class="text-4xl font-medium tracking-tight text-amare-text md:text-5xl">Todo grande encontro começa com uma boa conversa.</h1>
|
||||
<p class="max-w-2xl text-lg text-amare-muted">
|
||||
Conte sobre o seu evento no briefing abaixo. Retornaremos com uma proposta sob medida e sem compromisso.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="space-y-4 border-t border-amare-border pt-6 text-amare-muted md:border-t-0 md:border-l md:pt-0 md:pl-10" data-reveal-group>
|
||||
<p data-reveal data-reveal-from="up">{{ $siteSettings->city ?: 'São Paulo - SP' }}</p>
|
||||
<div class="space-y-4 border-t border-amare-border pt-6 text-amare-muted md:border-t-0 md:border-l md:pt-0 md:pl-10">
|
||||
<p>{{ $siteSettings->city ?: 'São Paulo - SP' }}</p>
|
||||
@if ($siteSettings->email)
|
||||
<p data-reveal data-reveal-from="up">
|
||||
<a href="mailto:{{ $siteSettings->email }}" class="inline-flex max-w-full min-h-11 items-center text-amare-accent transition-colors hover:text-amare-accent-deep"><span class="min-w-0 break-words">{{ $siteSettings->email }}</span></a>
|
||||
<p>
|
||||
<a href="mailto:{{ $siteSettings->email }}" class="inline-flex min-h-11 items-center text-amare-accent transition-colors hover:text-amare-accent-deep">{{ $siteSettings->email }}</a>
|
||||
</p>
|
||||
@endif
|
||||
@if ($siteSettings->phone)
|
||||
<p data-reveal data-reveal-from="up">
|
||||
<a href="tel:{{ preg_replace('/\D/', '', (string) $siteSettings->phone) }}" class="inline-flex max-w-full min-h-11 items-center text-amare-accent transition-colors hover:text-amare-accent-deep"><span class="min-w-0 break-words">{{ $siteSettings->phone }}</span></a>
|
||||
<p>
|
||||
<a href="tel:{{ preg_replace('/\D/', '', (string) $siteSettings->phone) }}" class="inline-flex min-h-11 items-center text-amare-accent transition-colors hover:text-amare-accent-deep">{{ $siteSettings->phone }}</a>
|
||||
</p>
|
||||
@endif
|
||||
@foreach ($siteSettings->social_links ?? [] as $network => $url)
|
||||
@if (filled($url))
|
||||
<p data-reveal data-reveal-from="up">
|
||||
<a href="{{ $url }}" class="inline-flex max-w-full min-h-11 items-center text-amare-accent transition-colors hover:text-amare-accent-deep" rel="noopener noreferrer" target="_blank"><span class="min-w-0 break-words">{{ ucfirst((string) $network) }}</span></a>
|
||||
<p>
|
||||
<a href="{{ $url }}" class="inline-flex min-h-11 items-center text-amare-accent transition-colors hover:text-amare-accent-deep" rel="noopener noreferrer" target="_blank">{{ ucfirst((string) $network) }}</a>
|
||||
</p>
|
||||
@endif
|
||||
@endforeach
|
||||
@@ -35,9 +35,9 @@
|
||||
</section>
|
||||
|
||||
<section class="border-b border-amare-border bg-amare-bg" aria-labelledby="briefing-heading">
|
||||
<div class="container-amare py-16 md:py-24" data-reveal-group>
|
||||
<h2 id="briefing-heading" class="text-2xl font-medium tracking-tight text-amare-text md:text-3xl" data-reveal data-reveal-from="up">Briefing de contato</h2>
|
||||
<p class="mt-2 max-w-2xl text-amare-muted" data-reveal data-reveal-from="up">
|
||||
<div class="container-amare py-16 md:py-24">
|
||||
<h2 id="briefing-heading" class="text-2xl font-medium tracking-tight text-amare-text md:text-3xl">Briefing de contato</h2>
|
||||
<p class="mt-2 max-w-2xl text-amare-muted">
|
||||
Preencha os campos abaixo. As informações obrigatórias estão marcadas com asterisco (*).
|
||||
</p>
|
||||
|
||||
|
||||
@@ -2,10 +2,6 @@
|
||||
|
||||
@section('content')
|
||||
@php
|
||||
$testimonials = $content->testimonials
|
||||
->filter(fn ($testimonial): bool => filled(trim((string) $testimonial->quote)))
|
||||
->values();
|
||||
|
||||
$chapters = [
|
||||
['id' => 'hero-heading', 'label' => 'Capa'],
|
||||
['id' => 'manifesto-heading', 'label' => 'Manifesto'],
|
||||
@@ -21,7 +17,7 @@
|
||||
|
||||
$chapters[] = ['id' => 'method-heading', 'label' => 'Método'];
|
||||
|
||||
if ($testimonials->isNotEmpty()) {
|
||||
if ($content->testimonials->isNotEmpty()) {
|
||||
$chapters[] = ['id' => 'testimonials-heading', 'label' => 'Depoimentos'];
|
||||
}
|
||||
|
||||
@@ -31,14 +27,12 @@
|
||||
|
||||
<x-home.chapter-index :chapters="$chapters" />
|
||||
|
||||
<div class="home-chapters">
|
||||
<x-home.hero :settings="$content->settings" />
|
||||
<x-home.manifesto :settings="$content->settings" />
|
||||
<x-home.services :services="$content->featuredServices" />
|
||||
<x-home.portfolio :cases="$content->featuredCases" />
|
||||
<x-home.method :settings="$content->settings" />
|
||||
<x-home.testimonials :testimonials="$content->testimonials" />
|
||||
<x-home.positioning :settings="$content->settings" />
|
||||
<x-home.final-cta :settings="$content->settings" editorial />
|
||||
</div>
|
||||
<x-home.hero :settings="$content->settings" />
|
||||
<x-home.manifesto :settings="$content->settings" />
|
||||
<x-home.services :services="$content->featuredServices" />
|
||||
<x-home.portfolio :cases="$content->featuredCases" />
|
||||
<x-home.method :settings="$content->settings" />
|
||||
<x-home.testimonials :testimonials="$content->testimonials" />
|
||||
<x-home.positioning :settings="$content->settings" />
|
||||
<x-home.final-cta :settings="$content->settings" />
|
||||
@endsection
|
||||
|
||||
@@ -3,18 +3,18 @@
|
||||
@section('content')
|
||||
<div class="border-b border-amare-border bg-amare-bg-deep" data-motion="page-open">
|
||||
<div class="container-amare space-y-10 py-16 md:py-24">
|
||||
<div class="max-w-2xl space-y-4" data-motion-beat="heading">
|
||||
<div class="max-w-2xl space-y-4">
|
||||
<p class="text-xs font-semibold uppercase tracking-[0.14em] text-amare-accent">Portfólio</p>
|
||||
<h1 class="text-headline font-medium tracking-tight text-amare-text">Atmosferas que contam histórias.</h1>
|
||||
<h1 class="text-4xl font-medium tracking-tight text-amare-text md:text-5xl">Atmosferas que contam histórias.</h1>
|
||||
<p class="text-lg text-amare-muted">Casos conduzidos com atenção a ritmo, composição e cada detalhe da experiência.</p>
|
||||
</div>
|
||||
|
||||
@if ($cases->isEmpty())
|
||||
<p class="text-amare-muted">Novos casos serão publicados assim que o acervo estiver organizado. Enquanto isso, fale conosco para conhecer o nosso trabalho.</p>
|
||||
@else
|
||||
<div class="grid gap-10 md:grid-cols-2" data-reveal-group>
|
||||
<div class="grid gap-10 md:grid-cols-2">
|
||||
@foreach ($cases as $case)
|
||||
<article class="space-y-4" data-reveal data-reveal-from="up">
|
||||
<article class="space-y-4">
|
||||
@if (filled($case->cover_image_path))
|
||||
<a href="{{ route('portfolio.show', $case->slug) }}" class="block overflow-hidden">
|
||||
<x-media.image
|
||||
@@ -43,6 +43,4 @@
|
||||
<p class="text-sm text-amare-accent">Imagens demonstrativas enquanto o acervo autorizado da Amare está em organização.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<x-home.final-cta :settings="$siteSettings" />
|
||||
@endsection
|
||||
|
||||
@@ -4,13 +4,11 @@
|
||||
<article data-motion="page-open">
|
||||
<div class="border-b border-amare-border bg-amare-bg">
|
||||
<div class="container-amare space-y-8 py-16 md:py-24">
|
||||
<header class="max-w-3xl space-y-4" data-motion-beat="heading">
|
||||
@if (filled($case->event_type))
|
||||
<p class="text-xs font-semibold uppercase tracking-[0.14em] text-amare-accent">{{ $case->event_type }}</p>
|
||||
@endif
|
||||
<h1 class="text-headline font-medium tracking-tight text-amare-text">{{ $case->title }}</h1>
|
||||
<header class="max-w-3xl space-y-4">
|
||||
<p class="text-xs font-semibold uppercase tracking-[0.14em] text-amare-accent">{{ $case->event_type }}</p>
|
||||
<h1 class="text-4xl font-medium tracking-tight text-amare-text md:text-5xl">{{ $case->title }}</h1>
|
||||
<p class="text-lg text-amare-muted">{{ $case->summary }}</p>
|
||||
<p class="text-sm break-words text-amare-muted">
|
||||
<p class="text-sm text-amare-muted">
|
||||
@if ($case->city){{ $case->city }}@endif
|
||||
@if ($case->venue) · {{ $case->venue }}@endif
|
||||
@if ($case->event_date) · {{ $case->event_date->format('d/m/Y') }}@endif
|
||||
@@ -24,24 +22,23 @@
|
||||
loading="eager"
|
||||
sizes="(max-width: 1024px) 100vw, 1120px"
|
||||
class="img-editorial aspect-[16/9] w-full object-cover"
|
||||
data-motion-beat="media"
|
||||
/>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="border-b border-amare-border bg-amare-bg-deep">
|
||||
<div class="container-amare grid gap-10 py-16 md:grid-cols-3" data-reveal-group>
|
||||
<section class="space-y-3" data-reveal data-reveal-from="up">
|
||||
<div class="container-amare grid gap-10 py-16 md:grid-cols-3">
|
||||
<section class="space-y-3">
|
||||
<h2 class="text-xs font-semibold uppercase tracking-[0.14em] text-amare-accent">Desafio</h2>
|
||||
<p class="text-amare-muted">{!! nl2br(e($case->challenge)) !!}</p>
|
||||
</section>
|
||||
<section class="space-y-3" data-reveal data-reveal-from="up">
|
||||
<section class="space-y-3">
|
||||
<h2 class="text-xs font-semibold uppercase tracking-[0.14em] text-amare-accent">Solução</h2>
|
||||
<p class="text-amare-muted">{!! nl2br(e($case->solution)) !!}</p>
|
||||
</section>
|
||||
@if (filled($case->result))
|
||||
<section class="space-y-3" data-reveal data-reveal-from="up">
|
||||
<section class="space-y-3">
|
||||
<h2 class="text-xs font-semibold uppercase tracking-[0.14em] text-amare-accent">Resultado</h2>
|
||||
<p class="text-amare-muted">{!! nl2br(e($case->result)) !!}</p>
|
||||
</section>
|
||||
@@ -53,9 +50,9 @@
|
||||
<section aria-labelledby="gallery-heading" class="bg-amare-bg">
|
||||
<div class="container-amare space-y-8 py-16">
|
||||
<h2 id="gallery-heading" class="text-3xl font-medium text-amare-text">Galeria</h2>
|
||||
<div class="grid gap-6 md:grid-cols-2" data-reveal-group>
|
||||
<div class="grid gap-6 md:grid-cols-2">
|
||||
@foreach ($case->images as $image)
|
||||
<figure class="space-y-2" data-reveal data-reveal-from="up" data-reveal-media>
|
||||
<figure class="space-y-2">
|
||||
<x-media.image
|
||||
:path="$image->path"
|
||||
:alt="$image->alt_text"
|
||||
@@ -72,6 +69,4 @@
|
||||
</section>
|
||||
@endif
|
||||
</article>
|
||||
|
||||
<x-home.final-cta :settings="$siteSettings" />
|
||||
@endsection
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
@extends('layouts.public')
|
||||
|
||||
@section('content')
|
||||
<div class="border-b border-amare-border bg-amare-bg" data-motion="page-open">
|
||||
<div class="container-amare max-w-3xl space-y-6 py-16 md:py-24" data-reveal-group>
|
||||
<p class="text-xs font-semibold uppercase tracking-[0.14em] text-amare-accent" data-motion-beat="seal">Privacidade</p>
|
||||
<h1 class="text-4xl font-medium tracking-tight text-amare-text" data-motion-beat="title">Política de privacidade</h1>
|
||||
<p class="text-amare-muted" data-reveal data-reveal-from="up">
|
||||
<div class="border-b border-amare-border bg-amare-bg">
|
||||
<div class="container-amare max-w-3xl space-y-6 py-16 md:py-24">
|
||||
<p class="text-xs font-semibold uppercase tracking-[0.14em] text-amare-accent">Privacidade</p>
|
||||
<h1 class="text-4xl font-medium tracking-tight text-amare-text">Política de privacidade</h1>
|
||||
<p class="text-amare-muted">
|
||||
A {{ $siteSettings->brand_name }} trata dados pessoais com responsabilidade e somente para finalidades
|
||||
relacionadas ao atendimento de interessados e à operação do site.
|
||||
</p>
|
||||
<p class="text-amare-muted" data-reveal data-reveal-from="up">
|
||||
<p class="text-amare-muted">
|
||||
Para dúvidas sobre privacidade, escreva para
|
||||
<a href="mailto:{{ $siteSettings->email }}" class="text-amare-accent underline underline-offset-2 transition-colors hover:text-amare-accent-deep">{{ $siteSettings->email }}</a>.
|
||||
<a href="mailto:{{ $siteSettings->email }}" class="text-amare-accent transition-colors hover:text-amare-accent-deep">{{ $siteSettings->email }}</a>.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -3,18 +3,18 @@
|
||||
@section('content')
|
||||
<div class="border-b border-amare-border bg-amare-bg" data-motion="page-open">
|
||||
<div class="container-amare space-y-10 py-16 md:py-24">
|
||||
<div class="max-w-2xl space-y-4" data-motion-beat="heading">
|
||||
<div class="max-w-2xl space-y-4">
|
||||
<p class="text-xs font-semibold uppercase tracking-[0.14em] text-amare-accent">Serviços</p>
|
||||
<h1 class="text-headline font-medium tracking-tight text-amare-text">Uma mesma excelência, diferentes ocasiões.</h1>
|
||||
<h1 class="text-4xl font-medium tracking-tight text-amare-text md:text-5xl">Uma mesma excelência, diferentes ocasiões.</h1>
|
||||
<p class="text-lg text-amare-muted">O escopo é construído de acordo com o momento do projeto, o nível de apoio necessário e a complexidade de cada evento.</p>
|
||||
</div>
|
||||
|
||||
@if ($services->isEmpty())
|
||||
<p class="text-amare-muted">O catálogo de serviços está em organização. Enquanto isso, fale conosco para uma primeira conversa.</p>
|
||||
@else
|
||||
<div class="divide-y divide-amare-border border-y border-amare-border" data-reveal-group>
|
||||
<div class="divide-y divide-amare-border border-y border-amare-border">
|
||||
@foreach ($services as $index => $service)
|
||||
<article class="grid gap-4 py-8 md:grid-cols-[5rem_minmax(0,1fr)_minmax(0,1.2fr)] md:items-start" data-reveal data-reveal-from="up">
|
||||
<article class="grid gap-4 py-8 md:grid-cols-[5rem_minmax(0,1fr)_minmax(0,1.2fr)] md:items-start">
|
||||
<span class="text-sm font-semibold uppercase tracking-[0.14em] text-amare-accent">{{ str_pad((string) ($index + 1), 2, '0', STR_PAD_LEFT) }}</span>
|
||||
<div class="space-y-3">
|
||||
<h2 class="text-2xl font-medium text-amare-text md:text-3xl">{{ $service->title }}</h2>
|
||||
@@ -41,6 +41,4 @@
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<x-home.final-cta :settings="$siteSettings" />
|
||||
@endsection
|
||||
|
||||
32
tasks.md
@@ -45,35 +45,3 @@
|
||||
- [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)
|
||||
|
||||
@@ -23,10 +23,6 @@ it('has no critical or serious accessibility issues on covered public routes', f
|
||||
'/servicos',
|
||||
'/portfolio',
|
||||
'/portfolio/'.$case->slug,
|
||||
'/sobre',
|
||||
'/contato',
|
||||
'/privacidade',
|
||||
'/__missing-accessibility-page',
|
||||
];
|
||||
|
||||
foreach ($routes as $route) {
|
||||
@@ -92,16 +88,7 @@ it('loads covered public routes without console errors', function (): void {
|
||||
'slug' => 'casamento-jardim',
|
||||
]);
|
||||
|
||||
foreach ([
|
||||
'/',
|
||||
'/servicos',
|
||||
'/portfolio',
|
||||
'/portfolio/'.$case->slug,
|
||||
'/sobre',
|
||||
'/contato',
|
||||
'/privacidade',
|
||||
'/__missing-console-page',
|
||||
] as $route) {
|
||||
foreach (['/', '/servicos', '/portfolio', '/portfolio/'.$case->slug] as $route) {
|
||||
$this->visit($route)
|
||||
->assertNoJavaScriptErrors();
|
||||
}
|
||||
|
||||
@@ -1,86 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\SiteSetting;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function (): void {
|
||||
SiteSetting::instance();
|
||||
});
|
||||
|
||||
it('does not overflow horizontally when contact info contains long unbroken strings', function (): void {
|
||||
SiteSetting::instance()->update([
|
||||
'email' => str_repeat('a', 50).'@email.'.str_repeat('b', 40),
|
||||
'phone' => '(11) 99999-9999',
|
||||
]);
|
||||
|
||||
$page = $this->visit('/contato');
|
||||
|
||||
$overflow = $page->script(
|
||||
'() => document.documentElement.scrollWidth - document.documentElement.clientWidth',
|
||||
);
|
||||
|
||||
expect((int) $overflow)->toBeLessThanOrEqual(0);
|
||||
});
|
||||
|
||||
it('shows sending state on submit and resets on bfcache restore', function (): void {
|
||||
$page = $this->visit('/contato');
|
||||
|
||||
$state = $page->script(<<<'JS'
|
||||
() => {
|
||||
const form = document.querySelector("form[data-contact-form]");
|
||||
const button = document.querySelector("[data-submit-button]");
|
||||
form?.dispatchEvent(new Event("submit", { bubbles: true, cancelable: true }));
|
||||
const afterSubmit = {
|
||||
label: button?.textContent?.trim(),
|
||||
disabled: button?.disabled ?? false,
|
||||
formBusy: form?.getAttribute("aria-busy"),
|
||||
};
|
||||
window.dispatchEvent(new PageTransitionEvent("pageshow", { persisted: true }));
|
||||
return {
|
||||
afterSubmit,
|
||||
afterRestore: {
|
||||
label: button?.textContent?.trim(),
|
||||
disabled: button?.disabled ?? false,
|
||||
formBusy: form?.getAttribute("aria-busy"),
|
||||
},
|
||||
};
|
||||
}
|
||||
JS);
|
||||
|
||||
expect($state['afterSubmit']['label'])->toBe('Enviando…');
|
||||
expect($state['afterSubmit']['disabled'])->toBeTrue();
|
||||
expect($state['afterSubmit']['formBusy'])->toBe('true');
|
||||
|
||||
expect($state['afterRestore']['label'])->toBe('Enviar briefing');
|
||||
expect($state['afterRestore']['disabled'])->toBeFalse();
|
||||
expect($state['afterRestore']['formBusy'])->toBeNull();
|
||||
});
|
||||
|
||||
it('traps focus within the open mobile menu', function (): void {
|
||||
$page = $this->visit('/');
|
||||
$page->resize(390, 844);
|
||||
$page->click('[data-menu-button]');
|
||||
|
||||
for ($i = 0; $i < 20; $i++) {
|
||||
$inside = $page->script(
|
||||
'() => document.querySelector("[data-main-nav]")?.contains(document.activeElement) ?? false',
|
||||
);
|
||||
expect($inside)->toBeTrue();
|
||||
|
||||
$page->keys(':focus', 'Tab');
|
||||
}
|
||||
|
||||
$page->script('() => document.querySelector("[data-main-nav] a")?.focus()');
|
||||
$page->keys(':focus', 'Shift+Tab');
|
||||
|
||||
$wrappedToLast = $page->script('() => {
|
||||
const nav = document.querySelector("[data-main-nav]");
|
||||
const links = nav?.querySelectorAll("a[href], button:not([disabled])") ?? [];
|
||||
return links.length > 0 && document.activeElement === links[links.length - 1];
|
||||
}');
|
||||
expect($wrappedToLast)->toBeTrue();
|
||||
});
|
||||
@@ -1,60 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Database\Seeders\VisualContentSeeder;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Artisan;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function (): void {
|
||||
Artisan::call('db:seed', ['--class' => VisualContentSeeder::class, '--force' => true]);
|
||||
});
|
||||
|
||||
it('keeps the full home cadence accessible and contained at both viewports', function (): void {
|
||||
foreach ([[1440, 1000], [390, 844]] as [$width, $height]) {
|
||||
$page = $this->visit('/', [
|
||||
'reducedMotion' => 'reduce',
|
||||
])->resize($width, $height);
|
||||
|
||||
$page->assertNoAccessibilityIssues(1);
|
||||
|
||||
$layout = $page->script(<<<'JS'
|
||||
() => {
|
||||
const chapters = Array.from(document.querySelectorAll('.home-chapter'));
|
||||
const folios = Array.from(document.querySelectorAll('[data-home-folio]'));
|
||||
const clippedValues = new Set(['clip', 'hidden']);
|
||||
const textNodes = Array.from(document.querySelectorAll(
|
||||
'.home-chapters h1, .home-chapters h2, .home-chapters h3, .home-chapters p, .home-chapters a, .home-chapters span'
|
||||
));
|
||||
|
||||
return {
|
||||
horizontalOverflow: document.documentElement.scrollWidth > window.innerWidth,
|
||||
overlappingChapters: chapters.slice(1).some((chapter, index) => {
|
||||
const previous = chapters[index].getBoundingClientRect();
|
||||
const current = chapter.getBoundingClientRect();
|
||||
return current.top < previous.bottom - 1;
|
||||
}),
|
||||
overflowingFolios: folios.filter((folio) => {
|
||||
const rect = folio.getBoundingClientRect();
|
||||
return rect.left < -1 || rect.right > window.innerWidth + 1;
|
||||
}).length,
|
||||
clippedText: textNodes.filter((node) => {
|
||||
const style = getComputedStyle(node);
|
||||
const clippedX = clippedValues.has(style.overflowX) && node.scrollWidth > node.clientWidth + 1;
|
||||
const clippedY = clippedValues.has(style.overflowY) && node.scrollHeight > node.clientHeight + 1;
|
||||
return clippedX || clippedY;
|
||||
}).length,
|
||||
};
|
||||
}
|
||||
JS);
|
||||
|
||||
expect($layout)->toBe([
|
||||
'horizontalOverflow' => false,
|
||||
'overlappingChapters' => false,
|
||||
'overflowingFolios' => 0,
|
||||
'clippedText' => 0,
|
||||
]);
|
||||
}
|
||||
});
|
||||
@@ -2,10 +2,8 @@
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\PortfolioCase;
|
||||
use App\Models\Service;
|
||||
use App\Models\SiteSetting;
|
||||
use App\Models\Testimonial;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
@@ -23,28 +21,24 @@ beforeEach(function (): void {
|
||||
]);
|
||||
});
|
||||
|
||||
it('keeps every home motion target in final visible state when reduced motion is preferred', function (): void {
|
||||
it('keeps home motion in final visible state when reduced motion is preferred', function (): void {
|
||||
$page = $this->visit('/', [
|
||||
'reducedMotion' => 'reduce',
|
||||
]);
|
||||
|
||||
$state = $page->script(<<<'JS'
|
||||
() => {
|
||||
const hero = document.querySelector('[data-motion="page-open"]');
|
||||
const hero = document.querySelector('[data-motion="dossie-hero"]');
|
||||
const title = document.querySelector('#hero-heading');
|
||||
const index = document.querySelector('[data-chapter-index]');
|
||||
const active = document.querySelector('[data-chapter-index] [aria-current="true"]');
|
||||
const titleStyle = title ? getComputedStyle(title) : null;
|
||||
const finalReveals = [...document.querySelectorAll('[data-reveal]')]
|
||||
.every((node) => node.classList.contains('is-revealed'));
|
||||
|
||||
return {
|
||||
heroActive: hero?.classList.contains('is-active') ?? false,
|
||||
titleOpacity: titleStyle ? Number.parseFloat(titleStyle.opacity) : 0,
|
||||
hasIndex: Boolean(index),
|
||||
hasActiveChapter: Boolean(active),
|
||||
finalReveals,
|
||||
enhancementEnabled: document.documentElement.hasAttribute('data-motion'),
|
||||
};
|
||||
}
|
||||
JS);
|
||||
@@ -53,11 +47,9 @@ it('keeps every home motion target in final visible state when reduced motion is
|
||||
expect($state['titleOpacity'])->toBeGreaterThan(0.9);
|
||||
expect($state['hasIndex'])->toBeTrue();
|
||||
expect($state['hasActiveChapter'])->toBeTrue();
|
||||
expect($state['finalReveals'])->toBeTrue();
|
||||
expect($state['enhancementEnabled'])->toBeFalse();
|
||||
});
|
||||
|
||||
it('activates the home opening with 500ms beats and capped 90ms stagger', function (): void {
|
||||
it('activates the home focal opening once when motion is allowed', function (): void {
|
||||
$page = $this->visit('/', [
|
||||
'reducedMotion' => 'no-preference',
|
||||
]);
|
||||
@@ -65,7 +57,7 @@ it('activates the home opening with 500ms beats and capped 90ms stagger', functi
|
||||
$page->script(<<<'JS'
|
||||
() => new Promise((resolve) => {
|
||||
const wait = () => {
|
||||
const hero = document.querySelector('[data-motion="page-open"]');
|
||||
const hero = document.querySelector('[data-motion="dossie-hero"]');
|
||||
if (hero?.classList.contains('is-active')) {
|
||||
resolve(true);
|
||||
return;
|
||||
@@ -77,186 +69,11 @@ it('activates the home opening with 500ms beats and capped 90ms stagger', functi
|
||||
})
|
||||
JS);
|
||||
|
||||
$state = $page->script(<<<'JS'
|
||||
() => {
|
||||
const hero = document.querySelector('[data-motion="page-open"]');
|
||||
const beatDurations = [...hero.querySelectorAll('[data-motion-beat]')]
|
||||
.map((beat) => Number.parseFloat(getComputedStyle(beat).transitionDuration));
|
||||
const methodItems = [...document.querySelectorAll('[data-chapter="method"] [data-reveal]')];
|
||||
|
||||
return {
|
||||
active: hero?.classList.contains('is-active') ?? false,
|
||||
beatDurations,
|
||||
methodIndexes: methodItems.map((item) => item.style.getPropertyValue('--motion-index')),
|
||||
};
|
||||
}
|
||||
$active = $page->script(<<<'JS'
|
||||
() => document.querySelector('[data-motion="dossie-hero"]')?.classList.contains('is-active') ?? false
|
||||
JS);
|
||||
|
||||
expect($state['active'])->toBeTrue();
|
||||
expect($state['beatDurations'])->each->toBe(0.5);
|
||||
expect($state['methodIndexes'])->toBe(['0', '1', '2', '3', '3']);
|
||||
});
|
||||
|
||||
it('reveals a section once and keeps it final after returning in the scroll', function (): void {
|
||||
$page = $this->visit('/', [
|
||||
'reducedMotion' => 'no-preference',
|
||||
]);
|
||||
|
||||
$before = $page->script(<<<'JS'
|
||||
() => document.querySelector('#final-cta-heading')?.closest('[data-reveal]')?.classList.contains('is-revealed') ?? false
|
||||
JS);
|
||||
|
||||
$page->script(<<<'JS'
|
||||
() => {
|
||||
document.querySelector('#final-cta-heading')?.scrollIntoView({ block: 'center' });
|
||||
return true;
|
||||
}
|
||||
JS);
|
||||
|
||||
$page->script(<<<'JS'
|
||||
() => new Promise((resolve) => {
|
||||
const target = document.querySelector('#final-cta-heading')?.closest('[data-reveal]');
|
||||
const wait = () => {
|
||||
if (target?.classList.contains('is-revealed')) {
|
||||
resolve(true);
|
||||
return;
|
||||
}
|
||||
requestAnimationFrame(wait);
|
||||
};
|
||||
wait();
|
||||
})
|
||||
JS);
|
||||
|
||||
$page->script('() => window.scrollTo({ top: 0, behavior: "instant" })');
|
||||
|
||||
$after = $page->script(<<<'JS'
|
||||
() => document.querySelector('#final-cta-heading')?.closest('[data-reveal]')?.classList.contains('is-revealed') ?? false
|
||||
JS);
|
||||
|
||||
expect($before)->toBeFalse();
|
||||
expect($after)->toBeTrue();
|
||||
});
|
||||
|
||||
it('preserves testimonial directions with reduced lateral distance on mobile', function (): void {
|
||||
Testimonial::factory()->published()->create([
|
||||
'quote' => 'Primeiro relato.',
|
||||
'sort_order' => 10,
|
||||
]);
|
||||
Testimonial::factory()->published()->create([
|
||||
'quote' => 'Segundo relato.',
|
||||
'sort_order' => 20,
|
||||
]);
|
||||
|
||||
$page = $this->visit('/', [
|
||||
'reducedMotion' => 'no-preference',
|
||||
]);
|
||||
|
||||
$readDistances = <<<'JS'
|
||||
() => [...document.querySelectorAll('[data-chapter="testimonials"] blockquote')]
|
||||
.map((item) => ({
|
||||
direction: item.dataset.revealFrom,
|
||||
distance: getComputedStyle(item).getPropertyValue('--motion-translate-x').trim(),
|
||||
}))
|
||||
JS;
|
||||
|
||||
$page->resize(1440, 1000);
|
||||
$page->script('() => new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)))');
|
||||
$desktop = $page->script($readDistances);
|
||||
|
||||
$page->resize(390, 844);
|
||||
$page->script('() => new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)))');
|
||||
$mobile = $page->script($readDistances);
|
||||
|
||||
expect($desktop)->toBe([
|
||||
['direction' => 'left', 'distance' => 'calc(-1 * 24px)'],
|
||||
['direction' => 'right', 'distance' => '24px'],
|
||||
]);
|
||||
expect($mobile)->toBe([
|
||||
['direction' => 'left', 'distance' => 'calc(-1 * 16px)'],
|
||||
['direction' => 'right', 'distance' => '16px'],
|
||||
]);
|
||||
});
|
||||
|
||||
it('keeps content and navigation usable without javascript', function (): void {
|
||||
$this->visit('/', [
|
||||
'javaScriptEnabled' => false,
|
||||
])
|
||||
->assertVisible('#hero-heading')
|
||||
->assertVisible('[data-testid="home-primary-cta"]')
|
||||
->click('[data-testid="home-primary-cta"]')
|
||||
->assertPathIs('/contato');
|
||||
});
|
||||
|
||||
it('keeps targets final when intersection observer is unavailable', function (): void {
|
||||
$page = $this->visit('/');
|
||||
$rawPage = $page->page();
|
||||
$url = $rawPage->url();
|
||||
|
||||
$rawPage->context()->addInitScript('window.IntersectionObserver = undefined;');
|
||||
$rawPage->goto($url);
|
||||
|
||||
$state = $page->script(<<<'JS'
|
||||
() => ({
|
||||
enhancementEnabled: document.documentElement.hasAttribute('data-motion'),
|
||||
pagesFinal: [...document.querySelectorAll('[data-motion="page-open"]')]
|
||||
.every((node) => node.classList.contains('is-active')),
|
||||
revealsFinal: [...document.querySelectorAll('[data-reveal]')]
|
||||
.every((node) => node.classList.contains('is-revealed')),
|
||||
})
|
||||
JS);
|
||||
|
||||
expect($state)->toBe([
|
||||
'enhancementEnabled' => false,
|
||||
'pagesFinal' => true,
|
||||
'revealsFinal' => true,
|
||||
]);
|
||||
});
|
||||
|
||||
it('allows cta activation while its opening is in progress', function (): void {
|
||||
$page = $this->visit('/', [
|
||||
'reducedMotion' => 'no-preference',
|
||||
]);
|
||||
|
||||
$page->script(<<<'JS'
|
||||
() => {
|
||||
const opening = document.querySelector('[data-motion="page-open"]');
|
||||
opening?.classList.remove('is-active');
|
||||
requestAnimationFrame(() => opening?.classList.add('is-active'));
|
||||
return true;
|
||||
}
|
||||
JS);
|
||||
|
||||
$page->click('[data-testid="home-primary-cta"]')
|
||||
->assertPathIs('/contato');
|
||||
});
|
||||
|
||||
it('avoids horizontal overflow on visual public routes at desktop and mobile', function (): void {
|
||||
$case = PortfolioCase::factory()->published()->create([
|
||||
'slug' => 'casamento-jardim',
|
||||
]);
|
||||
|
||||
$routes = [
|
||||
'/',
|
||||
'/servicos',
|
||||
'/portfolio',
|
||||
'/portfolio/'.$case->slug,
|
||||
'/sobre',
|
||||
'/contato',
|
||||
'/privacidade',
|
||||
'/__missing-public-motion',
|
||||
];
|
||||
|
||||
foreach ([[1440, 1000], [390, 844]] as [$width, $height]) {
|
||||
foreach ($routes as $route) {
|
||||
$page = $this->visit($route, [
|
||||
'reducedMotion' => 'no-preference',
|
||||
])->resize($width, $height);
|
||||
|
||||
$overflow = $page->script('() => document.documentElement.scrollWidth - document.documentElement.clientWidth');
|
||||
|
||||
expect($overflow)->toBeLessThanOrEqual(0);
|
||||
}
|
||||
}
|
||||
expect($active)->toBeTrue();
|
||||
});
|
||||
|
||||
it('updates the active chapter while scrolling the home dossier', function (): void {
|
||||
@@ -302,48 +119,3 @@ it('updates the active chapter while scrolling the home dossier', function (): v
|
||||
expect($before)->not->toBeNull();
|
||||
expect($after)->toBe('#method-heading');
|
||||
});
|
||||
|
||||
it('applies CSS numbering to only rendered home folios in chapter order', function (): void {
|
||||
PortfolioCase::factory()->published()->create([
|
||||
'is_featured' => true,
|
||||
]);
|
||||
Testimonial::factory()->published()->create([
|
||||
'quote' => 'Relato publicado.',
|
||||
]);
|
||||
|
||||
$page = $this->visit('/', [
|
||||
'reducedMotion' => 'reduce',
|
||||
]);
|
||||
|
||||
$numbering = $page->script(<<<'JS'
|
||||
() => {
|
||||
const flow = document.querySelector('.home-chapters');
|
||||
const chapters = Array.from(document.querySelectorAll('.home-chapter'));
|
||||
const folios = Array.from(document.querySelectorAll('[data-home-folio]'));
|
||||
|
||||
return {
|
||||
reset: flow ? getComputedStyle(flow).counterReset : '',
|
||||
increments: chapters.map((chapter) => getComputedStyle(chapter).counterIncrement),
|
||||
labels: folios.map((folio) => folio.querySelector('[data-home-folio-label]')?.textContent?.trim() ?? ''),
|
||||
contents: folios.map((folio) => {
|
||||
const number = folio.querySelector('[data-home-folio-number]');
|
||||
return number ? getComputedStyle(number, '::before').content.replace(/["']/g, '') : '';
|
||||
}),
|
||||
};
|
||||
}
|
||||
JS);
|
||||
|
||||
expect($numbering['reset'])->toContain('home-chapter -1');
|
||||
expect($numbering['increments'])->each->toContain('home-chapter 1');
|
||||
expect($numbering['labels'])->toBe([
|
||||
'Capa',
|
||||
'Manifesto',
|
||||
'Serviços',
|
||||
'Portfólio',
|
||||
'Método',
|
||||
'Depoimentos',
|
||||
'A Amare',
|
||||
'Próximo passo',
|
||||
]);
|
||||
expect($numbering['contents'])->each->toBe('counter(home-chapter, decimal-leading-zero)');
|
||||
});
|
||||
|
||||
@@ -31,10 +31,6 @@ $screens = [
|
||||
'services' => '/servicos',
|
||||
'portfolio' => '/portfolio',
|
||||
'portfolio-detail' => '/portfolio/casamento-ana-lucas',
|
||||
'about' => '/sobre',
|
||||
'contact' => '/contato',
|
||||
'privacy' => '/privacidade',
|
||||
'not-found' => '/__missing-visual-page',
|
||||
];
|
||||
|
||||
$viewports = [
|
||||
@@ -45,15 +41,13 @@ $viewports = [
|
||||
foreach ($screens as $screen => $path) {
|
||||
foreach ($viewports as $viewport => [$width, $height]) {
|
||||
it("matches {$screen} {$viewport} visual baseline", function () use ($path, $width, $height): void {
|
||||
$this->assertStableScreenshotMatches(
|
||||
$this->visit($path, [
|
||||
'reducedMotion' => 'reduce',
|
||||
])
|
||||
->withLocale('pt-BR')
|
||||
->withTimezone('America/Fortaleza')
|
||||
->resize($width, $height),
|
||||
resetScroll: $path === '/',
|
||||
);
|
||||
$this->visit($path, [
|
||||
'reducedMotion' => 'reduce',
|
||||
])
|
||||
->withLocale('pt-BR')
|
||||
->withTimezone('America/Fortaleza')
|
||||
->resize($width, $height)
|
||||
->assertScreenshotMatches();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,80 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Feature\PublicSite;
|
||||
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
use Tests\TestCase;
|
||||
|
||||
class ErrorPagesTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
public function test_419_renders_branded_session_expired_page(): void
|
||||
{
|
||||
Route::get('/__test-419', fn (): never => abort(419));
|
||||
|
||||
$this->get('/__test-419')
|
||||
->assertStatus(419)
|
||||
->assertSee('Erro 419')
|
||||
->assertSee('Sessão expirada')
|
||||
->assertSee('Voltar para a home');
|
||||
}
|
||||
|
||||
public function test_429_renders_branded_rate_limited_page(): void
|
||||
{
|
||||
Route::get('/__test-429', fn (): never => abort(429));
|
||||
|
||||
$this->get('/__test-429')
|
||||
->assertStatus(429)
|
||||
->assertSee('Erro 429')
|
||||
->assertSee('Muitas solicitações')
|
||||
->assertSee('Voltar para a home');
|
||||
}
|
||||
|
||||
public function test_503_renders_branded_maintenance_page(): void
|
||||
{
|
||||
Route::get('/__test-503', fn (): never => abort(503));
|
||||
|
||||
$this->get('/__test-503')
|
||||
->assertStatus(503)
|
||||
->assertSee('Erro 503')
|
||||
->assertSee('Em manutenção')
|
||||
->assertSee('Voltar para a home');
|
||||
}
|
||||
|
||||
public function test_error_pages_never_leak_debug_stack_traces(): void
|
||||
{
|
||||
config(['app.debug' => false]);
|
||||
|
||||
Route::get('/__test-503', fn (): never => abort(503));
|
||||
|
||||
$this->get('/__test-503')
|
||||
->assertStatus(503)
|
||||
->assertDontSee('segredo-interno-amare')
|
||||
->assertDontSee('Exception');
|
||||
}
|
||||
|
||||
public function test_all_branded_error_pages_expose_progressive_motion_hooks(): void
|
||||
{
|
||||
foreach ([419, 429, 500, 503] as $status) {
|
||||
Route::get('/__test-motion-'.$status, fn (): never => abort($status));
|
||||
|
||||
$this->get('/__test-motion-'.$status)
|
||||
->assertStatus($status)
|
||||
->assertSee('data-motion="page-open"', false)
|
||||
->assertSee('data-motion-beat', false)
|
||||
->assertSee('data-reveal-group', false)
|
||||
->assertSee('data-reveal', false);
|
||||
}
|
||||
|
||||
$this->get('/__missing-motion-page')
|
||||
->assertNotFound()
|
||||
->assertSee('data-motion="page-open"', false)
|
||||
->assertSee('data-motion-beat', false)
|
||||
->assertSee('data-reveal-group', false)
|
||||
->assertSee('data-reveal', false);
|
||||
}
|
||||
}
|
||||
@@ -110,152 +110,4 @@ class HomePageContentTest extends TestCase
|
||||
->assertSee('id="positioning-heading"', false)
|
||||
->assertSee('id="final-cta-heading"', false);
|
||||
}
|
||||
|
||||
public function test_hero_falls_back_to_default_copy_when_fields_are_empty(): void
|
||||
{
|
||||
SiteSetting::instance()->update([
|
||||
'hero_title' => '',
|
||||
'hero_cta_label' => '',
|
||||
'hero_secondary_cta_label' => '',
|
||||
'hero_subtitle' => '',
|
||||
'hero_note' => '',
|
||||
]);
|
||||
|
||||
$response = $this->get(route('home'));
|
||||
|
||||
$response
|
||||
->assertOk()
|
||||
->assertSeeInOrder(['id="hero-heading"', 'Celebrações com propósito'])
|
||||
->assertSeeInOrder(['data-testid="home-primary-cta"', 'Solicitar proposta']);
|
||||
}
|
||||
|
||||
public function test_blank_quote_testimonials_are_skipped(): void
|
||||
{
|
||||
Testimonial::factory()->published()->create([
|
||||
'author_name' => 'Autor Mantido',
|
||||
'quote' => 'Experiência impecável do início ao fim.',
|
||||
]);
|
||||
Testimonial::factory()->published()->create([
|
||||
'author_name' => 'Autor Oculto',
|
||||
'quote' => ' ',
|
||||
]);
|
||||
|
||||
$response = $this->get(route('home'));
|
||||
|
||||
$response
|
||||
->assertOk()
|
||||
->assertSee('Autor Mantido')
|
||||
->assertSee('Experiência impecável do início ao fim.')
|
||||
->assertDontSee('Autor Oculto')
|
||||
->assertSee('data-reveal-from="left"', false);
|
||||
}
|
||||
|
||||
public function test_blank_testimonials_are_filtered_before_directional_sequence(): void
|
||||
{
|
||||
Testimonial::factory()->published()->create([
|
||||
'author_name' => 'Primeira autora',
|
||||
'quote' => 'Primeiro relato.',
|
||||
'sort_order' => 10,
|
||||
]);
|
||||
Testimonial::factory()->published()->create([
|
||||
'author_name' => 'Relato vazio',
|
||||
'quote' => " \n ",
|
||||
'sort_order' => 20,
|
||||
]);
|
||||
Testimonial::factory()->published()->create([
|
||||
'author_name' => 'Segunda autora',
|
||||
'quote' => 'Segundo relato.',
|
||||
'sort_order' => 30,
|
||||
]);
|
||||
|
||||
$response = $this->get(route('home'));
|
||||
|
||||
$response
|
||||
->assertOk()
|
||||
->assertDontSee('Relato vazio')
|
||||
->assertSeeInOrder([
|
||||
'data-reveal-from="left"',
|
||||
'Primeira autora',
|
||||
'data-reveal-from="right"',
|
||||
'Segunda autora',
|
||||
], false);
|
||||
|
||||
$this->assertSame(1, substr_count($response->getContent(), 'data-reveal-from="left"'));
|
||||
$this->assertSame(1, substr_count($response->getContent(), 'data-reveal-from="right"'));
|
||||
}
|
||||
|
||||
public function test_testimonials_section_is_omitted_when_every_published_quote_is_blank(): void
|
||||
{
|
||||
Testimonial::factory()->published()->create([
|
||||
'author_name' => 'Relato vazio',
|
||||
'quote' => ' ',
|
||||
]);
|
||||
|
||||
$this->get(route('home'))
|
||||
->assertOk()
|
||||
->assertDontSee('id="testimonials-heading"', false)
|
||||
->assertDontSee('href="#testimonials-heading"', false)
|
||||
->assertDontSee('Relato vazio');
|
||||
}
|
||||
|
||||
public function test_home_folios_follow_rendered_chapter_order_and_hide_decorative_numbers(): void
|
||||
{
|
||||
SiteSetting::instance();
|
||||
|
||||
Service::factory()->published()->featured()->create();
|
||||
PortfolioCase::factory()->published()->create(['is_featured' => true]);
|
||||
Testimonial::factory()->published()->create(['quote' => 'Relato publicado.']);
|
||||
|
||||
$html = $this->get(route('home'))->assertOk()->getContent();
|
||||
|
||||
$this->assertSame([
|
||||
'Capa',
|
||||
'Manifesto',
|
||||
'Serviços',
|
||||
'Portfólio',
|
||||
'Método',
|
||||
'Depoimentos',
|
||||
'A Amare',
|
||||
'Próximo passo',
|
||||
], $this->folioLabels((string) $html));
|
||||
$this->assertSame(8, preg_match_all('/data-home-folio-number[^>]*aria-hidden="true"/i', (string) $html));
|
||||
$this->assertSame(1, preg_match_all('/<h1\b/i', (string) $html));
|
||||
|
||||
$css = (string) file_get_contents(resource_path('css/app.css'));
|
||||
|
||||
$this->assertStringContainsString('counter-reset: home-chapter -1', $css);
|
||||
$this->assertStringContainsString('counter-increment: home-chapter', $css);
|
||||
$this->assertStringContainsString('counter(home-chapter, decimal-leading-zero)', $css);
|
||||
}
|
||||
|
||||
public function test_home_folios_are_omitted_with_conditional_chapters_without_leaving_markup_gaps(): void
|
||||
{
|
||||
SiteSetting::instance();
|
||||
|
||||
$html = $this->get(route('home'))->assertOk()->getContent();
|
||||
|
||||
$this->assertSame([
|
||||
'Capa',
|
||||
'Manifesto',
|
||||
'Método',
|
||||
'A Amare',
|
||||
'Próximo passo',
|
||||
], $this->folioLabels((string) $html));
|
||||
$this->assertSame(5, preg_match_all('/data-home-folio-number[^>]*aria-hidden="true"/i', (string) $html));
|
||||
}
|
||||
|
||||
/** @return list<string> */
|
||||
private function folioLabels(string $html): array
|
||||
{
|
||||
preg_match_all(
|
||||
'/<span\b[^>]*data-home-folio-label[^>]*>\s*([^<]+?)\s*<\/span>/is',
|
||||
$html,
|
||||
$matches,
|
||||
);
|
||||
|
||||
return array_values(array_map(
|
||||
static fn (string $label): string => trim(html_entity_decode($label)),
|
||||
$matches[1],
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@ class MotionMarkupTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
public function test_home_exposes_page_open_beats_reveal_groups_and_chapter_index_links(): void
|
||||
public function test_home_exposes_focal_hero_hooks_and_chapter_index_links(): void
|
||||
{
|
||||
$settings = SiteSetting::instance();
|
||||
$settings->update([
|
||||
@@ -35,13 +35,11 @@ class MotionMarkupTest extends TestCase
|
||||
|
||||
$response
|
||||
->assertOk()
|
||||
->assertSee('data-motion="page-open"', false)
|
||||
->assertSee('data-motion="dossie-hero"', false)
|
||||
->assertSee('data-motion-beat="seal"', false)
|
||||
->assertSee('data-motion-beat="title"', false)
|
||||
->assertSee('data-motion-beat="media"', false)
|
||||
->assertSee('data-motion-beat="cta"', false)
|
||||
->assertSee('data-reveal-group', false)
|
||||
->assertSee('data-reveal-from="up"', false)
|
||||
->assertSee('data-chapter-index', false)
|
||||
->assertSee('data-chapter-progress', false)
|
||||
->assertSee('href="#hero-heading"', false)
|
||||
@@ -85,28 +83,18 @@ class MotionMarkupTest extends TestCase
|
||||
$appCss = (string) file_get_contents(resource_path('css/app.css'));
|
||||
$appJs = (string) file_get_contents(resource_path('js/app.js'));
|
||||
|
||||
$this->assertStringContainsString('--amare-motion-enter: 500ms', $tokens);
|
||||
$this->assertStringContainsString('--amare-motion-feedback: 150ms', $tokens);
|
||||
$this->assertStringContainsString('--amare-motion-stagger: 90ms', $tokens);
|
||||
$this->assertStringContainsString('--amare-motion-stagger-cap: 270ms', $tokens);
|
||||
$this->assertStringContainsString('--amare-motion-distance-y: 12px', $tokens);
|
||||
$this->assertStringContainsString('--amare-motion-distance-x: 24px', $tokens);
|
||||
$this->assertStringContainsString('--amare-motion-distance-x-mobile: 16px', $tokens);
|
||||
$this->assertStringContainsString('--amare-duration-focal:', $tokens);
|
||||
$this->assertStringContainsString('--amare-ease-arrival:', $tokens);
|
||||
$this->assertStringContainsString('--amare-motion-enter: 0.01ms', $tokens);
|
||||
$this->assertStringContainsString('--amare-duration-focal: 0.01ms', $tokens);
|
||||
|
||||
$this->assertStringContainsString('[data-motion="page-open"]', $appCss);
|
||||
$this->assertStringContainsString('[data-motion-beat]', $appCss);
|
||||
$this->assertStringContainsString('[data-reveal-group]', $appCss);
|
||||
$this->assertStringContainsString('[data-reveal-from="left"]', $appCss);
|
||||
$this->assertStringContainsString('[data-reveal-from="right"]', $appCss);
|
||||
$this->assertStringContainsString('[data-motion="dossie-hero"]', $appCss);
|
||||
$this->assertStringContainsString('[data-reveal]', $appCss);
|
||||
$this->assertStringContainsString('[data-chapter-index]', $appCss);
|
||||
$this->assertStringContainsString('prefers-reduced-motion: no-preference', $appCss);
|
||||
|
||||
// Entrance motion must not fade text opacity — mid-fade fails WCAG contrast (axe).
|
||||
$this->assertDoesNotMatchRegularExpression(
|
||||
'/\[data-motion-beat\][^{]*\{[^}]*opacity:\s*0/s',
|
||||
'/\[data-motion-beat="seal"\][^{]*\{[^}]*opacity:\s*0/s',
|
||||
$appCss,
|
||||
);
|
||||
$this->assertDoesNotMatchRegularExpression(
|
||||
@@ -120,17 +108,10 @@ class MotionMarkupTest extends TestCase
|
||||
|
||||
$this->assertFileExists(resource_path('js/motion.js'));
|
||||
$this->assertStringContainsString("import './motion.js'", $appJs);
|
||||
$motionJs = (string) file_get_contents(resource_path('js/motion.js'));
|
||||
$this->assertStringContainsString('prefers-reduced-motion', $motionJs);
|
||||
$this->assertStringContainsString("querySelectorAll('[data-reveal-group]')", $motionJs);
|
||||
$this->assertStringContainsString('Math.min(index, 3)', $motionJs);
|
||||
$this->assertStringContainsString('observer.unobserve(entry.target)', $motionJs);
|
||||
$this->assertStringContainsString("typeof IntersectionObserver !== 'function'", $motionJs);
|
||||
$this->assertStringContainsString('requestAnimationFrame', $motionJs);
|
||||
$this->assertStringContainsString('framePending', $motionJs);
|
||||
$this->assertStringContainsString('prefers-reduced-motion', (string) file_get_contents(resource_path('js/motion.js')));
|
||||
}
|
||||
|
||||
public function test_all_visual_public_pages_expose_shared_opening_and_reveal_hooks(): void
|
||||
public function test_internal_pages_expose_shared_page_open_hook(): void
|
||||
{
|
||||
SiteSetting::instance();
|
||||
$case = PortfolioCase::factory()->published()->create([
|
||||
@@ -138,38 +119,28 @@ class MotionMarkupTest extends TestCase
|
||||
'title' => 'Casamento Jardim',
|
||||
]);
|
||||
|
||||
$responses = [
|
||||
$this->get(route('home')),
|
||||
$this->get(route('services.index')),
|
||||
$this->get(route('portfolio.index')),
|
||||
$this->get(route('portfolio.show', $case->slug)),
|
||||
$this->get(route('about')),
|
||||
$this->get(route('contact')),
|
||||
$this->get(route('privacy')),
|
||||
];
|
||||
|
||||
foreach ($responses as $response) {
|
||||
$response
|
||||
->assertOk()
|
||||
->assertSee('data-motion="page-open"', false)
|
||||
->assertSee('data-motion-beat', false)
|
||||
->assertSee('data-reveal-group', false)
|
||||
->assertSee('data-reveal', false);
|
||||
}
|
||||
}
|
||||
|
||||
public function test_technical_endpoints_do_not_expose_motion_contract(): void
|
||||
{
|
||||
SiteSetting::instance();
|
||||
|
||||
$this->get(route('sitemap'))
|
||||
$this->get(route('services.index'))
|
||||
->assertOk()
|
||||
->assertDontSee('data-motion=', false)
|
||||
->assertDontSee('data-reveal', false);
|
||||
->assertSee('data-motion="page-open"', false);
|
||||
|
||||
$this->get(route('robots'))
|
||||
$this->get(route('portfolio.index'))
|
||||
->assertOk()
|
||||
->assertDontSee('data-motion=', false)
|
||||
->assertDontSee('data-reveal', false);
|
||||
->assertSee('data-motion="page-open"', false);
|
||||
|
||||
$this->get(route('portfolio.show', $case->slug))
|
||||
->assertOk()
|
||||
->assertSee('data-motion="page-open"', false);
|
||||
|
||||
$this->get(route('about'))
|
||||
->assertOk()
|
||||
->assertSee('data-motion="page-open"', false);
|
||||
|
||||
$this->get(route('contact'))
|
||||
->assertOk()
|
||||
->assertDontSee('data-motion="page-open"', false);
|
||||
|
||||
$this->get(route('privacy'))
|
||||
->assertOk()
|
||||
->assertDontSee('data-motion="page-open"', false);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -196,26 +196,4 @@ class PublicPagesTest extends TestCase
|
||||
|
||||
$this->assertLessThan(15, $queryCount);
|
||||
}
|
||||
|
||||
public function test_case_without_event_type_does_not_render_empty_eyebrow(): void
|
||||
{
|
||||
SiteSetting::instance();
|
||||
|
||||
$case = PortfolioCase::factory()->published()->create([
|
||||
'slug' => 'caso-sem-tipo',
|
||||
'title' => 'Caso Sem Tipo',
|
||||
'event_type' => '',
|
||||
]);
|
||||
|
||||
$response = $this->get(route('portfolio.show', $case->slug));
|
||||
|
||||
$response->assertOk()->assertSee('Caso Sem Tipo');
|
||||
|
||||
$emptyAccentParagraphs = preg_match_all(
|
||||
'/class="[^"]*text-amare-accent[^"]*"><\/p>/',
|
||||
(string) $response->getContent(),
|
||||
);
|
||||
|
||||
expect($emptyAccentParagraphs)->toBe(0);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,40 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Support;
|
||||
|
||||
final class MismatchScreenshotExporter
|
||||
{
|
||||
/**
|
||||
* Extracts the diff, expected, and actual images embedded in an ImageDiffView
|
||||
* HTML file and writes them as standalone PNG files so they can be inspected
|
||||
* directly in CI artifacts.
|
||||
*
|
||||
* @return list<string> paths of the written PNG files
|
||||
*/
|
||||
public static function export(string $htmlPath, string $targetDir): array
|
||||
{
|
||||
$html = (string) file_get_contents($htmlPath);
|
||||
|
||||
if (preg_match_all('/src="data:image\/png;base64,([^"]+)"/', $html, $matches) === false || ! isset($matches[1][2])) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$base = pathinfo($htmlPath, PATHINFO_FILENAME);
|
||||
$labels = ['diff', 'expected', 'actual'];
|
||||
$written = [];
|
||||
|
||||
foreach (array_slice($matches[1], 0, 3) as $index => $base64) {
|
||||
$path = $targetDir.'/'.$base.'-'.($labels[$index] ?? 'image-'.$index).'.png';
|
||||
|
||||
if (file_put_contents($path, (string) base64_decode($base64, true)) === false) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$written[] = $path;
|
||||
}
|
||||
|
||||
return $written;
|
||||
}
|
||||
}
|
||||
@@ -1,98 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Support;
|
||||
|
||||
use Pest\Browser\Api\AwaitableWebpage;
|
||||
use Pest\Browser\Exceptions\BrowserExpectationFailedException;
|
||||
use Pest\Browser\Support\Screenshot;
|
||||
use Pest\TestSuite;
|
||||
use PHPUnit\Framework\ExpectationFailedException;
|
||||
|
||||
trait StableScreenshot
|
||||
{
|
||||
/**
|
||||
* Asserts the page matches its visual baseline without relying on the
|
||||
* networkidle load state, which is inherently flaky (long-lived connections
|
||||
* like fonts, polling, or keep-alive can keep it from ever firing and cause
|
||||
* spurious timeouts at the 5s client default).
|
||||
*
|
||||
* On mismatch the vendor only writes an HTML diff view with base64-embedded
|
||||
* images, so this helper additionally exports standalone diff/expected/actual
|
||||
* PNG files that are directly viewable in CI artifacts.
|
||||
*/
|
||||
public function assertStableScreenshotMatches(AwaitableWebpage $awaitable, bool $resetScroll = false): void
|
||||
{
|
||||
$page = $awaitable->page();
|
||||
|
||||
$page->addStyleTag('* {
|
||||
transition: none !important;
|
||||
animation: none !important;
|
||||
font-family: Arial, sans-serif !important;
|
||||
body {
|
||||
-webkit-font-smoothing: antialiased !important;
|
||||
-moz-osx-font-smoothing: grayscale !important;
|
||||
}
|
||||
}');
|
||||
|
||||
$page->waitForFunction(
|
||||
'document.readyState === "complete"'
|
||||
.' && document.fonts.status === "loaded"'
|
||||
);
|
||||
|
||||
$page->evaluate(<<<'JS'
|
||||
async () => {
|
||||
const viewport = Math.max(window.innerHeight, 1);
|
||||
|
||||
for (let top = 0; top < document.documentElement.scrollHeight; top += viewport) {
|
||||
window.scrollTo(0, top);
|
||||
await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)));
|
||||
}
|
||||
|
||||
await Promise.all([...document.images].map(async (image) => {
|
||||
if (! image.complete) {
|
||||
await new Promise((resolve) => {
|
||||
image.addEventListener('load', resolve, { once: true });
|
||||
image.addEventListener('error', resolve, { once: true });
|
||||
});
|
||||
}
|
||||
|
||||
await image.decode().catch(() => {});
|
||||
}));
|
||||
|
||||
window.scrollTo(0, 0);
|
||||
await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)));
|
||||
}
|
||||
JS);
|
||||
|
||||
if ($resetScroll) {
|
||||
$page->evaluate('window.scrollTo(0, 0)');
|
||||
$page->waitForFunction('window.scrollY === 0');
|
||||
}
|
||||
|
||||
usleep(300_000);
|
||||
|
||||
try {
|
||||
$page->expectScreenshot(true, false);
|
||||
} catch (ExpectationFailedException $exception) {
|
||||
$this->exportMismatchScreenshots();
|
||||
|
||||
throw BrowserExpectationFailedException::from($page, $exception);
|
||||
}
|
||||
}
|
||||
|
||||
private function exportMismatchScreenshots(): void
|
||||
{
|
||||
[$snapshotName] = TestSuite::getInstance()->snapshots->get();
|
||||
|
||||
$base = pathinfo($snapshotName, PATHINFO_FILENAME);
|
||||
$htmlPath = Screenshot::dir().'/ImageDiffView/'.$base.'.html';
|
||||
|
||||
if (! is_file($htmlPath)) {
|
||||
return;
|
||||
}
|
||||
|
||||
MismatchScreenshotExporter::export($htmlPath, Screenshot::dir());
|
||||
}
|
||||
}
|
||||