feat(nav): הסרת דף מדגם-זהב (goldset) מה-UI

הכיול החד-פעמי של ולידטורי חילוץ-ההלכות (#81.8) הסתיים — הוסר מה-UI:
- web-ui/src/app/goldset/page.tsx (הדף)
- web-ui/src/components/goldset/goldset-panel.tsx (הרכיב)
- web-ui/src/lib/api/goldset.ts (מודול ה-API)
- הקישור "מדגם-זהב" מתפריט "פסיקה" + השטחת התת-כותרת "ניתוח וכיול"
  (נותר רק "מפת הקורפוס" → רשימה שטוחה)
- ניקוי אזכורי gold-set מהערות approvals/page.tsx ו-chair.ts

ה-backend נשאר במכוון: טבלת halacha_goldset, ה-endpoints (/api/goldset*)
ופונקציות ה-DB משמשים את סקריפטי ה-eval/benchmark ומחזיקים נתוני-תיוג
אנושיים — אין מחיקת DB ואין שבירת סקריפטים. /api/chair/pending ממילא לא
כלל goldset, אז אין קישור שבור במרכז-האישורים.

Invariants: G2 (הסרת יכולת-UF מיותרת ללא יצירת מסלול מקביל).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-08 07:32:48 +00:00
parent 6ec67d1a11
commit 54948eb8ab
6 changed files with 4 additions and 659 deletions

View File

@@ -56,8 +56,7 @@ const KNOWLEDGE_MENUS: NavMenuDef[] = [
{ href: "/precedents", label: "ספריית פסיקה" },
{ href: "/digests", label: "יומונים" },
{ href: "/missing-precedents", label: "פסיקה חסרה" },
{ href: "/graph", label: "מפת הקורפוס", groupLabel: "ניתוח וכיול" },
{ href: "/goldset", label: "מדגם-זהב" },
{ href: "/graph", label: "מפת הקורפוס" },
],
},
{

View File

@@ -1,500 +0,0 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import { Check, X, ChevronDown, ChevronLeft, Info, AlertTriangle } from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Skeleton } from "@/components/ui/skeleton";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import {
useGoldset, useGoldsetScore, useTagGoldset, useCreateGoldsetSample,
type GoldsetItem,
} from "@/lib/api/goldset";
import { AuthorityBadge } from "@/components/precedents/halacha-meta";
// rule ROLE only (INV-DM7) — authority (binding/persuasive) is a SEPARATE,
// derived axis, shown read-only and never tagged here.
const TYPES: { value: string; label: string }[] = [
{ value: "holding", label: "מהותי" },
{ value: "interpretive", label: "פרשני" },
{ value: "procedural", label: "פרוצדורלי" },
{ value: "application", label: "יישום" },
{ value: "obiter", label: "אמרת-אגב" },
];
// Consistency between is_holding and the role (#81.7): a real holding is
// holding/interpretive/procedural; a NON-holding is its reason —
// application (fact-bound) or obiter (not decided). Other pairings contradict.
const HOLDING_TYPES = new Set(["holding", "interpretive", "procedural"]);
const NON_HOLDING_TYPES = new Set(["application", "obiter"]);
function inconsistentTag(it: GoldsetItem): string | null {
if (it.is_holding === null || !it.correct_type) return null;
if (it.is_holding === true && NON_HOLDING_TYPES.has(it.correct_type)) {
return "סימנת \"הלכה\" אך הסוג הוא יישום/אמרת-אגב — אלה דווקא הסיבות שמשהו אינו הלכה.";
}
if (it.is_holding === false && HOLDING_TYPES.has(it.correct_type)) {
return "סימנת \"לא הלכה\" אך הסוג מציין הלכה (מהותי/פרשני/…); ל\"לא\" מתאים יישום או אמרת-אגב.";
}
return null;
}
const FLAG_LABELS: Record<string, string> = {
non_decision: "אי-הכרעה", truncated_quote: "ציטוט קטוע", thin_restatement: "ניסוח דק",
quote_unverified: "ציטוט לא מאומת", nli_unsupported: "כלל לא נגזר", application: "יישום",
near_duplicate: "כפילות-קרובה", nevo_preamble_leak: "דליפת רציו",
};
function cleanCitation(s: string | null | undefined): string {
if (!s) return "—";
return s.replace(/[--]/g, "").trim();
}
// Source separation (פסקי-דין מול החלטות ועדת-ערר) for convenient tagging.
function sourceLabel(s: string | null): string {
return s === "court_ruling" ? "פסק-דין"
: s === "appeals_committee" ? "ועדת ערר" : "אחר";
}
const SOURCE_FILTERS: { value: "all" | "court_ruling" | "appeals_committee"; label: string }[] = [
{ value: "all", label: "הכל" },
{ value: "court_ruling", label: "פסקי דין" },
{ value: "appeals_committee", label: "ועדת ערר" },
];
function isTagged(it: GoldsetItem): boolean {
// Fully tagged only when ALL THREE answers are set — otherwise, in
// "hide tagged" mode, a card would vanish the moment is_holding is clicked,
// before correct_type / quote_complete can be set.
return it.is_holding !== null && it.quote_complete !== null && !!it.correct_type;
}
// The AI second-opinion disagrees with the human tag (on is_holding or type).
function aiDisagrees(it: GoldsetItem): boolean {
if (!it.ai_generated_at) return false;
const holdDiff = it.is_holding !== null && it.ai_is_holding !== null
&& it.is_holding !== it.ai_is_holding;
const typeDiff = !!it.correct_type && !!it.ai_correct_type
&& it.correct_type !== it.ai_correct_type;
return holdDiff || typeDiff;
}
// ─── Score panel ──────────────────────────────────────────────────────────────
function ScorePanel({ batch }: { batch: string }) {
const { data } = useGoldsetScore(batch);
const [open, setOpen] = useState(true);
if (!data || data.labeled === 0) return null;
const rows = Object.entries(data.validators);
// negatives so far (truly "not a holding") = tp+fn of any validator.
const af = data.validators.any_flag;
const negatives = af ? af.tp + af.fn : 0;
return (
<div className="rounded-lg border border-rule bg-surface">
<button
type="button"
onClick={() => setOpen((v) => !v)}
className="w-full flex items-center gap-2 px-4 py-2.5 text-sm hover:bg-gold-wash/30"
aria-expanded={open}
>
{open ? <ChevronDown className="w-4 h-4" /> : <ChevronLeft className="w-4 h-4" />}
<span className="font-semibold text-navy">ציון הוולידטורים מול התיוג</span>
<span className="text-ink-muted">({data.labeled} מתויגות)</span>
</button>
{open && (
<div className="px-4 pb-3 overflow-x-auto">
<p className="text-[0.72rem] text-ink-muted mb-2">
המדדים מודדים זיהוי <strong>"לא-הלכה"</strong> (יישום / ציטוט-קטוע / אי-הכרעה...).
{negatives < 10
? ` עד כה תויגו רק ${negatives} פריטי "לא הלכה" — המספרים יהפכו משמעותיים ככל שיצטברו עוד (במיוחד מבקט המסומנים).`
: " precision גבוה = מעט אזעקות-שווא; recall גבוה = תופס את רוב ה'לא-הלכה'."}
</p>
<table className="w-full text-sm tabular-nums">
<thead>
<tr className="text-ink-muted text-[0.72rem] border-b border-rule">
<th className="text-start py-1 ps-1">ולידטור</th>
<th className="text-start">Precision</th>
<th className="text-start">Recall</th>
<th className="text-start">F1</th>
<th className="text-start">tp/fp/fn/tn</th>
</tr>
</thead>
<tbody>
{rows.map(([name, v]) => (
<tr key={name} className="border-b border-rule-soft last:border-0">
<td className="py-1 ps-1 text-navy">{name}</td>
<td>{v.precision.toFixed(2)}</td>
<td>{v.recall.toFixed(2)}</td>
<td className="font-semibold">{v.f1.toFixed(2)}</td>
<td className="text-ink-muted text-[0.72rem]">
{v.tp}/{v.fp}/{v.fn}/{v.tn}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
);
}
// ─── Rule-type help (info popover) ────────────────────────────────────────────
// Role only — "כמה מחייב" (מחייב/משכנע) is the SEPARATE authority axis, derived
// automatically from the court's identity and shown as a read-only badge.
const TYPE_HELP: { label: string; def: string; test: string; example: string }[] = [
{
label: "מהותי",
def: "העיקרון המהותי שהיה הכרחי להכרעה — ה-ratio האמיתי. בר-הסתמכות מלא.",
test: "מבחן וומבו: הפוך את הכלל — אם התוצאה הייתה משתנה → מהותי.",
example: "נטל ההוכחה בהיטל השבחה מוטל על הוועדה המקומית.",
},
{
label: "פרשני",
def: "קביעה שמפרשת הוראת-חוק / מונח / תכנית (מה המשמעות של סעיף X).",
test: "עונה ל'מה פירוש הנורמה?' ולא ל'מה הדין?'.",
example: "תכלית הפטור לפי ס' 19(ב)(4) היא לעודד פעילות ציבורית.",
},
{
label: "פרוצדורלי",
def: "כלל סדר-דין: מועדים, סמכות, זכות-עמידה, מיצוי הליכים, נטל.",
test: "עוסק ב'איך' מתנהל ההליך, לא במהות התכנונית.",
example: "המועד להגשת ערר הוא 30 יום.",
},
{
label: "יישום",
def: "החלת כלל על עובדות התיק הספציפי — תלוי-עובדות, לא בר-הכללה (לרוב 'לא הלכה').",
test: "מכיל 'במקרה דנן', שמות-צדדים, סכומים, המבנה הקונקרטי.",
example: "במקרה דנן ההיתר בטל כי השומה שגתה ב-12,000 ₪.",
},
{
label: "אמרת-אגב",
def: "נאמר אגב אורחא, לא הכרחי להכרעה; הערכאה לא הכריעה בו.",
test: "מבחן וומבו הפוך: היפוך הכלל לא משנה את התוצאה. דגלים: 'למעלה מן הצורך', 'מבלי לקבוע מסמרות'.",
example: "אף שאיננו נדרשים להכריע, נעיר כי ייתכן ש...",
},
];
function RuleTypeHelp() {
return (
<Popover>
<PopoverTrigger asChild>
<button
type="button"
className="inline-flex items-center text-ink-muted hover:text-gold-deep"
aria-label="הסבר על סוגי ההלכה"
title="הסבר על הסוגים"
>
<Info className="w-3.5 h-3.5" />
</button>
</PopoverTrigger>
<PopoverContent align="start" className="w-[min(92vw,560px)] max-h-[70vh] overflow-y-auto p-0">
<div className="p-3 border-b border-rule">
<p className="font-semibold text-navy text-sm">סוגי ההלכה במה הם נבדלים</p>
<p className="text-[0.72rem] text-ink-muted mt-0.5">
כלל-אצבע: סימנת &ldquo;הלכה&rdquo; לרוב מהותי / פרשני / פרוצדורלי. סימנת &ldquo;לא&rdquo; לרוב יישום / אמרת-אגב.
</p>
</div>
<ul className="divide-y divide-rule-soft">
{TYPE_HELP.map((t) => (
<li key={t.label} className="p-3 space-y-1" dir="rtl">
<div className="font-semibold text-navy text-sm">{t.label}</div>
<div className="text-[0.78rem] text-ink-soft leading-relaxed">{t.def}</div>
<div className="text-[0.72rem] text-ink-muted"><span className="font-medium">מבחן:</span> {t.test}</div>
<div className="text-[0.72rem] text-ink-muted"><span className="font-medium">דוגמה:</span> {t.example}</div>
</li>
))}
</ul>
</PopoverContent>
</Popover>
);
}
// ─── Tag card ─────────────────────────────────────────────────────────────────
function TagCard({
it, focused, onTag,
}: {
it: GoldsetItem;
focused: boolean;
onTag: (tag: { is_holding?: boolean; correct_type?: string; quote_complete?: boolean }) => void;
}) {
const tagged = isTagged(it);
return (
<div
data-goldset-id={it.id}
className={`rounded-lg border bg-surface p-4 space-y-3 transition-colors
${focused ? "border-gold ring-2 ring-gold/40 shadow-md" : tagged ? "border-rule-soft opacity-70" : "border-rule"}`}
>
<div className="flex items-center gap-2 text-[0.72rem] text-ink-muted flex-wrap">
<span className="font-semibold text-navy">{cleanCitation(it.case_number)}</span>
<Badge variant="outline"
className={`text-[0.65rem] ${it.source_type === "court_ruling"
? "bg-navy-soft/30 text-navy border-navy/30"
: "bg-gold-wash text-gold-deep border-gold/40"}`}>
{sourceLabel(it.source_type)}
</Badge>
<Badge variant="outline" className="text-[0.65rem]">מכונה: {it.rule_type}</Badge>
<AuthorityBadge authority={it.authority} />
{it.confidence != null && (
<Badge variant="outline" className="text-[0.65rem] tabular-nums">ביטחון {it.confidence.toFixed(2)}</Badge>
)}
{(it.quality_flags ?? []).map((f) => (
<Badge key={f} variant="outline" className="text-[0.65rem] bg-danger-bg text-danger border-danger/40">
{FLAG_LABELS[f] ?? f}
</Badge>
))}
{tagged && (
<Badge variant="outline" className="text-[0.65rem] bg-gold-wash text-gold-deep border-gold/40 ms-auto">
<Check className="w-3 h-3 me-1" /> תויג
</Badge>
)}
</div>
<p className="text-navy font-medium leading-relaxed" dir="rtl">{it.rule_statement}</p>
<blockquote className="text-ink-soft text-sm leading-relaxed border-r-2 border-gold pr-3" dir="rtl">
&ldquo;{it.supporting_quote}&rdquo;
</blockquote>
{it.ai_generated_at && (() => {
const aiType = TYPES.find((t) => t.value === it.ai_correct_type)?.label ?? it.ai_correct_type;
const holdDisagree = it.is_holding !== null && it.ai_is_holding !== null
&& it.is_holding !== it.ai_is_holding;
const typeDisagree = !!it.correct_type && !!it.ai_correct_type
&& it.correct_type !== it.ai_correct_type;
const anyTag = it.is_holding !== null || !!it.correct_type;
return (
<div className={`rounded-md border p-2.5 text-[0.78rem] space-y-1
${holdDisagree ? "border-amber-400 bg-amber-50" : "border-rule bg-rule-soft/20"}`} dir="rtl">
<div className="flex items-center gap-2 flex-wrap">
<span className="font-semibold text-navy">🤖 המלצת AI:</span>
<span>{it.ai_is_holding ? "הלכה" : "לא הלכה"}</span>
{aiType && <span className="text-ink-muted">· {aiType}</span>}
{anyTag && (
<span className={`ms-auto text-[0.7rem] px-1.5 py-0.5 rounded
${holdDisagree || typeDisagree
? "bg-amber-100 text-amber-800"
: "bg-emerald-50 text-emerald-700"}`}>
{holdDisagree ? "⚠ חולק על 'הלכה/לא'"
: typeDisagree ? "⚠ חולק על הסוג"
: "✓ מסכים איתך"}
</span>
)}
</div>
{it.ai_rationale && <div className="text-ink-soft leading-relaxed">{it.ai_rationale}</div>}
</div>
);
})()}
<div className="grid gap-3 sm:grid-cols-3 pt-1 border-t border-rule-soft">
{/* is_holding */}
<div>
<div className="text-[0.7rem] text-ink-muted mb-1">האם זו הלכה אמיתית?</div>
<div className="flex gap-1">
<Button size="sm" variant={it.is_holding === true ? "default" : "ghost"}
className={it.is_holding === true ? "bg-gold text-navy hover:bg-gold-deep" : ""}
onClick={() => onTag({ is_holding: true })}>הלכה (H)</Button>
<Button size="sm" variant={it.is_holding === false ? "default" : "ghost"}
className={it.is_holding === false ? "bg-danger text-parchment hover:bg-danger" : "text-danger"}
onClick={() => onTag({ is_holding: false })}>לא (N)</Button>
</div>
</div>
{/* correct_type */}
<div>
<div className="text-[0.7rem] text-ink-muted mb-1 flex items-center gap-1">
הסוג הנכון
<RuleTypeHelp />
</div>
<div className="flex gap-1 flex-wrap">
{TYPES.map((t) => (
<Button key={t.value} size="sm"
variant={it.correct_type === t.value ? "default" : "ghost"}
className={`text-[0.7rem] px-2 ${it.correct_type === t.value ? "bg-navy text-parchment hover:bg-navy-soft" : ""}`}
onClick={() => onTag({ correct_type: t.value })}>{t.label}</Button>
))}
</div>
{inconsistentTag(it) && (
<p className="mt-1 text-[0.68rem] text-amber-700 flex items-start gap-1">
<AlertTriangle className="w-3 h-3 mt-0.5 shrink-0" />
{inconsistentTag(it)}
</p>
)}
</div>
{/* quote_complete */}
<div>
<div className="text-[0.7rem] text-ink-muted mb-1">הציטוט שלם?</div>
<div className="flex gap-1">
<Button size="sm" variant={it.quote_complete === true ? "default" : "ghost"}
className={it.quote_complete === true ? "bg-gold text-navy hover:bg-gold-deep" : ""}
onClick={() => onTag({ quote_complete: true })}>שלם (C)</Button>
<Button size="sm" variant={it.quote_complete === false ? "default" : "ghost"}
className={it.quote_complete === false ? "bg-danger text-parchment hover:bg-danger" : "text-danger"}
onClick={() => onTag({ quote_complete: false })}>קטוע (X)</Button>
</div>
</div>
</div>
</div>
);
}
// ─── Main panel ───────────────────────────────────────────────────────────────
export function GoldsetPanel() {
const batch = "default";
const { data, isPending, error } = useGoldset(batch);
const tag = useTagGoldset(batch);
const createSample = useCreateGoldsetSample(batch);
const [focusedId, setFocusedId] = useState<string | null>(null);
// Single mutually-exclusive view mode — can't get "stuck" like the old
// independent toggles (where the disagree filter hid the untagged items).
const [viewMode, setViewMode] =
useState<"all" | "untagged" | "tagged" | "disagree">("all");
const [sourceFilter, setSourceFilter] =
useState<"all" | "court_ruling" | "appeals_committee">("all");
const items = useMemo(() => data?.items ?? [], [data]);
const taggedCount = items.filter(isTagged).length;
const untaggedCount = items.length - taggedCount;
const disagreeCount = items.filter(aiDisagrees).length;
const sourceCounts = useMemo(() => ({
court_ruling: items.filter((i) => i.source_type === "court_ruling").length,
appeals_committee: items.filter((i) => i.source_type === "appeals_committee").length,
}), [items]);
const visible = useMemo(() => {
let v = items;
if (sourceFilter !== "all") v = v.filter((i) => i.source_type === sourceFilter);
if (viewMode === "untagged") v = v.filter((i) => !isTagged(i));
else if (viewMode === "tagged") v = v.filter(isTagged);
else if (viewMode === "disagree") v = v.filter(aiDisagrees);
// group-sort: כל פסקי-הדין יחד, ואז כל החלטות ועדת-הערר (הפרדה ברורה).
const order = (s: string | null) =>
s === "court_ruling" ? 0 : s === "appeals_committee" ? 1 : 2;
return [...v].sort((a, b) => order(a.source_type) - order(b.source_type));
}, [items, viewMode, sourceFilter]);
const focused = focusedId ? visible.find((i) => i.id === focusedId) ?? null : null;
useEffect(() => {
if (focusedId && visible.some((i) => i.id === focusedId)) return;
setFocusedId(visible[0]?.id ?? null);
}, [focusedId, visible]);
useEffect(() => {
if (!focusedId) return;
document.querySelector(`[data-goldset-id="${focusedId}"]`)
?.scrollIntoView({ block: "nearest", behavior: "smooth" });
}, [focusedId]);
const move = (delta: 1 | -1) => {
if (!visible.length) return;
const idx = focusedId ? visible.findIndex((i) => i.id === focusedId) : -1;
const next = idx < 0 ? (delta > 0 ? 0 : visible.length - 1)
: Math.max(0, Math.min(visible.length - 1, idx + delta));
setFocusedId(visible[next].id);
};
const doTag = async (
it: GoldsetItem,
t: { is_holding?: boolean; correct_type?: string; quote_complete?: boolean },
) => {
try {
await tag.mutateAsync({ id: it.id, tag: t });
} catch (e) {
toast.error(e instanceof Error ? e.message : "שגיאה");
}
};
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
const tagName = (e.target as HTMLElement)?.tagName?.toLowerCase();
if (tagName === "input" || tagName === "textarea" || tagName === "select") return;
if (e.key === "j") { e.preventDefault(); move(1); }
else if (e.key === "k") { e.preventDefault(); move(-1); }
else if (focused && (e.key === "h" || e.key === "H")) { e.preventDefault(); doTag(focused, { is_holding: true }); }
else if (focused && (e.key === "n" || e.key === "N")) { e.preventDefault(); doTag(focused, { is_holding: false }); }
else if (focused && (e.key === "c" || e.key === "C")) { e.preventDefault(); doTag(focused, { quote_complete: true }); }
else if (focused && (e.key === "x" || e.key === "X")) { e.preventDefault(); doTag(focused, { quote_complete: false }); }
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [focused, visible]);
if (error) {
return <div className="rounded bg-danger-bg border border-danger/40 px-6 py-5 text-danger text-center">{error.message}</div>;
}
if (isPending) {
return <div className="space-y-3">{[...Array(3)].map((_, i) => <Skeleton key={i} className="h-40 w-full" />)}</div>;
}
if (!items.length) {
return (
<div className="text-center text-ink-muted py-16 space-y-4">
<p className="text-lg">אין מדגם-זהב עדיין.</p>
<Button disabled={createSample.isPending}
onClick={() => createSample.mutate(150)}
className="bg-gold text-navy hover:bg-gold-deep">
צור מדגם של 150 הלכות לתיוג
</Button>
</div>
);
}
const pct = items.length ? Math.round((taggedCount / items.length) * 100) : 0;
return (
<div className="space-y-4">
<ScorePanel batch={batch} />
{/* source separation — פסקי-דין מול החלטות ועדת-ערר */}
<div className="flex items-center gap-1 rounded-lg border border-rule p-0.5 bg-rule-soft/30 w-fit">
{SOURCE_FILTERS.map((s) => (
<Button key={s.value} size="sm"
variant={sourceFilter === s.value ? "default" : "ghost"}
className={sourceFilter === s.value ? "bg-gold text-navy hover:bg-gold-deep" : ""}
onClick={() => setSourceFilter(s.value)}>
{s.label}
{s.value === "court_ruling" && ` (${sourceCounts.court_ruling})`}
{s.value === "appeals_committee" && ` (${sourceCounts.appeals_committee})`}
</Button>
))}
</div>
<div className="flex items-center gap-3 flex-wrap text-sm">
<span className="text-navy font-semibold tabular-nums">{taggedCount}/{items.length} תויגו</span>
<div className="h-2 w-40 rounded-full bg-rule-soft overflow-hidden">
<div className="h-full bg-gold" style={{ width: `${pct}%` }} />
</div>
<span className="text-ink-muted text-[0.72rem]">
מקלדת: <kbd className="bg-rule-soft px-1.5 rounded">J</kbd>/<kbd className="bg-rule-soft px-1.5 rounded">K</kbd>
{" "}· הלכה <kbd className="bg-rule-soft px-1.5 rounded">H</kbd> / לא <kbd className="bg-rule-soft px-1.5 rounded">N</kbd>
{" "}· ציטוט שלם <kbd className="bg-rule-soft px-1.5 rounded">C</kbd> / קטוע <kbd className="bg-rule-soft px-1.5 rounded">X</kbd>
</span>
<div className="ms-auto flex items-center gap-1 rounded-lg border border-rule p-0.5 bg-rule-soft/30">
{([
{ v: "all", label: `הכל (${items.length})` },
{ v: "untagged", label: `לא תויגו (${untaggedCount})` },
{ v: "tagged", label: `תויגו (${taggedCount})` },
{ v: "disagree", label: `⚠ אי-הסכמות (${disagreeCount})` },
] as const).map((m) => (
<Button key={m.v} size="sm"
variant={viewMode === m.v ? "default" : "ghost"}
className={viewMode === m.v
? (m.v === "disagree" ? "bg-amber-500 text-white hover:bg-amber-600" : "bg-gold text-navy hover:bg-gold-deep")
: (m.v === "disagree" ? "text-amber-700" : "")}
onClick={() => setViewMode(m.v)}>
{m.label}
</Button>
))}
</div>
</div>
<div className="space-y-3">
{visible.map((it) => (
<TagCard key={it.id} it={it} focused={it.id === focusedId}
onTag={(t) => doTag(it, t)} />
))}
</div>
</div>
);
}