ux(nav): unify navbar palette, localize, fix locale-aware metadata
Some checks failed
CI / Web — typecheck + lint + build (push) Failing after 36s
CI / Scripts — Python smoke (push) Failing after 5s
CI / Web — npm audit (push) Failing after 37s
CI / Retrieval — golden set (Recall@5 + MRR) (push) Failing after 5s

Visual audit found several issues in the navbar / header layer; this pass
addresses the cluster.

- AuthBar: localize sign in/out + dev-disabled message; switch matrix-green
  (#00ff9c) to gold (#e0c080) to match the magazine palette.
- NavLink (new): client component reading usePathname to mark the active
  section with aria-current="page" and a gold underline+fill, so the user
  knows where they are inside the entity hub.
- Locale toggle: bump font to 12px, widen the pill, lift the active state's
  contrast. Hard reload on switch (window.location.reload) instead of
  router.refresh — that is what makes the <title>, <html lang> and og:locale
  actually update on toggle (router.refresh kept stale layout metadata).
- Root layout: convert static `export const metadata` to generateMetadata()
  so title/description/OG/twitter all read the cookie per request. Drop the
  duplicate floating LocaleToggle at bottom-left; the navbar carries it now.
- BureauNav: drop the cyan-on-home / gold-on-bureau split (palette mismatch
  with SiteHeader); single gold tone; localize "home"/"case files" + aria
  labels; mount AuthBar inside the bar so sub-pages get a sign-in surface.
- Pages using BureauNav: drop the standalone <AuthBar /> below it (now
  duplicated). Affected: /bureau, /c/[slug], /h/[hypothesisId], /jobs/[id].
- Lift nav-link rest text from #9aa6b8 to #cbd2dd for WCAG-AA on near-black.

Verified live: title/lang/og:locale switch with cookie; active link gold
indicator on /sightings; "Entrar"/"Sign in" localizes; no double AuthBar.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Luiz Gustavo 2026-05-28 17:50:10 -03:00
parent 912758e18a
commit 2ea350e283
10 changed files with 216 additions and 128 deletions

View file

@ -4,7 +4,6 @@
* Reader-facing list of every assembled narrative. No detective surfacing,
* no artefact dumps. Just stories with hooks.
*/
import { AuthBar } from "@/components/auth-bar";
import { BureauNav } from "@/components/bureau-nav";
import { CaseLibrary } from "@/components/case-library";
import { getLocale } from "@/components/locale-toggle";
@ -18,7 +17,6 @@ export default async function BureauPage() {
return (
<div className="min-h-screen bg-[#0a0e1a] text-[#e7ecf3]">
<BureauNav locale={locale} crumbs={[{ label: locale === "en" ? "case files" : "casos" }]} />
<AuthBar />
<div className="mx-auto max-w-5xl px-4 py-6 pt-4">
<header className="mb-6 border-b border-[rgba(224,192,128,0.32)] pb-4">
<h1 className="font-mono text-3xl text-[#e0c080]">

View file

@ -10,7 +10,6 @@ import { readFile } from "node:fs/promises";
import path from "node:path";
import type { Metadata } from "next";
import { MarkdownBody } from "@/components/markdown-body";
import { AuthBar } from "@/components/auth-bar";
import { BureauNav } from "@/components/bureau-nav";
import { getLocale } from "@/components/locale-toggle";
import { pickLocaleBody } from "@/lib/bilingual";
@ -156,7 +155,6 @@ export default async function CaseReportPage({
{ label: locale === "en" ? "case files" : "casos", href: "/bureau" },
{ label: title.length > 32 ? title.slice(0, 32) + "…" : title },
]} />
<AuthBar />
{/* Full-bleed editorial hero only when a painterly illustration
exists for this case. Other cases get the plain title header. */}

View file

@ -16,7 +16,6 @@ import Link from "next/link";
import { readFile } from "node:fs/promises";
import path from "node:path";
import { pgQuery } from "@/lib/retrieval/db";
import { AuthBar } from "@/components/auth-bar";
import { BureauNav } from "@/components/bureau-nav";
import { getLocale } from "@/components/locale-toggle";
import { RedTeamRequestButton } from "@/components/red-team-request-button";
@ -204,7 +203,6 @@ export default async function HypothesisPage({
{ label: "hypotheses", href: "/bureau#hypotheses" },
{ label: hypothesisId },
]} />
<AuthBar />
<div className="mx-auto max-w-5xl px-4 py-6 pt-4">
{/* Header */}

View file

@ -17,7 +17,6 @@
import { notFound } from "next/navigation";
import Link from "next/link";
import { pgQuery } from "@/lib/retrieval/db";
import { AuthBar } from "@/components/auth-bar";
import { BureauNav } from "@/components/bureau-nav";
import { getLocale } from "@/components/locale-toggle";
import { JobStatusPoller } from "@/components/job-status-poller";
@ -83,7 +82,6 @@ export default async function JobPage({
{ label: "jobs", href: "/bureau#jobs" },
{ label: job.job_id.slice(0, 8) },
]} />
<AuthBar />
<div className="mx-auto max-w-5xl px-4 py-6 pt-4">
<div className={`rounded-lg border border-[rgba(127,219,255,0.18)] bg-gradient-to-br ${detectiveBg} to-transparent p-5`}>

View file

@ -2,7 +2,7 @@ import type { Metadata } from "next";
import { JetBrains_Mono, Inter, Fraunces } from "next/font/google";
import "./globals.css";
import { CommandPalette } from "@/components/command-palette";
import { LocaleToggle, getLocale } from "@/components/locale-toggle";
import { getLocale } from "@/components/locale-toggle";
const inter = Inter({ subsets: ["latin"], variable: "--font-sans" });
const mono = JetBrains_Mono({ subsets: ["latin"], variable: "--font-mono" });
@ -16,16 +16,41 @@ const fraunces = Fraunces({
const SITE_URL = process.env.NEXT_PUBLIC_SITE_URL ?? "https://disclosure.top";
export const metadata: Metadata = {
metadataBase: new URL(SITE_URL),
title: {
default: "The Disclosure Bureau — UAP/UFO desclassificado",
// Site-wide metadata is locale-aware: title, description, OG, twitter, html
// lang and openGraph.locale must all switch when the cookie toggles. Static
// `export const metadata` is resolved without cookies, so the title would
// stay pinned to whichever language built the page. generateMetadata() runs
// per request and can read the cookie via getLocale().
export async function generateMetadata(): Promise<Metadata> {
const locale = await getLocale();
const t = locale === "en"
? {
title: "The Disclosure Bureau — declassified UAP/UFO",
template: "%s · The Disclosure Bureau",
},
description:
"122 documentos desclassificados do Departamento de Guerra dos EUA sobre UAP/UFO. " +
desc: "122 declassified US Department of War documents on UAP/UFO. " +
"Pilots, officers and physicists describe what they saw. Sightings, " +
"witnesses, catalogued craft — the open files of disclosure.",
og_desc: "122 declassified documents. Pilots, officers, physicists describe what they saw.",
tw_desc: "Declassified UAP/UFO files, narrated from the public record.",
ogLocale: "en_US",
altLocale: "pt_BR",
}
: {
title: "The Disclosure Bureau — UAP/UFO desclassificado",
template: "%s · The Disclosure Bureau",
desc: "122 documentos desclassificados do Departamento de Guerra dos EUA sobre UAP/UFO. " +
"Pilotos, oficiais e físicos relatam o que viram. Avistamentos, testemunhas, " +
"objetos catalogados — arquivos abertos da divulgação.",
og_desc: "122 documentos desclassificados. Pilotos, oficiais, físicos relatam o que viram.",
tw_desc: "Arquivos UAP/UFO desclassificados, narrados a partir do registro público.",
ogLocale: "pt_BR",
altLocale: "en_US",
};
return {
metadataBase: new URL(SITE_URL),
title: { default: t.title, template: t.template },
description: t.desc,
keywords: [
"UAP", "UFO", "ovni", "desclassificado", "war.gov", "Pentagon",
"Kenneth Arnold", "Mantell", "green fireballs", "Sandia",
@ -37,22 +62,21 @@ export const metadata: Metadata = {
openGraph: {
type: "website",
siteName: "The Disclosure Bureau",
title: "The Disclosure Bureau — UAP/UFO desclassificado",
description:
"122 documentos desclassificados. Pilotos, oficiais, físicos relatam o que viram.",
locale: "pt_BR",
alternateLocale: ["en_US"],
title: t.title,
description: t.og_desc,
locale: t.ogLocale,
alternateLocale: [t.altLocale],
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",
alt: t.title,
}],
},
twitter: {
card: "summary_large_image",
title: "The Disclosure Bureau",
description: "Arquivos UAP/UFO desclassificados, narrados a partir do registro público.",
description: t.tw_desc,
images: [`${SITE_URL}/api/static/processing/case-art/green-fireballs-narrative.png`],
},
alternates: {
@ -70,13 +94,12 @@ export const metadata: Metadata = {
},
},
other: {
// GEO (Generative Engine Optimization) — explicit primary-source statement
// so retrieval-augmented assistants understand what the site is.
"ai:purpose":
"Public, citation-linked archive of declassified UAP/UFO documents from the US Department of War. Each case file is grounded in primary-source memos with verbatim quotes and bbox-cropped imagery.",
"ai:license": "Documents are US Government works in the public domain; site narrative © Disclosure Bureau, CC-BY 4.0.",
},
};
};
}
export default async function RootLayout({ children }: { children: React.ReactNode }) {
const locale = await getLocale();
@ -105,9 +128,6 @@ export default async function RootLayout({ children }: { children: React.ReactNo
</a>
{children}
<CommandPalette />
<div className="fixed bottom-3 left-3 z-40 opacity-70 hover:opacity-100 transition">
<LocaleToggle current={locale} />
</div>
</body>
</html>
);

View file

@ -13,10 +13,26 @@ interface Profile {
daily_quota?: number;
}
export function AuthBar() {
const COPY = {
"pt-br": {
sign_in: "Entrar",
sign_out: "Sair",
auth_disabled: "auth: desativada (dev)",
msgs_today: "mensagens hoje",
},
en: {
sign_in: "Sign in",
sign_out: "Sign out",
auth_disabled: "auth: disabled (dev)",
msgs_today: "msgs today",
},
} as const;
export function AuthBar({ locale = "pt-br" }: { locale?: "pt-br" | "en" }) {
const [user, setUser] = useState<User | null>(null);
const [profile, setProfile] = useState<Profile | null>(null);
const [loaded, setLoaded] = useState(false);
const t = COPY[locale];
useEffect(() => {
if (!isSupabaseConfigured()) {
@ -45,7 +61,7 @@ export function AuthBar() {
if (!isSupabaseConfigured()) {
return (
<div className="font-mono text-[10px] text-[#5a6678] uppercase tracking-widest">
auth: disabled (dev)
{t.auth_disabled}
</div>
);
}
@ -54,10 +70,10 @@ export function AuthBar() {
return (
<Link
href="/auth/signin"
className="font-mono text-xs px-3 py-1 border border-[rgba(0,255,156,0.32)]
text-[#00ff9c] hover:bg-[rgba(0,255,156,0.08)] rounded"
className="font-mono text-[12px] px-3 py-1 border border-[rgba(224,192,128,0.35)]
text-[#e0c080] hover:bg-[rgba(224,192,128,0.08)] rounded-full transition-colors"
>
sign in
{t.sign_in}
</Link>
);
}
@ -69,17 +85,17 @@ export function AuthBar() {
<div>{profile.display_name ?? user.email}</div>
<div>
${(profile.total_cost_usd ?? 0).toFixed(2)} / ${(profile.budget_cap_usd ?? 0).toFixed(2)} ·{" "}
{profile.daily_used ?? 0}/{profile.daily_quota ?? 0} msgs today
{profile.daily_used ?? 0}/{profile.daily_quota ?? 0} {t.msgs_today}
</div>
</div>
)}
<form action="/auth/signout" method="POST">
<button
type="submit"
className="font-mono text-[10px] px-2 py-1 text-[#8896aa] hover:text-[#ff3344]
border border-transparent hover:border-[#ff3344] rounded"
className="font-mono text-[11px] px-2.5 py-1 text-[#9aa6b8] hover:text-[#ff3344]
border border-transparent hover:border-[#ff3344] rounded-full transition-colors"
>
sign out
{t.sign_out}
</button>
</form>
</div>

View file

@ -1,16 +1,18 @@
/**
* BureauNav top navigation bar present on every bureau page.
* BureauNav top navigation bar present on every bureau sub-page.
*
* Solves "I'm stuck on this page, there's no way back" UX. Shows:
* - "← Home" (always /)
* - "🔎 Bureau" (always /bureau)
* - "Bureau" (always /bureau)
* - Breadcrumb trail of current page (passed via props)
* - Locale toggle + auth pill on the right
*
* Server component. No client-side history needed because the parent links
* are deterministic (every bureau page has a known parent in the hierarchy).
* Visually harmonised with SiteHeader (single gold palette, no cyan); labels
* localised.
*/
import Link from "next/link";
import { ArrowLeft, Home } from "lucide-react";
import { ArrowLeft } from "lucide-react";
import { AuthBar } from "./auth-bar";
import { LocaleToggle, type Locale } from "./locale-toggle";
export interface Crumb {
@ -20,49 +22,56 @@ export interface Crumb {
href?: string;
}
export function BureauNav({ crumbs, locale }: { crumbs: Crumb[]; locale?: Locale }) {
const COPY = {
"pt-br": { home: "home", bureau: "casos", aria_home: "Voltar para a home", aria_bureau: "Listagem de casos" },
en: { home: "home", bureau: "case files", aria_home: "Back to home", aria_bureau: "Case file library" },
} as const;
export function BureauNav({ crumbs, locale = "pt-br" }: { crumbs: Crumb[]; locale?: Locale }) {
const t = COPY[locale];
return (
<nav className="sticky top-0 z-30 bg-[#0a0e1a]/95 backdrop-blur border-b border-[rgba(224,192,128,0.18)]">
<div className="mx-auto max-w-6xl px-4 py-2 flex items-center gap-3 flex-wrap text-[12px] font-mono">
<nav
aria-label={locale === "en" ? "Breadcrumb" : "Trilha"}
className="sticky top-0 z-30 bg-[#0a0e1a]/95 backdrop-blur border-b border-[rgba(224,192,128,0.18)]"
>
<div className="mx-auto max-w-6xl px-4 py-2.5 flex items-center gap-3 flex-wrap text-[12px] font-mono">
<Link
href="/"
className="inline-flex items-center gap-1 px-2 py-1 rounded border border-[rgba(127,219,255,0.30)] text-[#7fdbff] hover:bg-[rgba(127,219,255,0.08)]"
aria-label="Voltar para a home"
className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full border border-[rgba(224,192,128,0.30)] text-[#cbd2dd] hover:text-[#e0c080] hover:bg-[rgba(224,192,128,0.08)] transition-colors"
aria-label={t.aria_home}
>
<ArrowLeft size={12} />
<Home size={12} />
<span>home</span>
<ArrowLeft size={12} aria-hidden="true" />
<span>{t.home}</span>
</Link>
<Link
href="/bureau"
className="inline-flex items-center gap-1 px-2 py-1 rounded border border-[#e0c080] text-[#e0c080] hover:bg-[rgba(224,192,128,0.08)]"
aria-label="Hub do bureau"
className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full border border-[rgba(224,192,128,0.50)] text-[#e0c080] hover:bg-[rgba(224,192,128,0.08)] transition-colors"
aria-label={t.aria_bureau}
>
🔎 <span>bureau</span>
<span>{t.bureau}</span>
</Link>
{crumbs.length > 0 && (
<div className="text-[11px] text-[#5a6678] flex items-center gap-1.5 flex-wrap">
<span className="mx-1">/</span>
<span className="mx-1" aria-hidden="true">/</span>
{crumbs.map((c, i) => (
<span key={`${c.label}-${i}`} className="inline-flex items-center gap-1.5">
{c.href ? (
<Link href={c.href} className="hover:text-[#7fdbff]">{c.label}</Link>
<Link href={c.href} className="hover:text-[#e0c080] transition-colors">{c.label}</Link>
) : (
<span className="text-[#e7ecf3]">{c.label}</span>
<span className="text-[#e7ecf3]" aria-current="page">{c.label}</span>
)}
{i < crumbs.length - 1 && <span>/</span>}
{i < crumbs.length - 1 && <span aria-hidden="true">/</span>}
</span>
))}
</div>
)}
{locale && (
<span className="ml-auto">
<span className="ml-auto inline-flex items-center gap-2">
<LocaleToggle current={locale} variant="navbar" />
<AuthBar locale={locale} />
</span>
)}
</div>
</nav>
);

View file

@ -1,6 +1,5 @@
"use client";
import { useRouter } from "next/navigation";
import { Globe } from "lucide-react";
import type { Locale } from "./locale-toggle";
@ -8,30 +7,54 @@ export function LocaleToggleClient({
current,
variant = "retro",
}: { current: Locale; variant?: "retro" | "navbar" }) {
const router = useRouter();
function setLocale(next: Locale) {
if (next === current) return;
document.cookie = `locale=${next}; path=/; max-age=${60 * 60 * 24 * 365}; SameSite=Lax`;
router.refresh();
// Hard reload, not router.refresh(): the latter re-fetches the route's
// server tree but Next.js can keep a stale <title>/<meta> because metadata
// is rendered up in the layout tree which isn't always re-invalidated.
// A full navigation re-runs generateMetadata against the new cookie, so
// the tab title, OG tags, and html lang attribute all switch together.
window.location.reload();
}
if (variant === "navbar") {
// Gold-amber magazine theme to match SiteHeader / BureauNav.
// The active locale fills with gold; the inactive one stays muted text.
// Sized to be unmistakably readable in the navbar — the previous 11px pill
// disappeared visually next to the other links.
return (
<div className="inline-flex items-center gap-0.5 font-mono text-[11px] uppercase tracking-wider border border-[rgba(224,192,128,0.30)] rounded-full overflow-hidden">
<span className="pl-2.5 pr-1 text-[#5a6678]"><Globe size={11} /></span>
<div
role="group"
aria-label="Language"
className="inline-flex items-center font-mono text-[12px] uppercase tracking-wider border border-[rgba(224,192,128,0.35)] rounded-full overflow-hidden"
>
<span className="pl-2.5 pr-1.5 py-1 text-[#9aa6b8]" aria-hidden="true">
<Globe size={12} />
</span>
<button
type="button"
onClick={() => setLocale("pt-br")}
aria-pressed={current === "pt-br"}
className={`px-2 py-1 transition-colors ${current === "pt-br" ? "bg-[#e0c080] text-[#0a0e1a] font-semibold" : "text-[#9aa6b8] hover:text-[#e0c080]"}`}
aria-label="Português (Brasil)"
className={`px-2.5 py-1 transition-colors ${
current === "pt-br"
? "bg-[#e0c080] text-[#0a0e1a] font-semibold"
: "text-[#cbd2dd] hover:text-[#e0c080] hover:bg-[rgba(224,192,128,0.08)]"
}`}
>
PT
</button>
<button
type="button"
onClick={() => setLocale("en")}
aria-pressed={current === "en"}
className={`px-2 py-1 transition-colors ${current === "en" ? "bg-[#e0c080] text-[#0a0e1a] font-semibold" : "text-[#9aa6b8] hover:text-[#e0c080]"}`}
aria-label="English"
className={`px-2.5 py-1 transition-colors ${
current === "en"
? "bg-[#e0c080] text-[#0a0e1a] font-semibold"
: "text-[#cbd2dd] hover:text-[#e0c080] hover:bg-[rgba(224,192,128,0.08)]"
}`}
>
EN
</button>
@ -43,12 +66,14 @@ export function LocaleToggleClient({
<div className="inline-flex items-center gap-1 font-mono text-[10px] uppercase tracking-widest border border-[rgba(0,255,156,0.32)] rounded">
<span className="px-2 py-1 text-[#5a6678]"><Globe size={11} /></span>
<button
type="button"
onClick={() => setLocale("en")}
className={`px-2 py-1 ${current === "en" ? "bg-[rgba(0,255,156,0.12)] text-[#00ff9c]" : "text-[#8896aa] hover:text-[#7fdbff]"}`}
>
EN
</button>
<button
type="button"
onClick={() => setLocale("pt-br")}
className={`px-2 py-1 ${current === "pt-br" ? "bg-[rgba(0,255,156,0.12)] text-[#00ff9c]" : "text-[#8896aa] hover:text-[#7fdbff]"}`}
>

View file

@ -0,0 +1,33 @@
"use client";
/**
* NavLink primary-nav link with an active-page indicator.
*
* Reads usePathname to decide whether this link points at the active route,
* then renders a visible gold underline plus a brighter text colour and
* sets `aria-current="page"` for screen readers. A link counts as active for
* its exact path AND for descendant paths (e.g. /sightings is active when
* on /sightings/EV-1947-), so deep pages still highlight their section.
*/
import Link from "next/link";
import { usePathname } from "next/navigation";
export function NavLink({ href, label }: { href: string; label: string }) {
const pathname = usePathname() || "/";
const isActive =
pathname === href || (href !== "/" && pathname.startsWith(`${href}/`));
return (
<Link
href={href}
aria-current={isActive ? "page" : undefined}
className={
isActive
? "px-2.5 py-1.5 rounded text-[#e0c080] bg-[rgba(224,192,128,0.10)] border-b-2 border-[#e0c080] -mb-[2px] transition-colors"
: "px-2.5 py-1.5 rounded text-[#cbd2dd] hover:text-[#e0c080] hover:bg-[rgba(224,192,128,0.06)] border-b-2 border-transparent -mb-[2px] transition-colors"
}
>
{label}
</Link>
);
}

View file

@ -7,19 +7,23 @@
import Link from "next/link";
import { AuthBar } from "./auth-bar";
import { LocaleToggle } from "./locale-toggle";
import { NavLink } from "./nav-link";
export function SiteHeader({ locale }: { locale: "pt-br" | "en" }) {
return (
<header className="border-b border-[rgba(224,192,128,0.15)]">
<div className="mx-auto max-w-7xl px-4 md:px-8 py-4 flex items-center justify-between gap-4 flex-wrap">
<Link href="/" className="flex items-center gap-2 group">
<span className="text-[#e0c080] text-lg"></span>
<Link href="/" className="flex items-center gap-2 group" aria-label="The Disclosure Bureau — home">
<span className="text-[#e0c080] text-lg" aria-hidden="true"></span>
<span className="font-display text-xl text-[#e7ecf3] group-hover:text-[#e0c080] transition-colors">
The Disclosure Bureau
</span>
</Link>
<nav className="flex items-center gap-1 text-[12px] font-mono">
<nav
aria-label={locale === "en" ? "Primary" : "Principal"}
className="flex items-center gap-1 text-[13px] font-mono"
>
<NavLink href="/sightings" label={locale === "en" ? "Sightings" : "Avistamentos"} />
<NavLink href="/witnesses" label={locale === "en" ? "Witnesses" : "Testemunhas"} />
<NavLink href="/objects" label={locale === "en" ? "Craft" : "Objetos"} />
@ -27,21 +31,10 @@ export function SiteHeader({ locale }: { locale: "pt-br" | "en" }) {
<NavLink href="/operations" label={locale === "en" ? "Programs" : "Programas"} />
<NavLink href="/bureau" label={locale === "en" ? "Case files" : "Casos"} />
<NavLink href="/search" label={locale === "en" ? "Search" : "Busca"} />
<span className="ml-1"><LocaleToggle current={locale} variant="navbar" /></span>
<span className="ml-1"><AuthBar /></span>
<span className="ml-2"><LocaleToggle current={locale} variant="navbar" /></span>
<span className="ml-2"><AuthBar locale={locale} /></span>
</nav>
</div>
</header>
);
}
function NavLink({ href, label }: { href: string; label: string }) {
return (
<Link
href={href}
className="px-2.5 py-1.5 rounded text-[#9aa6b8] hover:text-[#e0c080] hover:bg-[rgba(224,192,128,0.06)] transition-colors"
>
{label}
</Link>
);
}