Compare commits

..

4 Commits

Author SHA1 Message Date
f7fdf3930c feat(home): preencher hero fotográfico 2026-08-11 10:30:19 -03:00
d2d1b436fa docs: planejar hero full-bleed da home 2026-08-11 10:15:09 -03:00
d044911d24 docs: especificar hero full-bleed da home 2026-08-11 10:08:00 -03:00
7902b35393 feat(home): alinhar cadência editorial (#47)
* feat(home): alinhar cadência editorial

* test(home): ajustar contrato do hero dividido
2026-08-11 09:52:08 -03:00
5 changed files with 285 additions and 40 deletions

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

View File

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

View File

@@ -4,52 +4,91 @@
<section <section
aria-labelledby="hero-heading" 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),
'md:h-[calc(100dvh-5rem)]' => filled($settings->hero_image_path),
])
data-chapter="hero" data-chapter="hero"
data-motion="page-open" data-motion="page-open"
> >
<div @class([ @if (filled($settings->hero_image_path))
'container-amare grid min-h-[100dvh] gap-12 py-20 md:py-10', <div class="grid md:h-full md:grid-cols-[minmax(0,0.9fr)_minmax(0,1.1fr)]">
'md:grid-cols-[minmax(0,0.9fr)_minmax(0,1.1fr)] md:items-stretch' => filled($settings->hero_image_path), <div
'md:max-w-4xl md:items-center' => blank($settings->hero_image_path), data-hero-content
]) data-reveal-group @if (blank($settings->hero_image_path)) data-tonal-hero @endif> data-reveal-group
<div class="flex flex-col justify-center space-y-8 md:py-12"> 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)))]"
<div data-motion-beat="seal" class="flex items-center gap-4"> >
<x-brand.logo mark variant="on-light" class="h-8 w-auto" alt="" /> <div data-motion-beat="seal" class="flex items-center gap-4">
@if (filled($settings->hero_eyebrow)) <x-brand.logo mark variant="on-light" class="h-8 w-auto" alt="" />
<p class="text-sm font-medium uppercase tracking-[0.18em] text-amare-accent">{{ $settings->hero_eyebrow }}</p> @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 @endif
</div>
<h1 id="hero-heading" data-motion-beat="title" class="max-w-3xl text-display font-medium text-amare-text"> <div data-motion-beat="cta" class="flex flex-wrap items-center gap-4">
{{ $settings->hero_title ?: 'Celebrações com propósito' }} <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">
</h1> {{ $settings->hero_cta_label ?: 'Solicitar proposta' }}
@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> </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 @endif
</div> </div>
@if (filled($settings->hero_note)) <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>
<p class="max-w-xl text-sm text-amare-text-muted">{{ $settings->hero_note }}</p> <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" />
@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> </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="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
</section> </section>

View File

@@ -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('/', [

View File

@@ -35,11 +35,16 @@ class ImmersivePhotoHeroTest extends TestCase
$response $response
->assertOk() ->assertOk()
->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-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="(max-width: 768px) 100vw, 55vw"', 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,9 +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()
->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);
}
} }
} }