diff --git a/ui/src/components/BridgeView.jsx b/ui/src/components/BridgeView.jsx index acb228d..3c5a89f 100644 --- a/ui/src/components/BridgeView.jsx +++ b/ui/src/components/BridgeView.jsx @@ -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 ( -
-
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. +
+
-
+
Bridge {versionName && {versionName}} @@ -512,8 +559,8 @@ export default function BridgeView({ {ticks.map(t => ( - + style={{ stroke: t === 0 ? AXIS : GRID }} strokeWidth={t === 0 ? 1.5 : 1} /> + {fmtAxis(t)} @@ -537,19 +584,19 @@ export default function BridgeView({ {/* connector to the next bar, drawn behind */} {i < steps.length - 1 && ( + style={{ stroke: AXIS }} strokeWidth="1" strokeDasharray="2 2" /> )} {/* hit target larger than the mark */} + style={{ stroke: PANEL }} strokeWidth="2" /> {/* direct label: few bars, so every one is labelled */} + style={{ fill: INK }} fontWeight="500"> {isAnchor ? fmt(s.end, 0) : fmtSigned(s.delta, 0)} - + {labelLines.map((ln, li) => ( {ln} ))} @@ -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 && ( - + ×{s.entries} )} diff --git a/ui/src/index.css b/ui/src/index.css index 140c2fe..adf2196 100644 --- a/ui/src/index.css +++ b/ui/src/index.css @@ -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); }