From 2ea350e283e1143bca86c36897aec1edeb02323d Mon Sep 17 00:00:00 2001 From: Luiz Gustavo Date: Thu, 28 May 2026 17:50:10 -0300 Subject: [PATCH] ux(nav): unify navbar palette, localize, fix locale-aware metadata MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 , <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> --- web/app/bureau/page.tsx | 2 - web/app/c/[slug]/page.tsx | 2 - web/app/h/[hypothesisId]/page.tsx | 2 - web/app/jobs/[id]/page.tsx | 2 - web/app/layout.tsx | 144 ++++++++++++++---------- web/components/auth-bar.tsx | 34 ++++-- web/components/bureau-nav.tsx | 59 ++++++---- web/components/locale-toggle-client.tsx | 41 +++++-- web/components/nav-link.tsx | 33 ++++++ web/components/site-header.tsx | 25 ++-- 10 files changed, 216 insertions(+), 128 deletions(-) create mode 100644 web/components/nav-link.tsx diff --git a/web/app/bureau/page.tsx b/web/app/bureau/page.tsx index 21aae11..071807f 100644 --- a/web/app/bureau/page.tsx +++ b/web/app/bureau/page.tsx @@ -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]"> diff --git a/web/app/c/[slug]/page.tsx b/web/app/c/[slug]/page.tsx index 45e2ecb..35d9143 100644 --- a/web/app/c/[slug]/page.tsx +++ b/web/app/c/[slug]/page.tsx @@ -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. */} diff --git a/web/app/h/[hypothesisId]/page.tsx b/web/app/h/[hypothesisId]/page.tsx index 8bb1187..d5d4dc6 100644 --- a/web/app/h/[hypothesisId]/page.tsx +++ b/web/app/h/[hypothesisId]/page.tsx @@ -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 */} diff --git a/web/app/jobs/[id]/page.tsx b/web/app/jobs/[id]/page.tsx index d7d0b15..29d07e3 100644 --- a/web/app/jobs/[id]/page.tsx +++ b/web/app/jobs/[id]/page.tsx @@ -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`}> diff --git a/web/app/layout.tsx b/web/app/layout.tsx index f8888e1..80086ae 100644 --- a/web/app/layout.tsx +++ b/web/app/layout.tsx @@ -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,67 +16,90 @@ 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", - template: "%s · The Disclosure Bureau", - }, - description: - "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.", - keywords: [ - "UAP", "UFO", "ovni", "desclassificado", "war.gov", "Pentagon", - "Kenneth Arnold", "Mantell", "green fireballs", "Sandia", - "Project Blue Book", "Robertson Panel", "AATIP", - "documentos desclassificados", "avistamento", "testemunha", - "disclosure", "divulgação UFO", - ], - authors: [{ name: "The Disclosure Bureau" }], - 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"], - 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: { - card: "summary_large_image", - title: "The Disclosure Bureau", - 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: { - canonical: "/", - languages: { "pt-BR": "/", "en-US": "/" }, - }, - robots: { - index: true, - follow: true, - googleBot: { +// 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", + 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", + "Project Blue Book", "Robertson Panel", "AATIP", + "documentos desclassificados", "avistamento", "testemunha", + "disclosure", "divulgação UFO", + ], + authors: [{ name: "The Disclosure Bureau" }], + openGraph: { + type: "website", + siteName: "The Disclosure Bureau", + 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: t.title, + }], + }, + twitter: { + card: "summary_large_image", + title: "The Disclosure Bureau", + description: t.tw_desc, + images: [`${SITE_URL}/api/static/processing/case-art/green-fireballs-narrative.png`], + }, + alternates: { + canonical: "/", + languages: { "pt-BR": "/", "en-US": "/" }, + }, + robots: { index: true, follow: true, - "max-image-preview": "large", - "max-snippet": -1, + googleBot: { + index: true, + follow: true, + "max-image-preview": "large", + "max-snippet": -1, + }, }, - }, - 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.", - }, -}; + other: { + "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> ); diff --git a/web/components/auth-bar.tsx b/web/components/auth-bar.tsx index 106edf6..9ef2277 100644 --- a/web/components/auth-bar.tsx +++ b/web/components/auth-bar.tsx @@ -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> diff --git a/web/components/bureau-nav.tsx b/web/components/bureau-nav.tsx index 03e548d..dc09733 100644 --- a/web/components/bureau-nav.tsx +++ b/web/components/bureau-nav.tsx @@ -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"> - <LocaleToggle current={locale} variant="navbar" /> - </span> - )} + <span className="ml-auto inline-flex items-center gap-2"> + <LocaleToggle current={locale} variant="navbar" /> + <AuthBar locale={locale} /> + </span> </div> </nav> ); diff --git a/web/components/locale-toggle-client.tsx b/web/components/locale-toggle-client.tsx index fc68426..d124bae 100644 --- a/web/components/locale-toggle-client.tsx +++ b/web/components/locale-toggle-client.tsx @@ -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]"}`} > diff --git a/web/components/nav-link.tsx b/web/components/nav-link.tsx new file mode 100644 index 0000000..bb4abc0 --- /dev/null +++ b/web/components/nav-link.tsx @@ -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> + ); +} diff --git a/web/components/site-header.tsx b/web/components/site-header.tsx index ef8849a..219fb91 100644 --- a/web/components/site-header.tsx +++ b/web/components/site-header.tsx @@ -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> - ); -}