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
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:
@@ -3,7 +3,7 @@
|
||||
import type { ReactNode } from "react";
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { ChevronDown } from "lucide-react";
|
||||
import { ChevronDown, Settings } from "lucide-react";
|
||||
|
||||
import {
|
||||
DropdownMenu,
|
||||
@@ -14,49 +14,59 @@ import {
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { GlobalSearch } from "@/components/global-search";
|
||||
import { headerSubtitle } from "@/components/header-context";
|
||||
|
||||
type AgentBoard = {
|
||||
prefix: string;
|
||||
label: string;
|
||||
hint: string;
|
||||
};
|
||||
/**
|
||||
* Ezer Mishpati navigation shell — two-row header.
|
||||
*
|
||||
* 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[] = [
|
||||
{ prefix: "CMP", label: "רישוי ובניה", hint: "תיקי 1xxx" },
|
||||
{ prefix: "CMPA", label: "היטלי השבחה", hint: "תיקי 8xxx / 9xxx" },
|
||||
{ prefix: "CMP", label: "רישוי ובניה", hint: "תיקי 1xxx" },
|
||||
{ prefix: "CMPA", label: "היטלי השבחה", hint: "תיקי 8xxx / 9xxx" },
|
||||
];
|
||||
|
||||
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 {
|
||||
if (href === "/") return pathname === "/";
|
||||
return pathname === href || pathname.startsWith(`${href}/`);
|
||||
@@ -64,104 +74,140 @@ function isActive(pathname: string, href: string): boolean {
|
||||
|
||||
export function AppShell({ children }: { children: ReactNode }) {
|
||||
const pathname = usePathname();
|
||||
const subtitle = headerSubtitle(pathname);
|
||||
const adminActive = ADMIN_ITEMS.some((i) => isActive(pathname, i.href));
|
||||
|
||||
return (
|
||||
<>
|
||||
<header
|
||||
className="
|
||||
relative z-10 flex items-center gap-4
|
||||
px-10 py-[18px]
|
||||
relative z-10 flex flex-col
|
||||
bg-navy text-parchment
|
||||
border-b-[3px] border-gold
|
||||
shadow-md
|
||||
"
|
||||
>
|
||||
<Link href="/" className="flex items-baseline gap-3 hover:text-parchment">
|
||||
<span className="font-display text-[1.45rem] font-bold tracking-[0.02em] text-parchment">
|
||||
עוזר משפטי
|
||||
</span>
|
||||
<span className="text-gold-soft text-sm font-medium">ניהול תיקים</span>
|
||||
</Link>
|
||||
{/* ─── 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>
|
||||
<span className="text-gold-soft text-sm font-medium" aria-live="polite">
|
||||
{subtitle}
|
||||
</span>
|
||||
</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 && (
|
||||
<div className="flex-1 min-w-0 max-w-[460px] mx-4 flex justify-center">
|
||||
<GlobalSearch />
|
||||
</div>
|
||||
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
className="
|
||||
shrink-0 flex items-baseline gap-2 px-3 py-1.5 rounded
|
||||
transition-colors outline-none
|
||||
text-parchment/80 hover:text-parchment hover:bg-navy-soft/60
|
||||
focus-visible:ring-2 focus-visible:ring-gold/60
|
||||
data-[state=open]:bg-navy-soft/80 data-[state=open]:text-parchment
|
||||
"
|
||||
aria-label="ניהול סוכנים — בחר ועדה"
|
||||
>
|
||||
<span className="font-display text-[1.45rem] font-bold tracking-[0.02em] text-parchment">
|
||||
ניהול סוכנים
|
||||
</span>
|
||||
<ChevronDown className="size-4 self-center text-gold-soft" aria-hidden="true" />
|
||||
</DropdownMenuTrigger>
|
||||
|
||||
<DropdownMenuContent align="end" sideOffset={10} className="min-w-[220px]">
|
||||
<DropdownMenuLabel className="text-xs text-muted-foreground">
|
||||
פתח דאשבורד Paperclip
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
{AGENT_BOARDS.map((board) => (
|
||||
<DropdownMenuItem key={board.prefix} asChild>
|
||||
<a
|
||||
href={`${PAPERCLIP_BASE}/${board.prefix}/dashboard`}
|
||||
target="_blank"
|
||||
rel="noreferrer noopener"
|
||||
className="flex items-baseline justify-between gap-3 cursor-pointer"
|
||||
>
|
||||
<span className="font-medium">{board.label}</span>
|
||||
<span className="text-xs text-muted-foreground tracking-wide">
|
||||
{board.prefix} · {board.hint}
|
||||
</span>
|
||||
</a>
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</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="absolute -bottom-[19px] inset-x-2 h-[2px] bg-gold"
|
||||
className="mx-2 h-4 w-px bg-parchment/20"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
|
||||
<div className="flex-1 min-w-0 max-w-[460px] mx-4 flex justify-center">
|
||||
<GlobalSearch />
|
||||
</div>
|
||||
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
className="
|
||||
flex items-baseline gap-2 px-3 py-1.5 rounded
|
||||
transition-colors outline-none
|
||||
text-parchment/80 hover:text-parchment hover:bg-navy-soft/60
|
||||
focus-visible:ring-2 focus-visible:ring-gold/60
|
||||
data-[state=open]:bg-navy-soft/80 data-[state=open]:text-parchment
|
||||
"
|
||||
aria-label="ניהול סוכנים — בחר ועדה"
|
||||
>
|
||||
<span className="font-display text-[1.45rem] font-bold tracking-[0.02em] text-parchment">
|
||||
ניהול סוכנים
|
||||
</span>
|
||||
<ChevronDown className="size-4 self-center text-gold-soft" aria-hidden="true" />
|
||||
</DropdownMenuTrigger>
|
||||
|
||||
<DropdownMenuContent
|
||||
align="end"
|
||||
sideOffset={10}
|
||||
className="min-w-[220px]"
|
||||
>
|
||||
<DropdownMenuLabel className="text-xs text-muted-foreground">
|
||||
פתח דאשבורד Paperclip
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
{AGENT_BOARDS.map((board) => (
|
||||
<DropdownMenuItem key={board.prefix} asChild>
|
||||
<a
|
||||
href={`${PAPERCLIP_BASE}/${board.prefix}/dashboard`}
|
||||
target="_blank"
|
||||
rel="noreferrer noopener"
|
||||
className="flex items-baseline justify-between gap-3 cursor-pointer"
|
||||
>
|
||||
<span className="font-medium">{board.label}</span>
|
||||
<span className="text-xs text-muted-foreground tracking-wide">
|
||||
{board.prefix} · {board.hint}
|
||||
</span>
|
||||
</a>
|
||||
</DropdownMenuItem>
|
||||
<div className="flex items-center gap-1">
|
||||
{group.items.map((item) => (
|
||||
<NavLink key={item.href} item={item} active={isActive(pathname, item.href)} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</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>
|
||||
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
31
web-ui/src/components/header-context.ts
Normal file
31
web-ui/src/components/header-context.ts
Normal 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 "ניהול תיקים";
|
||||
}
|
||||
Reference in New Issue
Block a user