Skip to content

Commit a9bf8da

Browse files
Add visual decision-tree diagram in a modal on Landing and Results
- New Modal component: portal-based, Escape/backdrop-click to close, scroll lock with restore, focus-on-open - New DecisionTreeDiagram component: hand-rolled SVG showing the gateway decision tree, escalation factors, and governance scoring bands. Static mode on the Landing page (educational); dynamic mode on Results that highlights the exact path taken for the assessed API, derived entirely from assess()'s existing structured fields (no engine.js changes needed) - Add tree icon; hoist tier colors into shared --tier-1/2/3 CSS variables - Wire trigger buttons into Landing hero and Results actions row Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
1 parent 9302a8e commit a9bf8da

6 files changed

Lines changed: 369 additions & 5 deletions

File tree

Lines changed: 226 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,226 @@
1+
import { GATEWAY_TIERS, GOVERNANCE_TIERS } from '../data/framework.js'
2+
3+
// Layout constants for the 940x560 canvas.
4+
const G0 = { x: 60, y: 30, w: 220, h: 46 }
5+
const G1 = { x: 60, y: 110, w: 220, h: 46 }
6+
const TERMINALS = {
7+
app: { x: 60, y: 210, w: 150, h: 60, label: 'App' },
8+
platform: { x: 250, y: 210, w: 150, h: 60, label: 'Platform' },
9+
enterprise: { x: 440, y: 210, w: 150, h: 60, label: 'Enterprise' },
10+
}
11+
const ESCALATIONS = {
12+
regulated: { x: 680, y: 30, w: 220, h: 46, label: 'Regulated data + exposure ≥ 2', target: 'enterprise', channelY: 284 },
13+
monetized: { x: 680, y: 90, w: 220, h: 46, label: 'Commercial model ≥ 3 (monetized)', target: 'enterprise', channelY: 290 },
14+
blastRadius: { x: 680, y: 150, w: 220, h: 46, label: 'Blast radius ≥ 3', target: 'platform', channelY: 296 },
15+
}
16+
const METER = { x: 60, y: 300, w: 800, h: 24 }
17+
const BANDS = [
18+
{ key: 'light', frac: 5 / 19, range: '6–10', label: 'Tier 3 – Light' },
19+
{ key: 'managed', frac: 6 / 19, range: '11–16', label: 'Tier 2 – Managed' },
20+
{ key: 'full', frac: 8 / 19, range: '17–24', label: 'Tier 1 – Full' },
21+
]
22+
const GOV_BOX_Y = 350
23+
const GOV_BOX_H = 70
24+
25+
function bandLayout() {
26+
let x = METER.x
27+
return BANDS.map((b) => {
28+
const w = METER.w * b.frac
29+
const box = { ...b, x, w }
30+
x += w
31+
return box
32+
})
33+
}
34+
35+
const cx = (n) => n.x + n.w / 2
36+
const cy = (n) => n.y + n.h / 2
37+
const bottomMid = (n) => [cx(n), n.y + n.h]
38+
39+
function DecisionNode({ node, active, lines }) {
40+
return (
41+
<g>
42+
<rect className={`diagram-node${active ? ' active' : ''}`} x={node.x} y={node.y} width={node.w} height={node.h} rx={8} />
43+
<text x={cx(node)} y={cy(node) + (lines.length > 1 ? -4 : 4)} textAnchor="middle" fontSize="12" fontWeight={active ? 700 : 500}>
44+
{lines.map((line, i) => (
45+
<tspan key={i} x={cx(node)} dy={i === 0 ? 0 : 14}>{line}</tspan>
46+
))}
47+
</text>
48+
</g>
49+
)
50+
}
51+
52+
function TerminalBox({ node, tierKey, active, title }) {
53+
return (
54+
<g>
55+
<rect
56+
className={`diagram-node terminal${active ? ` active-${tierKey}` : ''}`}
57+
x={node.x} y={node.y} width={node.w} height={node.h} rx={8}
58+
>
59+
<title>{title}</title>
60+
</rect>
61+
<text x={cx(node)} y={cy(node) + 5} textAnchor="middle" fontSize="13">{node.label}</text>
62+
</g>
63+
)
64+
}
65+
66+
function EscalationBox({ node, active }) {
67+
return (
68+
<g>
69+
<rect className={`diagram-node${active ? ' active' : ''}`} x={node.x} y={node.y} width={node.w} height={node.h} rx={8} />
70+
{splitLabel(node.label).map((line, i) => (
71+
<text key={i} x={cx(node)} y={cy(node) - 4 + i * 13} textAnchor="middle" fontSize="10.5" className={active ? '' : 'diagram-label-muted'}>
72+
{line}
73+
</text>
74+
))}
75+
{active && (
76+
<g transform={`translate(${node.x + node.w - 16}, ${node.y - 4})`}>
77+
<circle className="diagram-badge" r={8} />
78+
<text className="diagram-badge-text" textAnchor="middle" y={3}></text>
79+
</g>
80+
)}
81+
</g>
82+
)
83+
}
84+
85+
function splitLabel(label) {
86+
if (label.length <= 26) return [label]
87+
const mid = label.lastIndexOf(' ', 26) === -1 ? label.indexOf(' ') : label.lastIndexOf(' ', 26)
88+
return [label.slice(0, mid), label.slice(mid + 1)]
89+
}
90+
91+
export default function DecisionTreeDiagram({ result }) {
92+
const dynamic = !!result
93+
const scores = result?.scores
94+
const gatewayKey = result?.gatewayKey
95+
const governanceKey = result?.governanceKey
96+
const totalScore = result?.totalScore
97+
98+
const step1Taken = dynamic && scores.exposure >= 3
99+
const step2Taken = dynamic && !step1Taken && (scores.exposure === 2 || scores.consumerCount >= 2)
100+
const step3Taken = dynamic && !step1Taken && !step2Taken
101+
102+
const escalationActive = {
103+
regulated: dynamic && scores.dataSensitivity === 4 && scores.exposure >= 2,
104+
monetized: dynamic && scores.commercialModel >= 3,
105+
blastRadius: dynamic && scores.blastRadius >= 3,
106+
}
107+
108+
const antiOverrideActive = dynamic && totalScore <= 10 && scores.dataSensitivity === 4 && governanceKey === 'full'
109+
110+
const bands = bandLayout()
111+
const pointerX = dynamic
112+
? Math.min(METER.x + METER.w, Math.max(METER.x, METER.x + ((totalScore - 6) / 18) * METER.w))
113+
: null
114+
115+
const [g0BotX, g0BotY] = bottomMid(G0)
116+
const [g1BotX, g1BotY] = bottomMid(G1)
117+
const entC = { x: cx(TERMINALS.enterprise), top: TERMINALS.enterprise.y }
118+
const platC = { x: cx(TERMINALS.platform), top: TERMINALS.platform.y }
119+
const appC = { x: cx(TERMINALS.app), top: TERMINALS.app.y }
120+
121+
return (
122+
<div>
123+
<div className="diagram-legend">
124+
{dynamic ? (
125+
<>
126+
<span className="diagram-legend-item"><span className="diagram-legend-swatch" style={{ background: 'var(--brand)' }} /> Path taken</span>
127+
<span className="diagram-legend-item"><span className="diagram-legend-swatch" style={{ background: 'var(--line)' }} /> Not taken</span>
128+
<span className="diagram-legend-item"><span className="diagram-legend-swatch" style={{ background: '#c0392b' }} /> Escalation fired</span>
129+
</>
130+
) : (
131+
<>
132+
<span className="diagram-legend-item"><span className="diagram-legend-swatch" style={{ background: 'var(--line)' }} /> Decision step</span>
133+
<span className="diagram-legend-item"><span className="diagram-legend-swatch" style={{ background: 'var(--ink-soft)' }} /> Escalation factor (can raise the tier)</span>
134+
<span className="diagram-legend-item"><span className="diagram-legend-swatch" style={{ background: 'var(--tier-3)' }} /> Terminal tier</span>
135+
</>
136+
)}
137+
</div>
138+
139+
<svg className="diagram-svg" viewBox="0 0 940 560">
140+
{/* Gateway decision flow */}
141+
<path className={`diagram-edge${step2Taken || step3Taken ? ' active' : ''}`} d={`M${g0BotX},${g0BotY} L${g1BotX},${G1.y}`} />
142+
<path className={`diagram-edge${step1Taken ? ' active' : ''}`} d={`M${G0.x + G0.w},${cy(G0)} L${entC.x},${cy(G0)} L${entC.x},${entC.top}`} />
143+
<path className={`diagram-edge${step2Taken ? ' active' : ''}`} d={`M${G1.x + G1.w},${cy(G1)} L${platC.x},${cy(G1)} L${platC.x},${platC.top}`} />
144+
<path className={`diagram-edge${step3Taken ? ' active' : ''}`} d={`M${g1BotX},${g1BotY} L${appC.x},${g1BotY} L${appC.x},${appC.top}`} />
145+
146+
<text x={G0.x + G0.w + 14} y={cy(G0) - 8} fontSize="10.5" className="diagram-label-muted">Yes</text>
147+
<text x={g0BotX + 8} y={(g0BotY + G1.y) / 2 + 4} fontSize="10.5" className="diagram-label-muted">No</text>
148+
<text x={G1.x + G1.w + 14} y={cy(G1) - 8} fontSize="10.5" className="diagram-label-muted">Yes</text>
149+
<text x={appC.x + 8} y={g1BotY + 12} fontSize="10.5" className="diagram-label-muted">No</text>
150+
151+
<DecisionNode node={G0} active={dynamic} lines={['Exposure ≥ 3', '(Partner / Public)?']} />
152+
<DecisionNode node={G1} active={dynamic && !step1Taken} lines={['Exposure = 2 or', '≥2 team consumers?']} />
153+
154+
<TerminalBox node={TERMINALS.app} tierKey="app" active={gatewayKey === 'app'} title={GATEWAY_TIERS.app.label} />
155+
<TerminalBox node={TERMINALS.platform} tierKey="platform" active={gatewayKey === 'platform'} title={GATEWAY_TIERS.platform.label} />
156+
<TerminalBox node={TERMINALS.enterprise} tierKey="enterprise" active={gatewayKey === 'enterprise'} title={GATEWAY_TIERS.enterprise.label} />
157+
158+
{/* Escalation factors */}
159+
{Object.entries(ESCALATIONS).map(([key, esc]) => {
160+
const target = TERMINALS[esc.target]
161+
const [botX, botY] = bottomMid(esc)
162+
const targetX = cx(target)
163+
return (
164+
<path
165+
key={key}
166+
className={`diagram-edge escalation${escalationActive[key] ? ' active' : ''}`}
167+
d={`M${botX},${botY} L${botX},${esc.channelY} L${targetX},${esc.channelY} L${targetX},${target.y + target.h}`}
168+
/>
169+
)
170+
})}
171+
<EscalationBox node={ESCALATIONS.regulated} active={escalationActive.regulated} />
172+
<EscalationBox node={ESCALATIONS.monetized} active={escalationActive.monetized} />
173+
<EscalationBox node={ESCALATIONS.blastRadius} active={escalationActive.blastRadius} />
174+
175+
{/* Anti-pattern override: regulated data forcing Tier 1 governance */}
176+
{(!dynamic || antiOverrideActive) && (() => {
177+
const full = bands.find((b) => b.key === 'full')
178+
const fullBoxCx = full.x + full.w / 2
179+
const [botX, botY] = bottomMid(ESCALATIONS.regulated)
180+
return (
181+
<path
182+
className={`diagram-edge escalation${antiOverrideActive ? ' active' : ''}`}
183+
d={`M${botX},${botY} L910,${botY} L910,330 L${fullBoxCx},330 L${fullBoxCx},${GOV_BOX_Y}`}
184+
/>
185+
)
186+
})()}
187+
188+
{/* Governance scoring flow */}
189+
{bands.map((b) => (
190+
<rect
191+
key={b.key}
192+
className="diagram-meter-segment"
193+
x={b.x} y={METER.y} width={b.w} height={METER.h}
194+
fill={b.key === 'light' ? 'var(--tier-1)' : b.key === 'managed' ? 'var(--tier-2)' : 'var(--tier-3)'}
195+
opacity={dynamic && governanceKey !== b.key ? 0.35 : 1}
196+
/>
197+
))}
198+
{bands.map((b) => (
199+
<text key={b.key} x={b.x + b.w / 2} y={METER.y + METER.h / 2 + 4} textAnchor="middle" fontSize="11" fill="#fff" fontWeight="700">
200+
{b.range}
201+
</text>
202+
))}
203+
{dynamic && pointerX !== null && (
204+
<polygon className="diagram-meter-pointer" points={`${pointerX - 7},${METER.y - 12} ${pointerX + 7},${METER.y - 12} ${pointerX},${METER.y - 1}`} />
205+
)}
206+
{dynamic && pointerX !== null && (
207+
<text x={pointerX} y={METER.y - 16} textAnchor="middle" fontSize="11" fontWeight="700">{totalScore}</text>
208+
)}
209+
210+
{bands.map((b) => (
211+
<g key={b.key}>
212+
<rect
213+
className={`diagram-node terminal${governanceKey === b.key ? ` active-${b.key === 'full' ? 'full' : b.key}` : ''}`}
214+
x={b.x} y={GOV_BOX_Y} width={b.w} height={GOV_BOX_H} rx={8}
215+
>
216+
<title>{GOVERNANCE_TIERS[b.key].label}</title>
217+
</rect>
218+
<text x={b.x + b.w / 2} y={GOV_BOX_Y + GOV_BOX_H / 2 + 5} textAnchor="middle" fontSize="13">
219+
{b.label}
220+
</text>
221+
</g>
222+
))}
223+
</svg>
224+
</div>
225+
)
226+
}

src/components/Landing.jsx

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,17 @@
1+
import { useState } from 'react'
12
import {
23
AXES, ROLES, EXPOSURE_ROLES, GATEWAY_TIERS, GOVERNANCE_TIERS, PRINCIPLES,
34
} from '../data/framework.js'
45
import Icon from './icons.jsx'
6+
import Modal from './Modal.jsx'
7+
import DecisionTreeDiagram from './DecisionTreeDiagram.jsx'
58

69
const TIER_ORDER = ['light', 'managed', 'full']
710
const GATEWAY_ORDER = ['app', 'platform', 'enterprise']
811

912
export default function Landing({ onStart }) {
13+
const [diagramOpen, setDiagramOpen] = useState(false)
14+
1015
return (
1116
<main className="landing">
1217
{/* Hero — copy left, stats right */}
@@ -20,6 +25,9 @@ export default function Landing({ onStart }) {
2025
set of recommendations, with framework guidance at every step.
2126
</p>
2227
<button className="btn primary hero-cta" onClick={onStart}>Start the wizard →</button>
28+
<button className="btn ghost diagram-trigger" onClick={() => setDiagramOpen(true)}>
29+
<Icon name="tree" /> View decision tree diagram
30+
</button>
2331
</div>
2432
<div className="hero-stats">
2533
<div className="stat"><span className="stat-num">7</span><span className="stat-label">classification axes</span></div>
@@ -175,6 +183,10 @@ export default function Landing({ onStart }) {
175183
<p>10–12 guided questions, about 5 minutes, full guidance at every step.</p>
176184
<button className="btn primary hero-cta" onClick={onStart}>Start the wizard →</button>
177185
</section>
186+
187+
<Modal open={diagramOpen} onClose={() => setDiagramOpen(false)} title="How the decision tree works">
188+
<DecisionTreeDiagram />
189+
</Modal>
178190
</main>
179191
)
180192
}

src/components/Modal.jsx

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
import { useEffect, useRef } from 'react'
2+
import { createPortal } from 'react-dom'
3+
4+
export default function Modal({ open, onClose, title, children }) {
5+
const panelRef = useRef(null)
6+
7+
useEffect(() => {
8+
if (!open) return
9+
const previousOverflow = document.body.style.overflow
10+
document.body.style.overflow = 'hidden'
11+
panelRef.current?.focus()
12+
13+
const onKeyDown = (e) => {
14+
if (e.key === 'Escape') onClose()
15+
}
16+
document.addEventListener('keydown', onKeyDown)
17+
18+
return () => {
19+
document.body.style.overflow = previousOverflow
20+
document.removeEventListener('keydown', onKeyDown)
21+
}
22+
}, [open, onClose])
23+
24+
if (!open) return null
25+
26+
return createPortal(
27+
<div
28+
className="modal-overlay"
29+
onClick={(e) => { if (e.target === e.currentTarget) onClose() }}
30+
>
31+
<div className="modal-panel" ref={panelRef} tabIndex={-1} role="dialog" aria-modal="true" aria-label={title}>
32+
<div className="modal-header">
33+
<h3>{title}</h3>
34+
<button className="modal-close" onClick={onClose} aria-label="Close">×</button>
35+
</div>
36+
{children}
37+
</div>
38+
</div>,
39+
document.body
40+
)
41+
}

src/components/Results.jsx

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,16 @@
1+
import { useState } from 'react'
12
import {
23
SCORE_FACTORS, GOVERNANCE_TIERS, DATA_AUTHORITY_LABELS, PROTOCOL_LABELS, PRINCIPLES,
34
} from '../data/framework.js'
5+
import Icon from './icons.jsx'
6+
import Modal from './Modal.jsx'
7+
import DecisionTreeDiagram from './DecisionTreeDiagram.jsx'
48

5-
const TIER_COLORS = { light: '#8fb0dd', managed: '#4f7fc9', full: '#1d4ed8' }
6-
const GATEWAY_COLORS = { app: '#8fb0dd', platform: '#4f7fc9', enterprise: '#1d4ed8' }
9+
const TIER_COLORS = { light: 'var(--tier-1)', managed: 'var(--tier-2)', full: 'var(--tier-3)' }
10+
const GATEWAY_COLORS = { app: 'var(--tier-1)', platform: 'var(--tier-2)', enterprise: 'var(--tier-3)' }
711

812
export default function Results({ answers, result, onRestart, onEdit }) {
13+
const [diagramOpen, setDiagramOpen] = useState(false)
914
const {
1015
scores, totalScore, governance, governanceKey, governanceAdjustments,
1116
gateway, gatewayKey, gatewayReason, escalations, antiPatterns,
@@ -23,12 +28,17 @@ export default function Results({ answers, result, onRestart, onEdit }) {
2328
{answers.apiDescription && <p className="results-desc">{answers.apiDescription}</p>}
2429
</div>
2530
<div className="results-actions">
31+
<button className="btn ghost" onClick={() => setDiagramOpen(true)}><Icon name="tree" /> View decision path</button>
2632
<button className="btn ghost" onClick={onEdit}>← Edit answers</button>
2733
<button className="btn ghost" onClick={() => window.print()}>Print / PDF</button>
2834
<button className="btn primary" onClick={onRestart}>Assess another API</button>
2935
</div>
3036
</div>
3137

38+
<Modal open={diagramOpen} onClose={() => setDiagramOpen(false)} title={`Decision path — ${name}`}>
39+
<DecisionTreeDiagram result={result} />
40+
</Modal>
41+
3242
{/* Verdict cards */}
3343
<div className="verdict-grid">
3444
<div className="verdict-card" style={{ borderTopColor: TIER_COLORS[governanceKey] }}>

src/components/icons.jsx

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,16 @@ const PATHS = {
5353
<path d="M12 2a7 7 0 0 0-4 12.7c.6.5 1 1.4 1 2.3h6c0-.9.4-1.8 1-2.3A7 7 0 0 0 12 2z" />
5454
</>
5555
),
56+
tree: (
57+
<>
58+
<circle cx="12" cy="4" r="2.5" />
59+
<line x1="12" y1="6.5" x2="12" y2="10" />
60+
<line x1="12" y1="10" x2="6" y2="15" />
61+
<line x1="12" y1="10" x2="18" y2="15" />
62+
<circle cx="6" cy="17.5" r="2.5" />
63+
<circle cx="18" cy="17.5" r="2.5" />
64+
</>
65+
),
5666
}
5767

5868
export default function Icon({ name, size = 18, strokeWidth = 2, className = '' }) {

0 commit comments

Comments
 (0)