feat(ui): redesign header to two rows with grouped nav (Phase B)
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 32s

Splits the AppShell header into:
  Row 1 — brand: logo + dynamic context subtitle (route-aware) +
          global search + agent boards dropdown
  Row 2 — nav:   work group (בית · ארכיון) | knowledge group (ספריית
          פסיקה · אימון · מתודולוגיה) + admin dropdown (⚙) on the left

Three changes from the previous flat 8-item nav:

1. Grouping reflects intent. Daily-driver pages are in "work", corpus
   pages in "knowledge"; system pages (skills · diagnostics · settings)
   move into a single ⚙ dropdown so they stop competing for attention.

2. Subtitle is now dynamic. `headerSubtitle(pathname)` resolves the
   current section so the user always sees where they are without
   scanning the nav row. Case routes show the case number explicitly
   ("ערר 1234-24" / "ערר 1234-24 · ניסוח").

3. The gold-underline active state is preserved and the admin trigger
   inherits it whenever any admin route is active.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-03 18:15:20 +00:00
parent deb1a1eaf4
commit cca17689de
2 changed files with 219 additions and 119 deletions

View File

@@ -3,7 +3,7 @@
import type { ReactNode } from "react"; import type { ReactNode } from "react";
import Link from "next/link"; import Link from "next/link";
import { usePathname } from "next/navigation"; import { usePathname } from "next/navigation";
import { ChevronDown } from "lucide-react"; import { ChevronDown, Settings } from "lucide-react";
import { import {
DropdownMenu, DropdownMenu,
@@ -14,12 +14,51 @@ import {
DropdownMenuTrigger, DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"; } from "@/components/ui/dropdown-menu";
import { GlobalSearch } from "@/components/global-search"; import { GlobalSearch } from "@/components/global-search";
import { headerSubtitle } from "@/components/header-context";
type AgentBoard = { /**
prefix: string; * Ezer Mishpati navigation shell — two-row header.
label: string; *
hint: string; * Row 1 (brand): logo + dynamic context subtitle · global search · agent boards
}; * Row 2 (nav): work group · knowledge group · admin dropdown
*
* Editorial/judicial aesthetic preserved:
* - Navy background with a gold hairline rule (border-b-3)
* - Parchment text, gold accents on hover/active
* - Hebrew RTL throughout (set on <html> in layout.tsx)
* - Active item gets `aria-current="page"` and a gold underline anchored
* to the bottom border, so screen readers announce the section and
* sighted users see where they are.
*/
type NavItem = { href: string; label: string };
type NavGroup = { id: string; items: NavItem[] };
const NAV_GROUPS: NavGroup[] = [
{
id: "work",
items: [
{ href: "/", label: "בית" },
{ href: "/archive", label: "ארכיון" },
],
},
{
id: "knowledge",
items: [
{ href: "/precedents", label: "ספריית פסיקה" },
{ href: "/training", label: "אימון סגנון" },
{ href: "/methodology", label: "מתודולוגיה" },
],
},
];
const ADMIN_ITEMS: NavItem[] = [
{ href: "/skills", label: "מיומנויות" },
{ href: "/diagnostics", label: "אבחון" },
{ href: "/settings", label: "הגדרות" },
];
type AgentBoard = { prefix: string; label: string; hint: string };
const AGENT_BOARDS: AgentBoard[] = [ const AGENT_BOARDS: AgentBoard[] = [
{ prefix: "CMP", label: "רישוי ובניה", hint: "תיקי 1xxx" }, { prefix: "CMP", label: "רישוי ובניה", hint: "תיקי 1xxx" },
@@ -28,35 +67,6 @@ const AGENT_BOARDS: AgentBoard[] = [
const PAPERCLIP_BASE = "https://pc.nautilus.marcusgroup.org"; const PAPERCLIP_BASE = "https://pc.nautilus.marcusgroup.org";
/**
* Ezer Mishpati navigation shell.
*
* Editorial/judicial aesthetic:
* - Navy header with a gold hairline rule (border-b-3)
* - Parchment/cream body background (set on <body> via globals.css)
* - Hebrew RTL throughout (set on <html> in layout.tsx)
*
* Nav items pick up an `aria-current="page"` and a gold underline when
* the current route matches, so screen readers announce the active
* section and sighted users can see where they are.
*/
type NavItem = {
href: string;
label: string;
};
const NAV_ITEMS: NavItem[] = [
{ href: "/", label: "בית" },
{ href: "/archive", label: "ארכיון" },
{ href: "/training", label: "אימון סגנון" },
{ href: "/precedents", label: "ספריית פסיקה" },
{ href: "/methodology", label: "מתודולוגיה" },
{ href: "/skills", label: "מיומנויות" },
{ href: "/diagnostics", label: "אבחון" },
{ href: "/settings", label: "הגדרות" },
];
function isActive(pathname: string, href: string): boolean { function isActive(pathname: string, href: string): boolean {
if (href === "/") return pathname === "/"; if (href === "/") return pathname === "/";
return pathname === href || pathname.startsWith(`${href}/`); return pathname === href || pathname.startsWith(`${href}/`);
@@ -64,57 +74,30 @@ function isActive(pathname: string, href: string): boolean {
export function AppShell({ children }: { children: ReactNode }) { export function AppShell({ children }: { children: ReactNode }) {
const pathname = usePathname(); const pathname = usePathname();
const subtitle = headerSubtitle(pathname);
const adminActive = ADMIN_ITEMS.some((i) => isActive(pathname, i.href));
return ( return (
<> <>
<header <header
className=" className="
relative z-10 flex items-center gap-4 relative z-10 flex flex-col
px-10 py-[18px]
bg-navy text-parchment bg-navy text-parchment
border-b-[3px] border-gold border-b-[3px] border-gold
shadow-md shadow-md
" "
> >
<Link href="/" className="flex items-baseline gap-3 hover:text-parchment"> {/* ─── Row 1 — brand bar ─── */}
<div className="flex items-center gap-4 px-10 pt-[14px] pb-2">
<Link href="/" className="flex items-baseline gap-3 hover:text-parchment shrink-0">
<span className="font-display text-[1.45rem] font-bold tracking-[0.02em] text-parchment"> <span className="font-display text-[1.45rem] font-bold tracking-[0.02em] text-parchment">
עוזר משפטי עוזר משפטי
</span> </span>
<span className="text-gold-soft text-sm font-medium">ניהול תיקים</span> <span className="text-gold-soft text-sm font-medium" aria-live="polite">
{subtitle}
</span>
</Link> </Link>
<nav
className="flex items-center gap-1"
aria-label="ניווט ראשי"
>
{NAV_ITEMS.map((item) => {
const active = isActive(pathname, item.href);
return (
<Link
key={item.href}
href={item.href}
aria-current={active ? "page" : undefined}
className={`
relative px-3 py-1.5 rounded text-sm transition-colors
${
active
? "text-parchment font-semibold bg-navy-soft/80"
: "text-parchment/80 hover:text-parchment hover:bg-navy-soft/60"
}
`}
>
{item.label}
{active && (
<span
className="absolute -bottom-[19px] inset-x-2 h-[2px] bg-gold"
aria-hidden="true"
/>
)}
</Link>
);
})}
</nav>
<div className="flex-1 min-w-0 max-w-[460px] mx-4 flex justify-center"> <div className="flex-1 min-w-0 max-w-[460px] mx-4 flex justify-center">
<GlobalSearch /> <GlobalSearch />
</div> </div>
@@ -122,7 +105,7 @@ export function AppShell({ children }: { children: ReactNode }) {
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger <DropdownMenuTrigger
className=" className="
flex items-baseline gap-2 px-3 py-1.5 rounded shrink-0 flex items-baseline gap-2 px-3 py-1.5 rounded
transition-colors outline-none transition-colors outline-none
text-parchment/80 hover:text-parchment hover:bg-navy-soft/60 text-parchment/80 hover:text-parchment hover:bg-navy-soft/60
focus-visible:ring-2 focus-visible:ring-gold/60 focus-visible:ring-2 focus-visible:ring-gold/60
@@ -136,11 +119,7 @@ export function AppShell({ children }: { children: ReactNode }) {
<ChevronDown className="size-4 self-center text-gold-soft" aria-hidden="true" /> <ChevronDown className="size-4 self-center text-gold-soft" aria-hidden="true" />
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent <DropdownMenuContent align="end" sideOffset={10} className="min-w-[220px]">
align="end"
sideOffset={10}
className="min-w-[220px]"
>
<DropdownMenuLabel className="text-xs text-muted-foreground"> <DropdownMenuLabel className="text-xs text-muted-foreground">
פתח דאשבורד Paperclip פתח דאשבורד Paperclip
</DropdownMenuLabel> </DropdownMenuLabel>
@@ -162,6 +141,73 @@ export function AppShell({ children }: { children: ReactNode }) {
))} ))}
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
</div>
{/* ─── Row 2 — section nav ─── */}
<div className="flex items-center gap-3 px-10 pt-1 pb-[18px]">
<nav className="flex items-center gap-3" aria-label="ניווט ראשי">
{NAV_GROUPS.map((group, idx) => (
<div key={group.id} className="flex items-center">
{idx > 0 && (
<span
className="mx-2 h-4 w-px bg-parchment/20"
aria-hidden="true"
/>
)}
<div className="flex items-center gap-1">
{group.items.map((item) => (
<NavLink key={item.href} item={item} active={isActive(pathname, item.href)} />
))}
</div>
</div>
))}
</nav>
<DropdownMenu>
<DropdownMenuTrigger
className={`
relative ms-auto shrink-0 flex items-center gap-1.5
px-3 py-1.5 rounded text-sm transition-colors outline-none
focus-visible:ring-2 focus-visible:ring-gold/60
${adminActive
? "text-parchment font-semibold bg-navy-soft/80"
: "text-parchment/80 hover:text-parchment hover:bg-navy-soft/60"}
data-[state=open]:bg-navy-soft/80 data-[state=open]:text-parchment
`}
aria-label="הגדרות מערכת"
>
<Settings className="size-4" aria-hidden="true" />
<ChevronDown className="size-3" aria-hidden="true" />
{adminActive && (
<span
className="absolute -bottom-[19px] inset-x-2 h-[2px] bg-gold"
aria-hidden="true"
/>
)}
</DropdownMenuTrigger>
<DropdownMenuContent align="end" sideOffset={10} className="min-w-[180px]">
<DropdownMenuLabel className="text-xs text-muted-foreground">
מערכת
</DropdownMenuLabel>
<DropdownMenuSeparator />
{ADMIN_ITEMS.map((item) => {
const active = isActive(pathname, item.href);
return (
<DropdownMenuItem key={item.href} asChild>
<Link
href={item.href}
aria-current={active ? "page" : undefined}
className={`cursor-pointer ${active ? "font-semibold" : ""}`}
>
{item.label}
</Link>
</DropdownMenuItem>
);
})}
</DropdownMenuContent>
</DropdownMenu>
</div>
</header> </header>
<main <main
@@ -173,3 +219,26 @@ export function AppShell({ children }: { children: ReactNode }) {
</> </>
); );
} }
function NavLink({ item, active }: { item: NavItem; active: boolean }) {
return (
<Link
href={item.href}
aria-current={active ? "page" : undefined}
className={`
relative px-3 py-1.5 rounded text-sm transition-colors
${active
? "text-parchment font-semibold bg-navy-soft/80"
: "text-parchment/80 hover:text-parchment hover:bg-navy-soft/60"}
`}
>
{item.label}
{active && (
<span
className="absolute -bottom-[19px] inset-x-2 h-[2px] bg-gold"
aria-hidden="true"
/>
)}
</Link>
);
}

View File

@@ -0,0 +1,31 @@
/**
* Resolves the dynamic subtitle shown next to the brand in the AppShell
* header. Reflects the current section so the user always sees where they
* are without scanning the nav row.
*
* Special-cases case routes (`/cases/{caseNumber}` and `/compose`) so the
* subtitle includes the case number — the most useful piece of context
* during decision drafting.
*/
export function headerSubtitle(pathname: string): string {
if (pathname === "/") return "בית";
if (pathname.startsWith("/cases/")) {
const [, , slug] = pathname.split("/");
if (!slug || slug === "new") return "תיק חדש";
const isCompose = pathname.includes("/compose");
const decoded = decodeURIComponent(slug);
return isCompose ? `ערר ${decoded} · ניסוח` : `ערר ${decoded}`;
}
if (pathname.startsWith("/archive")) return "ארכיון";
if (pathname.startsWith("/training")) return "אימון סגנון";
if (pathname.startsWith("/precedents")) return "ספריית פסיקה";
if (pathname.startsWith("/methodology")) return "מתודולוגיה";
if (pathname.startsWith("/skills")) return "מיומנויות";
if (pathname.startsWith("/diagnostics")) return "אבחון";
if (pathname.startsWith("/settings")) return "הגדרות";
if (pathname.startsWith("/feedback")) return "הערות יו״ר";
return "ניהול תיקים";
}