From 20ed8e74842df6165c55bda6d8ed581799e4cfb6 Mon Sep 17 00:00:00 2001 From: Paul Trowbridge Date: Wed, 23 Sep 2026 22:01:48 -0400 Subject: [PATCH] Line the panel's buttons up, and cut the prose around them Every button group now reads `label [ A | B ]` on one label column, so nothing is staggered: change, apply, absorbs, target, per slice. The strip and the ledger zone share the same left edge, so the alignment holds across the two. - Together/Each sits under the operation it qualifies rather than loose in the middle of the strip, where it read as a peer of the operation - The apply-mode explanation is gone; the detail is on each option's tooltip. A sentence under a control is read once and skipped after, and this one cost the strip its height on every render - The plug, target, per-slice and clone hints are a few words each Co-Authored-By: Claude Opus 5 --- ui/src/components/OperationPanel.jsx | 99 +++++++++++++--------------- 1 file changed, 47 insertions(+), 52 deletions(-) diff --git a/ui/src/components/OperationPanel.jsx b/ui/src/components/OperationPanel.jsx index bd09fff..45e770a 100644 --- a/ui/src/components/OperationPanel.jsx +++ b/ui/src/components/OperationPanel.jsx @@ -834,17 +834,17 @@ function ScaleLedger({ currentTotals, scaleInputs, setScaleInputs, scalePlug, se const plugState = (() => { if (!(valueCol && unitsCol)) return null const u = filled('units'), p = filled('price'), v = filled('value') - if (u && p) return { value: null, live: false, why: 'Both qty and price are named, so the dollars follow from them.' } - if (u && v) return { value: null, live: false, why: `Both ${valueCol} and ${unitsCol} are named, so price falls out of the two — this is the deliberate price change.` } - if (u) return { value: null, live: false, why: `Price holds; ${valueCol} follows ${unitsCol} in proportion. Name the dollars as well to move price instead.`, unpin: 'units' } - if (p) return { value: 'volume', live: false, why: `Price is pinned, so ${unitsCol} absorbs the change.`, unpin: 'price' } - if (!v) return { value: null, live: false, why: 'Applies to a dollar figure entered on its own — nothing else names price or qty.' } + if (u && p) return { value: null, live: false, why: `${unitsCol} and price named — ${valueCol} follows` } + if (u && v) return { value: null, live: false, why: `${valueCol} and ${unitsCol} named — price follows` } + if (u) return { value: null, live: false, why: `price holds — ${valueCol} follows ${unitsCol}`, unpin: 'units' } + if (p) return { value: 'volume', live: false, why: `price pinned — ${unitsCol} absorbs it`, unpin: 'price' } + if (!v) return { value: null, live: false, why: `for a ${valueCol} figure on its own` } return { value: scalePlug, live: true, why: scalePlug === 'price' - ? `${unitsCol} holds; price moves to reach the number.` - : `Price holds; ${unitsCol} scales with the dollars.`, + ? `${unitsCol} holds — price moves` + : `price holds — ${unitsCol} moves`, } })() @@ -1046,7 +1046,7 @@ function ScaleLedger({ currentTotals, scaleInputs, setScaleInputs, scalePlug, se
{plugState && ( - + + ? `${exclName} can't move — the forecast absorbs it` + : `${exclName} left out — the pivot will show your number plus ${fmtNum(excl.value)}`}> {/* "Adjustable" and "Selected total" are the writer's words for it -- which rows the SQL may touch. What a rep is choosing is whether the number they type is the forecast alone or the @@ -1087,9 +1087,8 @@ function ScaleLedger({ currentTotals, scaleInputs, setScaleInputs, scalePlug, se )} {perSlice && ( - - Applied to each slice separately + + figures above are combined totals )}
@@ -1233,6 +1232,9 @@ function MovingTotal({ currentTotals, verb, factor, includeExcluded }) { } // ── Apply mode ────────────────────────────────────────────────────────────── +// The detail lives on each option's tooltip. It used to be a sentence printed +// under the control, and a sentence under a control is read once and skipped +// forever after -- while costing the top strip its height on every render. function ApplyModeChooser({ op, applyMode, setApplyMode, count }) { const options = op === 'scale' ? [ @@ -1243,14 +1245,7 @@ function ApplyModeChooser({ op, applyMode, setApplyMode, count }) { ['prorate', 'Together', `One operation over all ${count} slices — a single log entry.`], ['each', 'Each', `One operation per slice — ${count} log entries, undoable separately.`], ] - const active = options.find(([v]) => v === applyMode) - return ( -
- [v, l, t])} /> - {active &&

{active[2]}

} -
- ) + return } function RequestPreview({ payload }) { @@ -1351,9 +1346,7 @@ export default function OperationPanel({ which is the point: a period with no baseline borrows its shape from prior year or plan. */} + hint={cloneFrom ? 'only that segment, out of the selection' : undefined}> setCloneOffset(e.target.value)} @@ -1444,35 +1437,37 @@ export default function OperationPanel({ The operation used to be a row of tabs buried in the middle column with no heading over it, so the panel never said what it was about to do until you read the button. */} -
-
- {['scale', 'recode', 'clone'].map(op => ( - - ))} +
+
+ +
+ {['scale', 'recode', 'clone'].map(op => ( + + ))} +
+
+ + {hasSlice && ( +
+ setPeek(o => !o)} /> + {opBusy && writing rows…} + submitOp(activeOp)} busy={!!opBusy}> + {opBusy ? POSTING : POST} + +
+ )}
+ {/* Under the operation it qualifies, on the same label column as every + other button row -- it used to sit loose in the middle of the strip, + where it read as a peer of the operation rather than a setting on it. */} {hasSlice && multi && ( - - )} - - {hasSlice && ( - setPeek(o => !o)} /> - )} - - {hasSlice && ( -
- {opBusy && ( - - Writing rows — the pivot updates when it finishes. - - )} - submitOp(activeOp)} busy={!!opBusy}> - {opBusy ? POSTING : POST} - -
+ + + )}