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 <noreply@anthropic.com>
This commit is contained in:
Paul Trowbridge 2026-09-23 22:01:48 -04:00
parent d01dd2dbc8
commit 20ed8e7484

View File

@ -834,17 +834,17 @@ function ScaleLedger({ currentTotals, scaleInputs, setScaleInputs, scalePlug, se
const plugState = (() => { const plugState = (() => {
if (!(valueCol && unitsCol)) return null if (!(valueCol && unitsCol)) return null
const u = filled('units'), p = filled('price'), v = filled('value') 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 && p) return { value: null, live: false, why: `${unitsCol} and price named — ${valueCol} follows` }
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 && 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} in proportion. Name the dollars as well to move price instead.`, unpin: 'units' } if (u) return { value: null, live: false, why: `price holds — ${valueCol} follows ${unitsCol}`, unpin: 'units' }
if (p) return { value: 'volume', live: false, why: `Price is pinned, so ${unitsCol} absorbs the change.`, unpin: 'price' } if (p) return { value: 'volume', live: false, why: `price pinned — ${unitsCol} absorbs it`, 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 (!v) return { value: null, live: false, why: `for a ${valueCol} figure on its own` }
return { return {
value: scalePlug, value: scalePlug,
live: true, live: true,
why: scalePlug === 'price' why: scalePlug === 'price'
? `${unitsCol} holds; price moves to reach the number.` ? `${unitsCol} holds — price moves`
: `Price holds; ${unitsCol} scales with the dollars.`, : `price holds — ${unitsCol} moves`,
} }
})() })()
@ -1046,7 +1046,7 @@ function ScaleLedger({ currentTotals, scaleInputs, setScaleInputs, scalePlug, se
<div className="flex flex-col gap-2 mt-3 pt-3 border-t border-gray-100"> <div className="flex flex-col gap-2 mt-3 pt-3 border-t border-gray-100">
{plugState && ( {plugState && (
<Field label="Absorbed by" hint={plugState.why}> <Field label="absorbs" hint={plugState.why}>
<Segmented <Segmented
value={plugState.value} value={plugState.value}
onChange={setScalePlug} onChange={setScalePlug}
@ -1066,10 +1066,10 @@ function ScaleLedger({ currentTotals, scaleInputs, setScaleInputs, scalePlug, se
)} )}
{hasExcl && ( {hasExcl && (
<Field label="Target on" <Field label="target"
hint={onTotal hint={onTotal
? `${exclName} can't change, so the adjustable rows absorb the difference. The pivot will show your target.` ? `${exclName} can't move — the forecast absorbs it`
: `${exclName} ignored. The pivot will show your target plus ${fmtNum(excl.value)}.`}> : `${exclName} left out — the pivot will show your number plus ${fmtNum(excl.value)}`}>
{/* "Adjustable" and "Selected total" are the writer's words for {/* "Adjustable" and "Selected total" are the writer's words for
it -- which rows the SQL may touch. What a rep is choosing is it -- which rows the SQL may touch. What a rep is choosing is
whether the number they type is the forecast alone or the whether the number they type is the forecast alone or the
@ -1087,9 +1087,8 @@ function ScaleLedger({ currentTotals, scaleInputs, setScaleInputs, scalePlug, se
)} )}
{perSlice && ( {perSlice && (
<Field label="Per slice" <Field label="per slice">
hint="The figures above are combined totals, so each slice's own change will differ."> <span className="text-gray-500">figures above are combined totals</span>
<span className="text-gray-500">Applied to each slice separately</span>
</Field> </Field>
)} )}
</div> </div>
@ -1233,6 +1232,9 @@ function MovingTotal({ currentTotals, verb, factor, includeExcluded }) {
} }
// ── Apply mode ────────────────────────────────────────────────────────────── // ── 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 }) { function ApplyModeChooser({ op, applyMode, setApplyMode, count }) {
const options = op === 'scale' 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.`], ['prorate', 'Together', `One operation over all ${count} slices — a single log entry.`],
['each', 'Each', `One operation per slice — ${count} log entries, undoable separately.`], ['each', 'Each', `One operation per slice — ${count} log entries, undoable separately.`],
] ]
const active = options.find(([v]) => v === applyMode) return <Segmented value={applyMode} onChange={setApplyMode} options={options} />
return (
<div className="flex flex-col gap-1.5">
<Segmented value={applyMode} onChange={setApplyMode}
options={options.map(([v, l, t]) => [v, l, t])} />
{active && <p className="text-gray-600 leading-snug max-w-xs">{active[2]}</p>}
</div>
)
} }
function RequestPreview({ payload }) { function RequestPreview({ payload }) {
@ -1351,9 +1346,7 @@ export default function OperationPanel({
which is the point: a period with no baseline borrows its which is the point: a period with no baseline borrows its
shape from prior year or plan. */} shape from prior year or plan. */}
<Field label="rows from" <Field label="rows from"
hint={cloneFrom hint={cloneFrom ? 'only that segment, out of the selection' : undefined}>
? 'Only rows from that segment, out of everything selected.'
: undefined}>
<select value={cloneFrom} onChange={e => setCloneFrom(e.target.value)} <select value={cloneFrom} onChange={e => setCloneFrom(e.target.value)}
className={`${TEXT} w-48`}> className={`${TEXT} w-48`}>
<option value="">the whole selection</option> <option value="">the whole selection</option>
@ -1369,7 +1362,7 @@ export default function OperationPanel({
<Field label="shift dates" <Field label="shift dates"
hint={shifting hint={shifting
? 'Every date column moves; season and month are re-derived from the calendar. So select the period you are copying from, not the one you are filling.' ? 'every date moves — select the period you copy FROM'
: undefined}> : undefined}>
<input value={cloneOffset} list="pf-clone-offsets" <input value={cloneOffset} list="pf-clone-offsets"
onChange={e => setCloneOffset(e.target.value)} onChange={e => setCloneOffset(e.target.value)}
@ -1444,7 +1437,9 @@ export default function OperationPanel({
The operation used to be a row of tabs buried in the middle column 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 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-center gap-3 flex-wrap px-3 py-2 border-b border-gray-200 bg-gray-50/60"> <div className="flex flex-col gap-2 px-3 py-2 border-b border-gray-200 bg-gray-50/60">
<div className="flex items-start gap-4 flex-wrap">
<Field label="change">
<div className="inline-flex rounded border border-gray-200 divide-x divide-gray-200 overflow-hidden"> <div className="inline-flex rounded border border-gray-200 divide-x divide-gray-200 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}>
@ -1452,23 +1447,13 @@ export default function OperationPanel({
</Button> </Button>
))} ))}
</div> </div>
</Field>
{hasSlice && multi && (
<ApplyModeChooser op={activeOp} applyMode={applyMode} setApplyMode={setApplyMode} count={slices.length} />
)}
{hasSlice && ( {hasSlice && (
<div className="ml-auto flex items-center gap-3 pt-0.5">
<SliceSummary slices={slices} viewScope={viewScope} distinctSlices={distinctSlices} <SliceSummary slices={slices} viewScope={viewScope} distinctSlices={distinctSlices}
currentTotals={currentTotals} open={peek} onToggle={() => setPeek(o => !o)} /> currentTotals={currentTotals} open={peek} onToggle={() => setPeek(o => !o)} />
)} {opBusy && <span className="text-gray-500">writing rows…</span>}
{hasSlice && (
<div className="ml-auto flex items-center gap-3">
{opBusy && (
<span className="text-gray-500 leading-snug">
Writing rows — the pivot updates when it finishes.
</span>
)}
<Submit onClick={() => submitOp(activeOp)} busy={!!opBusy}> <Submit onClick={() => submitOp(activeOp)} busy={!!opBusy}>
{opBusy ? POSTING : POST} {opBusy ? POSTING : POST}
</Submit> </Submit>
@ -1476,6 +1461,16 @@ export default function OperationPanel({
)} )}
</div> </div>
{/* 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 && (
<Field label="apply">
<ApplyModeChooser op={activeOp} applyMode={applyMode} setApplyMode={setApplyMode} count={slices.length} />
</Field>
)}
</div>
{peek && hasSlice && ( {peek && hasSlice && (
<div className="px-3 py-2 border-b border-gray-200 bg-gray-50/60"> <div className="px-3 py-2 border-b border-gray-200 bg-gray-50/60">
<SelectionList <SelectionList