fix(plugin-charts): pie, funnel and treemap say when rows carry no magnitude they can draw - #7169

Merged
os-warren merged 1 commit into
mainfrom
claude/issue-7147-degenerate-geometry-blank
Sep 1, 2026
Merged

fix(plugin-charts): pie, funnel and treemap say when rows carry no magnitude they can draw#7169
os-warren merged 1 commit into
mainfrom
claude/issue-7147-degenerate-geometry-blank

Conversation

@os-warren

Copy link
Copy Markdown
Collaborator

Fixes#7147

Re-derived on origin/main40c4711 (the head this branch forks from; it already carries PR #7146's no-positive-flow refusal and PR #7161's ChartFootnote). The card's snippets and line numbers were stale, as PM assumed.

Rendered it and looked first

74 tiles in real Chromium (/opt/pw-browsers/chromium) — 8 chart families x 9 datasets, plus a blank reference and a live console control. Every tile screenshotted, MD5'd, and pixel-diffed against a literally empty div of the same box. measured-and-declined was genuinely on the table for each family separately. It survived for radar and bar, and for none of pie / donut / funnel / treemap.

The instrument's discriminating power, from its own controls: a two-row pie differs from a one-row pie by 9.683% of its pixels, a two-row treemap from a one-row treemap by 38.301%, and an all-zero bar puts 5,128 ink pixels (axes and ticks) against a blank tile's 0.

Per-family verdict

familydatasetwhat a reader saw at 40c4711verdict
pie / donutall-zero, all-null, all-negative0 non-white pixels of 124,800 — byte-identical to an empty div, while the DOM carried 31 descendants and a real svgfix
pie / donut40 beside a nulla full circle in the first category's colour — 99.35% pixel-identical to a legitimately one-row dataset (the 0.654% residue is the paddingAngle hairline, not information). The picture asserts "Alpha is 100%" of a dataset where Beta was never measuredfix
funnel40 beside a null178 ink pixels: zero segments, one label — and the label reads "Beta", the row with no value. The row carrying 40 drew nothing at allfix
funnelall-negativea confident two-band funnel whose mark area (220,320) exceeds the all-positive control's (111,881)fix
treemap40 / null, 40 / 0, 40 / -25 / -12all three byte-identical (diff 0.000%) to a genuinely one-row treemap — one full-bleed leaf labelled "Alpha". Four datasets, one imagefix
treemapall-zeroone full-bleed leaf labelled with the last category — as the card's adjacent observation recordedfix
barall-zero5,128 ink pixels of axes, ticks and a zero-labelled scaledeclined — its reader can already tell
radarall-zerogrid and axes drawn, and hashes distinct from both its all-positive and its one-row controldeclined

The mechanism is the same across all three families, and it is neither landed answer

The rows are never filtered. data reaches the pie, funnel and treemap elements whole — PR #7161 pinned that the sankey arm holds the only row-dropping filter in this file, and that still reads true at this head (AdvancedChartImpl.tsx:1081, the other two .filter( sites filter series). What happens instead is that the layout gives a non-positive row no area.

So PR #7161's count (data.length - rows.length) is exactly zero against these families. A hoisted copy of that footnote would have rendered nothing while looking like coverage. Confirmed rather than assumed: the sankey tiles are the only ones in the sweep carrying omitted-rows.

A2.3 was falsified in one respect and it changed nothing. Pie and funnel are not the same failure at the Recharts level — an unsizable pie row produces no sector element at all (path.recharts-sector = 0), whereas an unsizable funnel row produces a NaN-width trapezoid that also poisons its neighbour (which is why 40 beside a null loses the row worth 40, not the null one). Treemap is a third: the leaf collapses and its neighbours expand to fill the box. Three different Recharts behaviours, one authoring-side predicate — a row can only occupy area if its measure is a positive finite number — so one answer serves all three.

What changed

Three helpers, stated once so the arms cannot drift, and wired into the pie/donut, funnel and treemap arms:

  • countSizableRows(rows, dataKey)Number.isFinite(v) && v > 0. Number.isFinite rather than the sankey arm's Number(x) || 0 idiom, because this predicate must also reject Infinity, which || 0 lets straight through.
  • No row sizable and at least one row present: the file's existing refusal shell under its own code, data-chart-error="no-positive-magnitude".
  • Some rows sizable: the plot renders unchanged, wrapped in the existing ChartFootnote with data-chart-note="unsized-rows", carrying the count.

Copy names the predicate, not a cause — the reason no-positive-flow's docstring already gives: five shapes reach here (a genuine zero, a negative, null, an unparseable string, a missing key) and naming any one is a sentence false for the other four.

The note deliberately does not say "showing N of M". PR #7161's sankey note can, because there the missing rows are genuinely absent from the plot. Here they are not: a mixed-sign pie paints a sector for every row (measured: 40 / -25 / -12 drew 3 sectors) — it just paints them at a scale that means nothing. "Showing 1 of 3" would be a false statement about what is on the screen.

No console warning, matching both sankey answers and unlike the two guards at the bottom of the file: those carry a diagnostic pair that does not fit on screen, whereas these sentences already name the key, the test it failed, and how many rows failed it.

i18n: none added. This file has no i18n call sites at all and both landed refusals plus PR #7161's footnote are hardcoded English; this matches the file.

Before / after, and the 50 tiles that did not move

Measured at a tile taller than the chart's own CHART_MIN_HEIGHT floor of 280 (see "assumption falsified" below). The pre-fix renderer was reproduced by double ablation and verified against the true pre-fix run: 56/56 tiles byte-identical, so the "before" column is the real thing rather than a stand-in.

24 tiles changed. 50 did not. The 50 are what make the 24 discriminating:

treemap--posNull and treemap--posZero still share an image after the fix, and that is correct: to a chart that sizes by value a null and a 0 are the same fact, and the copy names the predicate rather than the cause.

Seam map, pinned from both sides

datasetsankeypie / donut / funnel / treemap
no rows at alluntouched (#7130, upstream)untouched (#7130, upstream)
no row above zerono-positive-flow (#7146)no-positive-magnitude (this PR)
some rows above zeroomitted-rows (#7161)unsized-rows (this PR)
all rows above zerountoucheduntouched, no wrapper element added

A test asserts the four codes are mutually exclusive across every family x dataset pair in the sweep, and two more assert a sankey never receives this PR's code or attribute and vice versa.

Console diagnostic, with its control

Across all 72 chart tiles: zero console output. That zero is readable only because the same instrument's positive control did fire on the same run — a rows-carry-no-category-key tile printed [chart] no row has the category key "name" .... A2.4 confirmed on the widened population.

Assumptions falsified

  1. The card's "funnel all-zero is pixel-identical to a blank tile" does not reproduce at this head on this instrument. It draws its two category labels (412 ink pixels) and zero segments. The likely difference is that this probe compiles the real Tailwind theme, so hsl(var(--foreground)) resolves and the labels are visible. The finding stands regardless — 0 of 2 segments — but the specific pixel-identity claim is not what I measured, so I am not repeating it.
  2. A 520x240 tile — the box the card's own sweep used — is 40px shorter than the chart's own CHART_MIN_HEIGHT of 280, so no footnote can be seen in it. Measured with the landed footnote as the control: sankey--posNull is byte-identical before and after this change at 240 (2b123b92769b), i.e. PR fix(plugin-charts): a sankey that drew only some of its rows says how many #7161's shipped note is equally invisible there. This is a pre-existing property of sub-280 boxes that this PR inherits and does not worsen, not something introduced here. All before/after numbers above are therefore quoted at a box that fits the floor.
  3. A2.3 — see above; the mechanism differs per family at the Recharts level while the authoring-side predicate does not.

Tests

AdvancedChartImpl.degenerateMagnitude.test.tsx, 64 tests. Run from the repo root with root-relative paths.

pnpm exec vitest run packages/plugin-charts/src/AdvancedChartImpl.degenerateMagnitude.test.tsx
Test Files 1 passed (1)
Tests 64 passed (64)
pnpm exec vitest run packages/plugin-charts/
Test Files 39 passed (39)
Tests 329 passed (329)

Ablation, direction predicted before running

Two legs, each proving mutation on disk by marker count and blob hash and restore by state, both with an absolute-path trap:

legmutationpredictedobserved
Arefusal guard neutered (sizable === 0 to sizable === -1, 3 sites)refusal block red, note block green26 failed / 38 passed — all 26 in the refusal block
Bnote guard neutered (unsized <= 0 to unsized >= 0, 1 site)note block red, refusal block green26 failed / 38 passed — all 26 in the note block

The two red sets are disjoint, and each leg left the other 38 green — which is what makes them discriminating rather than a blanket break. Blob hashes: 433eb895... mutated to 328319dd... (A) and 5d9ad41a... (B); both restored to 433eb895... with git diff HEAD empty.

Gates

All run on the committed head dd79defc7, after the final commit.

gateverdict, as the gate printed it
vitest packages/plugin-charts/Test Files 39 passed (39) / Tests 329 passed (329)
type-checkexit 0, tsc --noEmit, script name echoed
lint287 problems (0 errors, 287 warnings) — exit 0, all warnings pre-existing
check-changeset-presence2 source file(s) of 1 released package(s) changed, and this change declares 1 changeset(s)
check-changeset-no-majorNo changeset declares a major bump.
check-control-bytesOK (scanned 5942 tracked text file(s); skipped 85 binary).
check-vi-mock-specifiersOK (4099 tracked source file(s), 2371 test-named; 525 carry a mock; ...)
check-vi-mock-inheritOK (4099 tracked source file(s), ... 118 inherit, 0 auto-mocked ...)
check-lint-coveragelint coverage: 46/46 packages linted, 0 with outstanding errors (0 total).
check-type-check-coveragetype-check coverage: 45/46 via type-check, ... 1 not compiled.

The three tracked-file gates were re-run aftergit add; their own counts moved (5940 to 5942 text files, 524 to 525 files carrying a mock), which is how I know they actually saw the new test rather than skipping it as untracked.

type-check was NOT MEASURED on first attempt — 16 Cannot find module '@object-ui/*' errors because the dependency closure was unbuilt. Built with pnpm --workspace-concurrency=2 --filter '@object-ui/plugin-charts^...' build and re-run to a real verdict. tsc --listFiles confirms both edited files are in the compiled set (1 hit each), so the green covers them.

Declared narrowing: repo-wide pnpm lint was not run locally; CI owns it. The per-package run is a measurement, not a guess — eslint's own config selected 52 files in this package (--format json count), both edited files are in that set with 0 errors, and this package's eslint config enables no type-aware linting, so nothing in this diff can move a verdict in a file it does not touch.

Out of scope, reported not fixed

  • scatter is NOT MEASURED, not clean. Its all-positive control drew zero marks on this instrument (markN: 0 on every scatter tile including the control), so its zeros say nothing — a control that returns zero is no control. Scatter takes two measures and this sweep's single-measure fixture does not bind it. Worth a proper sweep with a correct fixture; I did not file a card, per the report-do-not-duplicate fence.
  • radar measured and clean for this defect class: grid and axes survive an all-zero dataset and its hashes are distinct from both its all-positive and its one-row control.

Generated by Claude Code

…gnitude they can draw
These four families (pie, donut, funnel, treemap) size a mark BY its measure,
so a row whose value is zero, negative, null or unparseable stays in `data` and
is simply given no area. That is a third mechanism, distinct from the early
return objectui#7146 answered and the silent row drop objectui#7148 answered:
those rows are never filtered, so objectui#7148's dropped-row count is exactly
zero here and hoisting its footnote would have rendered nothing while looking
like coverage.
Measured in real Chromium across 74 tiles at 40c4711, each screenshotted,
MD5'd and pixel-diffed against an empty div of the same box:
- all-zero, all-null and all-negative pies put ZERO non-white pixels out of
124,800 on the page, byte-identical to the empty div, with 31 descendants
and a real svg in the DOM
- a pie handed 40 beside a null drew a FULL circle, 99.35% pixel-identical to
a legitimately one-row dataset
- a funnel handed 40 beside a null drew ZERO segments and one label reading
the name of the row that had NO value
- a treemap handed 40/null, 40/0 or 40/-25/-12 drew one full-bleed leaf,
byte-identical to a genuinely one-row treemap in all three cases
- an all-zero treemap drew one full-bleed leaf labelled with the LAST category
No row sizable now renders the file's refusal shell under its own code
(no-positive-magnitude); some rows sizable keeps the plot and adds a note
counting the rest (data-chart-note="unsized-rows"). All-positive charts gain no
wrapper element, the no-rows case is untouched (that is the empty-result
question, answered upstream in ObjectChart), bar keeps its axes, and both
sankey answers keep their own codes -- every sankey and bar tile hashed
identically before and after.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012wwHa4aaFybxXrfmfHioDM
@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

MetricValueBudget
Eager closure (gzip, 48 chunks)3154.9 KB3191.4 KB
Main entry chunk (gzip)142.6 KB350 KB
Entry fileindex-D4k7_yQL.js
StatusPASS

The eager closure is every chunk the entry reaches through static imports — what the browser fetches and parses before the app renders. The entry chunk on its own is a small fraction of it.


📦 Bundle Size Report

PackageSizeGzipped
app-shell (consoleActionDispatch.js)0.20KB0.19KB
app-shell (index.js)15.33KB5.59KB
app-shell (runtime-config.js)20.68KB7.36KB
app-shell (types.js)0.01KB0.04KB
app-shell (urlParams.js)10.06KB3.86KB
auth (ActiveOrganizationStorage.js)25.05KB9.16KB
auth (AuthContext.js)0.31KB0.24KB
auth (AuthGuard.js)2.07KB1.00KB
auth (AuthProvider.js)40.18KB10.59KB
auth (AuthShell.js)3.49KB1.40KB
auth (ForgotPasswordForm.js)12.21KB3.45KB
auth (LoginForm.js)18.15KB5.39KB
auth (PreviewBanner.js)0.90KB0.50KB
auth (RegisterForm.js)6.65KB2.22KB
auth (SocialSignInButtons.js)9.61KB3.89KB
auth (UserMenu.js)3.41KB1.23KB
auth (auth-gate-events.js)1.29KB0.66KB
auth (authStyles.js)5.04KB1.72KB
auth (createAuthClient.js)40.21KB10.80KB
auth (createAuthenticatedFetch.js)8.46KB3.43KB
auth (index.js)3.19KB1.44KB
auth (invitation-status.js)1.22KB0.70KB
auth (org-roles.js)6.66KB2.78KB
auth (phone-identifier.js)1.11KB0.66KB
auth (types.js)0.59KB0.35KB
auth (useAuth.js)5.30KB1.02KB
auth (useWorkspaceAdminStatus.js)5.13KB2.35KB
collaboration (CommentThread.js)26.08KB7.56KB
collaboration (LiveCursors.js)3.17KB1.27KB
collaboration (PresenceAvatars.js)6.49KB2.64KB
collaboration (PresenceProvider.js)2.79KB1.13KB
collaboration (index.js)1.68KB0.73KB
collaboration (useCollaborationTranslation.js)6.05KB2.52KB
collaboration (useCommentSearch.js)1.98KB0.88KB
collaboration (useConflictResolution.js)7.75KB1.86KB
collaboration (useMentionNotifications.js)1.81KB0.68KB
collaboration (usePresence.js)6.33KB1.84KB
collaboration (useRealtimeSubscription.js)7.91KB2.01KB
components (index.js)512.32KB116.52KB
core (index.js)5.30KB2.13KB
create-plugin (index.js)10.08KB3.26KB
data-objectstack (index.js)178.14KB49.57KB
fields (index.js)244.25KB61.73KB
i18n (LocalizationContext.js)1.76KB0.96KB
i18n (currency.js)1.22KB0.64KB
i18n (fallbackInterpolation.js)6.25KB2.77KB
i18n (i18n.js)4.28KB1.75KB
i18n (index.js)3.44KB1.39KB
i18n (pickLocalized.js)7.62KB3.26KB
i18n (provider.js)26.89KB9.04KB
i18n (useDisplayLocale.js)2.85KB1.45KB
i18n (useObjectLabel.js)33.40KB8.71KB
i18n (useSafeTranslation.js)5.60KB2.33KB
layout (index.js)38.98KB10.98KB
mobile (MobileProvider.js)0.92KB0.49KB
mobile (ResponsiveContainer.js)0.94KB0.38KB
mobile (breakpoints.js)1.51KB0.70KB
mobile (createOfflineDataSource.js)5.61KB1.75KB
mobile (index.js)1.55KB0.62KB
mobile (offlineQueue.js)3.91KB1.35KB
mobile (pwa.js)0.97KB0.49KB
mobile (serviceWorker.js)1.48KB0.62KB
mobile (serviceWorkerSource.js)3.41KB1.48KB
mobile (useBreakpoint.js)1.54KB0.65KB
mobile (useGesture.js)6.96KB1.98KB
mobile (useOfflineSync.js)1.99KB0.72KB
mobile (usePullToRefresh.js)2.53KB0.85KB
mobile (useResponsive.js)0.72KB0.42KB
mobile (useResponsiveConfig.js)1.37KB0.63KB
mobile (useSpecGesture.js)4.32KB1.64KB
mobile (useTouchTarget.js)1.01KB0.54KB
permissions (MePermissionsProvider.js)11.71KB4.29KB
permissions (PermissionContext.js)0.31KB0.25KB
permissions (PermissionGuard.js)0.89KB0.45KB
permissions (PermissionProvider.js)6.24KB2.16KB
permissions (discardProofCache.js)1.04KB0.55KB
permissions (evaluator.js)5.12KB1.74KB
permissions (index.js)0.93KB0.41KB
permissions (store.js)0.91KB0.42KB
permissions (useFieldPermissions.js)1.28KB0.53KB
permissions (usePermissions.js)4.83KB2.27KB
plugin-ai (index.js)15.75KB3.80KB
plugin-calendar (index.js)46.92KB12.93KB
plugin-charts (index.js)68.45KB19.20KB
plugin-chatbot (index.js)190.53KB45.18KB
plugin-dashboard (index.js)132.63KB34.56KB
plugin-designer (index.js)212.87KB43.19KB
plugin-detail (index.js)249.91KB63.82KB
plugin-editor (index.js)2.46KB1.10KB
plugin-form (index.js)133.11KB32.61KB
plugin-gantt (index.js)165.21KB40.37KB
plugin-grid (index.js)205.53KB55.50KB
plugin-kanban (index.js)53.21KB14.66KB
plugin-list (index.js)113.21KB27.60KB
plugin-map (index.js)20.20KB6.66KB
plugin-markdown (index.js)13.72KB4.69KB
plugin-report (index.js)43.51KB11.94KB
plugin-timeline (index.js)29.34KB8.47KB
plugin-tree (index.js)8.98KB3.08KB
plugin-view (index.js)85.90KB21.12KB
providers (DataSourceProvider.js)0.75KB0.39KB
providers (MetadataProvider.js)1.37KB0.59KB
providers (ThemeProvider.js)1.90KB0.85KB
providers (UploadProvider.js)11.66KB3.50KB
providers (index.js)0.45KB0.23KB
providers (types.js)0.01KB0.04KB
react-runtime (index.js)5.62KB2.34KB
react (LazyPluginLoader.js)4.47KB1.63KB
react (SchemaRenderer.js)81.07KB26.86KB
react (data-invalidation.js)5.05KB2.08KB
react (index.js)3.11KB1.48KB
react (schema-input.js)2.32KB1.24KB
react (spec-input.js)0.20KB0.18KB
sdui-parser (codegen.js)5.41KB2.34KB
sdui-parser (dashboard-widget-options.js)3.08KB1.30KB
sdui-parser (index.js)4.93KB2.24KB
sdui-parser (input-type.js)2.84KB1.40KB
sdui-parser (parse.js)20.57KB5.88KB
sdui-parser (provenance.js)3.66KB1.82KB
sdui-parser (types.js)0.28KB0.23KB
sdui-parser (validate.js)10.35KB3.60KB
types (ai.js)0.20KB0.17KB
types (api-types.js)0.20KB0.18KB
types (app.js)2.87KB0.99KB
types (base.js)0.20KB0.18KB
types (blocks.js)0.20KB0.18KB
types (complex.js)2.74KB1.41KB
types (crud.js)0.20KB0.18KB
types (dashboard-filter-alias.js)6.23KB2.74KB
types (data-display.js)3.75KB1.85KB
types (data-protocol.js)0.20KB0.19KB
types (data.js)0.20KB0.18KB
types (designer.js)1.85KB0.85KB
types (disclosure.js)0.20KB0.18KB
types (error-code.js)1.54KB0.88KB
types (feedback.js)0.20KB0.18KB
types (field-types.js)0.20KB0.18KB
types (form.js)0.20KB0.18KB
types (http-inflight.js)8.87KB3.73KB
types (http-retry.js)4.32KB2.02KB
types (icon-key-migration.js)4.26KB1.63KB
types (index.js)4.72KB2.24KB
types (layout.js)0.20KB0.18KB
types (managed-by.js)0.19KB0.18KB
types (mobile.js)2.59KB1.31KB
types (navigation.js)0.20KB0.18KB
types (objectql.js)0.20KB0.18KB
types (overlay.js)0.20KB0.18KB
types (permissions.js)0.20KB0.18KB
types (plugin-scope.js)0.20KB0.18KB
types (record-components.js)0.20KB0.19KB
types (record-semantics.js)1.28KB0.67KB
types (registry.js)0.20KB0.18KB
types (reports.js)0.20KB0.18KB
types (spec-report.js)5.05KB1.93KB
types (spec-ui-namespace.js)0.20KB0.19KB
types (system-fields.js)3.33KB1.54KB
types (theme.js)6.28KB2.87KB
types (ui-action.js)3.40KB1.71KB
types (views.js)0.20KB0.18KB
types (widget.js)0.20KB0.18KB

Size Limits

  • ✅ Core packages should be < 50KB gzipped
  • ✅ Component packages should be < 100KB gzipped
  • ⚠️ Plugin packages should be < 150KB gzipped

@os-warrenClaude

Copy link
Copy Markdown
CollaboratorAuthor

✅ Reviewed — will arm on green

⚠️ First: my claim on objectui#7147 was incomplete, and you were right to flag it

You reported: "the card carried the assignee and four PM comments but NO claim comment with a session ID, so I could not confirm the claim from the card itself."

Correct, and that is my error. This repo's rule is explicit — a claim is assign plus a claim comment carrying the session ID and branch, because all agents share one GitHub identity and the assignee field alone cannot tell you whose claim it is. I set the assignee and labels and never posted the comment. Every other card this session got one; this one did not.

Proceeding after confirming no competing claim was the right call. Noting it publicly because that gap is precisely how one issue gets implemented twice.

The measurement

74 tiles — 8 families × 9 datasets, plus a blank reference and a live console control — MD5'd and pixel-diffed against a literally empty div. What it found is worse than the card described:

caseresult
pie/donut, all-zero / all-null / all-negative0 non-white pixels of 124,800, while carrying 31 DOM descendants and a real svg
pie, 40 beside a nulldrew a FULL circle — 99.35% pixel-identical to a legitimately one-row dataset
funnel, 40 beside a null0 segments and ONE label — reading the name of the row that had NO value. The row worth 40 vanished and the empty one got the label
treemap, 40/null, 40/0, 40/-25/-12byte-identical (0.000%) to a genuinely one-row treemap — four datasets, one image

The funnel case is the sharpest thing here. It is not a blank and not a partial draw; it is a chart labelling the wrong row. A reader sees one named category and no indication that the category carrying all the value was dropped.

measured-and-declined was assessed per family — and survived for two

Bar and radar are clean and stay untouched. Radar's grid and axes survive an all-zero dataset with hashes distinct from both its all-positive and its one-row control. That is the outcome I most wanted to see possible: the fence said declining was legitimate, and it was taken where the evidence supported it rather than applied uniformly because a fix was available.

50 of 74 tiles unchanged, including every all-positive control, every one-row control, every no-rows tile, all 9 bar tiles and all 9 sankey tiles byte-identical.

The ablation design is the best of the session

Two legs with disjoint red sets. Neutering the refusal guard: 26 failed / 38 passed, all 26 in the refusal block. Neutering the note guard: 26 failed / 38 passed, all 26 in the note block. Each leg leaves the other's 38 green — which is what makes them discriminating rather than merely red.

And the third leg is the part I have not seen before: you reproduced the pre-fix renderer and verified the reproduction against the true pre-fix run — 56/56 tiles byte-identical. So the before/after table is measured against the real thing, not against a stand-in that merely resembles it. That closes the gap where a reconstruction quietly differs from what it claims to reconstruct.

Browser instrument rebuilt on each leg with the markers verified present in dist/assets/*.js afterwards — so the measurement ran against mutated code, not a stale bundle. And the three tracked-file gates were re-run after git add with their counts moving (5940→5942, 524→525), which is how you know they saw the new test rather than skipping it as untracked. That is a control on the gate itself.

Two things you reported rather than filed — both correct, both being carried

Scatter is NOT MEASURED, not clean. Every scatter tile including the all-positive control drew zero marks, so its zeros say nothing — a control that returns zero is no control. Scatter takes two measures and this sweep's single-measure fixture does not bind it. I am filing that as its own card; it has no existing home, unlike objectui#7148's finding which had objectui#7147 to attach to.

⚠️ The footnote-visibility property is the one that reaches back. A 520×240 tile is 40px shorter than CHART_MIN_HEIGHT (280), so no footnote can be seen in one — measured with objectui#7148's landed note as the live control, byte-identical before and after at 240. That means objectui#7148's shipped note is equally invisible at that size. You are right that this PR inherits the property without worsening it, and right to quote every before/after number at a box that fits the floor. I am recording it on objectui#7148.

Ledger

Refusal ships under its own code no-positive-magnitude rather than reusing objectui#7146's — correct, since the codes name which refusal fired. One shared predicate Number.isFinite(v) && v > 0 across three arms rather than three spellings. type-check first returned NOT MEASURED (16 unbuilt-closure errors), named as such and re-run to a real verdict after building the closure.


Generated by Claude Code

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

finding(plugin-charts): funnel and pie handed all-zero rows render a pixel-identical blank tile — same silence as #7140, a different trigger

2 participants

@os-warren@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content

fix(plugin-charts): pie, funnel and treemap say when rows carry no magnitude they can draw - #7169

Merged
os-warren merged 1 commit into
mainfrom
claude/issue-7147-degenerate-geometry-blank
Sep 1, 2026
Merged

fix(plugin-charts): pie, funnel and treemap say when rows carry no magnitude they can draw#7169
os-warren merged 1 commit into
mainfrom
claude/issue-7147-degenerate-geometry-blank

Conversation

@os-warren

Copy link
Copy Markdown
Collaborator

Fixes#7147

Re-derived on origin/main40c4711 (the head this branch forks from; it already carries PR #7146's no-positive-flow refusal and PR #7161's ChartFootnote). The card's snippets and line numbers were stale, as PM assumed.

Rendered it and looked first

74 tiles in real Chromium (/opt/pw-browsers/chromium) — 8 chart families x 9 datasets, plus a blank reference and a live console control. Every tile screenshotted, MD5'd, and pixel-diffed against a literally empty div of the same box. measured-and-declined was genuinely on the table for each family separately. It survived for radar and bar, and for none of pie / donut / funnel / treemap.

The instrument's discriminating power, from its own controls: a two-row pie differs from a one-row pie by 9.683% of its pixels, a two-row treemap from a one-row treemap by 38.301%, and an all-zero bar puts 5,128 ink pixels (axes and ticks) against a blank tile's 0.

Per-family verdict

familydatasetwhat a reader saw at 40c4711verdict
pie / donutall-zero, all-null, all-negative0 non-white pixels of 124,800 — byte-identical to an empty div, while the DOM carried 31 descendants and a real svgfix
pie / donut40 beside a nulla full circle in the first category's colour — 99.35% pixel-identical to a legitimately one-row dataset (the 0.654% residue is the paddingAngle hairline, not information). The picture asserts "Alpha is 100%" of a dataset where Beta was never measuredfix
funnel40 beside a null178 ink pixels: zero segments, one label — and the label reads "Beta", the row with no value. The row carrying 40 drew nothing at allfix
funnelall-negativea confident two-band funnel whose mark area (220,320) exceeds the all-positive control's (111,881)fix
treemap40 / null, 40 / 0, 40 / -25 / -12all three byte-identical (diff 0.000%) to a genuinely one-row treemap — one full-bleed leaf labelled "Alpha". Four datasets, one imagefix
treemapall-zeroone full-bleed leaf labelled with the last category — as the card's adjacent observation recordedfix
barall-zero5,128 ink pixels of axes, ticks and a zero-labelled scaledeclined — its reader can already tell
radarall-zerogrid and axes drawn, and hashes distinct from both its all-positive and its one-row controldeclined

The mechanism is the same across all three families, and it is neither landed answer

The rows are never filtered. data reaches the pie, funnel and treemap elements whole — PR #7161 pinned that the sankey arm holds the only row-dropping filter in this file, and that still reads true at this head (AdvancedChartImpl.tsx:1081, the other two .filter( sites filter series). What happens instead is that the layout gives a non-positive row no area.

So PR #7161's count (data.length - rows.length) is exactly zero against these families. A hoisted copy of that footnote would have rendered nothing while looking like coverage. Confirmed rather than assumed: the sankey tiles are the only ones in the sweep carrying omitted-rows.

A2.3 was falsified in one respect and it changed nothing. Pie and funnel are not the same failure at the Recharts level — an unsizable pie row produces no sector element at all (path.recharts-sector = 0), whereas an unsizable funnel row produces a NaN-width trapezoid that also poisons its neighbour (which is why 40 beside a null loses the row worth 40, not the null one). Treemap is a third: the leaf collapses and its neighbours expand to fill the box. Three different Recharts behaviours, one authoring-side predicate — a row can only occupy area if its measure is a positive finite number — so one answer serves all three.

What changed

Three helpers, stated once so the arms cannot drift, and wired into the pie/donut, funnel and treemap arms:

  • countSizableRows(rows, dataKey)Number.isFinite(v) && v > 0. Number.isFinite rather than the sankey arm's Number(x) || 0 idiom, because this predicate must also reject Infinity, which || 0 lets straight through.
  • No row sizable and at least one row present: the file's existing refusal shell under its own code, data-chart-error="no-positive-magnitude".
  • Some rows sizable: the plot renders unchanged, wrapped in the existing ChartFootnote with data-chart-note="unsized-rows", carrying the count.

Copy names the predicate, not a cause — the reason no-positive-flow's docstring already gives: five shapes reach here (a genuine zero, a negative, null, an unparseable string, a missing key) and naming any one is a sentence false for the other four.

The note deliberately does not say "showing N of M". PR #7161's sankey note can, because there the missing rows are genuinely absent from the plot. Here they are not: a mixed-sign pie paints a sector for every row (measured: 40 / -25 / -12 drew 3 sectors) — it just paints them at a scale that means nothing. "Showing 1 of 3" would be a false statement about what is on the screen.

No console warning, matching both sankey answers and unlike the two guards at the bottom of the file: those carry a diagnostic pair that does not fit on screen, whereas these sentences already name the key, the test it failed, and how many rows failed it.

i18n: none added. This file has no i18n call sites at all and both landed refusals plus PR #7161's footnote are hardcoded English; this matches the file.

Before / after, and the 50 tiles that did not move

Measured at a tile taller than the chart's own CHART_MIN_HEIGHT floor of 280 (see "assumption falsified" below). The pre-fix renderer was reproduced by double ablation and verified against the true pre-fix run: 56/56 tiles byte-identical, so the "before" column is the real thing rather than a stand-in.

24 tiles changed. 50 did not. The 50 are what make the 24 discriminating:

treemap--posNull and treemap--posZero still share an image after the fix, and that is correct: to a chart that sizes by value a null and a 0 are the same fact, and the copy names the predicate rather than the cause.

Seam map, pinned from both sides

datasetsankeypie / donut / funnel / treemap
no rows at alluntouched (#7130, upstream)untouched (#7130, upstream)
no row above zerono-positive-flow (#7146)no-positive-magnitude (this PR)
some rows above zeroomitted-rows (#7161)unsized-rows (this PR)
all rows above zerountoucheduntouched, no wrapper element added

A test asserts the four codes are mutually exclusive across every family x dataset pair in the sweep, and two more assert a sankey never receives this PR's code or attribute and vice versa.

Console diagnostic, with its control

Across all 72 chart tiles: zero console output. That zero is readable only because the same instrument's positive control did fire on the same run — a rows-carry-no-category-key tile printed [chart] no row has the category key "name" .... A2.4 confirmed on the widened population.

Assumptions falsified

  1. The card's "funnel all-zero is pixel-identical to a blank tile" does not reproduce at this head on this instrument. It draws its two category labels (412 ink pixels) and zero segments. The likely difference is that this probe compiles the real Tailwind theme, so hsl(var(--foreground)) resolves and the labels are visible. The finding stands regardless — 0 of 2 segments — but the specific pixel-identity claim is not what I measured, so I am not repeating it.
  2. A 520x240 tile — the box the card's own sweep used — is 40px shorter than the chart's own CHART_MIN_HEIGHT of 280, so no footnote can be seen in it. Measured with the landed footnote as the control: sankey--posNull is byte-identical before and after this change at 240 (2b123b92769b), i.e. PR fix(plugin-charts): a sankey that drew only some of its rows says how many #7161's shipped note is equally invisible there. This is a pre-existing property of sub-280 boxes that this PR inherits and does not worsen, not something introduced here. All before/after numbers above are therefore quoted at a box that fits the floor.
  3. A2.3 — see above; the mechanism differs per family at the Recharts level while the authoring-side predicate does not.

Tests

AdvancedChartImpl.degenerateMagnitude.test.tsx, 64 tests. Run from the repo root with root-relative paths.

pnpm exec vitest run packages/plugin-charts/src/AdvancedChartImpl.degenerateMagnitude.test.tsx
Test Files 1 passed (1)
Tests 64 passed (64)
pnpm exec vitest run packages/plugin-charts/
Test Files 39 passed (39)
Tests 329 passed (329)

Ablation, direction predicted before running

Two legs, each proving mutation on disk by marker count and blob hash and restore by state, both with an absolute-path trap:

legmutationpredictedobserved
Arefusal guard neutered (sizable === 0 to sizable === -1, 3 sites)refusal block red, note block green26 failed / 38 passed — all 26 in the refusal block
Bnote guard neutered (unsized <= 0 to unsized >= 0, 1 site)note block red, refusal block green26 failed / 38 passed — all 26 in the note block

The two red sets are disjoint, and each leg left the other 38 green — which is what makes them discriminating rather than a blanket break. Blob hashes: 433eb895... mutated to 328319dd... (A) and 5d9ad41a... (B); both restored to 433eb895... with git diff HEAD empty.

Gates

All run on the committed head dd79defc7, after the final commit.

gateverdict, as the gate printed it
vitest packages/plugin-charts/Test Files 39 passed (39) / Tests 329 passed (329)
type-checkexit 0, tsc --noEmit, script name echoed
lint287 problems (0 errors, 287 warnings) — exit 0, all warnings pre-existing
check-changeset-presence2 source file(s) of 1 released package(s) changed, and this change declares 1 changeset(s)
check-changeset-no-majorNo changeset declares a major bump.
check-control-bytesOK (scanned 5942 tracked text file(s); skipped 85 binary).
check-vi-mock-specifiersOK (4099 tracked source file(s), 2371 test-named; 525 carry a mock; ...)
check-vi-mock-inheritOK (4099 tracked source file(s), ... 118 inherit, 0 auto-mocked ...)
check-lint-coveragelint coverage: 46/46 packages linted, 0 with outstanding errors (0 total).
check-type-check-coveragetype-check coverage: 45/46 via type-check, ... 1 not compiled.

The three tracked-file gates were re-run aftergit add; their own counts moved (5940 to 5942 text files, 524 to 525 files carrying a mock), which is how I know they actually saw the new test rather than skipping it as untracked.

type-check was NOT MEASURED on first attempt — 16 Cannot find module '@object-ui/*' errors because the dependency closure was unbuilt. Built with pnpm --workspace-concurrency=2 --filter '@object-ui/plugin-charts^...' build and re-run to a real verdict. tsc --listFiles confirms both edited files are in the compiled set (1 hit each), so the green covers them.

Declared narrowing: repo-wide pnpm lint was not run locally; CI owns it. The per-package run is a measurement, not a guess — eslint's own config selected 52 files in this package (--format json count), both edited files are in that set with 0 errors, and this package's eslint config enables no type-aware linting, so nothing in this diff can move a verdict in a file it does not touch.

Out of scope, reported not fixed

  • scatter is NOT MEASURED, not clean. Its all-positive control drew zero marks on this instrument (markN: 0 on every scatter tile including the control), so its zeros say nothing — a control that returns zero is no control. Scatter takes two measures and this sweep's single-measure fixture does not bind it. Worth a proper sweep with a correct fixture; I did not file a card, per the report-do-not-duplicate fence.
  • radar measured and clean for this defect class: grid and axes survive an all-zero dataset and its hashes are distinct from both its all-positive and its one-row control.

Generated by Claude Code

…gnitude they can draw
These four families (pie, donut, funnel, treemap) size a mark BY its measure,
so a row whose value is zero, negative, null or unparseable stays in `data` and
is simply given no area. That is a third mechanism, distinct from the early
return objectui#7146 answered and the silent row drop objectui#7148 answered:
those rows are never filtered, so objectui#7148's dropped-row count is exactly
zero here and hoisting its footnote would have rendered nothing while looking
like coverage.
Measured in real Chromium across 74 tiles at 40c4711, each screenshotted,
MD5'd and pixel-diffed against an empty div of the same box:
- all-zero, all-null and all-negative pies put ZERO non-white pixels out of
124,800 on the page, byte-identical to the empty div, with 31 descendants
and a real svg in the DOM
- a pie handed 40 beside a null drew a FULL circle, 99.35% pixel-identical to
a legitimately one-row dataset
- a funnel handed 40 beside a null drew ZERO segments and one label reading
the name of the row that had NO value
- a treemap handed 40/null, 40/0 or 40/-25/-12 drew one full-bleed leaf,
byte-identical to a genuinely one-row treemap in all three cases
- an all-zero treemap drew one full-bleed leaf labelled with the LAST category
No row sizable now renders the file's refusal shell under its own code
(no-positive-magnitude); some rows sizable keeps the plot and adds a note
counting the rest (data-chart-note="unsized-rows"). All-positive charts gain no
wrapper element, the no-rows case is untouched (that is the empty-result
question, answered upstream in ObjectChart), bar keeps its axes, and both
sankey answers keep their own codes -- every sankey and bar tile hashed
identically before and after.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012wwHa4aaFybxXrfmfHioDM
@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

MetricValueBudget
Eager closure (gzip, 48 chunks)3154.9 KB3191.4 KB
Main entry chunk (gzip)142.6 KB350 KB
Entry fileindex-D4k7_yQL.js
StatusPASS

The eager closure is every chunk the entry reaches through static imports — what the browser fetches and parses before the app renders. The entry chunk on its own is a small fraction of it.


📦 Bundle Size Report

PackageSizeGzipped
app-shell (consoleActionDispatch.js)0.20KB0.19KB
app-shell (index.js)15.33KB5.59KB
app-shell (runtime-config.js)20.68KB7.36KB
app-shell (types.js)0.01KB0.04KB
app-shell (urlParams.js)10.06KB3.86KB
auth (ActiveOrganizationStorage.js)25.05KB9.16KB
auth (AuthContext.js)0.31KB0.24KB
auth (AuthGuard.js)2.07KB1.00KB
auth (AuthProvider.js)40.18KB10.59KB
auth (AuthShell.js)3.49KB1.40KB
auth (ForgotPasswordForm.js)12.21KB3.45KB
auth (LoginForm.js)18.15KB5.39KB
auth (PreviewBanner.js)0.90KB0.50KB
auth (RegisterForm.js)6.65KB2.22KB
auth (SocialSignInButtons.js)9.61KB3.89KB
auth (UserMenu.js)3.41KB1.23KB
auth (auth-gate-events.js)1.29KB0.66KB
auth (authStyles.js)5.04KB1.72KB
auth (createAuthClient.js)40.21KB10.80KB
auth (createAuthenticatedFetch.js)8.46KB3.43KB
auth (index.js)3.19KB1.44KB
auth (invitation-status.js)1.22KB0.70KB
auth (org-roles.js)6.66KB2.78KB
auth (phone-identifier.js)1.11KB0.66KB
auth (types.js)0.59KB0.35KB
auth (useAuth.js)5.30KB1.02KB
auth (useWorkspaceAdminStatus.js)5.13KB2.35KB
collaboration (CommentThread.js)26.08KB7.56KB
collaboration (LiveCursors.js)3.17KB1.27KB
collaboration (PresenceAvatars.js)6.49KB2.64KB
collaboration (PresenceProvider.js)2.79KB1.13KB
collaboration (index.js)1.68KB0.73KB
collaboration (useCollaborationTranslation.js)6.05KB2.52KB
collaboration (useCommentSearch.js)1.98KB0.88KB
collaboration (useConflictResolution.js)7.75KB1.86KB
collaboration (useMentionNotifications.js)1.81KB0.68KB
collaboration (usePresence.js)6.33KB1.84KB
collaboration (useRealtimeSubscription.js)7.91KB2.01KB
components (index.js)512.32KB116.52KB
core (index.js)5.30KB2.13KB
create-plugin (index.js)10.08KB3.26KB
data-objectstack (index.js)178.14KB49.57KB
fields (index.js)244.25KB61.73KB
i18n (LocalizationContext.js)1.76KB0.96KB
i18n (currency.js)1.22KB0.64KB
i18n (fallbackInterpolation.js)6.25KB2.77KB
i18n (i18n.js)4.28KB1.75KB
i18n (index.js)3.44KB1.39KB
i18n (pickLocalized.js)7.62KB3.26KB
i18n (provider.js)26.89KB9.04KB
i18n (useDisplayLocale.js)2.85KB1.45KB
i18n (useObjectLabel.js)33.40KB8.71KB
i18n (useSafeTranslation.js)5.60KB2.33KB
layout (index.js)38.98KB10.98KB
mobile (MobileProvider.js)0.92KB0.49KB
mobile (ResponsiveContainer.js)0.94KB0.38KB
mobile (breakpoints.js)1.51KB0.70KB
mobile (createOfflineDataSource.js)5.61KB1.75KB
mobile (index.js)1.55KB0.62KB
mobile (offlineQueue.js)3.91KB1.35KB
mobile (pwa.js)0.97KB0.49KB
mobile (serviceWorker.js)1.48KB0.62KB
mobile (serviceWorkerSource.js)3.41KB1.48KB
mobile (useBreakpoint.js)1.54KB0.65KB
mobile (useGesture.js)6.96KB1.98KB
mobile (useOfflineSync.js)1.99KB0.72KB
mobile (usePullToRefresh.js)2.53KB0.85KB
mobile (useResponsive.js)0.72KB0.42KB
mobile (useResponsiveConfig.js)1.37KB0.63KB
mobile (useSpecGesture.js)4.32KB1.64KB
mobile (useTouchTarget.js)1.01KB0.54KB
permissions (MePermissionsProvider.js)11.71KB4.29KB
permissions (PermissionContext.js)0.31KB0.25KB
permissions (PermissionGuard.js)0.89KB0.45KB
permissions (PermissionProvider.js)6.24KB2.16KB
permissions (discardProofCache.js)1.04KB0.55KB
permissions (evaluator.js)5.12KB1.74KB
permissions (index.js)0.93KB0.41KB
permissions (store.js)0.91KB0.42KB
permissions (useFieldPermissions.js)1.28KB0.53KB
permissions (usePermissions.js)4.83KB2.27KB
plugin-ai (index.js)15.75KB3.80KB
plugin-calendar (index.js)46.92KB12.93KB
plugin-charts (index.js)68.45KB19.20KB
plugin-chatbot (index.js)190.53KB45.18KB
plugin-dashboard (index.js)132.63KB34.56KB
plugin-designer (index.js)212.87KB43.19KB
plugin-detail (index.js)249.91KB63.82KB
plugin-editor (index.js)2.46KB1.10KB
plugin-form (index.js)133.11KB32.61KB
plugin-gantt (index.js)165.21KB40.37KB
plugin-grid (index.js)205.53KB55.50KB
plugin-kanban (index.js)53.21KB14.66KB
plugin-list (index.js)113.21KB27.60KB
plugin-map (index.js)20.20KB6.66KB
plugin-markdown (index.js)13.72KB4.69KB
plugin-report (index.js)43.51KB11.94KB
plugin-timeline (index.js)29.34KB8.47KB
plugin-tree (index.js)8.98KB3.08KB
plugin-view (index.js)85.90KB21.12KB
providers (DataSourceProvider.js)0.75KB0.39KB
providers (MetadataProvider.js)1.37KB0.59KB
providers (ThemeProvider.js)1.90KB0.85KB
providers (UploadProvider.js)11.66KB3.50KB
providers (index.js)0.45KB0.23KB
providers (types.js)0.01KB0.04KB
react-runtime (index.js)5.62KB2.34KB
react (LazyPluginLoader.js)4.47KB1.63KB
react (SchemaRenderer.js)81.07KB26.86KB
react (data-invalidation.js)5.05KB2.08KB
react (index.js)3.11KB1.48KB
react (schema-input.js)2.32KB1.24KB
react (spec-input.js)0.20KB0.18KB
sdui-parser (codegen.js)5.41KB2.34KB
sdui-parser (dashboard-widget-options.js)3.08KB1.30KB
sdui-parser (index.js)4.93KB2.24KB
sdui-parser (input-type.js)2.84KB1.40KB
sdui-parser (parse.js)20.57KB5.88KB
sdui-parser (provenance.js)3.66KB1.82KB
sdui-parser (types.js)0.28KB0.23KB
sdui-parser (validate.js)10.35KB3.60KB
types (ai.js)0.20KB0.17KB
types (api-types.js)0.20KB0.18KB
types (app.js)2.87KB0.99KB
types (base.js)0.20KB0.18KB
types (blocks.js)0.20KB0.18KB
types (complex.js)2.74KB1.41KB
types (crud.js)0.20KB0.18KB
types (dashboard-filter-alias.js)6.23KB2.74KB
types (data-display.js)3.75KB1.85KB
types (data-protocol.js)0.20KB0.19KB
types (data.js)0.20KB0.18KB
types (designer.js)1.85KB0.85KB
types (disclosure.js)0.20KB0.18KB
types (error-code.js)1.54KB0.88KB
types (feedback.js)0.20KB0.18KB
types (field-types.js)0.20KB0.18KB
types (form.js)0.20KB0.18KB
types (http-inflight.js)8.87KB3.73KB
types (http-retry.js)4.32KB2.02KB
types (icon-key-migration.js)4.26KB1.63KB
types (index.js)4.72KB2.24KB
types (layout.js)0.20KB0.18KB
types (managed-by.js)0.19KB0.18KB
types (mobile.js)2.59KB1.31KB
types (navigation.js)0.20KB0.18KB
types (objectql.js)0.20KB0.18KB
types (overlay.js)0.20KB0.18KB
types (permissions.js)0.20KB0.18KB
types (plugin-scope.js)0.20KB0.18KB
types (record-components.js)0.20KB0.19KB
types (record-semantics.js)1.28KB0.67KB
types (registry.js)0.20KB0.18KB
types (reports.js)0.20KB0.18KB
types (spec-report.js)5.05KB1.93KB
types (spec-ui-namespace.js)0.20KB0.19KB
types (system-fields.js)3.33KB1.54KB
types (theme.js)6.28KB2.87KB
types (ui-action.js)3.40KB1.71KB
types (views.js)0.20KB0.18KB
types (widget.js)0.20KB0.18KB

Size Limits

  • ✅ Core packages should be < 50KB gzipped
  • ✅ Component packages should be < 100KB gzipped
  • ⚠️ Plugin packages should be < 150KB gzipped

@os-warrenClaude

Copy link
Copy Markdown
CollaboratorAuthor

✅ Reviewed — will arm on green

⚠️ First: my claim on objectui#7147 was incomplete, and you were right to flag it

You reported: "the card carried the assignee and four PM comments but NO claim comment with a session ID, so I could not confirm the claim from the card itself."

Correct, and that is my error. This repo's rule is explicit — a claim is assign plus a claim comment carrying the session ID and branch, because all agents share one GitHub identity and the assignee field alone cannot tell you whose claim it is. I set the assignee and labels and never posted the comment. Every other card this session got one; this one did not.

Proceeding after confirming no competing claim was the right call. Noting it publicly because that gap is precisely how one issue gets implemented twice.

The measurement

74 tiles — 8 families × 9 datasets, plus a blank reference and a live console control — MD5'd and pixel-diffed against a literally empty div. What it found is worse than the card described:

caseresult
pie/donut, all-zero / all-null / all-negative0 non-white pixels of 124,800, while carrying 31 DOM descendants and a real svg
pie, 40 beside a nulldrew a FULL circle — 99.35% pixel-identical to a legitimately one-row dataset
funnel, 40 beside a null0 segments and ONE label — reading the name of the row that had NO value. The row worth 40 vanished and the empty one got the label
treemap, 40/null, 40/0, 40/-25/-12byte-identical (0.000%) to a genuinely one-row treemap — four datasets, one image

The funnel case is the sharpest thing here. It is not a blank and not a partial draw; it is a chart labelling the wrong row. A reader sees one named category and no indication that the category carrying all the value was dropped.

measured-and-declined was assessed per family — and survived for two

Bar and radar are clean and stay untouched. Radar's grid and axes survive an all-zero dataset with hashes distinct from both its all-positive and its one-row control. That is the outcome I most wanted to see possible: the fence said declining was legitimate, and it was taken where the evidence supported it rather than applied uniformly because a fix was available.

50 of 74 tiles unchanged, including every all-positive control, every one-row control, every no-rows tile, all 9 bar tiles and all 9 sankey tiles byte-identical.

The ablation design is the best of the session

Two legs with disjoint red sets. Neutering the refusal guard: 26 failed / 38 passed, all 26 in the refusal block. Neutering the note guard: 26 failed / 38 passed, all 26 in the note block. Each leg leaves the other's 38 green — which is what makes them discriminating rather than merely red.

And the third leg is the part I have not seen before: you reproduced the pre-fix renderer and verified the reproduction against the true pre-fix run — 56/56 tiles byte-identical. So the before/after table is measured against the real thing, not against a stand-in that merely resembles it. That closes the gap where a reconstruction quietly differs from what it claims to reconstruct.

Browser instrument rebuilt on each leg with the markers verified present in dist/assets/*.js afterwards — so the measurement ran against mutated code, not a stale bundle. And the three tracked-file gates were re-run after git add with their counts moving (5940→5942, 524→525), which is how you know they saw the new test rather than skipping it as untracked. That is a control on the gate itself.

Two things you reported rather than filed — both correct, both being carried

Scatter is NOT MEASURED, not clean. Every scatter tile including the all-positive control drew zero marks, so its zeros say nothing — a control that returns zero is no control. Scatter takes two measures and this sweep's single-measure fixture does not bind it. I am filing that as its own card; it has no existing home, unlike objectui#7148's finding which had objectui#7147 to attach to.

⚠️ The footnote-visibility property is the one that reaches back. A 520×240 tile is 40px shorter than CHART_MIN_HEIGHT (280), so no footnote can be seen in one — measured with objectui#7148's landed note as the live control, byte-identical before and after at 240. That means objectui#7148's shipped note is equally invisible at that size. You are right that this PR inherits the property without worsening it, and right to quote every before/after number at a box that fits the floor. I am recording it on objectui#7148.

Ledger

Refusal ships under its own code no-positive-magnitude rather than reusing objectui#7146's — correct, since the codes name which refusal fired. One shared predicate Number.isFinite(v) && v > 0 across three arms rather than three spellings. type-check first returned NOT MEASURED (16 unbuilt-closure errors), named as such and re-run to a real verdict after building the closure.


Generated by Claude Code

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

finding(plugin-charts): funnel and pie handed all-zero rows render a pixel-identical blank tile — same silence as #7140, a different trigger

2 participants

@os-warren@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

fix(plugin-charts): pie, funnel and treemap say when rows carry no magnitude they can draw - #7169

Merged
os-warren merged 1 commit into
mainfrom
claude/issue-7147-degenerate-geometry-blank
Sep 1, 2026
Merged

fix(plugin-charts): pie, funnel and treemap say when rows carry no magnitude they can draw#7169
os-warren merged 1 commit into
mainfrom
claude/issue-7147-degenerate-geometry-blank

Conversation

@os-warren

Copy link
Copy Markdown
Collaborator

Fixes#7147

Re-derived on origin/main40c4711 (the head this branch forks from; it already carries PR #7146's no-positive-flow refusal and PR #7161's ChartFootnote). The card's snippets and line numbers were stale, as PM assumed.

Rendered it and looked first

74 tiles in real Chromium (/opt/pw-browsers/chromium) — 8 chart families x 9 datasets, plus a blank reference and a live console control. Every tile screenshotted, MD5'd, and pixel-diffed against a literally empty div of the same box. measured-and-declined was genuinely on the table for each family separately. It survived for radar and bar, and for none of pie / donut / funnel / treemap.

The instrument's discriminating power, from its own controls: a two-row pie differs from a one-row pie by 9.683% of its pixels, a two-row treemap from a one-row treemap by 38.301%, and an all-zero bar puts 5,128 ink pixels (axes and ticks) against a blank tile's 0.

Per-family verdict

familydatasetwhat a reader saw at 40c4711verdict
pie / donutall-zero, all-null, all-negative0 non-white pixels of 124,800 — byte-identical to an empty div, while the DOM carried 31 descendants and a real svgfix
pie / donut40 beside a nulla full circle in the first category's colour — 99.35% pixel-identical to a legitimately one-row dataset (the 0.654% residue is the paddingAngle hairline, not information). The picture asserts "Alpha is 100%" of a dataset where Beta was never measuredfix
funnel40 beside a null178 ink pixels: zero segments, one label — and the label reads "Beta", the row with no value. The row carrying 40 drew nothing at allfix
funnelall-negativea confident two-band funnel whose mark area (220,320) exceeds the all-positive control's (111,881)fix
treemap40 / null, 40 / 0, 40 / -25 / -12all three byte-identical (diff 0.000%) to a genuinely one-row treemap — one full-bleed leaf labelled "Alpha". Four datasets, one imagefix
treemapall-zeroone full-bleed leaf labelled with the last category — as the card's adjacent observation recordedfix
barall-zero5,128 ink pixels of axes, ticks and a zero-labelled scaledeclined — its reader can already tell
radarall-zerogrid and axes drawn, and hashes distinct from both its all-positive and its one-row controldeclined

The mechanism is the same across all three families, and it is neither landed answer

The rows are never filtered. data reaches the pie, funnel and treemap elements whole — PR #7161 pinned that the sankey arm holds the only row-dropping filter in this file, and that still reads true at this head (AdvancedChartImpl.tsx:1081, the other two .filter( sites filter series). What happens instead is that the layout gives a non-positive row no area.

So PR #7161's count (data.length - rows.length) is exactly zero against these families. A hoisted copy of that footnote would have rendered nothing while looking like coverage. Confirmed rather than assumed: the sankey tiles are the only ones in the sweep carrying omitted-rows.

A2.3 was falsified in one respect and it changed nothing. Pie and funnel are not the same failure at the Recharts level — an unsizable pie row produces no sector element at all (path.recharts-sector = 0), whereas an unsizable funnel row produces a NaN-width trapezoid that also poisons its neighbour (which is why 40 beside a null loses the row worth 40, not the null one). Treemap is a third: the leaf collapses and its neighbours expand to fill the box. Three different Recharts behaviours, one authoring-side predicate — a row can only occupy area if its measure is a positive finite number — so one answer serves all three.

What changed

Three helpers, stated once so the arms cannot drift, and wired into the pie/donut, funnel and treemap arms:

  • countSizableRows(rows, dataKey)Number.isFinite(v) && v > 0. Number.isFinite rather than the sankey arm's Number(x) || 0 idiom, because this predicate must also reject Infinity, which || 0 lets straight through.
  • No row sizable and at least one row present: the file's existing refusal shell under its own code, data-chart-error="no-positive-magnitude".
  • Some rows sizable: the plot renders unchanged, wrapped in the existing ChartFootnote with data-chart-note="unsized-rows", carrying the count.

Copy names the predicate, not a cause — the reason no-positive-flow's docstring already gives: five shapes reach here (a genuine zero, a negative, null, an unparseable string, a missing key) and naming any one is a sentence false for the other four.

The note deliberately does not say "showing N of M". PR #7161's sankey note can, because there the missing rows are genuinely absent from the plot. Here they are not: a mixed-sign pie paints a sector for every row (measured: 40 / -25 / -12 drew 3 sectors) — it just paints them at a scale that means nothing. "Showing 1 of 3" would be a false statement about what is on the screen.

No console warning, matching both sankey answers and unlike the two guards at the bottom of the file: those carry a diagnostic pair that does not fit on screen, whereas these sentences already name the key, the test it failed, and how many rows failed it.

i18n: none added. This file has no i18n call sites at all and both landed refusals plus PR #7161's footnote are hardcoded English; this matches the file.

Before / after, and the 50 tiles that did not move

Measured at a tile taller than the chart's own CHART_MIN_HEIGHT floor of 280 (see "assumption falsified" below). The pre-fix renderer was reproduced by double ablation and verified against the true pre-fix run: 56/56 tiles byte-identical, so the "before" column is the real thing rather than a stand-in.

24 tiles changed. 50 did not. The 50 are what make the 24 discriminating:

treemap--posNull and treemap--posZero still share an image after the fix, and that is correct: to a chart that sizes by value a null and a 0 are the same fact, and the copy names the predicate rather than the cause.

Seam map, pinned from both sides

datasetsankeypie / donut / funnel / treemap
no rows at alluntouched (#7130, upstream)untouched (#7130, upstream)
no row above zerono-positive-flow (#7146)no-positive-magnitude (this PR)
some rows above zeroomitted-rows (#7161)unsized-rows (this PR)
all rows above zerountoucheduntouched, no wrapper element added

A test asserts the four codes are mutually exclusive across every family x dataset pair in the sweep, and two more assert a sankey never receives this PR's code or attribute and vice versa.

Console diagnostic, with its control

Across all 72 chart tiles: zero console output. That zero is readable only because the same instrument's positive control did fire on the same run — a rows-carry-no-category-key tile printed [chart] no row has the category key "name" .... A2.4 confirmed on the widened population.

Assumptions falsified

  1. The card's "funnel all-zero is pixel-identical to a blank tile" does not reproduce at this head on this instrument. It draws its two category labels (412 ink pixels) and zero segments. The likely difference is that this probe compiles the real Tailwind theme, so hsl(var(--foreground)) resolves and the labels are visible. The finding stands regardless — 0 of 2 segments — but the specific pixel-identity claim is not what I measured, so I am not repeating it.
  2. A 520x240 tile — the box the card's own sweep used — is 40px shorter than the chart's own CHART_MIN_HEIGHT of 280, so no footnote can be seen in it. Measured with the landed footnote as the control: sankey--posNull is byte-identical before and after this change at 240 (2b123b92769b), i.e. PR fix(plugin-charts): a sankey that drew only some of its rows says how many #7161's shipped note is equally invisible there. This is a pre-existing property of sub-280 boxes that this PR inherits and does not worsen, not something introduced here. All before/after numbers above are therefore quoted at a box that fits the floor.
  3. A2.3 — see above; the mechanism differs per family at the Recharts level while the authoring-side predicate does not.

Tests

AdvancedChartImpl.degenerateMagnitude.test.tsx, 64 tests. Run from the repo root with root-relative paths.

pnpm exec vitest run packages/plugin-charts/src/AdvancedChartImpl.degenerateMagnitude.test.tsx
Test Files 1 passed (1)
Tests 64 passed (64)
pnpm exec vitest run packages/plugin-charts/
Test Files 39 passed (39)
Tests 329 passed (329)

Ablation, direction predicted before running

Two legs, each proving mutation on disk by marker count and blob hash and restore by state, both with an absolute-path trap:

legmutationpredictedobserved
Arefusal guard neutered (sizable === 0 to sizable === -1, 3 sites)refusal block red, note block green26 failed / 38 passed — all 26 in the refusal block
Bnote guard neutered (unsized <= 0 to unsized >= 0, 1 site)note block red, refusal block green26 failed / 38 passed — all 26 in the note block

The two red sets are disjoint, and each leg left the other 38 green — which is what makes them discriminating rather than a blanket break. Blob hashes: 433eb895... mutated to 328319dd... (A) and 5d9ad41a... (B); both restored to 433eb895... with git diff HEAD empty.

Gates

All run on the committed head dd79defc7, after the final commit.

gateverdict, as the gate printed it
vitest packages/plugin-charts/Test Files 39 passed (39) / Tests 329 passed (329)
type-checkexit 0, tsc --noEmit, script name echoed
lint287 problems (0 errors, 287 warnings) — exit 0, all warnings pre-existing
check-changeset-presence2 source file(s) of 1 released package(s) changed, and this change declares 1 changeset(s)
check-changeset-no-majorNo changeset declares a major bump.
check-control-bytesOK (scanned 5942 tracked text file(s); skipped 85 binary).
check-vi-mock-specifiersOK (4099 tracked source file(s), 2371 test-named; 525 carry a mock; ...)
check-vi-mock-inheritOK (4099 tracked source file(s), ... 118 inherit, 0 auto-mocked ...)
check-lint-coveragelint coverage: 46/46 packages linted, 0 with outstanding errors (0 total).
check-type-check-coveragetype-check coverage: 45/46 via type-check, ... 1 not compiled.

The three tracked-file gates were re-run aftergit add; their own counts moved (5940 to 5942 text files, 524 to 525 files carrying a mock), which is how I know they actually saw the new test rather than skipping it as untracked.

type-check was NOT MEASURED on first attempt — 16 Cannot find module '@object-ui/*' errors because the dependency closure was unbuilt. Built with pnpm --workspace-concurrency=2 --filter '@object-ui/plugin-charts^...' build and re-run to a real verdict. tsc --listFiles confirms both edited files are in the compiled set (1 hit each), so the green covers them.

Declared narrowing: repo-wide pnpm lint was not run locally; CI owns it. The per-package run is a measurement, not a guess — eslint's own config selected 52 files in this package (--format json count), both edited files are in that set with 0 errors, and this package's eslint config enables no type-aware linting, so nothing in this diff can move a verdict in a file it does not touch.

Out of scope, reported not fixed

  • scatter is NOT MEASURED, not clean. Its all-positive control drew zero marks on this instrument (markN: 0 on every scatter tile including the control), so its zeros say nothing — a control that returns zero is no control. Scatter takes two measures and this sweep's single-measure fixture does not bind it. Worth a proper sweep with a correct fixture; I did not file a card, per the report-do-not-duplicate fence.
  • radar measured and clean for this defect class: grid and axes survive an all-zero dataset and its hashes are distinct from both its all-positive and its one-row control.

Generated by Claude Code

…gnitude they can draw
These four families (pie, donut, funnel, treemap) size a mark BY its measure,
so a row whose value is zero, negative, null or unparseable stays in `data` and
is simply given no area. That is a third mechanism, distinct from the early
return objectui#7146 answered and the silent row drop objectui#7148 answered:
those rows are never filtered, so objectui#7148's dropped-row count is exactly
zero here and hoisting its footnote would have rendered nothing while looking
like coverage.
Measured in real Chromium across 74 tiles at 40c4711, each screenshotted,
MD5'd and pixel-diffed against an empty div of the same box:
- all-zero, all-null and all-negative pies put ZERO non-white pixels out of
124,800 on the page, byte-identical to the empty div, with 31 descendants
and a real svg in the DOM
- a pie handed 40 beside a null drew a FULL circle, 99.35% pixel-identical to
a legitimately one-row dataset
- a funnel handed 40 beside a null drew ZERO segments and one label reading
the name of the row that had NO value
- a treemap handed 40/null, 40/0 or 40/-25/-12 drew one full-bleed leaf,
byte-identical to a genuinely one-row treemap in all three cases
- an all-zero treemap drew one full-bleed leaf labelled with the LAST category
No row sizable now renders the file's refusal shell under its own code
(no-positive-magnitude); some rows sizable keeps the plot and adds a note
counting the rest (data-chart-note="unsized-rows"). All-positive charts gain no
wrapper element, the no-rows case is untouched (that is the empty-result
question, answered upstream in ObjectChart), bar keeps its axes, and both
sankey answers keep their own codes -- every sankey and bar tile hashed
identically before and after.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012wwHa4aaFybxXrfmfHioDM
@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

MetricValueBudget
Eager closure (gzip, 48 chunks)3154.9 KB3191.4 KB
Main entry chunk (gzip)142.6 KB350 KB
Entry fileindex-D4k7_yQL.js
StatusPASS

The eager closure is every chunk the entry reaches through static imports — what the browser fetches and parses before the app renders. The entry chunk on its own is a small fraction of it.


📦 Bundle Size Report

PackageSizeGzipped
app-shell (consoleActionDispatch.js)0.20KB0.19KB
app-shell (index.js)15.33KB5.59KB
app-shell (runtime-config.js)20.68KB7.36KB
app-shell (types.js)0.01KB0.04KB
app-shell (urlParams.js)10.06KB3.86KB
auth (ActiveOrganizationStorage.js)25.05KB9.16KB
auth (AuthContext.js)0.31KB0.24KB
auth (AuthGuard.js)2.07KB1.00KB
auth (AuthProvider.js)40.18KB10.59KB
auth (AuthShell.js)3.49KB1.40KB
auth (ForgotPasswordForm.js)12.21KB3.45KB
auth (LoginForm.js)18.15KB5.39KB
auth (PreviewBanner.js)0.90KB0.50KB
auth (RegisterForm.js)6.65KB2.22KB
auth (SocialSignInButtons.js)9.61KB3.89KB
auth (UserMenu.js)3.41KB1.23KB
auth (auth-gate-events.js)1.29KB0.66KB
auth (authStyles.js)5.04KB1.72KB
auth (createAuthClient.js)40.21KB10.80KB
auth (createAuthenticatedFetch.js)8.46KB3.43KB
auth (index.js)3.19KB1.44KB
auth (invitation-status.js)1.22KB0.70KB
auth (org-roles.js)6.66KB2.78KB
auth (phone-identifier.js)1.11KB0.66KB
auth (types.js)0.59KB0.35KB
auth (useAuth.js)5.30KB1.02KB
auth (useWorkspaceAdminStatus.js)5.13KB2.35KB
collaboration (CommentThread.js)26.08KB7.56KB
collaboration (LiveCursors.js)3.17KB1.27KB
collaboration (PresenceAvatars.js)6.49KB2.64KB
collaboration (PresenceProvider.js)2.79KB1.13KB
collaboration (index.js)1.68KB0.73KB
collaboration (useCollaborationTranslation.js)6.05KB2.52KB
collaboration (useCommentSearch.js)1.98KB0.88KB
collaboration (useConflictResolution.js)7.75KB1.86KB
collaboration (useMentionNotifications.js)1.81KB0.68KB
collaboration (usePresence.js)6.33KB1.84KB
collaboration (useRealtimeSubscription.js)7.91KB2.01KB
components (index.js)512.32KB116.52KB
core (index.js)5.30KB2.13KB
create-plugin (index.js)10.08KB3.26KB
data-objectstack (index.js)178.14KB49.57KB
fields (index.js)244.25KB61.73KB
i18n (LocalizationContext.js)1.76KB0.96KB
i18n (currency.js)1.22KB0.64KB
i18n (fallbackInterpolation.js)6.25KB2.77KB
i18n (i18n.js)4.28KB1.75KB
i18n (index.js)3.44KB1.39KB
i18n (pickLocalized.js)7.62KB3.26KB
i18n (provider.js)26.89KB9.04KB
i18n (useDisplayLocale.js)2.85KB1.45KB
i18n (useObjectLabel.js)33.40KB8.71KB
i18n (useSafeTranslation.js)5.60KB2.33KB
layout (index.js)38.98KB10.98KB
mobile (MobileProvider.js)0.92KB0.49KB
mobile (ResponsiveContainer.js)0.94KB0.38KB
mobile (breakpoints.js)1.51KB0.70KB
mobile (createOfflineDataSource.js)5.61KB1.75KB
mobile (index.js)1.55KB0.62KB
mobile (offlineQueue.js)3.91KB1.35KB
mobile (pwa.js)0.97KB0.49KB
mobile (serviceWorker.js)1.48KB0.62KB
mobile (serviceWorkerSource.js)3.41KB1.48KB
mobile (useBreakpoint.js)1.54KB0.65KB
mobile (useGesture.js)6.96KB1.98KB
mobile (useOfflineSync.js)1.99KB0.72KB
mobile (usePullToRefresh.js)2.53KB0.85KB
mobile (useResponsive.js)0.72KB0.42KB
mobile (useResponsiveConfig.js)1.37KB0.63KB
mobile (useSpecGesture.js)4.32KB1.64KB
mobile (useTouchTarget.js)1.01KB0.54KB
permissions (MePermissionsProvider.js)11.71KB4.29KB
permissions (PermissionContext.js)0.31KB0.25KB
permissions (PermissionGuard.js)0.89KB0.45KB
permissions (PermissionProvider.js)6.24KB2.16KB
permissions (discardProofCache.js)1.04KB0.55KB
permissions (evaluator.js)5.12KB1.74KB
permissions (index.js)0.93KB0.41KB
permissions (store.js)0.91KB0.42KB
permissions (useFieldPermissions.js)1.28KB0.53KB
permissions (usePermissions.js)4.83KB2.27KB
plugin-ai (index.js)15.75KB3.80KB
plugin-calendar (index.js)46.92KB12.93KB
plugin-charts (index.js)68.45KB19.20KB
plugin-chatbot (index.js)190.53KB45.18KB
plugin-dashboard (index.js)132.63KB34.56KB
plugin-designer (index.js)212.87KB43.19KB
plugin-detail (index.js)249.91KB63.82KB
plugin-editor (index.js)2.46KB1.10KB
plugin-form (index.js)133.11KB32.61KB
plugin-gantt (index.js)165.21KB40.37KB
plugin-grid (index.js)205.53KB55.50KB
plugin-kanban (index.js)53.21KB14.66KB
plugin-list (index.js)113.21KB27.60KB
plugin-map (index.js)20.20KB6.66KB
plugin-markdown (index.js)13.72KB4.69KB
plugin-report (index.js)43.51KB11.94KB
plugin-timeline (index.js)29.34KB8.47KB
plugin-tree (index.js)8.98KB3.08KB
plugin-view (index.js)85.90KB21.12KB
providers (DataSourceProvider.js)0.75KB0.39KB
providers (MetadataProvider.js)1.37KB0.59KB
providers (ThemeProvider.js)1.90KB0.85KB
providers (UploadProvider.js)11.66KB3.50KB
providers (index.js)0.45KB0.23KB
providers (types.js)0.01KB0.04KB
react-runtime (index.js)5.62KB2.34KB
react (LazyPluginLoader.js)4.47KB1.63KB
react (SchemaRenderer.js)81.07KB26.86KB
react (data-invalidation.js)5.05KB2.08KB
react (index.js)3.11KB1.48KB
react (schema-input.js)2.32KB1.24KB
react (spec-input.js)0.20KB0.18KB
sdui-parser (codegen.js)5.41KB2.34KB
sdui-parser (dashboard-widget-options.js)3.08KB1.30KB
sdui-parser (index.js)4.93KB2.24KB
sdui-parser (input-type.js)2.84KB1.40KB
sdui-parser (parse.js)20.57KB5.88KB
sdui-parser (provenance.js)3.66KB1.82KB
sdui-parser (types.js)0.28KB0.23KB
sdui-parser (validate.js)10.35KB3.60KB
types (ai.js)0.20KB0.17KB
types (api-types.js)0.20KB0.18KB
types (app.js)2.87KB0.99KB
types (base.js)0.20KB0.18KB
types (blocks.js)0.20KB0.18KB
types (complex.js)2.74KB1.41KB
types (crud.js)0.20KB0.18KB
types (dashboard-filter-alias.js)6.23KB2.74KB
types (data-display.js)3.75KB1.85KB
types (data-protocol.js)0.20KB0.19KB
types (data.js)0.20KB0.18KB
types (designer.js)1.85KB0.85KB
types (disclosure.js)0.20KB0.18KB
types (error-code.js)1.54KB0.88KB
types (feedback.js)0.20KB0.18KB
types (field-types.js)0.20KB0.18KB
types (form.js)0.20KB0.18KB
types (http-inflight.js)8.87KB3.73KB
types (http-retry.js)4.32KB2.02KB
types (icon-key-migration.js)4.26KB1.63KB
types (index.js)4.72KB2.24KB
types (layout.js)0.20KB0.18KB
types (managed-by.js)0.19KB0.18KB
types (mobile.js)2.59KB1.31KB
types (navigation.js)0.20KB0.18KB
types (objectql.js)0.20KB0.18KB
types (overlay.js)0.20KB0.18KB
types (permissions.js)0.20KB0.18KB
types (plugin-scope.js)0.20KB0.18KB
types (record-components.js)0.20KB0.19KB
types (record-semantics.js)1.28KB0.67KB
types (registry.js)0.20KB0.18KB
types (reports.js)0.20KB0.18KB
types (spec-report.js)5.05KB1.93KB
types (spec-ui-namespace.js)0.20KB0.19KB
types (system-fields.js)3.33KB1.54KB
types (theme.js)6.28KB2.87KB
types (ui-action.js)3.40KB1.71KB
types (views.js)0.20KB0.18KB
types (widget.js)0.20KB0.18KB

Size Limits

  • ✅ Core packages should be < 50KB gzipped
  • ✅ Component packages should be < 100KB gzipped
  • ⚠️ Plugin packages should be < 150KB gzipped

@os-warrenClaude

Copy link
Copy Markdown
CollaboratorAuthor

✅ Reviewed — will arm on green

⚠️ First: my claim on objectui#7147 was incomplete, and you were right to flag it

You reported: "the card carried the assignee and four PM comments but NO claim comment with a session ID, so I could not confirm the claim from the card itself."

Correct, and that is my error. This repo's rule is explicit — a claim is assign plus a claim comment carrying the session ID and branch, because all agents share one GitHub identity and the assignee field alone cannot tell you whose claim it is. I set the assignee and labels and never posted the comment. Every other card this session got one; this one did not.

Proceeding after confirming no competing claim was the right call. Noting it publicly because that gap is precisely how one issue gets implemented twice.

The measurement

74 tiles — 8 families × 9 datasets, plus a blank reference and a live console control — MD5'd and pixel-diffed against a literally empty div. What it found is worse than the card described:

caseresult
pie/donut, all-zero / all-null / all-negative0 non-white pixels of 124,800, while carrying 31 DOM descendants and a real svg
pie, 40 beside a nulldrew a FULL circle — 99.35% pixel-identical to a legitimately one-row dataset
funnel, 40 beside a null0 segments and ONE label — reading the name of the row that had NO value. The row worth 40 vanished and the empty one got the label
treemap, 40/null, 40/0, 40/-25/-12byte-identical (0.000%) to a genuinely one-row treemap — four datasets, one image

The funnel case is the sharpest thing here. It is not a blank and not a partial draw; it is a chart labelling the wrong row. A reader sees one named category and no indication that the category carrying all the value was dropped.

measured-and-declined was assessed per family — and survived for two

Bar and radar are clean and stay untouched. Radar's grid and axes survive an all-zero dataset with hashes distinct from both its all-positive and its one-row control. That is the outcome I most wanted to see possible: the fence said declining was legitimate, and it was taken where the evidence supported it rather than applied uniformly because a fix was available.

50 of 74 tiles unchanged, including every all-positive control, every one-row control, every no-rows tile, all 9 bar tiles and all 9 sankey tiles byte-identical.

The ablation design is the best of the session

Two legs with disjoint red sets. Neutering the refusal guard: 26 failed / 38 passed, all 26 in the refusal block. Neutering the note guard: 26 failed / 38 passed, all 26 in the note block. Each leg leaves the other's 38 green — which is what makes them discriminating rather than merely red.

And the third leg is the part I have not seen before: you reproduced the pre-fix renderer and verified the reproduction against the true pre-fix run — 56/56 tiles byte-identical. So the before/after table is measured against the real thing, not against a stand-in that merely resembles it. That closes the gap where a reconstruction quietly differs from what it claims to reconstruct.

Browser instrument rebuilt on each leg with the markers verified present in dist/assets/*.js afterwards — so the measurement ran against mutated code, not a stale bundle. And the three tracked-file gates were re-run after git add with their counts moving (5940→5942, 524→525), which is how you know they saw the new test rather than skipping it as untracked. That is a control on the gate itself.

Two things you reported rather than filed — both correct, both being carried

Scatter is NOT MEASURED, not clean. Every scatter tile including the all-positive control drew zero marks, so its zeros say nothing — a control that returns zero is no control. Scatter takes two measures and this sweep's single-measure fixture does not bind it. I am filing that as its own card; it has no existing home, unlike objectui#7148's finding which had objectui#7147 to attach to.

⚠️ The footnote-visibility property is the one that reaches back. A 520×240 tile is 40px shorter than CHART_MIN_HEIGHT (280), so no footnote can be seen in one — measured with objectui#7148's landed note as the live control, byte-identical before and after at 240. That means objectui#7148's shipped note is equally invisible at that size. You are right that this PR inherits the property without worsening it, and right to quote every before/after number at a box that fits the floor. I am recording it on objectui#7148.

Ledger

Refusal ships under its own code no-positive-magnitude rather than reusing objectui#7146's — correct, since the codes name which refusal fired. One shared predicate Number.isFinite(v) && v > 0 across three arms rather than three spellings. type-check first returned NOT MEASURED (16 unbuilt-closure errors), named as such and re-run to a real verdict after building the closure.


Generated by Claude Code

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

finding(plugin-charts): funnel and pie handed all-zero rows render a pixel-identical blank tile — same silence as #7140, a different trigger

2 participants

@os-warren@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

fix(plugin-charts): pie, funnel and treemap say when rows carry no magnitude they can draw - #7169

Merged
os-warren merged 1 commit into
mainfrom
claude/issue-7147-degenerate-geometry-blank
Sep 1, 2026
Merged

fix(plugin-charts): pie, funnel and treemap say when rows carry no magnitude they can draw#7169
os-warren merged 1 commit into
mainfrom
claude/issue-7147-degenerate-geometry-blank

Conversation

@os-warren

Copy link
Copy Markdown
Collaborator

Fixes#7147

Re-derived on origin/main40c4711 (the head this branch forks from; it already carries PR #7146's no-positive-flow refusal and PR #7161's ChartFootnote). The card's snippets and line numbers were stale, as PM assumed.

Rendered it and looked first

74 tiles in real Chromium (/opt/pw-browsers/chromium) — 8 chart families x 9 datasets, plus a blank reference and a live console control. Every tile screenshotted, MD5'd, and pixel-diffed against a literally empty div of the same box. measured-and-declined was genuinely on the table for each family separately. It survived for radar and bar, and for none of pie / donut / funnel / treemap.

The instrument's discriminating power, from its own controls: a two-row pie differs from a one-row pie by 9.683% of its pixels, a two-row treemap from a one-row treemap by 38.301%, and an all-zero bar puts 5,128 ink pixels (axes and ticks) against a blank tile's 0.

Per-family verdict

familydatasetwhat a reader saw at 40c4711verdict
pie / donutall-zero, all-null, all-negative0 non-white pixels of 124,800 — byte-identical to an empty div, while the DOM carried 31 descendants and a real svgfix
pie / donut40 beside a nulla full circle in the first category's colour — 99.35% pixel-identical to a legitimately one-row dataset (the 0.654% residue is the paddingAngle hairline, not information). The picture asserts "Alpha is 100%" of a dataset where Beta was never measuredfix
funnel40 beside a null178 ink pixels: zero segments, one label — and the label reads "Beta", the row with no value. The row carrying 40 drew nothing at allfix
funnelall-negativea confident two-band funnel whose mark area (220,320) exceeds the all-positive control's (111,881)fix
treemap40 / null, 40 / 0, 40 / -25 / -12all three byte-identical (diff 0.000%) to a genuinely one-row treemap — one full-bleed leaf labelled "Alpha". Four datasets, one imagefix
treemapall-zeroone full-bleed leaf labelled with the last category — as the card's adjacent observation recordedfix
barall-zero5,128 ink pixels of axes, ticks and a zero-labelled scaledeclined — its reader can already tell
radarall-zerogrid and axes drawn, and hashes distinct from both its all-positive and its one-row controldeclined

The mechanism is the same across all three families, and it is neither landed answer

The rows are never filtered. data reaches the pie, funnel and treemap elements whole — PR #7161 pinned that the sankey arm holds the only row-dropping filter in this file, and that still reads true at this head (AdvancedChartImpl.tsx:1081, the other two .filter( sites filter series). What happens instead is that the layout gives a non-positive row no area.

So PR #7161's count (data.length - rows.length) is exactly zero against these families. A hoisted copy of that footnote would have rendered nothing while looking like coverage. Confirmed rather than assumed: the sankey tiles are the only ones in the sweep carrying omitted-rows.

A2.3 was falsified in one respect and it changed nothing. Pie and funnel are not the same failure at the Recharts level — an unsizable pie row produces no sector element at all (path.recharts-sector = 0), whereas an unsizable funnel row produces a NaN-width trapezoid that also poisons its neighbour (which is why 40 beside a null loses the row worth 40, not the null one). Treemap is a third: the leaf collapses and its neighbours expand to fill the box. Three different Recharts behaviours, one authoring-side predicate — a row can only occupy area if its measure is a positive finite number — so one answer serves all three.

What changed

Three helpers, stated once so the arms cannot drift, and wired into the pie/donut, funnel and treemap arms:

  • countSizableRows(rows, dataKey)Number.isFinite(v) && v > 0. Number.isFinite rather than the sankey arm's Number(x) || 0 idiom, because this predicate must also reject Infinity, which || 0 lets straight through.
  • No row sizable and at least one row present: the file's existing refusal shell under its own code, data-chart-error="no-positive-magnitude".
  • Some rows sizable: the plot renders unchanged, wrapped in the existing ChartFootnote with data-chart-note="unsized-rows", carrying the count.

Copy names the predicate, not a cause — the reason no-positive-flow's docstring already gives: five shapes reach here (a genuine zero, a negative, null, an unparseable string, a missing key) and naming any one is a sentence false for the other four.

The note deliberately does not say "showing N of M". PR #7161's sankey note can, because there the missing rows are genuinely absent from the plot. Here they are not: a mixed-sign pie paints a sector for every row (measured: 40 / -25 / -12 drew 3 sectors) — it just paints them at a scale that means nothing. "Showing 1 of 3" would be a false statement about what is on the screen.

No console warning, matching both sankey answers and unlike the two guards at the bottom of the file: those carry a diagnostic pair that does not fit on screen, whereas these sentences already name the key, the test it failed, and how many rows failed it.

i18n: none added. This file has no i18n call sites at all and both landed refusals plus PR #7161's footnote are hardcoded English; this matches the file.

Before / after, and the 50 tiles that did not move

Measured at a tile taller than the chart's own CHART_MIN_HEIGHT floor of 280 (see "assumption falsified" below). The pre-fix renderer was reproduced by double ablation and verified against the true pre-fix run: 56/56 tiles byte-identical, so the "before" column is the real thing rather than a stand-in.

24 tiles changed. 50 did not. The 50 are what make the 24 discriminating:

treemap--posNull and treemap--posZero still share an image after the fix, and that is correct: to a chart that sizes by value a null and a 0 are the same fact, and the copy names the predicate rather than the cause.

Seam map, pinned from both sides

datasetsankeypie / donut / funnel / treemap
no rows at alluntouched (#7130, upstream)untouched (#7130, upstream)
no row above zerono-positive-flow (#7146)no-positive-magnitude (this PR)
some rows above zeroomitted-rows (#7161)unsized-rows (this PR)
all rows above zerountoucheduntouched, no wrapper element added

A test asserts the four codes are mutually exclusive across every family x dataset pair in the sweep, and two more assert a sankey never receives this PR's code or attribute and vice versa.

Console diagnostic, with its control

Across all 72 chart tiles: zero console output. That zero is readable only because the same instrument's positive control did fire on the same run — a rows-carry-no-category-key tile printed [chart] no row has the category key "name" .... A2.4 confirmed on the widened population.

Assumptions falsified

  1. The card's "funnel all-zero is pixel-identical to a blank tile" does not reproduce at this head on this instrument. It draws its two category labels (412 ink pixels) and zero segments. The likely difference is that this probe compiles the real Tailwind theme, so hsl(var(--foreground)) resolves and the labels are visible. The finding stands regardless — 0 of 2 segments — but the specific pixel-identity claim is not what I measured, so I am not repeating it.
  2. A 520x240 tile — the box the card's own sweep used — is 40px shorter than the chart's own CHART_MIN_HEIGHT of 280, so no footnote can be seen in it. Measured with the landed footnote as the control: sankey--posNull is byte-identical before and after this change at 240 (2b123b92769b), i.e. PR fix(plugin-charts): a sankey that drew only some of its rows says how many #7161's shipped note is equally invisible there. This is a pre-existing property of sub-280 boxes that this PR inherits and does not worsen, not something introduced here. All before/after numbers above are therefore quoted at a box that fits the floor.
  3. A2.3 — see above; the mechanism differs per family at the Recharts level while the authoring-side predicate does not.

Tests

AdvancedChartImpl.degenerateMagnitude.test.tsx, 64 tests. Run from the repo root with root-relative paths.

pnpm exec vitest run packages/plugin-charts/src/AdvancedChartImpl.degenerateMagnitude.test.tsx
Test Files 1 passed (1)
Tests 64 passed (64)
pnpm exec vitest run packages/plugin-charts/
Test Files 39 passed (39)
Tests 329 passed (329)

Ablation, direction predicted before running

Two legs, each proving mutation on disk by marker count and blob hash and restore by state, both with an absolute-path trap:

legmutationpredictedobserved
Arefusal guard neutered (sizable === 0 to sizable === -1, 3 sites)refusal block red, note block green26 failed / 38 passed — all 26 in the refusal block
Bnote guard neutered (unsized <= 0 to unsized >= 0, 1 site)note block red, refusal block green26 failed / 38 passed — all 26 in the note block

The two red sets are disjoint, and each leg left the other 38 green — which is what makes them discriminating rather than a blanket break. Blob hashes: 433eb895... mutated to 328319dd... (A) and 5d9ad41a... (B); both restored to 433eb895... with git diff HEAD empty.

Gates

All run on the committed head dd79defc7, after the final commit.

gateverdict, as the gate printed it
vitest packages/plugin-charts/Test Files 39 passed (39) / Tests 329 passed (329)
type-checkexit 0, tsc --noEmit, script name echoed
lint287 problems (0 errors, 287 warnings) — exit 0, all warnings pre-existing
check-changeset-presence2 source file(s) of 1 released package(s) changed, and this change declares 1 changeset(s)
check-changeset-no-majorNo changeset declares a major bump.
check-control-bytesOK (scanned 5942 tracked text file(s); skipped 85 binary).
check-vi-mock-specifiersOK (4099 tracked source file(s), 2371 test-named; 525 carry a mock; ...)
check-vi-mock-inheritOK (4099 tracked source file(s), ... 118 inherit, 0 auto-mocked ...)
check-lint-coveragelint coverage: 46/46 packages linted, 0 with outstanding errors (0 total).
check-type-check-coveragetype-check coverage: 45/46 via type-check, ... 1 not compiled.

The three tracked-file gates were re-run aftergit add; their own counts moved (5940 to 5942 text files, 524 to 525 files carrying a mock), which is how I know they actually saw the new test rather than skipping it as untracked.

type-check was NOT MEASURED on first attempt — 16 Cannot find module '@object-ui/*' errors because the dependency closure was unbuilt. Built with pnpm --workspace-concurrency=2 --filter '@object-ui/plugin-charts^...' build and re-run to a real verdict. tsc --listFiles confirms both edited files are in the compiled set (1 hit each), so the green covers them.

Declared narrowing: repo-wide pnpm lint was not run locally; CI owns it. The per-package run is a measurement, not a guess — eslint's own config selected 52 files in this package (--format json count), both edited files are in that set with 0 errors, and this package's eslint config enables no type-aware linting, so nothing in this diff can move a verdict in a file it does not touch.

Out of scope, reported not fixed

  • scatter is NOT MEASURED, not clean. Its all-positive control drew zero marks on this instrument (markN: 0 on every scatter tile including the control), so its zeros say nothing — a control that returns zero is no control. Scatter takes two measures and this sweep's single-measure fixture does not bind it. Worth a proper sweep with a correct fixture; I did not file a card, per the report-do-not-duplicate fence.
  • radar measured and clean for this defect class: grid and axes survive an all-zero dataset and its hashes are distinct from both its all-positive and its one-row control.

Generated by Claude Code

…gnitude they can draw
These four families (pie, donut, funnel, treemap) size a mark BY its measure,
so a row whose value is zero, negative, null or unparseable stays in `data` and
is simply given no area. That is a third mechanism, distinct from the early
return objectui#7146 answered and the silent row drop objectui#7148 answered:
those rows are never filtered, so objectui#7148's dropped-row count is exactly
zero here and hoisting its footnote would have rendered nothing while looking
like coverage.
Measured in real Chromium across 74 tiles at 40c4711, each screenshotted,
MD5'd and pixel-diffed against an empty div of the same box:
- all-zero, all-null and all-negative pies put ZERO non-white pixels out of
124,800 on the page, byte-identical to the empty div, with 31 descendants
and a real svg in the DOM
- a pie handed 40 beside a null drew a FULL circle, 99.35% pixel-identical to
a legitimately one-row dataset
- a funnel handed 40 beside a null drew ZERO segments and one label reading
the name of the row that had NO value
- a treemap handed 40/null, 40/0 or 40/-25/-12 drew one full-bleed leaf,
byte-identical to a genuinely one-row treemap in all three cases
- an all-zero treemap drew one full-bleed leaf labelled with the LAST category
No row sizable now renders the file's refusal shell under its own code
(no-positive-magnitude); some rows sizable keeps the plot and adds a note
counting the rest (data-chart-note="unsized-rows"). All-positive charts gain no
wrapper element, the no-rows case is untouched (that is the empty-result
question, answered upstream in ObjectChart), bar keeps its axes, and both
sankey answers keep their own codes -- every sankey and bar tile hashed
identically before and after.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012wwHa4aaFybxXrfmfHioDM
@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

MetricValueBudget
Eager closure (gzip, 48 chunks)3154.9 KB3191.4 KB
Main entry chunk (gzip)142.6 KB350 KB
Entry fileindex-D4k7_yQL.js
StatusPASS

The eager closure is every chunk the entry reaches through static imports — what the browser fetches and parses before the app renders. The entry chunk on its own is a small fraction of it.


📦 Bundle Size Report

PackageSizeGzipped
app-shell (consoleActionDispatch.js)0.20KB0.19KB
app-shell (index.js)15.33KB5.59KB
app-shell (runtime-config.js)20.68KB7.36KB
app-shell (types.js)0.01KB0.04KB
app-shell (urlParams.js)10.06KB3.86KB
auth (ActiveOrganizationStorage.js)25.05KB9.16KB
auth (AuthContext.js)0.31KB0.24KB
auth (AuthGuard.js)2.07KB1.00KB
auth (AuthProvider.js)40.18KB10.59KB
auth (AuthShell.js)3.49KB1.40KB
auth (ForgotPasswordForm.js)12.21KB3.45KB
auth (LoginForm.js)18.15KB5.39KB
auth (PreviewBanner.js)0.90KB0.50KB
auth (RegisterForm.js)6.65KB2.22KB
auth (SocialSignInButtons.js)9.61KB3.89KB
auth (UserMenu.js)3.41KB1.23KB
auth (auth-gate-events.js)1.29KB0.66KB
auth (authStyles.js)5.04KB1.72KB
auth (createAuthClient.js)40.21KB10.80KB
auth (createAuthenticatedFetch.js)8.46KB3.43KB
auth (index.js)3.19KB1.44KB
auth (invitation-status.js)1.22KB0.70KB
auth (org-roles.js)6.66KB2.78KB
auth (phone-identifier.js)1.11KB0.66KB
auth (types.js)0.59KB0.35KB
auth (useAuth.js)5.30KB1.02KB
auth (useWorkspaceAdminStatus.js)5.13KB2.35KB
collaboration (CommentThread.js)26.08KB7.56KB
collaboration (LiveCursors.js)3.17KB1.27KB
collaboration (PresenceAvatars.js)6.49KB2.64KB
collaboration (PresenceProvider.js)2.79KB1.13KB
collaboration (index.js)1.68KB0.73KB
collaboration (useCollaborationTranslation.js)6.05KB2.52KB
collaboration (useCommentSearch.js)1.98KB0.88KB
collaboration (useConflictResolution.js)7.75KB1.86KB
collaboration (useMentionNotifications.js)1.81KB0.68KB
collaboration (usePresence.js)6.33KB1.84KB
collaboration (useRealtimeSubscription.js)7.91KB2.01KB
components (index.js)512.32KB116.52KB
core (index.js)5.30KB2.13KB
create-plugin (index.js)10.08KB3.26KB
data-objectstack (index.js)178.14KB49.57KB
fields (index.js)244.25KB61.73KB
i18n (LocalizationContext.js)1.76KB0.96KB
i18n (currency.js)1.22KB0.64KB
i18n (fallbackInterpolation.js)6.25KB2.77KB
i18n (i18n.js)4.28KB1.75KB
i18n (index.js)3.44KB1.39KB
i18n (pickLocalized.js)7.62KB3.26KB
i18n (provider.js)26.89KB9.04KB
i18n (useDisplayLocale.js)2.85KB1.45KB
i18n (useObjectLabel.js)33.40KB8.71KB
i18n (useSafeTranslation.js)5.60KB2.33KB
layout (index.js)38.98KB10.98KB
mobile (MobileProvider.js)0.92KB0.49KB
mobile (ResponsiveContainer.js)0.94KB0.38KB
mobile (breakpoints.js)1.51KB0.70KB
mobile (createOfflineDataSource.js)5.61KB1.75KB
mobile (index.js)1.55KB0.62KB
mobile (offlineQueue.js)3.91KB1.35KB
mobile (pwa.js)0.97KB0.49KB
mobile (serviceWorker.js)1.48KB0.62KB
mobile (serviceWorkerSource.js)3.41KB1.48KB
mobile (useBreakpoint.js)1.54KB0.65KB
mobile (useGesture.js)6.96KB1.98KB
mobile (useOfflineSync.js)1.99KB0.72KB
mobile (usePullToRefresh.js)2.53KB0.85KB
mobile (useResponsive.js)0.72KB0.42KB
mobile (useResponsiveConfig.js)1.37KB0.63KB
mobile (useSpecGesture.js)4.32KB1.64KB
mobile (useTouchTarget.js)1.01KB0.54KB
permissions (MePermissionsProvider.js)11.71KB4.29KB
permissions (PermissionContext.js)0.31KB0.25KB
permissions (PermissionGuard.js)0.89KB0.45KB
permissions (PermissionProvider.js)6.24KB2.16KB
permissions (discardProofCache.js)1.04KB0.55KB
permissions (evaluator.js)5.12KB1.74KB
permissions (index.js)0.93KB0.41KB
permissions (store.js)0.91KB0.42KB
permissions (useFieldPermissions.js)1.28KB0.53KB
permissions (usePermissions.js)4.83KB2.27KB
plugin-ai (index.js)15.75KB3.80KB
plugin-calendar (index.js)46.92KB12.93KB
plugin-charts (index.js)68.45KB19.20KB
plugin-chatbot (index.js)190.53KB45.18KB
plugin-dashboard (index.js)132.63KB34.56KB
plugin-designer (index.js)212.87KB43.19KB
plugin-detail (index.js)249.91KB63.82KB
plugin-editor (index.js)2.46KB1.10KB
plugin-form (index.js)133.11KB32.61KB
plugin-gantt (index.js)165.21KB40.37KB
plugin-grid (index.js)205.53KB55.50KB
plugin-kanban (index.js)53.21KB14.66KB
plugin-list (index.js)113.21KB27.60KB
plugin-map (index.js)20.20KB6.66KB
plugin-markdown (index.js)13.72KB4.69KB
plugin-report (index.js)43.51KB11.94KB
plugin-timeline (index.js)29.34KB8.47KB
plugin-tree (index.js)8.98KB3.08KB
plugin-view (index.js)85.90KB21.12KB
providers (DataSourceProvider.js)0.75KB0.39KB
providers (MetadataProvider.js)1.37KB0.59KB
providers (ThemeProvider.js)1.90KB0.85KB
providers (UploadProvider.js)11.66KB3.50KB
providers (index.js)0.45KB0.23KB
providers (types.js)0.01KB0.04KB
react-runtime (index.js)5.62KB2.34KB
react (LazyPluginLoader.js)4.47KB1.63KB
react (SchemaRenderer.js)81.07KB26.86KB
react (data-invalidation.js)5.05KB2.08KB
react (index.js)3.11KB1.48KB
react (schema-input.js)2.32KB1.24KB
react (spec-input.js)0.20KB0.18KB
sdui-parser (codegen.js)5.41KB2.34KB
sdui-parser (dashboard-widget-options.js)3.08KB1.30KB
sdui-parser (index.js)4.93KB2.24KB
sdui-parser (input-type.js)2.84KB1.40KB
sdui-parser (parse.js)20.57KB5.88KB
sdui-parser (provenance.js)3.66KB1.82KB
sdui-parser (types.js)0.28KB0.23KB
sdui-parser (validate.js)10.35KB3.60KB
types (ai.js)0.20KB0.17KB
types (api-types.js)0.20KB0.18KB
types (app.js)2.87KB0.99KB
types (base.js)0.20KB0.18KB
types (blocks.js)0.20KB0.18KB
types (complex.js)2.74KB1.41KB
types (crud.js)0.20KB0.18KB
types (dashboard-filter-alias.js)6.23KB2.74KB
types (data-display.js)3.75KB1.85KB
types (data-protocol.js)0.20KB0.19KB
types (data.js)0.20KB0.18KB
types (designer.js)1.85KB0.85KB
types (disclosure.js)0.20KB0.18KB
types (error-code.js)1.54KB0.88KB
types (feedback.js)0.20KB0.18KB
types (field-types.js)0.20KB0.18KB
types (form.js)0.20KB0.18KB
types (http-inflight.js)8.87KB3.73KB
types (http-retry.js)4.32KB2.02KB
types (icon-key-migration.js)4.26KB1.63KB
types (index.js)4.72KB2.24KB
types (layout.js)0.20KB0.18KB
types (managed-by.js)0.19KB0.18KB
types (mobile.js)2.59KB1.31KB
types (navigation.js)0.20KB0.18KB
types (objectql.js)0.20KB0.18KB
types (overlay.js)0.20KB0.18KB
types (permissions.js)0.20KB0.18KB
types (plugin-scope.js)0.20KB0.18KB
types (record-components.js)0.20KB0.19KB
types (record-semantics.js)1.28KB0.67KB
types (registry.js)0.20KB0.18KB
types (reports.js)0.20KB0.18KB
types (spec-report.js)5.05KB1.93KB
types (spec-ui-namespace.js)0.20KB0.19KB
types (system-fields.js)3.33KB1.54KB
types (theme.js)6.28KB2.87KB
types (ui-action.js)3.40KB1.71KB
types (views.js)0.20KB0.18KB
types (widget.js)0.20KB0.18KB

Size Limits

  • ✅ Core packages should be < 50KB gzipped
  • ✅ Component packages should be < 100KB gzipped
  • ⚠️ Plugin packages should be < 150KB gzipped

@os-warrenClaude

Copy link
Copy Markdown
CollaboratorAuthor

✅ Reviewed — will arm on green

⚠️ First: my claim on objectui#7147 was incomplete, and you were right to flag it

You reported: "the card carried the assignee and four PM comments but NO claim comment with a session ID, so I could not confirm the claim from the card itself."

Correct, and that is my error. This repo's rule is explicit — a claim is assign plus a claim comment carrying the session ID and branch, because all agents share one GitHub identity and the assignee field alone cannot tell you whose claim it is. I set the assignee and labels and never posted the comment. Every other card this session got one; this one did not.

Proceeding after confirming no competing claim was the right call. Noting it publicly because that gap is precisely how one issue gets implemented twice.

The measurement

74 tiles — 8 families × 9 datasets, plus a blank reference and a live console control — MD5'd and pixel-diffed against a literally empty div. What it found is worse than the card described:

caseresult
pie/donut, all-zero / all-null / all-negative0 non-white pixels of 124,800, while carrying 31 DOM descendants and a real svg
pie, 40 beside a nulldrew a FULL circle — 99.35% pixel-identical to a legitimately one-row dataset
funnel, 40 beside a null0 segments and ONE label — reading the name of the row that had NO value. The row worth 40 vanished and the empty one got the label
treemap, 40/null, 40/0, 40/-25/-12byte-identical (0.000%) to a genuinely one-row treemap — four datasets, one image

The funnel case is the sharpest thing here. It is not a blank and not a partial draw; it is a chart labelling the wrong row. A reader sees one named category and no indication that the category carrying all the value was dropped.

measured-and-declined was assessed per family — and survived for two

Bar and radar are clean and stay untouched. Radar's grid and axes survive an all-zero dataset with hashes distinct from both its all-positive and its one-row control. That is the outcome I most wanted to see possible: the fence said declining was legitimate, and it was taken where the evidence supported it rather than applied uniformly because a fix was available.

50 of 74 tiles unchanged, including every all-positive control, every one-row control, every no-rows tile, all 9 bar tiles and all 9 sankey tiles byte-identical.

The ablation design is the best of the session

Two legs with disjoint red sets. Neutering the refusal guard: 26 failed / 38 passed, all 26 in the refusal block. Neutering the note guard: 26 failed / 38 passed, all 26 in the note block. Each leg leaves the other's 38 green — which is what makes them discriminating rather than merely red.

And the third leg is the part I have not seen before: you reproduced the pre-fix renderer and verified the reproduction against the true pre-fix run — 56/56 tiles byte-identical. So the before/after table is measured against the real thing, not against a stand-in that merely resembles it. That closes the gap where a reconstruction quietly differs from what it claims to reconstruct.

Browser instrument rebuilt on each leg with the markers verified present in dist/assets/*.js afterwards — so the measurement ran against mutated code, not a stale bundle. And the three tracked-file gates were re-run after git add with their counts moving (5940→5942, 524→525), which is how you know they saw the new test rather than skipping it as untracked. That is a control on the gate itself.

Two things you reported rather than filed — both correct, both being carried

Scatter is NOT MEASURED, not clean. Every scatter tile including the all-positive control drew zero marks, so its zeros say nothing — a control that returns zero is no control. Scatter takes two measures and this sweep's single-measure fixture does not bind it. I am filing that as its own card; it has no existing home, unlike objectui#7148's finding which had objectui#7147 to attach to.

⚠️ The footnote-visibility property is the one that reaches back. A 520×240 tile is 40px shorter than CHART_MIN_HEIGHT (280), so no footnote can be seen in one — measured with objectui#7148's landed note as the live control, byte-identical before and after at 240. That means objectui#7148's shipped note is equally invisible at that size. You are right that this PR inherits the property without worsening it, and right to quote every before/after number at a box that fits the floor. I am recording it on objectui#7148.

Ledger

Refusal ships under its own code no-positive-magnitude rather than reusing objectui#7146's — correct, since the codes name which refusal fired. One shared predicate Number.isFinite(v) && v > 0 across three arms rather than three spellings. type-check first returned NOT MEASURED (16 unbuilt-closure errors), named as such and re-run to a real verdict after building the closure.


Generated by Claude Code

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

finding(plugin-charts): funnel and pie handed all-zero rows render a pixel-identical blank tile — same silence as #7140, a different trigger

2 participants

@os-warren@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content

fix(plugin-charts): pie, funnel and treemap say when rows carry no magnitude they can draw - #7169

Merged
os-warren merged 1 commit into
mainfrom
claude/issue-7147-degenerate-geometry-blank
Sep 1, 2026
Merged

fix(plugin-charts): pie, funnel and treemap say when rows carry no magnitude they can draw#7169
os-warren merged 1 commit into
mainfrom
claude/issue-7147-degenerate-geometry-blank

Conversation

@os-warren

Copy link
Copy Markdown
Collaborator

Fixes#7147

Re-derived on origin/main40c4711 (the head this branch forks from; it already carries PR #7146's no-positive-flow refusal and PR #7161's ChartFootnote). The card's snippets and line numbers were stale, as PM assumed.

Rendered it and looked first

74 tiles in real Chromium (/opt/pw-browsers/chromium) — 8 chart families x 9 datasets, plus a blank reference and a live console control. Every tile screenshotted, MD5'd, and pixel-diffed against a literally empty div of the same box. measured-and-declined was genuinely on the table for each family separately. It survived for radar and bar, and for none of pie / donut / funnel / treemap.

The instrument's discriminating power, from its own controls: a two-row pie differs from a one-row pie by 9.683% of its pixels, a two-row treemap from a one-row treemap by 38.301%, and an all-zero bar puts 5,128 ink pixels (axes and ticks) against a blank tile's 0.

Per-family verdict

familydatasetwhat a reader saw at 40c4711verdict
pie / donutall-zero, all-null, all-negative0 non-white pixels of 124,800 — byte-identical to an empty div, while the DOM carried 31 descendants and a real svgfix
pie / donut40 beside a nulla full circle in the first category's colour — 99.35% pixel-identical to a legitimately one-row dataset (the 0.654% residue is the paddingAngle hairline, not information). The picture asserts "Alpha is 100%" of a dataset where Beta was never measuredfix
funnel40 beside a null178 ink pixels: zero segments, one label — and the label reads "Beta", the row with no value. The row carrying 40 drew nothing at allfix
funnelall-negativea confident two-band funnel whose mark area (220,320) exceeds the all-positive control's (111,881)fix
treemap40 / null, 40 / 0, 40 / -25 / -12all three byte-identical (diff 0.000%) to a genuinely one-row treemap — one full-bleed leaf labelled "Alpha". Four datasets, one imagefix
treemapall-zeroone full-bleed leaf labelled with the last category — as the card's adjacent observation recordedfix
barall-zero5,128 ink pixels of axes, ticks and a zero-labelled scaledeclined — its reader can already tell
radarall-zerogrid and axes drawn, and hashes distinct from both its all-positive and its one-row controldeclined

The mechanism is the same across all three families, and it is neither landed answer

The rows are never filtered. data reaches the pie, funnel and treemap elements whole — PR #7161 pinned that the sankey arm holds the only row-dropping filter in this file, and that still reads true at this head (AdvancedChartImpl.tsx:1081, the other two .filter( sites filter series). What happens instead is that the layout gives a non-positive row no area.

So PR #7161's count (data.length - rows.length) is exactly zero against these families. A hoisted copy of that footnote would have rendered nothing while looking like coverage. Confirmed rather than assumed: the sankey tiles are the only ones in the sweep carrying omitted-rows.

A2.3 was falsified in one respect and it changed nothing. Pie and funnel are not the same failure at the Recharts level — an unsizable pie row produces no sector element at all (path.recharts-sector = 0), whereas an unsizable funnel row produces a NaN-width trapezoid that also poisons its neighbour (which is why 40 beside a null loses the row worth 40, not the null one). Treemap is a third: the leaf collapses and its neighbours expand to fill the box. Three different Recharts behaviours, one authoring-side predicate — a row can only occupy area if its measure is a positive finite number — so one answer serves all three.

What changed

Three helpers, stated once so the arms cannot drift, and wired into the pie/donut, funnel and treemap arms:

  • countSizableRows(rows, dataKey)Number.isFinite(v) && v > 0. Number.isFinite rather than the sankey arm's Number(x) || 0 idiom, because this predicate must also reject Infinity, which || 0 lets straight through.
  • No row sizable and at least one row present: the file's existing refusal shell under its own code, data-chart-error="no-positive-magnitude".
  • Some rows sizable: the plot renders unchanged, wrapped in the existing ChartFootnote with data-chart-note="unsized-rows", carrying the count.

Copy names the predicate, not a cause — the reason no-positive-flow's docstring already gives: five shapes reach here (a genuine zero, a negative, null, an unparseable string, a missing key) and naming any one is a sentence false for the other four.

The note deliberately does not say "showing N of M". PR #7161's sankey note can, because there the missing rows are genuinely absent from the plot. Here they are not: a mixed-sign pie paints a sector for every row (measured: 40 / -25 / -12 drew 3 sectors) — it just paints them at a scale that means nothing. "Showing 1 of 3" would be a false statement about what is on the screen.

No console warning, matching both sankey answers and unlike the two guards at the bottom of the file: those carry a diagnostic pair that does not fit on screen, whereas these sentences already name the key, the test it failed, and how many rows failed it.

i18n: none added. This file has no i18n call sites at all and both landed refusals plus PR #7161's footnote are hardcoded English; this matches the file.

Before / after, and the 50 tiles that did not move

Measured at a tile taller than the chart's own CHART_MIN_HEIGHT floor of 280 (see "assumption falsified" below). The pre-fix renderer was reproduced by double ablation and verified against the true pre-fix run: 56/56 tiles byte-identical, so the "before" column is the real thing rather than a stand-in.

24 tiles changed. 50 did not. The 50 are what make the 24 discriminating:

treemap--posNull and treemap--posZero still share an image after the fix, and that is correct: to a chart that sizes by value a null and a 0 are the same fact, and the copy names the predicate rather than the cause.

Seam map, pinned from both sides

datasetsankeypie / donut / funnel / treemap
no rows at alluntouched (#7130, upstream)untouched (#7130, upstream)
no row above zerono-positive-flow (#7146)no-positive-magnitude (this PR)
some rows above zeroomitted-rows (#7161)unsized-rows (this PR)
all rows above zerountoucheduntouched, no wrapper element added

A test asserts the four codes are mutually exclusive across every family x dataset pair in the sweep, and two more assert a sankey never receives this PR's code or attribute and vice versa.

Console diagnostic, with its control

Across all 72 chart tiles: zero console output. That zero is readable only because the same instrument's positive control did fire on the same run — a rows-carry-no-category-key tile printed [chart] no row has the category key "name" .... A2.4 confirmed on the widened population.

Assumptions falsified

  1. The card's "funnel all-zero is pixel-identical to a blank tile" does not reproduce at this head on this instrument. It draws its two category labels (412 ink pixels) and zero segments. The likely difference is that this probe compiles the real Tailwind theme, so hsl(var(--foreground)) resolves and the labels are visible. The finding stands regardless — 0 of 2 segments — but the specific pixel-identity claim is not what I measured, so I am not repeating it.
  2. A 520x240 tile — the box the card's own sweep used — is 40px shorter than the chart's own CHART_MIN_HEIGHT of 280, so no footnote can be seen in it. Measured with the landed footnote as the control: sankey--posNull is byte-identical before and after this change at 240 (2b123b92769b), i.e. PR fix(plugin-charts): a sankey that drew only some of its rows says how many #7161's shipped note is equally invisible there. This is a pre-existing property of sub-280 boxes that this PR inherits and does not worsen, not something introduced here. All before/after numbers above are therefore quoted at a box that fits the floor.
  3. A2.3 — see above; the mechanism differs per family at the Recharts level while the authoring-side predicate does not.

Tests

AdvancedChartImpl.degenerateMagnitude.test.tsx, 64 tests. Run from the repo root with root-relative paths.

pnpm exec vitest run packages/plugin-charts/src/AdvancedChartImpl.degenerateMagnitude.test.tsx
Test Files 1 passed (1)
Tests 64 passed (64)
pnpm exec vitest run packages/plugin-charts/
Test Files 39 passed (39)
Tests 329 passed (329)

Ablation, direction predicted before running

Two legs, each proving mutation on disk by marker count and blob hash and restore by state, both with an absolute-path trap:

legmutationpredictedobserved
Arefusal guard neutered (sizable === 0 to sizable === -1, 3 sites)refusal block red, note block green26 failed / 38 passed — all 26 in the refusal block
Bnote guard neutered (unsized <= 0 to unsized >= 0, 1 site)note block red, refusal block green26 failed / 38 passed — all 26 in the note block

The two red sets are disjoint, and each leg left the other 38 green — which is what makes them discriminating rather than a blanket break. Blob hashes: 433eb895... mutated to 328319dd... (A) and 5d9ad41a... (B); both restored to 433eb895... with git diff HEAD empty.

Gates

All run on the committed head dd79defc7, after the final commit.

gateverdict, as the gate printed it
vitest packages/plugin-charts/Test Files 39 passed (39) / Tests 329 passed (329)
type-checkexit 0, tsc --noEmit, script name echoed
lint287 problems (0 errors, 287 warnings) — exit 0, all warnings pre-existing
check-changeset-presence2 source file(s) of 1 released package(s) changed, and this change declares 1 changeset(s)
check-changeset-no-majorNo changeset declares a major bump.
check-control-bytesOK (scanned 5942 tracked text file(s); skipped 85 binary).
check-vi-mock-specifiersOK (4099 tracked source file(s), 2371 test-named; 525 carry a mock; ...)
check-vi-mock-inheritOK (4099 tracked source file(s), ... 118 inherit, 0 auto-mocked ...)
check-lint-coveragelint coverage: 46/46 packages linted, 0 with outstanding errors (0 total).
check-type-check-coveragetype-check coverage: 45/46 via type-check, ... 1 not compiled.

The three tracked-file gates were re-run aftergit add; their own counts moved (5940 to 5942 text files, 524 to 525 files carrying a mock), which is how I know they actually saw the new test rather than skipping it as untracked.

type-check was NOT MEASURED on first attempt — 16 Cannot find module '@object-ui/*' errors because the dependency closure was unbuilt. Built with pnpm --workspace-concurrency=2 --filter '@object-ui/plugin-charts^...' build and re-run to a real verdict. tsc --listFiles confirms both edited files are in the compiled set (1 hit each), so the green covers them.

Declared narrowing: repo-wide pnpm lint was not run locally; CI owns it. The per-package run is a measurement, not a guess — eslint's own config selected 52 files in this package (--format json count), both edited files are in that set with 0 errors, and this package's eslint config enables no type-aware linting, so nothing in this diff can move a verdict in a file it does not touch.

Out of scope, reported not fixed

  • scatter is NOT MEASURED, not clean. Its all-positive control drew zero marks on this instrument (markN: 0 on every scatter tile including the control), so its zeros say nothing — a control that returns zero is no control. Scatter takes two measures and this sweep's single-measure fixture does not bind it. Worth a proper sweep with a correct fixture; I did not file a card, per the report-do-not-duplicate fence.
  • radar measured and clean for this defect class: grid and axes survive an all-zero dataset and its hashes are distinct from both its all-positive and its one-row control.

Generated by Claude Code

…gnitude they can draw
These four families (pie, donut, funnel, treemap) size a mark BY its measure,
so a row whose value is zero, negative, null or unparseable stays in `data` and
is simply given no area. That is a third mechanism, distinct from the early
return objectui#7146 answered and the silent row drop objectui#7148 answered:
those rows are never filtered, so objectui#7148's dropped-row count is exactly
zero here and hoisting its footnote would have rendered nothing while looking
like coverage.
Measured in real Chromium across 74 tiles at 40c4711, each screenshotted,
MD5'd and pixel-diffed against an empty div of the same box:
- all-zero, all-null and all-negative pies put ZERO non-white pixels out of
124,800 on the page, byte-identical to the empty div, with 31 descendants
and a real svg in the DOM
- a pie handed 40 beside a null drew a FULL circle, 99.35% pixel-identical to
a legitimately one-row dataset
- a funnel handed 40 beside a null drew ZERO segments and one label reading
the name of the row that had NO value
- a treemap handed 40/null, 40/0 or 40/-25/-12 drew one full-bleed leaf,
byte-identical to a genuinely one-row treemap in all three cases
- an all-zero treemap drew one full-bleed leaf labelled with the LAST category
No row sizable now renders the file's refusal shell under its own code
(no-positive-magnitude); some rows sizable keeps the plot and adds a note
counting the rest (data-chart-note="unsized-rows"). All-positive charts gain no
wrapper element, the no-rows case is untouched (that is the empty-result
question, answered upstream in ObjectChart), bar keeps its axes, and both
sankey answers keep their own codes -- every sankey and bar tile hashed
identically before and after.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012wwHa4aaFybxXrfmfHioDM
@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

MetricValueBudget
Eager closure (gzip, 48 chunks)3154.9 KB3191.4 KB
Main entry chunk (gzip)142.6 KB350 KB
Entry fileindex-D4k7_yQL.js
StatusPASS

The eager closure is every chunk the entry reaches through static imports — what the browser fetches and parses before the app renders. The entry chunk on its own is a small fraction of it.


📦 Bundle Size Report

PackageSizeGzipped
app-shell (consoleActionDispatch.js)0.20KB0.19KB
app-shell (index.js)15.33KB5.59KB
app-shell (runtime-config.js)20.68KB7.36KB
app-shell (types.js)0.01KB0.04KB
app-shell (urlParams.js)10.06KB3.86KB
auth (ActiveOrganizationStorage.js)25.05KB9.16KB
auth (AuthContext.js)0.31KB0.24KB
auth (AuthGuard.js)2.07KB1.00KB
auth (AuthProvider.js)40.18KB10.59KB
auth (AuthShell.js)3.49KB1.40KB
auth (ForgotPasswordForm.js)12.21KB3.45KB
auth (LoginForm.js)18.15KB5.39KB
auth (PreviewBanner.js)0.90KB0.50KB
auth (RegisterForm.js)6.65KB2.22KB
auth (SocialSignInButtons.js)9.61KB3.89KB
auth (UserMenu.js)3.41KB1.23KB
auth (auth-gate-events.js)1.29KB0.66KB
auth (authStyles.js)5.04KB1.72KB
auth (createAuthClient.js)40.21KB10.80KB
auth (createAuthenticatedFetch.js)8.46KB3.43KB
auth (index.js)3.19KB1.44KB
auth (invitation-status.js)1.22KB0.70KB
auth (org-roles.js)6.66KB2.78KB
auth (phone-identifier.js)1.11KB0.66KB
auth (types.js)0.59KB0.35KB
auth (useAuth.js)5.30KB1.02KB
auth (useWorkspaceAdminStatus.js)5.13KB2.35KB
collaboration (CommentThread.js)26.08KB7.56KB
collaboration (LiveCursors.js)3.17KB1.27KB
collaboration (PresenceAvatars.js)6.49KB2.64KB
collaboration (PresenceProvider.js)2.79KB1.13KB
collaboration (index.js)1.68KB0.73KB
collaboration (useCollaborationTranslation.js)6.05KB2.52KB
collaboration (useCommentSearch.js)1.98KB0.88KB
collaboration (useConflictResolution.js)7.75KB1.86KB
collaboration (useMentionNotifications.js)1.81KB0.68KB
collaboration (usePresence.js)6.33KB1.84KB
collaboration (useRealtimeSubscription.js)7.91KB2.01KB
components (index.js)512.32KB116.52KB
core (index.js)5.30KB2.13KB
create-plugin (index.js)10.08KB3.26KB
data-objectstack (index.js)178.14KB49.57KB
fields (index.js)244.25KB61.73KB
i18n (LocalizationContext.js)1.76KB0.96KB
i18n (currency.js)1.22KB0.64KB
i18n (fallbackInterpolation.js)6.25KB2.77KB
i18n (i18n.js)4.28KB1.75KB
i18n (index.js)3.44KB1.39KB
i18n (pickLocalized.js)7.62KB3.26KB
i18n (provider.js)26.89KB9.04KB
i18n (useDisplayLocale.js)2.85KB1.45KB
i18n (useObjectLabel.js)33.40KB8.71KB
i18n (useSafeTranslation.js)5.60KB2.33KB
layout (index.js)38.98KB10.98KB
mobile (MobileProvider.js)0.92KB0.49KB
mobile (ResponsiveContainer.js)0.94KB0.38KB
mobile (breakpoints.js)1.51KB0.70KB
mobile (createOfflineDataSource.js)5.61KB1.75KB
mobile (index.js)1.55KB0.62KB
mobile (offlineQueue.js)3.91KB1.35KB
mobile (pwa.js)0.97KB0.49KB
mobile (serviceWorker.js)1.48KB0.62KB
mobile (serviceWorkerSource.js)3.41KB1.48KB
mobile (useBreakpoint.js)1.54KB0.65KB
mobile (useGesture.js)6.96KB1.98KB
mobile (useOfflineSync.js)1.99KB0.72KB
mobile (usePullToRefresh.js)2.53KB0.85KB
mobile (useResponsive.js)0.72KB0.42KB
mobile (useResponsiveConfig.js)1.37KB0.63KB
mobile (useSpecGesture.js)4.32KB1.64KB
mobile (useTouchTarget.js)1.01KB0.54KB
permissions (MePermissionsProvider.js)11.71KB4.29KB
permissions (PermissionContext.js)0.31KB0.25KB
permissions (PermissionGuard.js)0.89KB0.45KB
permissions (PermissionProvider.js)6.24KB2.16KB
permissions (discardProofCache.js)1.04KB0.55KB
permissions (evaluator.js)5.12KB1.74KB
permissions (index.js)0.93KB0.41KB
permissions (store.js)0.91KB0.42KB
permissions (useFieldPermissions.js)1.28KB0.53KB
permissions (usePermissions.js)4.83KB2.27KB
plugin-ai (index.js)15.75KB3.80KB
plugin-calendar (index.js)46.92KB12.93KB
plugin-charts (index.js)68.45KB19.20KB
plugin-chatbot (index.js)190.53KB45.18KB
plugin-dashboard (index.js)132.63KB34.56KB
plugin-designer (index.js)212.87KB43.19KB
plugin-detail (index.js)249.91KB63.82KB
plugin-editor (index.js)2.46KB1.10KB
plugin-form (index.js)133.11KB32.61KB
plugin-gantt (index.js)165.21KB40.37KB
plugin-grid (index.js)205.53KB55.50KB
plugin-kanban (index.js)53.21KB14.66KB
plugin-list (index.js)113.21KB27.60KB
plugin-map (index.js)20.20KB6.66KB
plugin-markdown (index.js)13.72KB4.69KB
plugin-report (index.js)43.51KB11.94KB
plugin-timeline (index.js)29.34KB8.47KB
plugin-tree (index.js)8.98KB3.08KB
plugin-view (index.js)85.90KB21.12KB
providers (DataSourceProvider.js)0.75KB0.39KB
providers (MetadataProvider.js)1.37KB0.59KB
providers (ThemeProvider.js)1.90KB0.85KB
providers (UploadProvider.js)11.66KB3.50KB
providers (index.js)0.45KB0.23KB
providers (types.js)0.01KB0.04KB
react-runtime (index.js)5.62KB2.34KB
react (LazyPluginLoader.js)4.47KB1.63KB
react (SchemaRenderer.js)81.07KB26.86KB
react (data-invalidation.js)5.05KB2.08KB
react (index.js)3.11KB1.48KB
react (schema-input.js)2.32KB1.24KB
react (spec-input.js)0.20KB0.18KB
sdui-parser (codegen.js)5.41KB2.34KB
sdui-parser (dashboard-widget-options.js)3.08KB1.30KB
sdui-parser (index.js)4.93KB2.24KB
sdui-parser (input-type.js)2.84KB1.40KB
sdui-parser (parse.js)20.57KB5.88KB
sdui-parser (provenance.js)3.66KB1.82KB
sdui-parser (types.js)0.28KB0.23KB
sdui-parser (validate.js)10.35KB3.60KB
types (ai.js)0.20KB0.17KB
types (api-types.js)0.20KB0.18KB
types (app.js)2.87KB0.99KB
types (base.js)0.20KB0.18KB
types (blocks.js)0.20KB0.18KB
types (complex.js)2.74KB1.41KB
types (crud.js)0.20KB0.18KB
types (dashboard-filter-alias.js)6.23KB2.74KB
types (data-display.js)3.75KB1.85KB
types (data-protocol.js)0.20KB0.19KB
types (data.js)0.20KB0.18KB
types (designer.js)1.85KB0.85KB
types (disclosure.js)0.20KB0.18KB
types (error-code.js)1.54KB0.88KB
types (feedback.js)0.20KB0.18KB
types (field-types.js)0.20KB0.18KB
types (form.js)0.20KB0.18KB
types (http-inflight.js)8.87KB3.73KB
types (http-retry.js)4.32KB2.02KB
types (icon-key-migration.js)4.26KB1.63KB
types (index.js)4.72KB2.24KB
types (layout.js)0.20KB0.18KB
types (managed-by.js)0.19KB0.18KB
types (mobile.js)2.59KB1.31KB
types (navigation.js)0.20KB0.18KB
types (objectql.js)0.20KB0.18KB
types (overlay.js)0.20KB0.18KB
types (permissions.js)0.20KB0.18KB
types (plugin-scope.js)0.20KB0.18KB
types (record-components.js)0.20KB0.19KB
types (record-semantics.js)1.28KB0.67KB
types (registry.js)0.20KB0.18KB
types (reports.js)0.20KB0.18KB
types (spec-report.js)5.05KB1.93KB
types (spec-ui-namespace.js)0.20KB0.19KB
types (system-fields.js)3.33KB1.54KB
types (theme.js)6.28KB2.87KB
types (ui-action.js)3.40KB1.71KB
types (views.js)0.20KB0.18KB
types (widget.js)0.20KB0.18KB

Size Limits

  • ✅ Core packages should be < 50KB gzipped
  • ✅ Component packages should be < 100KB gzipped
  • ⚠️ Plugin packages should be < 150KB gzipped

@os-warrenClaude

Copy link
Copy Markdown
CollaboratorAuthor

✅ Reviewed — will arm on green

⚠️ First: my claim on objectui#7147 was incomplete, and you were right to flag it

You reported: "the card carried the assignee and four PM comments but NO claim comment with a session ID, so I could not confirm the claim from the card itself."

Correct, and that is my error. This repo's rule is explicit — a claim is assign plus a claim comment carrying the session ID and branch, because all agents share one GitHub identity and the assignee field alone cannot tell you whose claim it is. I set the assignee and labels and never posted the comment. Every other card this session got one; this one did not.

Proceeding after confirming no competing claim was the right call. Noting it publicly because that gap is precisely how one issue gets implemented twice.

The measurement

74 tiles — 8 families × 9 datasets, plus a blank reference and a live console control — MD5'd and pixel-diffed against a literally empty div. What it found is worse than the card described:

caseresult
pie/donut, all-zero / all-null / all-negative0 non-white pixels of 124,800, while carrying 31 DOM descendants and a real svg
pie, 40 beside a nulldrew a FULL circle — 99.35% pixel-identical to a legitimately one-row dataset
funnel, 40 beside a null0 segments and ONE label — reading the name of the row that had NO value. The row worth 40 vanished and the empty one got the label
treemap, 40/null, 40/0, 40/-25/-12byte-identical (0.000%) to a genuinely one-row treemap — four datasets, one image

The funnel case is the sharpest thing here. It is not a blank and not a partial draw; it is a chart labelling the wrong row. A reader sees one named category and no indication that the category carrying all the value was dropped.

measured-and-declined was assessed per family — and survived for two

Bar and radar are clean and stay untouched. Radar's grid and axes survive an all-zero dataset with hashes distinct from both its all-positive and its one-row control. That is the outcome I most wanted to see possible: the fence said declining was legitimate, and it was taken where the evidence supported it rather than applied uniformly because a fix was available.

50 of 74 tiles unchanged, including every all-positive control, every one-row control, every no-rows tile, all 9 bar tiles and all 9 sankey tiles byte-identical.

The ablation design is the best of the session

Two legs with disjoint red sets. Neutering the refusal guard: 26 failed / 38 passed, all 26 in the refusal block. Neutering the note guard: 26 failed / 38 passed, all 26 in the note block. Each leg leaves the other's 38 green — which is what makes them discriminating rather than merely red.

And the third leg is the part I have not seen before: you reproduced the pre-fix renderer and verified the reproduction against the true pre-fix run — 56/56 tiles byte-identical. So the before/after table is measured against the real thing, not against a stand-in that merely resembles it. That closes the gap where a reconstruction quietly differs from what it claims to reconstruct.

Browser instrument rebuilt on each leg with the markers verified present in dist/assets/*.js afterwards — so the measurement ran against mutated code, not a stale bundle. And the three tracked-file gates were re-run after git add with their counts moving (5940→5942, 524→525), which is how you know they saw the new test rather than skipping it as untracked. That is a control on the gate itself.

Two things you reported rather than filed — both correct, both being carried

Scatter is NOT MEASURED, not clean. Every scatter tile including the all-positive control drew zero marks, so its zeros say nothing — a control that returns zero is no control. Scatter takes two measures and this sweep's single-measure fixture does not bind it. I am filing that as its own card; it has no existing home, unlike objectui#7148's finding which had objectui#7147 to attach to.

⚠️ The footnote-visibility property is the one that reaches back. A 520×240 tile is 40px shorter than CHART_MIN_HEIGHT (280), so no footnote can be seen in one — measured with objectui#7148's landed note as the live control, byte-identical before and after at 240. That means objectui#7148's shipped note is equally invisible at that size. You are right that this PR inherits the property without worsening it, and right to quote every before/after number at a box that fits the floor. I am recording it on objectui#7148.

Ledger

Refusal ships under its own code no-positive-magnitude rather than reusing objectui#7146's — correct, since the codes name which refusal fired. One shared predicate Number.isFinite(v) && v > 0 across three arms rather than three spellings. type-check first returned NOT MEASURED (16 unbuilt-closure errors), named as such and re-run to a real verdict after building the closure.


Generated by Claude Code

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

finding(plugin-charts): funnel and pie handed all-zero rows render a pixel-identical blank tile — same silence as #7140, a different trigger

2 participants

@os-warren@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

fix(plugin-charts): pie, funnel and treemap say when rows carry no magnitude they can draw - #7169

Merged
os-warren merged 1 commit into
mainfrom
claude/issue-7147-degenerate-geometry-blank
Sep 1, 2026
Merged

fix(plugin-charts): pie, funnel and treemap say when rows carry no magnitude they can draw#7169
os-warren merged 1 commit into
mainfrom
claude/issue-7147-degenerate-geometry-blank

Conversation

@os-warren

Copy link
Copy Markdown
Collaborator

Fixes#7147

Re-derived on origin/main40c4711 (the head this branch forks from; it already carries PR #7146's no-positive-flow refusal and PR #7161's ChartFootnote). The card's snippets and line numbers were stale, as PM assumed.

Rendered it and looked first

74 tiles in real Chromium (/opt/pw-browsers/chromium) — 8 chart families x 9 datasets, plus a blank reference and a live console control. Every tile screenshotted, MD5'd, and pixel-diffed against a literally empty div of the same box. measured-and-declined was genuinely on the table for each family separately. It survived for radar and bar, and for none of pie / donut / funnel / treemap.

The instrument's discriminating power, from its own controls: a two-row pie differs from a one-row pie by 9.683% of its pixels, a two-row treemap from a one-row treemap by 38.301%, and an all-zero bar puts 5,128 ink pixels (axes and ticks) against a blank tile's 0.

Per-family verdict

familydatasetwhat a reader saw at 40c4711verdict
pie / donutall-zero, all-null, all-negative0 non-white pixels of 124,800 — byte-identical to an empty div, while the DOM carried 31 descendants and a real svgfix
pie / donut40 beside a nulla full circle in the first category's colour — 99.35% pixel-identical to a legitimately one-row dataset (the 0.654% residue is the paddingAngle hairline, not information). The picture asserts "Alpha is 100%" of a dataset where Beta was never measuredfix
funnel40 beside a null178 ink pixels: zero segments, one label — and the label reads "Beta", the row with no value. The row carrying 40 drew nothing at allfix
funnelall-negativea confident two-band funnel whose mark area (220,320) exceeds the all-positive control's (111,881)fix
treemap40 / null, 40 / 0, 40 / -25 / -12all three byte-identical (diff 0.000%) to a genuinely one-row treemap — one full-bleed leaf labelled "Alpha". Four datasets, one imagefix
treemapall-zeroone full-bleed leaf labelled with the last category — as the card's adjacent observation recordedfix
barall-zero5,128 ink pixels of axes, ticks and a zero-labelled scaledeclined — its reader can already tell
radarall-zerogrid and axes drawn, and hashes distinct from both its all-positive and its one-row controldeclined

The mechanism is the same across all three families, and it is neither landed answer

The rows are never filtered. data reaches the pie, funnel and treemap elements whole — PR #7161 pinned that the sankey arm holds the only row-dropping filter in this file, and that still reads true at this head (AdvancedChartImpl.tsx:1081, the other two .filter( sites filter series). What happens instead is that the layout gives a non-positive row no area.

So PR #7161's count (data.length - rows.length) is exactly zero against these families. A hoisted copy of that footnote would have rendered nothing while looking like coverage. Confirmed rather than assumed: the sankey tiles are the only ones in the sweep carrying omitted-rows.

A2.3 was falsified in one respect and it changed nothing. Pie and funnel are not the same failure at the Recharts level — an unsizable pie row produces no sector element at all (path.recharts-sector = 0), whereas an unsizable funnel row produces a NaN-width trapezoid that also poisons its neighbour (which is why 40 beside a null loses the row worth 40, not the null one). Treemap is a third: the leaf collapses and its neighbours expand to fill the box. Three different Recharts behaviours, one authoring-side predicate — a row can only occupy area if its measure is a positive finite number — so one answer serves all three.

What changed

Three helpers, stated once so the arms cannot drift, and wired into the pie/donut, funnel and treemap arms:

  • countSizableRows(rows, dataKey)Number.isFinite(v) && v > 0. Number.isFinite rather than the sankey arm's Number(x) || 0 idiom, because this predicate must also reject Infinity, which || 0 lets straight through.
  • No row sizable and at least one row present: the file's existing refusal shell under its own code, data-chart-error="no-positive-magnitude".
  • Some rows sizable: the plot renders unchanged, wrapped in the existing ChartFootnote with data-chart-note="unsized-rows", carrying the count.

Copy names the predicate, not a cause — the reason no-positive-flow's docstring already gives: five shapes reach here (a genuine zero, a negative, null, an unparseable string, a missing key) and naming any one is a sentence false for the other four.

The note deliberately does not say "showing N of M". PR #7161's sankey note can, because there the missing rows are genuinely absent from the plot. Here they are not: a mixed-sign pie paints a sector for every row (measured: 40 / -25 / -12 drew 3 sectors) — it just paints them at a scale that means nothing. "Showing 1 of 3" would be a false statement about what is on the screen.

No console warning, matching both sankey answers and unlike the two guards at the bottom of the file: those carry a diagnostic pair that does not fit on screen, whereas these sentences already name the key, the test it failed, and how many rows failed it.

i18n: none added. This file has no i18n call sites at all and both landed refusals plus PR #7161's footnote are hardcoded English; this matches the file.

Before / after, and the 50 tiles that did not move

Measured at a tile taller than the chart's own CHART_MIN_HEIGHT floor of 280 (see "assumption falsified" below). The pre-fix renderer was reproduced by double ablation and verified against the true pre-fix run: 56/56 tiles byte-identical, so the "before" column is the real thing rather than a stand-in.

24 tiles changed. 50 did not. The 50 are what make the 24 discriminating:

treemap--posNull and treemap--posZero still share an image after the fix, and that is correct: to a chart that sizes by value a null and a 0 are the same fact, and the copy names the predicate rather than the cause.

Seam map, pinned from both sides

datasetsankeypie / donut / funnel / treemap
no rows at alluntouched (#7130, upstream)untouched (#7130, upstream)
no row above zerono-positive-flow (#7146)no-positive-magnitude (this PR)
some rows above zeroomitted-rows (#7161)unsized-rows (this PR)
all rows above zerountoucheduntouched, no wrapper element added

A test asserts the four codes are mutually exclusive across every family x dataset pair in the sweep, and two more assert a sankey never receives this PR's code or attribute and vice versa.

Console diagnostic, with its control

Across all 72 chart tiles: zero console output. That zero is readable only because the same instrument's positive control did fire on the same run — a rows-carry-no-category-key tile printed [chart] no row has the category key "name" .... A2.4 confirmed on the widened population.

Assumptions falsified

  1. The card's "funnel all-zero is pixel-identical to a blank tile" does not reproduce at this head on this instrument. It draws its two category labels (412 ink pixels) and zero segments. The likely difference is that this probe compiles the real Tailwind theme, so hsl(var(--foreground)) resolves and the labels are visible. The finding stands regardless — 0 of 2 segments — but the specific pixel-identity claim is not what I measured, so I am not repeating it.
  2. A 520x240 tile — the box the card's own sweep used — is 40px shorter than the chart's own CHART_MIN_HEIGHT of 280, so no footnote can be seen in it. Measured with the landed footnote as the control: sankey--posNull is byte-identical before and after this change at 240 (2b123b92769b), i.e. PR fix(plugin-charts): a sankey that drew only some of its rows says how many #7161's shipped note is equally invisible there. This is a pre-existing property of sub-280 boxes that this PR inherits and does not worsen, not something introduced here. All before/after numbers above are therefore quoted at a box that fits the floor.
  3. A2.3 — see above; the mechanism differs per family at the Recharts level while the authoring-side predicate does not.

Tests

AdvancedChartImpl.degenerateMagnitude.test.tsx, 64 tests. Run from the repo root with root-relative paths.

pnpm exec vitest run packages/plugin-charts/src/AdvancedChartImpl.degenerateMagnitude.test.tsx
Test Files 1 passed (1)
Tests 64 passed (64)
pnpm exec vitest run packages/plugin-charts/
Test Files 39 passed (39)
Tests 329 passed (329)

Ablation, direction predicted before running

Two legs, each proving mutation on disk by marker count and blob hash and restore by state, both with an absolute-path trap:

legmutationpredictedobserved
Arefusal guard neutered (sizable === 0 to sizable === -1, 3 sites)refusal block red, note block green26 failed / 38 passed — all 26 in the refusal block
Bnote guard neutered (unsized <= 0 to unsized >= 0, 1 site)note block red, refusal block green26 failed / 38 passed — all 26 in the note block

The two red sets are disjoint, and each leg left the other 38 green — which is what makes them discriminating rather than a blanket break. Blob hashes: 433eb895... mutated to 328319dd... (A) and 5d9ad41a... (B); both restored to 433eb895... with git diff HEAD empty.

Gates

All run on the committed head dd79defc7, after the final commit.

gateverdict, as the gate printed it
vitest packages/plugin-charts/Test Files 39 passed (39) / Tests 329 passed (329)
type-checkexit 0, tsc --noEmit, script name echoed
lint287 problems (0 errors, 287 warnings) — exit 0, all warnings pre-existing
check-changeset-presence2 source file(s) of 1 released package(s) changed, and this change declares 1 changeset(s)
check-changeset-no-majorNo changeset declares a major bump.
check-control-bytesOK (scanned 5942 tracked text file(s); skipped 85 binary).
check-vi-mock-specifiersOK (4099 tracked source file(s), 2371 test-named; 525 carry a mock; ...)
check-vi-mock-inheritOK (4099 tracked source file(s), ... 118 inherit, 0 auto-mocked ...)
check-lint-coveragelint coverage: 46/46 packages linted, 0 with outstanding errors (0 total).
check-type-check-coveragetype-check coverage: 45/46 via type-check, ... 1 not compiled.

The three tracked-file gates were re-run aftergit add; their own counts moved (5940 to 5942 text files, 524 to 525 files carrying a mock), which is how I know they actually saw the new test rather than skipping it as untracked.

type-check was NOT MEASURED on first attempt — 16 Cannot find module '@object-ui/*' errors because the dependency closure was unbuilt. Built with pnpm --workspace-concurrency=2 --filter '@object-ui/plugin-charts^...' build and re-run to a real verdict. tsc --listFiles confirms both edited files are in the compiled set (1 hit each), so the green covers them.

Declared narrowing: repo-wide pnpm lint was not run locally; CI owns it. The per-package run is a measurement, not a guess — eslint's own config selected 52 files in this package (--format json count), both edited files are in that set with 0 errors, and this package's eslint config enables no type-aware linting, so nothing in this diff can move a verdict in a file it does not touch.

Out of scope, reported not fixed

  • scatter is NOT MEASURED, not clean. Its all-positive control drew zero marks on this instrument (markN: 0 on every scatter tile including the control), so its zeros say nothing — a control that returns zero is no control. Scatter takes two measures and this sweep's single-measure fixture does not bind it. Worth a proper sweep with a correct fixture; I did not file a card, per the report-do-not-duplicate fence.
  • radar measured and clean for this defect class: grid and axes survive an all-zero dataset and its hashes are distinct from both its all-positive and its one-row control.

Generated by Claude Code

…gnitude they can draw
These four families (pie, donut, funnel, treemap) size a mark BY its measure,
so a row whose value is zero, negative, null or unparseable stays in `data` and
is simply given no area. That is a third mechanism, distinct from the early
return objectui#7146 answered and the silent row drop objectui#7148 answered:
those rows are never filtered, so objectui#7148's dropped-row count is exactly
zero here and hoisting its footnote would have rendered nothing while looking
like coverage.
Measured in real Chromium across 74 tiles at 40c4711, each screenshotted,
MD5'd and pixel-diffed against an empty div of the same box:
- all-zero, all-null and all-negative pies put ZERO non-white pixels out of
124,800 on the page, byte-identical to the empty div, with 31 descendants
and a real svg in the DOM
- a pie handed 40 beside a null drew a FULL circle, 99.35% pixel-identical to
a legitimately one-row dataset
- a funnel handed 40 beside a null drew ZERO segments and one label reading
the name of the row that had NO value
- a treemap handed 40/null, 40/0 or 40/-25/-12 drew one full-bleed leaf,
byte-identical to a genuinely one-row treemap in all three cases
- an all-zero treemap drew one full-bleed leaf labelled with the LAST category
No row sizable now renders the file's refusal shell under its own code
(no-positive-magnitude); some rows sizable keeps the plot and adds a note
counting the rest (data-chart-note="unsized-rows"). All-positive charts gain no
wrapper element, the no-rows case is untouched (that is the empty-result
question, answered upstream in ObjectChart), bar keeps its axes, and both
sankey answers keep their own codes -- every sankey and bar tile hashed
identically before and after.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012wwHa4aaFybxXrfmfHioDM
@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

MetricValueBudget
Eager closure (gzip, 48 chunks)3154.9 KB3191.4 KB
Main entry chunk (gzip)142.6 KB350 KB
Entry fileindex-D4k7_yQL.js
StatusPASS

The eager closure is every chunk the entry reaches through static imports — what the browser fetches and parses before the app renders. The entry chunk on its own is a small fraction of it.


📦 Bundle Size Report

PackageSizeGzipped
app-shell (consoleActionDispatch.js)0.20KB0.19KB
app-shell (index.js)15.33KB5.59KB
app-shell (runtime-config.js)20.68KB7.36KB
app-shell (types.js)0.01KB0.04KB
app-shell (urlParams.js)10.06KB3.86KB
auth (ActiveOrganizationStorage.js)25.05KB9.16KB
auth (AuthContext.js)0.31KB0.24KB
auth (AuthGuard.js)2.07KB1.00KB
auth (AuthProvider.js)40.18KB10.59KB
auth (AuthShell.js)3.49KB1.40KB
auth (ForgotPasswordForm.js)12.21KB3.45KB
auth (LoginForm.js)18.15KB5.39KB
auth (PreviewBanner.js)0.90KB0.50KB
auth (RegisterForm.js)6.65KB2.22KB
auth (SocialSignInButtons.js)9.61KB3.89KB
auth (UserMenu.js)3.41KB1.23KB
auth (auth-gate-events.js)1.29KB0.66KB
auth (authStyles.js)5.04KB1.72KB
auth (createAuthClient.js)40.21KB10.80KB
auth (createAuthenticatedFetch.js)8.46KB3.43KB
auth (index.js)3.19KB1.44KB
auth (invitation-status.js)1.22KB0.70KB
auth (org-roles.js)6.66KB2.78KB
auth (phone-identifier.js)1.11KB0.66KB
auth (types.js)0.59KB0.35KB
auth (useAuth.js)5.30KB1.02KB
auth (useWorkspaceAdminStatus.js)5.13KB2.35KB
collaboration (CommentThread.js)26.08KB7.56KB
collaboration (LiveCursors.js)3.17KB1.27KB
collaboration (PresenceAvatars.js)6.49KB2.64KB
collaboration (PresenceProvider.js)2.79KB1.13KB
collaboration (index.js)1.68KB0.73KB
collaboration (useCollaborationTranslation.js)6.05KB2.52KB
collaboration (useCommentSearch.js)1.98KB0.88KB
collaboration (useConflictResolution.js)7.75KB1.86KB
collaboration (useMentionNotifications.js)1.81KB0.68KB
collaboration (usePresence.js)6.33KB1.84KB
collaboration (useRealtimeSubscription.js)7.91KB2.01KB
components (index.js)512.32KB116.52KB
core (index.js)5.30KB2.13KB
create-plugin (index.js)10.08KB3.26KB
data-objectstack (index.js)178.14KB49.57KB
fields (index.js)244.25KB61.73KB
i18n (LocalizationContext.js)1.76KB0.96KB
i18n (currency.js)1.22KB0.64KB
i18n (fallbackInterpolation.js)6.25KB2.77KB
i18n (i18n.js)4.28KB1.75KB
i18n (index.js)3.44KB1.39KB
i18n (pickLocalized.js)7.62KB3.26KB
i18n (provider.js)26.89KB9.04KB
i18n (useDisplayLocale.js)2.85KB1.45KB
i18n (useObjectLabel.js)33.40KB8.71KB
i18n (useSafeTranslation.js)5.60KB2.33KB
layout (index.js)38.98KB10.98KB
mobile (MobileProvider.js)0.92KB0.49KB
mobile (ResponsiveContainer.js)0.94KB0.38KB
mobile (breakpoints.js)1.51KB0.70KB
mobile (createOfflineDataSource.js)5.61KB1.75KB
mobile (index.js)1.55KB0.62KB
mobile (offlineQueue.js)3.91KB1.35KB
mobile (pwa.js)0.97KB0.49KB
mobile (serviceWorker.js)1.48KB0.62KB
mobile (serviceWorkerSource.js)3.41KB1.48KB
mobile (useBreakpoint.js)1.54KB0.65KB
mobile (useGesture.js)6.96KB1.98KB
mobile (useOfflineSync.js)1.99KB0.72KB
mobile (usePullToRefresh.js)2.53KB0.85KB
mobile (useResponsive.js)0.72KB0.42KB
mobile (useResponsiveConfig.js)1.37KB0.63KB
mobile (useSpecGesture.js)4.32KB1.64KB
mobile (useTouchTarget.js)1.01KB0.54KB
permissions (MePermissionsProvider.js)11.71KB4.29KB
permissions (PermissionContext.js)0.31KB0.25KB
permissions (PermissionGuard.js)0.89KB0.45KB
permissions (PermissionProvider.js)6.24KB2.16KB
permissions (discardProofCache.js)1.04KB0.55KB
permissions (evaluator.js)5.12KB1.74KB
permissions (index.js)0.93KB0.41KB
permissions (store.js)0.91KB0.42KB
permissions (useFieldPermissions.js)1.28KB0.53KB
permissions (usePermissions.js)4.83KB2.27KB
plugin-ai (index.js)15.75KB3.80KB
plugin-calendar (index.js)46.92KB12.93KB
plugin-charts (index.js)68.45KB19.20KB
plugin-chatbot (index.js)190.53KB45.18KB
plugin-dashboard (index.js)132.63KB34.56KB
plugin-designer (index.js)212.87KB43.19KB
plugin-detail (index.js)249.91KB63.82KB
plugin-editor (index.js)2.46KB1.10KB
plugin-form (index.js)133.11KB32.61KB
plugin-gantt (index.js)165.21KB40.37KB
plugin-grid (index.js)205.53KB55.50KB
plugin-kanban (index.js)53.21KB14.66KB
plugin-list (index.js)113.21KB27.60KB
plugin-map (index.js)20.20KB6.66KB
plugin-markdown (index.js)13.72KB4.69KB
plugin-report (index.js)43.51KB11.94KB
plugin-timeline (index.js)29.34KB8.47KB
plugin-tree (index.js)8.98KB3.08KB
plugin-view (index.js)85.90KB21.12KB
providers (DataSourceProvider.js)0.75KB0.39KB
providers (MetadataProvider.js)1.37KB0.59KB
providers (ThemeProvider.js)1.90KB0.85KB
providers (UploadProvider.js)11.66KB3.50KB
providers (index.js)0.45KB0.23KB
providers (types.js)0.01KB0.04KB
react-runtime (index.js)5.62KB2.34KB
react (LazyPluginLoader.js)4.47KB1.63KB
react (SchemaRenderer.js)81.07KB26.86KB
react (data-invalidation.js)5.05KB2.08KB
react (index.js)3.11KB1.48KB
react (schema-input.js)2.32KB1.24KB
react (spec-input.js)0.20KB0.18KB
sdui-parser (codegen.js)5.41KB2.34KB
sdui-parser (dashboard-widget-options.js)3.08KB1.30KB
sdui-parser (index.js)4.93KB2.24KB
sdui-parser (input-type.js)2.84KB1.40KB
sdui-parser (parse.js)20.57KB5.88KB
sdui-parser (provenance.js)3.66KB1.82KB
sdui-parser (types.js)0.28KB0.23KB
sdui-parser (validate.js)10.35KB3.60KB
types (ai.js)0.20KB0.17KB
types (api-types.js)0.20KB0.18KB
types (app.js)2.87KB0.99KB
types (base.js)0.20KB0.18KB
types (blocks.js)0.20KB0.18KB
types (complex.js)2.74KB1.41KB
types (crud.js)0.20KB0.18KB
types (dashboard-filter-alias.js)6.23KB2.74KB
types (data-display.js)3.75KB1.85KB
types (data-protocol.js)0.20KB0.19KB
types (data.js)0.20KB0.18KB
types (designer.js)1.85KB0.85KB
types (disclosure.js)0.20KB0.18KB
types (error-code.js)1.54KB0.88KB
types (feedback.js)0.20KB0.18KB
types (field-types.js)0.20KB0.18KB
types (form.js)0.20KB0.18KB
types (http-inflight.js)8.87KB3.73KB
types (http-retry.js)4.32KB2.02KB
types (icon-key-migration.js)4.26KB1.63KB
types (index.js)4.72KB2.24KB
types (layout.js)0.20KB0.18KB
types (managed-by.js)0.19KB0.18KB
types (mobile.js)2.59KB1.31KB
types (navigation.js)0.20KB0.18KB
types (objectql.js)0.20KB0.18KB
types (overlay.js)0.20KB0.18KB
types (permissions.js)0.20KB0.18KB
types (plugin-scope.js)0.20KB0.18KB
types (record-components.js)0.20KB0.19KB
types (record-semantics.js)1.28KB0.67KB
types (registry.js)0.20KB0.18KB
types (reports.js)0.20KB0.18KB
types (spec-report.js)5.05KB1.93KB
types (spec-ui-namespace.js)0.20KB0.19KB
types (system-fields.js)3.33KB1.54KB
types (theme.js)6.28KB2.87KB
types (ui-action.js)3.40KB1.71KB
types (views.js)0.20KB0.18KB
types (widget.js)0.20KB0.18KB

Size Limits

  • ✅ Core packages should be < 50KB gzipped
  • ✅ Component packages should be < 100KB gzipped
  • ⚠️ Plugin packages should be < 150KB gzipped

@os-warrenClaude

Copy link
Copy Markdown
CollaboratorAuthor

✅ Reviewed — will arm on green

⚠️ First: my claim on objectui#7147 was incomplete, and you were right to flag it

You reported: "the card carried the assignee and four PM comments but NO claim comment with a session ID, so I could not confirm the claim from the card itself."

Correct, and that is my error. This repo's rule is explicit — a claim is assign plus a claim comment carrying the session ID and branch, because all agents share one GitHub identity and the assignee field alone cannot tell you whose claim it is. I set the assignee and labels and never posted the comment. Every other card this session got one; this one did not.

Proceeding after confirming no competing claim was the right call. Noting it publicly because that gap is precisely how one issue gets implemented twice.

The measurement

74 tiles — 8 families × 9 datasets, plus a blank reference and a live console control — MD5'd and pixel-diffed against a literally empty div. What it found is worse than the card described:

caseresult
pie/donut, all-zero / all-null / all-negative0 non-white pixels of 124,800, while carrying 31 DOM descendants and a real svg
pie, 40 beside a nulldrew a FULL circle — 99.35% pixel-identical to a legitimately one-row dataset
funnel, 40 beside a null0 segments and ONE label — reading the name of the row that had NO value. The row worth 40 vanished and the empty one got the label
treemap, 40/null, 40/0, 40/-25/-12byte-identical (0.000%) to a genuinely one-row treemap — four datasets, one image

The funnel case is the sharpest thing here. It is not a blank and not a partial draw; it is a chart labelling the wrong row. A reader sees one named category and no indication that the category carrying all the value was dropped.

measured-and-declined was assessed per family — and survived for two

Bar and radar are clean and stay untouched. Radar's grid and axes survive an all-zero dataset with hashes distinct from both its all-positive and its one-row control. That is the outcome I most wanted to see possible: the fence said declining was legitimate, and it was taken where the evidence supported it rather than applied uniformly because a fix was available.

50 of 74 tiles unchanged, including every all-positive control, every one-row control, every no-rows tile, all 9 bar tiles and all 9 sankey tiles byte-identical.

The ablation design is the best of the session

Two legs with disjoint red sets. Neutering the refusal guard: 26 failed / 38 passed, all 26 in the refusal block. Neutering the note guard: 26 failed / 38 passed, all 26 in the note block. Each leg leaves the other's 38 green — which is what makes them discriminating rather than merely red.

And the third leg is the part I have not seen before: you reproduced the pre-fix renderer and verified the reproduction against the true pre-fix run — 56/56 tiles byte-identical. So the before/after table is measured against the real thing, not against a stand-in that merely resembles it. That closes the gap where a reconstruction quietly differs from what it claims to reconstruct.

Browser instrument rebuilt on each leg with the markers verified present in dist/assets/*.js afterwards — so the measurement ran against mutated code, not a stale bundle. And the three tracked-file gates were re-run after git add with their counts moving (5940→5942, 524→525), which is how you know they saw the new test rather than skipping it as untracked. That is a control on the gate itself.

Two things you reported rather than filed — both correct, both being carried

Scatter is NOT MEASURED, not clean. Every scatter tile including the all-positive control drew zero marks, so its zeros say nothing — a control that returns zero is no control. Scatter takes two measures and this sweep's single-measure fixture does not bind it. I am filing that as its own card; it has no existing home, unlike objectui#7148's finding which had objectui#7147 to attach to.

⚠️ The footnote-visibility property is the one that reaches back. A 520×240 tile is 40px shorter than CHART_MIN_HEIGHT (280), so no footnote can be seen in one — measured with objectui#7148's landed note as the live control, byte-identical before and after at 240. That means objectui#7148's shipped note is equally invisible at that size. You are right that this PR inherits the property without worsening it, and right to quote every before/after number at a box that fits the floor. I am recording it on objectui#7148.

Ledger

Refusal ships under its own code no-positive-magnitude rather than reusing objectui#7146's — correct, since the codes name which refusal fired. One shared predicate Number.isFinite(v) && v > 0 across three arms rather than three spellings. type-check first returned NOT MEASURED (16 unbuilt-closure errors), named as such and re-run to a real verdict after building the closure.


Generated by Claude Code

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

finding(plugin-charts): funnel and pie handed all-zero rows render a pixel-identical blank tile — same silence as #7140, a different trigger

2 participants

@os-warren@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

fix(plugin-charts): pie, funnel and treemap say when rows carry no magnitude they can draw - #7169

Merged
os-warren merged 1 commit into
mainfrom
claude/issue-7147-degenerate-geometry-blank
Sep 1, 2026
Merged

fix(plugin-charts): pie, funnel and treemap say when rows carry no magnitude they can draw#7169
os-warren merged 1 commit into
mainfrom
claude/issue-7147-degenerate-geometry-blank

Conversation

@os-warren

Copy link
Copy Markdown
Collaborator

Fixes#7147

Re-derived on origin/main40c4711 (the head this branch forks from; it already carries PR #7146's no-positive-flow refusal and PR #7161's ChartFootnote). The card's snippets and line numbers were stale, as PM assumed.

Rendered it and looked first

74 tiles in real Chromium (/opt/pw-browsers/chromium) — 8 chart families x 9 datasets, plus a blank reference and a live console control. Every tile screenshotted, MD5'd, and pixel-diffed against a literally empty div of the same box. measured-and-declined was genuinely on the table for each family separately. It survived for radar and bar, and for none of pie / donut / funnel / treemap.

The instrument's discriminating power, from its own controls: a two-row pie differs from a one-row pie by 9.683% of its pixels, a two-row treemap from a one-row treemap by 38.301%, and an all-zero bar puts 5,128 ink pixels (axes and ticks) against a blank tile's 0.

Per-family verdict

familydatasetwhat a reader saw at 40c4711verdict
pie / donutall-zero, all-null, all-negative0 non-white pixels of 124,800 — byte-identical to an empty div, while the DOM carried 31 descendants and a real svgfix
pie / donut40 beside a nulla full circle in the first category's colour — 99.35% pixel-identical to a legitimately one-row dataset (the 0.654% residue is the paddingAngle hairline, not information). The picture asserts "Alpha is 100%" of a dataset where Beta was never measuredfix
funnel40 beside a null178 ink pixels: zero segments, one label — and the label reads "Beta", the row with no value. The row carrying 40 drew nothing at allfix
funnelall-negativea confident two-band funnel whose mark area (220,320) exceeds the all-positive control's (111,881)fix
treemap40 / null, 40 / 0, 40 / -25 / -12all three byte-identical (diff 0.000%) to a genuinely one-row treemap — one full-bleed leaf labelled "Alpha". Four datasets, one imagefix
treemapall-zeroone full-bleed leaf labelled with the last category — as the card's adjacent observation recordedfix
barall-zero5,128 ink pixels of axes, ticks and a zero-labelled scaledeclined — its reader can already tell
radarall-zerogrid and axes drawn, and hashes distinct from both its all-positive and its one-row controldeclined

The mechanism is the same across all three families, and it is neither landed answer

The rows are never filtered. data reaches the pie, funnel and treemap elements whole — PR #7161 pinned that the sankey arm holds the only row-dropping filter in this file, and that still reads true at this head (AdvancedChartImpl.tsx:1081, the other two .filter( sites filter series). What happens instead is that the layout gives a non-positive row no area.

So PR #7161's count (data.length - rows.length) is exactly zero against these families. A hoisted copy of that footnote would have rendered nothing while looking like coverage. Confirmed rather than assumed: the sankey tiles are the only ones in the sweep carrying omitted-rows.

A2.3 was falsified in one respect and it changed nothing. Pie and funnel are not the same failure at the Recharts level — an unsizable pie row produces no sector element at all (path.recharts-sector = 0), whereas an unsizable funnel row produces a NaN-width trapezoid that also poisons its neighbour (which is why 40 beside a null loses the row worth 40, not the null one). Treemap is a third: the leaf collapses and its neighbours expand to fill the box. Three different Recharts behaviours, one authoring-side predicate — a row can only occupy area if its measure is a positive finite number — so one answer serves all three.

What changed

Three helpers, stated once so the arms cannot drift, and wired into the pie/donut, funnel and treemap arms:

  • countSizableRows(rows, dataKey)Number.isFinite(v) && v > 0. Number.isFinite rather than the sankey arm's Number(x) || 0 idiom, because this predicate must also reject Infinity, which || 0 lets straight through.
  • No row sizable and at least one row present: the file's existing refusal shell under its own code, data-chart-error="no-positive-magnitude".
  • Some rows sizable: the plot renders unchanged, wrapped in the existing ChartFootnote with data-chart-note="unsized-rows", carrying the count.

Copy names the predicate, not a cause — the reason no-positive-flow's docstring already gives: five shapes reach here (a genuine zero, a negative, null, an unparseable string, a missing key) and naming any one is a sentence false for the other four.

The note deliberately does not say "showing N of M". PR #7161's sankey note can, because there the missing rows are genuinely absent from the plot. Here they are not: a mixed-sign pie paints a sector for every row (measured: 40 / -25 / -12 drew 3 sectors) — it just paints them at a scale that means nothing. "Showing 1 of 3" would be a false statement about what is on the screen.

No console warning, matching both sankey answers and unlike the two guards at the bottom of the file: those carry a diagnostic pair that does not fit on screen, whereas these sentences already name the key, the test it failed, and how many rows failed it.

i18n: none added. This file has no i18n call sites at all and both landed refusals plus PR #7161's footnote are hardcoded English; this matches the file.

Before / after, and the 50 tiles that did not move

Measured at a tile taller than the chart's own CHART_MIN_HEIGHT floor of 280 (see "assumption falsified" below). The pre-fix renderer was reproduced by double ablation and verified against the true pre-fix run: 56/56 tiles byte-identical, so the "before" column is the real thing rather than a stand-in.

24 tiles changed. 50 did not. The 50 are what make the 24 discriminating:

treemap--posNull and treemap--posZero still share an image after the fix, and that is correct: to a chart that sizes by value a null and a 0 are the same fact, and the copy names the predicate rather than the cause.

Seam map, pinned from both sides

datasetsankeypie / donut / funnel / treemap
no rows at alluntouched (#7130, upstream)untouched (#7130, upstream)
no row above zerono-positive-flow (#7146)no-positive-magnitude (this PR)
some rows above zeroomitted-rows (#7161)unsized-rows (this PR)
all rows above zerountoucheduntouched, no wrapper element added

A test asserts the four codes are mutually exclusive across every family x dataset pair in the sweep, and two more assert a sankey never receives this PR's code or attribute and vice versa.

Console diagnostic, with its control

Across all 72 chart tiles: zero console output. That zero is readable only because the same instrument's positive control did fire on the same run — a rows-carry-no-category-key tile printed [chart] no row has the category key "name" .... A2.4 confirmed on the widened population.

Assumptions falsified

  1. The card's "funnel all-zero is pixel-identical to a blank tile" does not reproduce at this head on this instrument. It draws its two category labels (412 ink pixels) and zero segments. The likely difference is that this probe compiles the real Tailwind theme, so hsl(var(--foreground)) resolves and the labels are visible. The finding stands regardless — 0 of 2 segments — but the specific pixel-identity claim is not what I measured, so I am not repeating it.
  2. A 520x240 tile — the box the card's own sweep used — is 40px shorter than the chart's own CHART_MIN_HEIGHT of 280, so no footnote can be seen in it. Measured with the landed footnote as the control: sankey--posNull is byte-identical before and after this change at 240 (2b123b92769b), i.e. PR fix(plugin-charts): a sankey that drew only some of its rows says how many #7161's shipped note is equally invisible there. This is a pre-existing property of sub-280 boxes that this PR inherits and does not worsen, not something introduced here. All before/after numbers above are therefore quoted at a box that fits the floor.
  3. A2.3 — see above; the mechanism differs per family at the Recharts level while the authoring-side predicate does not.

Tests

AdvancedChartImpl.degenerateMagnitude.test.tsx, 64 tests. Run from the repo root with root-relative paths.

pnpm exec vitest run packages/plugin-charts/src/AdvancedChartImpl.degenerateMagnitude.test.tsx
Test Files 1 passed (1)
Tests 64 passed (64)
pnpm exec vitest run packages/plugin-charts/
Test Files 39 passed (39)
Tests 329 passed (329)

Ablation, direction predicted before running

Two legs, each proving mutation on disk by marker count and blob hash and restore by state, both with an absolute-path trap:

legmutationpredictedobserved
Arefusal guard neutered (sizable === 0 to sizable === -1, 3 sites)refusal block red, note block green26 failed / 38 passed — all 26 in the refusal block
Bnote guard neutered (unsized <= 0 to unsized >= 0, 1 site)note block red, refusal block green26 failed / 38 passed — all 26 in the note block

The two red sets are disjoint, and each leg left the other 38 green — which is what makes them discriminating rather than a blanket break. Blob hashes: 433eb895... mutated to 328319dd... (A) and 5d9ad41a... (B); both restored to 433eb895... with git diff HEAD empty.

Gates

All run on the committed head dd79defc7, after the final commit.

gateverdict, as the gate printed it
vitest packages/plugin-charts/Test Files 39 passed (39) / Tests 329 passed (329)
type-checkexit 0, tsc --noEmit, script name echoed
lint287 problems (0 errors, 287 warnings) — exit 0, all warnings pre-existing
check-changeset-presence2 source file(s) of 1 released package(s) changed, and this change declares 1 changeset(s)
check-changeset-no-majorNo changeset declares a major bump.
check-control-bytesOK (scanned 5942 tracked text file(s); skipped 85 binary).
check-vi-mock-specifiersOK (4099 tracked source file(s), 2371 test-named; 525 carry a mock; ...)
check-vi-mock-inheritOK (4099 tracked source file(s), ... 118 inherit, 0 auto-mocked ...)
check-lint-coveragelint coverage: 46/46 packages linted, 0 with outstanding errors (0 total).
check-type-check-coveragetype-check coverage: 45/46 via type-check, ... 1 not compiled.

The three tracked-file gates were re-run aftergit add; their own counts moved (5940 to 5942 text files, 524 to 525 files carrying a mock), which is how I know they actually saw the new test rather than skipping it as untracked.

type-check was NOT MEASURED on first attempt — 16 Cannot find module '@object-ui/*' errors because the dependency closure was unbuilt. Built with pnpm --workspace-concurrency=2 --filter '@object-ui/plugin-charts^...' build and re-run to a real verdict. tsc --listFiles confirms both edited files are in the compiled set (1 hit each), so the green covers them.

Declared narrowing: repo-wide pnpm lint was not run locally; CI owns it. The per-package run is a measurement, not a guess — eslint's own config selected 52 files in this package (--format json count), both edited files are in that set with 0 errors, and this package's eslint config enables no type-aware linting, so nothing in this diff can move a verdict in a file it does not touch.

Out of scope, reported not fixed

  • scatter is NOT MEASURED, not clean. Its all-positive control drew zero marks on this instrument (markN: 0 on every scatter tile including the control), so its zeros say nothing — a control that returns zero is no control. Scatter takes two measures and this sweep's single-measure fixture does not bind it. Worth a proper sweep with a correct fixture; I did not file a card, per the report-do-not-duplicate fence.
  • radar measured and clean for this defect class: grid and axes survive an all-zero dataset and its hashes are distinct from both its all-positive and its one-row control.

Generated by Claude Code

…gnitude they can draw
These four families (pie, donut, funnel, treemap) size a mark BY its measure,
so a row whose value is zero, negative, null or unparseable stays in `data` and
is simply given no area. That is a third mechanism, distinct from the early
return objectui#7146 answered and the silent row drop objectui#7148 answered:
those rows are never filtered, so objectui#7148's dropped-row count is exactly
zero here and hoisting its footnote would have rendered nothing while looking
like coverage.
Measured in real Chromium across 74 tiles at 40c4711, each screenshotted,
MD5'd and pixel-diffed against an empty div of the same box:
- all-zero, all-null and all-negative pies put ZERO non-white pixels out of
124,800 on the page, byte-identical to the empty div, with 31 descendants
and a real svg in the DOM
- a pie handed 40 beside a null drew a FULL circle, 99.35% pixel-identical to
a legitimately one-row dataset
- a funnel handed 40 beside a null drew ZERO segments and one label reading
the name of the row that had NO value
- a treemap handed 40/null, 40/0 or 40/-25/-12 drew one full-bleed leaf,
byte-identical to a genuinely one-row treemap in all three cases
- an all-zero treemap drew one full-bleed leaf labelled with the LAST category
No row sizable now renders the file's refusal shell under its own code
(no-positive-magnitude); some rows sizable keeps the plot and adds a note
counting the rest (data-chart-note="unsized-rows"). All-positive charts gain no
wrapper element, the no-rows case is untouched (that is the empty-result
question, answered upstream in ObjectChart), bar keeps its axes, and both
sankey answers keep their own codes -- every sankey and bar tile hashed
identically before and after.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012wwHa4aaFybxXrfmfHioDM
@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

MetricValueBudget
Eager closure (gzip, 48 chunks)3154.9 KB3191.4 KB
Main entry chunk (gzip)142.6 KB350 KB
Entry fileindex-D4k7_yQL.js
StatusPASS

The eager closure is every chunk the entry reaches through static imports — what the browser fetches and parses before the app renders. The entry chunk on its own is a small fraction of it.


📦 Bundle Size Report

PackageSizeGzipped
app-shell (consoleActionDispatch.js)0.20KB0.19KB
app-shell (index.js)15.33KB5.59KB
app-shell (runtime-config.js)20.68KB7.36KB
app-shell (types.js)0.01KB0.04KB
app-shell (urlParams.js)10.06KB3.86KB
auth (ActiveOrganizationStorage.js)25.05KB9.16KB
auth (AuthContext.js)0.31KB0.24KB
auth (AuthGuard.js)2.07KB1.00KB
auth (AuthProvider.js)40.18KB10.59KB
auth (AuthShell.js)3.49KB1.40KB
auth (ForgotPasswordForm.js)12.21KB3.45KB
auth (LoginForm.js)18.15KB5.39KB
auth (PreviewBanner.js)0.90KB0.50KB
auth (RegisterForm.js)6.65KB2.22KB
auth (SocialSignInButtons.js)9.61KB3.89KB
auth (UserMenu.js)3.41KB1.23KB
auth (auth-gate-events.js)1.29KB0.66KB
auth (authStyles.js)5.04KB1.72KB
auth (createAuthClient.js)40.21KB10.80KB
auth (createAuthenticatedFetch.js)8.46KB3.43KB
auth (index.js)3.19KB1.44KB
auth (invitation-status.js)1.22KB0.70KB
auth (org-roles.js)6.66KB2.78KB
auth (phone-identifier.js)1.11KB0.66KB
auth (types.js)0.59KB0.35KB
auth (useAuth.js)5.30KB1.02KB
auth (useWorkspaceAdminStatus.js)5.13KB2.35KB
collaboration (CommentThread.js)26.08KB7.56KB
collaboration (LiveCursors.js)3.17KB1.27KB
collaboration (PresenceAvatars.js)6.49KB2.64KB
collaboration (PresenceProvider.js)2.79KB1.13KB
collaboration (index.js)1.68KB0.73KB
collaboration (useCollaborationTranslation.js)6.05KB2.52KB
collaboration (useCommentSearch.js)1.98KB0.88KB
collaboration (useConflictResolution.js)7.75KB1.86KB
collaboration (useMentionNotifications.js)1.81KB0.68KB
collaboration (usePresence.js)6.33KB1.84KB
collaboration (useRealtimeSubscription.js)7.91KB2.01KB
components (index.js)512.32KB116.52KB
core (index.js)5.30KB2.13KB
create-plugin (index.js)10.08KB3.26KB
data-objectstack (index.js)178.14KB49.57KB
fields (index.js)244.25KB61.73KB
i18n (LocalizationContext.js)1.76KB0.96KB
i18n (currency.js)1.22KB0.64KB
i18n (fallbackInterpolation.js)6.25KB2.77KB
i18n (i18n.js)4.28KB1.75KB
i18n (index.js)3.44KB1.39KB
i18n (pickLocalized.js)7.62KB3.26KB
i18n (provider.js)26.89KB9.04KB
i18n (useDisplayLocale.js)2.85KB1.45KB
i18n (useObjectLabel.js)33.40KB8.71KB
i18n (useSafeTranslation.js)5.60KB2.33KB
layout (index.js)38.98KB10.98KB
mobile (MobileProvider.js)0.92KB0.49KB
mobile (ResponsiveContainer.js)0.94KB0.38KB
mobile (breakpoints.js)1.51KB0.70KB
mobile (createOfflineDataSource.js)5.61KB1.75KB
mobile (index.js)1.55KB0.62KB
mobile (offlineQueue.js)3.91KB1.35KB
mobile (pwa.js)0.97KB0.49KB
mobile (serviceWorker.js)1.48KB0.62KB
mobile (serviceWorkerSource.js)3.41KB1.48KB
mobile (useBreakpoint.js)1.54KB0.65KB
mobile (useGesture.js)6.96KB1.98KB
mobile (useOfflineSync.js)1.99KB0.72KB
mobile (usePullToRefresh.js)2.53KB0.85KB
mobile (useResponsive.js)0.72KB0.42KB
mobile (useResponsiveConfig.js)1.37KB0.63KB
mobile (useSpecGesture.js)4.32KB1.64KB
mobile (useTouchTarget.js)1.01KB0.54KB
permissions (MePermissionsProvider.js)11.71KB4.29KB
permissions (PermissionContext.js)0.31KB0.25KB
permissions (PermissionGuard.js)0.89KB0.45KB
permissions (PermissionProvider.js)6.24KB2.16KB
permissions (discardProofCache.js)1.04KB0.55KB
permissions (evaluator.js)5.12KB1.74KB
permissions (index.js)0.93KB0.41KB
permissions (store.js)0.91KB0.42KB
permissions (useFieldPermissions.js)1.28KB0.53KB
permissions (usePermissions.js)4.83KB2.27KB
plugin-ai (index.js)15.75KB3.80KB
plugin-calendar (index.js)46.92KB12.93KB
plugin-charts (index.js)68.45KB19.20KB
plugin-chatbot (index.js)190.53KB45.18KB
plugin-dashboard (index.js)132.63KB34.56KB
plugin-designer (index.js)212.87KB43.19KB
plugin-detail (index.js)249.91KB63.82KB
plugin-editor (index.js)2.46KB1.10KB
plugin-form (index.js)133.11KB32.61KB
plugin-gantt (index.js)165.21KB40.37KB
plugin-grid (index.js)205.53KB55.50KB
plugin-kanban (index.js)53.21KB14.66KB
plugin-list (index.js)113.21KB27.60KB
plugin-map (index.js)20.20KB6.66KB
plugin-markdown (index.js)13.72KB4.69KB
plugin-report (index.js)43.51KB11.94KB
plugin-timeline (index.js)29.34KB8.47KB
plugin-tree (index.js)8.98KB3.08KB
plugin-view (index.js)85.90KB21.12KB
providers (DataSourceProvider.js)0.75KB0.39KB
providers (MetadataProvider.js)1.37KB0.59KB
providers (ThemeProvider.js)1.90KB0.85KB
providers (UploadProvider.js)11.66KB3.50KB
providers (index.js)0.45KB0.23KB
providers (types.js)0.01KB0.04KB
react-runtime (index.js)5.62KB2.34KB
react (LazyPluginLoader.js)4.47KB1.63KB
react (SchemaRenderer.js)81.07KB26.86KB
react (data-invalidation.js)5.05KB2.08KB
react (index.js)3.11KB1.48KB
react (schema-input.js)2.32KB1.24KB
react (spec-input.js)0.20KB0.18KB
sdui-parser (codegen.js)5.41KB2.34KB
sdui-parser (dashboard-widget-options.js)3.08KB1.30KB
sdui-parser (index.js)4.93KB2.24KB
sdui-parser (input-type.js)2.84KB1.40KB
sdui-parser (parse.js)20.57KB5.88KB
sdui-parser (provenance.js)3.66KB1.82KB
sdui-parser (types.js)0.28KB0.23KB
sdui-parser (validate.js)10.35KB3.60KB
types (ai.js)0.20KB0.17KB
types (api-types.js)0.20KB0.18KB
types (app.js)2.87KB0.99KB
types (base.js)0.20KB0.18KB
types (blocks.js)0.20KB0.18KB
types (complex.js)2.74KB1.41KB
types (crud.js)0.20KB0.18KB
types (dashboard-filter-alias.js)6.23KB2.74KB
types (data-display.js)3.75KB1.85KB
types (data-protocol.js)0.20KB0.19KB
types (data.js)0.20KB0.18KB
types (designer.js)1.85KB0.85KB
types (disclosure.js)0.20KB0.18KB
types (error-code.js)1.54KB0.88KB
types (feedback.js)0.20KB0.18KB
types (field-types.js)0.20KB0.18KB
types (form.js)0.20KB0.18KB
types (http-inflight.js)8.87KB3.73KB
types (http-retry.js)4.32KB2.02KB
types (icon-key-migration.js)4.26KB1.63KB
types (index.js)4.72KB2.24KB
types (layout.js)0.20KB0.18KB
types (managed-by.js)0.19KB0.18KB
types (mobile.js)2.59KB1.31KB
types (navigation.js)0.20KB0.18KB
types (objectql.js)0.20KB0.18KB
types (overlay.js)0.20KB0.18KB
types (permissions.js)0.20KB0.18KB
types (plugin-scope.js)0.20KB0.18KB
types (record-components.js)0.20KB0.19KB
types (record-semantics.js)1.28KB0.67KB
types (registry.js)0.20KB0.18KB
types (reports.js)0.20KB0.18KB
types (spec-report.js)5.05KB1.93KB
types (spec-ui-namespace.js)0.20KB0.19KB
types (system-fields.js)3.33KB1.54KB
types (theme.js)6.28KB2.87KB
types (ui-action.js)3.40KB1.71KB
types (views.js)0.20KB0.18KB
types (widget.js)0.20KB0.18KB

Size Limits

  • ✅ Core packages should be < 50KB gzipped
  • ✅ Component packages should be < 100KB gzipped
  • ⚠️ Plugin packages should be < 150KB gzipped

@os-warrenClaude

Copy link
Copy Markdown
CollaboratorAuthor

✅ Reviewed — will arm on green

⚠️ First: my claim on objectui#7147 was incomplete, and you were right to flag it

You reported: "the card carried the assignee and four PM comments but NO claim comment with a session ID, so I could not confirm the claim from the card itself."

Correct, and that is my error. This repo's rule is explicit — a claim is assign plus a claim comment carrying the session ID and branch, because all agents share one GitHub identity and the assignee field alone cannot tell you whose claim it is. I set the assignee and labels and never posted the comment. Every other card this session got one; this one did not.

Proceeding after confirming no competing claim was the right call. Noting it publicly because that gap is precisely how one issue gets implemented twice.

The measurement

74 tiles — 8 families × 9 datasets, plus a blank reference and a live console control — MD5'd and pixel-diffed against a literally empty div. What it found is worse than the card described:

caseresult
pie/donut, all-zero / all-null / all-negative0 non-white pixels of 124,800, while carrying 31 DOM descendants and a real svg
pie, 40 beside a nulldrew a FULL circle — 99.35% pixel-identical to a legitimately one-row dataset
funnel, 40 beside a null0 segments and ONE label — reading the name of the row that had NO value. The row worth 40 vanished and the empty one got the label
treemap, 40/null, 40/0, 40/-25/-12byte-identical (0.000%) to a genuinely one-row treemap — four datasets, one image

The funnel case is the sharpest thing here. It is not a blank and not a partial draw; it is a chart labelling the wrong row. A reader sees one named category and no indication that the category carrying all the value was dropped.

measured-and-declined was assessed per family — and survived for two

Bar and radar are clean and stay untouched. Radar's grid and axes survive an all-zero dataset with hashes distinct from both its all-positive and its one-row control. That is the outcome I most wanted to see possible: the fence said declining was legitimate, and it was taken where the evidence supported it rather than applied uniformly because a fix was available.

50 of 74 tiles unchanged, including every all-positive control, every one-row control, every no-rows tile, all 9 bar tiles and all 9 sankey tiles byte-identical.

The ablation design is the best of the session

Two legs with disjoint red sets. Neutering the refusal guard: 26 failed / 38 passed, all 26 in the refusal block. Neutering the note guard: 26 failed / 38 passed, all 26 in the note block. Each leg leaves the other's 38 green — which is what makes them discriminating rather than merely red.

And the third leg is the part I have not seen before: you reproduced the pre-fix renderer and verified the reproduction against the true pre-fix run — 56/56 tiles byte-identical. So the before/after table is measured against the real thing, not against a stand-in that merely resembles it. That closes the gap where a reconstruction quietly differs from what it claims to reconstruct.

Browser instrument rebuilt on each leg with the markers verified present in dist/assets/*.js afterwards — so the measurement ran against mutated code, not a stale bundle. And the three tracked-file gates were re-run after git add with their counts moving (5940→5942, 524→525), which is how you know they saw the new test rather than skipping it as untracked. That is a control on the gate itself.

Two things you reported rather than filed — both correct, both being carried

Scatter is NOT MEASURED, not clean. Every scatter tile including the all-positive control drew zero marks, so its zeros say nothing — a control that returns zero is no control. Scatter takes two measures and this sweep's single-measure fixture does not bind it. I am filing that as its own card; it has no existing home, unlike objectui#7148's finding which had objectui#7147 to attach to.

⚠️ The footnote-visibility property is the one that reaches back. A 520×240 tile is 40px shorter than CHART_MIN_HEIGHT (280), so no footnote can be seen in one — measured with objectui#7148's landed note as the live control, byte-identical before and after at 240. That means objectui#7148's shipped note is equally invisible at that size. You are right that this PR inherits the property without worsening it, and right to quote every before/after number at a box that fits the floor. I am recording it on objectui#7148.

Ledger

Refusal ships under its own code no-positive-magnitude rather than reusing objectui#7146's — correct, since the codes name which refusal fired. One shared predicate Number.isFinite(v) && v > 0 across three arms rather than three spellings. type-check first returned NOT MEASURED (16 unbuilt-closure errors), named as such and re-run to a real verdict after building the closure.


Generated by Claude Code

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

finding(plugin-charts): funnel and pie handed all-zero rows render a pixel-identical blank tile — same silence as #7140, a different trigger

2 participants

@os-warren@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content

fix(plugin-charts): pie, funnel and treemap say when rows carry no magnitude they can draw - #7169

Merged
os-warren merged 1 commit into
mainfrom
claude/issue-7147-degenerate-geometry-blank
Sep 1, 2026
Merged

fix(plugin-charts): pie, funnel and treemap say when rows carry no magnitude they can draw#7169
os-warren merged 1 commit into
mainfrom
claude/issue-7147-degenerate-geometry-blank

Conversation

@os-warren

Copy link
Copy Markdown
Collaborator

Fixes#7147

Re-derived on origin/main40c4711 (the head this branch forks from; it already carries PR #7146's no-positive-flow refusal and PR #7161's ChartFootnote). The card's snippets and line numbers were stale, as PM assumed.

Rendered it and looked first

74 tiles in real Chromium (/opt/pw-browsers/chromium) — 8 chart families x 9 datasets, plus a blank reference and a live console control. Every tile screenshotted, MD5'd, and pixel-diffed against a literally empty div of the same box. measured-and-declined was genuinely on the table for each family separately. It survived for radar and bar, and for none of pie / donut / funnel / treemap.

The instrument's discriminating power, from its own controls: a two-row pie differs from a one-row pie by 9.683% of its pixels, a two-row treemap from a one-row treemap by 38.301%, and an all-zero bar puts 5,128 ink pixels (axes and ticks) against a blank tile's 0.

Per-family verdict

familydatasetwhat a reader saw at 40c4711verdict
pie / donutall-zero, all-null, all-negative0 non-white pixels of 124,800 — byte-identical to an empty div, while the DOM carried 31 descendants and a real svgfix
pie / donut40 beside a nulla full circle in the first category's colour — 99.35% pixel-identical to a legitimately one-row dataset (the 0.654% residue is the paddingAngle hairline, not information). The picture asserts "Alpha is 100%" of a dataset where Beta was never measuredfix
funnel40 beside a null178 ink pixels: zero segments, one label — and the label reads "Beta", the row with no value. The row carrying 40 drew nothing at allfix
funnelall-negativea confident two-band funnel whose mark area (220,320) exceeds the all-positive control's (111,881)fix
treemap40 / null, 40 / 0, 40 / -25 / -12all three byte-identical (diff 0.000%) to a genuinely one-row treemap — one full-bleed leaf labelled "Alpha". Four datasets, one imagefix
treemapall-zeroone full-bleed leaf labelled with the last category — as the card's adjacent observation recordedfix
barall-zero5,128 ink pixels of axes, ticks and a zero-labelled scaledeclined — its reader can already tell
radarall-zerogrid and axes drawn, and hashes distinct from both its all-positive and its one-row controldeclined

The mechanism is the same across all three families, and it is neither landed answer

The rows are never filtered. data reaches the pie, funnel and treemap elements whole — PR #7161 pinned that the sankey arm holds the only row-dropping filter in this file, and that still reads true at this head (AdvancedChartImpl.tsx:1081, the other two .filter( sites filter series). What happens instead is that the layout gives a non-positive row no area.

So PR #7161's count (data.length - rows.length) is exactly zero against these families. A hoisted copy of that footnote would have rendered nothing while looking like coverage. Confirmed rather than assumed: the sankey tiles are the only ones in the sweep carrying omitted-rows.

A2.3 was falsified in one respect and it changed nothing. Pie and funnel are not the same failure at the Recharts level — an unsizable pie row produces no sector element at all (path.recharts-sector = 0), whereas an unsizable funnel row produces a NaN-width trapezoid that also poisons its neighbour (which is why 40 beside a null loses the row worth 40, not the null one). Treemap is a third: the leaf collapses and its neighbours expand to fill the box. Three different Recharts behaviours, one authoring-side predicate — a row can only occupy area if its measure is a positive finite number — so one answer serves all three.

What changed

Three helpers, stated once so the arms cannot drift, and wired into the pie/donut, funnel and treemap arms:

  • countSizableRows(rows, dataKey)Number.isFinite(v) && v > 0. Number.isFinite rather than the sankey arm's Number(x) || 0 idiom, because this predicate must also reject Infinity, which || 0 lets straight through.
  • No row sizable and at least one row present: the file's existing refusal shell under its own code, data-chart-error="no-positive-magnitude".
  • Some rows sizable: the plot renders unchanged, wrapped in the existing ChartFootnote with data-chart-note="unsized-rows", carrying the count.

Copy names the predicate, not a cause — the reason no-positive-flow's docstring already gives: five shapes reach here (a genuine zero, a negative, null, an unparseable string, a missing key) and naming any one is a sentence false for the other four.

The note deliberately does not say "showing N of M". PR #7161's sankey note can, because there the missing rows are genuinely absent from the plot. Here they are not: a mixed-sign pie paints a sector for every row (measured: 40 / -25 / -12 drew 3 sectors) — it just paints them at a scale that means nothing. "Showing 1 of 3" would be a false statement about what is on the screen.

No console warning, matching both sankey answers and unlike the two guards at the bottom of the file: those carry a diagnostic pair that does not fit on screen, whereas these sentences already name the key, the test it failed, and how many rows failed it.

i18n: none added. This file has no i18n call sites at all and both landed refusals plus PR #7161's footnote are hardcoded English; this matches the file.

Before / after, and the 50 tiles that did not move

Measured at a tile taller than the chart's own CHART_MIN_HEIGHT floor of 280 (see "assumption falsified" below). The pre-fix renderer was reproduced by double ablation and verified against the true pre-fix run: 56/56 tiles byte-identical, so the "before" column is the real thing rather than a stand-in.

24 tiles changed. 50 did not. The 50 are what make the 24 discriminating:

treemap--posNull and treemap--posZero still share an image after the fix, and that is correct: to a chart that sizes by value a null and a 0 are the same fact, and the copy names the predicate rather than the cause.

Seam map, pinned from both sides

datasetsankeypie / donut / funnel / treemap
no rows at alluntouched (#7130, upstream)untouched (#7130, upstream)
no row above zerono-positive-flow (#7146)no-positive-magnitude (this PR)
some rows above zeroomitted-rows (#7161)unsized-rows (this PR)
all rows above zerountoucheduntouched, no wrapper element added

A test asserts the four codes are mutually exclusive across every family x dataset pair in the sweep, and two more assert a sankey never receives this PR's code or attribute and vice versa.

Console diagnostic, with its control

Across all 72 chart tiles: zero console output. That zero is readable only because the same instrument's positive control did fire on the same run — a rows-carry-no-category-key tile printed [chart] no row has the category key "name" .... A2.4 confirmed on the widened population.

Assumptions falsified

  1. The card's "funnel all-zero is pixel-identical to a blank tile" does not reproduce at this head on this instrument. It draws its two category labels (412 ink pixels) and zero segments. The likely difference is that this probe compiles the real Tailwind theme, so hsl(var(--foreground)) resolves and the labels are visible. The finding stands regardless — 0 of 2 segments — but the specific pixel-identity claim is not what I measured, so I am not repeating it.
  2. A 520x240 tile — the box the card's own sweep used — is 40px shorter than the chart's own CHART_MIN_HEIGHT of 280, so no footnote can be seen in it. Measured with the landed footnote as the control: sankey--posNull is byte-identical before and after this change at 240 (2b123b92769b), i.e. PR fix(plugin-charts): a sankey that drew only some of its rows says how many #7161's shipped note is equally invisible there. This is a pre-existing property of sub-280 boxes that this PR inherits and does not worsen, not something introduced here. All before/after numbers above are therefore quoted at a box that fits the floor.
  3. A2.3 — see above; the mechanism differs per family at the Recharts level while the authoring-side predicate does not.

Tests

AdvancedChartImpl.degenerateMagnitude.test.tsx, 64 tests. Run from the repo root with root-relative paths.

pnpm exec vitest run packages/plugin-charts/src/AdvancedChartImpl.degenerateMagnitude.test.tsx
Test Files 1 passed (1)
Tests 64 passed (64)
pnpm exec vitest run packages/plugin-charts/
Test Files 39 passed (39)
Tests 329 passed (329)

Ablation, direction predicted before running

Two legs, each proving mutation on disk by marker count and blob hash and restore by state, both with an absolute-path trap:

legmutationpredictedobserved
Arefusal guard neutered (sizable === 0 to sizable === -1, 3 sites)refusal block red, note block green26 failed / 38 passed — all 26 in the refusal block
Bnote guard neutered (unsized <= 0 to unsized >= 0, 1 site)note block red, refusal block green26 failed / 38 passed — all 26 in the note block

The two red sets are disjoint, and each leg left the other 38 green — which is what makes them discriminating rather than a blanket break. Blob hashes: 433eb895... mutated to 328319dd... (A) and 5d9ad41a... (B); both restored to 433eb895... with git diff HEAD empty.

Gates

All run on the committed head dd79defc7, after the final commit.

gateverdict, as the gate printed it
vitest packages/plugin-charts/Test Files 39 passed (39) / Tests 329 passed (329)
type-checkexit 0, tsc --noEmit, script name echoed
lint287 problems (0 errors, 287 warnings) — exit 0, all warnings pre-existing
check-changeset-presence2 source file(s) of 1 released package(s) changed, and this change declares 1 changeset(s)
check-changeset-no-majorNo changeset declares a major bump.
check-control-bytesOK (scanned 5942 tracked text file(s); skipped 85 binary).
check-vi-mock-specifiersOK (4099 tracked source file(s), 2371 test-named; 525 carry a mock; ...)
check-vi-mock-inheritOK (4099 tracked source file(s), ... 118 inherit, 0 auto-mocked ...)
check-lint-coveragelint coverage: 46/46 packages linted, 0 with outstanding errors (0 total).
check-type-check-coveragetype-check coverage: 45/46 via type-check, ... 1 not compiled.

The three tracked-file gates were re-run aftergit add; their own counts moved (5940 to 5942 text files, 524 to 525 files carrying a mock), which is how I know they actually saw the new test rather than skipping it as untracked.

type-check was NOT MEASURED on first attempt — 16 Cannot find module '@object-ui/*' errors because the dependency closure was unbuilt. Built with pnpm --workspace-concurrency=2 --filter '@object-ui/plugin-charts^...' build and re-run to a real verdict. tsc --listFiles confirms both edited files are in the compiled set (1 hit each), so the green covers them.

Declared narrowing: repo-wide pnpm lint was not run locally; CI owns it. The per-package run is a measurement, not a guess — eslint's own config selected 52 files in this package (--format json count), both edited files are in that set with 0 errors, and this package's eslint config enables no type-aware linting, so nothing in this diff can move a verdict in a file it does not touch.

Out of scope, reported not fixed

  • scatter is NOT MEASURED, not clean. Its all-positive control drew zero marks on this instrument (markN: 0 on every scatter tile including the control), so its zeros say nothing — a control that returns zero is no control. Scatter takes two measures and this sweep's single-measure fixture does not bind it. Worth a proper sweep with a correct fixture; I did not file a card, per the report-do-not-duplicate fence.
  • radar measured and clean for this defect class: grid and axes survive an all-zero dataset and its hashes are distinct from both its all-positive and its one-row control.

Generated by Claude Code

…gnitude they can draw
These four families (pie, donut, funnel, treemap) size a mark BY its measure,
so a row whose value is zero, negative, null or unparseable stays in `data` and
is simply given no area. That is a third mechanism, distinct from the early
return objectui#7146 answered and the silent row drop objectui#7148 answered:
those rows are never filtered, so objectui#7148's dropped-row count is exactly
zero here and hoisting its footnote would have rendered nothing while looking
like coverage.
Measured in real Chromium across 74 tiles at 40c4711, each screenshotted,
MD5'd and pixel-diffed against an empty div of the same box:
- all-zero, all-null and all-negative pies put ZERO non-white pixels out of
124,800 on the page, byte-identical to the empty div, with 31 descendants
and a real svg in the DOM
- a pie handed 40 beside a null drew a FULL circle, 99.35% pixel-identical to
a legitimately one-row dataset
- a funnel handed 40 beside a null drew ZERO segments and one label reading
the name of the row that had NO value
- a treemap handed 40/null, 40/0 or 40/-25/-12 drew one full-bleed leaf,
byte-identical to a genuinely one-row treemap in all three cases
- an all-zero treemap drew one full-bleed leaf labelled with the LAST category
No row sizable now renders the file's refusal shell under its own code
(no-positive-magnitude); some rows sizable keeps the plot and adds a note
counting the rest (data-chart-note="unsized-rows"). All-positive charts gain no
wrapper element, the no-rows case is untouched (that is the empty-result
question, answered upstream in ObjectChart), bar keeps its axes, and both
sankey answers keep their own codes -- every sankey and bar tile hashed
identically before and after.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012wwHa4aaFybxXrfmfHioDM
@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

MetricValueBudget
Eager closure (gzip, 48 chunks)3154.9 KB3191.4 KB
Main entry chunk (gzip)142.6 KB350 KB
Entry fileindex-D4k7_yQL.js
StatusPASS

The eager closure is every chunk the entry reaches through static imports — what the browser fetches and parses before the app renders. The entry chunk on its own is a small fraction of it.


📦 Bundle Size Report

PackageSizeGzipped
app-shell (consoleActionDispatch.js)0.20KB0.19KB
app-shell (index.js)15.33KB5.59KB
app-shell (runtime-config.js)20.68KB7.36KB
app-shell (types.js)0.01KB0.04KB
app-shell (urlParams.js)10.06KB3.86KB
auth (ActiveOrganizationStorage.js)25.05KB9.16KB
auth (AuthContext.js)0.31KB0.24KB
auth (AuthGuard.js)2.07KB1.00KB
auth (AuthProvider.js)40.18KB10.59KB
auth (AuthShell.js)3.49KB1.40KB
auth (ForgotPasswordForm.js)12.21KB3.45KB
auth (LoginForm.js)18.15KB5.39KB
auth (PreviewBanner.js)0.90KB0.50KB
auth (RegisterForm.js)6.65KB2.22KB
auth (SocialSignInButtons.js)9.61KB3.89KB
auth (UserMenu.js)3.41KB1.23KB
auth (auth-gate-events.js)1.29KB0.66KB
auth (authStyles.js)5.04KB1.72KB
auth (createAuthClient.js)40.21KB10.80KB
auth (createAuthenticatedFetch.js)8.46KB3.43KB
auth (index.js)3.19KB1.44KB
auth (invitation-status.js)1.22KB0.70KB
auth (org-roles.js)6.66KB2.78KB
auth (phone-identifier.js)1.11KB0.66KB
auth (types.js)0.59KB0.35KB
auth (useAuth.js)5.30KB1.02KB
auth (useWorkspaceAdminStatus.js)5.13KB2.35KB
collaboration (CommentThread.js)26.08KB7.56KB
collaboration (LiveCursors.js)3.17KB1.27KB
collaboration (PresenceAvatars.js)6.49KB2.64KB
collaboration (PresenceProvider.js)2.79KB1.13KB
collaboration (index.js)1.68KB0.73KB
collaboration (useCollaborationTranslation.js)6.05KB2.52KB
collaboration (useCommentSearch.js)1.98KB0.88KB
collaboration (useConflictResolution.js)7.75KB1.86KB
collaboration (useMentionNotifications.js)1.81KB0.68KB
collaboration (usePresence.js)6.33KB1.84KB
collaboration (useRealtimeSubscription.js)7.91KB2.01KB
components (index.js)512.32KB116.52KB
core (index.js)5.30KB2.13KB
create-plugin (index.js)10.08KB3.26KB
data-objectstack (index.js)178.14KB49.57KB
fields (index.js)244.25KB61.73KB
i18n (LocalizationContext.js)1.76KB0.96KB
i18n (currency.js)1.22KB0.64KB
i18n (fallbackInterpolation.js)6.25KB2.77KB
i18n (i18n.js)4.28KB1.75KB
i18n (index.js)3.44KB1.39KB
i18n (pickLocalized.js)7.62KB3.26KB
i18n (provider.js)26.89KB9.04KB
i18n (useDisplayLocale.js)2.85KB1.45KB
i18n (useObjectLabel.js)33.40KB8.71KB
i18n (useSafeTranslation.js)5.60KB2.33KB
layout (index.js)38.98KB10.98KB
mobile (MobileProvider.js)0.92KB0.49KB
mobile (ResponsiveContainer.js)0.94KB0.38KB
mobile (breakpoints.js)1.51KB0.70KB
mobile (createOfflineDataSource.js)5.61KB1.75KB
mobile (index.js)1.55KB0.62KB
mobile (offlineQueue.js)3.91KB1.35KB
mobile (pwa.js)0.97KB0.49KB
mobile (serviceWorker.js)1.48KB0.62KB
mobile (serviceWorkerSource.js)3.41KB1.48KB
mobile (useBreakpoint.js)1.54KB0.65KB
mobile (useGesture.js)6.96KB1.98KB
mobile (useOfflineSync.js)1.99KB0.72KB
mobile (usePullToRefresh.js)2.53KB0.85KB
mobile (useResponsive.js)0.72KB0.42KB
mobile (useResponsiveConfig.js)1.37KB0.63KB
mobile (useSpecGesture.js)4.32KB1.64KB
mobile (useTouchTarget.js)1.01KB0.54KB
permissions (MePermissionsProvider.js)11.71KB4.29KB
permissions (PermissionContext.js)0.31KB0.25KB
permissions (PermissionGuard.js)0.89KB0.45KB
permissions (PermissionProvider.js)6.24KB2.16KB
permissions (discardProofCache.js)1.04KB0.55KB
permissions (evaluator.js)5.12KB1.74KB
permissions (index.js)0.93KB0.41KB
permissions (store.js)0.91KB0.42KB
permissions (useFieldPermissions.js)1.28KB0.53KB
permissions (usePermissions.js)4.83KB2.27KB
plugin-ai (index.js)15.75KB3.80KB
plugin-calendar (index.js)46.92KB12.93KB
plugin-charts (index.js)68.45KB19.20KB
plugin-chatbot (index.js)190.53KB45.18KB
plugin-dashboard (index.js)132.63KB34.56KB
plugin-designer (index.js)212.87KB43.19KB
plugin-detail (index.js)249.91KB63.82KB
plugin-editor (index.js)2.46KB1.10KB
plugin-form (index.js)133.11KB32.61KB
plugin-gantt (index.js)165.21KB40.37KB
plugin-grid (index.js)205.53KB55.50KB
plugin-kanban (index.js)53.21KB14.66KB
plugin-list (index.js)113.21KB27.60KB
plugin-map (index.js)20.20KB6.66KB
plugin-markdown (index.js)13.72KB4.69KB
plugin-report (index.js)43.51KB11.94KB
plugin-timeline (index.js)29.34KB8.47KB
plugin-tree (index.js)8.98KB3.08KB
plugin-view (index.js)85.90KB21.12KB
providers (DataSourceProvider.js)0.75KB0.39KB
providers (MetadataProvider.js)1.37KB0.59KB
providers (ThemeProvider.js)1.90KB0.85KB
providers (UploadProvider.js)11.66KB3.50KB
providers (index.js)0.45KB0.23KB
providers (types.js)0.01KB0.04KB
react-runtime (index.js)5.62KB2.34KB
react (LazyPluginLoader.js)4.47KB1.63KB
react (SchemaRenderer.js)81.07KB26.86KB
react (data-invalidation.js)5.05KB2.08KB
react (index.js)3.11KB1.48KB
react (schema-input.js)2.32KB1.24KB
react (spec-input.js)0.20KB0.18KB
sdui-parser (codegen.js)5.41KB2.34KB
sdui-parser (dashboard-widget-options.js)3.08KB1.30KB
sdui-parser (index.js)4.93KB2.24KB
sdui-parser (input-type.js)2.84KB1.40KB
sdui-parser (parse.js)20.57KB5.88KB
sdui-parser (provenance.js)3.66KB1.82KB
sdui-parser (types.js)0.28KB0.23KB
sdui-parser (validate.js)10.35KB3.60KB
types (ai.js)0.20KB0.17KB
types (api-types.js)0.20KB0.18KB
types (app.js)2.87KB0.99KB
types (base.js)0.20KB0.18KB
types (blocks.js)0.20KB0.18KB
types (complex.js)2.74KB1.41KB
types (crud.js)0.20KB0.18KB
types (dashboard-filter-alias.js)6.23KB2.74KB
types (data-display.js)3.75KB1.85KB
types (data-protocol.js)0.20KB0.19KB
types (data.js)0.20KB0.18KB
types (designer.js)1.85KB0.85KB
types (disclosure.js)0.20KB0.18KB
types (error-code.js)1.54KB0.88KB
types (feedback.js)0.20KB0.18KB
types (field-types.js)0.20KB0.18KB
types (form.js)0.20KB0.18KB
types (http-inflight.js)8.87KB3.73KB
types (http-retry.js)4.32KB2.02KB
types (icon-key-migration.js)4.26KB1.63KB
types (index.js)4.72KB2.24KB
types (layout.js)0.20KB0.18KB
types (managed-by.js)0.19KB0.18KB
types (mobile.js)2.59KB1.31KB
types (navigation.js)0.20KB0.18KB
types (objectql.js)0.20KB0.18KB
types (overlay.js)0.20KB0.18KB
types (permissions.js)0.20KB0.18KB
types (plugin-scope.js)0.20KB0.18KB
types (record-components.js)0.20KB0.19KB
types (record-semantics.js)1.28KB0.67KB
types (registry.js)0.20KB0.18KB
types (reports.js)0.20KB0.18KB
types (spec-report.js)5.05KB1.93KB
types (spec-ui-namespace.js)0.20KB0.19KB
types (system-fields.js)3.33KB1.54KB
types (theme.js)6.28KB2.87KB
types (ui-action.js)3.40KB1.71KB
types (views.js)0.20KB0.18KB
types (widget.js)0.20KB0.18KB

Size Limits

  • ✅ Core packages should be < 50KB gzipped
  • ✅ Component packages should be < 100KB gzipped
  • ⚠️ Plugin packages should be < 150KB gzipped

@os-warrenClaude

Copy link
Copy Markdown
CollaboratorAuthor

✅ Reviewed — will arm on green

⚠️ First: my claim on objectui#7147 was incomplete, and you were right to flag it

You reported: "the card carried the assignee and four PM comments but NO claim comment with a session ID, so I could not confirm the claim from the card itself."

Correct, and that is my error. This repo's rule is explicit — a claim is assign plus a claim comment carrying the session ID and branch, because all agents share one GitHub identity and the assignee field alone cannot tell you whose claim it is. I set the assignee and labels and never posted the comment. Every other card this session got one; this one did not.

Proceeding after confirming no competing claim was the right call. Noting it publicly because that gap is precisely how one issue gets implemented twice.

The measurement

74 tiles — 8 families × 9 datasets, plus a blank reference and a live console control — MD5'd and pixel-diffed against a literally empty div. What it found is worse than the card described:

caseresult
pie/donut, all-zero / all-null / all-negative0 non-white pixels of 124,800, while carrying 31 DOM descendants and a real svg
pie, 40 beside a nulldrew a FULL circle — 99.35% pixel-identical to a legitimately one-row dataset
funnel, 40 beside a null0 segments and ONE label — reading the name of the row that had NO value. The row worth 40 vanished and the empty one got the label
treemap, 40/null, 40/0, 40/-25/-12byte-identical (0.000%) to a genuinely one-row treemap — four datasets, one image

The funnel case is the sharpest thing here. It is not a blank and not a partial draw; it is a chart labelling the wrong row. A reader sees one named category and no indication that the category carrying all the value was dropped.

measured-and-declined was assessed per family — and survived for two

Bar and radar are clean and stay untouched. Radar's grid and axes survive an all-zero dataset with hashes distinct from both its all-positive and its one-row control. That is the outcome I most wanted to see possible: the fence said declining was legitimate, and it was taken where the evidence supported it rather than applied uniformly because a fix was available.

50 of 74 tiles unchanged, including every all-positive control, every one-row control, every no-rows tile, all 9 bar tiles and all 9 sankey tiles byte-identical.

The ablation design is the best of the session

Two legs with disjoint red sets. Neutering the refusal guard: 26 failed / 38 passed, all 26 in the refusal block. Neutering the note guard: 26 failed / 38 passed, all 26 in the note block. Each leg leaves the other's 38 green — which is what makes them discriminating rather than merely red.

And the third leg is the part I have not seen before: you reproduced the pre-fix renderer and verified the reproduction against the true pre-fix run — 56/56 tiles byte-identical. So the before/after table is measured against the real thing, not against a stand-in that merely resembles it. That closes the gap where a reconstruction quietly differs from what it claims to reconstruct.

Browser instrument rebuilt on each leg with the markers verified present in dist/assets/*.js afterwards — so the measurement ran against mutated code, not a stale bundle. And the three tracked-file gates were re-run after git add with their counts moving (5940→5942, 524→525), which is how you know they saw the new test rather than skipping it as untracked. That is a control on the gate itself.

Two things you reported rather than filed — both correct, both being carried

Scatter is NOT MEASURED, not clean. Every scatter tile including the all-positive control drew zero marks, so its zeros say nothing — a control that returns zero is no control. Scatter takes two measures and this sweep's single-measure fixture does not bind it. I am filing that as its own card; it has no existing home, unlike objectui#7148's finding which had objectui#7147 to attach to.

⚠️ The footnote-visibility property is the one that reaches back. A 520×240 tile is 40px shorter than CHART_MIN_HEIGHT (280), so no footnote can be seen in one — measured with objectui#7148's landed note as the live control, byte-identical before and after at 240. That means objectui#7148's shipped note is equally invisible at that size. You are right that this PR inherits the property without worsening it, and right to quote every before/after number at a box that fits the floor. I am recording it on objectui#7148.

Ledger

Refusal ships under its own code no-positive-magnitude rather than reusing objectui#7146's — correct, since the codes name which refusal fired. One shared predicate Number.isFinite(v) && v > 0 across three arms rather than three spellings. type-check first returned NOT MEASURED (16 unbuilt-closure errors), named as such and re-run to a real verdict after building the closure.


Generated by Claude Code

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

finding(plugin-charts): funnel and pie handed all-zero rows render a pixel-identical blank tile — same silence as #7140, a different trigger

2 participants

@os-warren@claude