The ? opens upward, and covers the operations and Post

Opening downward put the popover under controls that already sit low in
a scrolling dock, so the dock's edge cut it off. Up is the default now;
`dir="down"` is for the few at the top of the panel, where up is the
clipped direction.

Scale, recode and clone each get one, and so does Post -- the three
controls that actually change the version were the three with no
explanation anywhere. They say what happens to the numbers and what is
left alone: that scale writes the difference as new rows rather than
editing what is there, that recode writes a negative and a positive
under one entry, that clone leaves the original, and that Post is one
change-log entry which undoes whole.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Paul Trowbridge 2026-09-23 22:38:58 -04:00
parent e88f52203c
commit 6e09e90bdf

View File

@ -97,7 +97,12 @@ const LABEL_W = 'w-24'
// which put a line of prose beside every toggle -- and a line of prose beside a // which put a line of prose beside every toggle -- and a line of prose beside a
// toggle is skipped, while still costing the dock a row. Hovering gives it // toggle is skipped, while still costing the dock a row. Hovering gives it
// (native title, which nothing can clip), clicking pins it. // (native title, which nothing can clip), clicking pins it.
function Help({ text }) { //
// It opens upward, because the settings it explains sit low in a dock that
// scrolls -- a popover hanging below them is cut off by the dock's own edge.
// `dir="down"` is for the few that sit at the top of the panel, where up is the
// clipped direction instead.
function Help({ text, dir = 'up' }) {
const [open, setOpen] = useState(false) const [open, setOpen] = useState(false)
if (!text) return null if (!text) return null
return ( return (
@ -108,8 +113,9 @@ function Help({ text }) {
open ? 'border-blue-400 text-blue-600 bg-blue-50' : 'border-gray-300 text-gray-500 hover:text-blue-600 hover:border-blue-400'}`} open ? 'border-blue-400 text-blue-600 bg-blue-50' : 'border-gray-300 text-gray-500 hover:text-blue-600 hover:border-blue-400'}`}
style={{ fontSize: '10px' }}>?</button> style={{ fontSize: '10px' }}>?</button>
{open && ( {open && (
<span className="absolute z-20 left-0 top-5 w-64 rounded border border-gray-200 bg-white shadow-lg px-2 py-1.5 <span className={`absolute z-20 left-0 w-64 rounded border border-gray-200 bg-white shadow-lg px-2 py-1.5
text-gray-600 leading-snug normal-case tracking-normal"> text-gray-600 leading-snug normal-case tracking-normal
${dir === 'down' ? 'top-5' : 'bottom-5'}`}>
{text} {text}
</span> </span>
)} )}
@ -120,11 +126,11 @@ function Help({ text }) {
// `stack` puts the label above its control instead of beside it. The aligned // `stack` puts the label above its control instead of beside it. The aligned
// label column costs 6rem, which is most of a narrow zone -- the tag field in // label column costs 6rem, which is most of a narrow zone -- the tag field in
// the bottom dock had 12rem of box left to type an initiative name into. // the bottom dock had 12rem of box left to type an initiative name into.
function Field({ label, children, hint, stack }) { function Field({ label, children, hint, hintDir, stack }) {
if (stack) { if (stack) {
return ( return (
<div className="flex flex-col gap-1 min-w-0"> <div className="flex flex-col gap-1 min-w-0">
<span className="text-gray-600 whitespace-nowrap flex items-center gap-1.5">{label}<Help text={hint} /></span> <span className="text-gray-600 whitespace-nowrap flex items-center gap-1.5">{label}<Help text={hint} dir={hintDir} /></span>
<div className="flex items-center gap-2 min-w-0">{children}</div> <div className="flex items-center gap-2 min-w-0">{children}</div>
</div> </div>
) )
@ -134,7 +140,7 @@ function Field({ label, children, hint, stack }) {
<span className={`text-gray-600 whitespace-nowrap shrink-0 pt-1 ${LABEL_W}`}>{label}</span> <span className={`text-gray-600 whitespace-nowrap shrink-0 pt-1 ${LABEL_W}`}>{label}</span>
<div className="flex items-center gap-2 flex-wrap min-w-0"> <div className="flex items-center gap-2 flex-wrap min-w-0">
{children} {children}
<Help text={hint} /> <Help text={hint} dir={hintDir} />
</div> </div>
</div> </div>
) )
@ -1312,6 +1318,20 @@ function RequestPreview({ payload, op, versionId }) {
) )
} }
// What the three operations are, for the ? beside them. Written for somebody
// who has been handed the app, not for whoever wrote the SQL: what it does to
// the numbers, and what it leaves alone.
const OP_HELP = {
scale: 'Moves the selection to a number you name — a new total, a change, or a percentage. '
+ 'The rows already there are left alone: the difference is written as new rows underneath them, '
+ 'so the pivot re-sums and the whole thing is one entry you can undo.',
recode: 'Moves the selection to different dimension values — another customer, part or program. '
+ 'It writes the original back out as a negative and the same amounts in at the new coordinates, '
+ 'so nothing is lost and both halves undo together.',
clone: 'Copies the selection to new rows, optionally shifted in time and scaled. The original is untouched. '
+ 'This is how a period with no baseline of its own borrows its shape from prior year or plan.',
}
export default function OperationPanel({ export default function OperationPanel({
dock, dock,
slices, setSlices, distinctSlices, slices, setSlices, distinctSlices,
@ -1500,6 +1520,9 @@ export default function OperationPanel({
<Submit onClick={() => submitOp(activeOp)} busy={!!opBusy}> <Submit onClick={() => submitOp(activeOp)} busy={!!opBusy}>
{opBusy ? POSTING : POST} {opBusy ? POSTING : POST}
</Submit> </Submit>
<Help text={'Writes the adjustment onto the forecast, under your account, as one entry in the change log. '
+ 'The pivot updates in place — no reload. Nothing is overwritten: the entry can be undone whole '
+ 'from Change log, which removes exactly the rows it wrote.'} />
{opBusy && <span className="text-gray-500 leading-snug">writing rows…</span>} {opBusy && <span className="text-gray-500 leading-snug">writing rows…</span>}
</div> </div>
@ -1517,7 +1540,7 @@ export default function OperationPanel({
with no heading over it, so the panel never said what it was about with no heading over it, so the panel never said what it was about
to do until you read the button. */} to do until you read the button. */}
<div className="flex items-start gap-4 flex-wrap px-3 py-2 border-b border-gray-200 bg-gray-50/60"> <div className="flex items-start gap-4 flex-wrap px-3 py-2 border-b border-gray-200 bg-gray-50/60">
<Field label="Change"> <Field label="Change" hintDir="down" hint={OP_HELP[activeOp]}>
<div className="inline-flex rounded border border-gray-300 divide-x divide-gray-300 shadow-sm overflow-hidden"> <div className="inline-flex rounded border border-gray-300 divide-x divide-gray-300 shadow-sm overflow-hidden">
{['scale', 'recode', 'clone'].map(op => ( {['scale', 'recode', 'clone'].map(op => (
<Button key={op} onClick={() => setActiveOp(op)} active={activeOp === op}> <Button key={op} onClick={() => setActiveOp(op)} active={activeOp === op}>