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.",
"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.",
"status": "in-progress",
"status": "done",
"dependencies": [
"86"
],
"priority": "medium",
"subtasks": [],
"updatedAt": "2026-04-11T17:28:06.562Z"
"updatedAt": "2026-04-11T17:33:42.976Z"
},
{
"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.",
"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.",
"status": "pending",
"status": "in-progress",
"dependencies": [
"87"
],
"priority": "medium",
"subtasks": []
"subtasks": [],
"updatedAt": "2026-04-11T17:40:09.247Z"
},
{
"id": "89",
@@ -899,9 +900,9 @@
],
"metadata": {
"version": "1.0.0",
"lastModified": "2026-04-11T17:28:06.563Z",
"lastModified": "2026-04-11T17:40:09.248Z",
"taskCount": 59,
"completedCount": 54,
"completedCount": 55,
"tags": [
"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
npm run dev
# or
yarn dev
# or
pnpm dev
# or
bun dev
npm install
npm run dev # http://localhost:3000
npm run build # full type check + production build
npm run lint
npm run api:types # regenerate src/lib/api/types.ts from FastAPI's OpenAPI
```
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.
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
## Smoke test (run after every deploy)
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 = {
title: "עוזר משפטי — ניהול תיקים",
description: "מערכת סיוע בניסוח החלטות לוועדת ערר לתכנון ובנייה",
title: {
default: "עוזר משפטי — ניהול תיקים",
template: "%s · עוזר משפטי",
},
description: "מערכת סיוע בניסוח החלטות לוועדת ערר לתכנון ובנייה, ירושלים",
};
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 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>}