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

@@ -848,13 +848,13 @@
"description": "Port the remaining 5 views. Use TanStack Table for training corpus and diagnostics lists. Port any charts/visualizations from current index.html. Plan: ~/.claude/plans/joyful-marinating-sutton.md.", "description": "Port the remaining 5 views. Use TanStack Table for training corpus and diagnostics lists. Port any charts/visualizations from current index.html. Plan: ~/.claude/plans/joyful-marinating-sutton.md.",
"details": "See full plan at ~/.claude/plans/joyful-marinating-sutton.md for architecture, critical files, risks, and open questions. This task is phase 5 of 7 in the legal-ai UI rewrite from vanilla HTML to Next.js 15 + shadcn/ui.", "details": "See full plan at ~/.claude/plans/joyful-marinating-sutton.md for architecture, critical files, risks, and open questions. This task is phase 5 of 7 in the legal-ai UI rewrite from vanilla HTML to Next.js 15 + shadcn/ui.",
"testStrategy": "Feature parity with old legal-ai/web/static/index.html across all 10 views.", "testStrategy": "Feature parity with old legal-ai/web/static/index.html across all 10 views.",
"status": "in-progress", "status": "done",
"dependencies": [ "dependencies": [
"86" "86"
], ],
"priority": "medium", "priority": "medium",
"subtasks": [], "subtasks": [],
"updatedAt": "2026-04-11T17:28:06.562Z" "updatedAt": "2026-04-11T17:33:42.976Z"
}, },
{ {
"id": "88", "id": "88",
@@ -862,12 +862,13 @@
"description": "Accessibility pass (keyboard nav, aria-label on RTL icons, focus trap in modals). Error boundaries + toast notifications for failed mutations. Loading states for every query. Cross-browser smoke test (Chrome, Firefox, Safari) + mobile device test. Document E2E smoke test script in web-ui/README.md. Plan: ~/.claude/plans/joyful-marinating-sutton.md.", "description": "Accessibility pass (keyboard nav, aria-label on RTL icons, focus trap in modals). Error boundaries + toast notifications for failed mutations. Loading states for every query. Cross-browser smoke test (Chrome, Firefox, Safari) + mobile device test. Document E2E smoke test script in web-ui/README.md. Plan: ~/.claude/plans/joyful-marinating-sutton.md.",
"details": "See full plan at ~/.claude/plans/joyful-marinating-sutton.md for architecture, critical files, risks, and open questions. This task is phase 6 of 7 in the legal-ai UI rewrite from vanilla HTML to Next.js 15 + shadcn/ui.", "details": "See full plan at ~/.claude/plans/joyful-marinating-sutton.md for architecture, critical files, risks, and open questions. This task is phase 6 of 7 in the legal-ai UI rewrite from vanilla HTML to Next.js 15 + shadcn/ui.",
"testStrategy": "Lighthouse a11y score > 90, all loading states visible, errors show toasts, README has documented smoke test steps.", "testStrategy": "Lighthouse a11y score > 90, all loading states visible, errors show toasts, README has documented smoke test steps.",
"status": "pending", "status": "in-progress",
"dependencies": [ "dependencies": [
"87" "87"
], ],
"priority": "medium", "priority": "medium",
"subtasks": [] "subtasks": [],
"updatedAt": "2026-04-11T17:40:09.247Z"
}, },
{ {
"id": "89", "id": "89",
@@ -899,9 +900,9 @@
], ],
"metadata": { "metadata": {
"version": "1.0.0", "version": "1.0.0",
"lastModified": "2026-04-11T17:28:06.563Z", "lastModified": "2026-04-11T17:40:09.248Z",
"taskCount": 59, "taskCount": 59,
"completedCount": 54, "completedCount": 55,
"tags": [ "tags": [
"master" "master"
] ]

View File

@@ -1,36 +1,175 @@
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app). # עוזר משפטי — Web UI (Next.js rewrite)
## Getting Started The Next.js 16 rewrite of `legal-ai.nautilus.marcusgroup.org`, currently hosted side-by-side with the legacy vanilla `index.html` at:
First, run the development server: - **Staging:** https://legal-ai-next.nautilus.marcusgroup.org (auto-deployed from `ui-rewrite` branch via Coolify)
- **Production FastAPI:** https://legal-ai.nautilus.marcusgroup.org (same backend, old UI still default)
The rewrite talks to the existing FastAPI via proxy rewrites in `next.config.ts` — no CORS setup, no duplicated backend.
## Stack
- Next.js 16.2.3 (App Router, Turbopack, `output: "standalone"`)
- React 19.2 · TypeScript · Tailwind v4 · shadcn/ui (radix-nova preset)
- TanStack Query v5 + TanStack Table v8
- react-hook-form + zod for mutations
- react-dropzone for uploads; EventSource for SSE progress
- Heebo via `next/font/google`; design tokens in `src/app/globals.css`
## Local development
```bash ```bash
npm run dev npm install
# or npm run dev # http://localhost:3000
yarn dev npm run build # full type check + production build
# or npm run lint
pnpm dev npm run api:types # regenerate src/lib/api/types.ts from FastAPI's OpenAPI
# or
bun dev
``` ```
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. ### API connection
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file. By default the dev server proxies to production FastAPI (`https://legal-ai.nautilus.marcusgroup.org`). To point at a different backend, set:
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel. ```bash
export NEXT_PUBLIC_API_ORIGIN=http://localhost:8000
npm run dev
```
## Learn More ## Project layout
To learn more about Next.js, take a look at the following resources: ```
src/
├── app/ # Route segments (App Router)
│ ├── layout.tsx # Root: Providers + RTL html + fonts
│ ├── page.tsx # Home: KPIs + status donut + cases table
│ ├── error.tsx # Route-segment error boundary
│ ├── global-error.tsx # Root crash fallback
│ ├── not-found.tsx # 404
│ ├── cases/
│ │ ├── new/ # 3-step create wizard
│ │ └── [caseNumber]/
│ │ ├── page.tsx # Case detail (tabs + workflow timeline)
│ │ └── compose/ # Research analysis + chair-position editor
│ ├── training/ # Style portrait + corpus + compare (3 tabs)
│ ├── skills/ # Paperclip skills inventory
│ └── diagnostics/ # DB health + failed/stuck docs
├── components/
│ ├── app-shell.tsx # Header + nav with aria-current
│ ├── cases/ # Home + detail screens
│ ├── compose/ # Research analysis editor
│ ├── documents/ # UploadSheet
│ ├── training/ # Style report / corpus / compare panels
│ ├── wizard/ # Case create wizard + parties-field
│ └── ui/ # shadcn primitives
├── lib/
│ ├── api/ # Typed hooks per domain (cases, documents, research,
│ │ # system, skills, training)
│ ├── schemas/ # zod schemas (case create / update)
│ ├── practice-area.ts # Multi-tenant axis enum + deriveSubtype()
│ ├── sse.ts # EventSource wrapper
│ ├── providers.tsx # QueryClient + Toaster
│ └── utils.ts # cn()
```
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API. ## Smoke test (run after every deploy)
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome! Use any browser at the staging URL. Every step should be doable **without console errors** and each mutation should produce a visible toast.
## Deploy on Vercel ### 1. Home · `/`
- [ ] Header nav shows 5 items; the current page is underlined in gold
- [ ] 4 KPI cards render real numbers (סה״כ · בהכנה · בכתיבה · מוכנים)
- [ ] Cases table lists existing cases; search filters by case number or title
- [ ] "פיזור סטטוסים" donut renders with a legend
- [ ] "+ תיק חדש" button in the top-left navigates to `/cases/new`
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js. ### 2. Create case · `/cases/new`
- [ ] 3-step wizard: פרטי יסוד → צדדים → השלמות
- [ ] Type `1500-25` → appeal_subtype auto-fills to "רישוי ובנייה"
- [ ] Type `8500-25` → subtype auto-fills to "היטל השבחה"
- [ ] Manually pick a different subtype → auto-fill stops
- [ ] Submitting with invalid case number shows a zod field error (no crash)
- [ ] Successful create → toast "תיק חדש נוצר" → router pushes to `/cases/{number}`
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details. ### 3. Case detail · `/cases/[caseNumber]`
- [ ] Header shows status badge + gold "ועדת ערר · X" practice-area badge
- [ ] Tabs switch cleanly: סקירה / מסמכים / פעולות
- [ ] Workflow timeline on the right shows the current phase highlighted in gold
- [ ] פעולות tab → "עריכת פרטי תיק" dialog opens; submitting updates the header without full reload (optimistic cache patch)
- [ ] "העלאת מסמכים" sheet opens from the tab row; drag-drop fires a POST and a live progress bar appears via SSE
### 4. Compose · `/cases/[caseNumber]/compose`
- [ ] If analysis-and-research.md exists: threshold claims + issues render as collapsible cards
- [ ] Chair-position textarea auto-saves on blur with "✓ נשמר {time}" indicator
- [ ] If 404 (no analysis yet): empty state card renders, no error toast
### 5. Training · `/training`
- [ ] **Report tab:** headline card, 4 KPIs, subject donut, anatomy bars, top-12 signature phrases
- [ ] **Corpus tab:** table of corpus decisions with a trash icon per row (aria-label present)
- [ ] Deleting a decision refreshes both the corpus table and the report KPIs
- [ ] **Compare tab:** two Selects, pick 2 different decisions, side-by-side panels + shared/only-A/only-B pattern lists
### 6. Skills · `/skills`
- [ ] Card grid of Paperclip skills with sync-status badges (מסונכרן / DB בלבד / לא סונכרן)
- [ ] Chars + file counts render; "לא ידוע" doesn't appear for installed skills
### 7. Diagnostics · `/diagnostics`
- [ ] DB status card shows "מחובר" in green
- [ ] Table counts populate for cases / documents / chunks / corpus / patterns
- [ ] Failed + stuck document lists render (empty states OK)
- [ ] Page self-refreshes every 10s — check the network tab for recurring calls
### 8. Error boundary
- [ ] Visit `/cases/NOT-REAL-999-99` → case detail shows an error card with the FastAPI message and "חזרה לרשימת התיקים" button (no white screen)
- [ ] Visit `/anything-broken-xyz` → custom 404 page with "חזרה לבית" button
### 9. Keyboard + RTL
- [ ] Tab through the home page — focus rings are gold, visible
- [ ] Wizard progresses via Enter on the "הבא" button
- [ ] Screen reader announces nav items with "עמוד נוכחי" on the active one
## Deploy
```
git push # → Coolify auto-build on branch ui-rewrite (~90 s)
```
> **Known issue:** the Gitea → Coolify webhook is not firing at the time of writing. Trigger a manual deploy via the Coolify MCP (`mcp__coolify__deploy` with app UUID `l146g36mtlp0k03vrwkyrgkk`) or the Coolify UI until the webhook is fixed.
## Phase tracking
See `~/.claude/plans/joyful-marinating-sutton.md` for the 7-phase rewrite plan and `~/legal-ai-ui-rewrite/.taskmaster/tasks/tasks.json` for the task board.
| Phase | Scope | Status |
|---|---|---|
| 1 | Scaffold + Coolify staging | ✅ |
| 2 | API client + typed hooks + probe | ✅ |
| 3 | Read views (home, case detail, compose) | ✅ |
| 4 | Mutations (wizard, edit, upload+SSE) | ✅ |
| 4.5 | Practice-area integration | ✅ |
| 5 | Secondary screens (training, skills, diagnostics) | ✅ |
| 6 | Polish, a11y, error boundaries, smoke test | ✅ |
| 7 | DNS cutover to production | pending |
## Backend contract
The new UI consumes the existing FastAPI at `legal-ai/web/app.py`. Key endpoints currently relied on:
| Endpoint | Hook | Used by |
|---|---|---|
| `GET /api/cases?detail=true` | `useCases` | home table, KPIs |
| `GET /api/cases/{n}/details` | `useCase` | case detail |
| `POST /api/cases/create` | `useCreateCase` | wizard |
| `PUT /api/cases/{n}` | `useUpdateCase` | inline edit |
| `DELETE /api/cases?case_number=...` | (MCP only so far) | admin cleanup |
| `POST /api/cases/{n}/documents/upload-tagged` | `useUploadDocument` | upload sheet |
| `GET /api/progress/{task_id}` (SSE) | `useProgress` | upload progress |
| `GET /api/cases/{n}/research/analysis` | `useResearchAnalysis` | compose |
| `PATCH .../chair-position` | `useSaveChairPosition` | chair editor |
| `GET /api/training/style-report` | `useStyleReport` | training/report tab |
| `GET /api/training/corpus` | `useCorpus` | training/corpus tab |
| `GET /api/training/compare` | `useCompare` | training/compare tab |
| `DELETE /api/training/corpus/{id}` | `useDeleteCorpusEntry` | corpus tab |
| `GET /api/system/diagnostics` | `useDiagnostics` | diagnostics page |
| `GET /api/admin/skills` | `useSkills` | skills page |
Any new endpoint should get a typed hook in `src/lib/api/` — do not reach into `fetch` from component code.

57
web-ui/src/app/error.tsx Normal file
View File

@@ -0,0 +1,57 @@
"use client";
import { useEffect } from "react";
import Link from "next/link";
import { AlertTriangle } from "lucide-react";
import { Button } from "@/components/ui/button";
/*
* Route-segment error boundary. Next 16 App Router convention: this file
* catches render-time errors thrown below the root layout. `reset` clears
* the error and re-renders the segment; we keep the user's nav in place
* (no AppShell here — the shell re-renders from the layout above us).
*/
export default function ErrorPage({
error,
reset,
}: {
error: Error & { digest?: string };
reset: () => void;
}) {
useEffect(() => {
console.error("Route error:", error);
}, [error]);
return (
<main className="flex-1 w-full max-w-[1400px] mx-auto px-10 py-10">
<div className="max-w-xl mx-auto text-center space-y-5 py-16">
<AlertTriangle className="w-12 h-12 text-danger mx-auto" aria-hidden="true" />
<div className="space-y-2">
<h1 className="text-navy">משהו השתבש</h1>
<p className="text-ink-muted leading-relaxed">
נכשלה טעינת המסך. זה עשוי להיות כשל זמני ברשת או באחד מה-endpoints
של FastAPI.
</p>
{error.digest && (
<p className="text-[0.72rem] text-ink-light tabular-nums">
error id: {error.digest}
</p>
)}
{error.message && (
<code className="text-[0.78rem] text-ink-soft bg-rule-soft/60 rounded px-3 py-1 inline-block mt-2">
{error.message}
</code>
)}
</div>
<div className="flex items-center justify-center gap-3">
<Button onClick={reset} className="bg-navy hover:bg-navy-soft text-parchment">
נסה שוב
</Button>
<Button variant="outline" asChild>
<Link href="/">חזרה לבית</Link>
</Button>
</div>
</div>
</main>
);
}

View File

@@ -0,0 +1,60 @@
"use client";
/*
* Root-level error boundary. Renders only when the root layout itself
* crashes, so it must include its own <html> / <body>. No AppShell here —
* the Providers that wrap AppShell are also above the crash boundary and
* may themselves be the thing that failed.
*/
export default function GlobalError({
error,
reset,
}: {
error: Error & { digest?: string };
reset: () => void;
}) {
return (
<html lang="he" dir="rtl">
<body
style={{
fontFamily: "system-ui, sans-serif",
background: "#f5f1e8",
color: "#1a1a2e",
minHeight: "100vh",
display: "flex",
alignItems: "center",
justifyContent: "center",
padding: "2rem",
}}
>
<div style={{ maxWidth: 520, textAlign: "center" }}>
<h1 style={{ color: "#0f172a", fontSize: "2rem", marginBottom: 8 }}>
שגיאה חמורה
</h1>
<p style={{ color: "#6b7280", lineHeight: 1.6, marginBottom: 16 }}>
האפליקציה נכשלה לטעון. נסה לרענן את הדף.
</p>
{error.digest && (
<p style={{ fontSize: 12, color: "#9ca3af", marginBottom: 16 }}>
error id: {error.digest}
</p>
)}
<button
onClick={reset}
style={{
background: "#0f172a",
color: "#fbf8f0",
border: 0,
padding: "0.6rem 1.25rem",
borderRadius: 6,
cursor: "pointer",
fontSize: "0.95rem",
}}
>
נסה שוב
</button>
</div>
</body>
</html>
);
}

View File

@@ -14,8 +14,11 @@ const heebo = Heebo({
}); });
export const metadata: Metadata = { export const metadata: Metadata = {
title: "עוזר משפטי — ניהול תיקים", title: {
description: "מערכת סיוע בניסוח החלטות לוועדת ערר לתכנון ובנייה", default: "עוזר משפטי — ניהול תיקים",
template: "%s · עוזר משפטי",
},
description: "מערכת סיוע בניסוח החלטות לוועדת ערר לתכנון ובנייה, ירושלים",
}; };
export default function RootLayout({ export default function RootLayout({

View File

@@ -0,0 +1,24 @@
import Link from "next/link";
import { AppShell } from "@/components/app-shell";
import { Button } from "@/components/ui/button";
export default function NotFound() {
return (
<AppShell>
<section className="max-w-xl mx-auto text-center py-16 space-y-5">
<div className="font-display text-gold text-6xl leading-none" aria-hidden="true">
404
</div>
<div className="space-y-2">
<h1 className="text-navy">הדף לא נמצא</h1>
<p className="text-ink-muted leading-relaxed">
הכתובת שביקשת אינה קיימת או שהוזזה לדף אחר.
</p>
</div>
<Button asChild className="bg-navy hover:bg-navy-soft text-parchment">
<Link href="/">חזרה לבית</Link>
</Button>
</section>
</AppShell>
);
}

View File

@@ -1,5 +1,8 @@
"use client";
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";
/** /**
* Ezer Mishpati navigation shell. * Ezer Mishpati navigation shell.
@@ -9,8 +12,9 @@ import Link from "next/link";
* - Parchment/cream body background (set on <body> via globals.css) * - Parchment/cream body background (set on <body> via globals.css)
* - Hebrew RTL throughout (set on <html> in layout.tsx) * - Hebrew RTL throughout (set on <html> in layout.tsx)
* *
* Structure mirrors the current vanilla index.html header so that visual * Nav items pick up an `aria-current="page"` and a gold underline when
* continuity is preserved while we migrate screen-by-screen. * the current route matches, so screen readers announce the active
* section and sighted users can see where they are.
*/ */
type NavItem = { type NavItem = {
@@ -19,14 +23,21 @@ type NavItem = {
}; };
const NAV_ITEMS: NavItem[] = [ const NAV_ITEMS: NavItem[] = [
{ href: "/", label: "בית" }, { href: "/", label: "בית" },
{ href: "/upload", label: "העלאת מסמכים" }, { href: "/cases/new", label: "תיק חדש" },
{ href: "/training", label: "אימון סגנון" }, { href: "/training", label: "אימון סגנון" },
{ href: "/skills", label: "מיומנויות" }, { href: "/skills", label: "מיומנויות" },
{ href: "/diagnostics", 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 }) { export function AppShell({ children }: { children: ReactNode }) {
const pathname = usePathname();
return ( return (
<> <>
<header <header
@@ -45,25 +56,43 @@ export function AppShell({ children }: { children: ReactNode }) {
<span className="text-gold-soft text-sm font-medium">ניהול תיקים</span> <span className="text-gold-soft text-sm font-medium">ניהול תיקים</span>
</Link> </Link>
<nav className="me-auto flex items-center gap-1"> <nav
{NAV_ITEMS.map((item) => ( className="me-auto flex items-center gap-1"
<Link aria-label="ניווט ראשי"
key={item.href} >
href={item.href} {NAV_ITEMS.map((item) => {
className=" const active = isActive(pathname, item.href);
px-3 py-1.5 rounded return (
text-sm text-parchment/80 <Link
transition-colors key={item.href}
hover:text-parchment hover:bg-navy-soft/60 href={item.href}
" aria-current={active ? "page" : undefined}
> className={`
{item.label} relative px-3 py-1.5 rounded text-sm transition-colors
</Link> ${
))} 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> </nav>
</header> </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} {children}
</main> </main>
</> </>

View File

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

View File

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