Name every section, and give the buttons some weight

- Button labels are capitalised and font-medium, the segmented groups
  carry a border and a shadow of their own. An inactive segment used to
  be white text-on-white inside a group border, which is why none of
  them read as something you could press.
- Each section says what it is: the operation strip, LEDGER (or RECODE
  TO / CLONE TO, which is what the form under those two operations is),
  SETTINGS over the toggles, LABEL THIS CHANGE.
- Together/Each moves down beside Price/Volume and Forecast/Total, in a
  wrapping row. Three one-line settings stacked cost three rows of a
  dock that is 260px tall to begin with, and they are read together
  anyway: what absorbs the change, what it is measured against, and
  whether it applies once or per slice.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Paul Trowbridge 2026-09-23 22:28:57 -04:00
parent 4bce3bbd17
commit 91b88cd809

View File

@ -78,10 +78,10 @@ function TextButton({ onClick, children, title, danger }) {
function Button({ onClick, active, children, title, disabled }) { function Button({ onClick, active, children, title, disabled }) {
return ( return (
<button onClick={onClick} title={title} disabled={disabled} <button onClick={onClick} title={title} disabled={disabled}
className={`px-3 py-1 rounded text-xs whitespace-nowrap transition-colors ${ className={`px-3 py-1 text-xs font-medium whitespace-nowrap transition-colors ${
disabled disabled
? (active ? 'bg-gray-300 text-white cursor-default' : 'bg-white text-gray-400 cursor-default') ? (active ? 'bg-gray-300 text-white cursor-default' : 'bg-white text-gray-400 cursor-default')
: (active ? 'bg-blue-600 text-white' : 'bg-white text-gray-700 hover:bg-gray-50')}`}> : (active ? 'bg-blue-600 text-white' : 'bg-white text-gray-700 hover:bg-gray-100')}`}>
{children} {children}
</button> </button>
) )
@ -119,9 +119,9 @@ function Field({ label, children, hint, stack }) {
// Same 10px uppercase as the ledger table headers, so the groupings read as part // Same 10px uppercase as the ledger table headers, so the groupings read as part
// of the same family rather than as a second style. // of the same family rather than as a second style.
function SectionLabel({ children }) { function SectionLabel({ children, className = '' }) {
return ( return (
<div className="text-gray-400 uppercase tracking-wide pt-1" style={{ fontSize: '10px' }}> <div className={`text-gray-500 uppercase tracking-wide pt-1 ${className}`} style={{ fontSize: '10px' }}>
{children} {children}
</div> </div>
) )
@ -132,8 +132,8 @@ function SectionLabel({ children }) {
// says the choice has been made elsewhere. // says the choice has been made elsewhere.
function Segmented({ options, value, onChange, disabled }) { function Segmented({ options, value, onChange, disabled }) {
return ( return (
<div className={`inline-flex rounded border divide-x divide-gray-200 overflow-hidden w-auto self-start ${ <div className={`inline-flex rounded border divide-x shadow-sm overflow-hidden w-auto self-start ${
disabled ? 'border-gray-200 bg-gray-50' : 'border-gray-200'}`}> disabled ? 'border-gray-200 divide-gray-200 bg-gray-50' : 'border-gray-300 divide-gray-300'}`}>
{options.map(([val, label, title]) => ( {options.map(([val, label, title]) => (
<Button key={val} onClick={() => onChange(val)} active={value === val} <Button key={val} onClick={() => onChange(val)} active={value === val}
title={title} disabled={disabled}>{label}</Button> title={title} disabled={disabled}>{label}</Button>
@ -145,7 +145,7 @@ function Segmented({ options, value, onChange, disabled }) {
function Submit({ onClick, children, disabled, busy }) { function Submit({ onClick, children, disabled, busy }) {
return ( return (
<button onClick={onClick} disabled={disabled || busy} <button onClick={onClick} disabled={disabled || busy}
className="self-start px-4 py-1.5 rounded text-xs font-medium bg-blue-600 text-white hover:bg-blue-700 className="self-start px-4 py-1.5 rounded text-xs font-semibold shadow-sm bg-blue-600 text-white hover:bg-blue-700
disabled:bg-gray-200 disabled:text-gray-600 disabled:cursor-not-allowed whitespace-nowrap disabled:bg-gray-200 disabled:text-gray-600 disabled:cursor-not-allowed whitespace-nowrap
inline-flex items-center gap-2"> inline-flex items-center gap-2">
{busy && ( {busy && (
@ -254,7 +254,7 @@ function IterBreakdown({ currentTotals }) {
return ( return (
<div className="mt-2"> <div className="mt-2">
<TextButton onClick={() => setOpen(o => !o)}> <TextButton onClick={() => setOpen(o => !o)}>
{open ? '▾' : '▸'} breakdown by iter {open ? '▾' : '▸'} Breakdown by iter
</TextButton> </TextButton>
{open && ( {open && (
<table className="mt-1 text-gray-500"> <table className="mt-1 text-gray-500">
@ -496,7 +496,7 @@ function TagInput({ value, onChange, tags = [], className }) {
) )
} }
function ScaleLedger({ currentTotals, scaleInputs, setScaleInputs, scalePlug, setScalePlug, targetBasis, setTargetBasis, logMeta = {}, multi, applyMode }) { function ScaleLedger({ currentTotals, scaleInputs, setScaleInputs, scalePlug, setScalePlug, targetBasis, setTargetBasis, logMeta = {}, multi, applyMode, settingsExtra }) {
const valueCol = currentTotals?.valueCol const valueCol = currentTotals?.valueCol
const unitsCol = currentTotals?.unitsCol const unitsCol = currentTotals?.unitsCol
const total = currentTotals?.total || { value: 0, units: 0 } const total = currentTotals?.total || { value: 0, units: 0 }
@ -884,7 +884,7 @@ function ScaleLedger({ currentTotals, scaleInputs, setScaleInputs, scalePlug, se
{showAllLines && adjLines.length > MAX_ADJ_LINES + 1 && ( {showAllLines && adjLines.length > MAX_ADJ_LINES + 1 && (
<tr> <tr>
<td colSpan={measures.length + 1 + (showRunning ? 1 : 0)} className="pr-3"> <td colSpan={measures.length + 1 + (showRunning ? 1 : 0)} className="pr-3">
<TextButton onClick={() => setShowAllLines(false)}>▾ collapse the smaller changes</TextButton> <TextButton onClick={() => setShowAllLines(false)}>▾ Collapse the smaller changes</TextButton>
</td> </td>
</tr> </tr>
)} )}
@ -1013,11 +1013,17 @@ function ScaleLedger({ currentTotals, scaleInputs, setScaleInputs, scalePlug, se
They used to hand-roll their own label row and hint, with two They used to hand-roll their own label row and hint, with two
different type sizes and no shared label width, so the two stacked different type sizes and no shared label width, so the two stacked
into a paragraph of loose words rather than reading as two settings. */} into a paragraph of loose words rather than reading as two settings. */}
{(plugState || hasExcl || perSlice) && ( {(plugState || hasExcl || perSlice || settingsExtra) && (
<div className="flex flex-col gap-2 mt-3 pt-3 border-t border-gray-100"> // Side by side, not stacked: three one-line settings in a column cost
// three rows of a dock that is 260px tall to begin with, and they are
// read together anyway -- what absorbs the change, what it is measured
// against, and whether it applies once or per slice.
<div className="flex flex-wrap items-start gap-x-8 gap-y-2 mt-3 pt-3 border-t border-gray-200">
<SectionLabel className="w-full">settings</SectionLabel>
{settingsExtra}
{plugState && ( {plugState && (
<Field label="absorbs" hint={plugState.why}> <Field label="Absorbs" hint={plugState.why}>
<Segmented <Segmented
value={plugState.value} value={plugState.value}
onChange={setScalePlug} onChange={setScalePlug}
@ -1037,7 +1043,7 @@ function ScaleLedger({ currentTotals, scaleInputs, setScaleInputs, scalePlug, se
)} )}
{hasExcl && ( {hasExcl && (
<Field label="target" <Field label="Target"
hint={onTotal hint={onTotal
? `${exclName} can't move — the forecast absorbs it` ? `${exclName} can't move — the forecast absorbs it`
: `${exclName} left out — the pivot will show your number plus ${fmtNum(excl.value)}`}> : `${exclName} left out — the pivot will show your number plus ${fmtNum(excl.value)}`}>
@ -1058,7 +1064,7 @@ function ScaleLedger({ currentTotals, scaleInputs, setScaleInputs, scalePlug, se
)} )}
{perSlice && ( {perSlice && (
<Field label="per slice"> <Field label="Per slice">
<span className="text-gray-500">figures above are combined totals</span> <span className="text-gray-500">figures above are combined totals</span>
</Field> </Field>
)} )}
@ -1272,7 +1278,7 @@ function RequestPreview({ payload, op, versionId }) {
<span className="font-medium text-gray-700 text-sm">Request</span> <span className="font-medium text-gray-700 text-sm">Request</span>
<span className="font-mono text-gray-600 text-xs truncate" title={line}>{line}</span> <span className="font-mono text-gray-600 text-xs truncate" title={line}>{line}</span>
<div className="ml-auto flex items-center gap-3"> <div className="ml-auto flex items-center gap-3">
<TextButton onClick={copy}>{copied ? 'copied' : 'copy'}</TextButton> <TextButton onClick={copy}>{copied ? 'Copied' : 'Copy'}</TextButton>
<button onClick={() => setOpen(false)} <button onClick={() => setOpen(false)}
className="text-gray-600 hover:text-gray-800 text-lg leading-none">×</button> className="text-gray-600 hover:text-gray-800 text-lg leading-none">×</button>
</div> </div>
@ -1332,6 +1338,15 @@ export default function OperationPanel({
// operation is named by the tabs beside it. // operation is named by the tabs beside it.
const POST = 'Post', POSTING = 'Posting…' const POST = 'Post', POSTING = 'Posting…'
// Sits with the ledger's own settings rather than under the operation tabs:
// it is one line, they are one line, and stacked they cost three rows of a
// dock that is 260px tall to begin with.
const applyField = multi ? (
<Field label="Apply">
<ApplyModeChooser op={activeOp} applyMode={applyMode} setApplyMode={setApplyMode} count={slices.length} />
</Field>
) : null
const ledger = ( const ledger = (
<> <>
{activeOp === 'scale' && ( {activeOp === 'scale' && (
@ -1342,6 +1357,7 @@ export default function OperationPanel({
targetBasis={targetBasis} setTargetBasis={setTargetBasis} targetBasis={targetBasis} setTargetBasis={setTargetBasis}
logMeta={logMeta} logMeta={logMeta}
multi={multi} applyMode={applyMode} multi={multi} applyMode={applyMode}
settingsExtra={applyField}
/> />
)} )}
{activeOp === 'recode' && ( {activeOp === 'recode' && (
@ -1367,7 +1383,7 @@ export default function OperationPanel({
reference ones operations are normally kept away from, reference ones operations are normally kept away from,
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 ? 'only that segment, out of the selection' : undefined}> hint={cloneFrom ? 'only that segment, out of the selection' : 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`}>
@ -1382,7 +1398,7 @@ export default function OperationPanel({
<SectionLabel>changes</SectionLabel> <SectionLabel>changes</SectionLabel>
<Field label="shift dates" <Field label="Shift dates"
hint={shifting hint={shifting
? 'every date moves — select the period you copy FROM' ? 'every date moves — select the period you copy FROM'
: undefined}> : undefined}>
@ -1398,7 +1414,7 @@ export default function OperationPanel({
</datalist> </datalist>
</Field> </Field>
<Field label="scale by"> <Field label="Scale by">
<input type="number" step="any" value={cloneScale} <input type="number" step="any" value={cloneScale}
onChange={e => setCloneScale(e.target.value)} onChange={e => setCloneScale(e.target.value)}
className={`${INPUT} w-28`} /> className={`${INPUT} w-28`} />
@ -1417,6 +1433,12 @@ export default function OperationPanel({
</div> </div>
} /> } />
)} )}
{activeOp !== 'scale' && applyField && (
<div className="flex flex-wrap items-start gap-x-8 gap-y-2 mt-3 pt-3 border-t border-gray-200">
<SectionLabel className="w-full">settings</SectionLabel>
{applyField}
</div>
)}
</> </>
) )
@ -1425,7 +1447,7 @@ export default function OperationPanel({
{/* Tag first: it is the field that gives an adjustment meaning later, {/* Tag first: it is the field that gives an adjustment meaning later,
in the ledger and in the bridge. Completes from initiatives already in the ledger and in the bridge. Completes from initiatives already
used on this source; free text is still accepted. */} used on this source; free text is still accepted. */}
<Field label="tag" stack> <Field label="Tag" stack>
<TagInput value={opTag} onChange={setOpTag} tags={knownTags} <TagInput value={opTag} onChange={setOpTag} tags={knownTags}
className={`${TEXT} w-full`} /> className={`${TEXT} w-full`} />
{opTag.trim() && ( {opTag.trim() && (
@ -1441,7 +1463,7 @@ export default function OperationPanel({
border. The same tags complete from the input's datalist, which border. The same tags complete from the input's datalist, which
costs no space and is not capped at six. */} costs no space and is not capped at six. */}
<Field label="note" stack> <Field label="Note" stack>
<input value={note} onChange={e => setNote(e.target.value)} <input value={note} onChange={e => setNote(e.target.value)}
placeholder="optional" className={`${TEXT} w-full`} /> placeholder="optional" className={`${TEXT} w-full`} />
</Field> </Field>
@ -1472,34 +1494,23 @@ 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 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 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">
<Field label="change"> <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-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}> <span className="capitalize">{op}</span>
<span className="capitalize">{op}</span> </Button>
</Button> ))}
))} </div>
</div> </Field>
</Field>
{hasSlice && ( {hasSlice && (
<div className="ml-auto pt-1"> <div className="ml-auto pt-1">
<TextButton onClick={() => setPeek(o => !o)}> <TextButton onClick={() => setPeek(o => !o)}>
{peek ? '▾' : '▸'} {slices.length} {slices.length === 1 ? 'slice' : 'slices'} {peek ? '▾' : '▸'} {slices.length} {slices.length === 1 ? 'slice' : 'slices'}
</TextButton> </TextButton>
</div> </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> </div>
@ -1535,7 +1546,8 @@ export default function OperationPanel({
style={horizontal style={horizontal
? { display: 'grid', gridTemplateColumns: 'minmax(0, 1fr) minmax(15rem, 20rem)', alignItems: 'start' } ? { display: 'grid', gridTemplateColumns: 'minmax(0, 1fr) minmax(15rem, 20rem)', alignItems: 'start' }
: undefined}> : undefined}>
<Block horizontal={horizontal} grow>{ledger}</Block> <Block title={activeOp === 'scale' ? 'Ledger' : `${activeOp} to`}
horizontal={horizontal} grow>{ledger}</Block>
<Block title="Label this change" horizontal={horizontal}>{labelZone}</Block> <Block title="Label this change" horizontal={horizontal}>{labelZone}</Block>
</div> </div>
)} )}