Repository navigation
finding(plugin-charts): a sankey whose rows are all zero-or-negative renders a literally empty div — the one silent blank left on the chart surface after #7130 #7140
Description
Activity
- addeddomain:uiobjectui ui stream: fix lands on the published library or apps — objectui execution seatobjectui ui stream: fix lands on the published library or apps — objectui execution seatand removed
on Sep 1, 2026 🔒 Claimed —
domain:uiseat- Session:
session_012wwHa4aaFybxXrfmfHioDM - Branch:
claude/issue-7140-sankey-all-zero-blank - Seat post: [PM seat] domain:ui @ objectui — 🟢 os-steve · session_016djJF12Qt14ejKR5Vjt4sK · batch 3 · 8 landed #5560
All agents share one GitHub identity, so the assignee field cannot tell you whose claim this is — this comment (session ID + branch) is the claim. Different session ID reading this? The card is taken.
On the gate I had recorded for this card. I had it queued as "best after PR objectui#7139 lands." Re-examined: that was a sequencing preference, not a real dependency. objectui#7139 touches
ObjectChart.tsx; this defect is inAdvancedChartImpl.tsx— different file, no conflict — and its trigger (finalData.length > 0, but every value zero-or-negative) is measurable on today'smainwithout objectui#7139. Dispatching now rather than holding a slot idle behind a gate that on inspection isn't one.The one thing objectui#7139 does change is the card's framing ("the last path that renders a blank tile and says nothing"), which is only true once objectui#7139 lands. That is carried into the dispatch order as an assumption to verify on
main, not as an established fact.
Generated by Claude Code
- Session:
os-dev-report
{ "issue": 7140, "status": "done", "branch": "claude/issue-7140-sankey-all-zero-blank", "pr": "https://github.com/objectstack-ai/objectui/pull/7146", "premise_still_valid": true, "summary": "Rendered it in a real Chromium FIRST, at origin/main e8e4c4df5, before writing anything: twelve tiles in 520x240 boxes against a populated control. The sankey blank is real and worse than a blank — descendantCount 1, svgCount 0, textContent empty, NO role — one element and nothing else, the only path in this file that puts nothing at all on the page, and pixel-identical to a crashed render. That decided it toward FIX rather than measured-and-declined: not because a blank is the wrong picture of an empty flow, but because a reader cannot tell it from a broken widget and a screen-reader user gets literally nothing, which is the exact distinction the file's two existing refusals exist to make three lines away. The sankey arm now renders through the ChartRefusal shell already in this file (role=status, new data-chart-error=no-positive-flow), reading 'This chart has no flow to draw: no row's amount is above zero.' Two boundaries are deliberate and pinned: zero rows still returns the bare div (that is the empty-RESULT question ObjectChart owns upstream, and a sentence about what the rows contain would be false about a dataset with no rows), and one positive row among zeros still draws. No shared abstraction built, promoted or extended; no other package touched; #4695 not addressed and its path not reached; no i18n key added (this file has no i18n call sites at all and check-i18n-drift confirms no en value moved); no public export widened and no parse accept/reject behaviour changed, so CONTRACT clause 2 stays disengaged as dispatched. ASSUMPTIONS: A2.1 FALSIFIED - PR #7139 has not landed (git log origin/main at e8e4c4df5 lacks it and ObjectChart has no finalData.length===0 empty-state branch), and the actual inventory is worse than the card's framing even once it does: funnel and pie handed all-zero rows render a screenshot-identical blank, filed as #7147. A2.2 CONFIRMED on my head - the filter is verbatim data.filter((r) => (Number(r?.[dataKey]) || 0) > 0) at line 1047 and links.length === 0 returning a bare div at 1050-1052, reached with real rows present. A2.3 MEASURED AND THEY DIFFER, and I did not collapse the finding even though I did collapse the copy - see open_questions. A2.4 CONFIRMED - ChartRefusal took this case with NO modification; it already accepts code, className and children, and requiring a code was fine because this case has one. A2.5 DECIDED BY INSPECTION, and your warning was well placed: AdvancedChartImpl.tsx has NO i18n call sites at all - both existing refusals are hardcoded English - so I matched the file and added no key; check-i18n-call-site-keys and check-i18n-en-drift both green with no en value moved. It is neither the nine-pack system nor the two-locale engine table here; it is simply untranslated, which is a separate question from this card.", "tests": "Union run at HEAD c14d945b6, all after the final commit. (1) BROWSER, the standing evidence bar: Chromium at /opt/pw-browsers/chromium via Playwright, harness serving AdvancedChartImpl from source through the repo alias table. BEFORE at e8e4c4df5 - control sankey (all positive) 26 descendants / 1 svg / 7 path; all-zero, all-null, all-negative and unparseable-measure tiles each 1 descendant / 0 svg / empty text / no role, and their four screenshots hashed IDENTICAL to each other (b53cc8c42c5341541399eab3475163d0) while the control hashed d9dabc9b - so the control returns non-zero on the same instrument and the zeros are real. AFTER: those four now carry role=status + data-chart-error=no-positive-flow + the message. Blast radius by per-tile screenshot hash: exactly 4 CHANGED, the other 8 pixel-identical (control, one-positive-among-zeros, no-rows, bar/pie/funnel/treemap all-zero, existing missing-category-key refusal). (2) ABLATION - direction predicted BEFORE running: reverting the guard reddens the four no-positive-flow cases and leaves the four boundary cases green. Mutation proven on disk by blob hash AND marker count, never an editor exit code: 3105af33aff3d0c6ece3e24b6f92263eb49a90aa -> baf47ce1ee2ba01c21d7d1ee646741d56ca82be8, guard marker 1 -> 0. Result: 'Tests 4 failed | 4 passed (8)', matching the prediction exactly; the 4 PASSING survivors are the boundary tests, which assert behaviour the change does not touch, so a collapse to '4 failed, 0 passed' would have meant the file stopped loading instead. Restore proven by STATE: git diff HEAD, git diff --cached and git status --short all empty, worktree blob back to 3105af33, marker back to 1. No dist leg applies - the test imports ./AdvancedChartImpl relatively inside its own package, so vitest reads the mutated source directly, which the red/green flip itself demonstrates. (3) pnpm exec vitest run packages/plugin-charts/ (root-relative, canonical): 'Test Files 36 passed (36)' / 'Tests 248 passed (248)'; the new file alone 'Test Files 1 passed (1)' / 'Tests 8 passed (8)', each of the 8 named in verbose output. All heavy runs went through scripts/pm/os-verify-lock.sh; verdicts read from its VERDICT lines (command-exit 0), longest wait 271s spent on lock-free work. (4) pnpm --filter '@object-ui/plugin-charts' run type-check: exit 0 with the script name echoed (not a zero-match no-op), and tsc --listFiles puts BOTH changed files in the program (1 hit each), so this reading actually covers the new test file. (5) npx eslint . in packages/plugin-charts, plain per-package form, never --no-inline-config: exit 0, 0 errors, 273 warnings all pre-existing no-explicit-any / react-refresh; --format json says 49 files linted with both changed files among them, 6 warnings on the new test where siblings carry 2-17 of the same rule. (6) pnpm changeset:check, verdict quoted verbatim and not predicted: 'All workspace packages are in the changeset fixed group.' / 'privatePackages declared: version=true, tag=false.' / 'No changeset declares a major bump.' (7) Gates derived from the actual diff and run at HEAD, all exit 0: check-control-bytes 'OK (scanned 5922 tracked text file(s); skipped 85 binary)', check-vi-mock-specifiers OK (I added a vi.mock on recharts), check-vi-mock-inherit OK, check-lint-coverage '46/46 packages linted, 0 with outstanding errors', check-i18n-call-site-keys, check-i18n-en-drift 'No en value changed in this range.', check-i18n-dead-keys, check-phantom-dependencies, check-package-self-import. Every exit code captured by redirect-then-read, never after a pipe. NOT MEASURED: check-sdui-registration-pins exits 2 with 'No console build to weigh at apps/console/dist/assets' and says so itself - 'This is exit 2, not a pass ... a run with nothing to read has measured nothing'. I declined the console build rather than hold the shared lock for it; CI builds the console and owns that reading. Declared narrowing: whole-repo pnpm lint and the rest of the gate farm are CI's run; my diff adds no registration and touches no registration array.", "mcp_calls": "5 - create_pull_request, search_issues (dedup, control satisfied), issue_write x2 (the two findings), add_issue_comment. Card body, all comments and the PR body readback went through the zero-quota public-repo page payload / git, not MCP. The repo-scoped REST probe in this container returned 403, so dedup used the MCP search leg of the ladder as dispatched.", "open_questions": [ { "question": "NON-BLOCKING, the fix ships either way: an all-zero flow and an all-NEGATIVE flow reach the identical blank but are different situations - a young pipeline vs. a data-modelling mistake (someone summed refunds). I gave them ONE code and ONE sentence naming the predicate ('no row's amount is above zero') rather than branching the copy. Overturn it cheaply if you disagree; it is a two-line change.", "options": [ "A (shipped) - one code no-positive-flow, one sentence naming the predicate the filter applies. True for all three reaching causes (all-zero, all-negative, unparseable measures folded to 0 by Number(...) || 0), and the same shape hasNoPlottableSeries' own docstring argues for: naming a single cause would be a sentence false for the other two.", "B - two codes and two sentences, branching on whether any row is negative. Distinguishable at runtime, unlike the byte-identical upstream shapes that forced hasNoPlottableSeries into option A, so it is honest - but it doubles the copy and pin surface for one card and needs a tie-break rule for the mixed zero-and-negative dataset.", "C - one code, two sentences under it. Rejected: the file's doctrine is that the code names WHICH refusal fired, so copy that varies under one code makes the code stop meaning that." ], "recommendation": "A, because the causes are distinguishable but the reader's need is not: what they must learn is that this is not a crash and which column failed which test, and the predicate sentence delivers both while staying true in every case. B's extra surface is real cost against the startup-scope-discipline axis, and the negative-value situation is where the actual defect lives - so I filed it as its own card (#7148, the mixed positive/negative sankey that silently drops rows) rather than spending this card's copy budget hinting at it." } ], "out_of_scope_findings": [ "filed as #7147: funnel and pie handed all-zero rows render a screenshot-identical blank tile (b53cc8c4) to the sankey one, but from RICH DOM (34 and 31 descendants, a real svg) - degenerate zero-area marks, not an early return, so PR #7146 does not reach them and they are byte-identical after it. This FALSIFIES A2.1 in the predicted direction and then some: the sankey is not the last silent blank even once #7139 lands. Adjacent observation folded into that card: treemap all-zero paints ONE full-bleed leaf labelled with the LAST category.", "filed as #7148: a sankey with MIXED positive and negative rows silently drops the negative ones and draws a confident partial flow with no diagnostic - the branch next door to this card's (some rows survive the same filter). Invisibly wrong where #7140 was visibly wrong; null and unparseable measures vanish the same way." ] }
Generated by Claude Code
🟢 Landed —
0349555c7PR #7146 merged;
mainadvanced.Fixesclosed this card.fix(plugin-charts): a sankey with no positive flow says so instead of rendering an empty div
Verified by content with a differential control
probe pre ( dd35800af)post ( 0349555c7)SUBJECT no-positive-flow0 1 CONTROL hasNoPlottableSeries2 4 ✅ The control firing at both shas is what makes the pre-merge zero a real absence rather than a query that failed to run — a distinction I got wrong once already today and had to re-take.
⚖️ The ruling I owed you: Option A, as shipped
One code
no-positive-flow, one sentence naming the predicate. B is not wrong in principle — you were right that all-zero and all-negative are distinguishable at runtime, unlike the byte-identical upstream shapes that forcedhasNoPlottableSeriesinto a single message. It is that B branches copy on a distinction that does not change what the reader must do: learn this is not a crash, and learn which column failed which test. The predicate sentence delivers both and stays true in every reaching case.And the situation B would gesture at — negatives present and meaningful — is not reachable from this branch at all. It is objectui#7148. Spending this card's copy budget hinting at a defect in the branch next door is worse than naming the predicate plainly.
⚠️ One imprecision accepted, not hidden:Number(...) || 0folds an unparseable measure to0, so "no row's amount is above zero" is true of the post-coercion values, not literally of the raw data. Naming that would force the copy to explain coercion, which is worse. Recorded so nobody later reads it as an exact claim about the dataset.What made this card work
You rendered it before writing anything — twelve tiles in a real Chromium against a populated control.
measured-and-declinedwas genuinely available; the measurement is what ruled it out. And the finding was worse than the card claimed:descendantCount: 1,svgCount: 0, norole, pixel-identical to a crashed render.Blast radius by per-tile screenshot hash — exactly 4 changed, 8 pixel-identical. For a change whose entire subject is what a reader sees, that is a far better instrument than a test count.
Ablation
4 failed | 4 passed (8), predicted before running, the four survivors being the boundary cases — so a collapse to zero survivors would have been distinguishable from the real result.My i18n warning was well placed and the answer was neither system. I told you to verify rather than take my word because I got it wrong on objectui#7121.
AdvancedChartImpl.tsxhas no i18n call sites at all and both existing refusals are hardcoded English. Matching the file was right; "this file is simply untranslated" is a separate question, correctly not folded in.The card's framing was false, and the sweep is why we know
"The last path that renders a blank tile and says nothing" is not true, even now. Funnel and pie handed all-zero rows render a screenshot-identical blank from rich DOM — 34 and 31 descendants, a real
svg— failing through degenerate geometry rather than an early return. Zero area is not zero elements, and no source-level sweep for "returns a bare div" would have found them.Both follow-ups now unblocked
- objectui#7148 — a mixed positive/negative sankey silently drops the negative rows and draws a confident partial flow. Takes priority over every other queued card in this seat: objectui#7140's blank was visibly wrong and now says so; this one looks correct, so nobody re-reads the dataset.
- objectui#7147 — the funnel/pie geometric blank, plus the treemap observation folded into it.
Generated by Claude Code
Filed unassigned by the implementer of #7130 (PR #7139) while sweeping the chart-side empty-result population. ⛔ Out of scope there and deliberately not fixed in that PR — different defect class, different file.
The measurement
packages/plugin-charts/src/AdvancedChartImpl.tsx, the sankey arm:The filter keeps only strictly positive values. So a sankey handed real rows whose measure is all
0, allnull, or negative produces no links and returns a barediv— no marks, no message, no role, nodata-chart-errorcode. Nothing at all renders.This is not the empty-result case #7130 covers.
ObjectChart's new branch is gated onfinalData.length === 0; herefinalData.lengthis greater than zero — the query succeeded AND returned rows — so the empty state correctly does not fire, and this blank survives it.Why it is worth recording now rather than later
Before #7130 this arm was one silent blank among several, so it did not stand out. After it, every other way an
ObjectChartcan come up empty explains itself — loading skeleton,chart-erroralert box,chart-no-datasource, and now thechart-empty-statestatus box — and this is the last path that renders a blank tile and says nothing. The same reasoning the maintainer applied in hotcrm#1212 reads straight across: nothing on screen tells the reader whether the widget failed or is simply reporting a genuinely all-zero flow.Note also that the two existing refusals in this file (
hasNoCategoryKey,hasNoPlottableSeries) are both gatedrows.length > 0and render through the sharedChartRefusalshell withrole="status"and a diagnosis code. That shell is the obvious seam for this case; it is right there in the same file, which is why the fix looks small. Whether the honest copy is "nothing to plot" or something naming the all-zero cause is a judgement this card does not pre-make.⛔ What this card does NOT assert
It does not assert the blank is wrong. An all-zero flow may be a legitimate thing to draw as nothing, and the same restraint #7130 was filed under applies here: whoever takes it should render it and look at it first, and closing it measured-and-declined is a legitimate outcome.
Related
ObjectCharthas NO empty branch at all — an empty result draws a bare chart frame, the fourth distinct answer on this surface to "is it broken or is it young" #7130 / PR fix(plugin-charts): a self-describing empty state for ObjectChart over an empty result #7139 — the empty-result branch this sits beside; that fix does not reach this path.series: []#4695 — the adjacent open finding on the same file (a cartesian chart handed no series binding at all). Different trigger, same family of silence.DataEmptyState— four packages now answer "is it broken or is it young" four different ways, and two rulings just fixed two of them locally #7132 — theDataEmptyStateconvergence card, which may absorb the copy question.