Clicking the blank `partgroup` node under ALTMAN PLANTS selected the customer's
whole $27,588,393.62 (6,851 rows) rather than the $6,198,860.42 (2,421) under
the node clicked -- and the ledger agreed, because the panel totals through the
same filter list the operation writes from. The key was not narrowed, it was
absent, and an absent key means every value of it.
The drop is the engine's: `getCellConfig` builds the click's filter list with
`pivot_value ? [pivot, '==', pivot_value] : undefined`, so a blank group key is
gone before `perspective-click` is dispatched -- and being falsy, so is a
dimension whose value is 0. `cellFilters()` re-derives both axes from
`detail.row.__ROW_PATH__` and the clicked column name instead; the click and a
dragged region now share that one derivation rather than keeping two copies of
the same falsy test.
Both blanks are real here and they are different rows: `partgroup` is null on
51,964 and '' on 111,696. So they stay distinct end to end. `buildWhere` renders
a null as IS NULL, where it used to fall through `esc()` to `= ''` and select
*the other blank* -- the slice naming one node while the operation wrote the
other. In a list the null arm is ORed on, since `IN (NULL)` is never true. The
ledger filters with `is null` rather than `== null`, the engine matching on type
and a null threshold against a string column being the same dropped predicate.
Only `undefined` now means absent, which keeps a subtotal row (a shorter row
path) and a collapsed column level reading as "not part of the slice".
A blank on the *column* axis still cannot be isolated: a column name is its
values joined with '|' and a null joins as the empty string, so the two blanks
are the same text by the time it reaches the client. Those cells are refused
with a message rather than widened.
The panel prints (null) and (blank), and keeps em dash for a column genuinely
absent from the slice -- isolating the blank node and ignoring the column are
opposite statements and read identically before.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Wording variants are a user problem, but the completion was making it one:
a native datalist substring-matches, so typing "moved to different sku"
never offered "moved to a different sku" -- one inserted word and the
existing tag is invisible. Four spellings of that initiative are on the
current version, with a ledger line each.
TagInput matches on words instead. Both sides lowercased, stripped of
punctuation, relieved of stopwords and lightly stemmed, so Pulled Forward
finds Pull Forward. Typing something that reduces to an existing tag
without being it says so: already used as X, use that.
Nothing is merged automatically. The suggestion is offered and the typing
stays yours -- moved to another sku is genuinely a different sentence from
moved to a different sku, and only the person typing knows whether it is a
different thing.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A slice as broad as a whole bucket reached every entry on the version, and
the walk stopped being a walk -- roughly 44 lines on the version this was
built against, where there are about a dozen things to say.
Three causes, three fixes.
Untagged adjustments had a line each, labelled "Scale #226" -- a row spent
to say nothing, 28 of them. They collapse to one line that counts them.
Tags grouped by exact text, so "Keystone reduction" and "Keystone
Reduction" reported as two initiatives, as did four spellings of moved to a
different sku. Grouping is case-insensitive now and the line shows whichever
spelling was used most. The tags themselves are untouched; wording variants
are still separate lines, and that is for whoever typed them.
What is left is capped: the loads stay whole, since they are the shape of
the forecast, and the adjustments keep the six largest with the rest rolled
into one line carrying its own subtotal. Still arithmetically the walk --
addition does not care about the order -- and the rollup expands.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Clicking a held box pins it, and the only way out was to know that
emptying the field unpins it -- filled() is false on a blank either way.
Clicking into a greyed column to see what is in it and then wanting out
again is the ordinary case, not the awkward one.
An x on the column header clears that measure's edit, shown only while it
has one, and Escape in the box does the same and steps out. The panel's own
Escape handler already ignores events from inputs, so the two do not
collide.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Absorbed by asks which of price and volume moves, and then said so only in
a sentence underneath itself. The column it holds still now says it: the
header reads "holds" in place of its hint and the three boxes go flat grey,
so what you are editing is legible from the numbers rather than from prose
about them.
The sentence goes, since the greyed column is the same statement made where
it applies. The toggle stays -- it is an input, and with dollars alone
nothing in the data answers it.
A held box is still typeable, and doing so is how you override the toggle:
naming qty or price answers the question directly, which is why the control
only appears while dollars are the sole input.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Scaling dollars with price holding moves qty, but the qty column stayed
blank until the Result row -- so the number you had just implied was the
one thing not shown beside the number you typed. A measure with no edit of
its own now reads all three ways from where the edit actually lands it.
derived stays the edit-driven map, since it is what outcome is computed
from and back-filling into it would feed the result into its own inputs; a
separate shown map is what the rows render.
Clicking into a back-filled figure pins it, which is the difference between
"units may move" and "units are this". The write is the same either way
when the pinned number is the implied one, so the cost of pinning by
accident is nothing.
Reverts the muted styling for the derived rows -- all three boxes look like
boxes again.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
One box per measure is live; the other two are the same number said
differently. Both states were full input boxes differing by a border
colour, so three typed figures appeared where there was one typed figure
and two calculations -- and with six boxes on screen there was no reading
which one the adjustment would be built from.
The live box keeps its blue border and ground and gains weight; the derived
two lose their border and background and go muted. They are still editable,
which is what the border on hover says, and the title spells out that
clicking enters the number that way instead.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The Result row re-parsed the ledger's own display strings, which fmtNum
grouped with toLocaleString. parseFloat stops at the first separator, so
"3,456,789" came back as 3 -- a plausible small number rather than an
error, which is why it read as a formatting quirk instead of a wrong
answer. A 3% change on sales_usd previewed as 3.
It showed only where the new value was derived rather than typed: the field
you type into keeps your raw text, so entering a target directly looked
right while the % and Change rows did not.
derive now returns the three figures as numbers alongside the strings, and
outcome reads those. Nothing has to know how the display is grouped.
The write was never affected -- buildPayload reads scaleInputs[*].raw,
which LedgerInput has always kept comma-free -- so this was a wrong
preview of a correct write.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The % was a sibling of the input in a flex row, so it took width of its
own. In a right-aligned cell that pushed the percent row's box a suffix and
a gap to the left of New value and Change -- three ways of saying the same
thing, not lining up.
It now sits inside the box, over the input's right padding, so every row is
the same width against the same edge, and that edge is where the numbers
above it end too.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Absorbed by and Target applies to each hand-rolled their own label row and
their own hint, at two different type sizes, with no shared label width and
no rule separating them from the numbers above. Stacked, they read as a
paragraph of loose words -- label, two buttons, a sentence, label, two
buttons, a longer sentence -- rather than as two settings on the edit.
Both now use Field, like every other setting in this panel: one aligned
label column, the control, the hint below it in the same muted 11px. Per
slice joins them instead of trailing off as a bare paragraph. Target
applies to becomes Target on, which fits the label column without wrapping.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The six quick-pick pills under it wrapped onto two rows as soon as the tags
were longer than a word, which most are, and the operations panel docked at
the bottom has a fixed height to spend -- so the form ran past its own
border. The same tags complete from the input's datalist, which costs no
space and is not capped at six.
The datalist now carries the tag alone. An option's text content renders as
a second column beside the value, so the use count was widening every row
of the list to show a number nobody picks on.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Apply Scale sat there live and silent for the whole round trip, so on a large
slice the only sign anything was happening was the absence of a result -- and
a second click applied the change twice.
The button now spins, reads "Applying…", and refuses further clicks until the
write returns, with a line beside it saying the pivot updates when it
finishes. Cleared in a finally, so a failure releases it too.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
It started at the baseline, so it accumulated only the part that can still
move and stopped short of the number on the screen. Now it accumulates in
display order from the top -- billed, then booked, then the baseline, then
each adjustment -- and closes on Selected total.
Adjustable keeps its own figure but no running cell: it is a subtotal of the
walk, not a point on the line, and printing the running there would put two
different totals side by side in one row.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The lines sum to Adjustable, and checking that they do meant adding
eight-digit numbers in your head. The column closes on the Adjustable row, so
the walk visibly lands where it says it does.
Value only. A cumulative price is meaningless -- prices do not add -- and a
second running column for units doubles the width to say what the value
column already implies. It appears only when there is more than one line to
accumulate.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
-2000000 and -20000000 are the same shape at a glance, and the ledger deals
in both.
Display only: what leaves the input is always the raw string, so nothing
upstream ever sees a comma. Partly-typed numbers survive intact -- "1." and
"-" and "1.50" are all states on the way to a value, and reformatting them
into something else mid-keystroke makes the field unusable.
The caret is restored by counting digits rather than remembering an offset,
since inserting a comma shifts every character after it and a remembered
position lands one place off for the rest of the number.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The immovable rows sat between Adjustable and Selected total, which made
them read as an afterthought to a figure they in fact constrain. They come
first now: this much is already booked and billed, this is what is left to
work with, and here is how that got to where it is.
The walk stays immediately above Adjustable, because it sums to it -- the
two are one statement and separating them would leave a column of numbers
adding up to nothing on the page.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The walk read from tag and note, and hardcoded the word "Baseline" for the
baseline load -- so a segment called 03 - New Orders in the pivot, in the
bridge and on the Baseline page read as "Baseline" in the one place you go
to check a number before changing it. label comes first now, the same
precedence pf_segment uses, in the ledger and the bridge alike. logMeta did
not carry label at all, which is why neither could reach it.
The immovable rows split one line per segment. Combined, "01 - YTD Sales ·
02 - Open Orders" said 1.6m was untouchable without saying how much of it was
billed and how much was booked -- different things a forecaster treats
differently. The FINAL badge also gains the space it was missing, having
rendered as "02 - Open Ordersfinal".
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Refusing anything but == was safe and useless: a view bounded to
sseas_e <= 2027 is an ordinary way to scope a forecast, and it has no slice
form at all, a slice being {col: value}. The filter now travels beside the
slices as [col, op, value] triples and is ANDed onto every unit -- not folded
into the slices, since it applies to all of them equally and under
apply_mode 'each' would just repeat itself in every statement.
Operators are Perspective's, since that is where they come from, and the list
is a whitelist: anything outside it is refused rather than ignored, because a
scope silently dropped is a write wider than the panel that authorised it.
The scope goes into the log's params too, so the audit trail records what
bounded the write and not only what was clicked.
The panel prints it above the selection as "within sseas_e <= 2027". It
scopes every figure below it and every row the operation writes while
appearing in none of the slices, so without it the panel showed a selection
wider than the one it was acting on -- which is exactly what made the
ledger's 956,485.13 look plausible against a cell of 921,225.71.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The phantom: pf_segment and pf_bucket are computed from pf.log when the rows
are served, so buildWhere had no column to compare and dropped them. Clicking
one bucket's cell and scaling therefore wrote every bucket at that dimension
intersection, while the panel showed only the bucket clicked. On the example
slice that is 350,524.74 displayed against 503,446.08 written.
They resolve exactly, without a new column: the name lives on the log row and
every forecast row carries the pf_logid that points at it, so the predicate is
pf_logid IN (SELECT id FROM pf.log WHERE <the same expression> = ...). Verified
against version 29 -- the clause returns 350,524.74 over 12 rows.
Any other pf_ key is now refused rather than skipped, since skipping is the
mechanism by which a selection silently widens. pf_iter stays exempt: the
client drops it deliberately, two cells differing only by iter band being the
same slice.
Client side they are ordinary columns in the loaded table, so both the
dispatch path and the panel's own totals filter on them directly -- the latter
matters as much, or the ledger reconciles against a wider selection than the
operation writes.
The ledger: excluded rows read "02 - Prior Year · FINAL" in amber rather than
"reference · fixed" -- named by the segment a forecaster recognises instead of
the iter band that happens to exclude it, and coloured because immovable is a
property worth seeing before reading a number. When the whole selection is
immovable it now says so in a sentence, where before it printed a row of zeros
and left the reason to be inferred from the edit rows failing below.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The clone form was four controls whose labels ran to different lengths --
"copy rows from", "scale cloned rows by", "tag", "note" -- each row starting
wherever its label happened to end, with a preview sentence floating after
them and a row of unexplained chips between the tag and the note. Nothing
separated where the rows come from, what happens to them, and what the
change is called.
Every row now shares a label column, so the controls line up, and the three
groups carry headers in the same 10px uppercase the ledger headers already
use. The tag suggestions sit inside the tag's own column, where they read as
values for it. Hints appear under the control they qualify and only when
relevant.
Also: the preview said "Copying 0.00 sales_usd" while five AOP rows worth
68,904 were selected. It reported the adjustable total, which is zero when
the selection is entirely prior year or plan -- the case clone exists for,
now that it can read reference rows. It counts them for clone, and still
does not for recode, which cannot touch them.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The offset field was rendered only when a segment had been named, from when
that was the only way clone could reach reference rows. Once clone could read
them directly the field became unreachable in the ordinary case -- so
"shift dates by" was invisible and the only typeable-looking control was
"copy rows from", which is a dropdown.
It is shown whenever clone is open now, and the payload carries the offset
independently of from_logid: shifting a selection through time is the common
case, and narrowing to one segment is the occasional one.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Shifting backwards already worked -- the offset is added as a Postgres
interval, and '-90 days' is a perfectly good one -- but the suggestions only
listed forward shifts, so nothing said so. Pulling a plan back a quarter is
a normal thing to want.
A day-level shift is also the case that most needs the dim_period
derivation: 15 Mar 2027 less 90 days is 15 Dec 2026, which crosses from
2027/10 - Mar into 2027/07 - Dec. Copying the period columns across would
have labelled it March.
The offset is interpolated into the statement, so a typo surfaced as a
Postgres parse error from the middle of a CTE. It is parsed on its own
first, where the failure is cheap and can name the field.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The slice filters the rows being copied, so cloning prior year forward means
selecting last December and shifting it, not selecting the December you want
to fill. Selecting the destination matches nothing -- prior year's rows are
labelled with prior year's periods, so a 2027 slice and a 2026 segment share
no rows -- and it fails silently, as zero rows cloned.
Nothing in the form said which way round it was, and "copy from <segment>"
reads like the selection is the target. It now says the selection is what
gets copied, and the segment narrows it rather than replacing it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Three changes, one feature: a period with no baseline borrows a shape from
prior year or plan.
The generator handled one date group. sql_generator.js used find(), so a
source with order, requested and ship date groups derived the order one and
copied the other two raw -- shifted dates against unshifted period labels.
Every group now gets its own join, and they are LEFT rather than inner: an
order that has not shipped has no ship date, and an inner join would have
dropped the row from the load entirely rather than leaving its period
columns empty. dateGroupsOf() is now shared, like grainOf, so the routes and
the generator agree on membership and on join aliases.
Clone carries every date column rather than only the primary one, since it
is the operation that moves rows through time, and re-derives the period
dimensions from pf.dim_period against the shifted date instead of copying
them from the row being cloned.
from_logid names the segment to copy from, replacing the exclude clause for
that one entry rather than widening it. Rows written stay pf_iter = 'clone',
so scale applies to them afterwards as a second step.
set is no longer required: copying a segment forward unchanged is a
reasonable thing to ask for.
Needs dim_period_col set in Setup (fisc_year on oseas/rseas/sseas_e,
fisc_month_abbr on omon/rmon/smon_e) and Generate SQL.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Every question about a part -- what values exist, what attributes go with
one -- was answered by querying the source, and the source is the wrong
place to ask. It is a view over a transaction table, so the query is slow
(76s for one ILIKE against 6.9M rows), it describes only what was
transacted, and it cannot express intent: there is no way to say a part is
discontinued, or to name one that has not sold yet.
pf.dim_member holds the app's own list: one row per key value per group,
siblings in jsonb, keyed on (source_id, dim_group, key_value). Refresh is a
merge rather than a replace, so curation survives it -- members absent from
the source are marked source_seen = false, not deleted. Triggered from
Setup, next to Generate SQL, because it reads the whole source and the
answer only changes when the catalogue does.
A key can carry several attribute sets across history -- 11,290 parts
against 13,662 combinations on osm_skinny -- so the refresh takes the most
recent by the source's date column. That also fixes the sibling autofill,
which used to run a DISTINCT ... LIMIT 2 against the source and silently
fill nothing whenever a part came back ambiguous. A member row is one
definition by construction.
The client fetches each group's list once per source and does both
completion and autofill against it in memory, so neither costs a request.
Columns outside a group, or a group never refreshed, still fall back to the
version's values endpoint.
Run 01_schema.sql to create the table.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Pointing completion at the source made every keystroke a 76-second query:
gs.osm_skinny is a plain view over rlarp.osm_stack, so ILIKE '%1601%'
scanned 6.9M rows and read 1.3M buffers off disk, 64s of it I/O. Nothing had
called that endpoint before, so the cost only appeared once a debounced
input was wired to it.
The source is the wrong list anyway. It reaches back over all of history and
would offer parts discontinued years ago; the version holds what was
actually loaded, which is what the forecast is being written against.
So GET /versions/:id/values/:col reads the version's own forecast table --
2.0s for 11,290 parts on fc_osm_skinny_29 -- and holds the result in memory,
keyed on that version's latest pf.log id. Any load, adjustment or undo moves
the id and the next request rebuilds, so nothing has to remember to
invalidate. Filtering happens over the cached array, so typing costs one
small max(id) query.
The column name is interpolated into the DISTINCT, so it is checked against
col_meta first.
The source-side endpoint keeps its new q/limit but no longer has a caller;
its ILIKE path against a view is the expensive one and should stay unused.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Recoding to a part meant typing a code from memory into a plain text box,
with the only feedback being that the sibling autofill either fired or
silently did nothing.
The values endpoint already existed but returned every distinct value with
no filter and no limit, which for part on osm_skinny is 11,290 rows -- too
slow to open and no easier to read than a short list. It now takes ?q= and
?limit=, so the field fetches matches for what has been typed so far,
debounced 200ms, and offers them through a native datalist.
Only key columns get it, which is the same condition the endpoint already
enforced, and the same one that decides whether the dim_group sibling
lookup runs on blur. So on osm_skinny it is part and customer: type 1601
and the eight parts containing it are offered; pick one and the nine
part-group columns fill themselves.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
sales_usd and qty run to eight digits here, where two decimals are noise.
Price is the opposite case: it sits around 0.27, so two places barely show a
move and four still round away part of one.
derive() hardcoded two decimals, so the editable rows would have disagreed
with the lines above them. It now takes the measure's own precision. The
percentage row keeps one place, being its own scale.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Price was blanked on the bridge lines and on the excluded row, so a ledger
that had both measures on every line showed a price on two of them. Baseline
sat there as 2,242,180.67 over 8,111,717.10 with the column empty.
A bridge line's price is the implied price of that initiative's own
contribution -- Scale #116 above works out to 0.2397 against a current
0.2735 -- which is the number that says whether the initiative was a price
move or a volume move. That is the same question the plug toggle asks about
the edit, answered for the adjustments already made.
A line with no units is left blank, since its price is undefined rather
than zero -- the "price" bridge line in that example moved dollars alone.
The price column's hint said "holds units", which stopped being true when
plug arrived: it holds units under plug = price and moves them under plug =
volume. It now just says what the column is.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Each ledger row derived only from its own input, so typing a dollar figure
left the units and price rows blank -- which are precisely the two numbers
that say whether you just asked for a volume change or a price change. The
plug toggle decided it and then showed you nothing.
Adds a Result line under the edit rows carrying value, units and price
together, resolved the same way the server resolves the increments: a price
input multiplies through volume, a dollar input with plug = volume scales
units in proportion, anything else holds units. Measures that actually move
are picked out in blue against the ones that hold, so plug = price reads as
price moving alone and plug = volume as price standing still.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A sales figure on its own does not say which of price or volume moved, and
scale was answering silently: it wrote the value increment and left units
alone, so volume stayed flat and price absorbed everything. That is one of
the three behaviours the Excel form offered, picked by default rather than
by the user.
/opt/forecast_api/VBA/fpvt.frm has the semantics. Edit Sales, then plug one
side:
plug volume: pchange = fVal/(pVal+bVal); fVol = (pVol+bVol)*pchange
plug price: fVol = pVol + bVol
So 'volume' moves units in the same proportion as the dollars, holding
price; 'price' leaves units alone, as now. Checked against those formulas:
+100 on 1000/500 gives units 500 -> 550 with price held at 2.0000, and
-400 on 2000/800 gives 800 -> 640 at 2.5000.
Price still defaults, so nothing changes for an existing caller that does
not send `plug`. The control only appears once the edit is dollars-only --
naming units or price has already settled the question. A price target now
also honours a units target alongside it, which is the form's Edit Price
mode where both are inputs and dollars fall out.
Holding price when the selection has no value is refused rather than
divided by zero, the same case the form guarded with "Zero times any number
is zero".
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Forecast operations could only act on one clicked row at a time, and the
panel that drove them separated the numbers you were reading from the
inputs that changed them. This reworks both, and adds initiative tags so
a version's history can be read as a bridge.
Operations
- Accept `slices` (array) alongside the legacy single `slice`, with
apply_mode 'prorate' (one pool) or 'each' (independent per slice).
- buildWhereAny() ORs the slices into one predicate. A union of slices
cannot be flattened into per-column IN lists without over-selecting,
and the result is parenthesised so the appended exclude clause does not
bind wrong.
- resolveIncrs() now resolves each measure independently: target, percent
or change amount per measure, so a target on value and a percent on
units can be submitted together. Replaces the single global `mode`.
- target_basis chooses what a target measures against: only the rows an
operation can write, or everything the pivot shows for the slice.
Excluded iters are visible in the grid but immovable, so a target set
against the visible total previously overshot by their contribution.
Two latent bugs surfaced by the above, both pre-existing:
- A slice naming no filterable column reduced to TRUE and applied the
operation to the entire version. Now rejected on all three operations.
- Prorating across a pool that nets to ~zero multiplies each row's share
by an exploding factor, sending rows to extreme opposite values to hit
the target. Refused when the net falls below 1% of gross.
Tags and the bridge
- pf.log gains a nullable `tag`, written by a follow-up UPDATE rather
than through the generated SQL: those templates are stored per source
in pf.sql, so a {{tag}} token would strand any source that had not
re-run "Generate SQL".
- Tag is editable after the fact in the change log, with completion from
tags already used on the source. PATCH branches on whether a field was
sent, so a tag can be cleared as well as set.
- BridgeView renders the walk from baseline to current as a waterfall,
one step per tag, scoped to the selection, the pivot's filters, or the
whole version. Computed from the loaded Perspective table so the
figures always reconcile with what is on screen; overlapping slices are
deduped by pf_id to match the OR semantics operations use.
- Colour is a polarity job, so it uses the validated diverging pair
(blue/red, CVD dE 21.6) with neutral anchors, not categorical hues.
Every bar is directly labelled and a table view is available.
Panel
- Extracted to OperationPanel; the scale form is one continuous ledger:
baseline, each adjustment, current, then New value / Change / % change
as three interchangeable editable rows. Typing in any one derives the
others, which removes the target/delta/percent mode toggle entirely.
- Dockable bottom, right, or floating (drag to move, grip to resize), and
closable via header, Esc, or the toolbar. Placement persists.
- Controls no longer stretch to the dock width, and text contrast now
clears WCAG AA against white throughout.
Also: the status bar names the physical table writes land in, with live
row counts; and the pivot's expand depth is re-applied when the tab
regains focus, since Perspective rebuilds its view on redraw and a
ROLLUP view with no depth set renders fully expanded.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W1TQiBYZbbWWkMNoCtUd8M