diff --git a/ui/src/components/OperationPanel.jsx b/ui/src/components/OperationPanel.jsx
index a033f50..723f8af 100644
--- a/ui/src/components/OperationPanel.jsx
+++ b/ui/src/components/OperationPanel.jsx
@@ -61,11 +61,13 @@ function Block({ title, hint, horizontal, grow, children }) {
)
}
-function Button({ onClick, active, children, title }) {
+function Button({ onClick, active, children, title, disabled }) {
return (
-
)
@@ -99,11 +101,16 @@ function SectionLabel({ children }) {
)
}
-function Segmented({ options, value, onChange }) {
+// `disabled` keeps the control in place and shows which way it is set rather
+// than removing it. A control that vanishes reads as broken; one that greys out
+// says the choice has been made elsewhere.
+function Segmented({ options, value, onChange, disabled }) {
return (
-
+
{options.map(([val, label, title]) => (
-
+
))}
)
@@ -731,6 +738,34 @@ function ScaleLedger({ currentTotals, scaleInputs, setScaleInputs, scalePlug, se
? (scalePlug === 'price' ? 'units' : 'price')
: null
+ // Which measure is absorbing the change, whether or not the toggle chose it.
+ //
+ // The toggle used to be rendered only while the edit was dollars-only, so it
+ // was absent before anything was typed and disappeared the moment a measure
+ // was pinned -- including by a click, since clicking a cell pins whatever it
+ // reads. Two different states both looked like the control was missing, and
+ // an accidental click into the qty column had no visible effect except that
+ // the control went away. It now stays put in every state and greys out when
+ // the inputs have already answered it, which they genuinely have:
+ // qty pinned -> price is what moves
+ // price pinned -> qty is what moves
+ // so the inert control still shows the truth rather than a stale choice.
+ 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) return { value: 'price', live: false, why: `${unitsCol} is pinned, so price absorbs the change.`, 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.' }
+ return {
+ value: scalePlug,
+ live: true,
+ why: scalePlug === 'price'
+ ? `${unitsCol} holds; price moves to reach the number.`
+ : `Price holds; ${unitsCol} scales with the dollars.`,
+ }
+ })()
+
const numCell = 'text-right font-mono tabular-nums whitespace-nowrap px-2'
const rule =
@@ -909,27 +944,37 @@ function ScaleLedger({ currentTotals, scaleInputs, setScaleInputs, scalePlug, se
- {/* A dollar figure on its own does not say whether price or volume moved.
- Only ask once the edit is actually dollars-only -- naming units or price
- has already answered it, and the control would just be noise. */}
+ {/* A dollar figure on its own does not say whether price or volume moved,
+ so the toggle answers for it. Kept visible even when it is not the one
+ answering -- see plugState -- because a control that comes and goes
+ reads as a fault, and its absence was the only sign that a stray click
+ had pinned a measure. */}
{/* Both of these are settings on the edit, so they use Field like every
other one -- an aligned label column and a hint below the control.
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. */}
- {(valueCol && unitsCol && dollarsOnly || hasExcl || perSlice) && (
+ {(plugState || hasExcl || perSlice) && (