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 }) {
return (
<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
? (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}
</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
// of the same family rather than as a second style.
function SectionLabel({ children }) {
function SectionLabel({ children, className = '' }) {
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}
</div>
)
@ -132,8 +132,8 @@ function SectionLabel({ children }) {
// says the choice has been made elsewhere.
function Segmented({ options, value, onChange, disabled }) {
return (
<div className={`inline-flex rounded border divide-x divide-gray-200 overflow-hidden w-auto self-start ${
disabled ? 'border-gray-200 bg-gray-50' : 'border-gray-200'}`}>
<div className={`inline-flex rounded border divide-x shadow-sm overflow-hidden w-auto self-start ${
disabled ? 'border-gray-200 divide-gray-200 bg-gray-50' : 'border-gray-300 divide-gray-300'}`}>
{options.map(([val, label, title]) => (
<Button key={val} onClick={() => onChange(val)} active={value === val}
title={title} disabled={disabled}>{label}</Button>
@ -145,7 +145,7 @@ function Segmented({ options, value, onChange, disabled }) {
function Submit({ onClick, children, disabled, busy }) {
return (
<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
inline-flex items-center gap-2">
{busy && (
@ -254,7 +254,7 @@ function IterBreakdown({ currentTotals }) {
return (
<div className="mt-2">
<TextButton onClick={() => setOpen(o => !o)}>
{open ? '▾' : '▸'} breakdown by iter
{open ? '▾' : '▸'} Breakdown by iter
</TextButton>
{open && (
<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 unitsCol = currentTotals?.unitsCol
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 && (
<tr>
<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>
</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
different type sizes and no shared label width, so the two stacked
into a paragraph of loose words rather than reading as two settings. */}
{(plugState || hasExcl || perSlice) && (
<div className="flex flex-col gap-2 mt-3 pt-3 border-t border-gray-100">
{(plugState || hasExcl || perSlice || settingsExtra) && (
// 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 && (
<Field label="absorbs" hint={plugState.why}>
<Field label="Absorbs" hint={plugState.why}>
<Segmented
value={plugState.value}
onChange={setScalePlug}
@ -1037,7 +1043,7 @@ function ScaleLedger({ currentTotals, scaleInputs, setScaleInputs, scalePlug, se
)}
{hasExcl && (
<Field label="target"
<Field label="Target"
hint={onTotal
? `${exclName} can't move — the forecast absorbs it`
: `${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 && (
<Field label="per slice">
<Field label="Per slice">
<span className="text-gray-500">figures above are combined totals</span>
</Field>
)}
@ -1272,7 +1278,7 @@ function RequestPreview({ payload, op, versionId }) {
<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>
<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)}
className="text-gray-600 hover:text-gray-800 text-lg leading-none">×</button>
</div>
@ -1332,6 +1338,15 @@ export default function OperationPanel({
// operation is named by the tabs beside it.
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 = (
<>
{activeOp === 'scale' && (
@ -1342,6 +1357,7 @@ export default function OperationPanel({
targetBasis={targetBasis} setTargetBasis={setTargetBasis}
logMeta={logMeta}
multi={multi} applyMode={applyMode}
settingsExtra={applyField}
/>
)}
{activeOp === 'recode' && (
@ -1367,7 +1383,7 @@ export default function OperationPanel({
reference ones operations are normally kept away from,
which is the point: a period with no baseline borrows its
shape from prior year or plan. */}
<Field label="rows from"
<Field label="Rows from"
hint={cloneFrom ? 'only that segment, out of the selection' : undefined}>
<select value={cloneFrom} onChange={e => setCloneFrom(e.target.value)}
className={`${TEXT} w-48`}>
@ -1382,7 +1398,7 @@ export default function OperationPanel({
<SectionLabel>changes</SectionLabel>
<Field label="shift dates"
<Field label="Shift dates"
hint={shifting
? 'every date moves — select the period you copy FROM'
: undefined}>
@ -1398,7 +1414,7 @@ export default function OperationPanel({
</datalist>
</Field>
<Field label="scale by">
<Field label="Scale by">
<input type="number" step="any" value={cloneScale}
onChange={e => setCloneScale(e.target.value)}
className={`${INPUT} w-28`} />
@ -1417,6 +1433,12 @@ export default function OperationPanel({
</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,
in the ledger and in the bridge. Completes from initiatives already
used on this source; free text is still accepted. */}
<Field label="tag" stack>
<Field label="Tag" stack>
<TagInput value={opTag} onChange={setOpTag} tags={knownTags}
className={`${TEXT} w-full`} />
{opTag.trim() && (
@ -1441,7 +1463,7 @@ export default function OperationPanel({
border. The same tags complete from the input's datalist, which
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)}
placeholder="optional" className={`${TEXT} w-full`} />
</Field>
@ -1472,10 +1494,9 @@ 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 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="flex items-start gap-4 flex-wrap px-3 py-2 border-b border-gray-200 bg-gray-50/60">
<Field label="Change">
<div className="inline-flex rounded border border-gray-300 divide-x divide-gray-300 shadow-sm overflow-hidden">
{['scale', 'recode', 'clone'].map(op => (
<Button key={op} onClick={() => setActiveOp(op)} active={activeOp === op}>
<span className="capitalize">{op}</span>
@ -1493,16 +1514,6 @@ export default function OperationPanel({
)}
</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 && (
<div className="px-3 py-2 border-b border-gray-200 bg-gray-50/60">
<SelectionList
@ -1535,7 +1546,8 @@ export default function OperationPanel({
style={horizontal
? { display: 'grid', gridTemplateColumns: 'minmax(0, 1fr) minmax(15rem, 20rem)', alignItems: 'start' }
: 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>
</div>
)}