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 = (() => {
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
<div className="flex flex-col gap-2 mt-3 pt-3 border-t border-gray-100">
{plugState && (
<Field label="Absorbed by" hint={plugState.why}>
<Field label="absorbs" hint={plugState.why}>
<Segmented
value={plugState.value}
onChange={setScalePlug}
@ -1066,10 +1066,10 @@ function ScaleLedger({ currentTotals, scaleInputs, setScaleInputs, scalePlug, se
)}
{hasExcl && (
<Field label="Target on"
<Field label="target"
hint={onTotal
? `${exclName} can't change, so the adjustable rows absorb the difference. The pivot will show your target.`
: `${exclName} ignored. The pivot will show your target plus ${fmtNum(excl.value)}.`}>
? `${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 && (
<Field label="Per slice"
hint="The figures above are combined totals, so each slice's own change will differ.">
<span className="text-gray-500">Applied to each slice separately</span>
<Field label="per slice">
<span className="text-gray-500">figures above are combined totals</span>
</Field>
)}
</div>
@ -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 (
<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>
)
return <Segmented value={applyMode} onChange={setApplyMode} options={options} />
}
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. */}
<Field label="rows from"
hint={cloneFrom
? 'Only rows from that segment, out of everything selected.'
: undefined}>
hint={cloneFrom ? 'only that segment, out of the selection' : undefined}>
<select value={cloneFrom} onChange={e => setCloneFrom(e.target.value)}
className={`${TEXT} w-48`}>
<option value="">the whole selection</option>
@ -1369,7 +1362,7 @@ export default function OperationPanel({
<Field label="shift dates"
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}>
<input value={cloneOffset} list="pf-clone-offsets"
onChange={e => 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. */}
<div className="flex items-center gap-3 flex-wrap px-3 py-2 border-b border-gray-200 bg-gray-50/60">
<div className="inline-flex rounded border border-gray-200 divide-x divide-gray-200 overflow-hidden">
{['scale', 'recode', 'clone'].map(op => (
<Button key={op} onClick={() => setActiveOp(op)} active={activeOp === op}>
<span className="capitalize">{op}</span>
</Button>
))}
<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">
{['scale', 'recode', 'clone'].map(op => (
<Button key={op} onClick={() => setActiveOp(op)} active={activeOp === op}>
<span className="capitalize">{op}</span>
</Button>
))}
</div>
</Field>
{hasSlice && (
<div className="ml-auto flex items-center gap-3 pt-0.5">
<SliceSummary slices={slices} viewScope={viewScope} distinctSlices={distinctSlices}
currentTotals={currentTotals} open={peek} onToggle={() => setPeek(o => !o)} />
{opBusy && <span className="text-gray-500">writing rows…</span>}
<Submit onClick={() => submitOp(activeOp)} busy={!!opBusy}>
{opBusy ? POSTING : POST}
</Submit>
</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 && (
<ApplyModeChooser op={activeOp} applyMode={applyMode} setApplyMode={setApplyMode} count={slices.length} />
)}
{hasSlice && (
<SliceSummary slices={slices} viewScope={viewScope} distinctSlices={distinctSlices}
currentTotals={currentTotals} open={peek} onToggle={() => setPeek(o => !o)} />
)}
{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}>
{opBusy ? POSTING : POST}
</Submit>
</div>
<Field label="apply">
<ApplyModeChooser op={activeOp} applyMode={applyMode} setApplyMode={setApplyMode} count={slices.length} />
</Field>
)}
</div>