Drop the selection line; Post goes last

The summary printed back the cells you had just clicked in the grid --
count, total and filter, none of it news. It is a peek button now, and
the peek still carries the pivot filter and the duplicate-slice warning,
which are the parts that are not visible in the grid.

Post moves to a footer of its own, last in the flow: pick the operation,
read the ledger, name the change, post it. It sits on the same label
column as every other button, and sticks to the bottom of the dock --
a fixed-height dock with its own scrollbar would otherwise put the
primary action below the fold.

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

View File

@ -166,45 +166,7 @@ function scopeText(viewScope = []) {
.join(' · ') .join(' · ')
} }
// The selection, in one line you can open. What it always shows is what is not function SelectionList({ slices, viewScope = [], distinctSlices, currentTotals, onRemove, onClear }) {
// visible anywhere else: how many slices, what they total, and the pivot filter
// they sit inside -- which scopes every figure in the ledger and every row the
// write touches, while appearing in none of the slices themselves.
function SliceSummary({ slices, viewScope, distinctSlices, currentTotals, open, onToggle }) {
const scope = scopeText(viewScope)
const one = slices.length === 1 ? sliceLabel(slices[0]) : null
const total = currentTotals
? (currentTotals.total?.value || 0) + (currentTotals.excluded?.value || 0)
: null
const dupes = distinctSlices != null && distinctSlices < slices.length
return (
<div className="flex items-baseline gap-2 min-w-0">
<span className="text-gray-600 shrink-0">
{one ? '1 slice' : `${slices.length} slices`}
</span>
{one && <span className="font-mono text-gray-700 truncate max-w-[22rem]" title={one}>{one}</span>}
{total != null && (
<span className="font-mono tabular-nums text-gray-700 shrink-0">{fmtNum(total)}</span>
)}
{scope && (
<span className="text-gray-500 truncate max-w-[16rem]" title={scope}>
<span className="uppercase tracking-wide text-gray-400">within </span>
<span className="font-mono">{scope}</span>
</span>
)}
{dupes && (
<span className="shrink-0 px-1 py-0.5 rounded bg-amber-50 text-amber-700 text-[10px] uppercase tracking-wide"
title={`${slices.length} cells cover ${distinctSlices} distinct slices — some differ only by a column operations cannot target. The duplicates are applied once.`}>
{distinctSlices} distinct
</span>
)}
<TextButton onClick={onToggle}>{open ? '▾ hide' : '▸ peek'}</TextButton>
</div>
)
}
function SelectionList({ slices, viewScope = [], currentTotals, onRemove, onClear }) {
const multi = slices.length > 1 const multi = slices.length > 1
const perSlice = currentTotals?.perSlice || [] const perSlice = currentTotals?.perSlice || []
const valueCol = currentTotals?.valueCol const valueCol = currentTotals?.valueCol
@ -226,6 +188,15 @@ function SelectionList({ slices, viewScope = [], currentTotals, onRemove, onClea
return ( return (
<div className="min-w-0"> <div className="min-w-0">
{/* Cells that differ only by a column operations cannot filter on collapse
to the same slice — say so, rather than implying more reach than there is */}
{distinctSlices != null && distinctSlices < slices.length && (
<p className="text-amber-700 bg-amber-50 border border-amber-200 rounded px-2 py-1 mb-2 leading-snug">
{slices.length} cells cover {distinctSlices} distinct{' '}
{distinctSlices === 1 ? 'slice' : 'slices'} — some differ only by a column
operations cannot target. The duplicates are applied once.
</p>
)}
{scopeLine && ( {scopeLine && (
<div className="mb-1 flex items-baseline gap-1.5 text-[11px]"> <div className="mb-1 flex items-baseline gap-1.5 text-[11px]">
<span className="text-gray-400 uppercase tracking-wide shrink-0">within</span> <span className="text-gray-400 uppercase tracking-wide shrink-0">within</span>
@ -1450,13 +1421,10 @@ export default function OperationPanel({
</Field> </Field>
{hasSlice && ( {hasSlice && (
<div className="ml-auto flex items-center gap-3 pt-0.5"> <div className="ml-auto pt-1">
<SliceSummary slices={slices} viewScope={viewScope} distinctSlices={distinctSlices} <TextButton onClick={() => setPeek(o => !o)}>
currentTotals={currentTotals} open={peek} onToggle={() => setPeek(o => !o)} /> {peek ? '▾' : '▸'} {slices.length} {slices.length === 1 ? 'slice' : 'slices'}
{opBusy && <span className="text-gray-500">writing rows…</span>} </TextButton>
<Submit onClick={() => submitOp(activeOp)} busy={!!opBusy}>
{opBusy ? POSTING : POST}
</Submit>
</div> </div>
)} )}
</div> </div>
@ -1476,6 +1444,7 @@ export default function OperationPanel({
<SelectionList <SelectionList
slices={slices} slices={slices}
viewScope={viewScope} viewScope={viewScope}
distinctSlices={distinctSlices}
currentTotals={currentTotals} currentTotals={currentTotals}
onRemove={(i) => setSlices(prev => prev.filter((_, x) => x !== i))} onRemove={(i) => setSlices(prev => prev.filter((_, x) => x !== i))}
onClear={() => setSlices([])} onClear={() => setSlices([])}
@ -1506,6 +1475,23 @@ export default function OperationPanel({
<Block title="Label this change" horizontal={horizontal}>{labelZone}</Block> <Block title="Label this change" horizontal={horizontal}>{labelZone}</Block>
</div> </div>
)} )}
{/* Last, because it is last: pick the operation, read the ledger, name
the change, post it. The blank label keeps Post in the same column as
every other button in the panel rather than at an edge of its own.
Sticky, because the bottom dock is a fixed height with its own scroll
-- a primary action that has to be scrolled to is a primary action
people miss. */}
{hasSlice && (
<div className="sticky bottom-0 z-10 flex items-center gap-3 px-3 py-2 border-t border-gray-200 bg-gray-50">
<Field label="">
<Submit onClick={() => submitOp(activeOp)} busy={!!opBusy}>
{opBusy ? POSTING : POST}
</Submit>
</Field>
{opBusy && <span className="text-gray-500">writing rows — the pivot updates when it finishes</span>}
</div>
)}
</div> </div>
) )
} }