Compare commits
4 Commits
feat/home-
...
f7fdf3930c
| Author | SHA1 | Date | |
|---|---|---|---|
| f7fdf3930c | |||
| d2d1b436fa | |||
| d044911d24 | |||
| 7902b35393 |
@@ -57,6 +57,9 @@ 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->hero_image_path)) {
|
||||||
|
$paths[] = (string) $settings->hero_image_path;
|
||||||
|
}
|
||||||
if ($settings && filled($settings->about_image_path)) {
|
if ($settings && filled($settings->about_image_path)) {
|
||||||
$paths[] = (string) $settings->about_image_path;
|
$paths[] = (string) $settings->about_image_path;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -160,6 +160,8 @@ class ManageSiteSettings extends Page
|
|||||||
Textarea::make('hero_note')
|
Textarea::make('hero_note')
|
||||||
->label('Nota do hero')
|
->label('Nota do hero')
|
||||||
->rows(2),
|
->rows(2),
|
||||||
|
PublicImageUploadRules::fileUpload('hero_image_path', 'Imagem do hero', 'content/home'),
|
||||||
|
PublicImageUploadRules::altTextField('hero_image_alt', 'hero_image_path'),
|
||||||
Textarea::make('about_summary')
|
Textarea::make('about_summary')
|
||||||
->label('Resumo institucional')
|
->label('Resumo institucional')
|
||||||
->rows(3),
|
->rows(3),
|
||||||
|
|||||||
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.
|
||||||
@@ -130,43 +130,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);
|
||||||
|
|||||||
@@ -7,14 +7,12 @@
|
|||||||
'border-t border-amare-border bg-amare-bg-deep py-20',
|
'border-t border-amare-border bg-amare-bg-deep py-20',
|
||||||
'home-chapter' => $editorial,
|
'home-chapter' => $editorial,
|
||||||
]) data-chapter="final-cta" data-reveal-group>
|
]) data-chapter="final-cta" data-reveal-group>
|
||||||
<div class="container-amare space-y-6 text-center" data-reveal data-reveal-from="up">
|
<div class="container-amare max-w-4xl space-y-6 py-4" data-reveal data-reveal-from="up">
|
||||||
@if ($editorial)
|
@unless ($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
|
@endunless
|
||||||
<h2 id="final-cta-heading" class="text-headline font-medium text-amare-text">Do casamento ao evento corporativo, tudo começa com uma boa conversa.</h2>
|
<h2 id="final-cta-heading" class="text-headline font-medium text-amare-text">Do casamento ao evento corporativo, tudo começa com uma boa conversa.</h2>
|
||||||
<p class="mx-auto max-w-2xl text-amare-muted">
|
<p class="max-w-2xl text-amare-muted">
|
||||||
Compartilhe as primeiras informações do seu evento. A Amare retorna para entender o contexto e orientar os próximos passos.
|
Compartilhe as primeiras informações do seu evento. A Amare retorna para entender o contexto e orientar os próximos passos.
|
||||||
</p>
|
</p>
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@@ -1,20 +0,0 @@
|
|||||||
@props([
|
|
||||||
'label',
|
|
||||||
'tone' => 'default',
|
|
||||||
])
|
|
||||||
|
|
||||||
@php
|
|
||||||
$tone = $tone === 'inverse' ? 'inverse' : 'default';
|
|
||||||
@endphp
|
|
||||||
|
|
||||||
<p
|
|
||||||
{{ $attributes->class([
|
|
||||||
'home-folio',
|
|
||||||
'home-folio--inverse' => $tone === 'inverse',
|
|
||||||
]) }}
|
|
||||||
data-home-folio
|
|
||||||
>
|
|
||||||
<span data-home-folio-label>{{ $label }}</span>
|
|
||||||
<span class="home-folio__separator" aria-hidden="true">·</span>
|
|
||||||
<span class="home-folio__number" data-home-folio-number aria-hidden="true"></span>
|
|
||||||
</p>
|
|
||||||
@@ -2,26 +2,93 @@
|
|||||||
'settings',
|
'settings',
|
||||||
])
|
])
|
||||||
|
|
||||||
<x-public.photo-hero
|
<section
|
||||||
class="home-chapter"
|
aria-labelledby="hero-heading"
|
||||||
|
@class([
|
||||||
|
'home-chapter border-b border-amare-border bg-amare-bg',
|
||||||
|
'min-h-[100dvh]' => blank($settings->hero_image_path),
|
||||||
|
'md:h-[calc(100dvh-5rem)]' => filled($settings->hero_image_path),
|
||||||
|
])
|
||||||
data-chapter="hero"
|
data-chapter="hero"
|
||||||
:image-path="$settings->hero_image_path"
|
data-motion="page-open"
|
||||||
:image-alt="$settings->hero_image_alt"
|
|
||||||
:eyebrow="$settings->hero_eyebrow"
|
|
||||||
:title="$settings->hero_title ?: 'Celebrações com propósito'"
|
|
||||||
:summary="$settings->hero_subtitle"
|
|
||||||
brand-mark
|
|
||||||
>
|
>
|
||||||
<div class="container-amare pt-8 md:pt-10">
|
@if (filled($settings->hero_image_path))
|
||||||
<x-home.folio label="Capa" data-motion-beat="folio" />
|
<div class="grid md:h-full md:grid-cols-[minmax(0,0.9fr)_minmax(0,1.1fr)]">
|
||||||
</div>
|
<div
|
||||||
<div class="flex flex-wrap items-center gap-4">
|
data-hero-content
|
||||||
<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>
|
data-reveal-group
|
||||||
@if (filled($settings->hero_secondary_cta_label))
|
class="flex flex-col justify-center space-y-8 px-6 py-20 md:py-12 md:pr-[clamp(3rem,6vw,7rem)] md:pl-[max(var(--amare-container-padding),calc((100vw-var(--amare-container-max))/2+var(--amare-container-padding)))]"
|
||||||
<a href="{{ route('portfolio.index') }}" class="inline-flex min-h-11 items-center text-sm font-semibold text-amare-accent transition-colors hover:text-amare-accent-deep"><span class="border-b border-amare-accent pb-1">{{ $settings->hero_secondary_cta_label }}</span></a>
|
>
|
||||||
@endif
|
<div data-motion-beat="seal" class="flex items-center gap-4">
|
||||||
</div>
|
<x-brand.logo mark variant="on-light" class="h-8 w-auto" alt="" />
|
||||||
@if (filled($settings->hero_note))
|
@if (filled($settings->hero_eyebrow))
|
||||||
<p class="mt-5 max-w-xl text-sm text-amare-text-muted">{{ $settings->hero_note }}</p>
|
<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="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>
|
||||||
|
</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 data-split-hero data-motion-beat="media" class="max-md:aspect-[4/5] overflow-hidden bg-amare-bg-deep md: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: 767px) 100vw, 55vw" class="img-editorial h-full w-full object-cover" />
|
||||||
|
</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="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>
|
||||||
|
</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
|
@endif
|
||||||
</x-public.photo-hero>
|
</section>
|
||||||
|
|||||||
@@ -8,14 +8,12 @@
|
|||||||
$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="home-chapter border-b border-amare-border bg-amare-bg-deep py-24 md:py-32" 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-10 md:grid-cols-12" data-reveal-group>
|
||||||
<div class="space-y-5 md:pr-8" data-reveal data-reveal-from="up">
|
<div class="space-y-5 md:col-span-4 md:pt-20" data-reveal data-reveal-from="left">
|
||||||
<x-home.folio label="Manifesto" />
|
|
||||||
<span class="block h-px w-16 bg-amare-border" aria-hidden="true"></span>
|
|
||||||
<p class="max-w-52 text-lg leading-snug text-amare-text">Humana no cuidado. Precisa na entrega.</p>
|
<p class="max-w-52 text-lg leading-snug text-amare-text">Humana no cuidado. Precisa na entrega.</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="space-y-6" data-reveal data-reveal-from="up">
|
<div class="space-y-6 md:col-span-7 md:col-start-6" data-reveal data-reveal-from="up">
|
||||||
<h2 id="manifesto-heading" class="max-w-3xl text-headline font-medium text-amare-text">{{ $title }}</h2>
|
<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>
|
||||||
|
|||||||
@@ -7,10 +7,9 @@
|
|||||||
$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="home-chapter border-b border-amare-border bg-amare-bg-archive py-24" 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" data-reveal-group>
|
||||||
<div class="space-y-4" data-reveal data-reveal-from="up">
|
<div class="space-y-4" data-reveal data-reveal-from="up">
|
||||||
<x-home.folio label="Método" />
|
|
||||||
<h2 id="method-heading" class="text-headline font-medium text-amare-text">Cuidado orientado por processo.</h2>
|
<h2 id="method-heading" class="text-headline 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
|
<x-brand.logo
|
||||||
|
|||||||
@@ -3,17 +3,16 @@
|
|||||||
])
|
])
|
||||||
|
|
||||||
@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" data-editorial-portfolio>
|
<section aria-labelledby="portfolio-heading" class="home-chapter border-b border-amare-accent-deep bg-amare-accent-deep py-24 text-amare-accent-text" data-chapter="portfolio">
|
||||||
<div class="container-amare space-y-10" data-reveal-group>
|
<div class="container-amare space-y-10" data-reveal-group>
|
||||||
<div class="grid gap-4 md:grid-cols-[minmax(0,0.8fr)_minmax(0,1.2fr)]" data-reveal data-reveal-from="up">
|
<div class="grid gap-4 md:grid-cols-12" data-reveal data-reveal-from="up">
|
||||||
<x-home.folio label="Portfólio" tone="inverse" />
|
<div class="md:col-span-7 md:col-start-5 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-headline font-medium">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-8 md:grid-cols-12 md:gap-x-8 md:gap-y-14">
|
<div class="grid gap-8 md:grid-cols-12 md:gap-x-8 md:gap-y-14" data-editorial-portfolio>
|
||||||
@foreach ($cases as $case)
|
@foreach ($cases as $case)
|
||||||
@php
|
@php
|
||||||
$isFeature = $loop->first;
|
$isFeature = $loop->first;
|
||||||
|
|||||||
@@ -7,18 +7,27 @@
|
|||||||
$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="home-chapter border-b border-amare-border py-24" 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-12" data-reveal-group>
|
||||||
<div class="space-y-4" data-reveal data-reveal-from="up">
|
<div class="space-y-4 md:col-span-5" data-reveal data-reveal-from="left">
|
||||||
<x-home.folio label="A Amare" />
|
|
||||||
<div class="flex items-center gap-4">
|
<div class="flex items-center gap-4">
|
||||||
<span class="h-px w-10 bg-amare-border" aria-hidden="true"></span>
|
<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>
|
<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>
|
<h2 id="positioning-heading" class="text-headline font-medium text-amare-text">Presença que organiza o essencial.</h2>
|
||||||
</div>
|
</div>
|
||||||
<div class="space-y-8" data-reveal data-reveal-from="up">
|
<div class="space-y-8 md:col-span-6 md:col-start-7" 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>
|
||||||
|
@if (filled($settings->about_image_path))
|
||||||
|
<div class="overflow-hidden bg-amare-bg-deep" data-reveal-media>
|
||||||
|
<x-media.image
|
||||||
|
:path="$settings->about_image_path"
|
||||||
|
:alt="$settings->about_image_alt ?: $settings->brand_name"
|
||||||
|
sizes="(max-width: 768px) 100vw, 50vw"
|
||||||
|
class="img-editorial aspect-[4/3] w-full object-cover"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
<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)
|
||||||
<li class="border-b border-amare-border py-3 text-amare-text-muted">{{ $principle }}</li>
|
<li class="border-b border-amare-border py-3 text-amare-text-muted">{{ $principle }}</li>
|
||||||
|
|||||||
@@ -3,11 +3,10 @@
|
|||||||
])
|
])
|
||||||
|
|
||||||
@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="home-chapter border-b border-amare-border py-24" data-chapter="services">
|
||||||
<div class="container-amare space-y-10" data-reveal-group>
|
<div class="container-amare space-y-10" data-reveal-group>
|
||||||
<div class="grid gap-6 md:grid-cols-12 md:items-end" data-reveal data-reveal-from="up">
|
<div class="grid gap-6 md:grid-cols-12 md:items-end" data-reveal data-reveal-from="up">
|
||||||
<div class="space-y-3 md:col-span-4">
|
<div class="space-y-3 md:col-span-4">
|
||||||
<x-home.folio label="Serviços" />
|
|
||||||
<h2 id="services-heading" class="text-3xl font-medium text-amare-text">Serviços</h2>
|
<h2 id="services-heading" class="text-3xl font-medium text-amare-text">Serviços</h2>
|
||||||
</div>
|
</div>
|
||||||
<div class="space-y-4 md:col-span-6 md:col-start-7">
|
<div class="space-y-4 md:col-span-6 md:col-start-7">
|
||||||
|
|||||||
@@ -9,21 +9,23 @@
|
|||||||
@endphp
|
@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="home-chapter border-b border-amare-border bg-amare-bg py-24" data-chapter="testimonials">
|
||||||
<div class="container-amare space-y-8" data-reveal-group>
|
<div class="container-amare space-y-10" data-reveal-group>
|
||||||
<div class="max-w-2xl space-y-3" data-reveal data-reveal-from="up">
|
<div class="max-w-2xl space-y-3 md:ml-[16.666667%]" data-reveal data-reveal-from="up">
|
||||||
<x-home.folio label="Depoimentos" />
|
|
||||||
<h2 id="testimonials-heading" class="text-3xl font-medium text-amare-text">Depoimentos</h2>
|
<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>
|
||||||
|
|
||||||
<div class="grid gap-6 md:grid-cols-2">
|
<div class="grid gap-6 md:grid-cols-12">
|
||||||
@foreach ($testimonials as $testimonial)
|
@foreach ($testimonials as $testimonial)
|
||||||
@php
|
@php
|
||||||
$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 md:col-span-5' => $loop->odd,
|
||||||
|
'space-y-4 border-t border-amare-border pt-4 md:col-span-5 md:col-start-7 md:mt-16' => $loop->even,
|
||||||
|
]) data-reveal data-reveal-from="{{ $loop->odd ? 'left' : 'right' }}">
|
||||||
<div class="grid grid-cols-[1.5rem_minmax(0,1fr)] gap-2">
|
<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>
|
<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">
|
||||||
|
|||||||
@@ -12,6 +12,86 @@ beforeEach(function (): void {
|
|||||||
Artisan::call('db:seed', ['--class' => VisualContentSeeder::class, '--force' => true]);
|
Artisan::call('db:seed', ['--class' => VisualContentSeeder::class, '--force' => true]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('fills the desktop opening from below the header while keeping the media full bleed', 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' => 0.818,
|
||||||
|
'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 {
|
it('keeps the full home cadence accessible and contained at both viewports', function (): void {
|
||||||
foreach ([[1440, 1000], [390, 844]] as [$width, $height]) {
|
foreach ([[1440, 1000], [390, 844]] as [$width, $height]) {
|
||||||
$page = $this->visit('/', [
|
$page = $this->visit('/', [
|
||||||
@@ -23,7 +103,6 @@ it('keeps the full home cadence accessible and contained at both viewports', fun
|
|||||||
$layout = $page->script(<<<'JS'
|
$layout = $page->script(<<<'JS'
|
||||||
() => {
|
() => {
|
||||||
const chapters = Array.from(document.querySelectorAll('.home-chapter'));
|
const chapters = Array.from(document.querySelectorAll('.home-chapter'));
|
||||||
const folios = Array.from(document.querySelectorAll('[data-home-folio]'));
|
|
||||||
const clippedValues = new Set(['clip', 'hidden']);
|
const clippedValues = new Set(['clip', 'hidden']);
|
||||||
const textNodes = Array.from(document.querySelectorAll(
|
const textNodes = Array.from(document.querySelectorAll(
|
||||||
'.home-chapters h1, .home-chapters h2, .home-chapters h3, .home-chapters p, .home-chapters a, .home-chapters span'
|
'.home-chapters h1, .home-chapters h2, .home-chapters h3, .home-chapters p, .home-chapters a, .home-chapters span'
|
||||||
@@ -36,10 +115,7 @@ it('keeps the full home cadence accessible and contained at both viewports', fun
|
|||||||
const current = chapter.getBoundingClientRect();
|
const current = chapter.getBoundingClientRect();
|
||||||
return current.top < previous.bottom - 1;
|
return current.top < previous.bottom - 1;
|
||||||
}),
|
}),
|
||||||
overflowingFolios: folios.filter((folio) => {
|
ornamentalFolios: document.querySelectorAll('[data-home-folio]').length,
|
||||||
const rect = folio.getBoundingClientRect();
|
|
||||||
return rect.left < -1 || rect.right > window.innerWidth + 1;
|
|
||||||
}).length,
|
|
||||||
clippedText: textNodes.filter((node) => {
|
clippedText: textNodes.filter((node) => {
|
||||||
const style = getComputedStyle(node);
|
const style = getComputedStyle(node);
|
||||||
const clippedX = clippedValues.has(style.overflowX) && node.scrollWidth > node.clientWidth + 1;
|
const clippedX = clippedValues.has(style.overflowX) && node.scrollWidth > node.clientWidth + 1;
|
||||||
@@ -53,7 +129,7 @@ it('keeps the full home cadence accessible and contained at both viewports', fun
|
|||||||
expect($layout)->toBe([
|
expect($layout)->toBe([
|
||||||
'horizontalOverflow' => false,
|
'horizontalOverflow' => false,
|
||||||
'overlappingChapters' => false,
|
'overlappingChapters' => false,
|
||||||
'overflowingFolios' => 0,
|
'ornamentalFolios' => 0,
|
||||||
'clippedText' => 0,
|
'clippedText' => 0,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -259,7 +259,7 @@ it('avoids horizontal overflow on visual public routes at desktop and mobile', f
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
it('applies CSS numbering to only rendered home folios in chapter order', function (): void {
|
it('keeps rendered home chapters free of ornamental folios and counters', function (): void {
|
||||||
PortfolioCase::factory()->published()->create([
|
PortfolioCase::factory()->published()->create([
|
||||||
'is_featured' => true,
|
'is_featured' => true,
|
||||||
]);
|
]);
|
||||||
@@ -271,7 +271,7 @@ it('applies CSS numbering to only rendered home folios in chapter order', functi
|
|||||||
'reducedMotion' => 'reduce',
|
'reducedMotion' => 'reduce',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$numbering = $page->script(<<<'JS'
|
$editorial = $page->script(<<<'JS'
|
||||||
() => {
|
() => {
|
||||||
const flow = document.querySelector('.home-chapters');
|
const flow = document.querySelector('.home-chapters');
|
||||||
const chapters = Array.from(document.querySelectorAll('.home-chapter'));
|
const chapters = Array.from(document.querySelectorAll('.home-chapter'));
|
||||||
@@ -280,26 +280,12 @@ it('applies CSS numbering to only rendered home folios in chapter order', functi
|
|||||||
return {
|
return {
|
||||||
reset: flow ? getComputedStyle(flow).counterReset : '',
|
reset: flow ? getComputedStyle(flow).counterReset : '',
|
||||||
increments: chapters.map((chapter) => getComputedStyle(chapter).counterIncrement),
|
increments: chapters.map((chapter) => getComputedStyle(chapter).counterIncrement),
|
||||||
labels: folios.map((folio) => folio.querySelector('[data-home-folio-label]')?.textContent?.trim() ?? ''),
|
folioCount: folios.length,
|
||||||
contents: folios.map((folio) => {
|
|
||||||
const number = folio.querySelector('[data-home-folio-number]');
|
|
||||||
return number ? getComputedStyle(number, '::before').content.replace(/["']/g, '') : '';
|
|
||||||
}),
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
JS);
|
JS);
|
||||||
|
|
||||||
expect($numbering['reset'])->toContain('home-chapter -1');
|
expect($editorial['reset'])->toBe('none');
|
||||||
expect($numbering['increments'])->each->toContain('home-chapter 1');
|
expect($editorial['increments'])->each->toBe('none');
|
||||||
expect($numbering['labels'])->toBe([
|
expect($editorial['folioCount'])->toBe(0);
|
||||||
'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)');
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -111,6 +111,48 @@ class SiteSettingsTest extends TestCase
|
|||||||
->assertHasNoFormErrors();
|
->assertHasNoFormErrors();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function test_admin_can_upload_a_home_hero_image_with_alt_text(): void
|
||||||
|
{
|
||||||
|
Storage::fake('public');
|
||||||
|
|
||||||
|
$admin = User::factory()->admin()->create();
|
||||||
|
SiteSetting::instance();
|
||||||
|
|
||||||
|
$this->actingAs($admin);
|
||||||
|
|
||||||
|
Livewire::test(ManageSiteSettings::class)
|
||||||
|
->set('data.hero_image_path', [
|
||||||
|
UploadedFile::fake()->image('hero.jpg', 1600, 1000),
|
||||||
|
])
|
||||||
|
->set('data.hero_image_alt', 'Convidados em uma celebração ao ar livre')
|
||||||
|
->call('save')
|
||||||
|
->assertHasNoFormErrors();
|
||||||
|
|
||||||
|
$settings = SiteSetting::instance()->refresh();
|
||||||
|
|
||||||
|
$this->assertSame('Convidados em uma celebração ao ar livre', $settings->hero_image_alt);
|
||||||
|
$this->assertNotNull($settings->hero_image_path);
|
||||||
|
Storage::disk('public')->assertExists($settings->hero_image_path);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_home_hero_image_requires_alt_text(): void
|
||||||
|
{
|
||||||
|
Storage::fake('public');
|
||||||
|
|
||||||
|
$admin = User::factory()->admin()->create();
|
||||||
|
SiteSetting::instance();
|
||||||
|
|
||||||
|
$this->actingAs($admin);
|
||||||
|
|
||||||
|
Livewire::test(ManageSiteSettings::class)
|
||||||
|
->set('data.hero_image_path', [
|
||||||
|
UploadedFile::fake()->image('hero.jpg', 1600, 1000),
|
||||||
|
])
|
||||||
|
->set('data.hero_image_alt', null)
|
||||||
|
->call('save')
|
||||||
|
->assertHasFormErrors(['hero_image_alt' => 'required']);
|
||||||
|
}
|
||||||
|
|
||||||
public function test_content_seeder_includes_default_og_image_alt_text(): void
|
public function test_content_seeder_includes_default_og_image_alt_text(): void
|
||||||
{
|
{
|
||||||
Storage::fake('public');
|
Storage::fake('public');
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ declare(strict_types=1);
|
|||||||
namespace Tests\Feature;
|
namespace Tests\Feature;
|
||||||
|
|
||||||
use App\Models\Service;
|
use App\Models\Service;
|
||||||
|
use App\Models\SiteSetting;
|
||||||
use App\Support\ResponsiveImage;
|
use App\Support\ResponsiveImage;
|
||||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
use Illuminate\Support\Facades\Artisan;
|
use Illuminate\Support\Facades\Artisan;
|
||||||
@@ -61,4 +62,25 @@ class MediaGenerateVariantsCommandTest extends TestCase
|
|||||||
$this->assertSame(0, $exitCode);
|
$this->assertSame(0, $exitCode);
|
||||||
$this->assertNull(ResponsiveImage::metadata($path, 'public'));
|
$this->assertNull(ResponsiveImage::metadata($path, 'public'));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function test_command_backfills_variants_for_the_home_hero_image(): void
|
||||||
|
{
|
||||||
|
Storage::fake('public');
|
||||||
|
|
||||||
|
$manager = new ImageManager(new Driver);
|
||||||
|
$path = 'content/home/hero.jpg';
|
||||||
|
Storage::disk('public')->put($path, (string) $manager->create(1600, 1000)->toJpeg());
|
||||||
|
|
||||||
|
SiteSetting::instance()->update([
|
||||||
|
'hero_image_path' => $path,
|
||||||
|
'hero_image_alt' => 'Celebração ao ar livre',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$exitCode = Artisan::call('media:generate-variants');
|
||||||
|
|
||||||
|
$this->assertSame(0, $exitCode);
|
||||||
|
foreach (ResponsiveImage::WIDTHS as $width) {
|
||||||
|
Storage::disk('public')->assertExists(ResponsiveImage::variantPath($path, $width));
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -129,6 +129,41 @@ class HomePageContentTest extends TestCase
|
|||||||
->assertSeeInOrder(['data-testid="home-primary-cta"', 'Solicitar proposta']);
|
->assertSeeInOrder(['data-testid="home-primary-cta"', 'Solicitar proposta']);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function test_home_hero_uses_its_own_cms_image_instead_of_the_social_og_image(): void
|
||||||
|
{
|
||||||
|
SiteSetting::instance()->update([
|
||||||
|
'hero_image_path' => 'content/home/hero.jpg',
|
||||||
|
'hero_image_alt' => 'Celebração ao ar livre',
|
||||||
|
'default_og_image_path' => 'content/og/social.jpg',
|
||||||
|
'default_og_image_alt' => 'Imagem social',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$response = $this->get(route('home'))
|
||||||
|
->assertOk()
|
||||||
|
->assertSee('content/home/hero.jpg', false)
|
||||||
|
->assertSee('alt="Celebração ao ar livre"', false);
|
||||||
|
|
||||||
|
$this->assertMatchesRegularExpression(
|
||||||
|
'/data-motion-beat="media"[^>]*>\s*<img[^>]*content\/home\/hero\.jpg/s',
|
||||||
|
$response->getContent(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_home_hero_keeps_an_intentional_typographic_fallback_without_media(): void
|
||||||
|
{
|
||||||
|
SiteSetting::instance()->update([
|
||||||
|
'hero_image_path' => null,
|
||||||
|
'hero_image_alt' => null,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->get(route('home'))
|
||||||
|
->assertOk()
|
||||||
|
->assertSee('min-h-[100dvh]', false)
|
||||||
|
->assertSee('id="hero-heading"', false)
|
||||||
|
->assertSee('data-testid="home-primary-cta"', false)
|
||||||
|
->assertDontSee('data-motion-beat="media"', false);
|
||||||
|
}
|
||||||
|
|
||||||
public function test_blank_quote_testimonials_are_skipped(): void
|
public function test_blank_quote_testimonials_are_skipped(): void
|
||||||
{
|
{
|
||||||
Testimonial::factory()->published()->create([
|
Testimonial::factory()->published()->create([
|
||||||
@@ -180,8 +215,8 @@ class HomePageContentTest extends TestCase
|
|||||||
'Segunda autora',
|
'Segunda autora',
|
||||||
], false);
|
], false);
|
||||||
|
|
||||||
$this->assertSame(1, substr_count($response->getContent(), 'data-reveal-from="left"'));
|
$this->assertSame(1, preg_match_all('/<blockquote\b[^>]*data-reveal-from="left"/i', $response->getContent()));
|
||||||
$this->assertSame(1, substr_count($response->getContent(), 'data-reveal-from="right"'));
|
$this->assertSame(1, preg_match_all('/<blockquote\b[^>]*data-reveal-from="right"/i', $response->getContent()));
|
||||||
}
|
}
|
||||||
|
|
||||||
public function test_testimonials_section_is_omitted_when_every_published_quote_is_blank(): void
|
public function test_testimonials_section_is_omitted_when_every_published_quote_is_blank(): void
|
||||||
@@ -198,7 +233,7 @@ class HomePageContentTest extends TestCase
|
|||||||
->assertDontSee('Relato vazio');
|
->assertDontSee('Relato vazio');
|
||||||
}
|
}
|
||||||
|
|
||||||
public function test_home_folios_follow_rendered_chapter_order_and_hide_decorative_numbers(): void
|
public function test_home_removes_ornamental_folios_and_counter_css(): void
|
||||||
{
|
{
|
||||||
SiteSetting::instance();
|
SiteSetting::instance();
|
||||||
|
|
||||||
@@ -208,54 +243,15 @@ class HomePageContentTest extends TestCase
|
|||||||
|
|
||||||
$html = $this->get(route('home'))->assertOk()->getContent();
|
$html = $this->get(route('home'))->assertOk()->getContent();
|
||||||
|
|
||||||
$this->assertSame([
|
$this->assertStringNotContainsString('data-home-folio', (string) $html);
|
||||||
'Capa',
|
$this->assertStringNotContainsString('home-folio', (string) $html);
|
||||||
'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));
|
$this->assertSame(1, preg_match_all('/<h1\b/i', (string) $html));
|
||||||
|
|
||||||
$css = (string) file_get_contents(resource_path('css/app.css'));
|
$css = (string) file_get_contents(resource_path('css/app.css'));
|
||||||
|
|
||||||
$this->assertStringContainsString('counter-reset: home-chapter -1', $css);
|
$this->assertStringNotContainsString('counter-reset: home-chapter', $css);
|
||||||
$this->assertStringContainsString('counter-increment: home-chapter', $css);
|
$this->assertStringNotContainsString('counter-increment: home-chapter', $css);
|
||||||
$this->assertStringContainsString('counter(home-chapter, decimal-leading-zero)', $css);
|
$this->assertStringNotContainsString('counter(home-chapter', $css);
|
||||||
}
|
$this->assertFileDoesNotExist(resource_path('views/components/home/folio.blade.php'));
|
||||||
|
|
||||||
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],
|
|
||||||
));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,11 +35,16 @@ class ImmersivePhotoHeroTest extends TestCase
|
|||||||
|
|
||||||
$response
|
$response
|
||||||
->assertOk()
|
->assertOk()
|
||||||
->assertSee('data-photo-hero', false)
|
->assertSee('data-chapter="hero"', false)
|
||||||
|
->assertSee('data-motion="page-open"', false)
|
||||||
|
->assertSee('h-[calc(100dvh-5rem)]', false)
|
||||||
|
->assertSee('data-hero-content', false)
|
||||||
|
->assertSee('data-split-hero', false)
|
||||||
|
->assertSee('data-motion-beat="media"', false)
|
||||||
->assertSee('content/heroes/home.jpg', false)
|
->assertSee('content/heroes/home.jpg', false)
|
||||||
->assertSee('loading="eager"', false)
|
->assertSee('loading="eager"', false)
|
||||||
->assertSee('fetchpriority="high"', false)
|
->assertSee('fetchpriority="high"', false)
|
||||||
->assertSee('sizes="100vw"', false)
|
->assertSee('sizes="(max-width: 767px) 100vw, 55vw"', false)
|
||||||
->assertSee('content="http://localhost/storage/content/og/social.jpg"', false);
|
->assertSee('content="http://localhost/storage/content/og/social.jpg"', false);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -59,10 +64,18 @@ class ImmersivePhotoHeroTest extends TestCase
|
|||||||
$case = PortfolioCase::factory()->published()->create(['cover_image_path' => '', 'cover_image_alt' => '']);
|
$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) {
|
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()
|
->assertOk()
|
||||||
->assertDontSee('data-photo-hero', false)
|
|
||||||
->assertSee('data-tonal-hero', false);
|
->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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -46,7 +46,8 @@ class MotionMarkupTest extends TestCase
|
|||||||
->assertDontSee('data-chapter-progress', false)
|
->assertDontSee('data-chapter-progress', false)
|
||||||
->assertSee('data-chapter="hero"', false)
|
->assertSee('data-chapter="hero"', false)
|
||||||
->assertSee('data-chapter="manifesto"', false)
|
->assertSee('data-chapter="manifesto"', false)
|
||||||
->assertSee('data-reveal', false);
|
->assertSee('data-reveal', false)
|
||||||
|
->assertDontSee('data-home-folio', false);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function test_home_has_no_chapter_navigation_when_cms_sections_are_empty(): void
|
public function test_home_has_no_chapter_navigation_when_cms_sections_are_empty(): void
|
||||||
|
|||||||
Reference in New Issue
Block a user