
3D Extruded Buildings & Urban Footprints
Real-world 3D building extrusions with dynamic sunlight shadows, terrain DEM elevations, and pitch/bearing camera controls.
Product and architectural decision matrix with dynamic weighted scoring algorithms (Impact, Confidence, Effort, Risk), RICE framework toggle, interactive weight sliders, option creator, multi-dimensional SVG tradeoff radar, and ADR consensus breakdown.
Also available for Vue ->$pnpm dlx shadcn@latest add https://uipkge.dev/r/react/decision-matrix-table.json$npx shadcn@latest add https://uipkge.dev/r/react/decision-matrix-table.json$yarn dlx shadcn@latest add https://uipkge.dev/r/react/decision-matrix-table.json$bunx shadcn@latest add https://uipkge.dev/r/react/decision-matrix-table.jsonnpx shadcn@latest add @uipkge-react/decision-matrix-tableInstalls to:components/blocks/| Name | Type / Values | Default | Required |
|---|---|---|---|
title | string | Architectural Decision Matrix (RICE / Weighted Scoring) | optional |
subtitle | string | Evaluate technical options against weighted business and engineering criteria. | optional |
defaultFramework | FrameworkType | weighted-matrix | optional |
Type aliases exported from this item's source. Use these to shape the data you pass in.
CriterionDefinterface CriterionDef {
key: 'impact' | 'confidence' | 'effort' | 'dx' | 'maintenance'
label: string
shortLabel: string
description: string
defaultWeight: number
}MatrixOptioninterface MatrixOption {
id: string
name: string
architecture: string
summary: string
keyRisk: string
color: string
pros: string[]
cons: string[]
scores: {
impact: number // 1 to 5
confidence: number // 1 to 5
effort: number // 1 to 5 (simplicity / low effort)
dx: number // 1 to 5 (dx ownership)
maintenance: number // 1 to 5 (low maintenance overhead)
}
rice: {
reach: number // 0 to 100 (% of engineers / services)
impact: number // 1 to 5 multiplier
confidence: number // 50 to 100 (%)
effort: number // 1 to 5 person-sprints
}
isCustom?: boolean
}'use client'
import * as React from 'react'
import {
AlertCircle,
Award,
BarChart3,
Check,
CheckCircle2,
ChevronDown,
ChevronUp,
FileCode2,
HelpCircle,
Info,
Plus,
RefreshCw,
Scale,
ShieldCheck,
SlidersHorizontal,
PlusCircle,
Trash2,
XCircle,
} from 'lucide-react'
import { cn } from '@/lib/utils'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card'
import { Input } from '@/components/ui/input'
import { Separator } from '@/components/ui/separator'
import { Slider } from '@/components/ui/slider'
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
export type FrameworkType = 'weighted-matrix' | 'rice'
export interface DecisionMatrixTableProps extends React.HTMLAttributes<HTMLDivElement> {
title?: string
subtitle?: string
defaultFramework?: FrameworkType
}
export interface CriterionDef {
key: 'impact' | 'confidence' | 'effort' | 'dx' | 'maintenance'
label: string
shortLabel: string
description: string
defaultWeight: number
}
export interface MatrixOption {
id: string
name: string
architecture: string
summary: string
keyRisk: string
color: string
pros: string[]
cons: string[]
scores: {
impact: number // 1 to 5
confidence: number // 1 to 5
effort: number // 1 to 5 (simplicity / low effort)
dx: number // 1 to 5 (dx ownership)
maintenance: number // 1 to 5 (low maintenance overhead)
}
rice: {
reach: number // 0 to 100 (% of engineers / services)
impact: number // 1 to 5 multiplier
confidence: number // 50 to 100 (%)
effort: number // 1 to 5 person-sprints
}
isCustom?: boolean
}
const criteriaList: CriterionDef[] = [
{
key: 'impact',
label: 'Engineering Impact',
shortLabel: 'Impact',
description: 'System modularity, bundle footprint reduction, and runtime performance gains.',
defaultWeight: 25,
},
{
key: 'confidence',
label: 'Confidence / Feasibility',
shortLabel: 'Confidence',
description: 'Ecosystem support, library maturity, and developer team readiness.',
defaultWeight: 25,
},
{
key: 'effort',
label: 'Simplicity (Low Effort)',
shortLabel: 'Low Effort',
description: 'Frictionless adoption curve, zero monolith refactors, fast time to market.',
defaultWeight: 15,
},
{
key: 'dx',
label: 'DX & Consumer Ownership',
shortLabel: 'DX Ownership',
description: 'Full source access, effortless Tailwind styling customization, zero semver lock-in.',
defaultWeight: 20,
},
{
key: 'maintenance',
label: 'Low Maintenance',
shortLabel: 'Low Maint.',
description: 'Decoupled component lifecycles, zero breaking upstream transitive dependency cascades.',
defaultWeight: 15,
},
]
const initialOptions: MatrixOption[] = [
{
id: 'opt-a',
name: 'Option A: Unbundled Registry architecture',
architecture: 'UIPKGE / shadcn Model',
summary:
'Components copy directly into consumer source tree. Zero semver locks, complete styling autonomy, and unconstrained design customization.',
keyRisk: 'Downstream drift from upstream patches managed through automated registry diffing.',
color: '#10b981',
pros: [
'Zero npm package version lock-in or dependency gridlock',
'Immediate Tailwind class-level and DOM customization',
'100% dead-code elimination and optimal bundle footprint',
],
cons: ['Manual or CLI-assisted updates when upstream security patches land'],
scores: {
impact: 4.8,
confidence: 4.7,
effort: 4.5,
dx: 5.0,
maintenance: 4.4,
},
rice: {
reach: 96,
impact: 5.0,
confidence: 95,
effort: 1.8,
},
},
{
id: 'opt-b',
name: 'Option B: Monolithic Multi-Framework NPM',
architecture: 'Centralized Monorepo Package',
summary:
'Single heavy versioned library published to private registry. Standardized tokens but painful breaking version cascades across product suites.',
keyRisk: 'High cross-team dependency coupling and blocked semver upgrades.',
color: '#f59e0b',
pros: [
'Single point of governance for security and accessibility patches',
'Familiar `npm install` workflow for junior developers',
],
cons: [
'Massive wrapper overhead and difficult Tailwind class overrides',
'Breaking changes in one component delay releases for entire company',
'Bloated runtime JS bundle containing unused component code',
],
scores: {
impact: 3.2,
confidence: 4.1,
effort: 3.8,
dx: 2.3,
maintenance: 2.6,
},
rice: {
reach: 82,
impact: 3.0,
confidence: 85,
effort: 3.6,
},
},
{
id: 'opt-c',
name: 'Option C: Custom Web Components Wrapper',
architecture: 'Custom Elements v1 & Shadow DOM',
summary:
'Framework-agnostic web components encapsulated in Shadow DOM. Cross-platform runtime interoperability, but major SSR hydration and styling friction.',
keyRisk: 'SSR rendering latency and challenging CSS theme token propagation through shadow boundaries.',
color: '#6366f1',
pros: [
'Universal encapsulation across Vue, React, Angular, and vanilla HTML',
'Strict CSS isolation prevents accidental style collisions',
],
cons: [
'Complex SSR and Declarative Shadow DOM hydration mechanics',
'Difficult theme token synchronization and slot styling gymnastics',
'Suboptimal ergonomics with React synthetic event systems',
],
scores: {
impact: 3.7,
confidence: 3.4,
effort: 2.8,
dx: 3.1,
maintenance: 3.5,
},
rice: {
reach: 90,
impact: 3.5,
confidence: 70,
effort: 3.2,
},
},
{
id: 'opt-d',
name: 'Option D: Micro-Frontend Module Federation',
architecture: 'Webpack / Vite Runtime Federation',
summary:
'Dynamic runtime module stitching over HTTP. Independent team deployments, but vulnerable to CSS leakage, network latency, and orchestration failures.',
keyRisk: 'Runtime script evaluation latency and version mismatch instability in production.',
color: '#ef4444',
pros: ['Fully decentralized team deployments without monorepo synchronization'],
cons: [
'High network overhead and cumulative layout shifts on initial load',
'Complex local development sandbox and shared singleton debugging',
'Runtime failures when remote hosts experience temporary downtime',
],
scores: {
impact: 3.0,
confidence: 2.5,
effort: 1.8,
dx: 2.6,
maintenance: 1.6,
},
rice: {
reach: 58,
impact: 3.0,
confidence: 55,
effort: 4.6,
},
},
]
export function DecisionMatrixTable({
className,
title = 'Architectural Decision Matrix (RICE / Weighted Scoring)',
subtitle = 'Evaluate technical options against weighted business and engineering criteria.',
defaultFramework = 'weighted-matrix',
...props
}: DecisionMatrixTableProps) {
const [activeFramework, setActiveFramework] = React.useState<FrameworkType>(defaultFramework)
const [showWeightEditor, setShowWeightEditor] = React.useState(false)
const [showAddOptionForm, setShowAddOptionForm] = React.useState(false)
const [selectedOptionId, setSelectedOptionId] = React.useState<string>('opt-a')
const [criterionWeights, setCriterionWeights] = React.useState<Record<string, number>>({
impact: 25,
confidence: 25,
effort: 15,
dx: 20,
maintenance: 15,
})
const [options, setOptions] = React.useState<MatrixOption[]>(initialOptions)
// Add custom option inputs
const [newOptionName, setNewOptionName] = React.useState('')
const [newOptionParadigm, setNewOptionParadigm] = React.useState('')
const [newOptionSummary, setNewOptionSummary] = React.useState('')
const [newOptionScores, setNewOptionScores] = React.useState({
impact: 4.0,
confidence: 4.0,
effort: 3.5,
dx: 4.0,
maintenance: 3.5,
})
const totalWeight = React.useMemo(() => {
return Object.values(criterionWeights).reduce((sum, w) => sum + (Number(w) || 0), 0)
}, [criterionWeights])
const resetWeights = () => {
const fresh: Record<string, number> = {}
criteriaList.forEach((c) => {
fresh[c.key] = c.defaultWeight
})
setCriterionWeights(fresh)
}
const resetOptions = () => {
setOptions(initialOptions)
setSelectedOptionId('opt-a')
}
const handleAddOption = () => {
if (!newOptionName.trim()) return
const newId = `opt-custom-${Date.now()}`
const created: MatrixOption = {
id: newId,
name: newOptionName.trim(),
architecture: newOptionParadigm.trim() || 'Custom Technical Architecture',
summary: newOptionSummary.trim() || 'Custom evaluated technical approach for this architectural decision.',
keyRisk: 'Requires comprehensive proof-of-concept testing in staging environments.',
color: '#06b6d4',
pros: ['Tailored to immediate domain constraints', 'Custom architectural design'],
cons: ['Needs dedicated long-term ownership and testing framework'],
scores: { ...newOptionScores },
rice: {
reach: 75,
impact: newOptionScores.impact,
confidence: Number((newOptionScores.confidence * 20).toFixed(0)),
effort: Number((6 - newOptionScores.effort).toFixed(1)),
},
isCustom: true,
}
setOptions((prev) => [...prev, created])
setSelectedOptionId(newId)
setNewOptionName('')
setNewOptionParadigm('')
setNewOptionSummary('')
setShowAddOptionForm(false)
}
const removeOption = (id: string) => {
setOptions((prev) => prev.filter((opt) => opt.id !== id))
if (selectedOptionId === id) {
setSelectedOptionId(options[0]?.id || '')
}
}
// Scored Options Calculation
const scoredOptions = React.useMemo(() => {
const sumW = totalWeight || 100
const calculated = options.map((opt) => {
const weightedSum =
opt.scores.impact * (criterionWeights.impact ?? 25) +
opt.scores.confidence * (criterionWeights.confidence ?? 25) +
opt.scores.effort * (criterionWeights.effort ?? 15) +
opt.scores.dx * (criterionWeights.dx ?? 20) +
opt.scores.maintenance * (criterionWeights.maintenance ?? 15)
const weightedScore = Number(((weightedSum / (5 * sumW)) * 100).toFixed(1))
const reachFactor = opt.rice.reach
const impactFactor = opt.rice.impact
const confidenceFactor = opt.rice.confidence / 100
const effortFactor = Math.max(0.5, opt.rice.effort)
const riceScore = Number(((reachFactor * impactFactor * confidenceFactor) / effortFactor).toFixed(1))
return {
...opt,
weightedScore,
riceScore,
rank: 0,
verdict: 'Discarded' as const,
verdictVariant: 'destructive' as const,
}
})
calculated.sort((a, b) => {
if (activeFramework === 'rice') {
return b.riceScore - a.riceScore
}
return b.weightedScore - a.weightedScore
})
return calculated.map((item, index) => {
const rank = index + 1
let verdict: 'Recommended Winner' | 'Second Choice' | 'Alternative' | 'Discarded' = 'Discarded'
let verdictVariant: 'success' | 'info' | 'warning' | 'destructive' = 'destructive'
if (rank === 1) {
verdict = 'Recommended Winner'
verdictVariant = 'success'
} else if (rank === 2) {
verdict = 'Second Choice'
verdictVariant = 'info'
} else if (rank === 3) {
verdict = 'Alternative'
verdictVariant = 'warning'
} else {
verdict = 'Discarded'
verdictVariant = 'destructive'
}
return {
...item,
rank,
verdict,
verdictVariant,
}
})
}, [options, totalWeight, criterionWeights, activeFramework])
const winningOption = scoredOptions[0]
const activeSelectedOption = scoredOptions.find((opt) => opt.id === selectedOptionId) ?? winningOption
// Radar chart SVG geometry
const radarCenter = { x: 170, y: 155 }
const radarRadius = 95
const radarAxesCount = 5
const getRadarPoint = (axisIndex: number, scoreValue: number, maxVal = 5) => {
const angle = -Math.PI / 2 + (axisIndex * 2 * Math.PI) / radarAxesCount
const ratio = Math.min(1, Math.max(0, scoreValue / maxVal))
const r = radarRadius * ratio
const x = radarCenter.x + r * Math.cos(angle)
const y = radarCenter.y + r * Math.sin(angle)
return { x, y }
}
const getRadarPolygon = (scores: {
impact: number
confidence: number
effort: number
dx: number
maintenance: number
}) => {
const values = [scores.impact, scores.confidence, scores.effort, scores.dx, scores.maintenance]
return values
.map((val, idx) => {
const pt = getRadarPoint(idx, val)
return `${pt.x.toFixed(1)},${pt.y.toFixed(1)}`
})
.join(' ')
}
const getRingPolygon = (ratio: number) => {
return Array.from({ length: radarAxesCount })
.map((_, idx) => {
const pt = getRadarPoint(idx, ratio * 5)
return `${pt.x.toFixed(1)},${pt.y.toFixed(1)}`
})
.join(' ')
}
const radarAxisLabels = [
{ index: 0, label: 'Impact', x: 170, y: 38, anchor: 'middle' as const },
{ index: 1, label: 'Confidence', x: 285, y: 115, anchor: 'start' as const },
{ index: 2, label: 'Simplicity', x: 245, y: 258, anchor: 'middle' as const },
{ index: 3, label: 'DX & Own', x: 95, y: 258, anchor: 'middle' as const },
{ index: 4, label: 'Low Maint', x: 55, y: 115, anchor: 'end' as const },
]
const getScoreBadgeVariant = (score: number): 'success' | 'default' | 'secondary' | 'outline' => {
if (score >= 4.5) return 'success'
if (score >= 3.5) return 'default'
if (score >= 2.5) return 'secondary'
return 'outline'
}
return (
<div
data-slot="decision-matrix-table"
className={cn('bg-background text-foreground w-full space-y-6', className)}
{...props}
>
{/* Header Card: Controls and Metadata */}
<Card className="border-border bg-card shadow-xs">
<CardHeader className="pb-4">
<div className="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
<div className="space-y-1.5">
<div className="flex flex-wrap items-center gap-2">
<Badge variant="outline" className="gap-1 text-xs font-semibold tracking-wide uppercase">
<Scale className="text-primary size-3.5" />
ADR-042 Evaluation
</Badge>
<Badge variant="secondary" className="text-xs">
Updated Aug 2026
</Badge>
</div>
<CardTitle className="text-xl font-semibold tracking-tight sm:text-2xl">{title}</CardTitle>
<CardDescription className="text-sm">{subtitle}</CardDescription>
</div>
{/* Framework Switcher & Action Toolbar */}
<div className="flex flex-wrap items-center gap-2.5">
<div className="bg-muted border-border inline-flex items-center rounded-lg border p-1">
<button
type="button"
className={cn(
'focus-visible:ring-ring rounded-md px-3 py-1.5 text-xs font-medium transition-colors focus-visible:ring-2 focus-visible:outline-none',
activeFramework === 'weighted-matrix'
? 'bg-background text-foreground font-semibold shadow-xs'
: 'text-muted-foreground hover:text-foreground',
)}
onClick={() => setActiveFramework('weighted-matrix')}
>
Weighted Value-Risk Matrix
</button>
<button
type="button"
className={cn(
'focus-visible:ring-ring rounded-md px-3 py-1.5 text-xs font-medium transition-colors focus-visible:ring-2 focus-visible:outline-none',
activeFramework === 'rice'
? 'bg-background text-foreground font-semibold shadow-xs'
: 'text-muted-foreground hover:text-foreground',
)}
onClick={() => setActiveFramework('rice')}
>
RICE Framework
</button>
</div>
{activeFramework === 'weighted-matrix' && (
<Button
variant="outline"
size="sm"
className="gap-1.5 text-xs"
onClick={() => setShowWeightEditor((v) => !v)}
>
<SlidersHorizontal className="text-muted-foreground size-3.5" />
<span>Criterion Weights</span>
<Badge
variant="secondary"
className={cn(
'ml-0.5 px-1.5 py-0 text-xs tabular-nums',
totalWeight === 100 ? 'text-foreground' : 'bg-warning/20 text-warning font-semibold',
)}
>
{totalWeight}%
</Badge>
{!showWeightEditor ? (
<ChevronDown className="text-muted-foreground size-3" />
) : (
<ChevronUp className="text-muted-foreground size-3" />
)}
</Button>
)}
<Button size="sm" className="gap-1.5 text-xs font-medium" onClick={() => setShowAddOptionForm((v) => !v)}>
<Plus className="size-3.5" />
<span>Add Option</span>
</Button>
</div>
</div>
</CardHeader>
{/* Expandable Criterion Weights Adjuster */}
{showWeightEditor && activeFramework === 'weighted-matrix' && (
<div className="border-border bg-muted/20 border-t px-6 py-5">
<div className="space-y-4">
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<div>
<h4 className="text-foreground text-sm font-semibold tracking-tight">
Calibrate Evaluation Criteria Weights
</h4>
<p className="text-muted-foreground text-xs">
Adjust criteria percentages to reflect your organization's architectural priorities.
</p>
</div>
<div className="flex items-center gap-2">
<span
className={cn(
'inline-flex items-center gap-1 rounded-md px-2 py-0.5 text-xs font-semibold tabular-nums',
totalWeight === 100 ? 'bg-success/10 text-success' : 'bg-warning/15 text-warning font-bold',
)}
>
{totalWeight === 100 ? <CheckCircle2 className="size-3" /> : <AlertCircle className="size-3" />}
Total Weight: {totalWeight}%
</span>
<Button
variant="ghost"
size="xs"
className="text-muted-foreground gap-1 text-xs"
onClick={resetWeights}
>
<RefreshCw className="size-3" />
Reset Defaults
</Button>
</div>
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-5">
{criteriaList.map((criterion) => (
<div
key={criterion.key}
className="border-border bg-card space-y-2.5 rounded-lg border p-3.5 shadow-2xs"
>
<div className="flex items-center justify-between gap-1">
<span className="text-foreground truncate text-xs font-semibold" title={criterion.label}>
{criterion.label}
</span>
<span className="bg-primary/10 text-primary rounded px-1.5 py-0.5 text-xs font-bold tabular-nums">
{criterionWeights[criterion.key] ?? criterion.defaultWeight}%
</span>
</div>
<p className="text-muted-foreground line-clamp-2 h-8 text-xs leading-tight">
{criterion.description}
</p>
<div className="pt-1">
<Slider
value={[criterionWeights[criterion.key] ?? criterion.defaultWeight]}
min={0}
max={50}
step={5}
size="small"
onValueChange={(vals) =>
setCriterionWeights((prev) => ({
...prev,
[criterion.key]: vals[0] ?? 0,
}))
}
/>
</div>
</div>
))}
</div>
</div>
</div>
)}
{/* Expandable Add Custom Proposal Form */}
{showAddOptionForm && (
<div className="border-border bg-muted/30 border-t px-6 py-5">
<div className="max-w-3xl space-y-4">
<div className="flex items-center justify-between gap-x-2">
<h4 className="text-foreground flex items-center gap-1.5 text-sm font-semibold tracking-tight">
<PlusCircle className="text-primary size-4" />
Add Technical Architecture Option to Matrix
</h4>
<Button variant="ghost" size="xs" onClick={() => setShowAddOptionForm(false)}>
Cancel
</Button>
</div>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<div className="space-y-1">
<label className="text-foreground text-xs font-medium">Option Proposal Name</label>
<Input
value={newOptionName}
onChange={(e) => setNewOptionName(e.target.value)}
placeholder="e.g. Option E: Shared UI Submodule"
size="small"
/>
</div>
<div className="space-y-1">
<label className="text-foreground text-xs font-medium">Architectural architecture</label>
<Input
value={newOptionParadigm}
onChange={(e) => setNewOptionParadigm(e.target.value)}
placeholder="e.g. Git Submodule & Monorepo Link"
size="small"
/>
</div>
</div>
<div className="space-y-1">
<label className="text-foreground text-xs font-medium">Executive Architectural Summary</label>
<Input
value={newOptionSummary}
onChange={(e) => setNewOptionSummary(e.target.value)}
placeholder="Brief summary of distribution mechanics, bundle characteristics, and DX impact..."
size="small"
/>
</div>
<div className="space-y-2">
<label className="text-foreground text-xs font-medium">Initial Scores (1.0 to 5.0)</label>
<div className="grid grid-cols-2 gap-2.5 sm:grid-cols-5">
{criteriaList.map((criterion) => (
<div key={criterion.key} className="border-border bg-card rounded-md border p-2 text-center">
<div className="text-muted-foreground truncate text-xs font-medium">{criterion.shortLabel}</div>
<div className="text-foreground mt-0.5 text-sm font-bold tabular-nums">
{newOptionScores[criterion.key]} / 5
</div>
<div className="pt-1.5">
<Slider
value={[newOptionScores[criterion.key]]}
min={1}
max={5}
step={0.5}
size="small"
onValueChange={(vals) =>
setNewOptionScores((prev) => ({
...prev,
[criterion.key]: vals[0] ?? 3,
}))
}
/>
</div>
</div>
))}
</div>
</div>
<div className="flex items-center justify-end gap-2 pt-2">
<Button variant="outline" size="sm" onClick={() => setShowAddOptionForm(false)}>
Cancel
</Button>
<Button size="sm" disabled={!newOptionName.trim()} onClick={handleAddOption}>
Add to Matrix
</Button>
</div>
</div>
</div>
)}
</Card>
{/* Main Scoring Matrix Table */}
<Card className="border-border bg-card overflow-hidden shadow-xs">
<div className="overflow-x-auto">
<Table>
<TableHeader className="bg-muted/50">
<TableRow className="hover:bg-transparent">
<TableHead className="text-muted-foreground w-[42px] px-3 text-center text-xs font-semibold">
Rank
</TableHead>
<TableHead className="text-foreground min-w-[240px] text-xs font-semibold">
Technical Option & architecture
</TableHead>
{activeFramework === 'weighted-matrix' ? (
<>
{criteriaList.map((criterion) => (
<TableHead
key={criterion.key}
className="text-foreground min-w-[130px] text-center text-xs font-semibold"
>
<div className="flex flex-col items-center">
<span>{criterion.shortLabel}</span>
<span className="text-muted-foreground text-xs font-normal tabular-nums">
{criterionWeights[criterion.key] ?? criterion.defaultWeight}% wt
</span>
</div>
</TableHead>
))}
<TableHead className="text-foreground min-w-[140px] text-center text-xs font-semibold">
Weighted Score
</TableHead>
</>
) : (
<>
<TableHead className="text-foreground min-w-[110px] text-center text-xs font-semibold">
<div className="flex flex-col items-center">
<span>Reach</span>
<span className="text-muted-foreground text-xs font-normal">Dev Scale %</span>
</div>
</TableHead>
<TableHead className="text-foreground min-w-[110px] text-center text-xs font-semibold">
<div className="flex flex-col items-center">
<span>Impact</span>
<span className="text-muted-foreground text-xs font-normal">Multiplier (1-5)</span>
</div>
</TableHead>
<TableHead className="text-foreground min-w-[110px] text-center text-xs font-semibold">
<div className="flex flex-col items-center">
<span>Confidence</span>
<span className="text-muted-foreground text-xs font-normal">% Certainty</span>
</div>
</TableHead>
<TableHead className="text-foreground min-w-[110px] text-center text-xs font-semibold">
<div className="flex flex-col items-center">
<span>Effort</span>
<span className="text-muted-foreground text-xs font-normal">Sprints (1-5)</span>
</div>
</TableHead>
<TableHead className="text-foreground min-w-[130px] text-center text-xs font-semibold">
RICE Score
</TableHead>
</>
)}
<TableHead className="text-foreground min-w-[160px] text-center text-xs font-semibold">
ADR Verdict
</TableHead>
<TableHead className="text-muted-foreground w-[80px] text-center text-xs font-semibold">
Inspect
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{scoredOptions.map((opt) => (
<TableRow
key={opt.id}
className={cn(
'cursor-pointer transition-colors duration-150',
opt.id === selectedOptionId ? 'bg-primary/5 dark:bg-primary/10' : 'hover:bg-muted/40',
opt.rank === 1 && 'font-medium',
)}
onClick={() => setSelectedOptionId(opt.id)}
>
{/* Rank Column */}
<TableCell className="px-3 text-center">
<span
className={cn(
'inline-flex size-6 items-center justify-center rounded-full text-xs font-semibold tabular-nums',
opt.rank === 1
? 'bg-success text-white shadow-xs'
: opt.rank === 2
? 'bg-info/15 text-info font-semibold'
: 'bg-muted text-muted-foreground font-normal',
)}
>
{opt.rank}
</span>
</TableCell>
{/* Proposal Name & Summary */}
<TableCell>
<div className="space-y-1 py-1">
<div className="flex flex-wrap items-center gap-2">
<span className="text-foreground text-sm font-semibold tracking-tight">{opt.name}</span>
<span
className="size-2 shrink-0 rounded-full"
style={{ backgroundColor: opt.color }}
title={opt.name}
/>
{opt.isCustom && (
<Badge variant="outline" className="text-xs">
Custom
</Badge>
)}
</div>
<div className="text-muted-foreground text-xs font-medium">{opt.architecture}</div>
<p className="text-muted-foreground/80 line-clamp-1 max-w-[220px] text-xs">{opt.summary}</p>
</div>
</TableCell>
{/* Matrix Criteria Columns */}
{activeFramework === 'weighted-matrix' ? (
<>
{criteriaList.map((criterion) => (
<TableCell key={criterion.key} className="text-center">
<div className="inline-flex flex-col items-center gap-0.5">
<Badge
variant={getScoreBadgeVariant(opt.scores[criterion.key])}
className="px-2 py-0.5 text-xs font-semibold tabular-nums"
>
{opt.scores[criterion.key].toFixed(1)}
</Badge>
<div className="mt-1 flex items-center gap-0.5">
{[1, 2, 3, 4, 5].map((seg) => (
<span
key={seg}
className={cn(
'size-1 rounded-full',
seg <= Math.round(opt.scores[criterion.key])
? 'bg-primary'
: 'bg-muted-foreground/20',
)}
/>
))}
</div>
</div>
</TableCell>
))}
{/* Weighted Score */}
<TableCell className="text-center">
<div className="inline-flex flex-col items-center gap-1">
<span
className={cn(
'text-base font-semibold tracking-tight tabular-nums',
opt.rank === 1
? 'text-success'
: opt.weightedScore >= 70
? 'text-foreground'
: 'text-muted-foreground',
)}
>
{opt.weightedScore}
<span className="text-muted-foreground text-xs font-normal">/ 100</span>
</span>
<div className="bg-muted h-1.5 w-20 overflow-hidden rounded-full">
<div
className={cn(
'h-full rounded-full transition-[width,background-color] duration-300',
opt.rank === 1
? 'bg-success'
: opt.weightedScore >= 70
? 'bg-info'
: opt.weightedScore >= 50
? 'bg-warning'
: 'bg-destructive',
)}
style={{ width: `${opt.weightedScore}%` }}
/>
</div>
</div>
</TableCell>
</>
) : (
<>
<TableCell className="text-center text-xs font-semibold tabular-nums">
{opt.rice.reach}%
</TableCell>
<TableCell className="text-center text-xs font-semibold tabular-nums">
{opt.rice.impact.toFixed(1)}x
</TableCell>
<TableCell className="text-center text-xs font-semibold tabular-nums">
{opt.rice.confidence}%
</TableCell>
<TableCell className="text-center text-xs font-semibold tabular-nums">
{opt.rice.effort.toFixed(1)} sprints
</TableCell>
<TableCell className="text-center">
<div className="inline-flex flex-col items-center">
<span className="text-primary text-base font-semibold tabular-nums">{opt.riceScore}</span>
<span className="text-muted-foreground text-xs font-medium">RICE Pts</span>
</div>
</TableCell>
</>
)}
{/* Verdict Column */}
<TableCell className="text-center">
{opt.rank === 1 ? (
<span className="border-success/30 bg-success/10 text-success inline-flex items-center gap-1.5 rounded-full border px-2.5 py-1 text-xs font-semibold">
<Award className="size-3.5" />
Recommended Winner
</span>
) : opt.rank === 2 ? (
<span className="border-info/30 bg-info/10 text-info inline-flex items-center gap-1 rounded-full border px-2.5 py-1 text-xs font-semibold">
<CheckCircle2 className="size-3.5" />
Second Choice
</span>
) : opt.rank === 3 ? (
<span className="border-border bg-muted/60 text-muted-foreground inline-flex items-center gap-1 rounded-full border px-2.5 py-1 text-xs font-medium">
<HelpCircle className="size-3.5" />
Alternative
</span>
) : (
<span className="border-destructive/20 bg-destructive/10 text-destructive inline-flex items-center gap-1 rounded-full border px-2.5 py-1 text-xs font-medium">
<XCircle className="size-3.5" />
Discarded
</span>
)}
</TableCell>
{/* Action / Inspect */}
<TableCell className="text-center" onClick={(e) => e.stopPropagation()}>
{opt.isCustom ? (
<Button
variant="ghost"
size="icon-sm"
className="text-muted-foreground hover:text-destructive"
title="Remove Custom Option"
onClick={() => removeOption(opt.id)}
>
<Trash2 className="size-3.5" />
</Button>
) : (
<Button
variant="ghost"
size="icon-sm"
className="text-muted-foreground hover:text-foreground"
title={`Inspect ${opt.name}`}
onClick={() => setSelectedOptionId(opt.id)}
>
<Info className="size-3.5" />
</Button>
)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
<CardFooter className="border-border bg-muted/20 text-muted-foreground flex flex-wrap items-center justify-between gap-3 border-t px-6 py-3 text-xs">
<div className="flex items-center gap-2">
<span className="bg-success inline-block size-2 rounded-full" />
<span>Scores normalize dynamically to a 0–100 scale based on active criteria weights.</span>
</div>
<div className="flex items-center gap-3">
<Button variant="ghost" size="xs" className="text-muted-foreground gap-1 text-xs" onClick={resetOptions}>
<RefreshCw className="size-3" />
Reset Matrix Options
</Button>
</div>
</CardFooter>
</Card>
{/* Tradeoff Visualizer & ADR Recommendation Section */}
<div className="grid grid-cols-1 gap-6 lg:grid-cols-12">
{/* Radar Chart: Multi-Option Criteria Comparison */}
<Card className="border-border bg-card flex flex-col shadow-xs lg:col-span-6">
<CardHeader className="pb-2">
<div className="flex items-center justify-between gap-x-2">
<div className="space-y-0.5">
<CardTitle className="flex items-center gap-2 text-base font-semibold tracking-tight">
<BarChart3 className="text-primary size-4" />
Decision Tradeoff Radar
</CardTitle>
<CardDescription className="text-xs">
Multi-dimensional polygon overlay comparing architectural options.
</CardDescription>
</div>
<Badge variant="outline" className="text-xs font-normal">
5 Axes (1-5)
</Badge>
</div>
</CardHeader>
<CardContent className="flex flex-1 flex-col items-center justify-center p-4">
{/* SVG Radar Canvas */}
<div className="relative flex aspect-square w-full max-w-[340px] items-center justify-center">
<svg viewBox="0 0 340 310" className="h-full w-full overflow-visible">
{/* Background Concentric Grid Rings */}
{[0.2, 0.4, 0.6, 0.8, 1.0].map((ring) => (
<polygon
key={ring}
points={getRingPolygon(ring)}
fill="none"
stroke="currentColor"
strokeWidth="1"
className="text-border/50"
strokeDasharray={ring === 1.0 ? 'none' : '2,2'}
/>
))}
{/* Radial Axes Lines */}
{Array.from({ length: radarAxesCount }).map((_, idx) => (
<line
key={idx}
x1={radarCenter.x}
y1={radarCenter.y}
x2={getRadarPoint(idx, 5).x}
y2={getRadarPoint(idx, 5).y}
stroke="currentColor"
strokeWidth="1"
className="text-border"
/>
))}
{/* Polygons for non-selected options */}
{scoredOptions.map(
(opt) =>
opt.id !== activeSelectedOption.id && (
<polygon
key={opt.id}
points={getRadarPolygon(opt.scores)}
fill={opt.color}
fillOpacity="0.08"
stroke={opt.color}
strokeWidth="1.5"
strokeOpacity="0.45"
className="cursor-pointer transition-colors duration-300"
onClick={() => setSelectedOptionId(opt.id)}
/>
),
)}
{/* Polygon for Selected Option */}
<polygon
points={getRadarPolygon(activeSelectedOption.scores)}
fill={activeSelectedOption.color}
fillOpacity="0.25"
stroke={activeSelectedOption.color}
strokeWidth="2.5"
className="transition-colors duration-300"
/>
{/* Vertex Dots for Selected Option */}
{[
activeSelectedOption.scores.impact,
activeSelectedOption.scores.confidence,
activeSelectedOption.scores.effort,
activeSelectedOption.scores.dx,
activeSelectedOption.scores.maintenance,
].map((val, idx) => (
<circle
key={idx}
cx={getRadarPoint(idx, val).x}
cy={getRadarPoint(idx, val).y}
r="4"
fill={activeSelectedOption.color}
className="stroke-background stroke-2 transition-colors duration-300"
/>
))}
{/* Axis Labels */}
{radarAxisLabels.map((axis) => (
<text
key={axis.index}
x={axis.x}
y={axis.y}
textAnchor={axis.anchor}
className="fill-foreground text-xs font-semibold select-none"
style={{ fontSize: 11 }}
>
{axis.label}
</text>
))}
</svg>
</div>
{/* Radar Chart Legend */}
<div className="border-border flex w-full flex-wrap items-center justify-center gap-3 border-t pt-3">
{scoredOptions.map((opt) => (
<button
key={opt.id}
type="button"
className={cn(
'inline-flex items-center gap-1.5 rounded-md px-2 py-1 text-xs transition-colors',
opt.id === activeSelectedOption.id
? 'bg-muted text-foreground ring-border font-semibold ring-1'
: 'text-muted-foreground hover:text-foreground opacity-80',
)}
onClick={() => setSelectedOptionId(opt.id)}
>
<span className="size-2.5 shrink-0 rounded-full" style={{ backgroundColor: opt.color }} />
<span className="max-w-[120px] truncate">{opt.name.split(':')[0]}</span>
</button>
))}
</div>
</CardContent>
</Card>
{/* Architectural Consensus & Tradeoff Detail Card */}
<Card className="border-border bg-card flex flex-col justify-between shadow-xs lg:col-span-6">
<CardHeader className="pb-3">
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="min-w-0 space-y-1">
<div className="flex flex-wrap items-center gap-2">
<Badge variant="outline" className="text-xs">
Selected Option Details
</Badge>
<Badge
variant={activeSelectedOption.verdictVariant === 'success' ? 'success' : 'secondary'}
className="text-xs font-semibold"
>
{activeSelectedOption.verdict}
</Badge>
</div>
<CardTitle className="text-foreground text-lg font-semibold tracking-tight">
{activeSelectedOption.name}
</CardTitle>
<CardDescription className="text-primary text-xs font-medium">
{activeSelectedOption.architecture}
</CardDescription>
</div>
<div className="shrink-0 text-right">
<div className="text-foreground text-2xl font-semibold tabular-nums">
{activeFramework === 'rice' ? activeSelectedOption.riceScore : activeSelectedOption.weightedScore}
</div>
<div className="text-muted-foreground text-xs">
{activeFramework === 'rice' ? 'RICE Score' : 'Weighted Total'}
</div>
</div>
</div>
</CardHeader>
<CardContent className="space-y-4 text-xs">
{/* Summary paragraph */}
<p className="text-muted-foreground text-sm leading-relaxed">{activeSelectedOption.summary}</p>
<Separator />
{/* Pros & Cons Grid */}
<div className="grid grid-cols-1 gap-3.5 sm:grid-cols-2">
{/* Advantages */}
<div className="border-success/20 bg-success/5 space-y-2 rounded-lg border p-3">
<span className="text-success flex items-center gap-1.5 text-xs font-semibold">
<Check className="size-3.5" />
Strategic Advantages
</span>
<ul className="text-muted-foreground space-y-1.5">
{activeSelectedOption.pros.map((pro, idx) => (
<li key={idx} className="flex items-start gap-1.5">
<span className="text-success mt-0.5">•</span>
<span>{pro}</span>
</li>
))}
</ul>
</div>
{/* Friction & Tradeoffs */}
<div className="border-destructive/20 bg-destructive/5 space-y-2 rounded-lg border p-3">
<span className="text-destructive flex items-center gap-1.5 text-xs font-semibold">
<AlertCircle className="size-3.5" />
Compromises & Friction
</span>
<ul className="text-muted-foreground space-y-1.5">
{activeSelectedOption.cons.map((con, idx) => (
<li key={idx} className="flex items-start gap-1.5">
<span className="text-destructive mt-0.5">•</span>
<span>{con}</span>
</li>
))}
</ul>
</div>
</div>
{/* Risk Mitigation */}
<div className="border-border bg-muted/40 space-y-1 rounded-md border p-3">
<span className="text-foreground flex items-center gap-1.5 font-semibold">
<ShieldCheck className="text-primary size-3.5" />
Risk Mitigation & Governance Policy:
</span>
<p className="text-muted-foreground">{activeSelectedOption.keyRisk}</p>
</div>
</CardContent>
<CardFooter className="border-border bg-muted/20 flex items-center justify-between gap-x-2 border-t px-6 py-3">
<div className="flex items-center gap-2">
<FileCode2 className="text-muted-foreground size-4" />
<span className="text-muted-foreground text-xs font-medium">ADR Record Consensus: </span>
<span className="text-foreground text-xs font-semibold">
{winningOption.name.split(':')[1] || winningOption.name}
</span>
</div>
<Badge variant="outline" className="gap-1 text-xs">
<Award className="text-success size-3" />
Rank #{activeSelectedOption.rank}
</Badge>
</CardFooter>
</Card>
</div>
</div>
)
}
Raw manifest:https://uipkge.dev/r/react/decision-matrix-table.json