Compare commits

..

4 Commits

112 changed files with 308 additions and 2067 deletions

View File

@@ -31,8 +31,7 @@ jobs:
name: static name: static
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v5 - uses: actions/checkout@v4
- run: cp .env.example .env
- uses: shivammathur/setup-php@v2 - uses: shivammathur/setup-php@v2
with: with:
@@ -40,7 +39,7 @@ jobs:
extensions: dom, curl, libxml, mbstring, zip, pcntl, pdo, pdo_pgsql, bcmath, intl, sodium, gd extensions: dom, curl, libxml, mbstring, zip, pcntl, pdo, pdo_pgsql, bcmath, intl, sodium, gd
coverage: none coverage: none
- uses: actions/cache@v5 - uses: actions/cache@v4
with: with:
path: ~/.composer/cache/files path: ~/.composer/cache/files
key: composer-${{ runner.os }}-${{ hashFiles('**/composer.lock') }} key: composer-${{ runner.os }}-${{ hashFiles('**/composer.lock') }}
@@ -56,8 +55,7 @@ jobs:
name: unit name: unit
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v5 - uses: actions/checkout@v4
- run: cp .env.example .env
- uses: shivammathur/setup-php@v2 - uses: shivammathur/setup-php@v2
with: with:
@@ -65,7 +63,7 @@ jobs:
extensions: dom, curl, libxml, mbstring, zip, pcntl, pdo, pdo_pgsql, bcmath, intl, sodium, gd extensions: dom, curl, libxml, mbstring, zip, pcntl, pdo, pdo_pgsql, bcmath, intl, sodium, gd
coverage: none coverage: none
- uses: actions/cache@v5 - uses: actions/cache@v4
with: with:
path: ~/.composer/cache/files path: ~/.composer/cache/files
key: composer-${{ runner.os }}-${{ hashFiles('**/composer.lock') }} key: composer-${{ runner.os }}-${{ hashFiles('**/composer.lock') }}
@@ -94,8 +92,7 @@ jobs:
--health-timeout 5s --health-timeout 5s
--health-retries 10 --health-retries 10
steps: steps:
- uses: actions/checkout@v5 - uses: actions/checkout@v4
- run: cp .env.example .env
- uses: shivammathur/setup-php@v2 - uses: shivammathur/setup-php@v2
with: with:
@@ -103,13 +100,13 @@ jobs:
extensions: dom, curl, libxml, mbstring, zip, pcntl, pdo, pdo_pgsql, bcmath, intl, sodium, gd extensions: dom, curl, libxml, mbstring, zip, pcntl, pdo, pdo_pgsql, bcmath, intl, sodium, gd
coverage: none coverage: none
- uses: actions/cache@v5 - uses: actions/cache@v4
with: with:
path: ~/.composer/cache/files path: ~/.composer/cache/files
key: composer-${{ runner.os }}-${{ hashFiles('**/composer.lock') }} key: composer-${{ runner.os }}-${{ hashFiles('**/composer.lock') }}
restore-keys: composer-${{ runner.os }}- restore-keys: composer-${{ runner.os }}-
- uses: actions/cache@v5 - uses: actions/cache@v4
with: with:
path: ~/.npm path: ~/.npm
key: npm-${{ runner.os }}-${{ hashFiles('**/package-lock.json') }} key: npm-${{ runner.os }}-${{ hashFiles('**/package-lock.json') }}
@@ -139,8 +136,7 @@ jobs:
--health-timeout 5s --health-timeout 5s
--health-retries 10 --health-retries 10
steps: steps:
- uses: actions/checkout@v5 - uses: actions/checkout@v4
- run: cp .env.example .env
- uses: shivammathur/setup-php@v2 - uses: shivammathur/setup-php@v2
with: with:
@@ -148,13 +144,13 @@ jobs:
extensions: dom, curl, libxml, mbstring, zip, pcntl, pdo, pdo_pgsql, bcmath, intl, sodium, gd extensions: dom, curl, libxml, mbstring, zip, pcntl, pdo, pdo_pgsql, bcmath, intl, sodium, gd
coverage: none coverage: none
- uses: actions/cache@v5 - uses: actions/cache@v4
with: with:
path: ~/.composer/cache/files path: ~/.composer/cache/files
key: composer-${{ runner.os }}-${{ hashFiles('**/composer.lock') }} key: composer-${{ runner.os }}-${{ hashFiles('**/composer.lock') }}
restore-keys: composer-${{ runner.os }}- restore-keys: composer-${{ runner.os }}-
- uses: actions/cache@v5 - uses: actions/cache@v4
with: with:
path: ~/.npm path: ~/.npm
key: npm-${{ runner.os }}-${{ hashFiles('**/package-lock.json') }} key: npm-${{ runner.os }}-${{ hashFiles('**/package-lock.json') }}
@@ -228,7 +224,7 @@ jobs:
name: container name: container
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v5 - uses: actions/checkout@v4
- name: Build production image - name: Build production image
run: docker build -t amare-app:ci . run: docker build -t amare-app:ci .

1
.gitignore vendored
View File

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

View File

@@ -41,17 +41,3 @@ History follows Conventional Commit-style subjects, for example `feat: Fase 0
## Security & Configuration ## Security & Configuration
Copy `.env.example`; never commit secrets or production credentials. Development seed credentials are local-only. Validate uploads and authorization through Laravel policies, and run `composer security-audit` after dependency changes. Copy `.env.example`; never commit secrets or production credentials. Development seed credentials are local-only. Validate uploads and authorization through Laravel policies, and run `composer security-audit` after dependency changes.
## Agent skills
### Issue tracker
Issues live in Linear, driven through the Linear MCP tools. See `docs/agents/issue-tracker.md`.
### Triage labels
Default vocabulary: `needs-triage`, `needs-info`, `ready-for-agent`, `ready-for-human`, `wontfix`. See `docs/agents/triage-labels.md`.
### Domain docs
Single-context: `CONTEXT.md` at root + `docs/adr/`. See `docs/agents/domain.md`.

104
SPEC.md
View File

@@ -14,8 +14,7 @@
| Estágio | MVP | | Estágio | MVP |
| Status da especificação | Aprovada para implementação | | Status da especificação | Aprovada para implementação |
| Idioma da interface | Português do Brasil (`pt-BR`) | | Idioma da interface | Português do Brasil (`pt-BR`) |
| Timezone padrão | `America/Sao_Paulo` | | Timezone padrão | `America/Fortaleza` |
| Cidade de atuação | São Paulo (capital) |
| Moeda | BRL, sem conversão entre moedas | | Moeda | BRL, sem conversão entre moedas |
| Princípio principal | YAGNI — implementar somente o necessário para validar o produto | | Princípio principal | YAGNI — implementar somente o necessário para validar o produto |
| Arquitetura | Monólito modular Laravel | | Arquitetura | Monólito modular Laravel |
@@ -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. 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 ## 1. Contrato de operação para agentes
@@ -352,14 +349,13 @@ A home DEVE conter, nesta ordem aproximada:
1. header e navegação; 1. header e navegação;
2. hero com proposta de valor e CTA; 2. hero com proposta de valor e CTA;
3. manifesto da marca; 3. prova visual por eventos em destaque;
4. resumo dos serviços em destaque; 4. resumo dos serviços;
5. casos selecionados do portfólio; 5. método de trabalho;
6. método de trabalho (4 passos); 6. casos selecionados;
7. depoimentos; 7. depoimentos;
8. posicionamento/perfil; 8. CTA final para briefing;
9. CTA final para briefing; 9. footer com contato, redes e links legais.
10. footer com contato, redes e links legais.
A ordem pode variar apenas se a revisão de UX justificar a mudança. 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. 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 ### 6.4 Requisitos de mídia
- Imagens públicas DEVERÃO possuir texto alternativo. - 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. - Dimensões DEVERÃO ser reservadas para evitar layout shift.
- Não armazenar blobs de imagem no PostgreSQL. - Não armazenar blobs de imagem no PostgreSQL.
- Não depender do disco efêmero do contêiner em produção. - 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 ### 6.5 Acessibilidade
@@ -548,10 +534,12 @@ Campos públicos:
- validar no servidor; - validar no servidor;
- usar honeypot e rate limiting; - usar honeypot e rate limiting;
- impedir duplo envio acidental; - 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; - enviar e-mail de confirmação quando o serviço de e-mail estiver configurado;
- exibir sucesso sem revelar dados internos. - falha no e-mail não pode apagar o lead já criado.
> **Estado atual (Fases 01):** 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.
**Aceite:** **Aceite:**
@@ -1265,19 +1253,10 @@ Singleton:
- `id` bigint PK; - `id` bigint PK;
- `brand_name`; - `brand_name`;
- `logo_path` nullable;
- `logo_alt` nullable;
- `hero_eyebrow` nullable; - `hero_eyebrow` nullable;
- `hero_title`; - `hero_title`;
- `hero_subtitle`; - `hero_subtitle`;
- `hero_cta_label`; - `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; - `about_summary` nullable;
- `email`; - `email`;
- `phone`; - `phone`;
@@ -1286,8 +1265,7 @@ Singleton:
- `default_meta_title`; - `default_meta_title`;
- `default_meta_description`; - `default_meta_description`;
- `default_og_image_path` nullable; - `default_og_image_path` nullable;
- `default_og_image_alt` nullable; - analytics fields nullable;
- `analytics_enabled` boolean default false;
- timestamps. - timestamps.
#### `services` #### `services`
@@ -1558,7 +1536,7 @@ Constraints de banco devem proteger:
| Servidor | FrankenPHP + Caddy | | Servidor | FrankenPHP + Caddy |
| Assets | Vite | | Assets | Vite |
| Testes | Pest 4 + Pest Browser/Playwright | | 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 | | Fila | Database queue |
| Scheduler | Laravel Scheduler em processo separado | | 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 ### 11.2 Formulário de briefing
> **Estado atual (Fases 01):** o formulário é implementado em Blade + Controller (`POST /contato`, `ContactBriefingRequest`), conforme WEB-05. Se a Fase 2 mantiver Blade + Controller, os requisitos abaixo valem para o formulário e seus testes independentemente da tecnologia; a criação de Lead segue para a Fase 2.
O componente deve: O componente deve:
- ter estado tipado ou Form Object quando útil; - ter estado tipado ou Form Object quando útil;
@@ -1994,7 +1970,7 @@ Determinismo obrigatório:
- Chromium e imagem Linux fixos; - Chromium e imagem Linux fixos;
- viewport fixo; - viewport fixo;
- timezone `America/Sao_Paulo`; - timezone `America/Fortaleza`;
- locale `pt-BR`; - locale `pt-BR`;
- fontes instaladas na imagem; - fontes instaladas na imagem;
- relógio congelado; - relógio congelado;
@@ -2076,7 +2052,7 @@ quality → Pint check + PHPStan/Larastan + audits + testes
| Job | Responsabilidade | Bloqueia merge | | 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 | | `unit` | Unitários, arquitetura e cobertura | Sim |
| `feature` | PostgreSQL, migrations, Livewire, Filament e integração | Sim | | `feature` | PostgreSQL, migrations, Livewire, Filament e integração | Sim |
| `browser` | Vite, FrankenPHP, E2E, smoke, acessibilidade e visual | Sim | | `browser` | Vite, FrankenPHP, E2E, smoke, acessibilidade e visual | Sim |
@@ -2097,11 +2073,11 @@ quality → Pint check + PHPStan/Larastan + audits + testes
### 14.3 Branches e ambientes ### 14.3 Branches e ambientes
- PR: testes e preview opcional; - PR: testes e preview opcional;
- `main`: build imutável por SHA publicado no GHCR e deploy automático em staging via Dokploy; - `main`: build imutável por SHA e deploy automático em staging;
- staging: Dokploy Compose executa migração, healthcheck `/up` e smoke pós-deploy (`/up`, `/`, `/admin/login`); - staging: migration, cache warmup e smoke pós-deploy;
- produção: promoção da mesma imagem aprovada, sem rebuild (retag do digest em `:production`); - produção: promoção da mesma imagem aprovada, sem rebuild;
- produção requer aprovação humana explícita no MVP (`workflow_dispatch` com confirmação); - produção requer aprovação humana explícita no MVP;
- rollback usa imagem anterior (SHA anterior, sem rebuild); - rollback usa imagem anterior;
- migrations devem ser backward-compatible quando possível. - migrations devem ser backward-compatible quando possível.
### 14.4 Definition of Done ### 14.4 Definition of Done
@@ -2183,7 +2159,7 @@ APP_DEBUG=false
APP_URL APP_URL
APP_LOCALE=pt_BR APP_LOCALE=pt_BR
APP_FALLBACK_LOCALE=pt_BR APP_FALLBACK_LOCALE=pt_BR
APP_TIMEZONE=America/Sao_Paulo APP_TIMEZONE=America/Fortaleza
DB_CONNECTION=pgsql DB_CONNECTION=pgsql
DB_HOST DB_HOST
@@ -2196,16 +2172,19 @@ CACHE_STORE=database ou file conforme ambiente
QUEUE_CONNECTION=database QUEUE_CONNECTION=database
SESSION_DRIVER=database ou cookie conforme decisão SESSION_DRIVER=database ou cookie conforme decisão
FILESYSTEM_DISK=r2 em produção FILESYSTEM_DISK=s3 em produção
R2_ACCESS_KEY_ID AWS_ACCESS_KEY_ID
R2_SECRET_ACCESS_KEY AWS_SECRET_ACCESS_KEY
R2_BUCKET AWS_DEFAULT_REGION
R2_ENDPOINT AWS_BUCKET
R2_URL (domínio próprio opcional) AWS_ENDPOINT 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 AWS_USE_PATH_STYLE_ENDPOINT opcional
MAIL_MAILER=resend em produção (mailer nativo Laravel) MAIL_MAILER
RESEND_API_KEY MAIL_HOST
MAIL_PORT
MAIL_USERNAME
MAIL_PASSWORD
MAIL_FROM_ADDRESS MAIL_FROM_ADDRESS
MAIL_FROM_NAME MAIL_FROM_NAME
``` ```
@@ -2300,7 +2279,7 @@ O seed deve criar:
- configurações do site; - configurações do site;
- 3 serviços; - 3 serviços;
- 3 casos de portfólio; - 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; - leads em estados variados;
- 2 eventos futuros e 1 concluído; - 2 eventos futuros e 1 concluído;
- tarefas vencidas e futuras; - 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] Filament instalado e autenticado;
- [x] Livewire configurado; - [x] Livewire configurado;
- [x] Tailwind/Vite; - [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] papéis admin/assistant;
- [x] Pint; - [x] Pint;
- [x] PHPStan/Larastan; - [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] design tokens mínimos;
- [x] healthcheck; - [x] healthcheck;
- [x] seed de admin local. - [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. **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-008 | Database queue; Redis adiado | Aceita |
| ADR-009 | Dinheiro em BRL armazenado como centavos inteiros | 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-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 |
--- ---

View File

@@ -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 * Error pages carry no canonical, are excluded from search indexes and use
* the page name suffixed with the brand name as their title. * the page name suffixed with the brand name as their title.
@@ -94,13 +94,9 @@ final readonly class PageMeta
SiteSetting $settings, SiteSetting $settings,
int $status = 404, int $status = 404,
): self { ): self {
[$title, $description] = match ($status) { [$title, $description] = $status === 500
500 => ['Algo deu errado', 'Não foi possível concluir o pedido. Tente novamente em instantes.'], ? ['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.'], : ['Página não encontrada', 'A página que você procura não existe ou foi movida.'];
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.'],
};
return new self( return new self(
title: trim($title).' - '.$settings->brand_name, title: trim($title).' - '.$settings->brand_name,

View File

@@ -56,9 +56,6 @@ final class MediaGenerateVariantsCommand extends Command
if ($settings && filled($settings->default_og_image_path)) { if ($settings && filled($settings->default_og_image_path)) {
$paths[] = (string) $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) { foreach (Service::query()->whereNotNull('cover_image_path')->pluck('cover_image_path') as $path) {
$paths[] = (string) $path; $paths[] = (string) $path;

View File

@@ -228,12 +228,6 @@ class ManageSiteSettings extends Page
->addActionLabel('Adicionar rede'), ->addActionLabel('Adicionar rede'),
]) ])
->columns(2), ->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') Section::make('SEO padrão')
->schema([ ->schema([
TextInput::make('default_meta_title') TextInput::make('default_meta_title')

View File

@@ -16,8 +16,6 @@ use Illuminate\Database\Eloquent\Model;
* @property bool $analytics_enabled * @property bool $analytics_enabled
* @property string|null $default_og_image_path * @property string|null $default_og_image_path
* @property string|null $default_og_image_alt * @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_path
* @property string|null $logo_alt * @property string|null $logo_alt
*/ */
@@ -32,8 +30,6 @@ use Illuminate\Database\Eloquent\Model;
'hero_secondary_cta_label', 'hero_secondary_cta_label',
'hero_note', 'hero_note',
'about_summary', 'about_summary',
'about_image_path',
'about_image_alt',
'manifesto_title', 'manifesto_title',
'manifesto_lead', 'manifesto_lead',
'manifesto_body', 'manifesto_body',

12
composer.lock generated
View File

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 151 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 436 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 254 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 258 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 622 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 499 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 244 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 235 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 239 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 236 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 360 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 330 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 302 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 337 KiB

View File

@@ -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']);
});
}
};

View File

@@ -8,7 +8,6 @@ use App\Models\PortfolioCase;
use App\Models\PortfolioImage; use App\Models\PortfolioImage;
use App\Models\Service; use App\Models\Service;
use App\Models\SiteSetting; use App\Models\SiteSetting;
use App\Support\PublicImageUploadRules;
use Illuminate\Database\Seeder; use Illuminate\Database\Seeder;
use Illuminate\Support\Carbon; use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\File; use Illuminate\Support\Facades\File;
@@ -37,8 +36,6 @@ class ContentSeeder extends Seeder
'hero_secondary_cta_label' => 'Conheça nosso olhar', 'hero_secondary_cta_label' => 'Conheça nosso olhar',
'hero_note' => 'Planejamento cuidadoso, comunicação clara e execução segura — do primeiro encontro ao último detalhe.', 'hero_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_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_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_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.', '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']], ['slug' => $service['slug']],
[ [
...$service, ...$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'], 'cover_image_alt' => 'Capa do serviço '.$service['title'],
'published_at' => Carbon::parse(self::SEED_TIMESTAMP), 'published_at' => Carbon::parse(self::SEED_TIMESTAMP),
], ],
@@ -151,7 +148,7 @@ class ContentSeeder extends Seeder
['slug' => $caseData['slug']], ['slug' => $caseData['slug']],
[ [
...$caseData, ...$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'], 'cover_image_alt' => 'Capa do caso '.$caseData['title'],
'is_featured' => true, 'is_featured' => true,
'published_at' => Carbon::parse(self::SEED_TIMESTAMP), 'published_at' => Carbon::parse(self::SEED_TIMESTAMP),
@@ -163,7 +160,7 @@ class ContentSeeder extends Seeder
foreach ([1, 2] as $index) { foreach ([1, 2] as $index) {
PortfolioImage::query()->create([ PortfolioImage::query()->create([
'portfolio_case_id' => $case->id, '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, 'alt_text' => 'Galeria '.$caseData['title'].' '.$index,
'caption' => $index === 1 ? 'Detalhe da decoração' : null, 'caption' => $index === 1 ? 'Detalhe da decoração' : null,
'sort_order' => $index, 'sort_order' => $index,
@@ -174,10 +171,8 @@ class ContentSeeder extends Seeder
private function copyFixture(string $fixtureName, string $destination): string private function copyFixture(string $fixtureName, string $destination): string
{ {
$source = base_path('database/fixtures/images/'.$fixtureName); $source = base_path('tests/fixtures/images/'.$fixtureName);
$disk = PublicImageUploadRules::disk(); Storage::disk('public')->put($destination, File::get($source));
Storage::disk($disk)->put($destination, File::get($source), 'public');
return $destination; return $destination;
} }

View File

@@ -11,6 +11,7 @@ use App\Models\SiteSetting;
use App\Models\Testimonial; use App\Models\Testimonial;
use Illuminate\Database\Seeder; use Illuminate\Database\Seeder;
use Illuminate\Support\Carbon; use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Facades\Storage; use Illuminate\Support\Facades\Storage;
/** /**
@@ -43,8 +44,6 @@ class VisualContentSeeder extends Seeder
'hero_secondary_cta_label' => 'Conheça nosso olhar', 'hero_secondary_cta_label' => 'Conheça nosso olhar',
'hero_note' => 'Planejamento cuidadoso, comunicação clara e execução segura — do primeiro encontro ao último detalhe.', 'hero_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_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_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_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.', '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_title' => 'Amare Assessoria de Eventos',
'default_meta_description' => 'Assessoria premium para casamentos e eventos corporativos em São Paulo.', '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', 'default_og_image_alt' => 'Identidade visual da Amare Assessoria de Eventos',
'analytics_enabled' => false, 'analytics_enabled' => false,
'analytics_script' => null, 'analytics_script' => null,
@@ -90,7 +89,7 @@ class VisualContentSeeder extends Seeder
['slug' => $service['slug']], ['slug' => $service['slug']],
[ [
...$service, ...$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'], 'cover_image_alt' => 'Capa do serviço '.$service['title'],
'published_at' => Carbon::parse(self::SEED_TIMESTAMP), 'published_at' => Carbon::parse(self::SEED_TIMESTAMP),
], ],
@@ -134,7 +133,7 @@ class VisualContentSeeder extends Seeder
['slug' => $caseData['slug']], ['slug' => $caseData['slug']],
[ [
...$caseData, ...$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'], 'cover_image_alt' => 'Capa do caso '.$caseData['title'],
'is_featured' => true, 'is_featured' => true,
'published_at' => Carbon::parse(self::SEED_TIMESTAMP), 'published_at' => Carbon::parse(self::SEED_TIMESTAMP),
@@ -145,7 +144,7 @@ class VisualContentSeeder extends Seeder
PortfolioImage::query()->create([ PortfolioImage::query()->create([
'portfolio_case_id' => $case->id, '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', 'alt_text' => 'Galeria '.$caseData['title'].' 1',
'caption' => 'Detalhe da decoração', 'caption' => 'Detalhe da decoração',
'sort_order' => 1, 'sort_order' => 1,
@@ -187,21 +186,10 @@ class VisualContentSeeder extends Seeder
} }
} }
/** private function copyFixture(string $fixtureName, string $destination): string
* @param array{0: int, 1: int, 2: int} $rgb
*/
private function writeSolidJpeg(string $destination, int $width, int $height, array $rgb): string
{ {
$image = imagecreatetruecolor($width, $height); $source = base_path('tests/fixtures/images/'.$fixtureName);
$color = imagecolorallocate($image, $rgb[0], $rgb[1], $rgb[2]); Storage::disk('public')->put($destination, File::get($source));
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);
return $destination; return $destination;
} }

View File

@@ -14,7 +14,7 @@ services:
restart: "no" restart: "no"
env_file: env_file:
- .env - .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: networks:
- dokploy-network - dokploy-network

View File

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

View File

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

View File

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 170 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 155 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 137 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 192 KiB

View File

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

View File

@@ -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 (100200 ms) em cor/transform/borda. `:focus-visible` continua prioritário; alertas e mensagens de erro não entram no contrato de motion. Alternativa rejeitada: feedback via JS, desnecessário para estados CSS.
6. **Progresso com coalescência por frame.** Eventos de scroll apenas agendam uma atualização por `requestAnimationFrame`; o cálculo existente é preservado. Alternativa rejeitada: calcular em todo evento, que multiplica leituras/escritas durante scroll.
7. **Testes em camadas.** Feature tests comprovam contratos Blade/CSS/JS e renderização de erros; browser tests comprovam entrada, stagger, direções, reduced motion, fallback, interação, overflow e console. Axe/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.

View File

@@ -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.

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

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

View File

@@ -1,7 +1,7 @@
# container-runtime Specification # container-runtime Specification
## Purpose ## 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 ## Requirements
### Requirement: Production image uses multi-stage FrankenPHP build ### Requirement: Production image uses multi-stage FrankenPHP build

View File

@@ -1,7 +1,7 @@
# design-tokens Specification # design-tokens Specification
## Purpose ## 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 ## Requirements
### Requirement: Design tokens are centralized for the public site ### Requirement: Design tokens are centralized for the public site

View File

@@ -1,7 +1,7 @@
# health-check Specification # health-check Specification
## Purpose ## 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 ## Requirements
### Requirement: Public health endpoint responds without authentication ### Requirement: Public health endpoint responds without authentication

View File

@@ -1,7 +1,7 @@
# internal-authentication Specification # internal-authentication Specification
## Purpose ## 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 ## Requirements
### Requirement: Internal users authenticate via Filament panel ### Requirement: Internal users authenticate via Filament panel

View File

@@ -18,13 +18,6 @@
--text-3xl: var(--amare-text-3xl); --text-3xl: var(--amare-text-3xl);
--text-4xl: var(--amare-text-4xl); --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-1: var(--amare-space-1);
--spacing-2: var(--amare-space-2); --spacing-2: var(--amare-space-2);
--spacing-3: var(--amare-space-3); --spacing-3: var(--amare-space-3);
@@ -66,7 +59,6 @@
background-color: var(--amare-color-bg); background-color: var(--amare-color-bg);
color: var(--amare-color-text); color: var(--amare-color-text);
font-family: var(--amare-font-serif); font-family: var(--amare-font-serif);
overflow-wrap: break-word;
} }
[id$='-heading'] { [id$='-heading'] {
@@ -109,43 +101,6 @@
display: flex; 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) { @media (prefers-reduced-motion: no-preference) {
.main-nav { .main-nav {
transition: opacity var(--amare-duration-normal) var(--amare-ease-standard); transition: opacity var(--amare-duration-normal) var(--amare-ease-standard);
@@ -156,10 +111,6 @@
overflow: hidden; overflow: hidden;
} }
main {
overflow-x: clip;
}
/* Dossiê vivo — content visible by default; enhance only when opted in */ /* Dossiê vivo — content visible by default; enhance only when opted in */
[data-chapter-index] { [data-chapter-index] {
display: none; display: none;
@@ -217,7 +168,6 @@
text-transform: uppercase; text-transform: uppercase;
color: var(--amare-color-muted); color: var(--amare-color-muted);
text-decoration: none; text-decoration: none;
overflow-wrap: break-word;
transition: color var(--amare-duration-fast) var(--amare-ease-standard); transition: color var(--amare-duration-fast) var(--amare-ease-standard);
} }
@@ -240,81 +190,60 @@
} }
@media (prefers-reduced-motion: no-preference) { @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). */ /* 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] { html[data-motion="enhance"] [data-motion="dossie-hero"] [data-motion-beat="seal"],
transform: translateY(var(--amare-motion-distance-y)); 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); 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; transform: none;
clip-path: inset(0 0 0 0); clip-path: inset(0 0 0 0);
transition: transition:
transform var(--amare-motion-enter) var(--amare-ease-arrival), transform var(--amare-duration-slow) var(--amare-ease-arrival),
clip-path var(--amare-motion-enter) var(--amare-ease-arrival); clip-path var(--amare-duration-focal) var(--amare-ease-arrival);
transition-delay: min(
calc(var(--motion-index, 0) * var(--amare-motion-stagger)),
var(--amare-motion-stagger-cap)
);
} }
html[data-motion="enhance"] [data-reveal] { html[data-motion="enhance"] [data-motion="dossie-hero"].is-active [data-motion-beat="seal"] {
--motion-translate-x: 0; transition-delay: 0ms;
--motion-translate-y: var(--amare-motion-distance-y);
} }
html[data-motion="enhance"] [data-reveal-from="left"] { html[data-motion="enhance"] [data-motion="dossie-hero"].is-active [data-motion-beat="title"] {
--motion-translate-x: calc(-1 * var(--amare-motion-distance-x-mobile)); transition-delay: 80ms;
--motion-translate-y: 0;
} }
html[data-motion="enhance"] [data-reveal-from="right"] { html[data-motion="enhance"] [data-motion="dossie-hero"].is-active [data-motion-beat="media"] {
--motion-translate-x: var(--amare-motion-distance-x-mobile); transition-delay: 120ms;
--motion-translate-y: 0;
} }
@media (min-width: 768px) { html[data-motion="enhance"] [data-motion="dossie-hero"].is-active [data-motion-beat="cta"] {
html[data-motion="enhance"] [data-reveal-from="left"] { transition-delay: 220ms;
--motion-translate-x: calc(-1 * var(--amare-motion-distance-x));
}
html[data-motion="enhance"] [data-reveal-from="right"] {
--motion-translate-x: var(--amare-motion-distance-x);
}
} }
html[data-motion="enhance"] [data-reveal]:not(.is-revealed) { 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 { html[data-motion="enhance"] [data-reveal].is-revealed {
transform: none; transform: none;
clip-path: inset(0 0 0 0); transition: transform var(--amare-duration-slow) var(--amare-ease-arrival);
transition: }
transform var(--amare-motion-enter) var(--amare-ease-arrival),
clip-path var(--amare-motion-enter) var(--amare-ease-arrival); html[data-motion="enhance"] [data-motion="page-open"]:not(.is-active) {
transition-delay: min( transform: translateY(0.4rem);
calc(var(--motion-index, 0) * var(--amare-motion-stagger)), }
var(--amare-motion-stagger-cap)
); html[data-motion="enhance"] [data-motion="page-open"].is-active {
transform: none;
transition: transform var(--amare-duration-normal) var(--amare-ease-arrival);
} }
} }
} }

View File

@@ -59,15 +59,6 @@
--amare-duration-focal: 720ms; --amare-duration-focal: 720ms;
--amare-ease-standard: cubic-bezier(0.4, 0, 0.2, 1); --amare-ease-standard: cubic-bezier(0.4, 0, 0.2, 1);
--amare-ease-arrival: cubic-bezier(0.16, 1, 0.3, 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) { @media (prefers-reduced-motion: reduce) {
@@ -76,13 +67,6 @@
--amare-duration-normal: 0.01ms; --amare-duration-normal: 0.01ms;
--amare-duration-slow: 0.01ms; --amare-duration-slow: 0.01ms;
--amare-duration-focal: 0.01ms; --amare-duration-focal: 0.01ms;
--amare-motion-enter: 0.01ms;
--amare-motion-feedback: 0.01ms;
--amare-motion-stagger: 0ms;
--amare-motion-stagger-cap: 0ms;
--amare-motion-distance-y: 0px;
--amare-motion-distance-x: 0px;
--amare-motion-distance-x-mobile: 0px;
} }
*, *,

View File

@@ -6,7 +6,6 @@ document.addEventListener('DOMContentLoaded', () => {
if (menuButton && navigation) { if (menuButton && navigation) {
const isDesktop = () => window.matchMedia('(min-width: 768px)').matches; const isDesktop = () => window.matchMedia('(min-width: 768px)').matches;
const isMenuOpen = () => navigation.classList.contains('is-open');
const setOpen = (isOpen, { returnFocus = false } = {}) => { const setOpen = (isOpen, { returnFocus = false } = {}) => {
navigation.classList.toggle('is-open', isOpen); navigation.classList.toggle('is-open', isOpen);
@@ -40,27 +39,6 @@ document.addEventListener('DOMContentLoaded', () => {
navigation.addEventListener('keydown', (event) => { navigation.addEventListener('keydown', (event) => {
if (event.key === 'Escape') { if (event.key === 'Escape') {
setOpen(false, { returnFocus: true }); 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) => { 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', () => { form.addEventListener('submit', () => {
if (submitButton) { const button = form.querySelector('[data-submit-button]');
submitButton.dataset.defaultLabel ??= submitButton.textContent.trim();
submitButton.textContent = 'Enviando…';
submitButton.disabled = true;
submitButton.setAttribute('aria-busy', 'true');
}
form.setAttribute('aria-busy', 'true'); if (button) {
}); button.disabled = true;
button.setAttribute('aria-busy', 'true');
window.addEventListener('pageshow', (event) => {
if (event.persisted) {
resetSubmitState();
} }
}); });
}); });

View File

@@ -5,7 +5,7 @@ function prefersReducedMotion() {
} }
function enableEnhancement() { function enableEnhancement() {
if (prefersReducedMotion() || typeof IntersectionObserver !== 'function') { if (prefersReducedMotion()) {
document.documentElement.removeAttribute('data-motion'); document.documentElement.removeAttribute('data-motion');
return false; return false;
} }
@@ -14,6 +14,24 @@ function enableEnhancement() {
return true; 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) { function activatePageOpen(enhance) {
document.querySelectorAll('[data-motion="page-open"]').forEach((node) => { document.querySelectorAll('[data-motion="page-open"]').forEach((node) => {
if (!enhance) { 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) { function observeReveals(enhance) {
const nodes = Array.from(document.querySelectorAll('[data-reveal]')); const nodes = Array.from(document.querySelectorAll('[data-reveal]'));
if (nodes.length === 0) { if (nodes.length === 0) {
@@ -94,7 +86,6 @@ function setupChapterIndex() {
} }
const links = Array.from(index.querySelectorAll('a[href^="#"]')); const links = Array.from(index.querySelectorAll('a[href^="#"]'));
let framePending = false;
const setActive = (id) => { const setActive = (id) => {
links.forEach((link) => { links.forEach((link) => {
@@ -138,28 +129,16 @@ function setupChapterIndex() {
updateProgress(window.scrollY / max); updateProgress(window.scrollY / max);
}; };
const scheduleSync = () => {
if (framePending) {
return;
}
framePending = true;
requestAnimationFrame(() => {
framePending = false;
sync();
});
};
setActive(chapters[0].getAttribute('aria-labelledby') || 'hero-heading'); setActive(chapters[0].getAttribute('aria-labelledby') || 'hero-heading');
sync(); sync();
window.addEventListener('scroll', scheduleSync, { passive: true }); window.addEventListener('scroll', sync, { passive: true });
window.addEventListener('resize', scheduleSync); window.addEventListener('resize', sync);
} }
function bootMotion() { function bootMotion() {
initializeMotionDelays();
const enhance = enableEnhancement(); const enhance = enableEnhancement();
activateHero(enhance);
activatePageOpen(enhance); activatePageOpen(enhance);
observeReveals(enhance); observeReveals(enhance);
setupChapterIndex(); setupChapterIndex();
@@ -167,7 +146,7 @@ function bootMotion() {
window.matchMedia(MOTION_QUERY).addEventListener('change', (event) => { window.matchMedia(MOTION_QUERY).addEventListener('change', (event) => {
if (event.matches) { if (event.matches) {
document.documentElement.removeAttribute('data-motion'); 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'); node.classList.add('is-active');
}); });
document.querySelectorAll('[data-reveal]').forEach((node) => { document.querySelectorAll('[data-reveal]').forEach((node) => {

View File

@@ -1,19 +1,11 @@
@props([ @props([
'settings', 'settings',
'editorial' => false,
]) ])
<section aria-labelledby="final-cta-heading" @class([ <section aria-labelledby="final-cta-heading" class="border-t border-amare-border bg-amare-bg-deep py-20" data-chapter="final-cta">
'border-t border-amare-border bg-amare-bg-deep py-20', <div class="container-amare space-y-6 text-center" data-reveal>
'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> <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-3xl font-medium text-amare-text md:text-4xl">Do casamento ao evento corporativo, tudo começa com uma boa conversa.</h2>
<h2 id="final-cta-heading" class="text-headline font-medium text-amare-text">Do casamento ao evento corporativo, tudo começa com uma boa conversa.</h2>
<p class="mx-auto max-w-2xl text-amare-muted"> <p class="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. Compartilhe as primeiras informações do seu evento. A Amare retorna para entender o contexto e orientar os próximos passos.
</p> </p>
@@ -22,7 +14,7 @@
href="{{ route('contact') }}" 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" 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> </a>
</div> </div>
</div> </div>

View File

@@ -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>

View File

@@ -4,15 +4,11 @@
<section <section
aria-labelledby="hero-heading" 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-chapter="hero"
data-motion="page-open" data-motion="dossie-hero"
> >
<div class="container-amare pt-8 md:pt-10"> <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">
<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="space-y-8"> <div class="space-y-8">
<div data-motion-beat="seal" class="flex items-center gap-4"> <div data-motion-beat="seal" class="flex items-center gap-4">
<x-brand.logo mark variant="on-light" class="h-8 w-auto" alt="" /> <x-brand.logo mark variant="on-light" class="h-8 w-auto" alt="" />
@@ -21,8 +17,8 @@
@endif @endif
</div> </div>
<h1 id="hero-heading" data-motion-beat="title" class="max-w-3xl text-display font-medium text-amare-text"> <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 ?: 'Celebrações com propósito' }} {{ $settings->hero_title }}
</h1> </h1>
@if (filled($settings->hero_subtitle)) @if (filled($settings->hero_subtitle))
@@ -35,7 +31,7 @@
data-testid="home-primary-cta" 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" 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> </a>
@if (filled($settings->hero_secondary_cta_label)) @if (filled($settings->hero_secondary_cta_label))

View File

@@ -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.'; $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 @endphp
<section aria-labelledby="manifesto-heading" class="home-chapter border-b border-amare-border bg-amare-bg-deep py-20" data-chapter="manifesto"> <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-group> <div class="container-amare grid gap-8 md:grid-cols-[minmax(0,0.8fr)_minmax(0,1.2fr)]" data-reveal>
<div class="space-y-5 md:pr-8" data-reveal data-reveal-from="up"> <p class="text-sm font-medium uppercase tracking-[0.18em] text-amare-accent">Manifesto</p>
<x-home.folio label="Manifesto" /> <div class="space-y-6">
<span class="block h-px w-16 bg-amare-border" aria-hidden="true"></span> <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-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>
<p class="max-w-2xl text-xl leading-relaxed text-amare-text">{{ $lead }}</p> <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> <p class="max-w-2xl text-amare-text-muted">{{ $body }}</p>
</div> </div>

View File

@@ -7,24 +7,17 @@
$intro = $settings->method_intro ?: 'Clareza em cada etapa. Tranquilidade durante todo o processo.'; $intro = $settings->method_intro ?: 'Clareza em cada etapa. Tranquilidade durante todo o processo.';
@endphp @endphp
<section aria-labelledby="method-heading" class="home-chapter border-b border-amare-border bg-amare-bg-archive py-20" data-chapter="method"> <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" data-reveal-group> <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-4" data-reveal data-reveal-from="up"> <div class="space-y-3" data-reveal>
<x-home.folio label="Método" /> <p class="text-sm font-medium uppercase tracking-[0.18em] text-amare-accent">Método</p>
<h2 id="method-heading" class="text-headline font-medium text-amare-text">Cuidado orientado por processo.</h2> <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> <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> </div>
<ol class="grid gap-5 border-t border-amare-border"> <ol class="grid gap-5 border-t border-amare-border">
@foreach ($steps as $index => $step) @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> <span class="text-sm text-amare-accent">{{ str_pad((string) ($index + 1), 2, '0', STR_PAD_LEFT) }}</span>
<div class="space-y-2"> <div class="space-y-2">
<h3 class="text-2xl font-medium text-amare-text">{{ $step['title'] ?? '' }}</h3> <h3 class="text-2xl font-medium text-amare-text">{{ $step['title'] ?? '' }}</h3>

View File

@@ -3,19 +3,19 @@
]) ])
@if ($cases->isNotEmpty()) @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"> <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" data-reveal-group> <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 data-reveal-from="up"> <div class="grid gap-4 md:grid-cols-[minmax(0,0.8fr)_minmax(0,1.2fr)]" data-reveal>
<x-home.folio label="Portfólio" tone="inverse" /> <p class="text-sm font-medium uppercase tracking-[0.18em] text-amare-accent-text/80">Portfólio</p>
<div class="space-y-3"> <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> <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> </div>
<div class="grid gap-10 md:grid-cols-2"> <div class="grid gap-10 md:grid-cols-2">
@foreach ($cases as $case) @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)) @if (filled($case->cover_image_path))
<x-media.image <x-media.image
:path="$case->cover_image_path" :path="$case->cover_image_path"

View File

@@ -7,17 +7,13 @@
$principles = filled($settings->principles) ? $settings->principles : \App\Models\SiteSetting::defaultPrinciples(); $principles = filled($settings->principles) ? $settings->principles : \App\Models\SiteSetting::defaultPrinciples();
@endphp @endphp
<section aria-labelledby="positioning-heading" class="home-chapter border-b border-amare-border py-20" data-chapter="positioning"> <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)]" data-reveal-group> <div class="container-amare grid gap-10 md:grid-cols-[minmax(0,0.8fr)_minmax(0,1.2fr)]">
<div class="space-y-4" data-reveal data-reveal-from="up"> <div class="space-y-3" data-reveal>
<x-home.folio label="A Amare" /> <p class="text-sm font-medium uppercase tracking-[0.18em] text-amare-accent">A Amare</p>
<div class="flex items-center gap-4"> <h2 id="positioning-heading" class="text-3xl font-medium text-amare-text">Presença que organiza o essencial.</h2>
<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>
</div> </div>
<h2 id="positioning-heading" class="text-headline font-medium text-amare-text">Presença que organiza o essencial.</h2> <div class="space-y-8" data-reveal>
</div>
<div class="space-y-8" data-reveal data-reveal-from="up">
<p class="max-w-2xl text-xl leading-relaxed text-amare-text">{{ $summary }}</p> <p class="max-w-2xl text-xl leading-relaxed text-amare-text">{{ $summary }}</p>
<ul class="grid gap-3 border-t border-amare-border"> <ul class="grid gap-3 border-t border-amare-border">
@foreach ($principles as $principle) @foreach ($principles as $principle)

View File

@@ -3,22 +3,17 @@
]) ])
@if ($services->isNotEmpty()) @if ($services->isNotEmpty())
<section aria-labelledby="services-heading" class="home-chapter border-b border-amare-border py-20" data-chapter="services"> <section aria-labelledby="services-heading" class="border-b border-amare-border py-20" data-chapter="services">
<div class="container-amare space-y-10" data-reveal-group> <div class="container-amare space-y-10">
<div class="grid gap-6 md:grid-cols-12 md:items-end" data-reveal data-reveal-from="up"> <div class="max-w-2xl space-y-3" data-reveal>
<div class="space-y-3 md:col-span-4"> <p class="text-sm font-medium uppercase tracking-[0.18em] text-amare-accent">Atuação</p>
<x-home.folio label="Serviços" />
<h2 id="services-heading" class="text-3xl font-medium text-amare-text">Serviços</h2> <h2 id="services-heading" class="text-3xl font-medium text-amare-text">Serviços</h2>
</div> <p class="text-amare-text-muted">Assessoria sob medida para decisões importantes e celebrações bem conduzidas.</p>
<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>
</div> </div>
<ol class="border-t border-amare-border"> <ol class="border-t border-amare-border">
@foreach ($services as $index => $service) @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> <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> <h3 class="text-2xl font-medium text-amare-text">{{ $service->title }}</h3>
<p class="text-amare-text-muted">{{ $service->summary }}</p> <p class="text-amare-text-muted">{{ $service->summary }}</p>

View File

@@ -2,18 +2,11 @@
'testimonials', 'testimonials',
]) ])
@php
$testimonials = $testimonials
->filter(fn ($testimonial): bool => filled(trim((string) $testimonial->quote)))
->values();
@endphp
@if ($testimonials->isNotEmpty()) @if ($testimonials->isNotEmpty())
<section aria-labelledby="testimonials-heading" class="home-chapter border-b border-amare-border bg-amare-bg py-16" data-chapter="testimonials"> <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" data-reveal-group> <div class="container-amare space-y-8">
<div class="max-w-2xl space-y-3" data-reveal data-reveal-from="up"> <div class="max-w-2xl space-y-3" data-reveal>
<x-home.folio label="Depoimentos" /> <h2 id="testimonials-heading" class="text-3xl font-semibold text-amare-text">Depoimentos</h2>
<h2 id="testimonials-heading" class="text-3xl font-medium text-amare-text">Depoimentos</h2>
<p class="text-amare-text-muted">Quem celebrou com a Amare conta como foi a experiência.</p> <p class="text-amare-text-muted">Quem celebrou com a Amare conta como foi a experiência.</p>
</div> </div>
@@ -23,16 +16,13 @@
$paragraphs = preg_split('/\n\s*\n/', trim((string) $testimonial->quote)) ?: []; $paragraphs = preg_split('/\n\s*\n/', trim((string) $testimonial->quote)) ?: [];
$paragraphs = array_values(array_filter(array_map('trim', $paragraphs), fn (string $p): bool => $p !== '')); $paragraphs = array_values(array_filter(array_map('trim', $paragraphs), fn (string $p): bool => $p !== ''));
@endphp @endphp
<blockquote class="space-y-4 border-t border-amare-border pt-4" data-reveal data-reveal-from="{{ $loop->odd ? 'left' : 'right' }}"> <blockquote class="space-y-4 border-t border-amare-border pt-4" data-reveal>
<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"> <div class="space-y-3 text-lg text-amare-text">
@foreach ($paragraphs as $paragraph) @foreach ($paragraphs as $index => $paragraph)
<p>{{ $paragraph }}</p> <p>@if ($index === 0)@endif{{ $paragraph }}@if ($index === count($paragraphs) - 1)@endif</p>
@endforeach @endforeach
</div> </div>
</div> <footer class="text-sm text-amare-text-muted">
<footer class="pl-8 text-sm text-amare-text-muted">
<cite class="not-italic font-semibold text-amare-text">{{ $testimonial->author_name }}</cite> <cite class="not-italic font-semibold text-amare-text">{{ $testimonial->author_name }}</cite>
@if (filled($testimonial->context)) @if (filled($testimonial->context))
<span> {{ $testimonial->context }}</span> <span> {{ $testimonial->context }}</span>

View File

@@ -33,7 +33,7 @@
$resolvedWidth = $width ?? $dimensions['width'] ?? null; $resolvedWidth = $width ?? $dimensions['width'] ?? null;
$resolvedHeight = $height ?? $dimensions['height'] ?? null; $resolvedHeight = $height ?? $dimensions['height'] ?? null;
$loadingValue = $loading; $loadingValue = $loading === 'eager' ? 'eager' : 'lazy';
@endphp @endphp
<img <img

View File

@@ -15,11 +15,11 @@
@extends('layouts.public') @extends('layouts.public')
@section('content') @section('content')
<div class="container-amare max-w-2xl space-y-6 py-20 text-center" data-motion="page-open" data-reveal-group> <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" data-motion-beat="seal">Erro 404</p> <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" data-motion-beat="title">Página não encontrada</h1> <h1 class="text-4xl font-medium tracking-tight text-amare-text">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 class="text-amare-muted">O endereço que você tentou abrir não existe ou foi movido.</p>
<p data-reveal data-reveal-from="up"> <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"> <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 Voltar para a home
</a> </a>

View File

@@ -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

View File

@@ -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

View File

@@ -15,11 +15,11 @@
@extends('layouts.public') @extends('layouts.public')
@section('content') @section('content')
<div class="container-amare max-w-2xl space-y-6 py-20 text-center" data-motion="page-open" data-reveal-group> <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" data-motion-beat="seal">Erro 500</p> <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" data-motion-beat="title">Algo deu errado</h1> <h1 class="text-4xl font-medium tracking-tight text-amare-text">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 class="text-amare-muted">Não foi possível concluir o pedido agora. Tente novamente em instantes.</p>
<p data-reveal data-reveal-from="up"> <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"> <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 Voltar para a home
</a> </a>

View File

@@ -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

View File

@@ -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('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('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('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> </ul>
</div> </div>
@@ -106,18 +107,18 @@
@endif @endif
@if ($siteSettings->email) @if ($siteSettings->email)
<p> <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> </p>
@endif @endif
@if ($siteSettings->phone) @if ($siteSettings->phone)
<p> <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> </p>
@endif @endif
@foreach ($siteSettings->social_links ?? [] as $network => $url) @foreach ($siteSettings->social_links ?? [] as $network => $url)
@if (filled($url)) @if (filled($url))
<p> <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> </p>
@endif @endif
@endforeach @endforeach

View File

@@ -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="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="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> <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-lg text-amare-muted">{{ $siteSettings->about_summary }}</p>
<p class="text-amare-muted"> <p class="text-amare-muted">
A {{ $siteSettings->brand_name }} atua em {{ $city }} com foco em planejamento completo, 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. presença no dia do evento e uma condução serena do início ao fim.
</p> </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> </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) @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 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> <span>{{ $principle }}</span>
</li> </li>
@@ -42,6 +30,4 @@
</ul> </ul>
</div> </div>
</div> </div>
<x-home.final-cta :settings="$siteSettings" />
@endsection @endsection

View File

@@ -1,32 +1,32 @@
@extends('layouts.public') @extends('layouts.public')
@section('content') @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="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> <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"> <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. Conte sobre o seu evento no briefing abaixo. Retornaremos com uma proposta sob medida e sem compromisso.
</p> </p>
</div> </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> <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 data-reveal data-reveal-from="up">{{ $siteSettings->city ?: 'São Paulo - SP' }}</p> <p>{{ $siteSettings->city ?: 'São Paulo - SP' }}</p>
@if ($siteSettings->email) @if ($siteSettings->email)
<p data-reveal data-reveal-from="up"> <p>
<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> <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> </p>
@endif @endif
@if ($siteSettings->phone) @if ($siteSettings->phone)
<p data-reveal data-reveal-from="up"> <p>
<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> <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> </p>
@endif @endif
@foreach ($siteSettings->social_links ?? [] as $network => $url) @foreach ($siteSettings->social_links ?? [] as $network => $url)
@if (filled($url)) @if (filled($url))
<p data-reveal data-reveal-from="up"> <p>
<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> <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> </p>
@endif @endif
@endforeach @endforeach
@@ -35,9 +35,9 @@
</section> </section>
<section class="border-b border-amare-border bg-amare-bg" aria-labelledby="briefing-heading"> <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> <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" data-reveal data-reveal-from="up">Briefing de contato</h2> <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" data-reveal data-reveal-from="up"> <p class="mt-2 max-w-2xl text-amare-muted">
Preencha os campos abaixo. As informações obrigatórias estão marcadas com asterisco (*). Preencha os campos abaixo. As informações obrigatórias estão marcadas com asterisco (*).
</p> </p>

View File

@@ -2,10 +2,6 @@
@section('content') @section('content')
@php @php
$testimonials = $content->testimonials
->filter(fn ($testimonial): bool => filled(trim((string) $testimonial->quote)))
->values();
$chapters = [ $chapters = [
['id' => 'hero-heading', 'label' => 'Capa'], ['id' => 'hero-heading', 'label' => 'Capa'],
['id' => 'manifesto-heading', 'label' => 'Manifesto'], ['id' => 'manifesto-heading', 'label' => 'Manifesto'],
@@ -21,7 +17,7 @@
$chapters[] = ['id' => 'method-heading', 'label' => 'Método']; $chapters[] = ['id' => 'method-heading', 'label' => 'Método'];
if ($testimonials->isNotEmpty()) { if ($content->testimonials->isNotEmpty()) {
$chapters[] = ['id' => 'testimonials-heading', 'label' => 'Depoimentos']; $chapters[] = ['id' => 'testimonials-heading', 'label' => 'Depoimentos'];
} }
@@ -31,7 +27,6 @@
<x-home.chapter-index :chapters="$chapters" /> <x-home.chapter-index :chapters="$chapters" />
<div class="home-chapters">
<x-home.hero :settings="$content->settings" /> <x-home.hero :settings="$content->settings" />
<x-home.manifesto :settings="$content->settings" /> <x-home.manifesto :settings="$content->settings" />
<x-home.services :services="$content->featuredServices" /> <x-home.services :services="$content->featuredServices" />
@@ -39,6 +34,5 @@
<x-home.method :settings="$content->settings" /> <x-home.method :settings="$content->settings" />
<x-home.testimonials :testimonials="$content->testimonials" /> <x-home.testimonials :testimonials="$content->testimonials" />
<x-home.positioning :settings="$content->settings" /> <x-home.positioning :settings="$content->settings" />
<x-home.final-cta :settings="$content->settings" editorial /> <x-home.final-cta :settings="$content->settings" />
</div>
@endsection @endsection

View File

@@ -3,18 +3,18 @@
@section('content') @section('content')
<div class="border-b border-amare-border bg-amare-bg-deep" data-motion="page-open"> <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="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> <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> <p class="text-lg text-amare-muted">Casos conduzidos com atenção a ritmo, composição e cada detalhe da experiência.</p>
</div> </div>
@if ($cases->isEmpty()) @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> <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 @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) @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)) @if (filled($case->cover_image_path))
<a href="{{ route('portfolio.show', $case->slug) }}" class="block overflow-hidden"> <a href="{{ route('portfolio.show', $case->slug) }}" class="block overflow-hidden">
<x-media.image <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> <p class="text-sm text-amare-accent">Imagens demonstrativas enquanto o acervo autorizado da Amare está em organização.</p>
</div> </div>
</div> </div>
<x-home.final-cta :settings="$siteSettings" />
@endsection @endsection

View File

@@ -4,13 +4,11 @@
<article data-motion="page-open"> <article data-motion="page-open">
<div class="border-b border-amare-border bg-amare-bg"> <div class="border-b border-amare-border bg-amare-bg">
<div class="container-amare space-y-8 py-16 md:py-24"> <div class="container-amare space-y-8 py-16 md:py-24">
<header class="max-w-3xl space-y-4" data-motion-beat="heading"> <header class="max-w-3xl space-y-4">
@if (filled($case->event_type))
<p class="text-xs font-semibold uppercase tracking-[0.14em] text-amare-accent">{{ $case->event_type }}</p> <p class="text-xs font-semibold uppercase tracking-[0.14em] text-amare-accent">{{ $case->event_type }}</p>
@endif <h1 class="text-4xl font-medium tracking-tight text-amare-text md:text-5xl">{{ $case->title }}</h1>
<h1 class="text-headline font-medium tracking-tight text-amare-text">{{ $case->title }}</h1>
<p class="text-lg text-amare-muted">{{ $case->summary }}</p> <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->city){{ $case->city }}@endif
@if ($case->venue) · {{ $case->venue }}@endif @if ($case->venue) · {{ $case->venue }}@endif
@if ($case->event_date) · {{ $case->event_date->format('d/m/Y') }}@endif @if ($case->event_date) · {{ $case->event_date->format('d/m/Y') }}@endif
@@ -24,24 +22,23 @@
loading="eager" loading="eager"
sizes="(max-width: 1024px) 100vw, 1120px" sizes="(max-width: 1024px) 100vw, 1120px"
class="img-editorial aspect-[16/9] w-full object-cover" class="img-editorial aspect-[16/9] w-full object-cover"
data-motion-beat="media"
/> />
@endif @endif
</div> </div>
</div> </div>
<div class="border-b border-amare-border bg-amare-bg-deep"> <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> <div class="container-amare grid gap-10 py-16 md:grid-cols-3">
<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">Desafio</h2> <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> <p class="text-amare-muted">{!! nl2br(e($case->challenge)) !!}</p>
</section> </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> <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> <p class="text-amare-muted">{!! nl2br(e($case->solution)) !!}</p>
</section> </section>
@if (filled($case->result)) @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> <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> <p class="text-amare-muted">{!! nl2br(e($case->result)) !!}</p>
</section> </section>
@@ -53,9 +50,9 @@
<section aria-labelledby="gallery-heading" class="bg-amare-bg"> <section aria-labelledby="gallery-heading" class="bg-amare-bg">
<div class="container-amare space-y-8 py-16"> <div class="container-amare space-y-8 py-16">
<h2 id="gallery-heading" class="text-3xl font-medium text-amare-text">Galeria</h2> <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) @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 <x-media.image
:path="$image->path" :path="$image->path"
:alt="$image->alt_text" :alt="$image->alt_text"
@@ -72,6 +69,4 @@
</section> </section>
@endif @endif
</article> </article>
<x-home.final-cta :settings="$siteSettings" />
@endsection @endsection

View File

@@ -1,17 +1,17 @@
@extends('layouts.public') @extends('layouts.public')
@section('content') @section('content')
<div class="border-b border-amare-border bg-amare-bg" data-motion="page-open"> <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" data-reveal-group> <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" data-motion-beat="seal">Privacidade</p> <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" data-motion-beat="title">Política de privacidade</h1> <h1 class="text-4xl font-medium tracking-tight text-amare-text">Política de privacidade</h1>
<p class="text-amare-muted" data-reveal data-reveal-from="up"> <p class="text-amare-muted">
A {{ $siteSettings->brand_name }} trata dados pessoais com responsabilidade e somente para finalidades A {{ $siteSettings->brand_name }} trata dados pessoais com responsabilidade e somente para finalidades
relacionadas ao atendimento de interessados e à operação do site. relacionadas ao atendimento de interessados e à operação do site.
</p> </p>
<p class="text-amare-muted" data-reveal data-reveal-from="up"> <p class="text-amare-muted">
Para dúvidas sobre privacidade, escreva para 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> </p>
</div> </div>
</div> </div>

View File

@@ -3,18 +3,18 @@
@section('content') @section('content')
<div class="border-b border-amare-border bg-amare-bg" data-motion="page-open"> <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="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> <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> <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> </div>
@if ($services->isEmpty()) @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> <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 @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) @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> <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"> <div class="space-y-3">
<h2 class="text-2xl font-medium text-amare-text md:text-3xl">{{ $service->title }}</h2> <h2 class="text-2xl font-medium text-amare-text md:text-3xl">{{ $service->title }}</h2>
@@ -41,6 +41,4 @@
@endif @endif
</div> </div>
</div> </div>
<x-home.final-cta :settings="$siteSettings" />
@endsection @endsection

View File

@@ -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] 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] Mobile: sem overflow horizontal nas 7 rotas (390px); menu mobile abre/fecha + Escape + retorno de foco
- [x] Relatório de entrega - [x] Relatório de entrega
## T7 — Hardening Public Blades
- [x] Chunk 1: CSS overflow resilience (`overflow-wrap` body + `[data-chapter-index]` wrap safety, browser test)
- [x] Chunk 2: Hero + final-CTA fallbacks + `leading-tight` (TDD)
- [x] Chunk 3a: Skip blank-quote testimonials (TDD)
- [x] Chunk 3b: Guard empty `event_type` + `break-words` meta line (TDD)
- [x] Chunk 4: Extend `PageMeta::forErrorPage` + branded 419/429/503 error views (TDD)
- [x] Chunk 5: `app.js` defensive hardening — bfcache submit reset, "Enviando…" label swap, aria-busy, mobile-menu focus trap + Escape
- [x] Chunk 6: Quality gates — Pint passed, PHPStan clean, 104 feature tests passed (587 assertions), Vite build succeeded
### Deviations from plan
- `PageMeta::forErrorPage` landed on `main` during implementation; rebased and extended it for 419/429/503.
- Contact form landed on `main`; submit-state browser coverage was restored.
- `event_type: null` violates NOT NULL column — test uses `''` instead (guard covers both).
# Browser CI Gate — estabilidade e diagnosticabilidade
## BG — Causa raiz
- [x] Diagnosticar 3 runs: mismatch visual (8 testes), acessibilidade (1), timeout networkidle 5s + overflow real
- [x] Reproduzir local: `waitForLoadState('networkidle')` fragil (conexões longas), default 5s
- [x] Reproduzir warning `file_get_contents(.env)` em TODO teste (CI sem .env)
## BG — Fixes
- [x] Helper `StableScreenshot` (tests/Support/): drop networkidle → readyState + fonts + settle
- [x] Exportar PNGs standalone (diff/expected/actual) no mismatch — visíveis nos artifacts
- [x] CI: `cp .env.example .env` nos jobs de teste; checkout/cache v5
- [x] Gitignore tests/Browser/Screenshots
- [x] Unit test MismatchScreenshotExporter; pint/phpstan/unit/feature verdes
- [x] Push + PR + CI browser verde (PR #15 merge f09ea83, run 31098941038)
- [x] Refresh baselines de CI se necessário (não necessário — browser passou com baselines atuais)

View File

@@ -23,10 +23,6 @@ it('has no critical or serious accessibility issues on covered public routes', f
'/servicos', '/servicos',
'/portfolio', '/portfolio',
'/portfolio/'.$case->slug, '/portfolio/'.$case->slug,
'/sobre',
'/contato',
'/privacidade',
'/__missing-accessibility-page',
]; ];
foreach ($routes as $route) { foreach ($routes as $route) {
@@ -92,16 +88,7 @@ it('loads covered public routes without console errors', function (): void {
'slug' => 'casamento-jardim', 'slug' => 'casamento-jardim',
]); ]);
foreach ([ foreach (['/', '/servicos', '/portfolio', '/portfolio/'.$case->slug] as $route) {
'/',
'/servicos',
'/portfolio',
'/portfolio/'.$case->slug,
'/sobre',
'/contato',
'/privacidade',
'/__missing-console-page',
] as $route) {
$this->visit($route) $this->visit($route)
->assertNoJavaScriptErrors(); ->assertNoJavaScriptErrors();
} }

View File

@@ -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();
});

View File

@@ -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,
]);
}
});

View File

@@ -2,10 +2,8 @@
declare(strict_types=1); declare(strict_types=1);
use App\Models\PortfolioCase;
use App\Models\Service; use App\Models\Service;
use App\Models\SiteSetting; use App\Models\SiteSetting;
use App\Models\Testimonial;
use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class); 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('/', [ $page = $this->visit('/', [
'reducedMotion' => 'reduce', 'reducedMotion' => 'reduce',
]); ]);
$state = $page->script(<<<'JS' $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 title = document.querySelector('#hero-heading');
const index = document.querySelector('[data-chapter-index]'); const index = document.querySelector('[data-chapter-index]');
const active = document.querySelector('[data-chapter-index] [aria-current="true"]'); const active = document.querySelector('[data-chapter-index] [aria-current="true"]');
const titleStyle = title ? getComputedStyle(title) : null; const titleStyle = title ? getComputedStyle(title) : null;
const finalReveals = [...document.querySelectorAll('[data-reveal]')]
.every((node) => node.classList.contains('is-revealed'));
return { return {
heroActive: hero?.classList.contains('is-active') ?? false, heroActive: hero?.classList.contains('is-active') ?? false,
titleOpacity: titleStyle ? Number.parseFloat(titleStyle.opacity) : 0, titleOpacity: titleStyle ? Number.parseFloat(titleStyle.opacity) : 0,
hasIndex: Boolean(index), hasIndex: Boolean(index),
hasActiveChapter: Boolean(active), hasActiveChapter: Boolean(active),
finalReveals,
enhancementEnabled: document.documentElement.hasAttribute('data-motion'),
}; };
} }
JS); 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['titleOpacity'])->toBeGreaterThan(0.9);
expect($state['hasIndex'])->toBeTrue(); expect($state['hasIndex'])->toBeTrue();
expect($state['hasActiveChapter'])->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('/', [ $page = $this->visit('/', [
'reducedMotion' => 'no-preference', 'reducedMotion' => 'no-preference',
]); ]);
@@ -65,7 +57,7 @@ it('activates the home opening with 500ms beats and capped 90ms stagger', functi
$page->script(<<<'JS' $page->script(<<<'JS'
() => new Promise((resolve) => { () => new Promise((resolve) => {
const wait = () => { const wait = () => {
const hero = document.querySelector('[data-motion="page-open"]'); const hero = document.querySelector('[data-motion="dossie-hero"]');
if (hero?.classList.contains('is-active')) { if (hero?.classList.contains('is-active')) {
resolve(true); resolve(true);
return; return;
@@ -77,186 +69,11 @@ it('activates the home opening with 500ms beats and capped 90ms stagger', functi
}) })
JS); JS);
$state = $page->script(<<<'JS' $active = $page->script(<<<'JS'
() => { () => document.querySelector('[data-motion="dossie-hero"]')?.classList.contains('is-active') ?? false
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')),
};
}
JS); JS);
expect($state['active'])->toBeTrue(); expect($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);
}
}
}); });
it('updates the active chapter while scrolling the home dossier', function (): void { 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($before)->not->toBeNull();
expect($after)->toBe('#method-heading'); 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)');
});

View File

@@ -31,10 +31,6 @@ $screens = [
'services' => '/servicos', 'services' => '/servicos',
'portfolio' => '/portfolio', 'portfolio' => '/portfolio',
'portfolio-detail' => '/portfolio/casamento-ana-lucas', 'portfolio-detail' => '/portfolio/casamento-ana-lucas',
'about' => '/sobre',
'contact' => '/contato',
'privacy' => '/privacidade',
'not-found' => '/__missing-visual-page',
]; ];
$viewports = [ $viewports = [
@@ -45,15 +41,13 @@ $viewports = [
foreach ($screens as $screen => $path) { foreach ($screens as $screen => $path) {
foreach ($viewports as $viewport => [$width, $height]) { foreach ($viewports as $viewport => [$width, $height]) {
it("matches {$screen} {$viewport} visual baseline", function () use ($path, $width, $height): void { it("matches {$screen} {$viewport} visual baseline", function () use ($path, $width, $height): void {
$this->assertStableScreenshotMatches(
$this->visit($path, [ $this->visit($path, [
'reducedMotion' => 'reduce', 'reducedMotion' => 'reduce',
]) ])
->withLocale('pt-BR') ->withLocale('pt-BR')
->withTimezone('America/Fortaleza') ->withTimezone('America/Fortaleza')
->resize($width, $height), ->resize($width, $height)
resetScroll: $path === '/', ->assertScreenshotMatches();
);
}); });
} }
} }

View File

@@ -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);
}
}

View File

@@ -110,152 +110,4 @@ class HomePageContentTest extends TestCase
->assertSee('id="positioning-heading"', false) ->assertSee('id="positioning-heading"', false)
->assertSee('id="final-cta-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],
));
}
} }

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