Compare commits
4 Commits
f7fdf3930c
...
merge54
| Author | SHA1 | Date | |
|---|---|---|---|
| 0d1ca40c11 | |||
| 213d6843e7 | |||
| f8765add85 | |||
| 168a21e459 |
24
PRODUCT.md
24
PRODUCT.md
@@ -14,36 +14,42 @@ web
|
||||
|
||||
## Product Purpose
|
||||
|
||||
A Amare Assessoria usa uma única plataforma para apresentar sua marca, converter visitantes em oportunidades e conduzir a operação dos eventos. O produto deve reduzir planilhas e informações dispersas, preservar o fluxo de trabalho da equipe e deixar claro o que exige atenção.
|
||||
No recorte de lançamento, a Amare Assessoria usa o site institucional e seu CMS para apresentar a marca, publicar conteúdo e converter visitantes em pedidos de proposta por e-mail. O produto futuro continua planejado como uma única plataforma para conduzir leads e a operação dos eventos, mas essa evolução não faz parte do lançamento de 31/08/2026.
|
||||
|
||||
Sucesso significa:
|
||||
Sucesso no lançamento significa:
|
||||
|
||||
- visitantes qualificados concluírem o briefing;
|
||||
- a assessora atualizar os conteúdos essenciais sem depender de desenvolvimento;
|
||||
- o site equilibrar eventos sociais e corporativos com conteúdo real e autorizado;
|
||||
- acessibilidade, desempenho, jornadas críticas e deploy serem verificados antes da publicação.
|
||||
|
||||
Nas fases futuras, sucesso também significará:
|
||||
|
||||
- leads avançarem pelo pipeline sem depender de planilhas externas;
|
||||
- leads conquistados virarem eventos sem redigitação;
|
||||
- pendências reais do dia aparecerem com clareza;
|
||||
- tarefas, fornecedores, orçamento, pagamentos manuais e documentos permanecerem organizados por evento;
|
||||
- jornadas críticas, acessibilidade e qualidade visual serem verificadas antes de cada deploy.
|
||||
- tarefas, fornecedores, orçamento, pagamentos manuais e documentos permanecerem organizados por evento.
|
||||
|
||||
## Positioning
|
||||
|
||||
Site público premium e operação interna formam um fluxo único. A mesma plataforma que transmite profissionalismo e capta a demanda transforma o lead em evento e orienta a equipe pelas pendências e exceções que precisam de ação, sem exigir troca radical de processo.
|
||||
No lançamento, o site público premium e o CMS interno formam a superfície entregue: a mesma aplicação transmite profissionalismo, capta a demanda por e-mail e permite manter o conteúdo essencial. A operação interna completa permanece como direção futura para transformar leads em eventos e orientar a equipe pelas pendências e exceções que precisam de ação, sem exigir troca radical de processo.
|
||||
|
||||
## Operating Context
|
||||
|
||||
- Atuação em São Paulo, capital.
|
||||
- Serviços: casamentos, eventos corporativos e celebrações.
|
||||
- Operação realizada por assessora principal e equipe pequena.
|
||||
- Jornada prevista: descoberta no site, briefing, qualificação do lead, pipeline comercial, conversão em evento, checklist, fornecedores, orçamento, pagamentos manuais e documentos.
|
||||
- Jornada de lançamento: descoberta no site, briefing por e-mail e contato comercial fora da plataforma.
|
||||
- Jornada futura prevista: qualificação do lead, pipeline comercial, conversão em evento, checklist, fornecedores, orçamento, pagamentos manuais e documentos.
|
||||
- Interface pública em português do Brasil; dados financeiros em reais.
|
||||
- Área interna acessível por administradora e assistentes com papéis simples.
|
||||
|
||||
## Capabilities and Constraints
|
||||
|
||||
- Produto single-tenant para a operação da Amare; não é SaaS para várias assessorias.
|
||||
- Duas superfícies integradas: site público responsivo com CMS e área interna operacional em Filament.
|
||||
- MVP inclui briefing, CRM de leads, conversão em evento, tarefas, fornecedores, orçamento, pagamentos manuais, documentos, dashboard orientado a exceções, notificações, auditoria, SEO básico e deploy automatizado.
|
||||
- Implementação atual cobre fundação, site público e CMS; módulos operacionais posteriores ainda fazem parte do escopo planejado.
|
||||
- O lançamento inclui site público responsivo, CMS em Filament, briefing enviado por e-mail sem persistência de Lead, usuários internos simples, SEO básico, acessibilidade automatizada e deploy em contêiner.
|
||||
- CRM de leads, conversão em evento, tarefas, fornecedores, orçamento, pagamentos manuais, documentos, dashboard orientado a exceções, notificações e auditoria permanecem especificados para as Fases 2 a 5, adiadas sem data pela ADR-016.
|
||||
- O produto futuro mantém duas superfícies integradas: site público responsivo com CMS e área interna operacional em Filament; no lançamento, a área interna cobre apenas CMS, configurações e usuários.
|
||||
- Não inclui portal ou login para cliente final, aplicativo nativo, pagamentos online, RSVP, gestão de convidados, assinatura eletrônica, integração oficial com WhatsApp, multi-tenancy ou PWA offline avançada.
|
||||
- Contas internas usam os papéis `admin` e `assistant`; permissões granulares ficam fora do MVP.
|
||||
- Fatos ainda em aberto: processo real de qualificação comercial, política de retenção LGPD para leads perdidos, canais oficiais, domínio e infraestrutura de produção.
|
||||
|
||||
2
SPEC.md
2
SPEC.md
@@ -2524,7 +2524,7 @@ O lançamento está concluído somente quando:
|
||||
- assessora edita os conteúdos essenciais sem desenvolvedor;
|
||||
- briefing envia o pedido de proposta de forma segura, com proteção contra abuso e aceite de privacidade registrado;
|
||||
- usuários e Policies estão corretos;
|
||||
- testes unit, feature, visuais e arquitetura estão verdes;
|
||||
- testes unit, feature, browser funcionais e arquitetura estão verdes;
|
||||
- CI bloqueia regressões;
|
||||
- imagem FrankenPHP é reproduzível;
|
||||
- staging e produção usam a mesma imagem promovida;
|
||||
|
||||
81
docs/superpowers/plans/2026-08-11-full-bleed-home-hero.md
Normal file
81
docs/superpowers/plans/2026-08-11-full-bleed-home-hero.md
Normal file
@@ -0,0 +1,81 @@
|
||||
# Hero full-bleed da home Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Fazer a fotografia configurada do hero da home preencher toda a coluna direita abaixo do header, preservando a leitura editorial e o fallback sem foto.
|
||||
|
||||
**Architecture:** `x-home.hero` continuará a renderizar as duas variantes. A variante com imagem usará uma grade full-width com a coluna textual ancorada à régua global e a mídia como segundo trilho sem container; a variante tonal permanece contida. Os testes de feature e browser documentam os contratos de markup, tamanho e responsividade.
|
||||
|
||||
**Tech Stack:** Laravel Blade, Tailwind CSS 4 utilities, Pest feature/browser tests, Vite.
|
||||
|
||||
---
|
||||
|
||||
## Chunk 1: Layout e contratos do hero
|
||||
|
||||
### Task 1: Cobrir o spread full-bleed
|
||||
|
||||
**Files:**
|
||||
- Modify: `tests/Feature/PublicSite/ImmersivePhotoHeroTest.php`
|
||||
- Modify: `tests/Browser/HomeEditorialCadenceTest.php`
|
||||
|
||||
- [ ] **Step 1: Escrever as expectativas de feature para a variante com foto**
|
||||
|
||||
Exigir `data-split-hero`, `data-hero-content`, `data-motion="page-open"`, `data-reveal-group`, `aria-labelledby="hero-heading"`, `loading="eager"`, `fetchpriority="high"` e `sizes="(max-width: 767px) 100vw, 55vw"`. Com `hero_image_path` nulo, exigir `data-tonal-hero`, os mesmos atributos de motion/semântica, ausência de `data-split-hero` e ausência de `<img` dentro de `[data-tonal-hero]`.
|
||||
|
||||
- [ ] **Step 2: Estender a verificação browser de geometria**
|
||||
|
||||
No viewport 1440×1000, obter os retângulos de `.site-header`, `[data-chapter="hero"]`, `[data-hero-content]` e `[data-split-hero]`; esperar, com tolerância de 1px, que `hero.top === header.bottom`, `media.top === hero.top`, `media.bottom === hero.bottom`, `media.right === innerWidth` e `hero.height === innerHeight - header.height`. No viewport 390×844, comparar `data-split-hero` com `[data-hero-content]`; esperar que a mídia inicie depois do conteúdo, que `width / height` fique entre 0,79 e 0,81, e que não exista overflow horizontal. Antes das duas visitas de fallback, zerar `hero_image_path`; numa visita com reduced motion, esperar conteúdo visível. Em outra com `reducedMotion: no-preference` e `IntersectionObserver` desabilitado antes da navegação, esperar ausência de `data-motion` em `document.documentElement`, cada `[data-motion="page-open"]` com `.is-active`, cada `[data-reveal]` com `.is-revealed` e `aria-labelledby="hero-heading"`.
|
||||
|
||||
- [ ] **Step 3: Executar os testes focados e confirmar a falha inicial**
|
||||
|
||||
Run: `php artisan test tests/Feature/PublicSite/ImmersivePhotoHeroTest.php && php artisan test tests/Browser/HomeEditorialCadenceTest.php`
|
||||
|
||||
Expected: falha nas novas expectativas de geometria/markup até a alteração do componente.
|
||||
|
||||
### Task 2: Implementar a grade full-bleed
|
||||
|
||||
**Files:**
|
||||
- Modify: `resources/views/components/home/hero.blade.php`
|
||||
|
||||
- [ ] **Step 1: Separar a variante com imagem do container compartilhado**
|
||||
|
||||
Substituir `min-h-[100dvh]` do `<section>` por `md:h-[calc(100dvh-5rem)]` quando `hero_image_path` estiver preenchido e renderizar um wrapper `grid min-h-[calc(100dvh-5rem)] md:h-full md:grid-cols-[minmax(0,0.9fr)_minmax(0,1.1fr)]`. A coluna de texto deve manter `data-reveal-group`, receber `data-hero-content`, ter `padding-left: max(var(--amare-container-padding), calc((100vw - var(--amare-container-max)) / 2 + var(--amare-container-padding)))`, padding direito fluido e centralização vertical.
|
||||
|
||||
- [ ] **Step 2: Tornar a mídia contínua**
|
||||
|
||||
Aplicar ao bloco `data-split-hero` `overflow-hidden`, `bg-amare-bg-deep`, `md:min-h-full`; abaixo de `md`, aplicar `aspect-[4/5]`. Manter `data-motion-beat="media"`, `data-reveal-media`, `object-cover`, path/alt CMS e mudar `sizes` para `(max-width: 767px) 100vw, 55vw`.
|
||||
|
||||
- [ ] **Step 3: Preservar o fallback**
|
||||
|
||||
Manter o wrapper tonal existente para hero sem imagem, com `data-tonal-hero`, `data-motion="page-open"` e `data-reveal-group`; não renderizar o bloco de mídia nessa variante.
|
||||
|
||||
- [ ] **Step 4: Executar os testes focados e confirmar a passagem**
|
||||
|
||||
Run: `php artisan test tests/Feature/PublicSite/ImmersivePhotoHeroTest.php && php artisan test tests/Browser/HomeEditorialCadenceTest.php`
|
||||
|
||||
Expected: PASS.
|
||||
|
||||
### Task 3: Verificar e registrar
|
||||
|
||||
**Files:**
|
||||
- Modify: `resources/views/components/home/hero.blade.php`
|
||||
- Modify: `tests/Feature/PublicSite/ImmersivePhotoHeroTest.php`
|
||||
- Modify: `tests/Browser/HomeEditorialCadenceTest.php`
|
||||
|
||||
- [ ] **Step 1: Formatar e executar as verificações proporcionais**
|
||||
|
||||
Run: `composer pint && composer phpstan && composer test:feature && composer test:browser && npm run build`
|
||||
|
||||
Expected: todos os comandos passam.
|
||||
|
||||
- [ ] **Step 2: Inspecionar o diff e registrar**
|
||||
|
||||
Run: `git diff --check && git status --short`
|
||||
|
||||
Expected: apenas os três arquivos de implementação/testes e a documentação deste plano/especificação aparecem como escopo.
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
Run: `git add resources/views/components/home/hero.blade.php tests/Feature/PublicSite/ImmersivePhotoHeroTest.php tests/Browser/HomeEditorialCadenceTest.php docs/superpowers && git commit -m "feat(home): tornar hero fotográfico full-bleed"`
|
||||
|
||||
Expected: commit focado, sem artefatos de build ou screenshots.
|
||||
@@ -0,0 +1,31 @@
|
||||
# Hero da home com fotografia full-bleed
|
||||
|
||||
## Objetivo
|
||||
|
||||
Dar à fotografia do hero da home a mesma presença contínua da referência aprovada: no desktop, ela preenche integralmente a coluna direita abaixo do header; o conteúdo editorial permanece concentrado na coluna esquerda.
|
||||
|
||||
## Escopo
|
||||
|
||||
- Alterar somente o componente `resources/views/components/home/hero.blade.php` e seus testes diretos.
|
||||
- Quando `hero_image_path` existir, usar uma grade externa `0.9fr / 1.1fr` a partir de `md` (768px), com altura exata `calc(100dvh - 5rem)`: o espaço restante depois do header desktop. A mídia deve chegar ao topo e à base da seção, sem padding vertical ou lateral de container.
|
||||
- Manter o texto, CTAs, imagem CMS, `alt`, carregamento eager, `fetchpriority`, responsividade de imagem e atributos de motion existentes.
|
||||
- Abaixo de `md`, manter a ordem texto seguido de imagem, sem overflow horizontal, e fixar a mídia em `aspect-ratio: 4 / 5` com `object-cover`.
|
||||
- Quando não houver imagem configurada, manter a abertura tonal, tipográfica e contida já existente, sem área vazia para mídia.
|
||||
|
||||
## Fora de escopo
|
||||
|
||||
- Não alterar conteúdo do CMS, imagens, Open Graph, outras rotas públicas, header, animações, tokens globais ou dependências.
|
||||
- Não criar ou publicar mídia nova.
|
||||
|
||||
## Estrutura
|
||||
|
||||
O componente continuará sendo a única unidade de layout do hero. A variante com foto terá um wrapper full-width em desktop. A coluna textual terá `data-hero-content`, margem esquerda `max(var(--amare-container-padding), calc((100vw - var(--amare-container-max)) / 2 + var(--amare-container-padding)))`, a mesma régua esquerda de `container-amare`, e padding direito fluido para não comprimir a leitura. O bloco de mídia ocupará os 55% da grade, receberá `data-split-hero` e `data-reveal-media`, e deixará de ficar limitado pelo container. O atributo `sizes` será `(max-width: 767px) 100vw, 55vw`. A variante sem foto permanece no wrapper atual.
|
||||
|
||||
## Critérios de aceitação
|
||||
|
||||
- Em desktop, `data-split-hero` inicia sob o header e mede `calc(100dvh - 5rem)`, sem margens externas de container.
|
||||
- A foto usa `object-cover`, `loading="eager"`, `fetchpriority="high"`, path/alt próprios do hero e `sizes="(max-width: 767px) 100vw, 55vw"`.
|
||||
- Em mobile, o conteúdo essencial continua acessível antes da foto, sem overflow nem texto recortado.
|
||||
- O fallback sem foto continua com `data-tonal-hero`, sem `data-split-hero` e sem mídia renderizada.
|
||||
- `data-motion="page-open"`, `data-reveal-group`, beats existentes, motion reduzido, CTAs e semântica atual continuam funcionais.
|
||||
- Testes verificam os contratos full-bleed da variante com foto, a ausência de mídia no fallback, e a continuidade de motion/semântica.
|
||||
@@ -21,4 +21,4 @@
|
||||
|
||||
- [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
|
||||
- [x] 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 — delivered in PR #22 with the requested screenshots and local verification; the PR merged as `0aee15e`, the worktree/remote branch no longer exists, and the current implementation is covered by all five green CI jobs on `main` (run 31501097667). Pixel snapshots were later removed by PR #44 and are not part of the current gate.
|
||||
@@ -36,8 +36,8 @@
|
||||
|
||||
## 6. Phase 0 exit evidence
|
||||
|
||||
- [ ] 6.1 Perform first successful staging deploy of a `main` SHA and capture evidence (workflow URL, smoke output)
|
||||
- [x] 6.1 Perform first successful staging deploy of a `main` SHA and capture evidence (workflow URL, smoke output) — run 31395107465 deployed `7e68c0e` successfully; smoke recorded `/up`, `/` and `/admin/login` returning HTTP 200.
|
||||
- [ ] 6.2 Verify rollback to previous SHA works once on staging
|
||||
- [x] 6.3 Update `SPEC.md` §18 Fase 0 checkboxes only for items with evidence; note remaining deferred items if any — flipped L2338 (FrankenPHP/Compose) and the auth/npm-audit/coverage bullets to `[x]`; left the staging hello-world bullet and the phase exit-criterion line unchecked (Dokploy deploy still failing, out of scope here)
|
||||
- [ ] 6.4 Run full `composer quality` and confirm all five CI jobs + staging deploy path green
|
||||
- [ ] 6.5 Report in SPEC §24 format; archive this change only after remaining parity tasks (1–3) also complete
|
||||
- [x] 6.3 Update `SPEC.md` §18 Fase 0 checkboxes only for items with evidence; note remaining deferred items if any — local runtime/auth/audit/coverage evidence was recorded by PR #39, and the staging exit criterion was subsequently marked complete from successful run 31395107465.
|
||||
- [x] 6.4 Run full `composer quality` and confirm all five CI jobs + staging deploy path green — local quality evidence is recorded in task 2.4; PR #39 passed `static`, `unit`, `feature`, `browser` and `container`, then run 31395107465 deployed that exact merge SHA and passed all three smoke endpoints.
|
||||
- [ ] 6.5 Report in SPEC §24 format; archive this change only after remaining parity tasks are complete — blocked by tasks 3.4 and 6.2, tracked separately in MAN-125.
|
||||
|
||||
@@ -57,3 +57,31 @@ The system SHALL define a container healthcheck that verifies application availa
|
||||
- **WHEN** the application inside the container fails to respond healthy on `/up`
|
||||
- **THEN** the container healthcheck MUST report unhealthy status
|
||||
|
||||
### Requirement: Local Compose runs FrankenPHP application alongside PostgreSQL
|
||||
|
||||
The local Docker Compose stack SHALL include a FrankenPHP application service built from the project Dockerfile, dependent on a healthy PostgreSQL service, exposing the application on port 8000 (or documented equivalent). FrankenPHP MUST run in regular mode (ADR-006).
|
||||
|
||||
#### Scenario: Developer starts full local stack
|
||||
|
||||
- **WHEN** a developer runs `docker compose up -d` with the application service enabled
|
||||
- **THEN** PostgreSQL and the FrankenPHP app containers MUST become healthy
|
||||
- **AND** `GET /up` on the app port MUST return HTTP 200
|
||||
|
||||
#### Scenario: App waits for database readiness
|
||||
|
||||
- **WHEN** the application service starts
|
||||
- **THEN** it MUST depend on the PostgreSQL healthcheck succeeding before becoming ready
|
||||
|
||||
### Requirement: Runtime PHP version is canonically 8.4
|
||||
|
||||
Docker, CI, and project documentation MUST treat PHP 8.4 as the canonical runtime for this MVP. Statements that require PHP 8.5+ MUST NOT remain in project docs while the runtime image uses 8.4.
|
||||
|
||||
#### Scenario: Dockerfile pins PHP 8.4
|
||||
|
||||
- **WHEN** the production/runtime image is built
|
||||
- **THEN** the FrankenPHP base image MUST use PHP 8.4
|
||||
|
||||
#### Scenario: Documentation matches the runtime
|
||||
|
||||
- **WHEN** a contributor reads the README requirements
|
||||
- **THEN** the documented PHP version MUST be 8.4 (compatible with Composer `^8.3`)
|
||||
|
||||
@@ -126,11 +126,16 @@ The system SHALL provide optimized Amare brand logo assets derived from the offi
|
||||
- **THEN** the uploaded logo MUST be used instead of the static fallback
|
||||
|
||||
### Requirement: Editorial image treatment remains self-hosted and deterministic
|
||||
|
||||
Public photography SHALL continue to use validated self-hosted uploads and responsive variants. Decorative saturation/contrast treatment for editorial mood MUST be applied via CSS on self-hosted images and MUST NOT introduce external image CDN dependencies that compromise browser-test reliability.
|
||||
Public photography SHALL continue to use validated self-hosted uploads and responsive variants. Editorial layouts MUST use CSS-only tonal treatment and alternating aspect ratios for portfolio media while retaining document order and the image component's `srcset`, `sizes`, dimensions and loading behavior. The system MUST NOT introduce external image CDN dependencies that compromise browser-test reliability.
|
||||
|
||||
#### Scenario: Public pages do not depend on external stock hosts
|
||||
|
||||
- **WHEN** the visual or browser suite loads covered public routes
|
||||
- **THEN** content images MUST resolve from the application media disk or static fixtures
|
||||
- **AND** MUST NOT require network access to third-party stock hosts
|
||||
|
||||
#### Scenario: Editorial portfolio media retains responsive delivery
|
||||
|
||||
- **WHEN** a portfolio listing or gallery applies an editorial image proportion
|
||||
- **THEN** the rendered image MUST still expose the responsive media component markup
|
||||
- **AND** image order and lazy-loading behavior MUST remain intact
|
||||
|
||||
@@ -53,3 +53,11 @@ The public site SHALL express hierarchy through tonal paper fields, 1px botanica
|
||||
- **THEN** they MUST NOT depend on `--amare-shadow-*` card elevation for hierarchy
|
||||
- **AND** separation MUST come from borders, tonal backgrounds, or whitespace
|
||||
|
||||
### 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
|
||||
|
||||
|
||||
@@ -26,3 +26,17 @@ The system SHALL return a failure status when the application cannot initialize
|
||||
- **WHEN** the application fails to boot due to misconfiguration or missing dependencies
|
||||
- **THEN** the health endpoint MUST NOT return HTTP 200
|
||||
|
||||
### Requirement: Staging health and smoke use the public /up endpoint
|
||||
|
||||
Staging healthchecks and post-deploy smoke MUST call `GET /up` without authentication and MUST treat a non-200 response as deployment failure (SPEC §15.5, §16.2). The health response MUST NOT expose secrets.
|
||||
|
||||
#### Scenario: Staging healthcheck probes /up
|
||||
|
||||
- **WHEN** Dokploy or the container runtime evaluates application health after deploy
|
||||
- **THEN** it MUST request `/up`
|
||||
- **AND** MUST require HTTP 200 before marking the service healthy
|
||||
|
||||
#### Scenario: Smoke failure on /up fails the deploy gate
|
||||
|
||||
- **WHEN** post-deploy smoke requests `/up` and receives a non-200 response
|
||||
- **THEN** the staging promotion MUST be considered failed
|
||||
|
||||
@@ -58,3 +58,35 @@ The system SHALL restrict user management (create, update, deactivate) to users
|
||||
- **WHEN** an authenticated assistant attempts to access user management in the panel
|
||||
- **THEN** the system MUST deny access via authorization policy
|
||||
|
||||
### Requirement: Verified email is required for panel access
|
||||
|
||||
Internal users MUST have a verified email address to access the Filament panel (SPEC §12.1, ADM-01). Active users with unverified email MUST be denied panel access. Development seeds MUST mark local demo users as verified.
|
||||
|
||||
#### Scenario: Unverified active user is denied panel access
|
||||
|
||||
- **WHEN** an active user with null `email_verified_at` authenticates
|
||||
- **THEN** the system MUST NOT grant access to the Filament panel
|
||||
|
||||
#### Scenario: Verified active user can access the panel
|
||||
|
||||
- **WHEN** an active user with a non-null `email_verified_at` submits valid credentials
|
||||
- **THEN** the system authenticates the user and allows Filament panel access subject to role rules
|
||||
|
||||
#### Scenario: Local seed users are verified
|
||||
|
||||
- **WHEN** `DatabaseSeeder` creates the local admin and assistant
|
||||
- **THEN** both users MUST have `email_verified_at` set
|
||||
|
||||
### Requirement: Password reset flow is covered by automated tests
|
||||
|
||||
The secure password reset flow for internal users MUST be covered by feature tests that assert a reset request for a registered email queues/sends a reset notification without revealing whether the email exists to the client (SPEC §12.1; existing password-reset requirement).
|
||||
|
||||
#### Scenario: Reset request does not reveal account existence
|
||||
|
||||
- **WHEN** a visitor submits a password reset for an unknown email
|
||||
- **THEN** the response MUST not disclose that the email is unregistered
|
||||
|
||||
#### Scenario: Registered email receives reset notification
|
||||
|
||||
- **WHEN** a visitor submits a password reset for a registered email
|
||||
- **THEN** the system MUST dispatch the password reset notification (faked in tests)
|
||||
|
||||
79
openspec/specs/public-site-motion/spec.md
Normal file
79
openspec/specs/public-site-motion/spec.md
Normal file
@@ -0,0 +1,79 @@
|
||||
# public-site-motion Specification
|
||||
|
||||
## Purpose
|
||||
|
||||
Define progressive, accessible and non-blocking motion choreography for the public site, including shared timings, one-shot reveals, interaction feedback and safe fallbacks.
|
||||
|
||||
## 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
|
||||
@@ -34,8 +34,7 @@ The system SHALL expose the public routes of SPEC §5.1: `home` (`/`), `services
|
||||
- **AND** the case MUST appear in the `/portfolio` listing
|
||||
|
||||
### Requirement: Home renders the editorial structure from CMS content
|
||||
|
||||
The home page SHALL render, in order: header/navigation, hero, manifesto, featured services summary, featured portfolio selection, working method (four steps), testimonials, Amare positioning/profile, final contact CTA, and footer with contact, social links and legal links (WEB-01). Hero copy, brand name, manifesto, method and principles MUST come from `site_settings` (with editorial defaults when optional fields are empty); services, cases and testimonials MUST come from published records. The home MUST follow the Heritage Editorial composition (asymmetric spreads on desktop, linear sequence on mobile) rather than rounded card grids.
|
||||
The home page SHALL render, in order: header/navigation, hero, manifesto, featured services summary, featured portfolio selection, working method (four steps), testimonials, Amare positioning/profile, final contact CTA, and footer with contact, social links and legal links (WEB-01). Hero copy, brand name, manifesto, method and principles MUST come from `site_settings` (with editorial defaults when optional fields are empty); services, cases and testimonials MUST come from published records. The home MUST follow the Heritage Editorial composition: asymmetric spreads and tonal fields on desktop, with portfolio images in alternating editorial proportions, and a linear complete sequence on mobile rather than rounded card grids.
|
||||
|
||||
#### Scenario: Published content is displayed in configured order
|
||||
|
||||
@@ -44,6 +43,7 @@ The home page SHALL render, in order: header/navigation, hero, manifesto, featur
|
||||
- **THEN** the published content MUST be displayed following the `sort_order` and featured flags
|
||||
- **AND** the hero MUST show the values stored in `site_settings`
|
||||
- **AND** the manifesto, method and positioning sections MUST be present
|
||||
- **AND** the portfolio section MUST use an olive tonal field with editorial image proportions
|
||||
|
||||
#### Scenario: CTA leads to the contact placeholder page
|
||||
|
||||
@@ -65,8 +65,7 @@ The home page SHALL render, in order: header/navigation, hero, manifesto, featur
|
||||
- **THEN** the browser console MUST contain no JavaScript errors
|
||||
|
||||
### Requirement: Listing and detail pages exist for catalog content
|
||||
|
||||
The system SHALL render a services listing (WEB-02) and a portfolio listing plus case detail (WEB-03) using the Heritage Editorial visual language. The case detail MUST present summary, event type, optional city/venue/date, challenge, solution, optional result, cover image and the ordered gallery.
|
||||
The system SHALL render a services listing (WEB-02) and a portfolio listing plus case detail (WEB-03) using the Heritage Editorial visual language. The case detail MUST present summary, event type, optional city/venue/date, challenge, solution, optional result, cover image and the ordered gallery. Portfolio listings and galleries MUST use alternating editorial image proportions on desktop while retaining DOM order and a single-column readable sequence on mobile.
|
||||
|
||||
#### Scenario: Services listing shows published services
|
||||
|
||||
@@ -79,6 +78,7 @@ The system SHALL render a services listing (WEB-02) and a portfolio listing plus
|
||||
- **GIVEN** a published case with multiple gallery images
|
||||
- **WHEN** a visitor loads the case detail
|
||||
- **THEN** the images MUST be rendered ordered by `sort_order`
|
||||
- **AND** desktop presentation MUST alternate editorial image proportions without changing that order
|
||||
|
||||
#### Scenario: Listings paginate open-ended growth
|
||||
|
||||
@@ -86,8 +86,7 @@ The system SHALL render a services listing (WEB-02) and a portfolio listing plus
|
||||
- **THEN** `/portfolio` MUST paginate instead of rendering all records
|
||||
|
||||
### Requirement: Institutional and error pages have brand identity
|
||||
|
||||
The system SHALL provide the Sobre and Política de privacidade pages and branded error pages (WEB-07) using the Heritage Editorial public layout, including the brand mark when available. The 404 page MUST use the public layout, and the 500 page MUST NOT expose stack traces or internal details when `APP_DEBUG` is false.
|
||||
The system SHALL provide the Sobre and Política de privacidade pages and branded error pages (WEB-07) using the Heritage Editorial public layout, including the brand mark when available. Contact, privacy and error surfaces MUST remain sober tonal layouts without decorative photography. The 404 page MUST use the public layout, and the 500 page MUST NOT expose stack traces or internal details when `APP_DEBUG` is false.
|
||||
|
||||
#### Scenario: Unknown URL renders branded 404
|
||||
|
||||
@@ -146,3 +145,16 @@ When public pages render illustrative/demo photography that is not an authorized
|
||||
|
||||
- **WHEN** the home or portfolio renders placeholder photography
|
||||
- **THEN** a visible note or equivalent disclosure MUST indicate the imagery is illustrative pending authorized assets
|
||||
|
||||
### 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
|
||||
|
||||
@@ -70,3 +70,40 @@ The system SHALL execute browser tests using Pest Browser/Playwright against an
|
||||
|
||||
- **WHEN** a browser test fails in CI
|
||||
- **THEN** the job MUST publish application logs and browser logs as diagnostics
|
||||
|
||||
### Requirement: Quality gate includes npm dependency audit
|
||||
|
||||
The system SHALL run an npm audit as part of the quality/static gate according to the project policy (SPEC §12.6, §13.9). Audit failure MUST block merge unless an explicit, documented exception exists.
|
||||
|
||||
#### Scenario: Vulnerable dependency fails static gate
|
||||
|
||||
- **WHEN** `npm audit` reports a policy-violating vulnerability in production dependencies
|
||||
- **THEN** the static/quality gate MUST fail and block merge
|
||||
|
||||
#### Scenario: Developer runs quality locally with audit
|
||||
|
||||
- **WHEN** a developer runs `composer quality` (or the documented npm audit step it requires)
|
||||
- **THEN** the npm audit MUST execute as part of the gate sequence
|
||||
|
||||
### Requirement: Domain and Application maintain minimum coverage
|
||||
|
||||
The CI unit gate SHALL measure code coverage for `App\Domain` and `App\Application` and MUST fail when either namespace falls below 80% (SPEC §13.7). Views, migrations, generated code, and framework code MUST NOT be required to meet this threshold.
|
||||
|
||||
#### Scenario: Coverage drop below threshold blocks merge
|
||||
|
||||
- **WHEN** Domain or Application coverage is below 80% on the unit CI job
|
||||
- **THEN** the job MUST fail
|
||||
|
||||
#### Scenario: Coverage ignores non-domain layers
|
||||
|
||||
- **WHEN** coverage is computed for the gate
|
||||
- **THEN** Blade views and migrations MUST NOT be counted toward the Domain/Application threshold
|
||||
|
||||
### Requirement: Staging deploy job runs only after blocking CI jobs
|
||||
|
||||
When deploying from `main`, the staging deploy workflow MUST require the five blocking CI jobs (`static`, `unit`, `feature`, `browser`, `container`) to succeed before building/pushing the image and triggering Dokploy (SPEC §14.1, §14.3).
|
||||
|
||||
#### Scenario: Failed browser job prevents staging deploy
|
||||
|
||||
- **WHEN** the `browser` CI job fails on `main`
|
||||
- **THEN** the staging deploy workflow MUST NOT promote a new image
|
||||
|
||||
83
openspec/specs/staging-deployment/spec.md
Normal file
83
openspec/specs/staging-deployment/spec.md
Normal file
@@ -0,0 +1,83 @@
|
||||
# staging-deployment Specification
|
||||
|
||||
## Purpose
|
||||
|
||||
Define immutable staging and production promotion through GHCR and Dokploy Compose, including migration, health, smoke, rollback, and backup evidence.
|
||||
|
||||
## Requirements
|
||||
|
||||
### Requirement: Staging deploys an immutable application image by commit SHA
|
||||
|
||||
The system SHALL deploy staging from a single FrankenPHP application image tagged with the Git commit SHA and published to GHCR (SPEC §14.3, §15.2). Web, queue worker, and scheduler processes MUST use that same image digest. Rebuilds per process on the staging host MUST NOT be the promotion path.
|
||||
|
||||
#### Scenario: Same image serves all application processes
|
||||
|
||||
- **WHEN** a staging deployment is promoted for commit SHA `abc123`
|
||||
- **THEN** web, queue, and scheduler MUST run from `ghcr.io/<owner>/<repo>:abc123` (or equivalent digest)
|
||||
- **AND** MUST NOT rebuild distinct images per process
|
||||
|
||||
#### Scenario: CI publishes the image before Dokploy deploy
|
||||
|
||||
- **WHEN** `main` passes the blocking CI jobs
|
||||
- **THEN** the deploy workflow MUST build and push the SHA-tagged image before triggering Dokploy
|
||||
|
||||
### Requirement: Staging runs on Dokploy Compose on the operator VPS
|
||||
|
||||
Staging SHALL be operated as a Dokploy Docker Compose deployment on the operator-managed VPS. PostgreSQL MUST be a managed private database service; application media MUST use the configured production object storage disk (`r2`) and mail MUST use the configured Resend transport when enabled.
|
||||
|
||||
#### Scenario: Compose defines required processes
|
||||
|
||||
- **WHEN** staging Compose is deployed
|
||||
- **THEN** it MUST include web, queue worker, scheduler, and a one-shot migrate job using the application image
|
||||
|
||||
#### Scenario: Database is not embedded in the application image
|
||||
|
||||
- **WHEN** staging boots
|
||||
- **THEN** PostgreSQL MUST be provided by the Dokploy/database service
|
||||
- **AND** MUST NOT live inside the application image layers
|
||||
|
||||
### Requirement: Staging deploy migrates then proves health with smoke checks
|
||||
|
||||
A staging promotion MUST run database migrations against the staging database, wait for `/up` to report healthy, and execute smoke HTTP checks for `/up`, the public home, and `/admin/login` (SPEC §14.3, §15.5, §16.2).
|
||||
|
||||
#### Scenario: Unhealthy deploy is not considered successful
|
||||
|
||||
- **WHEN** `/up` fails after a staging deploy attempt
|
||||
- **THEN** the deployment MUST be treated as failed
|
||||
- **AND** MUST NOT be recorded as a successful Phase 0 exit criterion
|
||||
|
||||
#### Scenario: Smoke checks cover public and login surfaces
|
||||
|
||||
- **WHEN** staging deploy health succeeds
|
||||
- **THEN** smoke checks MUST request `/`, `/up`, and `/admin/login`
|
||||
- **AND** each MUST return HTTP 200
|
||||
|
||||
### Requirement: Staging rollback uses the previous image SHA
|
||||
|
||||
Rollback SHALL redeploy a previously published SHA-tagged image without rebuilding from source (SPEC §14.3).
|
||||
|
||||
#### Scenario: Operator rolls back to previous SHA
|
||||
|
||||
- **WHEN** the operator points staging Compose at a previous SHA tag and redeploys
|
||||
- **THEN** web, queue, and scheduler MUST run that previous image
|
||||
- **AND** no source rebuild MUST be required
|
||||
|
||||
### Requirement: Production promotion reuses the same immutable digest
|
||||
|
||||
Production SHALL be promoted from an already-published SHA-tagged image without rebuilding from source. Promotion MUST require explicit human action (SPEC §14.3).
|
||||
|
||||
#### Scenario: Operator promotes a staging-approved SHA to production
|
||||
|
||||
- **WHEN** the operator confirms promotion of commit SHA `abc123`
|
||||
- **THEN** production web, queue, and scheduler MUST run the same digest previously published as `ghcr.io/<owner>/<repo>:abc123`
|
||||
- **AND** MUST NOT rebuild from source for that promotion
|
||||
|
||||
### Requirement: Database backups exist before production cutover
|
||||
|
||||
Staging and production PostgreSQL services SHALL have automated daily backups with retention of at least 14 days, and a documented restore procedure MUST be verified on staging before the first production promotion (SPEC §16.3).
|
||||
|
||||
#### Scenario: Staging restore is proven before production promotion
|
||||
|
||||
- **WHEN** the operator prepares the first production promotion
|
||||
- **THEN** a restore from a staging backup MUST have been documented and successfully tested
|
||||
- **AND** production MUST have daily backup configured with retention of at least 14 days
|
||||
@@ -6,7 +6,7 @@ Define testimonial management, publication, authorization, and featured filterin
|
||||
## 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. 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.
|
||||
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
|
||||
|
||||
@@ -34,6 +34,18 @@ The system SHALL allow admins to manage testimonials (SPEC WEB-04) with quote te
|
||||
- **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
|
||||
|
||||
### Requirement: Real wedding testimonials are seeded from authorized source copy
|
||||
|
||||
Content and visual seeders SHALL replace fictional testimonials with the five real wedding testimonials from `depoimentos.md`, preserving author couple names, quote wording, and date/context. Until final publication authorization is confirmed, production deployments MUST keep those records unpublished or gated by explicit admin publish action.
|
||||
|
||||
@@ -6,7 +6,7 @@ Define automated accessibility checks, semantic structure, keyboard operability,
|
||||
## 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 redesign. A critical or serious issue MUST fail the suite.
|
||||
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
|
||||
|
||||
@@ -16,7 +16,7 @@ The system SHALL run automated accessibility checks on the public routes covered
|
||||
#### Scenario: Covered routes are checked
|
||||
|
||||
- **WHEN** the accessibility suite runs
|
||||
- **THEN** the home, services listing, portfolio listing and case detail MUST each be checked
|
||||
- **THEN** home, services listing, portfolio listing, case detail, about, contact, privacy and branded 404 MUST each be checked
|
||||
|
||||
### Requirement: Public pages use accessible semantic structure
|
||||
|
||||
@@ -65,19 +65,34 @@ Visitors SHALL be able to reach and activate every interactive element with the
|
||||
|
||||
### 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 editorial hover scales and menu transitions introduced by the redesign.
|
||||
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 pages that load the mobile navigation script.
|
||||
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
|
||||
|
||||
### 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
|
||||
|
||||
13
package-lock.json
generated
13
package-lock.json
generated
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"name": "site-amare",
|
||||
"name": "daisyui",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
@@ -7,6 +7,7 @@
|
||||
"devDependencies": {
|
||||
"@tailwindcss/vite": "^4.0.0",
|
||||
"concurrently": "^9.0.1",
|
||||
"daisyui": "^5.7.16",
|
||||
"husky": "^9.1.7",
|
||||
"laravel-vite-plugin": "^3.1",
|
||||
"playwright": "^1.62.0",
|
||||
@@ -790,6 +791,16 @@
|
||||
"url": "https://github.com/open-cli-tools/concurrently?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/daisyui": {
|
||||
"version": "5.7.16",
|
||||
"resolved": "https://registry.npmjs.org/daisyui/-/daisyui-5.7.16.tgz",
|
||||
"integrity": "sha512-V9CJxvrWIXTDuP/0tpijjWaKaKJNYo2IrULJfTWs3/DjW9rPqswk1n8NCLQRLrTutmlG2Ech7nkJDpCnF+6Dzw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/saadeghi/daisyui?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/detect-libc": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
"devDependencies": {
|
||||
"@tailwindcss/vite": "^4.0.0",
|
||||
"concurrently": "^9.0.1",
|
||||
"daisyui": "^5.7.16",
|
||||
"husky": "^9.1.7",
|
||||
"laravel-vite-plugin": "^3.1",
|
||||
"playwright": "^1.62.0",
|
||||
|
||||
@@ -1,6 +1,46 @@
|
||||
@import 'tailwindcss';
|
||||
@import './tokens.css';
|
||||
|
||||
@plugin "daisyui" {
|
||||
themes: false;
|
||||
}
|
||||
|
||||
@plugin "daisyui/theme" {
|
||||
name: "amare";
|
||||
default: true;
|
||||
color-scheme: light;
|
||||
|
||||
--color-base-100: var(--amare-color-bg);
|
||||
--color-base-200: var(--amare-color-bg-deep);
|
||||
--color-base-300: var(--amare-color-bg-archive);
|
||||
--color-base-content: var(--amare-color-text);
|
||||
--color-primary: var(--amare-color-accent);
|
||||
--color-primary-content: var(--amare-color-accent-text);
|
||||
--color-secondary: var(--amare-color-sage);
|
||||
--color-secondary-content: var(--amare-color-text);
|
||||
--color-accent: var(--amare-color-accent-deep);
|
||||
--color-accent-content: var(--amare-color-accent-text);
|
||||
--color-neutral: var(--amare-color-muted);
|
||||
--color-neutral-content: var(--amare-color-accent-text);
|
||||
--color-info: #2563eb;
|
||||
--color-info-content: #ffffff;
|
||||
--color-success: var(--amare-color-success);
|
||||
--color-success-content: var(--amare-color-accent-text);
|
||||
--color-warning: var(--amare-color-warning);
|
||||
--color-warning-content: var(--amare-color-accent-text);
|
||||
--color-error: var(--amare-color-error);
|
||||
--color-error-content: var(--amare-color-accent-text);
|
||||
|
||||
--radius-selector: 0;
|
||||
--radius-field: 0;
|
||||
--radius-box: 0;
|
||||
--size-selector: 0.25rem;
|
||||
--size-field: 0.25rem;
|
||||
--border: 1px;
|
||||
--depth: 0;
|
||||
--noise: 0;
|
||||
}
|
||||
|
||||
@source '../../vendor/laravel/framework/src/Illuminate/Pagination/resources/views/*.blade.php';
|
||||
@source '../../storage/framework/views/*.php';
|
||||
@source '../views/**/*.blade.php';
|
||||
@@ -223,3 +263,11 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* daisyUI emits unlayered; match focus ring to the amare accent. */
|
||||
.input:focus,
|
||||
.select:focus,
|
||||
.textarea:focus,
|
||||
.checkbox:focus-visible {
|
||||
--input-color: var(--amare-color-accent);
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
<div>
|
||||
<a
|
||||
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="btn btn-primary text-sm font-semibold uppercase tracking-[0.12em]"
|
||||
>
|
||||
{{ $settings->hero_cta_label ?: 'Solicitar proposta' }}
|
||||
</a>
|
||||
|
||||
@@ -4,52 +4,91 @@
|
||||
|
||||
<section
|
||||
aria-labelledby="hero-heading"
|
||||
class="home-chapter min-h-[100dvh] border-b border-amare-border bg-amare-bg"
|
||||
@class([
|
||||
'home-chapter border-b border-amare-border bg-amare-bg',
|
||||
'min-h-[100dvh]' => blank($settings->hero_image_path),
|
||||
'lg:h-[calc(100dvh-5rem)]' => filled($settings->hero_image_path),
|
||||
])
|
||||
data-chapter="hero"
|
||||
data-motion="page-open"
|
||||
>
|
||||
<div @class([
|
||||
'container-amare grid min-h-[100dvh] gap-12 py-20 md:py-10',
|
||||
'md:grid-cols-[minmax(0,0.9fr)_minmax(0,1.1fr)] md:items-stretch' => filled($settings->hero_image_path),
|
||||
'md:max-w-4xl md:items-center' => blank($settings->hero_image_path),
|
||||
]) data-reveal-group @if (blank($settings->hero_image_path)) data-tonal-hero @endif>
|
||||
<div class="flex flex-col justify-center space-y-8 md:py-12">
|
||||
<div data-motion-beat="seal" class="flex items-center gap-4">
|
||||
<x-brand.logo mark variant="on-light" class="h-8 w-auto" alt="" />
|
||||
@if (filled($settings->hero_eyebrow))
|
||||
<p class="text-sm font-medium uppercase tracking-[0.18em] text-amare-accent">{{ $settings->hero_eyebrow }}</p>
|
||||
@if (filled($settings->hero_image_path))
|
||||
<div class="grid lg:h-full lg:grid-cols-2">
|
||||
<div
|
||||
data-hero-content
|
||||
data-reveal-group
|
||||
class="flex flex-col justify-center space-y-8 px-6 py-20 lg:py-12 lg:pr-[clamp(3rem,6vw,7rem)] lg:pl-[max(var(--amare-container-padding),calc((100vw-var(--amare-container-max))/2+var(--amare-container-padding)))]"
|
||||
>
|
||||
<div data-motion-beat="seal" class="flex items-center gap-4">
|
||||
<x-brand.logo mark variant="on-light" class="h-8 w-auto" alt="" />
|
||||
@if (filled($settings->hero_eyebrow))
|
||||
<p class="text-sm font-medium uppercase tracking-[0.18em] text-amare-accent">{{ $settings->hero_eyebrow }}</p>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<h1 id="hero-heading" data-motion-beat="title" class="max-w-3xl text-display font-medium text-amare-text">
|
||||
{{ $settings->hero_title ?: 'Celebrações com propósito' }}
|
||||
</h1>
|
||||
|
||||
@if (filled($settings->hero_subtitle))
|
||||
<p class="max-w-2xl text-lg text-amare-text-muted">{{ $settings->hero_subtitle }}</p>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<h1 id="hero-heading" data-motion-beat="title" class="max-w-3xl text-display font-medium text-amare-text">
|
||||
{{ $settings->hero_title ?: 'Celebrações com propósito' }}
|
||||
</h1>
|
||||
|
||||
@if (filled($settings->hero_subtitle))
|
||||
<p class="max-w-2xl text-lg text-amare-text-muted">{{ $settings->hero_subtitle }}</p>
|
||||
@endif
|
||||
|
||||
<div data-motion-beat="cta" class="flex flex-wrap items-center gap-4">
|
||||
<a href="{{ route('contact') }}" data-testid="home-primary-cta" class="inline-flex min-h-11 items-center bg-amare-accent px-5 py-3 text-sm font-semibold text-amare-accent-text transition-colors duration-(--amare-duration-normal) ease-(--amare-ease-standard) hover:bg-amare-accent-hover">
|
||||
{{ $settings->hero_cta_label ?: 'Solicitar proposta' }}
|
||||
</a>
|
||||
|
||||
@if (filled($settings->hero_secondary_cta_label))
|
||||
<a href="{{ route('portfolio.index') }}" class="inline-flex min-h-11 items-center text-sm font-semibold text-amare-accent transition-colors hover:text-amare-accent-deep">
|
||||
<span class="border-b border-amare-accent pb-1">{{ $settings->hero_secondary_cta_label }}</span>
|
||||
<div data-motion-beat="cta" class="flex flex-wrap items-center gap-4">
|
||||
<a href="{{ route('contact') }}" data-testid="home-primary-cta" class="btn btn-primary text-sm font-semibold">
|
||||
{{ $settings->hero_cta_label ?: 'Solicitar proposta' }}
|
||||
</a>
|
||||
|
||||
@if (filled($settings->hero_secondary_cta_label))
|
||||
<a href="{{ route('portfolio.index') }}" class="btn btn-ghost min-h-0 px-0 text-amare-accent hover:bg-transparent hover:text-amare-accent-deep">
|
||||
<span class="border-b border-amare-accent pb-1">{{ $settings->hero_secondary_cta_label }}</span>
|
||||
</a>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
@if (filled($settings->hero_note))
|
||||
<p class="max-w-xl text-sm text-amare-text-muted">{{ $settings->hero_note }}</p>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
@if (filled($settings->hero_note))
|
||||
<p class="max-w-xl text-sm text-amare-text-muted">{{ $settings->hero_note }}</p>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
@if (filled($settings->hero_image_path))
|
||||
<div data-split-hero data-motion-beat="media" class="min-h-80 overflow-hidden bg-amare-bg-deep md:min-h-full" data-reveal-media>
|
||||
<x-media.image :path="$settings->hero_image_path" :alt="$settings->hero_image_alt ?: $settings->brand_name" loading="eager" fetchpriority="high" sizes="(max-width: 768px) 100vw, 55vw" class="img-editorial h-full w-full object-cover" />
|
||||
<div data-split-hero data-motion-beat="media" class="max-lg:aspect-[4/5] overflow-hidden bg-amare-bg-deep lg:h-full" data-reveal-media>
|
||||
<x-media.image :path="$settings->hero_image_path" :alt="$settings->hero_image_alt ?: $settings->brand_name" loading="eager" fetchpriority="high" sizes="(max-width: 1023px) 100vw, 50vw" class="img-editorial h-full w-full object-cover" />
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
@else
|
||||
<div class="container-amare grid min-h-[100dvh] gap-12 py-20 md:max-w-4xl md:items-center md:py-10" data-reveal-group data-tonal-hero>
|
||||
<div class="flex flex-col justify-center space-y-8 md:py-12">
|
||||
<div data-motion-beat="seal" class="flex items-center gap-4">
|
||||
<x-brand.logo mark variant="on-light" class="h-8 w-auto" alt="" />
|
||||
@if (filled($settings->hero_eyebrow))
|
||||
<p class="text-sm font-medium uppercase tracking-[0.18em] text-amare-accent">{{ $settings->hero_eyebrow }}</p>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<h1 id="hero-heading" data-motion-beat="title" class="max-w-3xl text-display font-medium text-amare-text">
|
||||
{{ $settings->hero_title ?: 'Celebrações com propósito' }}
|
||||
</h1>
|
||||
|
||||
@if (filled($settings->hero_subtitle))
|
||||
<p class="max-w-2xl text-lg text-amare-text-muted">{{ $settings->hero_subtitle }}</p>
|
||||
@endif
|
||||
|
||||
<div data-motion-beat="cta" class="flex flex-wrap items-center gap-4">
|
||||
<a href="{{ route('contact') }}" data-testid="home-primary-cta" class="btn btn-primary text-sm font-semibold">
|
||||
{{ $settings->hero_cta_label ?: 'Solicitar proposta' }}
|
||||
</a>
|
||||
|
||||
@if (filled($settings->hero_secondary_cta_label))
|
||||
<a href="{{ route('portfolio.index') }}" class="btn btn-ghost min-h-0 px-0 text-amare-accent hover:bg-transparent hover:text-amare-accent-deep">
|
||||
<span class="border-b border-amare-accent pb-1">{{ $settings->hero_secondary_cta_label }}</span>
|
||||
</a>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
@if (filled($settings->hero_note))
|
||||
<p class="max-w-xl text-sm text-amare-text-muted">{{ $settings->hero_note }}</p>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
</section>
|
||||
|
||||
@@ -34,7 +34,7 @@
|
||||
@endforeach
|
||||
</ul>
|
||||
<p>
|
||||
<a href="{{ route('about') }}" class="inline-flex min-h-11 items-center text-sm font-semibold text-amare-accent transition-colors hover:text-amare-accent-deep">
|
||||
<a href="{{ route('about') }}" class="btn btn-ghost min-h-0 px-0 text-sm font-semibold text-amare-accent hover:bg-transparent hover:text-amare-accent-deep">
|
||||
<span class="border-b border-amare-accent pb-1">Conhecer a Amare</span>
|
||||
</a>
|
||||
</p>
|
||||
|
||||
@@ -26,7 +26,7 @@
|
||||
</ol>
|
||||
|
||||
<p>
|
||||
<a href="{{ route('services.index') }}" class="inline-flex min-h-11 items-center text-sm font-semibold text-amare-accent transition-colors hover:text-amare-accent-deep">
|
||||
<a href="{{ route('services.index') }}" class="btn btn-ghost min-h-0 px-0 text-sm font-semibold text-amare-accent hover:bg-transparent hover:text-amare-accent-deep">
|
||||
<span class="border-b border-amare-accent pb-1">Ver todos os serviços</span>
|
||||
</a>
|
||||
</p>
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
<h1 class="text-4xl font-medium tracking-tight text-amare-text" data-motion-beat="title">Página não encontrada</h1>
|
||||
<p class="text-amare-muted" data-reveal data-reveal-from="up">O endereço que você tentou abrir não existe ou foi movido.</p>
|
||||
<p data-reveal data-reveal-from="up">
|
||||
<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="btn btn-primary text-sm font-semibold uppercase tracking-[0.12em]">
|
||||
Voltar para a home
|
||||
</a>
|
||||
</p>
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
<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">
|
||||
<a href="{{ route('home') }}" class="btn btn-primary text-sm font-semibold uppercase tracking-[0.12em]">
|
||||
Voltar para a home
|
||||
</a>
|
||||
</p>
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
<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">
|
||||
<a href="{{ route('home') }}" class="btn btn-primary text-sm font-semibold uppercase tracking-[0.12em]">
|
||||
Voltar para a home
|
||||
</a>
|
||||
</p>
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
<h1 class="text-4xl font-medium tracking-tight text-amare-text" data-motion-beat="title">Algo deu errado</h1>
|
||||
<p class="text-amare-muted" data-reveal data-reveal-from="up">Não foi possível concluir o pedido agora. Tente novamente em instantes.</p>
|
||||
<p data-reveal data-reveal-from="up">
|
||||
<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="btn btn-primary text-sm font-semibold uppercase tracking-[0.12em]">
|
||||
Voltar para a home
|
||||
</a>
|
||||
</p>
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
<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">
|
||||
<a href="{{ route('home') }}" class="btn btn-primary text-sm font-semibold uppercase tracking-[0.12em]">
|
||||
Voltar para a home
|
||||
</a>
|
||||
</p>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}">
|
||||
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}" data-theme="amare">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
@@ -58,11 +58,11 @@
|
||||
<header class="site-header sticky top-0 z-40 border-b border-amare-border/80 bg-amare-bg/90 backdrop-blur-sm">
|
||||
<div class="container-amare grid grid-cols-[auto_1fr_auto] items-center gap-4 py-4 md:grid-cols-[1fr_auto_1fr]">
|
||||
<nav id="main-nav" class="main-nav order-3 col-span-3 hidden flex-col gap-4 border-t border-amare-border pt-4 md:order-1 md:col-span-1 md:flex md:flex-row md:items-center md:gap-1 md:border-0 md:pt-0" aria-label="Principal" data-main-nav>
|
||||
<a href="{{ route('home') }}" class="inline-flex items-center py-3 text-sm font-semibold uppercase tracking-[0.12em] text-amare-muted transition-colors hover:text-amare-accent md:px-2">Início</a>
|
||||
<a href="{{ route('services.index') }}" class="inline-flex items-center py-3 text-sm font-semibold uppercase tracking-[0.12em] text-amare-muted transition-colors hover:text-amare-accent md:px-2">Serviços</a>
|
||||
<a href="{{ route('portfolio.index') }}" class="inline-flex items-center py-3 text-sm font-semibold uppercase tracking-[0.12em] text-amare-muted transition-colors hover:text-amare-accent md:px-2">Portfólio</a>
|
||||
<a href="{{ route('about') }}" class="inline-flex items-center py-3 text-sm font-semibold uppercase tracking-[0.12em] text-amare-muted transition-colors hover:text-amare-accent md:px-2">Amare</a>
|
||||
<a href="{{ route('contact') }}" class="inline-flex items-center py-3 text-sm font-semibold uppercase tracking-[0.12em] text-amare-muted transition-colors hover:text-amare-accent md:hidden">Solicitar proposta</a>
|
||||
<a href="{{ route('home') }}" class="btn btn-ghost min-h-0 px-0 py-3 text-sm font-semibold uppercase tracking-[0.12em] text-amare-muted hover:text-amare-accent md:px-2">Início</a>
|
||||
<a href="{{ route('services.index') }}" class="btn btn-ghost min-h-0 px-0 py-3 text-sm font-semibold uppercase tracking-[0.12em] text-amare-muted hover:text-amare-accent md:px-2">Serviços</a>
|
||||
<a href="{{ route('portfolio.index') }}" class="btn btn-ghost min-h-0 px-0 py-3 text-sm font-semibold uppercase tracking-[0.12em] text-amare-muted hover:text-amare-accent md:px-2">Portfólio</a>
|
||||
<a href="{{ route('about') }}" class="btn btn-ghost min-h-0 px-0 py-3 text-sm font-semibold uppercase tracking-[0.12em] text-amare-muted hover:text-amare-accent md:px-2">Amare</a>
|
||||
<a href="{{ route('contact') }}" class="btn btn-ghost min-h-0 px-0 py-3 text-sm font-semibold uppercase tracking-[0.12em] text-amare-muted hover:text-amare-accent md:hidden">Solicitar proposta</a>
|
||||
</nav>
|
||||
|
||||
<a href="{{ route('home') }}" class="order-1 justify-self-start md:order-2 md:justify-self-center" aria-label="{{ $siteSettings->brand_name }} — página inicial">
|
||||
@@ -70,13 +70,13 @@
|
||||
</a>
|
||||
|
||||
<div class="order-2 flex items-center justify-end gap-3 md:order-3">
|
||||
<a href="{{ route('contact') }}" class="hidden items-center py-3 text-sm font-semibold uppercase tracking-[0.12em] text-amare-accent transition-colors hover:text-amare-accent-deep md:inline-flex">
|
||||
<a href="{{ route('contact') }}" class="btn btn-ghost hidden min-h-0 px-0 py-3 text-sm font-semibold uppercase tracking-[0.12em] text-amare-accent hover:text-amare-accent-deep md:inline-flex">
|
||||
Solicitar proposta
|
||||
</a>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="menu-button inline-flex h-11 w-11 items-center justify-center border border-amare-border text-amare-text md:hidden"
|
||||
class="menu-button btn btn-square btn-ghost border border-amare-border text-amare-text md:hidden"
|
||||
aria-label="Abrir menu"
|
||||
aria-controls="main-nav"
|
||||
aria-expanded="false"
|
||||
|
||||
@@ -42,7 +42,7 @@
|
||||
</p>
|
||||
|
||||
@if (session('status') === 'briefing-sent')
|
||||
<div role="status" class="mt-8 border border-amare-border bg-amare-bg-deep px-6 py-5">
|
||||
<div role="status" class="alert mt-8 border border-amare-border bg-amare-bg-deep">
|
||||
<p class="font-semibold text-amare-text">Mensagem enviada.</p>
|
||||
<p class="mt-1 text-amare-muted">Recebemos seu briefing e retornaremos em breve pelo canal informado.</p>
|
||||
</div>
|
||||
@@ -61,7 +61,7 @@
|
||||
</div>
|
||||
|
||||
@if ($errors->any())
|
||||
<div role="alert" class="border border-amare-error/40 bg-amare-error/5 px-6 py-5">
|
||||
<div role="alert" class="alert alert-error border border-amare-error/40">
|
||||
<p class="font-semibold text-amare-error">Não foi possível enviar.</p>
|
||||
<p class="mt-1 text-sm text-amare-muted">Revise os campos destacados abaixo e tente novamente.</p>
|
||||
</div>
|
||||
@@ -79,7 +79,7 @@
|
||||
autocomplete="name"
|
||||
maxlength="120"
|
||||
placeholder="Seu nome"
|
||||
class="w-full border-0 border-b border-amare-border bg-transparent py-3 text-amare-text transition-colors placeholder:text-amare-muted/60 focus:border-amare-accent focus:outline-none @error('nome') border-amare-error @enderror"
|
||||
class="input w-full text-amare-text placeholder:text-amare-muted/60 @error('nome') input-error @enderror"
|
||||
@error('nome') aria-invalid="true" aria-describedby="nome-error" @enderror
|
||||
>
|
||||
@error('nome')
|
||||
@@ -98,7 +98,7 @@
|
||||
autocomplete="email"
|
||||
maxlength="254"
|
||||
placeholder="voce@email.com"
|
||||
class="w-full border-0 border-b border-amare-border bg-transparent py-3 text-amare-text transition-colors placeholder:text-amare-muted/60 focus:border-amare-accent focus:outline-none @error('email') border-amare-error @enderror"
|
||||
class="input w-full text-amare-text placeholder:text-amare-muted/60 @error('email') input-error @enderror"
|
||||
@error('email') aria-invalid="true" aria-describedby="email-error" @enderror
|
||||
>
|
||||
@error('email')
|
||||
@@ -117,7 +117,7 @@
|
||||
autocomplete="tel"
|
||||
maxlength="40"
|
||||
placeholder="(11) 90000-0000"
|
||||
class="w-full border-0 border-b border-amare-border bg-transparent py-3 text-amare-text transition-colors placeholder:text-amare-muted/60 focus:border-amare-accent focus:outline-none @error('telefone') border-amare-error @enderror"
|
||||
class="input w-full text-amare-text placeholder:text-amare-muted/60 @error('telefone') input-error @enderror"
|
||||
@error('telefone') aria-invalid="true" aria-describedby="telefone-error" @enderror
|
||||
>
|
||||
@error('telefone')
|
||||
@@ -131,7 +131,7 @@
|
||||
id="tipo_evento"
|
||||
name="tipo_evento"
|
||||
required
|
||||
class="w-full border-0 border-b border-amare-border bg-transparent py-3 text-amare-text transition-colors focus:border-amare-accent focus:outline-none @error('tipo_evento') border-amare-error @enderror"
|
||||
class="select w-full text-amare-text @error('tipo_evento') select-error @enderror"
|
||||
@error('tipo_evento') aria-invalid="true" aria-describedby="tipo_evento-error" @enderror
|
||||
>
|
||||
<option value="" selected disabled>Selecione...</option>
|
||||
@@ -154,7 +154,7 @@
|
||||
value="{{ old('data_periodo') }}"
|
||||
maxlength="80"
|
||||
placeholder="Ex.: novembro de 2027"
|
||||
class="w-full border-0 border-b border-amare-border bg-transparent py-3 text-amare-text transition-colors placeholder:text-amare-muted/60 focus:border-amare-accent focus:outline-none @error('data_periodo') border-amare-error @enderror"
|
||||
class="input w-full text-amare-text placeholder:text-amare-muted/60 @error('data_periodo') input-error @enderror"
|
||||
@error('data_periodo') aria-invalid="true" aria-describedby="data_periodo-error" @enderror
|
||||
>
|
||||
@error('data_periodo')
|
||||
@@ -172,7 +172,7 @@
|
||||
required
|
||||
maxlength="80"
|
||||
placeholder="Ex.: São Paulo"
|
||||
class="w-full border-0 border-b border-amare-border bg-transparent py-3 text-amare-text transition-colors placeholder:text-amare-muted/60 focus:border-amare-accent focus:outline-none @error('cidade') border-amare-error @enderror"
|
||||
class="input w-full text-amare-text placeholder:text-amare-muted/60 @error('cidade') input-error @enderror"
|
||||
@error('cidade') aria-invalid="true" aria-describedby="cidade-error" @enderror
|
||||
>
|
||||
@error('cidade')
|
||||
@@ -192,7 +192,7 @@
|
||||
inputmode="numeric"
|
||||
autocomplete="off"
|
||||
placeholder="Ex.: 120"
|
||||
class="w-full border-0 border-b border-amare-border bg-transparent py-3 text-amare-text transition-colors placeholder:text-amare-muted/60 focus:border-amare-accent focus:outline-none @error('convidados') border-amare-error @enderror"
|
||||
class="input w-full text-amare-text placeholder:text-amare-muted/60 @error('convidados') input-error @enderror"
|
||||
@error('convidados') aria-invalid="true" aria-describedby="convidados-error" @enderror
|
||||
>
|
||||
@error('convidados')
|
||||
@@ -209,7 +209,7 @@
|
||||
value="{{ old('servico_interesse') }}"
|
||||
maxlength="120"
|
||||
placeholder="Ex.: planejamento completo"
|
||||
class="w-full border-0 border-b border-amare-border bg-transparent py-3 text-amare-text transition-colors placeholder:text-amare-muted/60 focus:border-amare-accent focus:outline-none @error('servico_interesse') border-amare-error @enderror"
|
||||
class="input w-full text-amare-text placeholder:text-amare-muted/60 @error('servico_interesse') input-error @enderror"
|
||||
@error('servico_interesse') aria-invalid="true" aria-describedby="servico_interesse-error" @enderror
|
||||
>
|
||||
@error('servico_interesse')
|
||||
@@ -227,7 +227,7 @@
|
||||
rows="6"
|
||||
maxlength="3000"
|
||||
placeholder="Conte sobre o seu evento, expectativas e principais preocupações."
|
||||
class="w-full border-0 border-b border-amare-border bg-transparent py-3 text-amare-text transition-colors placeholder:text-amare-muted/60 focus:border-amare-accent focus:outline-none @error('mensagem') border-amare-error @enderror"
|
||||
class="textarea w-full text-amare-text placeholder:text-amare-muted/60 @error('mensagem') textarea-error @enderror"
|
||||
@error('mensagem') aria-invalid="true" aria-describedby="mensagem-error" @enderror
|
||||
>{{ old('mensagem') }}</textarea>
|
||||
@error('mensagem')
|
||||
@@ -244,7 +244,7 @@
|
||||
value="1"
|
||||
required
|
||||
@checked(old('privacidade'))
|
||||
class="mt-1 h-5 w-5 shrink-0 accent-amare-accent"
|
||||
class="checkbox checkbox-primary mt-1 shrink-0"
|
||||
@error('privacidade') aria-invalid="true" aria-describedby="privacidade-error" @enderror
|
||||
>
|
||||
<span class="text-sm text-amare-muted">
|
||||
@@ -262,7 +262,7 @@
|
||||
<button
|
||||
type="submit"
|
||||
data-submit-button
|
||||
class="inline-flex min-h-[44px] items-center justify-center bg-amare-accent px-8 py-3 text-sm font-semibold uppercase tracking-[0.12em] text-amare-accent-text transition-colors hover:bg-amare-accent-deep"
|
||||
class="btn btn-primary min-h-11 px-8 text-sm font-semibold uppercase tracking-[0.12em]"
|
||||
>
|
||||
Enviar briefing
|
||||
</button>
|
||||
|
||||
@@ -12,6 +12,120 @@ beforeEach(function (): void {
|
||||
Artisan::call('db:seed', ['--class' => VisualContentSeeder::class, '--force' => true]);
|
||||
});
|
||||
|
||||
it('uses equal desktop columns while filling the opening below the header with full-bleed media', function (): void {
|
||||
$page = $this->visit('/', [
|
||||
'reducedMotion' => 'reduce',
|
||||
])->resize(1440, 1000);
|
||||
|
||||
$page->script('() => new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)))');
|
||||
|
||||
$layout = $page->script(<<<'JS'
|
||||
() => {
|
||||
const header = document.querySelector('.site-header');
|
||||
const hero = document.querySelector('[data-chapter="hero"]');
|
||||
const content = hero?.querySelector('[data-hero-content]');
|
||||
const media = hero?.querySelector('[data-split-hero]');
|
||||
const image = media?.querySelector('img');
|
||||
const headerRect = header?.getBoundingClientRect();
|
||||
const heroRect = hero?.getBoundingClientRect();
|
||||
const contentRect = content?.getBoundingClientRect();
|
||||
const mediaRect = media?.getBoundingClientRect();
|
||||
|
||||
return {
|
||||
hasContentColumn: Boolean(content),
|
||||
startsBelowHeader: Math.abs((heroRect?.top ?? -1) - (headerRect?.bottom ?? -2)) <= 1,
|
||||
fillsRemainingViewport: Math.abs((heroRect?.bottom ?? -1) - window.innerHeight) <= 1,
|
||||
mediaTouchesHeroTop: Math.abs((mediaRect?.top ?? -1) - (heroRect?.top ?? -2)) <= 1,
|
||||
mediaTouchesHeroBottom: Math.abs((mediaRect?.bottom ?? -1) - (heroRect?.bottom ?? -2)) <= 1,
|
||||
mediaTouchesViewportRight: Math.abs((mediaRect?.right ?? -1) - window.innerWidth) <= 1,
|
||||
splitImageObjectFit: image ? getComputedStyle(image).objectFit : null,
|
||||
columnRatio: Number(((contentRect?.width ?? 0) / (mediaRect?.width ?? 1)).toFixed(3)),
|
||||
horizontalOverflow: document.documentElement.scrollWidth > window.innerWidth,
|
||||
};
|
||||
}
|
||||
JS);
|
||||
|
||||
expect($layout)->toBe([
|
||||
'hasContentColumn' => true,
|
||||
'startsBelowHeader' => true,
|
||||
'fillsRemainingViewport' => true,
|
||||
'mediaTouchesHeroTop' => true,
|
||||
'mediaTouchesHeroBottom' => true,
|
||||
'mediaTouchesViewportRight' => true,
|
||||
'splitImageObjectFit' => 'cover',
|
||||
'columnRatio' => 1,
|
||||
'horizontalOverflow' => false,
|
||||
]);
|
||||
});
|
||||
|
||||
it('keeps the home hero linear through tablet with a full-width four-by-five crop', function (): void {
|
||||
$page = $this->visit('/', [
|
||||
'reducedMotion' => 'reduce',
|
||||
])->resize(1023, 1000);
|
||||
|
||||
$page->script('() => new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)))');
|
||||
|
||||
$layout = $page->script(<<<'JS'
|
||||
() => {
|
||||
const hero = document.querySelector('[data-chapter="hero"]');
|
||||
const content = hero?.querySelector('[data-hero-content]');
|
||||
const media = hero?.querySelector('[data-split-hero]');
|
||||
const contentRect = content?.getBoundingClientRect();
|
||||
const mediaRect = media?.getBoundingClientRect();
|
||||
|
||||
return {
|
||||
hasContentColumn: Boolean(content),
|
||||
mediaFollowsContent: (mediaRect?.top ?? 0) >= (contentRect?.bottom ?? Number.POSITIVE_INFINITY) - 1,
|
||||
mediaFillsViewportWidth: Math.abs((mediaRect?.width ?? 0) - window.innerWidth) <= 1,
|
||||
mediaAspectRatio: Number(((mediaRect?.width ?? 0) / (mediaRect?.height ?? 1)).toFixed(3)),
|
||||
horizontalOverflow: document.documentElement.scrollWidth > window.innerWidth,
|
||||
};
|
||||
}
|
||||
JS);
|
||||
|
||||
expect($layout)->toBe([
|
||||
'hasContentColumn' => true,
|
||||
'mediaFollowsContent' => true,
|
||||
'mediaFillsViewportWidth' => true,
|
||||
'mediaAspectRatio' => 0.8,
|
||||
'horizontalOverflow' => false,
|
||||
]);
|
||||
});
|
||||
|
||||
it('stacks the home hero media after its content as a full-width four-by-five crop on mobile', function (): void {
|
||||
$page = $this->visit('/', [
|
||||
'reducedMotion' => 'reduce',
|
||||
])->resize(390, 844);
|
||||
|
||||
$page->script('() => new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)))');
|
||||
|
||||
$layout = $page->script(<<<'JS'
|
||||
() => {
|
||||
const hero = document.querySelector('[data-chapter="hero"]');
|
||||
const content = hero?.querySelector('[data-hero-content]');
|
||||
const media = hero?.querySelector('[data-split-hero]');
|
||||
const contentRect = content?.getBoundingClientRect();
|
||||
const mediaRect = media?.getBoundingClientRect();
|
||||
|
||||
return {
|
||||
hasContentColumn: Boolean(content),
|
||||
mediaFollowsContent: (mediaRect?.top ?? 0) >= (contentRect?.bottom ?? Number.POSITIVE_INFINITY) - 1,
|
||||
mediaFillsViewportWidth: Math.abs((mediaRect?.width ?? 0) - window.innerWidth) <= 1,
|
||||
mediaAspectRatio: Number(((mediaRect?.width ?? 0) / (mediaRect?.height ?? 1)).toFixed(3)),
|
||||
horizontalOverflow: document.documentElement.scrollWidth > window.innerWidth,
|
||||
};
|
||||
}
|
||||
JS);
|
||||
|
||||
expect($layout)->toBe([
|
||||
'hasContentColumn' => true,
|
||||
'mediaFollowsContent' => true,
|
||||
'mediaFillsViewportWidth' => true,
|
||||
'mediaAspectRatio' => 0.8,
|
||||
'horizontalOverflow' => false,
|
||||
]);
|
||||
});
|
||||
|
||||
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('/', [
|
||||
|
||||
@@ -35,11 +35,18 @@ class ImmersivePhotoHeroTest extends TestCase
|
||||
|
||||
$response
|
||||
->assertOk()
|
||||
->assertSee('data-chapter="hero"', false)
|
||||
->assertSee('data-motion="page-open"', false)
|
||||
->assertSee('lg:h-[calc(100dvh-5rem)]', false)
|
||||
->assertSee('data-hero-content', false)
|
||||
->assertSee('data-split-hero', false)
|
||||
->assertSee('data-motion-beat="media"', false)
|
||||
->assertSee('lg:grid-cols-2', false)
|
||||
->assertSee('max-lg:aspect-[4/5]', false)
|
||||
->assertSee('content/heroes/home.jpg', false)
|
||||
->assertSee('loading="eager"', false)
|
||||
->assertSee('fetchpriority="high"', false)
|
||||
->assertSee('sizes="(max-width: 768px) 100vw, 55vw"', false)
|
||||
->assertSee('sizes="(max-width: 1023px) 100vw, 50vw"', false)
|
||||
->assertSee('content="http://localhost/storage/content/og/social.jpg"', false);
|
||||
}
|
||||
|
||||
@@ -59,9 +66,18 @@ class ImmersivePhotoHeroTest extends TestCase
|
||||
$case = PortfolioCase::factory()->published()->create(['cover_image_path' => '', 'cover_image_alt' => '']);
|
||||
|
||||
foreach ([route('home'), route('services.index'), route('portfolio.index'), route('portfolio.show', $case->slug), route('about')] as $route) {
|
||||
$this->get($route)
|
||||
$response = $this->get($route)
|
||||
->assertOk()
|
||||
->assertSee('data-tonal-hero', false);
|
||||
|
||||
if ($route === route('home')) {
|
||||
$response
|
||||
->assertSee('data-motion="page-open"', false)
|
||||
->assertSee('id="hero-heading"', false)
|
||||
->assertSee('data-testid="home-primary-cta"', false)
|
||||
->assertDontSee('data-split-hero', false)
|
||||
->assertDontSee('content/heroes/home.jpg', false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user