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 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
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