W5.7 (Phase 3E): perf + a11y + OG images
Some checks failed
CI / Web — typecheck + lint + build (push) Failing after 36s
CI / Scripts — Python smoke (push) Failing after 6s
CI / Web — npm audit (push) Failing after 42s
CI / Retrieval — golden set (Recall@5 + MRR) (push) Failing after 5s

A11y:
  - Skip-link in <body> (focus-visible only) that jumps to #main.
    Bilingual ("Skip to content" / "Pular para o conteúdo").
  - <main id="main"> landmark wrapping the homepage body.
  - prefers-reduced-motion media query disables the hover-scale + image
    transitions for users with vestibular sensitivity.
  - Skip-link styled with high contrast (gold-on-dark) + outline on
    keyboard focus.

Performance:
  - HeroBanner background image: fetchPriority="high" + explicit
    width/height (1600x900) for zero CLS.
  - FeaturedCase image: fetchPriority="high" + 1280x720 to prevent
    layout shift while the 2.7MB painting loads.
  - IconicCases tiles already have loading="lazy".
  - prose blockquote: overflow-wrap:anywhere so verbatim quotes don't
    bust the mobile viewport.

Open Graph:
  - app/layout.tsx default OG image set to the green-fireballs
    painting (any page without its own image card inherits this).
  - app/c/[slug] OG image is the case's editorial illustration when
    one exists. WhatsApp, Twitter, Telegram, Slack, ChatGPT search
    all pull this when the link is shared. 2000x1125 for the
    "summary_large_image" twitter card.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Luiz Gustavo 2026-05-24 16:55:35 -03:00
parent 1687120b7f
commit df82d40a96
6 changed files with 64 additions and 1 deletions

View file

@ -84,6 +84,11 @@ export async function generateMetadata(
const title = locale === "pt-br" ? (c.fm.topic_pt_br ?? c.fm.topic ?? slug) : (c.fm.topic ?? slug); const title = locale === "pt-br" ? (c.fm.topic_pt_br ?? c.fm.topic ?? slug) : (c.fm.topic ?? slug);
const desc = pickLead(c.body, locale).slice(0, 200); const desc = pickLead(c.body, locale).slice(0, 200);
const canonical = `${SITE_URL}/c/${slug}`; const canonical = `${SITE_URL}/c/${slug}`;
// OG image — use the case's editorial illustration when present. WhatsApp,
// Twitter, Slack, Telegram, ChatGPT search all pull this as the link card.
const ogImage = (await hasIllustration(slug))
? `${SITE_URL}/api/static/processing/case-art/${slug}.png`
: `${SITE_URL}/api/static/processing/case-art/green-fireballs-narrative.png`;
return { return {
title, title,
description: desc, description: desc,
@ -96,11 +101,13 @@ export async function generateMetadata(
siteName: "The Disclosure Bureau", siteName: "The Disclosure Bureau",
locale: locale === "pt-br" ? "pt_BR" : "en_US", locale: locale === "pt-br" ? "pt_BR" : "en_US",
publishedTime: c.fm.created_at, publishedTime: c.fm.created_at,
images: [{ url: ogImage, width: 2000, height: 1125, alt: title }],
}, },
twitter: { twitter: {
card: "summary_large_image", card: "summary_large_image",
title, title,
description: desc, description: desc,
images: [ogImage],
}, },
}; };
} }

View file

@ -29,6 +29,45 @@ html, body {
font-feature-settings: "ss01", "ss02"; font-feature-settings: "ss01", "ss02";
} }
/* Skip-to-content link (a11y) — hidden until keyboard focus reveals it. */
.skip-link {
position: fixed;
top: -40px;
left: 8px;
z-index: 100;
padding: 8px 16px;
background: #e0c080;
color: #0a0e1a;
font-family: var(--font-mono), monospace;
font-size: 12px;
font-weight: 600;
border-radius: 4px;
text-decoration: none;
transition: top 0.15s ease-out;
}
.skip-link:focus {
top: 8px;
outline: 2px solid #fff;
outline-offset: 2px;
}
/* Mobile-first overflow safety on prose blockquotes. */
.prose blockquote {
overflow-wrap: anywhere;
}
/* Honour user "reduce motion" preference for our hover-scale transitions. */
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
scroll-behavior: auto !important;
}
}
body { body {
background-image: background-image:
radial-gradient(ellipse 60% 50% at 92% 8%, rgba(0, 255, 156, 0.05) 0%, transparent 65%), radial-gradient(ellipse 60% 50% at 92% 8%, rgba(0, 255, 156, 0.05) 0%, transparent 65%),

View file

@ -43,11 +43,17 @@ export const metadata: Metadata = {
locale: "pt_BR", locale: "pt_BR",
alternateLocale: ["en_US"], alternateLocale: ["en_US"],
url: SITE_URL, url: SITE_URL,
images: [{
url: `${SITE_URL}/api/static/processing/case-art/green-fireballs-narrative.png`,
width: 2000, height: 1125,
alt: "The Disclosure Bureau — UAP/UFO desclassificado",
}],
}, },
twitter: { twitter: {
card: "summary_large_image", card: "summary_large_image",
title: "The Disclosure Bureau", title: "The Disclosure Bureau",
description: "Arquivos UAP/UFO desclassificados, narrados a partir do registro público.", description: "Arquivos UAP/UFO desclassificados, narrados a partir do registro público.",
images: [`${SITE_URL}/api/static/processing/case-art/green-fireballs-narrative.png`],
}, },
alternates: { alternates: {
canonical: "/", canonical: "/",
@ -94,6 +100,9 @@ export default async function RootLayout({ children }: { children: React.ReactNo
/> />
</head> </head>
<body className={`${inter.variable} ${mono.variable} ${fraunces.variable}`}> <body className={`${inter.variable} ${mono.variable} ${fraunces.variable}`}>
<a href="#main" className="skip-link">
{locale === "en" ? "Skip to content" : "Pular para o conteúdo"}
</a>
{children} {children}
<CommandPalette /> <CommandPalette />
<div className="fixed bottom-3 left-3 z-40 opacity-70 hover:opacity-100 transition"> <div className="fixed bottom-3 left-3 z-40 opacity-70 hover:opacity-100 transition">

View file

@ -59,6 +59,7 @@ export default async function Home() {
<div className="min-h-screen"> <div className="min-h-screen">
<SiteHeader locale={locale} /> <SiteHeader locale={locale} />
<main id="main">
<HeroBanner <HeroBanner
locale={locale} locale={locale}
stats={{ stats={{
@ -96,6 +97,7 @@ export default async function Home() {
</p> </p>
<DocListFilters docs={docs} /> <DocListFilters docs={docs} />
</section> </section>
</main>
<ChatBubble context={{}} /> <ChatBubble context={{}} />

View file

@ -145,6 +145,9 @@ export async function FeaturedCase({ locale }: { locale: "pt-br" | "en" }) {
<img <img
src={heroImg} src={heroImg}
alt={title} alt={title}
fetchPriority="high"
width={1280}
height={720}
className={`absolute inset-0 w-full h-full object-cover ${heroIsArt ? "object-center" : "object-top"} opacity-95 group-hover:opacity-100 group-hover:scale-[1.02] transition-all duration-700`} className={`absolute inset-0 w-full h-full object-cover ${heroIsArt ? "object-center" : "object-top"} opacity-95 group-hover:opacity-100 group-hover:scale-[1.02] transition-all duration-700`}
/> />
) : ( ) : (

View file

@ -31,13 +31,16 @@ export function HeroBanner({ locale, stats, heroDocId, heroPage }: {
return ( return (
<section className="relative overflow-hidden border-b border-[rgba(224,192,128,0.15)]"> <section className="relative overflow-hidden border-b border-[rgba(224,192,128,0.15)]">
{/* Background art layer */} {/* Background art layer — high-priority because it's above the fold */}
{heroImg && ( {heroImg && (
// eslint-disable-next-line @next/next/no-img-element // eslint-disable-next-line @next/next/no-img-element
<img <img
src={heroImg} src={heroImg}
alt="" alt=""
aria-hidden="true" aria-hidden="true"
fetchPriority="high"
width={1600}
height={900}
className="absolute inset-0 w-full h-full object-cover opacity-[0.18] mix-blend-luminosity" className="absolute inset-0 w-full h-full object-cover opacity-[0.18] mix-blend-luminosity"
/> />
)} )}