Render research prose as Markdown with RTL tables

The analysis-and-research.md content has markdown tables (ציר דיוני)
and inline formatting like **label:** strong runs, which were
rendering as raw pipes and dashes because the compose page used
whitespace-pre-line on plain text.

Add a reusable <Markdown> component backed by react-markdown +
remark-gfm with a custom `components` map that styles paragraphs,
lists, blockquotes, strong runs, and especially GFM tables for RTL
+ aligned columns:

- table: table-auto + border-collapse, wrapped in overflow-x-auto
  so very wide tables don't push the parent card out
- th: whitespace-nowrap so the header row sets column widths and
  every row border lines up row-to-row
- everything text-right + the whole block gets dir="rtl" at the root

Use it in three places on the compose screen:
- ProseSection (represented_party, procedural_background,
  agreed_facts, disputed_facts)
- Conclusions card
- SubsectionCard field content — threshold_claims and issues have
  the same markdown shape in their fields[]

react-markdown + remark-gfm added (~30KB gzipped).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-11 19:43:37 +00:00
parent 4e418787cf
commit ca6ec48580
5 changed files with 1582 additions and 12 deletions

1460
web-ui/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -23,6 +23,8 @@
"react-dom": "19.2.4", "react-dom": "19.2.4",
"react-dropzone": "^15.0.0", "react-dropzone": "^15.0.0",
"react-hook-form": "^7.72.1", "react-hook-form": "^7.72.1",
"react-markdown": "^10.1.0",
"remark-gfm": "^4.0.1",
"shadcn": "^4.2.0", "shadcn": "^4.2.0",
"sonner": "^2.0.7", "sonner": "^2.0.7",
"tailwind-merge": "^3.5.0", "tailwind-merge": "^3.5.0",

View File

@@ -8,6 +8,7 @@ import { Button } from "@/components/ui/button";
import { Skeleton } from "@/components/ui/skeleton"; import { Skeleton } from "@/components/ui/skeleton";
import { SubsectionCard } from "@/components/compose/subsection-card"; import { SubsectionCard } from "@/components/compose/subsection-card";
import { PrecedentsSection } from "@/components/compose/precedents-section"; import { PrecedentsSection } from "@/components/compose/precedents-section";
import { Markdown } from "@/components/ui/markdown";
import { useCase } from "@/lib/api/cases"; import { useCase } from "@/lib/api/cases";
import { useResearchAnalysis } from "@/lib/api/research"; import { useResearchAnalysis } from "@/lib/api/research";
import { useCasePrecedents } from "@/lib/api/precedents"; import { useCasePrecedents } from "@/lib/api/precedents";
@@ -19,9 +20,7 @@ function ProseSection({ title, content }: { title: string; content?: string }) {
<h3 className="text-[0.78rem] uppercase tracking-[0.08em] text-gold-deep font-semibold"> <h3 className="text-[0.78rem] uppercase tracking-[0.08em] text-gold-deep font-semibold">
{title} {title}
</h3> </h3>
<p className="text-sm text-ink-soft leading-relaxed whitespace-pre-line prose"> <Markdown content={content.trim()} />
{content.trim()}
</p>
</section> </section>
); );
} }
@@ -216,11 +215,9 @@ export default function ComposePage({
{analysis.data.conclusions?.trim() && ( {analysis.data.conclusions?.trim() && (
<Card className="bg-gold-wash border-gold/40 shadow-sm"> <Card className="bg-gold-wash border-gold/40 shadow-sm">
<CardContent className="px-6 py-5 space-y-2"> <CardContent className="px-6 py-5 space-y-3">
<h2 className="text-gold-deep text-xl mb-0">מסקנות</h2> <h2 className="text-gold-deep text-xl mb-0">מסקנות</h2>
<p className="text-sm text-ink leading-relaxed whitespace-pre-line prose"> <Markdown content={analysis.data.conclusions.trim()} />
{analysis.data.conclusions.trim()}
</p>
</CardContent> </CardContent>
</Card> </Card>
)} )}

View File

@@ -4,6 +4,7 @@ import { useState } from "react";
import { ChevronDown } from "lucide-react"; import { ChevronDown } from "lucide-react";
import { ChairEditor } from "@/components/compose/chair-editor"; import { ChairEditor } from "@/components/compose/chair-editor";
import { PrecedentsSection } from "@/components/compose/precedents-section"; import { PrecedentsSection } from "@/components/compose/precedents-section";
import { Markdown } from "@/components/ui/markdown";
import type { ResearchSubsection } from "@/lib/api/research"; import type { ResearchSubsection } from "@/lib/api/research";
import type { CasePrecedent } from "@/lib/api/precedents"; import type { CasePrecedent } from "@/lib/api/precedents";
import type { PracticeArea } from "@/lib/practice-area"; import type { PracticeArea } from "@/lib/practice-area";
@@ -76,8 +77,8 @@ export function SubsectionCard({
<dt className="text-[0.72rem] uppercase tracking-wider text-gold-deep font-semibold mb-1"> <dt className="text-[0.72rem] uppercase tracking-wider text-gold-deep font-semibold mb-1">
{f.label} {f.label}
</dt> </dt>
<dd className="text-sm text-ink-soft leading-relaxed whitespace-pre-line"> <dd>
{f.content} <Markdown content={f.content} />
</dd> </dd>
</div> </div>
))} ))}

View File

@@ -0,0 +1,116 @@
"use client";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
/*
* Tiny markdown renderer for Hebrew prose blocks — paragraphs, lists,
* emphasis, and GFM tables (the main reason this exists). The parsed
* research_md fields and the conclusions field both contain tables
* like "ציר דיוני" that we want to render as real <table>s, RTL, with
* auto-sized columns that line up row-to-row.
*
* Table styling uses `table-auto` + `whitespace-nowrap` on header cells
* so the column widths are dictated by the longest cell in that column,
* and every row's borders align exactly underneath each other. The
* overflow-x-auto wrapper catches extremely wide tables on narrow
* viewports without letting the parent card grow.
*/
export function Markdown({ content }: { content: string }) {
return (
<div className="prose-md text-sm text-ink-soft leading-relaxed" dir="rtl">
<ReactMarkdown
remarkPlugins={[remarkGfm]}
components={{
p: ({ node: _n, ...props }) => (
<p className="mb-2 last:mb-0 text-justify" {...props} />
),
strong: ({ node: _n, ...props }) => (
<strong className="text-navy font-semibold" {...props} />
),
em: ({ node: _n, ...props }) => (
<em className="text-ink" {...props} />
),
a: ({ node: _n, ...props }) => (
<a
className="text-gold-deep hover:text-gold underline underline-offset-2"
target="_blank"
rel="noreferrer"
{...props}
/>
),
ul: ({ node: _n, ...props }) => (
<ul className="list-disc ps-5 mb-2 space-y-1" {...props} />
),
ol: ({ node: _n, ...props }) => (
<ol className="list-decimal ps-5 mb-2 space-y-1" {...props} />
),
li: ({ node: _n, ...props }) => (
<li className="text-ink" {...props} />
),
h1: ({ node: _n, ...props }) => (
<h3 className="text-navy text-base font-semibold mt-3 mb-1" {...props} />
),
h2: ({ node: _n, ...props }) => (
<h4 className="text-navy text-sm font-semibold mt-3 mb-1" {...props} />
),
h3: ({ node: _n, ...props }) => (
<h5 className="text-navy text-sm font-semibold mt-2 mb-1" {...props} />
),
blockquote: ({ node: _n, ...props }) => (
<blockquote
className="border-e-2 border-gold-soft pe-3 text-ink italic my-2"
{...props}
/>
),
code: ({ node: _n, ...props }) => (
<code
className="rounded bg-rule-soft px-1 py-0.5 font-mono text-[0.78rem] text-ink"
{...props}
/>
),
/* ── Tables ─────────────────────────────────────────────────
Wrapped in an overflow-x-auto so very wide tables don't push
the parent card out of its track. table-auto lets the browser
size columns by their longest cell (that's what keeps borders
aligned row-to-row) and whitespace-nowrap on the headers
ensures the header row sets column widths instead of
breaking mid-word. */
table: ({ node: _n, ...props }) => (
<div className="my-3 -mx-1 overflow-x-auto">
<table
className="w-full table-auto border-collapse border border-rule text-sm text-right"
{...props}
/>
</div>
),
thead: ({ node: _n, ...props }) => (
<thead className="bg-rule-soft/70" {...props} />
),
tbody: ({ node: _n, ...props }) => <tbody {...props} />,
tr: ({ node: _n, ...props }) => (
<tr className="border-b border-rule last:border-b-0" {...props} />
),
th: ({ node: _n, ...props }) => (
<th
className="border border-rule px-3 py-2 text-right text-navy font-semibold whitespace-nowrap align-top"
{...props}
/>
),
td: ({ node: _n, ...props }) => (
<td
className="border border-rule px-3 py-2 text-right text-ink align-top"
{...props}
/>
),
hr: ({ node: _n, ...props }) => (
<hr className="my-3 border-rule" {...props} />
),
}}
>
{content}
</ReactMarkdown>
</div>
);
}