Phase 6: polish — error boundaries, a11y, smoke test doc

Close out the read-only surface before cutover with three families of
small fixes that the previous phases left unfinished:

- Error boundaries: add src/app/error.tsx (route-segment), global-error.tsx
  (root crash fallback with its own minimal html/body — no Providers
  dependency since those may be the thing that crashed), and not-found.tsx
  for a Hebrew 404 instead of the default Next page.

- Accessibility: wire usePathname() into AppShell so the current nav item
  gets aria-current="page" and a gold underline. Add aria-label + aria-hidden
  on the icon-only buttons that Phase 5 left text-less (corpus trash,
  parties-field Plus). Nav gets an aria-label of its own.

- Metadata template: title on each route now reads "X · עוזר משפטי" via
  the layout.tsx title.template. Description localized to Jerusalem.

- README: full E2E smoke test checklist covering all 9 screens, plus a
  backend contract table so future phases know which hook wraps which
  endpoint. Documents the known Gitea→Coolify webhook issue.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-11 17:43:59 +00:00
parent fb1f73fa25
commit cbe9d60901
9 changed files with 375 additions and 55 deletions

View File

@@ -1,5 +1,8 @@
"use client";
import type { ReactNode } from "react";
import Link from "next/link";
import { usePathname } from "next/navigation";
/**
* Ezer Mishpati navigation shell.
@@ -9,8 +12,9 @@ import Link from "next/link";
* - Parchment/cream body background (set on <body> via globals.css)
* - Hebrew RTL throughout (set on <html> in layout.tsx)
*
* Structure mirrors the current vanilla index.html header so that visual
* continuity is preserved while we migrate screen-by-screen.
* 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 = {
@@ -19,14 +23,21 @@ type NavItem = {
};
const NAV_ITEMS: NavItem[] = [
{ href: "/", label: "בית" },
{ href: "/upload", label: "העלאת מסמכים" },
{ href: "/training", label: "אימון סגנון" },
{ href: "/skills", label: "מיומנויות" },
{ href: "/diagnostics", label: "אבחון" },
{ href: "/", label: "בית" },
{ href: "/cases/new", label: "תיק חדש" },
{ href: "/training", label: "אימון סגנון" },
{ href: "/skills", label: "מיומנויות" },
{ href: "/diagnostics", label: "אבחון" },
];
function isActive(pathname: string, href: string): boolean {
if (href === "/") return pathname === "/";
return pathname === href || pathname.startsWith(`${href}/`);
}
export function AppShell({ children }: { children: ReactNode }) {
const pathname = usePathname();
return (
<>
<header
@@ -45,25 +56,43 @@ export function AppShell({ children }: { children: ReactNode }) {
<span className="text-gold-soft text-sm font-medium">ניהול תיקים</span>
</Link>
<nav className="me-auto flex items-center gap-1">
{NAV_ITEMS.map((item) => (
<Link
key={item.href}
href={item.href}
className="
px-3 py-1.5 rounded
text-sm text-parchment/80
transition-colors
hover:text-parchment hover:bg-navy-soft/60
"
>
{item.label}
</Link>
))}
<nav
className="me-auto 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>
</header>
<main className="flex-1 w-full max-w-[1400px] mx-auto px-10 py-10">
<main
id="main"
className="flex-1 w-full max-w-[1400px] mx-auto px-10 py-10"
>
{children}
</main>
</>

View File

@@ -79,9 +79,10 @@ function Row({ item }: { item: CorpusDecision }) {
size="sm"
onClick={onDelete}
disabled={del.isPending}
aria-label={`הסר את ${item.decision_number || "החלטה זו"} מהקורפוס`}
className="text-danger hover:text-danger hover:bg-danger-bg"
>
<Trash2 className="w-4 h-4" />
<Trash2 className="w-4 h-4" aria-hidden="true" />
</Button>
</TableCell>
</TableRow>

View File

@@ -79,8 +79,14 @@ export function PartiesField({
placeholder="שם מלא של הצד"
dir="rtl"
/>
<Button type="button" variant="outline" size="sm" onClick={add}>
<Plus className="w-4 h-4" />
<Button
type="button"
variant="outline"
size="sm"
onClick={add}
aria-label={`הוסף ${label}`}
>
<Plus className="w-4 h-4" aria-hidden="true" />
</Button>
</div>
{error && <p className="text-[0.72rem] text-danger mt-1">{error}</p>}