feat(cases): prova certa por serviço, páginas internas com trabalho no topo

Diagnóstico sem case que não cabe; desktop com o GCC Installer como projeto próprio; IoT com o Twins Lab e a nota da Apolo. Mosaico de pôsteres em /servicos e /cases, faixa baixa em uma coluna no currículo, privacidade e laboratório; Laboratório como lista compacta; SEV começando por catálogo, pedido no WhatsApp e painel; Joinville e SC nos títulos; imagens do LCP com fetchPriority e sizes reais; pré-carregamento só no hover; 404 com Open Graph próprio.
This commit is contained in:
Evandro Fonseca Junior committed 2026-10-14 17:01:35 -03:00
1 parent 7b633f0f4a
commit de6af74a0c
37 files changed
+1062 -254

No files matched your search

@@ -4,7 +4,8 @@ import { CaseContactSection } from "./CaseContactSection";
/**
* Testes de caminhos-base de CaseContactSection: o convite de um case de cliente ("projeto parecido") e o de
* um projeto de laboratório ("problema parecido"), o link para a lista de cases e os serviços ligados.
* um projeto de laboratório ("problema parecido" no botão), o link para a lista de cases e os serviços ligados.
* A mensagem do WhatsApp é a mesma frase nos dois: "um projeto parecido com o {título}".
*/
const sev = { id: "sev-modaintima", title: "SEV Moda Íntima" };
@@ -29,7 +30,7 @@ describe("CaseContactSection", () => {
});
expect(cta).toHaveAttribute("target", "_blank");
expect(messageOf(cta)).toContain(
`Quero conversar sobre um projeto parecido com o da ${sev.title}.`,
`Quero conversar sobre um projeto parecido com o ${sev.title}.`,
);
expect(getRelatedServices(sev.id).length).toBeGreaterThan(0);
expect(
@@ -49,9 +50,11 @@ describe("CaseContactSection", () => {
const cta = screen.getByRole("link", {
name: "Tem um problema parecido? Vamos conversar.",
});
// a mensagem usa a mesma frase do cabeçalho, sem "da" nem "do projeto" na frente do nome
expect(messageOf(cta)).toContain(
`Quero conversar sobre um problema parecido com o do projeto ${nutri.title}.`,
`Quero conversar sobre um projeto parecido com o ${nutri.title}.`,
);
expect(messageOf(cta)).not.toMatch(/problema parecido|com o da |com o do /);
expect(
screen.queryByRole("link", {
name: "Conversar sobre um projeto parecido",
+9 -8
View File
@@ -1,24 +1,25 @@
import Link from "next/link";
import {
primaryButton,
secondaryButtonDark,
} from "@/app/components/button-styles";
import HoverPrefetchLink from "@/app/components/HoverPrefetchLink";
import WhatsAppLink from "@/app/components/WhatsAppLink";
import type { CaseStudyContent } from "@/lib/site-content";
import { similarProjectSubject } from "@/lib/whatsapp";
import { CaseRelatedServices } from "./CaseRelatedServices";
/** Textos do convite ao contato: o case de cliente fala de um projeto parecido; o de laboratório, de um problema parecido. */
/**
* Textos do convite ao contato: o case de cliente fala de um projeto parecido; o de laboratório, de um problema parecido.
* O assunto da conversa de WhatsApp é o mesmo nos dois (e no botão do cabeçalho): `similarProjectSubject`.
*/
const contactCopy = {
client: {
title: "Quer um projeto parecido com este?",
action: "Conversar sobre um projeto parecido",
subject: (title: string) => `um projeto parecido com o da ${title}`,
},
lab: {
title: "Fale sobre o seu caso",
action: "Tem um problema parecido? Vamos conversar.",
subject: (title: string) =>
`um problema parecido com o do projeto ${title}`,
},
} as const;
@@ -47,15 +48,15 @@ export function CaseContactSection({
</p>
<div className="mt-sp-6 flex flex-wrap items-center gap-sp-3">
<WhatsAppLink
service={copy.subject(caseStudy.title)}
service={similarProjectSubject(caseStudy.title)}
placement="case_detail"
className={primaryButton}
>
{copy.action}
</WhatsAppLink>
<Link href="/cases" className={secondaryButtonDark}>
<HoverPrefetchLink href="/cases" className={secondaryButtonDark}>
Ver outros cases
</Link>
</HoverPrefetchLink>
</div>
<CaseRelatedServices caseId={caseStudy.id} />
</div>
+15 -5
View File
@@ -1,11 +1,11 @@
import { render, screen } from "@testing-library/react";
import { getShowcaseAssets } from "@/lib/project-showcase";
import { CaseCover } from "./CaseCover";
import { CaseCover, coverSizes } from "./CaseCover";
import { getCaseMedia } from "./case-media";
/**
* Testes de caminhos-base de CaseCover: sem captura (nada), com captura (moldura + pôster)
* e os valores padrão de `sizes` e `priority`.
* e os valores padrão de `sizes` e `priority` (prioridade = carga imediata e `fetchpriority="high"`).
*/
jest.mock("./case-media", () => ({ getCaseMedia: jest.fn() }));
@@ -49,16 +49,26 @@ describe("CaseCover", () => {
render(<CaseCover {...props} />);
const image = screen.getByAltText(`Captura de tela do site ${props.title}`);
expect(image).toHaveAttribute("sizes", "(min-width: 1024px) 560px, 100vw");
expect(image).toHaveAttribute("sizes", coverSizes.card);
expect(image).toHaveAttribute("loading", "lazy");
expect(image).not.toHaveAttribute("fetchpriority");
});
it("path: sizes and priority given -> uses them", () => {
it("path: sizes and priority given -> uses them, with eager loading and high fetch priority", () => {
mockedGetCaseMedia.mockImplementation(getShowcaseAssets);
render(<CaseCover {...props} sizes="320px" priority />);
const image = screen.getByAltText(`Captura de tela do site ${props.title}`);
expect(image).toHaveAttribute("sizes", "320px");
expect(image).not.toHaveAttribute("loading", "lazy");
expect(image).toHaveAttribute("loading", "eager");
expect(image).toHaveAttribute("fetchpriority", "high");
});
it("path: no 100vw sizes -> every preset describes the width the cover really has", () => {
// 100vw baixava a imagem de 750 px para uma capa de 300 px no celular
for (const sizes of Object.values(coverSizes)) {
expect(sizes).not.toMatch(/(^|, )100vw$/);
expect(sizes).toMatch(/calc\(100vw - \d+px\)$/);
}
});
});
+32 -3
View File
@@ -5,12 +5,40 @@ import {
} from "@/app/components/showcase/BrowserFrame";
import { getCaseMedia } from "./case-media";
/** Capa do case: o pôster do site dentro de uma moldura de navegador. Sem captura gravada, não mostra nada. */
/**
* Largura real da capa em cada lugar da página, para o navegador baixar a imagem do tamanho certo
* (com 100vw, um celular de 390 px pedia a versão de 750 px para uma capa de 300 px). As contas seguem o
* padding das seções (20 px até 768 px; depois 6vw, no máximo 64 px), a coluna central de 1240 px e os cartões
* (borda e 20 a 28 px de padding).
*/
export const coverSizes = {
/** Topo do case e do serviço: coluna da direita a partir de 1024 px, e a largura da página abaixo disso. */
hero: "(min-width: 1440px) 560px, (min-width: 1024px) 40vw, (min-width: 769px) 88vw, calc(100vw - 40px)",
/** Capa dentro de um cartão (`ds-card`) da grade de duas colunas, que vira uma só até 1024 px. */
card: "(min-width: 1440px) 560px, (min-width: 1025px) calc(50vw - 134px), (min-width: 769px) calc(88vw - 56px), calc(100vw - 88px)",
/** Capa solta numa grade de duas colunas, sem cartão em volta (a partir de 768 px). */
column:
"(min-width: 1440px) 604px, (min-width: 1068px) calc(50vw - 80px), (min-width: 768px) calc(44vw - 16px), calc(100vw - 40px)",
} as const;
/**
* Props da imagem que abre a página (a maior acima da dobra): carrega na hora e com prioridade de rede alta.
* No Next 16 a prop `priority` do `Image` só gera o `<link rel="preload">`; a prioridade de rede vem do `fetchPriority`.
*/
export const priorityImageProps = {
loading: "eager",
fetchPriority: "high",
} as const;
/**
* Capa do case: o pôster do site dentro de uma moldura de navegador. Sem captura gravada, não mostra nada.
* `priority` é para a imagem que abre a página: aplica `priorityImageProps`.
*/
export function CaseCover({
id,
title,
demoUrl,
sizes = "(min-width: 1024px) 560px, 100vw",
sizes = coverSizes.card,
priority = false,
}: {
id: string;
@@ -21,6 +49,7 @@ export function CaseCover({
}) {
const media = getCaseMedia(id, "desktop");
if (!media) return null;
const priorityProps = priority ? priorityImageProps : {};
return (
<BrowserFrame url={displayUrl({ demoUrl, title })}>
@@ -30,7 +59,7 @@ export function CaseCover({
alt={`Captura de tela do site ${title}`}
fill
sizes={sizes}
priority={priority}
{...priorityProps}
className="object-cover object-top"
/>
</div>
@@ -1,11 +1,13 @@
import { render, screen } from "@testing-library/react";
import { fireEvent, render, screen } from "@testing-library/react";
import { getShowcaseAssets } from "@/lib/project-showcase";
import { coverSizes } from "./CaseCover";
import { CaseCoverLink } from "./CaseCoverLink";
import { getCaseMedia } from "./case-media";
/**
* Testes de caminhos-base de CaseCoverLink: sem captura (nada), com captura (link fora da leitura e do teclado,
* com a capa dentro), os valores padrão de `sizes`, `priority` e `prefetch`, e os valores dados.
* com a capa dentro), os valores padrão de `sizes` e `priority`, o pré-carregamento só ao chegar no link
* e os valores dados.
*/
jest.mock("./case-media", () => ({ getCaseMedia: jest.fn() }));
@@ -61,32 +63,34 @@ describe("CaseCoverLink", () => {
).toBeInTheDocument();
});
it("path: sizes, priority and prefetch omitted -> default sizes, lazy image and the link's own prefetch", () => {
it("path: sizes and priority omitted -> default sizes and lazy image", () => {
mockedGetCaseMedia.mockImplementation(getShowcaseAssets);
render(<CaseCoverLink {...props} />);
const image = screen.getByAltText(`Captura de tela do site ${props.title}`);
expect(image).toHaveAttribute("sizes", coverSizes.card);
expect(image).toHaveAttribute("loading", "lazy");
expect(image).not.toHaveAttribute("fetchpriority");
});
it("path: link -> pre-fetches the case only when the pointer or focus reaches it", () => {
mockedGetCaseMedia.mockImplementation(getShowcaseAssets);
const { container } = render(<CaseCoverLink {...props} />);
const image = screen.getByAltText(`Captura de tela do site ${props.title}`);
expect(image).toHaveAttribute("sizes", "(min-width: 1024px) 560px, 100vw");
expect(image).toHaveAttribute("loading", "lazy");
expect(container.querySelector("a")).toHaveAttribute(
"data-prefetch",
"undefined",
);
const link = container.querySelector("a");
expect(link).toHaveAttribute("data-prefetch", "false");
fireEvent.mouseEnter(link as HTMLAnchorElement);
expect(link).toHaveAttribute("data-prefetch", "null");
});
it("path: sizes, priority and prefetch given -> all of them are used", () => {
it("path: sizes and priority given -> both are used", () => {
mockedGetCaseMedia.mockImplementation(getShowcaseAssets);
const { container } = render(
<CaseCoverLink {...props} sizes="320px" priority prefetch={false} />,
);
render(<CaseCoverLink {...props} sizes="320px" priority />);
const image = screen.getByAltText(`Captura de tela do site ${props.title}`);
expect(image).toHaveAttribute("sizes", "320px");
expect(image).not.toHaveAttribute("loading", "lazy");
expect(container.querySelector("a")).toHaveAttribute(
"data-prefetch",
"false",
);
expect(image).toHaveAttribute("loading", "eager");
expect(image).toHaveAttribute("fetchpriority", "high");
});
it("path: no demo address -> the browser bar falls back to the project title", () => {
+6 -8
View File
@@ -1,34 +1,32 @@
import Link from "next/link";
import { CaseCover } from "./CaseCover";
import HoverPrefetchLink from "@/app/components/HoverPrefetchLink";
import { CaseCover, coverSizes } from "./CaseCover";
import { getCaseMedia } from "./case-media";
/**
* Capa do case dentro de um cartão: a imagem leva ao case, mas fica fora da leitura e da ordem de tabulação,
* porque o link de texto do mesmo cartão já leva ao mesmo lugar. Sem captura gravada, não mostra nada.
* A rota do case só é pré-carregada quando a pessoa chega no link (HoverPrefetchLink).
*/
export function CaseCoverLink({
id,
title,
demoUrl,
sizes = "(min-width: 1024px) 560px, 100vw",
sizes = coverSizes.card,
priority = false,
prefetch,
}: {
id: string;
title: string;
demoUrl?: string | null;
sizes?: string;
priority?: boolean;
prefetch?: boolean;
}) {
if (!getCaseMedia(id, "desktop")) return null;
return (
<Link
<HoverPrefetchLink
href={`/cases/${id}`}
aria-hidden="true"
tabIndex={-1}
prefetch={prefetch}
className="mb-sp-5 block"
>
<CaseCover
@@ -38,6 +36,6 @@ export function CaseCoverLink({
sizes={sizes}
priority={priority}
/>
</Link>
</HoverPrefetchLink>
);
}
@@ -3,6 +3,7 @@ import { getShowcaseAssets } from "@/lib/project-showcase";
import { caseStudies } from "@/lib/site-content";
import { CaseHero } from "./CaseHero";
import { getCaseMedia } from "./case-media";
import { compactHero } from "./hero-styles";
/**
* Testes de caminhos-base de CaseHero e dos fatos do projeto: com e sem captura gravada, case de cliente e de
@@ -77,6 +78,27 @@ describe("CaseHero: captura gravada e tipo de case", () => {
expect(screen.getByText(lab.summary)).toBeVisible();
});
it("path: with a capture -> the top strip keeps the default height", () => {
withCapture(true);
const { container } = render(<CaseHero caseStudy={client} isClient />);
expect(container.querySelector("section")).not.toHaveClass(compactHero);
});
it("path: without a capture -> the top strip is the lower one-column strip, for client and laboratory cases", () => {
withCapture(false);
for (const [caseStudy, isClient] of [
[client, true],
[lab, false],
] as const) {
const { container, unmount } = render(
<CaseHero caseStudy={caseStudy} isClient={isClient} />,
);
expect(container.querySelector("section")).toHaveClass(compactHero);
unmount();
}
});
it("path: breadcrumb -> Início, Cases and the case itself as the current page", () => {
withCapture(false);
render(<CaseHero caseStudy={client} isClient />);
+9 -4
View File
@@ -1,8 +1,9 @@
import { Breadcrumb } from "@/app/components/Breadcrumb";
import { pageTop } from "@/app/components/button-styles";
import type { CaseStudyContent } from "@/lib/site-content";
import { CaseCover } from "./CaseCover";
import { CaseCover, coverSizes } from "./CaseCover";
import { getCaseMedia } from "./case-media";
import { compactHero } from "./hero-styles";
/** Fatos verificáveis do projeto. Sem nenhum, a lista não aparece. */
function CaseFacts({ facts }: { facts?: string[] }) {
@@ -28,7 +29,7 @@ function CaseHeroCover({ caseStudy }: { caseStudy: CaseStudyContent }) {
id={caseStudy.id}
title={caseStudy.title}
demoUrl={caseStudy.demoUrl}
sizes="(min-width: 1024px) 560px, 100vw"
sizes={coverSizes.hero}
priority
/>
</div>
@@ -37,7 +38,7 @@ function CaseHeroCover({ caseStudy }: { caseStudy: CaseStudyContent }) {
/**
* Topo do case: trilha, título, resumo e fatos. Com captura gravada, o texto fica à esquerda e o pôster à direita;
* no celular a mídia vem logo abaixo do título.
* no celular a mídia vem logo abaixo do título. Sem captura, o topo é uma coluna só e mais baixo.
*/
export function CaseHero({
caseStudy,
@@ -49,7 +50,11 @@ export function CaseHero({
const hasMedia = Boolean(getCaseMedia(caseStudy.id, "desktop"));
return (
<section className={`ds-section ds-section-dark ${pageTop}`}>
<section
className={`ds-section ds-section-dark ${pageTop} ${
hasMedia ? "" : compactHero
}`}
>
<div
className={`ds-section-shell ${
hasMedia
+3 -1
View File
@@ -14,12 +14,14 @@ const deviceLabel: Record<Device, string> = {
* Pôster com um botão de play. O vídeo só é montado, e só baixa, quando a pessoa aperta o botão:
* antes disso a página mostra a imagem do site, não um vídeo parado em 0:00 com controles do navegador.
* Vai dentro de um quadro com `position: relative` (moldura de navegador ou de celular) que dá o tamanho.
* `sizes` segue a grade de CaseMedia: a coluna da esquerda de um bloco de até 1040 px (88 vw da página abaixo de 1182 px)
* com a moldura do celular ao lado (220 px mais 32 px de vão); abaixo de 768 px, a largura da página menos 40 px.
*/
export function CasePlayer({
assets,
title,
device,
sizes = "(min-width: 1280px) 900px, 100vw",
sizes = "(min-width: 1182px) 788px, (min-width: 768px) calc(88vw - 252px), calc(100vw - 40px)",
}: {
assets: ShowcaseAssets;
title: string;
@@ -1,5 +1,5 @@
import Link from "next/link";
import { textLinkDark } from "@/app/components/button-styles";
import HoverPrefetchLink from "@/app/components/HoverPrefetchLink";
import { getRelatedServices } from "@/lib/site-content";
/** Quantos serviços aparecem no fim do case: dois cards bastam para levar a pessoa ao serviço certo sem virar um menu. */
@@ -19,12 +19,12 @@ export function CaseRelatedServices({ caseId }: { caseId: string }) {
<article className="ds-card w-full">
<h3 className="ds-card-title !mt-0">{service.navLabel}</h3>
<p className="ds-copy">{service.intro}</p>
<Link
<HoverPrefetchLink
className={`${textLinkDark} mt-sp-4 self-start`}
href={`/servicos/${service.slug}`}
>
Ver o serviço: {service.navLabel}
</Link>
</HoverPrefetchLink>
</article>
</li>
))}
+108
View File
@@ -0,0 +1,108 @@
import { render } from "@testing-library/react";
import { getShowcaseAssets } from "@/lib/project-showcase";
import { coverSizes } from "./CaseCover";
import { getCaseMedia } from "./case-media";
import { getMosaicPosters, MOSAIC_SIZE, PosterMosaic } from "./PosterMosaic";
/**
* Mosaico de pôsteres do topo de /servicos e /cases: quais pôsteres entram (só os com captura, no máximo três),
* e como cada imagem carrega (a primeira, a maior, na hora e com prioridade alta; as outras só quando aparecem).
*/
jest.mock("./case-media", () => ({ getCaseMedia: jest.fn() }));
const mockedGetCaseMedia = jest.mocked(getCaseMedia);
afterEach(() => {
mockedGetCaseMedia.mockReset();
});
describe("getMosaicPosters", () => {
it("path: every id has a capture -> keeps the order and stops at three", () => {
mockedGetCaseMedia.mockImplementation(getShowcaseAssets);
const posters = getMosaicPosters(["a", "b", "c", "d"]);
expect(MOSAIC_SIZE).toBe(3);
expect(posters).toEqual([
{ id: "a", poster: "/videos/a/desktop.webp" },
{ id: "b", poster: "/videos/b/desktop.webp" },
{ id: "c", poster: "/videos/c/desktop.webp" },
]);
});
it("path: some ids have no capture -> skips them and fills with the next ones", () => {
mockedGetCaseMedia.mockImplementation((id, device) =>
id === "b" ? undefined : getShowcaseAssets(id, device),
);
expect(getMosaicPosters(["a", "b", "c", "d"]).map(({ id }) => id)).toEqual([
"a",
"c",
"d",
]);
expect(mockedGetCaseMedia).toHaveBeenCalledWith("b", "desktop");
});
it("path: no id has a capture -> an empty list", () => {
mockedGetCaseMedia.mockReturnValue(undefined);
expect(getMosaicPosters(["a", "b"])).toEqual([]);
});
});
describe("PosterMosaic", () => {
const three = ["a", "b", "c"].map((id) => ({
id,
poster: `/videos/${id}/desktop.webp`,
}));
it("path: no posters -> renders nothing", () => {
const { container } = render(<PosterMosaic posters={[]} />);
expect(container).toBeEmptyDOMElement();
});
it("path: three posters -> decorative, the first one eager with high priority and the real column width", () => {
const { container } = render(<PosterMosaic posters={three} />);
expect(container.firstElementChild).toHaveAttribute("aria-hidden", "true");
const images = Array.from(container.querySelectorAll("img"));
expect(images).toHaveLength(3);
for (const image of images) {
expect(image).toHaveAttribute("alt", "");
}
const [main, ...side] = images;
expect(main).toHaveAttribute("loading", "eager");
expect(main).toHaveAttribute("fetchpriority", "high");
expect(main).toHaveAttribute("sizes", coverSizes.hero);
expect(main.closest("div")).toHaveClass("col-span-2");
for (const image of side) {
expect(image).toHaveAttribute("loading", "lazy");
expect(image).not.toHaveAttribute("fetchpriority");
// metade da coluna, e nunca a largura toda da tela
expect(image.getAttribute("sizes")).toBe(
"(min-width: 1440px) 274px, 20vw",
);
}
});
it("path: the smaller posters are hidden below 1024 px, so they are not downloaded on a phone", () => {
const { container } = render(<PosterMosaic posters={three} />);
const [main, ...side] = Array.from(container.querySelectorAll("img")).map(
(image) => image.closest("div"),
);
expect(main).not.toHaveClass("hidden");
for (const frame of side) {
expect(frame).toHaveClass("hidden", "lg:block");
}
});
it("path: a single poster -> only the main one, with no side posters", () => {
const { container } = render(<PosterMosaic posters={three.slice(0, 1)} />);
expect(container.querySelectorAll("img")).toHaveLength(1);
});
});
+68
View File
@@ -0,0 +1,68 @@
import Image from "next/image";
import { coverSizes, priorityImageProps } from "./CaseCover";
import { getCaseMedia } from "./case-media";
export interface MosaicPoster {
id: string;
poster: string;
}
/** Quantos pôsteres entram no mosaico: um grande em cima e dois menores embaixo. */
export const MOSAIC_SIZE = 3;
/**
* Pôsteres gravados dos cases, na ordem dada, só dos que têm captura no disco (no máximo `MOSAIC_SIZE`).
* Roda no servidor, como `getCaseMedia`.
*/
export function getMosaicPosters(ids: readonly string[]): MosaicPoster[] {
return ids
.flatMap((id) => {
const media = getCaseMedia(id, "desktop");
return media ? [{ id, poster: media.poster }] : [];
})
.slice(0, MOSAIC_SIZE);
}
const frame =
"relative aspect-[16/10] overflow-hidden border border-white/15 bg-[#0a0a0a]";
/**
* Mosaico de pôsteres dos sites no topo das páginas internas, na coluna que ficaria vazia ao lado do título.
* É decorativo (texto alternativo vazio): quem lê os cases tem os links logo abaixo. O primeiro pôster é o maior
* e o que abre a página, então carrega na hora e com prioridade alta; no celular só ele aparece, e os outros dois
* (que não são baixados enquanto estão ocultos) entram a partir de 1024 px.
*/
export function PosterMosaic({
posters,
}: {
posters: readonly MosaicPoster[];
}) {
const [main, ...others] = posters;
if (!main) return null;
return (
<div className="grid min-w-0 grid-cols-2 gap-sp-3" aria-hidden="true">
<div className={`${frame} col-span-2`}>
<Image
src={main.poster}
alt=""
fill
sizes={coverSizes.hero}
{...priorityImageProps}
className="object-cover object-top"
/>
</div>
{others.map(({ id, poster }) => (
<div key={id} className={`${frame} hidden lg:block`}>
<Image
src={poster}
alt=""
fill
sizes="(min-width: 1440px) 274px, 20vw"
className="object-cover object-top"
/>
</div>
))}
</div>
);
}
+15
View File
@@ -0,0 +1,15 @@
/**
* Faixa de topo de uma página interna que não tem imagem ao lado: uma coluna só e mais baixa que a padrão.
* O "!" vence o CSS sem camada de globals.css (.ds-section: 72 a 112 px de folga em cima e embaixo).
* Em cima fica a altura do cabeçalho fixo mais um respiro, para a trilha não ficar por baixo dele; embaixo, 48 px.
* No celular vale o padding de `pageTop` (button-styles.ts) e o padrão de baixo.
*/
export const compactHero =
"md:!pt-[calc(var(--site-header-height)+var(--sp-10))] md:!pb-sp-12";
/**
* Topo com imagem: texto à esquerda e a mídia à direita a partir de 1024 px (1,1 parte para 1), uma coluna abaixo disso.
* Os `sizes` das imagens (CaseCover e PosterMosaic) seguem esta grade.
*/
export const heroWithMediaGrid =
"grid items-center gap-y-sp-8 lg:grid-cols-[minmax(0,1.1fr)_minmax(0,1fr)] lg:gap-x-sp-16";