SPB Git

spb/airiskindex Public

The most methodologically rigorous, fully transparent AI job-exposure index.

TypeScript 88% Python 6.1% SQL 2.7% CSS 1.2% JavaScript 0.9% Shell 0.8%

feat(web): mobile dropdown menu (hamburger) in sticky header

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
simon-pierre boucher committed 6 days ago (Aug 5, 2026) parent 731b4f5

Showing 2 changed files with +80 and −2

modified apps/web/app/layout.tsx +10 −2
@@ -3,6 +3,7 @@ import Link from "next/link";
3 3 import type { ReactNode } from "react";
4 4 import { prisma } from "@airiskindex/db";
5 5 import { INDEX_VERSION } from "@airiskindex/scoring";
6 +import { MobileMenu } from "@/components/mobile-menu";
6 7 import "./globals.css";
7 8
8 9 export const metadata: Metadata = {
@@ -44,6 +45,12 @@ const NAV = [
44 45 ["/api/v1/methodology", "API"],
45 46 ] as const;
46 47
48 +const MOBILE_NAV = [
49 + ...NAV,
50 + ["/terms", "Terms of Service"],
51 + ["/privacy", "Privacy Policy"],
52 +] as const;
53 +
47 54 export default async function RootLayout({
48 55 children,
49 56 }: {
@@ -60,7 +67,7 @@ export default async function RootLayout({
60 67 </div>
61 68 )}
62 69 <header className="sticky top-0 z-40 border-b border-[var(--border)] backdrop-blur-md [background:var(--header)]">
63 <nav className="mx-auto flex max-w-5xl items-center justify-between gap-3 px-4 py-3 sm:px-6">
70 + <nav className="relative mx-auto flex max-w-5xl items-center justify-between gap-3 px-4 py-3 sm:px-6">
64 71 <Link href="/" className="flex shrink-0 items-center gap-2 font-semibold tracking-tight">
65 72 <span
66 73 aria-hidden="true"
@@ -68,7 +75,7 @@ export default async function RootLayout({
68 75 />
69 76 AI Risk Index
70 77 </Link>
71 <div className="flex items-center gap-4 overflow-x-auto whitespace-nowrap text-sm text-[var(--ink-2)] sm:gap-6">
78 + <div className="hidden items-center gap-6 text-sm text-[var(--ink-2)] sm:flex">
72 79 {NAV.map(([href, label]) => (
73 80 <Link
74 81 key={href}
@@ -79,6 +86,7 @@ export default async function RootLayout({
79 86 </Link>
80 87 ))}
81 88 </div>
89 + <MobileMenu items={MOBILE_NAV} />
82 90 </nav>
83 91 </header>
84 92 {children}
added apps/web/components/mobile-menu.tsx +70 −0
@@ -0,0 +1,70 @@
1 +"use client";
2 +
3 +import Link from "next/link";
4 +import { useEffect, useState } from "react";
5 +
6 +export function MobileMenu({ items }: { items: ReadonlyArray<readonly [string, string]> }): JSX.Element {
7 + const [open, setOpen] = useState(false);
8 +
9 + // Close on Escape; lock scroll while open.
10 + useEffect(() => {
11 + if (!open) return;
12 + const onKey = (event: KeyboardEvent) => {
13 + if (event.key === "Escape") setOpen(false);
14 + };
15 + document.addEventListener("keydown", onKey);
16 + document.body.style.overflow = "hidden";
17 + return () => {
18 + document.removeEventListener("keydown", onKey);
19 + document.body.style.overflow = "";
20 + };
21 + }, [open]);
22 +
23 + return (
24 + <div className="sm:hidden">
25 + <button
26 + type="button"
27 + aria-expanded={open}
28 + aria-label={open ? "Close menu" : "Open menu"}
29 + onClick={() => setOpen((value) => !value)}
30 + className="flex h-9 w-9 items-center justify-center rounded-lg border border-[var(--border)] bg-[var(--surface-1)] text-[var(--ink)]"
31 + >
32 + {open ? (
33 + <svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true">
34 + <path d="M3 3l10 10M13 3L3 13" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" />
35 + </svg>
36 + ) : (
37 + <svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true">
38 + <path d="M2 4.5h12M2 8h12M2 11.5h12" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" />
39 + </svg>
40 + )}
41 + </button>
42 +
43 + {open && (
44 + <>
45 + <button
46 + type="button"
47 + aria-label="Close menu"
48 + onClick={() => setOpen(false)}
49 + className="fixed inset-0 top-[57px] z-40 cursor-default bg-black/20 backdrop-blur-[2px]"
50 + />
51 + <nav className="absolute inset-x-0 top-full z-50 border-b border-[var(--border)] bg-[var(--surface-1)] shadow-lg">
52 + <ul className="mx-auto max-w-5xl px-4 py-2">
53 + {items.map(([href, label]) => (
54 + <li key={href} className="border-b border-[var(--grid)] last:border-b-0">
55 + <Link
56 + href={href}
57 + onClick={() => setOpen(false)}
58 + className="block py-3.5 text-base font-medium text-[var(--ink)] active:bg-[var(--wash)]"
59 + >
60 + {label}
61 + </Link>
62 + </li>
63 + ))}
64 + </ul>
65 + </nav>
66 + </>
67 + )}
68 + </div>
69 + );
70 +}
71