feat(nav): replace squeezed mobile tab bar with a header menu

The mobile bottom bar rendered all 15 routes into a single row. On a
390px iPhone that works out at ~17px per tab: the icons overlap, every
label truncates to a single letter and the tap targets sit far below
the 44px minimum, so the tabs could not be read or hit reliably.

Mobile now uses a hamburger in the header that opens a full-screen menu
listing every section, grouped (Main / CIPC & BizPortal / SARS /
Practice). The panel locks background scroll, closes on Escape or on
navigation, and carries the theme toggle so the mobile chrome keeps
parity with the desktop cluster.

Desktop had the same problem in a different shape. The full 15-link row
needed 1752px inside a 1280px container, so the page scrolled sideways
even at 1920px (measured scrollWidth 2065 vs a 1920 viewport) and the
right-hand cluster was pushed off screen. The inline nav now carries
six primary links plus a "More" dropdown, which fits exactly.

- NavBar: mobile tab bar replaced by the header menu panel; desktop nav
  reduced to primary links + More dropdown; grouped list shared by both
  so the two surfaces cannot drift apart.
- RouteChrome: dropped the pb-24 md:pb-0 padding that only existed to
  clear the old bottom bar.
- .gitignore / Workflow/.dockerignore: ignore .next-stale* so a stale
  build directory cannot bloat the Docker build context.
This commit is contained in:
sjcoetzeesj-coder 2026-10-27 12:16:58 +02:00
parent 2f01b55d5a
commit 3595cd54bb
4 changed files with 330 additions and 71 deletions

9
.gitignore vendored
View file

@ -9,6 +9,7 @@
# ----------------------------------------------- deps, build & tooling ---
node_modules/
.next/
.next-stale*/
out/
dist/
build/
@ -29,6 +30,14 @@ npm-debug.log*
scratch-*/
_backups/
# --------------------------- CIPC report run state (generated) --------
# The report explorer writes its run folder, produced PDFs and sample
# page renders at runtime — none of that belongs in the repo.
Workflow/.cipc-report/
Workflow/reports/
Workflow/scripts/one-id.txt
Workflow/scripts/cipc-report/_sample*/
# --------------------------------- debug captures & one-off probe output ---
# Ad-hoc dumps kept while debugging SARS / CIPC screens. Not project source.
_probe*.json

View file

@ -14,6 +14,7 @@
# --- deps, build output, git history ----------------------------------
node_modules
.next
.next-stale*
out
dist
build

View file

@ -3,11 +3,17 @@
// center, secondary status on the right. Sticky on scroll with the
// signature hairline border-bottom.
//
// Mobile (<md): a fixed bottom tab bar with inline-SVG icons (Overview
// · Directors · Companies · Onboarding). The second-row horizontal
// scroller from earlier turns has been replaced because the tab bar
// is the standard mobile-app pattern and reclaims vertical screen
// real estate.
// Mobile (<md): no tab bar. The header carries a hamburger that opens
// a full-screen menu panel listing every section, grouped. A previous
// iteration tried a fixed bottom tab bar, but all 15 routes were
// crammed into a 390px phone width (≈17px per tab, labels cut to one
// letter) — unusable. The full-screen menu keeps every section one
// tap away with full-size rows.
//
// Desktop (md+): primary links inline, everything else behind a
// "More" dropdown. The full 15-link row needed 1752px inside a
// 1280px container, which pushed the right-hand cluster off-screen
// and gave the whole page a horizontal scrollbar.
//
// Icons use stroke-currentColor at 1.6px per the design system
// (anti-slop rule: emoji and gradient icons are forbidden). Brand
@ -18,7 +24,7 @@
import Link from "next/link";
import { usePathname } from "next/navigation";
import { useTheme } from "next-themes";
import { useEffect, useState } from "react";
import { useEffect, useRef, useState } from "react";
import type { ReactNode } from "react";
const LINKS = [
@ -28,6 +34,7 @@ const LINKS = [
{ href: "/bizportal-2", label: "BizPortal 2.0", icon: IconSearch, badge: "test" },
{ href: "/cipc", label: "CIPC", icon: IconSearch },
{ href: "/cipc-2", label: "CIPC2.0", icon: IconSearch, badge: "test" },
{ href: "/cipc-report", label: "CIPC Report", icon: IconCheckSquare },
{ href: "/sars-returns", label: "SARS Returns", icon: IconReceipt },
{ href: "/sars-registrations", label: "SARS Registrations", icon: IconRegistry },
{ href: "/tasks", label: "Tasks", icon: IconClipboard },
@ -38,8 +45,83 @@ const LINKS = [
{ href: "/resolutions", label: "Resolutions", icon: IconDocument },
] as const;
type LinkItem = (typeof LINKS)[number];
const LINK_BY_HREF: Record<string, LinkItem> = Object.fromEntries(
LINKS.map((l) => [l.href, l]),
) as Record<string, LinkItem>;
/** Sections that stay one click away in the desktop header. */
const DESKTOP_PRIMARY = [
"/",
"/notes",
"/companies",
"/directors",
"/tasks",
"/compliance",
] as const;
/** Every section, grouped — the shared source of truth for the mobile
* menu panel and the desktop "More" dropdown. */
const MENU_GROUPS: { title: string; hrefs: string[] }[] = [
{ title: "Main", hrefs: ["/", "/tasks", "/directors", "/companies"] },
{
title: "CIPC & BizPortal",
hrefs: ["/bizportal", "/bizportal-2", "/cipc", "/cipc-2", "/cipc-report"],
},
{ title: "SARS", hrefs: ["/sars-returns", "/sars-registrations"] },
{ title: "Practice", hrefs: ["/notes", "/groups", "/compliance", "/resolutions"] },
];
export default function NavBar() {
const pathname = usePathname() ?? "/";
const [menuOpen, setMenuOpen] = useState(false);
const [moreOpen, setMoreOpen] = useState(false);
const moreRef = useRef<HTMLDivElement | null>(null);
const closeRef = useRef<HTMLButtonElement | null>(null);
// Navigating away closes whichever surface was open.
useEffect(() => {
setMenuOpen(false);
setMoreOpen(false);
}, [pathname]);
// The mobile panel is a modal surface: lock the page behind it.
useEffect(() => {
if (!menuOpen) return;
const previous = document.body.style.overflow;
document.body.style.overflow = "hidden";
closeRef.current?.focus();
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") setMenuOpen(false);
};
document.addEventListener("keydown", onKey);
return () => {
document.body.style.overflow = previous;
document.removeEventListener("keydown", onKey);
};
}, [menuOpen]);
// The desktop dropdown closes on an outside click or Escape.
useEffect(() => {
if (!moreOpen) return;
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") setMoreOpen(false);
};
const onPointer = (e: MouseEvent) => {
if (!moreRef.current?.contains(e.target as Node)) setMoreOpen(false);
};
document.addEventListener("keydown", onKey);
document.addEventListener("mousedown", onPointer);
return () => {
document.removeEventListener("keydown", onKey);
document.removeEventListener("mousedown", onPointer);
};
}, [moreOpen]);
const primaryLinks = LINKS.filter((l) =>
(DESKTOP_PRIMARY as readonly string[]).includes(l.href),
);
return (
<>
@ -47,7 +129,7 @@ export default function NavBar() {
data-od-id="navbar"
className="sticky top-0 z-30 bg-surface border-b border-border"
>
<div className="container-x flex items-center justify-between h-20">
<div className="container-x flex items-center justify-between h-[calc(5rem+env(safe-area-inset-top))] pt-[env(safe-area-inset-top)]">
<Link
href="/"
data-od-id="brand-mark"
@ -68,7 +150,7 @@ export default function NavBar() {
className="hidden md:flex items-center gap-2"
aria-label="Primary"
>
{LINKS.map((link) => {
{primaryLinks.map((link) => {
const isActive = isActiveLink(pathname, link.href);
return (
<Link
@ -83,89 +165,201 @@ export default function NavBar() {
}
>
<span>{link.label}</span>
{"badge" in link && link.badge ? (
<span className="text-[10px] font-bold uppercase tracking-wider px-1.5 py-0.5 rounded bg-amber-100 text-amber-800 border border-amber-300">
{link.badge}
</span>
) : null}
</Link>
);
})}
{/* Overflow dropdown — the desktop counterpart of the
mobile menu panel. */}
<div className="relative" ref={moreRef}>
<button
type="button"
onClick={() => setMoreOpen((v) => !v)}
aria-expanded={moreOpen}
aria-haspopup="true"
aria-label="More sections"
data-od-id="desktop-more-button"
className={
"px-4 py-2 rounded-md text-sm font-semibold transition-colors inline-flex items-center gap-1.5 " +
(moreOpen || isOverflowActive(pathname)
? "bg-surface-warm text-fg"
: "text-fg hover:bg-surface-warm")
}
>
More
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.8"
strokeLinecap="round"
strokeLinejoin="round"
className={
"w-4 h-4 transition-transform " + (moreOpen ? "rotate-180" : "")
}
aria-hidden="true"
>
<polyline points="6,9 12,15 18,9" />
</svg>
</button>
{moreOpen ? (
<div
data-od-id="desktop-more-panel"
className="absolute right-0 top-full mt-2 w-[340px] max-h-[70vh] overflow-y-auto bg-surface border border-border rounded-lg shadow-raised p-2 z-40"
>
<GroupedList onNavigate={() => setMoreOpen(false)} />
</div>
) : null}
</div>
</nav>
{/* Right-side desktop cluster: label + theme toggle. Hidden
on mobile — the bottom tab bar carries the brand context. */}
on mobile — the menu panel carries both. */}
<div className="hidden md:flex items-center gap-3">
<span className="text-xs text-muted">Registry Browser</span>
<span className="hidden lg:inline text-xs text-muted">
Registry Browser
</span>
<ThemeToggle />
</div>
{/* Spacer for mobile so the brand lockup stays left-aligned
and the bottom-bar height is the only vertical change. */}
<div className="md:hidden" aria-hidden="true" />
{/* Mobile: hamburger opens the full-screen menu. */}
<button
type="button"
onClick={() => setMenuOpen(true)}
aria-label="Open menu"
aria-haspopup="dialog"
aria-expanded={menuOpen}
data-od-id="mobile-menu-button"
className="md:hidden inline-flex items-center justify-center w-10 h-10 -mr-2 rounded-md text-fg"
>
<IconMenu className="w-6 h-6" />
</button>
</div>
</header>
{/* Mobile bottom tab bar */}
<nav
className="md:hidden fixed inset-x-0 bottom-0 z-30 bg-surface border-t border-border pb-[env(safe-area-inset-bottom)]"
aria-label="Primary"
>
{/*
Layout notes (phone, full-bleed):
- No `container-x` here — the bottom nav should sit edge-to-edge.
- Each tab uses `flex-1 min-w-0` so the labels can truncate cleanly
instead of pushing siblings or wrapping onto a second line.
- `gap-2` between tabs is decorative breathing room; `flex-1` does
the real even spacing.
- `text-[10px] tracking-tight` keeps 8-tab labels inside ~46px slots
on a 390px viewport without truncating every label into dots.
*/}
<ul className="flex items-stretch justify-between h-16 gap-2 px-2">
{LINKS.map((link) => {
const isActive = isActiveLink(pathname, link.href);
const Icon = link.icon;
return (
<li
key={link.href}
className="flex-1 min-w-0"
>
<Link
href={link.href}
aria-current={isActive ? "page" : undefined}
aria-label={link.label}
className={
"group flex flex-col items-center justify-center gap-0.5 h-full w-full px-1 no-underline transition-colors " +
(isActive
? "text-accent"
: "text-muted hover:text-fg")
}
{/* Mobile full-screen menu panel. */}
{menuOpen ? (
<div
role="dialog"
aria-modal="true"
aria-label="Main menu"
data-od-id="mobile-menu-panel"
className="md:hidden fixed inset-0 z-50 bg-bg overflow-y-auto"
>
<div className="sticky top-0 bg-surface border-b border-border">
<div className="container-x flex items-center justify-between h-[calc(5rem+env(safe-area-inset-top))] pt-[env(safe-area-inset-top)]">
<Link href="/" className="flex items-center gap-2 no-underline">
<span
aria-hidden="true"
className="inline-flex items-center justify-center w-8 h-8 rounded-pill bg-accent text-accent-on font-bold text-sm"
>
<Icon
className={
"w-5 h-5 shrink-0 transition-transform " +
(isActive ? "scale-110" : "scale-100")
}
active={isActive}
/>
<span className="text-[10px] font-semibold tracking-tight truncate w-full text-center">
{link.label}
</span>
</Link>
</li>
);
})}
</ul>
</nav>
Y
</span>
<span className="text-lg font-bold text-fg tracking-tight">
Workflow
</span>
</Link>
<button
ref={closeRef}
type="button"
onClick={() => setMenuOpen(false)}
aria-label="Close menu"
data-od-id="mobile-menu-close"
className="w-10 h-10 -mr-2 inline-flex items-center justify-center rounded-md text-fg"
>
<IconClose className="w-6 h-6" />
</button>
</div>
</div>
<GroupedList onNavigate={() => setMenuOpen(false)} />
<div className="container-x flex items-center justify-between py-6 pb-[calc(env(safe-area-inset-bottom)+24px)]">
<span className="text-xs text-muted">Registry Browser</span>
<ThemeToggle />
</div>
</div>
) : null}
</>
);
}
/**
* Grouped list of every section, shared by the mobile menu panel and
* the desktop "More" dropdown.
*/
function GroupedList({ onNavigate }: { onNavigate?: () => void }) {
const pathname = usePathname() ?? "/";
return (
<div className="px-4 pb-2 md:px-0">
{MENU_GROUPS.map((group) => (
<div key={group.title} className="pt-5">
<p className="text-[11px] font-bold uppercase tracking-wider text-meta mb-1 px-1">
{group.title}
</p>
<ul>
{group.hrefs.map((href) => {
const link = LINK_BY_HREF[href];
if (!link) return null;
const Icon = link.icon;
const active = isActiveLink(pathname, href);
return (
<li key={href}>
<Link
href={href}
onClick={onNavigate}
className="flex items-center gap-3 py-2.5 no-underline rounded-md px-1"
>
<span
className={
"w-9 h-9 rounded-md inline-flex items-center justify-center shrink-0 " +
(active
? "bg-accent text-accent-on"
: "bg-surface-warm text-muted")
}
>
<Icon className="w-5 h-5" active={active} />
</span>
<span
className={
"text-[15px] font-semibold " +
(active ? "text-accent" : "text-fg")
}
>
{link.label}
</span>
{"badge" in link && link.badge ? (
<span className="text-[10px] font-bold uppercase tracking-wider px-1.5 py-0.5 rounded bg-amber-100 text-amber-800 border border-amber-300">
{link.badge}
</span>
) : null}
<IconChevronRight className="w-4 h-4 ml-auto text-meta shrink-0" />
</Link>
</li>
);
})}
</ul>
</div>
))}
</div>
);
}
function isActiveLink(pathname: string, href: string): boolean {
if (href === "/") return pathname === "/";
return pathname === href || pathname.startsWith(href + "/");
}
/** True when the current route lives behind the "More" dropdown. */
function isOverflowActive(pathname: string): boolean {
return LINKS.some(
(l) =>
!(DESKTOP_PRIMARY as readonly string[]).includes(l.href) &&
isActiveLink(pathname, l.href),
);
}
/* ─── Icon set (inline SVG, stroke-currentColor, 1.6px stroke) ──── */
interface IconProps {
@ -201,7 +395,7 @@ function IconUsers({ className }: IconProps): ReactNode {
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={1.6}
strokeWidth="1.6"
strokeLinecap="round"
strokeLinejoin="round"
className={className}
@ -221,7 +415,7 @@ function IconBuilding({ className }: IconProps): ReactNode {
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={1.6}
strokeWidth="1.6"
strokeLinecap="round"
strokeLinejoin="round"
className={className}
@ -407,6 +601,61 @@ function IconNotepad({ className }: IconProps): ReactNode {
);
}
/** Hamburger glyph for the mobile menu button. */
function IconMenu({ className }: IconProps): ReactNode {
return (
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.8"
strokeLinecap="round"
className={className}
aria-hidden="true"
>
<line x1="3.5" y1="7" x2="20.5" y2="7" />
<line x1="3.5" y1="12" x2="20.5" y2="12" />
<line x1="3.5" y1="17" x2="20.5" y2="17" />
</svg>
);
}
/** Close glyph for the mobile menu panel. */
function IconClose({ className }: IconProps): ReactNode {
return (
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.8"
strokeLinecap="round"
className={className}
aria-hidden="true"
>
<line x1="5" y1="5" x2="19" y2="19" />
<line x1="19" y1="5" x2="5" y2="19" />
</svg>
);
}
/** Right-chevron used as the list affordance in the grouped lists. */
function IconChevronRight({ className }: IconProps): ReactNode {
return (
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.8"
strokeLinecap="round"
strokeLinejoin="round"
className={className}
aria-hidden="true"
>
<polyline points="9.5,5 16.5,12 9.5,19" />
</svg>
);
}
/* ─── Theme toggle ────────────────────────────────────────────────── */
/**

View file

@ -49,7 +49,7 @@ export default function RouteChrome({ children }: { children: ReactNode }) {
return (
<>
<NavBar />
<main id="main" className="min-h-[60vh] pb-24 md:pb-0">
<main id="main" className="min-h-[60vh]">
{children}
</main>
<footer className="bg-surface-warm border-t border-border mt-16">