Make the bridge readable in dark mode, and movable

The chart's text and rules were hex literals, so in dark mode the value
labels stayed dark grey on the dark panel. They now follow the theme.

The panel drags by its header and is no longer modal: the backdrop passes
clicks through, so the pivot stays usable while the bridge is open, and
Escape closes it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Paul Trowbridge 2026-09-25 01:30:14 -04:00
parent 093b1473ab
commit f9e989f20e
2 changed files with 65 additions and 14 deletions

View File

@ -15,9 +15,15 @@ import { useState, useEffect, useRef, useCallback } from 'react'
const UP = '#2a78d6' // increase
const DOWN = '#e34948' // decrease
const ANCHOR = '#6b7280' // baseline / current — neutral, 4.83:1 on white
const GRID = '#e5e7eb'
const INK = '#374151'
const INK_DIM = '#6b7280'
// Everything that is not a bar follows the theme. These were hex literals, so in
// dark mode the value labels stayed #374151 on a #2a2c2f panel and could not be
// read. Applied through `style` rather than the presentation attribute, where
// var() is not reliable across browsers.
const GRID = 'var(--border-color)'
const AXIS = 'var(--chart-axis)'
const INK = 'var(--text-secondary)'
const INK_DIM = 'var(--chart-ink-dim)'
const PANEL = 'var(--bg-secondary)' // the bars' separating stroke
const fmt = (n, dp = 2) =>
n == null || !isFinite(n) ? '—'
@ -294,6 +300,10 @@ export default function BridgeView({
const [hover, setHover] = useState(null)
const [width, setWidth] = useState(880)
const boxRef = useRef(null)
// Where the panel has been dragged to, as an offset from centre. It opens
// centred each time rather than wherever it was last left.
const [pos, setPos] = useState({ x: 0, y: 0 })
const dragRef = useRef(null)
// Build the steps: baseline anchor, one floating step per tag, current anchor.
const compute = useCallback(async () => {
@ -405,6 +415,36 @@ export default function BridgeView({
useEffect(() => { try { localStorage.setItem('pf_bridge_basis', basis) } catch {} }, [basis])
useEffect(() => { if (open) compute() }, [open, compute])
useEffect(() => { if (open) setPos({ x: 0, y: 0 }) }, [open])
// Escape closes it. There is no backdrop to click any more -- see below.
useEffect(() => {
if (!open) return
const onKey = (e) => { if (e.key === 'Escape') onClose() }
window.addEventListener('keydown', onKey)
return () => window.removeEventListener('keydown', onKey)
}, [open, onClose])
// Drag by the header. Pointer capture keeps the drag alive when the cursor
// outruns the panel; the close button is excluded so clicking it still closes.
const startDrag = (e) => {
if (e.button !== 0 || e.target.closest('button')) return
e.currentTarget.setPointerCapture(e.pointerId)
dragRef.current = { px: e.clientX, py: e.clientY, x: pos.x, y: pos.y }
}
const moveDrag = (e) => {
const d = dragRef.current
if (!d) return
// keep the header on screen, or there is nothing left to drag it back by
const maxX = window.innerWidth / 2 - 40
const maxY = window.innerHeight / 2 - 20
setPos({
x: Math.max(-maxX, Math.min(maxX, d.x + e.clientX - d.px)),
y: Math.max(-maxY, Math.min(maxY, d.y + e.clientY - d.py)),
})
}
const endDrag = () => { dragRef.current = null }
useEffect(() => {
if (!open || !boxRef.current) return
const ro = new ResizeObserver(([e]) => setWidth(Math.max(420, e.contentRect.width)))
@ -421,11 +461,18 @@ export default function BridgeView({
const ticks = niceTicks(yMin, yMax, 5)
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40" onClick={onClose}>
<div className="bg-white rounded-lg shadow-xl w-full max-w-5xl mx-4 flex flex-col max-h-[88vh]"
onClick={e => e.stopPropagation()}>
// Not modal. The overlay passes clicks through, so the pivot stays usable
// with the bridge open -- moving it aside to read the grid under it, or
// changing the selection it is scoped to, which recomputes it. It used to
// be a dimmed backdrop that closed on click, which made the panel immovable
// and the pivot unreachable until it was dismissed.
<div className="fixed inset-0 z-50 flex items-center justify-center pointer-events-none">
<div className="pointer-events-auto bg-white rounded-lg shadow-2xl border border-gray-200 w-full max-w-5xl mx-4 flex flex-col max-h-[88vh]"
style={{ transform: `translate(${pos.x}px, ${pos.y}px)` }}>
<div className="flex items-center justify-between px-5 py-3 border-b border-gray-200 shrink-0">
<div className="flex items-center justify-between px-5 py-3 border-b border-gray-200 shrink-0 cursor-move select-none touch-none"
onPointerDown={startDrag} onPointerMove={moveDrag}
onPointerUp={endDrag} onPointerCancel={endDrag}>
<div className="flex items-baseline gap-2">
<span className="font-medium text-gray-700 text-sm">Bridge</span>
{versionName && <span className="text-gray-600 text-xs">{versionName}</span>}
@ -512,8 +559,8 @@ export default function BridgeView({
{ticks.map(t => (
<g key={t}>
<line x1={PAD.l} x2={PAD.l + plotW} y1={y(t)} y2={y(t)}
stroke={t === 0 ? '#d1d5db' : GRID} strokeWidth={t === 0 ? 1.5 : 1} />
<text x={PAD.l - 8} y={y(t) + 3} textAnchor="end" fontSize="10" fill={INK_DIM}>
style={{ stroke: t === 0 ? AXIS : GRID }} strokeWidth={t === 0 ? 1.5 : 1} />
<text x={PAD.l - 8} y={y(t) + 3} textAnchor="end" fontSize="10" style={{ fill: INK_DIM }}>
{fmtAxis(t)}
</text>
</g>
@ -537,19 +584,19 @@ export default function BridgeView({
{/* connector to the next bar, drawn behind */}
{i < steps.length - 1 && (
<line x1={x + barW} x2={xOf(i + 1)} y1={y(s.end)} y2={y(s.end)}
stroke="#cbd5e1" strokeWidth="1" strokeDasharray="2 2" />
style={{ stroke: AXIS }} strokeWidth="1" strokeDasharray="2 2" />
)}
{/* hit target larger than the mark */}
<rect x={x - 6} y={PAD.t} width={barW + 12} height={plotH} fill="transparent" />
<rect x={x} y={top} width={barW} height={h} rx="4" fill={fill}
opacity={on ? 1 : 0.92}
stroke="#ffffff" strokeWidth="2" />
style={{ stroke: PANEL }} strokeWidth="2" />
{/* direct label: few bars, so every one is labelled */}
<text x={x + barW / 2} y={top - 6} textAnchor="middle" fontSize="10"
fill={INK} fontWeight="500">
style={{ fill: INK }} fontWeight="500">
{isAnchor ? fmt(s.end, 0) : fmtSigned(s.delta, 0)}
</text>
<text x={x + barW / 2} y={PAD.t + plotH + 16} textAnchor="middle" fontSize="10" fill={INK}>
<text x={x + barW / 2} y={PAD.t + plotH + 16} textAnchor="middle" fontSize="10" style={{ fill: INK }}>
{labelLines.map((ln, li) => (
<tspan key={li} x={x + barW / 2} dy={li === 0 ? 0 : 11}>{ln}</tspan>
))}
@ -558,7 +605,7 @@ export default function BridgeView({
untagged bucket -- its label already reads "N untagged
adjustments", so a ×N here would just repeat the count. */}
{!isAnchor && s.tagged && s.entries > 1 && (
<text x={x + barW / 2} y={subY} textAnchor="middle" fontSize="9" fill={INK_DIM}>
<text x={x + barW / 2} y={subY} textAnchor="middle" fontSize="9" style={{ fill: INK_DIM }}>
×{s.entries}
</text>
)}

View File

@ -11,6 +11,8 @@
--border-light: #f3f4f6;
--accent-bg: #eff6ff;
--accent-text: #1d4ed8;
--chart-ink-dim: #6b7280;
--chart-axis: #d1d5db;
}
/* Dark palette tuned to Perspective's "Pro Dark" theme:
@ -27,6 +29,8 @@
--border-light: #3b3f46;
--accent-bg: rgba(39, 113, 170, 0.32);
--accent-text: #4778c2;
--chart-ink-dim: #9ca3af;
--chart-axis: #61656e;
}
body { margin: 0; background-color: var(--bg-primary); color: var(--text-primary); }