From f9e989f20ee575ef69e54b7ea8f6f7e5942c3954 Mon Sep 17 00:00:00 2001 From: Paul Trowbridge Date: Fri, 25 Sep 2026 01:30:14 -0400 Subject: [PATCH] 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 --- ui/src/components/BridgeView.jsx | 75 ++++++++++++++++++++++++++------ ui/src/index.css | 4 ++ 2 files changed, 65 insertions(+), 14 deletions(-) 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); }