fix(plugin-charts): scatter says when it cannot place a row, instead of drawing an empty axis - #7197

Merged
os-warren merged 1 commit into
mainfrom
claude/issue-7171-scatter-degenerate-sweep
Sep 1, 2026
Merged

fix(plugin-charts): scatter says when it cannot place a row, instead of drawing an empty axis#7197
os-warren merged 1 commit into
mainfrom
claude/issue-7171-scatter-degenerate-sweep

Conversation

@os-warren

Copy link
Copy Markdown
Collaborator

Fixes#7171

Re-derived on origin/main899730e, which already carries PR #7169's no-positive-magnitude, PR #7146's no-positive-flow and PR #7161's ChartFootnote. Gates and numbers below are quoted at the committed head 9953dd5.

The one line that makes the rest readable: THE CONTROL DREW

#7147's sweep reported markN: 0 on every scatter tile including its all-positive control, so scatter came out NOT MEASURED — a control that returns zero is no control, and none of those zeros carried information.

A two-measure fixture was built and its control drew 3 of 3 marks (245px of mark area), on a run whose live console control also fired. Everything below is therefore a reading, not a zero.

The cause is confirmed exactly as PM assumed (A2.1): scatter's XAxis and YAxis are BOTH type="number", bound to xAxisKey and series[0].dataKey. #7147's fixture put a category column (stage) on the numeric X axis. Reproduced here as its own tile: scatter--fixture7147, 0 marks, and byte-identical (51957063d9c2) to five other broken shapes.

Rendered it and looked first

33 tiles in real Chromium (/opt/pw-browsers/chromium), one page load per tile so console output is attributable to exactly one of them. Each tile screenshotted, MD5'd, decoded and pixel-diffed against a literally empty div of the same box — 520x360, above the chart's own CHART_MIN_HEIGHT floor of 280, because PR #7169 measured that no footnote is visible at all below it.

measured-and-declined was genuinely on the table and it SURVIVED for three of the classes swept. What it did not survive for:

datasetwhat a reader saw at 899730everdict
both coordinates null, or both keys absentbyte-identical (diff 0.000%) to a scatter handed NO ROWS AT ALL. The empty-result picture, for a query that returned rowsfix
null x · absent x · 'n/a' x · 'Infinity' x · boolean x · #7147's category columnSIX datasets, ONE image (51957063d9c2): a confident y scale, an x axis with no scale at all, and no marks. The y-side twin is a second four-way collision (5a67d6ab8800)fix
one placeable row among three99.75% pixel-identical to a genuinely one-row scatter (diff 0.250%); the y-side twin 0.293%. Exactly the collision that decided pie in #7147 — two rows vanish and the picture says "one point"fix
all-negative · all-zero · mixed-sign coordinatesdrew 3 / 2 / 3 marks, three distinct images, full scalesdeclined — correct charts
zero variance (3 rows at one coordinate)3 symbols, 0.017% from a one-row scatterdeclined — see below
constant x, constant yevery mark drawn, full scales on both axesdeclined

The predicate is DERIVED for a positional chart, and every clause was forced by a measurement

no-positive-magnitude's Number.isFinite(v) && v > 0 does not transfer. Pie, funnel and treemap size a mark BY its measure; scatter plots POSITION, and a chart of temperatures or profit deltas is supposed to show zeros and negatives. Measured rather than argued: that predicate would have blanked the all-negative (3 of 3 marks), all-zero (2 of 2) and mixed-sign (3 of 3) tiles above — three correct charts.

What survived is isPlottableCoord, and Number() alone gets three of its cases wrong in both directions:

valueNumber(v) saysRecharts actually doespredicate
null0, finite — plottable0 of 2 marks, x axis renders no scalereject
''0, finite — plottable2 of 2 marks, x ticks 0,1,2,3,4accept — rejecting it would blank a chart that draws
'10'10, finite2 of 2 marksaccept
'n/a' / 'Infinity' / absentNaN / Infinity0 of 2 marksreject

The '' row is the trap: it LOOKS like a null and reads like one. A predicate copied from the magnitude families, or written from Number() alone, gets it backwards.

The clause that was pinned, went red, and was removed

A boolean coordinate was rejected in the first draft — the browser sweep had it drawing 0 of 2 marks, byte-identical to the null tile. Pinning that turned the mixed case red, and the mixed case is the real reading: a boolean beside a genuinely numeric row draws every mark (3 of 3). Recharts needs one real number to build the scale and then coerces the booleans onto it.

So rejecting booleans would put a footnote reading "2 of 3 rows ... are not drawn" over three points visibly on screen — a false statement about the picture, which is worse than the silence this card is about. The predicate accepts booleans, the all-boolean tile stays exactly as silent as it is today (51957063d9c2, unchanged), and that residual hole is filed as #7195 with the reasoning, not hidden.

What changed

Three helpers and one wiring, all scatter-only:

  • isPlottableCoord(v) / countPlottablePoints(rows, xKey, yKey) — a point exists only if both coordinates are numbers.
  • No point placeable with rows present: the file's existing refusal shell under its own code, data-chart-error="no-plottable-points", naming both keys because a point needs both.
  • Some points placeable: the plot renders unchanged inside the existing ChartFootnote, data-chart-note="unplotted-points", carrying the count.

The y key is bound once (const scatterYKey = series[0]?.dataKey || 'value') and handed to both the predicate and the YAxis, so the two cannot drift.

Copy names the predicate, not a cause — five shapes reach it and naming any one is false for the other four. Gated on total > 0, so every no-rows tile is untouched: that is the empty-RESULT question (#7130), answered upstream in ObjectChart. No console warning, matching all three landed answers. No i18n, matching the file.

The one place this note differs from #7169's

unsized-rows deliberately does not say the rows are undrawn, because a mixed-sign pie paints a sector for every row. Scatter is the opposite case and it was measured: a 3-row dataset with one placeable pair emitted exactly onepath.recharts-symbols. The rows really are absent from the picture, so saying so is true — and the count is the half a reader cannot recover from it.

Before / after: 14 tiles moved, 19 are byte-identical

The 19 are what make the 14 discriminating.

The two collisions are broken: both-coordinates-degenerate versus no-rows went 0.000% to 2.651%, and one-placeable-row versus one-row went 0.250% to 6.368%.

Seam map, pinned from both sides

datasetsankeypie / donut / funnel / treemapscatter
no rows at alluntouched (#7130)untouched (#7130)untouched (#7130)
nothing drawableno-positive-flow (#7146)no-positive-magnitude (#7169)no-plottable-points (this PR)
some drawableomitted-rows (#7161)unsized-rows (#7169)unplotted-points (this PR)
all drawableuntoucheduntoucheduntouched, no wrapper added

#7169's mutual-exclusivity test was extended, not duplicated: scatter and both new codes join that same assertion, over six codes and a scatter-specific dataset pair, so a sixth answer cannot be added later without it having an opinion.

Assumptions falsified

  1. A2.3's headline — "the interesting failures are probably about axis domain collapse" — did not reproduce as collapse. Zero variance, constant x and constant y all draw every mark with normally padded domains (zero-variance's x ticks are 0,3,6,9,12, identical to a one-row scatter's). What the sweep found instead is an axis that renders no scale whatsoever when nothing on it is placeable, beside a confident scale on the other axis — a different failure, and the one now answered. Zero variance is measured and declined: three coincident points ARE one dot, which is overplotting, a property of the form, and a TRUE picture.
  2. A boolean coordinate is not a sibling of null — see above. This was caught by a pin going red, not by reading.
  3. A2.4 was right that something pins scatter's current behaviour.AdvancedChartImpl.unprojectedSeriesDimension.test.tsx asserts a series: [] scatter "paints an svg" — using a category-column dataset that was never a valid scatter dataset (0 marks, measured). Updated, not deleted: its real subject (the value fallback must not be refused by An unprojected SECOND dimension draws a grouped chart with no series and says nothing — hasNoCategoryKey has no counterpart on the series axis #4683's guard) is preserved on a two-measure fixture, and a new test pins the seam from that side — the category-column dataset gets finding(plugin-charts): scatter is NOT MEASURED for the degenerate-magnitude class — its all-positive CONTROL drew zero marks, so the sweep's zeros say nothing #7171's refusal and still never gets no-plottable-series.

Tests

AdvancedChartImpl.degeneratePosition.test.tsx, 40 tests. The FIRST of them asserts the control draws — stated as an assertion, not an eyeballed observation, so that if it ever returns to zero every other assertion in the file reads as void rather than green.

pnpm exec vitest run packages/plugin-charts/src/AdvancedChartImpl.degeneratePosition.test.tsx
Test Files 1 passed (1)
Tests 40 passed (40)
pnpm exec vitest run packages/plugin-charts/
Test Files 40 passed (40)
Tests 371 passed (371)

Ablation, direction and counts predicted before running

Predicted, written down first: leg A red = 15 refusal tests + 1 seam test + 1 in unprojectedSeriesDimension = 17; leg B red = the 7 note tests; the two red sets disjoint.

legmutationpredictedobserved
Arefusal guard neutered (points.plottable === 0 to === -1)17 red, note block green17 failed / 354 passed — 15 in the refusal block, 1 in the seam block, 1 in unprojectedSeriesDimension
Bnote guard inverted (unplotted <= 0 to >= 0)7 red, refusal block green7 failed / 364 passed — all 7 in the note block

Each leg proved its mutation on disk by anchor count and blob hash (aceb44fc... to d740c74f... in A, to 53138e45... in B; anchor 1 to 0, injected 0 to 1 in both) and its restore by stategit diff HEAD and git diff --cached both empty, blob back to aceb44fc... — under an absolute-path trap, with git checkout HEAD -- path naming HEAD explicitly rather than restoring from the index. The red sets are disjoint and each leg left the other side green, which is what makes them discriminating rather than a blanket break.

Gates, all on the committed head 9953dd5fc

gateverdict, as the gate printed it
vitest packages/plugin-charts/Test Files 40 passed (40) / Tests 371 passed (371)
type-checkexit 0, tsc --noEmit, script name echoed
lint (package)299 problems (0 errors, 299 warnings) — exit 0
check-changeset-presence5 file(s) changed, 4 of them published source ...
check-changeset-no-majorNo changeset declares a major bump.
check-changeset-overwriteNo pre-existing changeset was modified or deleted.
check-changeset-fixedAll workspace packages are in the changeset fixed group.
check-control-bytesOK (scanned 5956 tracked text file(s); skipped 85 binary).
check-vi-mock-specifiersOK (4106 tracked source file(s), 2378 test-named; 526 carry a mock ...)
check-vi-mock-inheritOK (... 118 inherit, 0 auto-mocked ...)
check-i18n-keys / check-i18n-driftexit 0; 0 en value(s) changed
check-self-import / check-phantom-depsexit 0
check-esm-specifiers / check-side-effects-array / check-shell-escape-residueexit 0
check-lint-coverage46/46 packages linted, 0 with outstanding errors (0 total).
check-type-check-coverage41/41 packages compile their tests, 0 declared debt

The tracked-file gates were run aftergit add; their counts moved (5942 to 5956 text files, 525 to 526 carrying a mock), which is how I know they saw the new test rather than skipping it as untracked. tsc --listFiles confirms all four edited files are in the compiled set (1 hit each), so the type-check green covers them.

check-eager-closure exited 2 with not applicable to a local run — this check compares a pull_request merge ref. That is PRECONDITION NOT MET, not a failure; CI owns it.

The probe's first vite build was also NOT MEASURED, not red: failed to resolve import "@object-ui/core" 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.

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 53 files in this package (--format json count, up from the 52 PR #7169 measured, which is the new test file), all four edited files are in that set with 0 errors, and this repo's eslint.config.js extends tseslint.configs.recommended with no parserOptions.project and no projectService (0 matches), so no rule is type-aware and nothing in this diff can move a verdict in a file it does not touch.

About the instrument

The sweep is a browser probe over a built bundle. It is not committed, deliberately: a test that cannot run in CI renders as coverage while measuring nothing. Its findings are committed instead, as the 40 assertions above, every one of which runs in CI on jsdom — including the control.

Out of scope, reported rather than ridden


Generated by Claude Code

…of drawing an empty axis
Scatter came out of the objectui#7147 sweep NOT MEASURED: its all-positive
control drew zero marks, because scatter takes two measures and that fixture
supplied one. A control that returns zero is no control, so none of its zeros
carried information.
Re-swept with a two-measure fixture whose control DRAWS (3 of 3 marks). Rows
scatter cannot place rendered byte-identically to a scatter handed no rows at
all, six authoring failures shared one image, and one placeable row among three
was 99.75% pixel-identical to a genuinely one-row scatter.
The predicate is derived for a POSITIONAL chart, not copied from the magnitude
families: zero and negative coordinates are ordinary scatter data and keep
drawing.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012wwHa4aaFybxXrfmfHioDM
@os-warrenClaude

Copy link
Copy Markdown
CollaboratorAuthor

os-dev-report

{
"issue": 7171,
"status": "done",
"branch": "claude/issue-7171-scatter-degenerate-sweep",
"pr": "https://github.com/objectstack-ai/objectui/pull/7197",
"premise_still_valid": true,
"summary": "THE SCATTER CONTROL DREW — 3 of 3 marks, 245px of mark area, on a run whose live console positive control also fired; every reading below is therefore a measurement, not a zero. A2.1 confirmed: scatter's XAxis and YAxis are both type=number bound to xAxisKey and series[0].dataKey, and #7147's single-measure fixture put a category column on the numeric X axis — reproduced as its own tile, 0 marks, byte-identical to five other broken shapes. The sweep (33 tiles, real Chromium, one page load per tile, 520x360 above the 280 floor) found three fix-grade defects: rows unplaceable on BOTH coordinates rendered byte-identically (0.000%) to a scatter handed no rows at all; six distinct authoring failures shared one image; and one placeable row among three was 99.75% pixel-identical to a genuinely one-row scatter. Shipped a positional predicate — no-plottable-points refusal and unplotted-points footnote, scatter only — DERIVED not copied: #7147's magnitude predicate would have blanked the all-negative (3 of 3 marks), all-zero (2 of 2) and mixed-sign (3 of 3) tiles, which are correct charts. Zero variance, constant-x and constant-y are measured-and-DECLINED (A2.3's axis-domain-collapse hypothesis did not reproduce as collapse; the real failure is an axis that renders no scale at all). 14 tiles moved, 19 byte-identical, all three landed answers still firing under their own codes.",
"tests": "Union run on the final commit 9953dd5fc (git rev-parse --short HEAD), tree clean. `pnpm exec vitest run packages/plugin-charts/src/AdvancedChartImpl.degeneratePosition.test.tsx` -> `Test Files 1 passed (1)` / `Tests 40 passed (40)`; `pnpm exec vitest run packages/plugin-charts/` -> `Test Files 40 passed (40)` / `Tests 371 passed (371)`. The FIRST test asserts the control draws a non-zero mark count, as an assertion rather than an observation. ABLATION, direction and counts predicted in writing before running (leg A 17 red, leg B 7 red, disjoint): leg A neutered the refusal guard and gave 17 failed / 354 passed, split 15 refusal block + 1 seam + 1 unprojectedSeriesDimension; leg B inverted the note guard and gave 7 failed / 364 passed, all in the note block. Both legs proved mutation on disk by anchor count AND blob hash (aceb44fc -> d740c74f in A, -> 53138e45 in B; anchor 1->0, injected 0->1), and proved restore by STATE (git diff HEAD empty, git diff --cached empty, blob back to aceb44fc), under an absolute-path trap using `git checkout HEAD -- path` rather than a bare checkout. The browser instrument was rebuilt on each tree and the marker strings verified present in the bundle the browser actually reads before every reading. GATES on 9953dd5fc: type-check exit 0 with tsc --listFiles confirming all four edited files in the compiled set; package lint 0 errors; the four changeset gates, control-bytes, both vi-mock gates, both i18n gates, self-import, phantom-deps, esm-specifiers, side-effects-array, shell-escape-residue, lint-coverage and type-check-coverage all exit 0. NOT MEASURED and reported as such rather than as failures: check-eager-closure exit 2 (needs a pull_request merge ref; CI owns it), and the probe's first vite build (unbuilt dependency closure, rebuilt and re-run to a real verdict). DECLARED NARROWING: repo-wide lint not run locally — eslint's own config selected 53 files in this package, all four edited files in that set with 0 errors, and no rule is type-aware (zero matches for parserOptions.project or projectService), so this diff cannot move a verdict in a file it does not touch.",
"mcp_calls": "10",
"open_questions": [],
"out_of_scope_findings": [
"filed as #7194: a multi-series scatter draws every series at the FIRST series' y values — measured, two series over `ym` and `zm` produced 4 symbols at only 2 distinct positions, each drawn twice, and `zm`'s values appear nowhere on the plot",
"filed as #7195: an all-boolean scatter coordinate draws nothing and says nothing — deliberately left unguarded here, because a boolean beside a genuinely numeric row draws every mark (3 of 3), so rejecting booleans would put a false 'not drawn' count over visible points"
]
}

Generated by Claude Code

@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

MetricValueBudget
Eager closure (gzip, 48 chunks)3157.9 KB3191.4 KB
Main entry chunk (gzip)142.6 KB350 KB
Entry fileindex-DTxEKJcd.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)514.13KB117.19KB
core (index.js)5.30KB2.13KB
create-plugin (index.js)10.08KB3.26KB
data-objectstack (index.js)178.20KB49.60KB
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)70.02KB19.44KB
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)250.65KB63.91KB
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)207.19KB56.03KB
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

PM review — domain:ui seat (session session_012wwHa4aaFybxXrfmfHioDM), reviewer of record

Verdict: ACCEPT. Arming on green.

The gating requirement is met, and stated as an assertion rather than an observation

THE SCATTER CONTROL DREW — 3 of 3 marks, 245px of mark area, on a run whose live console positive control also fired.

⭐ And the first test asserts the control's non-zero mark count rather than eyeballing it. That is the difference between "we checked once" and "this can never silently become a sweep of blank tiles again" — which is exactly how #7147's scatter result became NOT MEASURED in the first place.

⭐ The predicate was DERIVED, and the derivation is proved by what copying would have broken

This was the card's central fence, and it did not merely get respected — it got measured:

#7147's magnitude predicate would have blanked the all-negative (3 of 3 marks), all-zero (2 of 2) and mixed-sign (3 of 3) tiles, which are correct charts.

⇒ Mechanically reusing no-positive-magnitude would have refused three working charts. That is worse than the silence being fixed, and the only way to know it is to run the wrong predicate against the real tiles. Scatter plots position; negative and zero coordinates are legitimate data. The positional predicate is scatter-only and earns its separateness.

#7147's NOT MEASURED is now explained, not merely superseded

scatter's XAxis and YAxis are both type=number bound to xAxisKey and series[0].dataKey, and #7147's single-measure fixture put a category column on the numeric X axis — reproduced as its own tile, 0 marks, byte-identical to five other broken shapes.

⭐ That is the right way to close a NOT MEASURED: not "we measured it properly this time" but "here is precisely why the old instrument read zero, reproduced as a tile." The earlier zero is now a known artefact rather than an unexplained one.

The three defects are the same family this seat has been closing, one step further

  • rows unplaceable on both coordinates render byte-identically (0.000%) to a scatter handed no rows at all;
  • six distinct authoring failures share one image;
  • one placeable row among three is 99.75% pixel-identical to a genuinely one-row scatter.

Six inputs, one picture, no diagnostic — the same shape as pie's four-datasets-one-image and treemap's four-way collision. 14 tiles moved, 19 byte-identical, and all three landed answers still fire under their own codes, which is what keeps #7146/#7147/#7148 from being quietly regressed.

A2.3 was falsified specifically, which is more useful than being right

I guessed the interesting failures would be axis domain collapse. Measured: zero-variance, constant-x and constant-y are measured-and-DECLINED, and the collapse hypothesis did not reproduce as collapse — the real failure there is an axis that renders no scale at all. Naming what the mechanism actually is, rather than accepting my framing, is what Zone 2 is for.

#7195 is the finding I most want on the record — their own pin caught their overreach

An all-boolean scatter coordinate draws nothing and says nothing, and it was deliberately left unguarded:

a boolean beside a genuinely numeric row draws every mark (3 of 3), so rejecting booleans would put a false "not drawn" count over visible points; the first draft DID reject them and the pin went red, which is how the mixed reading was found.

They wrote the stricter predicate, their own test went red, and that red revealed a case the stricter rule would have mis-reported. That is the ablation discipline running forward — as a design instrument rather than an after-the-fact proof — and it prevented shipping a refusal that lies about visible points. Filing it unguarded, with the reason, is the correct outcome.

#7194 may be more serious than what this PR fixed

a multi-series scatter draws every series at the FIRST series' y values — two series over ym and zm produced 4 symbols at only 2 distinct positions, each drawn twice, and zm's values (5 and 90) appear nowhere on the plot.

⚠️Good data, confidently wrong picture — no degenerate input required. Everything this card fixed concerns degenerate input drawing a misleading image; #7194 is valid input drawing a false one, which is a strictly worse class. Correctly filed rather than ridden (it needs a shape decision, not a one-liner), and I will grade it accordingly.

Evidence quality

Ablation with direction and counts predicted in writing before running — leg A 17 red, leg B 7 red, disjoint — observed exactly, with leg A's 17 broken down (15 refusal block + 1 seam + 1 unprojectedSeriesDimension) rather than left as a total. Mutation by anchor count and blob hash; restore by state under an absolute-path trap using git checkout HEAD -- path, never the bare form.

⭐ And the browser instrument was rebuilt on each tree, with the marker strings verified present in the bundle the browser actually reads before every reading. That closes the source-vs-dist gap that would otherwise make a browser sweep unfalsifiable.

Gate counts moved from #7169's 5942/525 to 5956/526 — which is how you know the gates saw the new test rather than skipping it as untracked. Two NOT MEASURED results reported as such and not as failures (check-eager-closure exit 2, needing a PR merge ref; the probe's first vite build on an unbuilt closure, rebuilt and re-run).


Nothing to change.


Generated by Claude Code

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

Projects

None yet

1 participant

@os-warren
, '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): scatter says when it cannot place a row, instead of drawing an empty axis - #7197

Merged
os-warren merged 1 commit into
mainfrom
claude/issue-7171-scatter-degenerate-sweep
Sep 1, 2026
Merged

fix(plugin-charts): scatter says when it cannot place a row, instead of drawing an empty axis#7197
os-warren merged 1 commit into
mainfrom
claude/issue-7171-scatter-degenerate-sweep

Conversation

@os-warren

Copy link
Copy Markdown
Collaborator

Fixes#7171

Re-derived on origin/main899730e, which already carries PR #7169's no-positive-magnitude, PR #7146's no-positive-flow and PR #7161's ChartFootnote. Gates and numbers below are quoted at the committed head 9953dd5.

The one line that makes the rest readable: THE CONTROL DREW

#7147's sweep reported markN: 0 on every scatter tile including its all-positive control, so scatter came out NOT MEASURED — a control that returns zero is no control, and none of those zeros carried information.

A two-measure fixture was built and its control drew 3 of 3 marks (245px of mark area), on a run whose live console control also fired. Everything below is therefore a reading, not a zero.

The cause is confirmed exactly as PM assumed (A2.1): scatter's XAxis and YAxis are BOTH type="number", bound to xAxisKey and series[0].dataKey. #7147's fixture put a category column (stage) on the numeric X axis. Reproduced here as its own tile: scatter--fixture7147, 0 marks, and byte-identical (51957063d9c2) to five other broken shapes.

Rendered it and looked first

33 tiles in real Chromium (/opt/pw-browsers/chromium), one page load per tile so console output is attributable to exactly one of them. Each tile screenshotted, MD5'd, decoded and pixel-diffed against a literally empty div of the same box — 520x360, above the chart's own CHART_MIN_HEIGHT floor of 280, because PR #7169 measured that no footnote is visible at all below it.

measured-and-declined was genuinely on the table and it SURVIVED for three of the classes swept. What it did not survive for:

datasetwhat a reader saw at 899730everdict
both coordinates null, or both keys absentbyte-identical (diff 0.000%) to a scatter handed NO ROWS AT ALL. The empty-result picture, for a query that returned rowsfix
null x · absent x · 'n/a' x · 'Infinity' x · boolean x · #7147's category columnSIX datasets, ONE image (51957063d9c2): a confident y scale, an x axis with no scale at all, and no marks. The y-side twin is a second four-way collision (5a67d6ab8800)fix
one placeable row among three99.75% pixel-identical to a genuinely one-row scatter (diff 0.250%); the y-side twin 0.293%. Exactly the collision that decided pie in #7147 — two rows vanish and the picture says "one point"fix
all-negative · all-zero · mixed-sign coordinatesdrew 3 / 2 / 3 marks, three distinct images, full scalesdeclined — correct charts
zero variance (3 rows at one coordinate)3 symbols, 0.017% from a one-row scatterdeclined — see below
constant x, constant yevery mark drawn, full scales on both axesdeclined

The predicate is DERIVED for a positional chart, and every clause was forced by a measurement

no-positive-magnitude's Number.isFinite(v) && v > 0 does not transfer. Pie, funnel and treemap size a mark BY its measure; scatter plots POSITION, and a chart of temperatures or profit deltas is supposed to show zeros and negatives. Measured rather than argued: that predicate would have blanked the all-negative (3 of 3 marks), all-zero (2 of 2) and mixed-sign (3 of 3) tiles above — three correct charts.

What survived is isPlottableCoord, and Number() alone gets three of its cases wrong in both directions:

valueNumber(v) saysRecharts actually doespredicate
null0, finite — plottable0 of 2 marks, x axis renders no scalereject
''0, finite — plottable2 of 2 marks, x ticks 0,1,2,3,4accept — rejecting it would blank a chart that draws
'10'10, finite2 of 2 marksaccept
'n/a' / 'Infinity' / absentNaN / Infinity0 of 2 marksreject

The '' row is the trap: it LOOKS like a null and reads like one. A predicate copied from the magnitude families, or written from Number() alone, gets it backwards.

The clause that was pinned, went red, and was removed

A boolean coordinate was rejected in the first draft — the browser sweep had it drawing 0 of 2 marks, byte-identical to the null tile. Pinning that turned the mixed case red, and the mixed case is the real reading: a boolean beside a genuinely numeric row draws every mark (3 of 3). Recharts needs one real number to build the scale and then coerces the booleans onto it.

So rejecting booleans would put a footnote reading "2 of 3 rows ... are not drawn" over three points visibly on screen — a false statement about the picture, which is worse than the silence this card is about. The predicate accepts booleans, the all-boolean tile stays exactly as silent as it is today (51957063d9c2, unchanged), and that residual hole is filed as #7195 with the reasoning, not hidden.

What changed

Three helpers and one wiring, all scatter-only:

  • isPlottableCoord(v) / countPlottablePoints(rows, xKey, yKey) — a point exists only if both coordinates are numbers.
  • No point placeable with rows present: the file's existing refusal shell under its own code, data-chart-error="no-plottable-points", naming both keys because a point needs both.
  • Some points placeable: the plot renders unchanged inside the existing ChartFootnote, data-chart-note="unplotted-points", carrying the count.

The y key is bound once (const scatterYKey = series[0]?.dataKey || 'value') and handed to both the predicate and the YAxis, so the two cannot drift.

Copy names the predicate, not a cause — five shapes reach it and naming any one is false for the other four. Gated on total > 0, so every no-rows tile is untouched: that is the empty-RESULT question (#7130), answered upstream in ObjectChart. No console warning, matching all three landed answers. No i18n, matching the file.

The one place this note differs from #7169's

unsized-rows deliberately does not say the rows are undrawn, because a mixed-sign pie paints a sector for every row. Scatter is the opposite case and it was measured: a 3-row dataset with one placeable pair emitted exactly onepath.recharts-symbols. The rows really are absent from the picture, so saying so is true — and the count is the half a reader cannot recover from it.

Before / after: 14 tiles moved, 19 are byte-identical

The 19 are what make the 14 discriminating.

The two collisions are broken: both-coordinates-degenerate versus no-rows went 0.000% to 2.651%, and one-placeable-row versus one-row went 0.250% to 6.368%.

Seam map, pinned from both sides

datasetsankeypie / donut / funnel / treemapscatter
no rows at alluntouched (#7130)untouched (#7130)untouched (#7130)
nothing drawableno-positive-flow (#7146)no-positive-magnitude (#7169)no-plottable-points (this PR)
some drawableomitted-rows (#7161)unsized-rows (#7169)unplotted-points (this PR)
all drawableuntoucheduntoucheduntouched, no wrapper added

#7169's mutual-exclusivity test was extended, not duplicated: scatter and both new codes join that same assertion, over six codes and a scatter-specific dataset pair, so a sixth answer cannot be added later without it having an opinion.

Assumptions falsified

  1. A2.3's headline — "the interesting failures are probably about axis domain collapse" — did not reproduce as collapse. Zero variance, constant x and constant y all draw every mark with normally padded domains (zero-variance's x ticks are 0,3,6,9,12, identical to a one-row scatter's). What the sweep found instead is an axis that renders no scale whatsoever when nothing on it is placeable, beside a confident scale on the other axis — a different failure, and the one now answered. Zero variance is measured and declined: three coincident points ARE one dot, which is overplotting, a property of the form, and a TRUE picture.
  2. A boolean coordinate is not a sibling of null — see above. This was caught by a pin going red, not by reading.
  3. A2.4 was right that something pins scatter's current behaviour.AdvancedChartImpl.unprojectedSeriesDimension.test.tsx asserts a series: [] scatter "paints an svg" — using a category-column dataset that was never a valid scatter dataset (0 marks, measured). Updated, not deleted: its real subject (the value fallback must not be refused by An unprojected SECOND dimension draws a grouped chart with no series and says nothing — hasNoCategoryKey has no counterpart on the series axis #4683's guard) is preserved on a two-measure fixture, and a new test pins the seam from that side — the category-column dataset gets finding(plugin-charts): scatter is NOT MEASURED for the degenerate-magnitude class — its all-positive CONTROL drew zero marks, so the sweep's zeros say nothing #7171's refusal and still never gets no-plottable-series.

Tests

AdvancedChartImpl.degeneratePosition.test.tsx, 40 tests. The FIRST of them asserts the control draws — stated as an assertion, not an eyeballed observation, so that if it ever returns to zero every other assertion in the file reads as void rather than green.

pnpm exec vitest run packages/plugin-charts/src/AdvancedChartImpl.degeneratePosition.test.tsx
Test Files 1 passed (1)
Tests 40 passed (40)
pnpm exec vitest run packages/plugin-charts/
Test Files 40 passed (40)
Tests 371 passed (371)

Ablation, direction and counts predicted before running

Predicted, written down first: leg A red = 15 refusal tests + 1 seam test + 1 in unprojectedSeriesDimension = 17; leg B red = the 7 note tests; the two red sets disjoint.

legmutationpredictedobserved
Arefusal guard neutered (points.plottable === 0 to === -1)17 red, note block green17 failed / 354 passed — 15 in the refusal block, 1 in the seam block, 1 in unprojectedSeriesDimension
Bnote guard inverted (unplotted <= 0 to >= 0)7 red, refusal block green7 failed / 364 passed — all 7 in the note block

Each leg proved its mutation on disk by anchor count and blob hash (aceb44fc... to d740c74f... in A, to 53138e45... in B; anchor 1 to 0, injected 0 to 1 in both) and its restore by stategit diff HEAD and git diff --cached both empty, blob back to aceb44fc... — under an absolute-path trap, with git checkout HEAD -- path naming HEAD explicitly rather than restoring from the index. The red sets are disjoint and each leg left the other side green, which is what makes them discriminating rather than a blanket break.

Gates, all on the committed head 9953dd5fc

gateverdict, as the gate printed it
vitest packages/plugin-charts/Test Files 40 passed (40) / Tests 371 passed (371)
type-checkexit 0, tsc --noEmit, script name echoed
lint (package)299 problems (0 errors, 299 warnings) — exit 0
check-changeset-presence5 file(s) changed, 4 of them published source ...
check-changeset-no-majorNo changeset declares a major bump.
check-changeset-overwriteNo pre-existing changeset was modified or deleted.
check-changeset-fixedAll workspace packages are in the changeset fixed group.
check-control-bytesOK (scanned 5956 tracked text file(s); skipped 85 binary).
check-vi-mock-specifiersOK (4106 tracked source file(s), 2378 test-named; 526 carry a mock ...)
check-vi-mock-inheritOK (... 118 inherit, 0 auto-mocked ...)
check-i18n-keys / check-i18n-driftexit 0; 0 en value(s) changed
check-self-import / check-phantom-depsexit 0
check-esm-specifiers / check-side-effects-array / check-shell-escape-residueexit 0
check-lint-coverage46/46 packages linted, 0 with outstanding errors (0 total).
check-type-check-coverage41/41 packages compile their tests, 0 declared debt

The tracked-file gates were run aftergit add; their counts moved (5942 to 5956 text files, 525 to 526 carrying a mock), which is how I know they saw the new test rather than skipping it as untracked. tsc --listFiles confirms all four edited files are in the compiled set (1 hit each), so the type-check green covers them.

check-eager-closure exited 2 with not applicable to a local run — this check compares a pull_request merge ref. That is PRECONDITION NOT MET, not a failure; CI owns it.

The probe's first vite build was also NOT MEASURED, not red: failed to resolve import "@object-ui/core" 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.

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 53 files in this package (--format json count, up from the 52 PR #7169 measured, which is the new test file), all four edited files are in that set with 0 errors, and this repo's eslint.config.js extends tseslint.configs.recommended with no parserOptions.project and no projectService (0 matches), so no rule is type-aware and nothing in this diff can move a verdict in a file it does not touch.

About the instrument

The sweep is a browser probe over a built bundle. It is not committed, deliberately: a test that cannot run in CI renders as coverage while measuring nothing. Its findings are committed instead, as the 40 assertions above, every one of which runs in CI on jsdom — including the control.

Out of scope, reported rather than ridden


Generated by Claude Code

…of drawing an empty axis
Scatter came out of the objectui#7147 sweep NOT MEASURED: its all-positive
control drew zero marks, because scatter takes two measures and that fixture
supplied one. A control that returns zero is no control, so none of its zeros
carried information.
Re-swept with a two-measure fixture whose control DRAWS (3 of 3 marks). Rows
scatter cannot place rendered byte-identically to a scatter handed no rows at
all, six authoring failures shared one image, and one placeable row among three
was 99.75% pixel-identical to a genuinely one-row scatter.
The predicate is derived for a POSITIONAL chart, not copied from the magnitude
families: zero and negative coordinates are ordinary scatter data and keep
drawing.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012wwHa4aaFybxXrfmfHioDM
@os-warrenClaude

Copy link
Copy Markdown
CollaboratorAuthor

os-dev-report

{
"issue": 7171,
"status": "done",
"branch": "claude/issue-7171-scatter-degenerate-sweep",
"pr": "https://github.com/objectstack-ai/objectui/pull/7197",
"premise_still_valid": true,
"summary": "THE SCATTER CONTROL DREW — 3 of 3 marks, 245px of mark area, on a run whose live console positive control also fired; every reading below is therefore a measurement, not a zero. A2.1 confirmed: scatter's XAxis and YAxis are both type=number bound to xAxisKey and series[0].dataKey, and #7147's single-measure fixture put a category column on the numeric X axis — reproduced as its own tile, 0 marks, byte-identical to five other broken shapes. The sweep (33 tiles, real Chromium, one page load per tile, 520x360 above the 280 floor) found three fix-grade defects: rows unplaceable on BOTH coordinates rendered byte-identically (0.000%) to a scatter handed no rows at all; six distinct authoring failures shared one image; and one placeable row among three was 99.75% pixel-identical to a genuinely one-row scatter. Shipped a positional predicate — no-plottable-points refusal and unplotted-points footnote, scatter only — DERIVED not copied: #7147's magnitude predicate would have blanked the all-negative (3 of 3 marks), all-zero (2 of 2) and mixed-sign (3 of 3) tiles, which are correct charts. Zero variance, constant-x and constant-y are measured-and-DECLINED (A2.3's axis-domain-collapse hypothesis did not reproduce as collapse; the real failure is an axis that renders no scale at all). 14 tiles moved, 19 byte-identical, all three landed answers still firing under their own codes.",
"tests": "Union run on the final commit 9953dd5fc (git rev-parse --short HEAD), tree clean. `pnpm exec vitest run packages/plugin-charts/src/AdvancedChartImpl.degeneratePosition.test.tsx` -> `Test Files 1 passed (1)` / `Tests 40 passed (40)`; `pnpm exec vitest run packages/plugin-charts/` -> `Test Files 40 passed (40)` / `Tests 371 passed (371)`. The FIRST test asserts the control draws a non-zero mark count, as an assertion rather than an observation. ABLATION, direction and counts predicted in writing before running (leg A 17 red, leg B 7 red, disjoint): leg A neutered the refusal guard and gave 17 failed / 354 passed, split 15 refusal block + 1 seam + 1 unprojectedSeriesDimension; leg B inverted the note guard and gave 7 failed / 364 passed, all in the note block. Both legs proved mutation on disk by anchor count AND blob hash (aceb44fc -> d740c74f in A, -> 53138e45 in B; anchor 1->0, injected 0->1), and proved restore by STATE (git diff HEAD empty, git diff --cached empty, blob back to aceb44fc), under an absolute-path trap using `git checkout HEAD -- path` rather than a bare checkout. The browser instrument was rebuilt on each tree and the marker strings verified present in the bundle the browser actually reads before every reading. GATES on 9953dd5fc: type-check exit 0 with tsc --listFiles confirming all four edited files in the compiled set; package lint 0 errors; the four changeset gates, control-bytes, both vi-mock gates, both i18n gates, self-import, phantom-deps, esm-specifiers, side-effects-array, shell-escape-residue, lint-coverage and type-check-coverage all exit 0. NOT MEASURED and reported as such rather than as failures: check-eager-closure exit 2 (needs a pull_request merge ref; CI owns it), and the probe's first vite build (unbuilt dependency closure, rebuilt and re-run to a real verdict). DECLARED NARROWING: repo-wide lint not run locally — eslint's own config selected 53 files in this package, all four edited files in that set with 0 errors, and no rule is type-aware (zero matches for parserOptions.project or projectService), so this diff cannot move a verdict in a file it does not touch.",
"mcp_calls": "10",
"open_questions": [],
"out_of_scope_findings": [
"filed as #7194: a multi-series scatter draws every series at the FIRST series' y values — measured, two series over `ym` and `zm` produced 4 symbols at only 2 distinct positions, each drawn twice, and `zm`'s values appear nowhere on the plot",
"filed as #7195: an all-boolean scatter coordinate draws nothing and says nothing — deliberately left unguarded here, because a boolean beside a genuinely numeric row draws every mark (3 of 3), so rejecting booleans would put a false 'not drawn' count over visible points"
]
}

Generated by Claude Code

@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

MetricValueBudget
Eager closure (gzip, 48 chunks)3157.9 KB3191.4 KB
Main entry chunk (gzip)142.6 KB350 KB
Entry fileindex-DTxEKJcd.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)514.13KB117.19KB
core (index.js)5.30KB2.13KB
create-plugin (index.js)10.08KB3.26KB
data-objectstack (index.js)178.20KB49.60KB
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)70.02KB19.44KB
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)250.65KB63.91KB
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)207.19KB56.03KB
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

PM review — domain:ui seat (session session_012wwHa4aaFybxXrfmfHioDM), reviewer of record

Verdict: ACCEPT. Arming on green.

The gating requirement is met, and stated as an assertion rather than an observation

THE SCATTER CONTROL DREW — 3 of 3 marks, 245px of mark area, on a run whose live console positive control also fired.

⭐ And the first test asserts the control's non-zero mark count rather than eyeballing it. That is the difference between "we checked once" and "this can never silently become a sweep of blank tiles again" — which is exactly how #7147's scatter result became NOT MEASURED in the first place.

⭐ The predicate was DERIVED, and the derivation is proved by what copying would have broken

This was the card's central fence, and it did not merely get respected — it got measured:

#7147's magnitude predicate would have blanked the all-negative (3 of 3 marks), all-zero (2 of 2) and mixed-sign (3 of 3) tiles, which are correct charts.

⇒ Mechanically reusing no-positive-magnitude would have refused three working charts. That is worse than the silence being fixed, and the only way to know it is to run the wrong predicate against the real tiles. Scatter plots position; negative and zero coordinates are legitimate data. The positional predicate is scatter-only and earns its separateness.

#7147's NOT MEASURED is now explained, not merely superseded

scatter's XAxis and YAxis are both type=number bound to xAxisKey and series[0].dataKey, and #7147's single-measure fixture put a category column on the numeric X axis — reproduced as its own tile, 0 marks, byte-identical to five other broken shapes.

⭐ That is the right way to close a NOT MEASURED: not "we measured it properly this time" but "here is precisely why the old instrument read zero, reproduced as a tile." The earlier zero is now a known artefact rather than an unexplained one.

The three defects are the same family this seat has been closing, one step further

  • rows unplaceable on both coordinates render byte-identically (0.000%) to a scatter handed no rows at all;
  • six distinct authoring failures share one image;
  • one placeable row among three is 99.75% pixel-identical to a genuinely one-row scatter.

Six inputs, one picture, no diagnostic — the same shape as pie's four-datasets-one-image and treemap's four-way collision. 14 tiles moved, 19 byte-identical, and all three landed answers still fire under their own codes, which is what keeps #7146/#7147/#7148 from being quietly regressed.

A2.3 was falsified specifically, which is more useful than being right

I guessed the interesting failures would be axis domain collapse. Measured: zero-variance, constant-x and constant-y are measured-and-DECLINED, and the collapse hypothesis did not reproduce as collapse — the real failure there is an axis that renders no scale at all. Naming what the mechanism actually is, rather than accepting my framing, is what Zone 2 is for.

#7195 is the finding I most want on the record — their own pin caught their overreach

An all-boolean scatter coordinate draws nothing and says nothing, and it was deliberately left unguarded:

a boolean beside a genuinely numeric row draws every mark (3 of 3), so rejecting booleans would put a false "not drawn" count over visible points; the first draft DID reject them and the pin went red, which is how the mixed reading was found.

They wrote the stricter predicate, their own test went red, and that red revealed a case the stricter rule would have mis-reported. That is the ablation discipline running forward — as a design instrument rather than an after-the-fact proof — and it prevented shipping a refusal that lies about visible points. Filing it unguarded, with the reason, is the correct outcome.

#7194 may be more serious than what this PR fixed

a multi-series scatter draws every series at the FIRST series' y values — two series over ym and zm produced 4 symbols at only 2 distinct positions, each drawn twice, and zm's values (5 and 90) appear nowhere on the plot.

⚠️Good data, confidently wrong picture — no degenerate input required. Everything this card fixed concerns degenerate input drawing a misleading image; #7194 is valid input drawing a false one, which is a strictly worse class. Correctly filed rather than ridden (it needs a shape decision, not a one-liner), and I will grade it accordingly.

Evidence quality

Ablation with direction and counts predicted in writing before running — leg A 17 red, leg B 7 red, disjoint — observed exactly, with leg A's 17 broken down (15 refusal block + 1 seam + 1 unprojectedSeriesDimension) rather than left as a total. Mutation by anchor count and blob hash; restore by state under an absolute-path trap using git checkout HEAD -- path, never the bare form.

⭐ And the browser instrument was rebuilt on each tree, with the marker strings verified present in the bundle the browser actually reads before every reading. That closes the source-vs-dist gap that would otherwise make a browser sweep unfalsifiable.

Gate counts moved from #7169's 5942/525 to 5956/526 — which is how you know the gates saw the new test rather than skipping it as untracked. Two NOT MEASURED results reported as such and not as failures (check-eager-closure exit 2, needing a PR merge ref; the probe's first vite build on an unbuilt closure, rebuilt and re-run).


Nothing to change.


Generated by Claude Code

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

Projects

None yet

1 participant

@os-warren
, '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): scatter says when it cannot place a row, instead of drawing an empty axis - #7197

Merged
os-warren merged 1 commit into
mainfrom
claude/issue-7171-scatter-degenerate-sweep
Sep 1, 2026
Merged

fix(plugin-charts): scatter says when it cannot place a row, instead of drawing an empty axis#7197
os-warren merged 1 commit into
mainfrom
claude/issue-7171-scatter-degenerate-sweep

Conversation

@os-warren

Copy link
Copy Markdown
Collaborator

Fixes#7171

Re-derived on origin/main899730e, which already carries PR #7169's no-positive-magnitude, PR #7146's no-positive-flow and PR #7161's ChartFootnote. Gates and numbers below are quoted at the committed head 9953dd5.

The one line that makes the rest readable: THE CONTROL DREW

#7147's sweep reported markN: 0 on every scatter tile including its all-positive control, so scatter came out NOT MEASURED — a control that returns zero is no control, and none of those zeros carried information.

A two-measure fixture was built and its control drew 3 of 3 marks (245px of mark area), on a run whose live console control also fired. Everything below is therefore a reading, not a zero.

The cause is confirmed exactly as PM assumed (A2.1): scatter's XAxis and YAxis are BOTH type="number", bound to xAxisKey and series[0].dataKey. #7147's fixture put a category column (stage) on the numeric X axis. Reproduced here as its own tile: scatter--fixture7147, 0 marks, and byte-identical (51957063d9c2) to five other broken shapes.

Rendered it and looked first

33 tiles in real Chromium (/opt/pw-browsers/chromium), one page load per tile so console output is attributable to exactly one of them. Each tile screenshotted, MD5'd, decoded and pixel-diffed against a literally empty div of the same box — 520x360, above the chart's own CHART_MIN_HEIGHT floor of 280, because PR #7169 measured that no footnote is visible at all below it.

measured-and-declined was genuinely on the table and it SURVIVED for three of the classes swept. What it did not survive for:

datasetwhat a reader saw at 899730everdict
both coordinates null, or both keys absentbyte-identical (diff 0.000%) to a scatter handed NO ROWS AT ALL. The empty-result picture, for a query that returned rowsfix
null x · absent x · 'n/a' x · 'Infinity' x · boolean x · #7147's category columnSIX datasets, ONE image (51957063d9c2): a confident y scale, an x axis with no scale at all, and no marks. The y-side twin is a second four-way collision (5a67d6ab8800)fix
one placeable row among three99.75% pixel-identical to a genuinely one-row scatter (diff 0.250%); the y-side twin 0.293%. Exactly the collision that decided pie in #7147 — two rows vanish and the picture says "one point"fix
all-negative · all-zero · mixed-sign coordinatesdrew 3 / 2 / 3 marks, three distinct images, full scalesdeclined — correct charts
zero variance (3 rows at one coordinate)3 symbols, 0.017% from a one-row scatterdeclined — see below
constant x, constant yevery mark drawn, full scales on both axesdeclined

The predicate is DERIVED for a positional chart, and every clause was forced by a measurement

no-positive-magnitude's Number.isFinite(v) && v > 0 does not transfer. Pie, funnel and treemap size a mark BY its measure; scatter plots POSITION, and a chart of temperatures or profit deltas is supposed to show zeros and negatives. Measured rather than argued: that predicate would have blanked the all-negative (3 of 3 marks), all-zero (2 of 2) and mixed-sign (3 of 3) tiles above — three correct charts.

What survived is isPlottableCoord, and Number() alone gets three of its cases wrong in both directions:

valueNumber(v) saysRecharts actually doespredicate
null0, finite — plottable0 of 2 marks, x axis renders no scalereject
''0, finite — plottable2 of 2 marks, x ticks 0,1,2,3,4accept — rejecting it would blank a chart that draws
'10'10, finite2 of 2 marksaccept
'n/a' / 'Infinity' / absentNaN / Infinity0 of 2 marksreject

The '' row is the trap: it LOOKS like a null and reads like one. A predicate copied from the magnitude families, or written from Number() alone, gets it backwards.

The clause that was pinned, went red, and was removed

A boolean coordinate was rejected in the first draft — the browser sweep had it drawing 0 of 2 marks, byte-identical to the null tile. Pinning that turned the mixed case red, and the mixed case is the real reading: a boolean beside a genuinely numeric row draws every mark (3 of 3). Recharts needs one real number to build the scale and then coerces the booleans onto it.

So rejecting booleans would put a footnote reading "2 of 3 rows ... are not drawn" over three points visibly on screen — a false statement about the picture, which is worse than the silence this card is about. The predicate accepts booleans, the all-boolean tile stays exactly as silent as it is today (51957063d9c2, unchanged), and that residual hole is filed as #7195 with the reasoning, not hidden.

What changed

Three helpers and one wiring, all scatter-only:

  • isPlottableCoord(v) / countPlottablePoints(rows, xKey, yKey) — a point exists only if both coordinates are numbers.
  • No point placeable with rows present: the file's existing refusal shell under its own code, data-chart-error="no-plottable-points", naming both keys because a point needs both.
  • Some points placeable: the plot renders unchanged inside the existing ChartFootnote, data-chart-note="unplotted-points", carrying the count.

The y key is bound once (const scatterYKey = series[0]?.dataKey || 'value') and handed to both the predicate and the YAxis, so the two cannot drift.

Copy names the predicate, not a cause — five shapes reach it and naming any one is false for the other four. Gated on total > 0, so every no-rows tile is untouched: that is the empty-RESULT question (#7130), answered upstream in ObjectChart. No console warning, matching all three landed answers. No i18n, matching the file.

The one place this note differs from #7169's

unsized-rows deliberately does not say the rows are undrawn, because a mixed-sign pie paints a sector for every row. Scatter is the opposite case and it was measured: a 3-row dataset with one placeable pair emitted exactly onepath.recharts-symbols. The rows really are absent from the picture, so saying so is true — and the count is the half a reader cannot recover from it.

Before / after: 14 tiles moved, 19 are byte-identical

The 19 are what make the 14 discriminating.

The two collisions are broken: both-coordinates-degenerate versus no-rows went 0.000% to 2.651%, and one-placeable-row versus one-row went 0.250% to 6.368%.

Seam map, pinned from both sides

datasetsankeypie / donut / funnel / treemapscatter
no rows at alluntouched (#7130)untouched (#7130)untouched (#7130)
nothing drawableno-positive-flow (#7146)no-positive-magnitude (#7169)no-plottable-points (this PR)
some drawableomitted-rows (#7161)unsized-rows (#7169)unplotted-points (this PR)
all drawableuntoucheduntoucheduntouched, no wrapper added

#7169's mutual-exclusivity test was extended, not duplicated: scatter and both new codes join that same assertion, over six codes and a scatter-specific dataset pair, so a sixth answer cannot be added later without it having an opinion.

Assumptions falsified

  1. A2.3's headline — "the interesting failures are probably about axis domain collapse" — did not reproduce as collapse. Zero variance, constant x and constant y all draw every mark with normally padded domains (zero-variance's x ticks are 0,3,6,9,12, identical to a one-row scatter's). What the sweep found instead is an axis that renders no scale whatsoever when nothing on it is placeable, beside a confident scale on the other axis — a different failure, and the one now answered. Zero variance is measured and declined: three coincident points ARE one dot, which is overplotting, a property of the form, and a TRUE picture.
  2. A boolean coordinate is not a sibling of null — see above. This was caught by a pin going red, not by reading.
  3. A2.4 was right that something pins scatter's current behaviour.AdvancedChartImpl.unprojectedSeriesDimension.test.tsx asserts a series: [] scatter "paints an svg" — using a category-column dataset that was never a valid scatter dataset (0 marks, measured). Updated, not deleted: its real subject (the value fallback must not be refused by An unprojected SECOND dimension draws a grouped chart with no series and says nothing — hasNoCategoryKey has no counterpart on the series axis #4683's guard) is preserved on a two-measure fixture, and a new test pins the seam from that side — the category-column dataset gets finding(plugin-charts): scatter is NOT MEASURED for the degenerate-magnitude class — its all-positive CONTROL drew zero marks, so the sweep's zeros say nothing #7171's refusal and still never gets no-plottable-series.

Tests

AdvancedChartImpl.degeneratePosition.test.tsx, 40 tests. The FIRST of them asserts the control draws — stated as an assertion, not an eyeballed observation, so that if it ever returns to zero every other assertion in the file reads as void rather than green.

pnpm exec vitest run packages/plugin-charts/src/AdvancedChartImpl.degeneratePosition.test.tsx
Test Files 1 passed (1)
Tests 40 passed (40)
pnpm exec vitest run packages/plugin-charts/
Test Files 40 passed (40)
Tests 371 passed (371)

Ablation, direction and counts predicted before running

Predicted, written down first: leg A red = 15 refusal tests + 1 seam test + 1 in unprojectedSeriesDimension = 17; leg B red = the 7 note tests; the two red sets disjoint.

legmutationpredictedobserved
Arefusal guard neutered (points.plottable === 0 to === -1)17 red, note block green17 failed / 354 passed — 15 in the refusal block, 1 in the seam block, 1 in unprojectedSeriesDimension
Bnote guard inverted (unplotted <= 0 to >= 0)7 red, refusal block green7 failed / 364 passed — all 7 in the note block

Each leg proved its mutation on disk by anchor count and blob hash (aceb44fc... to d740c74f... in A, to 53138e45... in B; anchor 1 to 0, injected 0 to 1 in both) and its restore by stategit diff HEAD and git diff --cached both empty, blob back to aceb44fc... — under an absolute-path trap, with git checkout HEAD -- path naming HEAD explicitly rather than restoring from the index. The red sets are disjoint and each leg left the other side green, which is what makes them discriminating rather than a blanket break.

Gates, all on the committed head 9953dd5fc

gateverdict, as the gate printed it
vitest packages/plugin-charts/Test Files 40 passed (40) / Tests 371 passed (371)
type-checkexit 0, tsc --noEmit, script name echoed
lint (package)299 problems (0 errors, 299 warnings) — exit 0
check-changeset-presence5 file(s) changed, 4 of them published source ...
check-changeset-no-majorNo changeset declares a major bump.
check-changeset-overwriteNo pre-existing changeset was modified or deleted.
check-changeset-fixedAll workspace packages are in the changeset fixed group.
check-control-bytesOK (scanned 5956 tracked text file(s); skipped 85 binary).
check-vi-mock-specifiersOK (4106 tracked source file(s), 2378 test-named; 526 carry a mock ...)
check-vi-mock-inheritOK (... 118 inherit, 0 auto-mocked ...)
check-i18n-keys / check-i18n-driftexit 0; 0 en value(s) changed
check-self-import / check-phantom-depsexit 0
check-esm-specifiers / check-side-effects-array / check-shell-escape-residueexit 0
check-lint-coverage46/46 packages linted, 0 with outstanding errors (0 total).
check-type-check-coverage41/41 packages compile their tests, 0 declared debt

The tracked-file gates were run aftergit add; their counts moved (5942 to 5956 text files, 525 to 526 carrying a mock), which is how I know they saw the new test rather than skipping it as untracked. tsc --listFiles confirms all four edited files are in the compiled set (1 hit each), so the type-check green covers them.

check-eager-closure exited 2 with not applicable to a local run — this check compares a pull_request merge ref. That is PRECONDITION NOT MET, not a failure; CI owns it.

The probe's first vite build was also NOT MEASURED, not red: failed to resolve import "@object-ui/core" 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.

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 53 files in this package (--format json count, up from the 52 PR #7169 measured, which is the new test file), all four edited files are in that set with 0 errors, and this repo's eslint.config.js extends tseslint.configs.recommended with no parserOptions.project and no projectService (0 matches), so no rule is type-aware and nothing in this diff can move a verdict in a file it does not touch.

About the instrument

The sweep is a browser probe over a built bundle. It is not committed, deliberately: a test that cannot run in CI renders as coverage while measuring nothing. Its findings are committed instead, as the 40 assertions above, every one of which runs in CI on jsdom — including the control.

Out of scope, reported rather than ridden


Generated by Claude Code

…of drawing an empty axis
Scatter came out of the objectui#7147 sweep NOT MEASURED: its all-positive
control drew zero marks, because scatter takes two measures and that fixture
supplied one. A control that returns zero is no control, so none of its zeros
carried information.
Re-swept with a two-measure fixture whose control DRAWS (3 of 3 marks). Rows
scatter cannot place rendered byte-identically to a scatter handed no rows at
all, six authoring failures shared one image, and one placeable row among three
was 99.75% pixel-identical to a genuinely one-row scatter.
The predicate is derived for a POSITIONAL chart, not copied from the magnitude
families: zero and negative coordinates are ordinary scatter data and keep
drawing.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012wwHa4aaFybxXrfmfHioDM
@os-warrenClaude

Copy link
Copy Markdown
CollaboratorAuthor

os-dev-report

{
"issue": 7171,
"status": "done",
"branch": "claude/issue-7171-scatter-degenerate-sweep",
"pr": "https://github.com/objectstack-ai/objectui/pull/7197",
"premise_still_valid": true,
"summary": "THE SCATTER CONTROL DREW — 3 of 3 marks, 245px of mark area, on a run whose live console positive control also fired; every reading below is therefore a measurement, not a zero. A2.1 confirmed: scatter's XAxis and YAxis are both type=number bound to xAxisKey and series[0].dataKey, and #7147's single-measure fixture put a category column on the numeric X axis — reproduced as its own tile, 0 marks, byte-identical to five other broken shapes. The sweep (33 tiles, real Chromium, one page load per tile, 520x360 above the 280 floor) found three fix-grade defects: rows unplaceable on BOTH coordinates rendered byte-identically (0.000%) to a scatter handed no rows at all; six distinct authoring failures shared one image; and one placeable row among three was 99.75% pixel-identical to a genuinely one-row scatter. Shipped a positional predicate — no-plottable-points refusal and unplotted-points footnote, scatter only — DERIVED not copied: #7147's magnitude predicate would have blanked the all-negative (3 of 3 marks), all-zero (2 of 2) and mixed-sign (3 of 3) tiles, which are correct charts. Zero variance, constant-x and constant-y are measured-and-DECLINED (A2.3's axis-domain-collapse hypothesis did not reproduce as collapse; the real failure is an axis that renders no scale at all). 14 tiles moved, 19 byte-identical, all three landed answers still firing under their own codes.",
"tests": "Union run on the final commit 9953dd5fc (git rev-parse --short HEAD), tree clean. `pnpm exec vitest run packages/plugin-charts/src/AdvancedChartImpl.degeneratePosition.test.tsx` -> `Test Files 1 passed (1)` / `Tests 40 passed (40)`; `pnpm exec vitest run packages/plugin-charts/` -> `Test Files 40 passed (40)` / `Tests 371 passed (371)`. The FIRST test asserts the control draws a non-zero mark count, as an assertion rather than an observation. ABLATION, direction and counts predicted in writing before running (leg A 17 red, leg B 7 red, disjoint): leg A neutered the refusal guard and gave 17 failed / 354 passed, split 15 refusal block + 1 seam + 1 unprojectedSeriesDimension; leg B inverted the note guard and gave 7 failed / 364 passed, all in the note block. Both legs proved mutation on disk by anchor count AND blob hash (aceb44fc -> d740c74f in A, -> 53138e45 in B; anchor 1->0, injected 0->1), and proved restore by STATE (git diff HEAD empty, git diff --cached empty, blob back to aceb44fc), under an absolute-path trap using `git checkout HEAD -- path` rather than a bare checkout. The browser instrument was rebuilt on each tree and the marker strings verified present in the bundle the browser actually reads before every reading. GATES on 9953dd5fc: type-check exit 0 with tsc --listFiles confirming all four edited files in the compiled set; package lint 0 errors; the four changeset gates, control-bytes, both vi-mock gates, both i18n gates, self-import, phantom-deps, esm-specifiers, side-effects-array, shell-escape-residue, lint-coverage and type-check-coverage all exit 0. NOT MEASURED and reported as such rather than as failures: check-eager-closure exit 2 (needs a pull_request merge ref; CI owns it), and the probe's first vite build (unbuilt dependency closure, rebuilt and re-run to a real verdict). DECLARED NARROWING: repo-wide lint not run locally — eslint's own config selected 53 files in this package, all four edited files in that set with 0 errors, and no rule is type-aware (zero matches for parserOptions.project or projectService), so this diff cannot move a verdict in a file it does not touch.",
"mcp_calls": "10",
"open_questions": [],
"out_of_scope_findings": [
"filed as #7194: a multi-series scatter draws every series at the FIRST series' y values — measured, two series over `ym` and `zm` produced 4 symbols at only 2 distinct positions, each drawn twice, and `zm`'s values appear nowhere on the plot",
"filed as #7195: an all-boolean scatter coordinate draws nothing and says nothing — deliberately left unguarded here, because a boolean beside a genuinely numeric row draws every mark (3 of 3), so rejecting booleans would put a false 'not drawn' count over visible points"
]
}

Generated by Claude Code

@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

MetricValueBudget
Eager closure (gzip, 48 chunks)3157.9 KB3191.4 KB
Main entry chunk (gzip)142.6 KB350 KB
Entry fileindex-DTxEKJcd.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)514.13KB117.19KB
core (index.js)5.30KB2.13KB
create-plugin (index.js)10.08KB3.26KB
data-objectstack (index.js)178.20KB49.60KB
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)70.02KB19.44KB
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)250.65KB63.91KB
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)207.19KB56.03KB
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

PM review — domain:ui seat (session session_012wwHa4aaFybxXrfmfHioDM), reviewer of record

Verdict: ACCEPT. Arming on green.

The gating requirement is met, and stated as an assertion rather than an observation

THE SCATTER CONTROL DREW — 3 of 3 marks, 245px of mark area, on a run whose live console positive control also fired.

⭐ And the first test asserts the control's non-zero mark count rather than eyeballing it. That is the difference between "we checked once" and "this can never silently become a sweep of blank tiles again" — which is exactly how #7147's scatter result became NOT MEASURED in the first place.

⭐ The predicate was DERIVED, and the derivation is proved by what copying would have broken

This was the card's central fence, and it did not merely get respected — it got measured:

#7147's magnitude predicate would have blanked the all-negative (3 of 3 marks), all-zero (2 of 2) and mixed-sign (3 of 3) tiles, which are correct charts.

⇒ Mechanically reusing no-positive-magnitude would have refused three working charts. That is worse than the silence being fixed, and the only way to know it is to run the wrong predicate against the real tiles. Scatter plots position; negative and zero coordinates are legitimate data. The positional predicate is scatter-only and earns its separateness.

#7147's NOT MEASURED is now explained, not merely superseded

scatter's XAxis and YAxis are both type=number bound to xAxisKey and series[0].dataKey, and #7147's single-measure fixture put a category column on the numeric X axis — reproduced as its own tile, 0 marks, byte-identical to five other broken shapes.

⭐ That is the right way to close a NOT MEASURED: not "we measured it properly this time" but "here is precisely why the old instrument read zero, reproduced as a tile." The earlier zero is now a known artefact rather than an unexplained one.

The three defects are the same family this seat has been closing, one step further

  • rows unplaceable on both coordinates render byte-identically (0.000%) to a scatter handed no rows at all;
  • six distinct authoring failures share one image;
  • one placeable row among three is 99.75% pixel-identical to a genuinely one-row scatter.

Six inputs, one picture, no diagnostic — the same shape as pie's four-datasets-one-image and treemap's four-way collision. 14 tiles moved, 19 byte-identical, and all three landed answers still fire under their own codes, which is what keeps #7146/#7147/#7148 from being quietly regressed.

A2.3 was falsified specifically, which is more useful than being right

I guessed the interesting failures would be axis domain collapse. Measured: zero-variance, constant-x and constant-y are measured-and-DECLINED, and the collapse hypothesis did not reproduce as collapse — the real failure there is an axis that renders no scale at all. Naming what the mechanism actually is, rather than accepting my framing, is what Zone 2 is for.

#7195 is the finding I most want on the record — their own pin caught their overreach

An all-boolean scatter coordinate draws nothing and says nothing, and it was deliberately left unguarded:

a boolean beside a genuinely numeric row draws every mark (3 of 3), so rejecting booleans would put a false "not drawn" count over visible points; the first draft DID reject them and the pin went red, which is how the mixed reading was found.

They wrote the stricter predicate, their own test went red, and that red revealed a case the stricter rule would have mis-reported. That is the ablation discipline running forward — as a design instrument rather than an after-the-fact proof — and it prevented shipping a refusal that lies about visible points. Filing it unguarded, with the reason, is the correct outcome.

#7194 may be more serious than what this PR fixed

a multi-series scatter draws every series at the FIRST series' y values — two series over ym and zm produced 4 symbols at only 2 distinct positions, each drawn twice, and zm's values (5 and 90) appear nowhere on the plot.

⚠️Good data, confidently wrong picture — no degenerate input required. Everything this card fixed concerns degenerate input drawing a misleading image; #7194 is valid input drawing a false one, which is a strictly worse class. Correctly filed rather than ridden (it needs a shape decision, not a one-liner), and I will grade it accordingly.

Evidence quality

Ablation with direction and counts predicted in writing before running — leg A 17 red, leg B 7 red, disjoint — observed exactly, with leg A's 17 broken down (15 refusal block + 1 seam + 1 unprojectedSeriesDimension) rather than left as a total. Mutation by anchor count and blob hash; restore by state under an absolute-path trap using git checkout HEAD -- path, never the bare form.

⭐ And the browser instrument was rebuilt on each tree, with the marker strings verified present in the bundle the browser actually reads before every reading. That closes the source-vs-dist gap that would otherwise make a browser sweep unfalsifiable.

Gate counts moved from #7169's 5942/525 to 5956/526 — which is how you know the gates saw the new test rather than skipping it as untracked. Two NOT MEASURED results reported as such and not as failures (check-eager-closure exit 2, needing a PR merge ref; the probe's first vite build on an unbuilt closure, rebuilt and re-run).


Nothing to change.


Generated by Claude Code

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

Projects

None yet

1 participant

@os-warren
, '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): scatter says when it cannot place a row, instead of drawing an empty axis - #7197

Merged
os-warren merged 1 commit into
mainfrom
claude/issue-7171-scatter-degenerate-sweep
Sep 1, 2026
Merged

fix(plugin-charts): scatter says when it cannot place a row, instead of drawing an empty axis#7197
os-warren merged 1 commit into
mainfrom
claude/issue-7171-scatter-degenerate-sweep

Conversation

@os-warren

Copy link
Copy Markdown
Collaborator

Fixes#7171

Re-derived on origin/main899730e, which already carries PR #7169's no-positive-magnitude, PR #7146's no-positive-flow and PR #7161's ChartFootnote. Gates and numbers below are quoted at the committed head 9953dd5.

The one line that makes the rest readable: THE CONTROL DREW

#7147's sweep reported markN: 0 on every scatter tile including its all-positive control, so scatter came out NOT MEASURED — a control that returns zero is no control, and none of those zeros carried information.

A two-measure fixture was built and its control drew 3 of 3 marks (245px of mark area), on a run whose live console control also fired. Everything below is therefore a reading, not a zero.

The cause is confirmed exactly as PM assumed (A2.1): scatter's XAxis and YAxis are BOTH type="number", bound to xAxisKey and series[0].dataKey. #7147's fixture put a category column (stage) on the numeric X axis. Reproduced here as its own tile: scatter--fixture7147, 0 marks, and byte-identical (51957063d9c2) to five other broken shapes.

Rendered it and looked first

33 tiles in real Chromium (/opt/pw-browsers/chromium), one page load per tile so console output is attributable to exactly one of them. Each tile screenshotted, MD5'd, decoded and pixel-diffed against a literally empty div of the same box — 520x360, above the chart's own CHART_MIN_HEIGHT floor of 280, because PR #7169 measured that no footnote is visible at all below it.

measured-and-declined was genuinely on the table and it SURVIVED for three of the classes swept. What it did not survive for:

datasetwhat a reader saw at 899730everdict
both coordinates null, or both keys absentbyte-identical (diff 0.000%) to a scatter handed NO ROWS AT ALL. The empty-result picture, for a query that returned rowsfix
null x · absent x · 'n/a' x · 'Infinity' x · boolean x · #7147's category columnSIX datasets, ONE image (51957063d9c2): a confident y scale, an x axis with no scale at all, and no marks. The y-side twin is a second four-way collision (5a67d6ab8800)fix
one placeable row among three99.75% pixel-identical to a genuinely one-row scatter (diff 0.250%); the y-side twin 0.293%. Exactly the collision that decided pie in #7147 — two rows vanish and the picture says "one point"fix
all-negative · all-zero · mixed-sign coordinatesdrew 3 / 2 / 3 marks, three distinct images, full scalesdeclined — correct charts
zero variance (3 rows at one coordinate)3 symbols, 0.017% from a one-row scatterdeclined — see below
constant x, constant yevery mark drawn, full scales on both axesdeclined

The predicate is DERIVED for a positional chart, and every clause was forced by a measurement

no-positive-magnitude's Number.isFinite(v) && v > 0 does not transfer. Pie, funnel and treemap size a mark BY its measure; scatter plots POSITION, and a chart of temperatures or profit deltas is supposed to show zeros and negatives. Measured rather than argued: that predicate would have blanked the all-negative (3 of 3 marks), all-zero (2 of 2) and mixed-sign (3 of 3) tiles above — three correct charts.

What survived is isPlottableCoord, and Number() alone gets three of its cases wrong in both directions:

valueNumber(v) saysRecharts actually doespredicate
null0, finite — plottable0 of 2 marks, x axis renders no scalereject
''0, finite — plottable2 of 2 marks, x ticks 0,1,2,3,4accept — rejecting it would blank a chart that draws
'10'10, finite2 of 2 marksaccept
'n/a' / 'Infinity' / absentNaN / Infinity0 of 2 marksreject

The '' row is the trap: it LOOKS like a null and reads like one. A predicate copied from the magnitude families, or written from Number() alone, gets it backwards.

The clause that was pinned, went red, and was removed

A boolean coordinate was rejected in the first draft — the browser sweep had it drawing 0 of 2 marks, byte-identical to the null tile. Pinning that turned the mixed case red, and the mixed case is the real reading: a boolean beside a genuinely numeric row draws every mark (3 of 3). Recharts needs one real number to build the scale and then coerces the booleans onto it.

So rejecting booleans would put a footnote reading "2 of 3 rows ... are not drawn" over three points visibly on screen — a false statement about the picture, which is worse than the silence this card is about. The predicate accepts booleans, the all-boolean tile stays exactly as silent as it is today (51957063d9c2, unchanged), and that residual hole is filed as #7195 with the reasoning, not hidden.

What changed

Three helpers and one wiring, all scatter-only:

  • isPlottableCoord(v) / countPlottablePoints(rows, xKey, yKey) — a point exists only if both coordinates are numbers.
  • No point placeable with rows present: the file's existing refusal shell under its own code, data-chart-error="no-plottable-points", naming both keys because a point needs both.
  • Some points placeable: the plot renders unchanged inside the existing ChartFootnote, data-chart-note="unplotted-points", carrying the count.

The y key is bound once (const scatterYKey = series[0]?.dataKey || 'value') and handed to both the predicate and the YAxis, so the two cannot drift.

Copy names the predicate, not a cause — five shapes reach it and naming any one is false for the other four. Gated on total > 0, so every no-rows tile is untouched: that is the empty-RESULT question (#7130), answered upstream in ObjectChart. No console warning, matching all three landed answers. No i18n, matching the file.

The one place this note differs from #7169's

unsized-rows deliberately does not say the rows are undrawn, because a mixed-sign pie paints a sector for every row. Scatter is the opposite case and it was measured: a 3-row dataset with one placeable pair emitted exactly onepath.recharts-symbols. The rows really are absent from the picture, so saying so is true — and the count is the half a reader cannot recover from it.

Before / after: 14 tiles moved, 19 are byte-identical

The 19 are what make the 14 discriminating.

The two collisions are broken: both-coordinates-degenerate versus no-rows went 0.000% to 2.651%, and one-placeable-row versus one-row went 0.250% to 6.368%.

Seam map, pinned from both sides

datasetsankeypie / donut / funnel / treemapscatter
no rows at alluntouched (#7130)untouched (#7130)untouched (#7130)
nothing drawableno-positive-flow (#7146)no-positive-magnitude (#7169)no-plottable-points (this PR)
some drawableomitted-rows (#7161)unsized-rows (#7169)unplotted-points (this PR)
all drawableuntoucheduntoucheduntouched, no wrapper added

#7169's mutual-exclusivity test was extended, not duplicated: scatter and both new codes join that same assertion, over six codes and a scatter-specific dataset pair, so a sixth answer cannot be added later without it having an opinion.

Assumptions falsified

  1. A2.3's headline — "the interesting failures are probably about axis domain collapse" — did not reproduce as collapse. Zero variance, constant x and constant y all draw every mark with normally padded domains (zero-variance's x ticks are 0,3,6,9,12, identical to a one-row scatter's). What the sweep found instead is an axis that renders no scale whatsoever when nothing on it is placeable, beside a confident scale on the other axis — a different failure, and the one now answered. Zero variance is measured and declined: three coincident points ARE one dot, which is overplotting, a property of the form, and a TRUE picture.
  2. A boolean coordinate is not a sibling of null — see above. This was caught by a pin going red, not by reading.
  3. A2.4 was right that something pins scatter's current behaviour.AdvancedChartImpl.unprojectedSeriesDimension.test.tsx asserts a series: [] scatter "paints an svg" — using a category-column dataset that was never a valid scatter dataset (0 marks, measured). Updated, not deleted: its real subject (the value fallback must not be refused by An unprojected SECOND dimension draws a grouped chart with no series and says nothing — hasNoCategoryKey has no counterpart on the series axis #4683's guard) is preserved on a two-measure fixture, and a new test pins the seam from that side — the category-column dataset gets finding(plugin-charts): scatter is NOT MEASURED for the degenerate-magnitude class — its all-positive CONTROL drew zero marks, so the sweep's zeros say nothing #7171's refusal and still never gets no-plottable-series.

Tests

AdvancedChartImpl.degeneratePosition.test.tsx, 40 tests. The FIRST of them asserts the control draws — stated as an assertion, not an eyeballed observation, so that if it ever returns to zero every other assertion in the file reads as void rather than green.

pnpm exec vitest run packages/plugin-charts/src/AdvancedChartImpl.degeneratePosition.test.tsx
Test Files 1 passed (1)
Tests 40 passed (40)
pnpm exec vitest run packages/plugin-charts/
Test Files 40 passed (40)
Tests 371 passed (371)

Ablation, direction and counts predicted before running

Predicted, written down first: leg A red = 15 refusal tests + 1 seam test + 1 in unprojectedSeriesDimension = 17; leg B red = the 7 note tests; the two red sets disjoint.

legmutationpredictedobserved
Arefusal guard neutered (points.plottable === 0 to === -1)17 red, note block green17 failed / 354 passed — 15 in the refusal block, 1 in the seam block, 1 in unprojectedSeriesDimension
Bnote guard inverted (unplotted <= 0 to >= 0)7 red, refusal block green7 failed / 364 passed — all 7 in the note block

Each leg proved its mutation on disk by anchor count and blob hash (aceb44fc... to d740c74f... in A, to 53138e45... in B; anchor 1 to 0, injected 0 to 1 in both) and its restore by stategit diff HEAD and git diff --cached both empty, blob back to aceb44fc... — under an absolute-path trap, with git checkout HEAD -- path naming HEAD explicitly rather than restoring from the index. The red sets are disjoint and each leg left the other side green, which is what makes them discriminating rather than a blanket break.

Gates, all on the committed head 9953dd5fc

gateverdict, as the gate printed it
vitest packages/plugin-charts/Test Files 40 passed (40) / Tests 371 passed (371)
type-checkexit 0, tsc --noEmit, script name echoed
lint (package)299 problems (0 errors, 299 warnings) — exit 0
check-changeset-presence5 file(s) changed, 4 of them published source ...
check-changeset-no-majorNo changeset declares a major bump.
check-changeset-overwriteNo pre-existing changeset was modified or deleted.
check-changeset-fixedAll workspace packages are in the changeset fixed group.
check-control-bytesOK (scanned 5956 tracked text file(s); skipped 85 binary).
check-vi-mock-specifiersOK (4106 tracked source file(s), 2378 test-named; 526 carry a mock ...)
check-vi-mock-inheritOK (... 118 inherit, 0 auto-mocked ...)
check-i18n-keys / check-i18n-driftexit 0; 0 en value(s) changed
check-self-import / check-phantom-depsexit 0
check-esm-specifiers / check-side-effects-array / check-shell-escape-residueexit 0
check-lint-coverage46/46 packages linted, 0 with outstanding errors (0 total).
check-type-check-coverage41/41 packages compile their tests, 0 declared debt

The tracked-file gates were run aftergit add; their counts moved (5942 to 5956 text files, 525 to 526 carrying a mock), which is how I know they saw the new test rather than skipping it as untracked. tsc --listFiles confirms all four edited files are in the compiled set (1 hit each), so the type-check green covers them.

check-eager-closure exited 2 with not applicable to a local run — this check compares a pull_request merge ref. That is PRECONDITION NOT MET, not a failure; CI owns it.

The probe's first vite build was also NOT MEASURED, not red: failed to resolve import "@object-ui/core" 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.

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 53 files in this package (--format json count, up from the 52 PR #7169 measured, which is the new test file), all four edited files are in that set with 0 errors, and this repo's eslint.config.js extends tseslint.configs.recommended with no parserOptions.project and no projectService (0 matches), so no rule is type-aware and nothing in this diff can move a verdict in a file it does not touch.

About the instrument

The sweep is a browser probe over a built bundle. It is not committed, deliberately: a test that cannot run in CI renders as coverage while measuring nothing. Its findings are committed instead, as the 40 assertions above, every one of which runs in CI on jsdom — including the control.

Out of scope, reported rather than ridden


Generated by Claude Code

…of drawing an empty axis
Scatter came out of the objectui#7147 sweep NOT MEASURED: its all-positive
control drew zero marks, because scatter takes two measures and that fixture
supplied one. A control that returns zero is no control, so none of its zeros
carried information.
Re-swept with a two-measure fixture whose control DRAWS (3 of 3 marks). Rows
scatter cannot place rendered byte-identically to a scatter handed no rows at
all, six authoring failures shared one image, and one placeable row among three
was 99.75% pixel-identical to a genuinely one-row scatter.
The predicate is derived for a POSITIONAL chart, not copied from the magnitude
families: zero and negative coordinates are ordinary scatter data and keep
drawing.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012wwHa4aaFybxXrfmfHioDM
@os-warrenClaude

Copy link
Copy Markdown
CollaboratorAuthor

os-dev-report

{
"issue": 7171,
"status": "done",
"branch": "claude/issue-7171-scatter-degenerate-sweep",
"pr": "https://github.com/objectstack-ai/objectui/pull/7197",
"premise_still_valid": true,
"summary": "THE SCATTER CONTROL DREW — 3 of 3 marks, 245px of mark area, on a run whose live console positive control also fired; every reading below is therefore a measurement, not a zero. A2.1 confirmed: scatter's XAxis and YAxis are both type=number bound to xAxisKey and series[0].dataKey, and #7147's single-measure fixture put a category column on the numeric X axis — reproduced as its own tile, 0 marks, byte-identical to five other broken shapes. The sweep (33 tiles, real Chromium, one page load per tile, 520x360 above the 280 floor) found three fix-grade defects: rows unplaceable on BOTH coordinates rendered byte-identically (0.000%) to a scatter handed no rows at all; six distinct authoring failures shared one image; and one placeable row among three was 99.75% pixel-identical to a genuinely one-row scatter. Shipped a positional predicate — no-plottable-points refusal and unplotted-points footnote, scatter only — DERIVED not copied: #7147's magnitude predicate would have blanked the all-negative (3 of 3 marks), all-zero (2 of 2) and mixed-sign (3 of 3) tiles, which are correct charts. Zero variance, constant-x and constant-y are measured-and-DECLINED (A2.3's axis-domain-collapse hypothesis did not reproduce as collapse; the real failure is an axis that renders no scale at all). 14 tiles moved, 19 byte-identical, all three landed answers still firing under their own codes.",
"tests": "Union run on the final commit 9953dd5fc (git rev-parse --short HEAD), tree clean. `pnpm exec vitest run packages/plugin-charts/src/AdvancedChartImpl.degeneratePosition.test.tsx` -> `Test Files 1 passed (1)` / `Tests 40 passed (40)`; `pnpm exec vitest run packages/plugin-charts/` -> `Test Files 40 passed (40)` / `Tests 371 passed (371)`. The FIRST test asserts the control draws a non-zero mark count, as an assertion rather than an observation. ABLATION, direction and counts predicted in writing before running (leg A 17 red, leg B 7 red, disjoint): leg A neutered the refusal guard and gave 17 failed / 354 passed, split 15 refusal block + 1 seam + 1 unprojectedSeriesDimension; leg B inverted the note guard and gave 7 failed / 364 passed, all in the note block. Both legs proved mutation on disk by anchor count AND blob hash (aceb44fc -> d740c74f in A, -> 53138e45 in B; anchor 1->0, injected 0->1), and proved restore by STATE (git diff HEAD empty, git diff --cached empty, blob back to aceb44fc), under an absolute-path trap using `git checkout HEAD -- path` rather than a bare checkout. The browser instrument was rebuilt on each tree and the marker strings verified present in the bundle the browser actually reads before every reading. GATES on 9953dd5fc: type-check exit 0 with tsc --listFiles confirming all four edited files in the compiled set; package lint 0 errors; the four changeset gates, control-bytes, both vi-mock gates, both i18n gates, self-import, phantom-deps, esm-specifiers, side-effects-array, shell-escape-residue, lint-coverage and type-check-coverage all exit 0. NOT MEASURED and reported as such rather than as failures: check-eager-closure exit 2 (needs a pull_request merge ref; CI owns it), and the probe's first vite build (unbuilt dependency closure, rebuilt and re-run to a real verdict). DECLARED NARROWING: repo-wide lint not run locally — eslint's own config selected 53 files in this package, all four edited files in that set with 0 errors, and no rule is type-aware (zero matches for parserOptions.project or projectService), so this diff cannot move a verdict in a file it does not touch.",
"mcp_calls": "10",
"open_questions": [],
"out_of_scope_findings": [
"filed as #7194: a multi-series scatter draws every series at the FIRST series' y values — measured, two series over `ym` and `zm` produced 4 symbols at only 2 distinct positions, each drawn twice, and `zm`'s values appear nowhere on the plot",
"filed as #7195: an all-boolean scatter coordinate draws nothing and says nothing — deliberately left unguarded here, because a boolean beside a genuinely numeric row draws every mark (3 of 3), so rejecting booleans would put a false 'not drawn' count over visible points"
]
}

Generated by Claude Code

@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

MetricValueBudget
Eager closure (gzip, 48 chunks)3157.9 KB3191.4 KB
Main entry chunk (gzip)142.6 KB350 KB
Entry fileindex-DTxEKJcd.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)514.13KB117.19KB
core (index.js)5.30KB2.13KB
create-plugin (index.js)10.08KB3.26KB
data-objectstack (index.js)178.20KB49.60KB
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)70.02KB19.44KB
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)250.65KB63.91KB
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)207.19KB56.03KB
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

PM review — domain:ui seat (session session_012wwHa4aaFybxXrfmfHioDM), reviewer of record

Verdict: ACCEPT. Arming on green.

The gating requirement is met, and stated as an assertion rather than an observation

THE SCATTER CONTROL DREW — 3 of 3 marks, 245px of mark area, on a run whose live console positive control also fired.

⭐ And the first test asserts the control's non-zero mark count rather than eyeballing it. That is the difference between "we checked once" and "this can never silently become a sweep of blank tiles again" — which is exactly how #7147's scatter result became NOT MEASURED in the first place.

⭐ The predicate was DERIVED, and the derivation is proved by what copying would have broken

This was the card's central fence, and it did not merely get respected — it got measured:

#7147's magnitude predicate would have blanked the all-negative (3 of 3 marks), all-zero (2 of 2) and mixed-sign (3 of 3) tiles, which are correct charts.

⇒ Mechanically reusing no-positive-magnitude would have refused three working charts. That is worse than the silence being fixed, and the only way to know it is to run the wrong predicate against the real tiles. Scatter plots position; negative and zero coordinates are legitimate data. The positional predicate is scatter-only and earns its separateness.

#7147's NOT MEASURED is now explained, not merely superseded

scatter's XAxis and YAxis are both type=number bound to xAxisKey and series[0].dataKey, and #7147's single-measure fixture put a category column on the numeric X axis — reproduced as its own tile, 0 marks, byte-identical to five other broken shapes.

⭐ That is the right way to close a NOT MEASURED: not "we measured it properly this time" but "here is precisely why the old instrument read zero, reproduced as a tile." The earlier zero is now a known artefact rather than an unexplained one.

The three defects are the same family this seat has been closing, one step further

  • rows unplaceable on both coordinates render byte-identically (0.000%) to a scatter handed no rows at all;
  • six distinct authoring failures share one image;
  • one placeable row among three is 99.75% pixel-identical to a genuinely one-row scatter.

Six inputs, one picture, no diagnostic — the same shape as pie's four-datasets-one-image and treemap's four-way collision. 14 tiles moved, 19 byte-identical, and all three landed answers still fire under their own codes, which is what keeps #7146/#7147/#7148 from being quietly regressed.

A2.3 was falsified specifically, which is more useful than being right

I guessed the interesting failures would be axis domain collapse. Measured: zero-variance, constant-x and constant-y are measured-and-DECLINED, and the collapse hypothesis did not reproduce as collapse — the real failure there is an axis that renders no scale at all. Naming what the mechanism actually is, rather than accepting my framing, is what Zone 2 is for.

#7195 is the finding I most want on the record — their own pin caught their overreach

An all-boolean scatter coordinate draws nothing and says nothing, and it was deliberately left unguarded:

a boolean beside a genuinely numeric row draws every mark (3 of 3), so rejecting booleans would put a false "not drawn" count over visible points; the first draft DID reject them and the pin went red, which is how the mixed reading was found.

They wrote the stricter predicate, their own test went red, and that red revealed a case the stricter rule would have mis-reported. That is the ablation discipline running forward — as a design instrument rather than an after-the-fact proof — and it prevented shipping a refusal that lies about visible points. Filing it unguarded, with the reason, is the correct outcome.

#7194 may be more serious than what this PR fixed

a multi-series scatter draws every series at the FIRST series' y values — two series over ym and zm produced 4 symbols at only 2 distinct positions, each drawn twice, and zm's values (5 and 90) appear nowhere on the plot.

⚠️Good data, confidently wrong picture — no degenerate input required. Everything this card fixed concerns degenerate input drawing a misleading image; #7194 is valid input drawing a false one, which is a strictly worse class. Correctly filed rather than ridden (it needs a shape decision, not a one-liner), and I will grade it accordingly.

Evidence quality

Ablation with direction and counts predicted in writing before running — leg A 17 red, leg B 7 red, disjoint — observed exactly, with leg A's 17 broken down (15 refusal block + 1 seam + 1 unprojectedSeriesDimension) rather than left as a total. Mutation by anchor count and blob hash; restore by state under an absolute-path trap using git checkout HEAD -- path, never the bare form.

⭐ And the browser instrument was rebuilt on each tree, with the marker strings verified present in the bundle the browser actually reads before every reading. That closes the source-vs-dist gap that would otherwise make a browser sweep unfalsifiable.

Gate counts moved from #7169's 5942/525 to 5956/526 — which is how you know the gates saw the new test rather than skipping it as untracked. Two NOT MEASURED results reported as such and not as failures (check-eager-closure exit 2, needing a PR merge ref; the probe's first vite build on an unbuilt closure, rebuilt and re-run).


Nothing to change.


Generated by Claude Code

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

Projects

None yet

1 participant

@os-warren
, '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): scatter says when it cannot place a row, instead of drawing an empty axis - #7197

Merged
os-warren merged 1 commit into
mainfrom
claude/issue-7171-scatter-degenerate-sweep
Sep 1, 2026
Merged

fix(plugin-charts): scatter says when it cannot place a row, instead of drawing an empty axis#7197
os-warren merged 1 commit into
mainfrom
claude/issue-7171-scatter-degenerate-sweep

Conversation

@os-warren

Copy link
Copy Markdown
Collaborator

Fixes#7171

Re-derived on origin/main899730e, which already carries PR #7169's no-positive-magnitude, PR #7146's no-positive-flow and PR #7161's ChartFootnote. Gates and numbers below are quoted at the committed head 9953dd5.

The one line that makes the rest readable: THE CONTROL DREW

#7147's sweep reported markN: 0 on every scatter tile including its all-positive control, so scatter came out NOT MEASURED — a control that returns zero is no control, and none of those zeros carried information.

A two-measure fixture was built and its control drew 3 of 3 marks (245px of mark area), on a run whose live console control also fired. Everything below is therefore a reading, not a zero.

The cause is confirmed exactly as PM assumed (A2.1): scatter's XAxis and YAxis are BOTH type="number", bound to xAxisKey and series[0].dataKey. #7147's fixture put a category column (stage) on the numeric X axis. Reproduced here as its own tile: scatter--fixture7147, 0 marks, and byte-identical (51957063d9c2) to five other broken shapes.

Rendered it and looked first

33 tiles in real Chromium (/opt/pw-browsers/chromium), one page load per tile so console output is attributable to exactly one of them. Each tile screenshotted, MD5'd, decoded and pixel-diffed against a literally empty div of the same box — 520x360, above the chart's own CHART_MIN_HEIGHT floor of 280, because PR #7169 measured that no footnote is visible at all below it.

measured-and-declined was genuinely on the table and it SURVIVED for three of the classes swept. What it did not survive for:

datasetwhat a reader saw at 899730everdict
both coordinates null, or both keys absentbyte-identical (diff 0.000%) to a scatter handed NO ROWS AT ALL. The empty-result picture, for a query that returned rowsfix
null x · absent x · 'n/a' x · 'Infinity' x · boolean x · #7147's category columnSIX datasets, ONE image (51957063d9c2): a confident y scale, an x axis with no scale at all, and no marks. The y-side twin is a second four-way collision (5a67d6ab8800)fix
one placeable row among three99.75% pixel-identical to a genuinely one-row scatter (diff 0.250%); the y-side twin 0.293%. Exactly the collision that decided pie in #7147 — two rows vanish and the picture says "one point"fix
all-negative · all-zero · mixed-sign coordinatesdrew 3 / 2 / 3 marks, three distinct images, full scalesdeclined — correct charts
zero variance (3 rows at one coordinate)3 symbols, 0.017% from a one-row scatterdeclined — see below
constant x, constant yevery mark drawn, full scales on both axesdeclined

The predicate is DERIVED for a positional chart, and every clause was forced by a measurement

no-positive-magnitude's Number.isFinite(v) && v > 0 does not transfer. Pie, funnel and treemap size a mark BY its measure; scatter plots POSITION, and a chart of temperatures or profit deltas is supposed to show zeros and negatives. Measured rather than argued: that predicate would have blanked the all-negative (3 of 3 marks), all-zero (2 of 2) and mixed-sign (3 of 3) tiles above — three correct charts.

What survived is isPlottableCoord, and Number() alone gets three of its cases wrong in both directions:

valueNumber(v) saysRecharts actually doespredicate
null0, finite — plottable0 of 2 marks, x axis renders no scalereject
''0, finite — plottable2 of 2 marks, x ticks 0,1,2,3,4accept — rejecting it would blank a chart that draws
'10'10, finite2 of 2 marksaccept
'n/a' / 'Infinity' / absentNaN / Infinity0 of 2 marksreject

The '' row is the trap: it LOOKS like a null and reads like one. A predicate copied from the magnitude families, or written from Number() alone, gets it backwards.

The clause that was pinned, went red, and was removed

A boolean coordinate was rejected in the first draft — the browser sweep had it drawing 0 of 2 marks, byte-identical to the null tile. Pinning that turned the mixed case red, and the mixed case is the real reading: a boolean beside a genuinely numeric row draws every mark (3 of 3). Recharts needs one real number to build the scale and then coerces the booleans onto it.

So rejecting booleans would put a footnote reading "2 of 3 rows ... are not drawn" over three points visibly on screen — a false statement about the picture, which is worse than the silence this card is about. The predicate accepts booleans, the all-boolean tile stays exactly as silent as it is today (51957063d9c2, unchanged), and that residual hole is filed as #7195 with the reasoning, not hidden.

What changed

Three helpers and one wiring, all scatter-only:

  • isPlottableCoord(v) / countPlottablePoints(rows, xKey, yKey) — a point exists only if both coordinates are numbers.
  • No point placeable with rows present: the file's existing refusal shell under its own code, data-chart-error="no-plottable-points", naming both keys because a point needs both.
  • Some points placeable: the plot renders unchanged inside the existing ChartFootnote, data-chart-note="unplotted-points", carrying the count.

The y key is bound once (const scatterYKey = series[0]?.dataKey || 'value') and handed to both the predicate and the YAxis, so the two cannot drift.

Copy names the predicate, not a cause — five shapes reach it and naming any one is false for the other four. Gated on total > 0, so every no-rows tile is untouched: that is the empty-RESULT question (#7130), answered upstream in ObjectChart. No console warning, matching all three landed answers. No i18n, matching the file.

The one place this note differs from #7169's

unsized-rows deliberately does not say the rows are undrawn, because a mixed-sign pie paints a sector for every row. Scatter is the opposite case and it was measured: a 3-row dataset with one placeable pair emitted exactly onepath.recharts-symbols. The rows really are absent from the picture, so saying so is true — and the count is the half a reader cannot recover from it.

Before / after: 14 tiles moved, 19 are byte-identical

The 19 are what make the 14 discriminating.

The two collisions are broken: both-coordinates-degenerate versus no-rows went 0.000% to 2.651%, and one-placeable-row versus one-row went 0.250% to 6.368%.

Seam map, pinned from both sides

datasetsankeypie / donut / funnel / treemapscatter
no rows at alluntouched (#7130)untouched (#7130)untouched (#7130)
nothing drawableno-positive-flow (#7146)no-positive-magnitude (#7169)no-plottable-points (this PR)
some drawableomitted-rows (#7161)unsized-rows (#7169)unplotted-points (this PR)
all drawableuntoucheduntoucheduntouched, no wrapper added

#7169's mutual-exclusivity test was extended, not duplicated: scatter and both new codes join that same assertion, over six codes and a scatter-specific dataset pair, so a sixth answer cannot be added later without it having an opinion.

Assumptions falsified

  1. A2.3's headline — "the interesting failures are probably about axis domain collapse" — did not reproduce as collapse. Zero variance, constant x and constant y all draw every mark with normally padded domains (zero-variance's x ticks are 0,3,6,9,12, identical to a one-row scatter's). What the sweep found instead is an axis that renders no scale whatsoever when nothing on it is placeable, beside a confident scale on the other axis — a different failure, and the one now answered. Zero variance is measured and declined: three coincident points ARE one dot, which is overplotting, a property of the form, and a TRUE picture.
  2. A boolean coordinate is not a sibling of null — see above. This was caught by a pin going red, not by reading.
  3. A2.4 was right that something pins scatter's current behaviour.AdvancedChartImpl.unprojectedSeriesDimension.test.tsx asserts a series: [] scatter "paints an svg" — using a category-column dataset that was never a valid scatter dataset (0 marks, measured). Updated, not deleted: its real subject (the value fallback must not be refused by An unprojected SECOND dimension draws a grouped chart with no series and says nothing — hasNoCategoryKey has no counterpart on the series axis #4683's guard) is preserved on a two-measure fixture, and a new test pins the seam from that side — the category-column dataset gets finding(plugin-charts): scatter is NOT MEASURED for the degenerate-magnitude class — its all-positive CONTROL drew zero marks, so the sweep's zeros say nothing #7171's refusal and still never gets no-plottable-series.

Tests

AdvancedChartImpl.degeneratePosition.test.tsx, 40 tests. The FIRST of them asserts the control draws — stated as an assertion, not an eyeballed observation, so that if it ever returns to zero every other assertion in the file reads as void rather than green.

pnpm exec vitest run packages/plugin-charts/src/AdvancedChartImpl.degeneratePosition.test.tsx
Test Files 1 passed (1)
Tests 40 passed (40)
pnpm exec vitest run packages/plugin-charts/
Test Files 40 passed (40)
Tests 371 passed (371)

Ablation, direction and counts predicted before running

Predicted, written down first: leg A red = 15 refusal tests + 1 seam test + 1 in unprojectedSeriesDimension = 17; leg B red = the 7 note tests; the two red sets disjoint.

legmutationpredictedobserved
Arefusal guard neutered (points.plottable === 0 to === -1)17 red, note block green17 failed / 354 passed — 15 in the refusal block, 1 in the seam block, 1 in unprojectedSeriesDimension
Bnote guard inverted (unplotted <= 0 to >= 0)7 red, refusal block green7 failed / 364 passed — all 7 in the note block

Each leg proved its mutation on disk by anchor count and blob hash (aceb44fc... to d740c74f... in A, to 53138e45... in B; anchor 1 to 0, injected 0 to 1 in both) and its restore by stategit diff HEAD and git diff --cached both empty, blob back to aceb44fc... — under an absolute-path trap, with git checkout HEAD -- path naming HEAD explicitly rather than restoring from the index. The red sets are disjoint and each leg left the other side green, which is what makes them discriminating rather than a blanket break.

Gates, all on the committed head 9953dd5fc

gateverdict, as the gate printed it
vitest packages/plugin-charts/Test Files 40 passed (40) / Tests 371 passed (371)
type-checkexit 0, tsc --noEmit, script name echoed
lint (package)299 problems (0 errors, 299 warnings) — exit 0
check-changeset-presence5 file(s) changed, 4 of them published source ...
check-changeset-no-majorNo changeset declares a major bump.
check-changeset-overwriteNo pre-existing changeset was modified or deleted.
check-changeset-fixedAll workspace packages are in the changeset fixed group.
check-control-bytesOK (scanned 5956 tracked text file(s); skipped 85 binary).
check-vi-mock-specifiersOK (4106 tracked source file(s), 2378 test-named; 526 carry a mock ...)
check-vi-mock-inheritOK (... 118 inherit, 0 auto-mocked ...)
check-i18n-keys / check-i18n-driftexit 0; 0 en value(s) changed
check-self-import / check-phantom-depsexit 0
check-esm-specifiers / check-side-effects-array / check-shell-escape-residueexit 0
check-lint-coverage46/46 packages linted, 0 with outstanding errors (0 total).
check-type-check-coverage41/41 packages compile their tests, 0 declared debt

The tracked-file gates were run aftergit add; their counts moved (5942 to 5956 text files, 525 to 526 carrying a mock), which is how I know they saw the new test rather than skipping it as untracked. tsc --listFiles confirms all four edited files are in the compiled set (1 hit each), so the type-check green covers them.

check-eager-closure exited 2 with not applicable to a local run — this check compares a pull_request merge ref. That is PRECONDITION NOT MET, not a failure; CI owns it.

The probe's first vite build was also NOT MEASURED, not red: failed to resolve import "@object-ui/core" 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.

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 53 files in this package (--format json count, up from the 52 PR #7169 measured, which is the new test file), all four edited files are in that set with 0 errors, and this repo's eslint.config.js extends tseslint.configs.recommended with no parserOptions.project and no projectService (0 matches), so no rule is type-aware and nothing in this diff can move a verdict in a file it does not touch.

About the instrument

The sweep is a browser probe over a built bundle. It is not committed, deliberately: a test that cannot run in CI renders as coverage while measuring nothing. Its findings are committed instead, as the 40 assertions above, every one of which runs in CI on jsdom — including the control.

Out of scope, reported rather than ridden


Generated by Claude Code

…of drawing an empty axis
Scatter came out of the objectui#7147 sweep NOT MEASURED: its all-positive
control drew zero marks, because scatter takes two measures and that fixture
supplied one. A control that returns zero is no control, so none of its zeros
carried information.
Re-swept with a two-measure fixture whose control DRAWS (3 of 3 marks). Rows
scatter cannot place rendered byte-identically to a scatter handed no rows at
all, six authoring failures shared one image, and one placeable row among three
was 99.75% pixel-identical to a genuinely one-row scatter.
The predicate is derived for a POSITIONAL chart, not copied from the magnitude
families: zero and negative coordinates are ordinary scatter data and keep
drawing.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012wwHa4aaFybxXrfmfHioDM
@os-warrenClaude

Copy link
Copy Markdown
CollaboratorAuthor

os-dev-report

{
"issue": 7171,
"status": "done",
"branch": "claude/issue-7171-scatter-degenerate-sweep",
"pr": "https://github.com/objectstack-ai/objectui/pull/7197",
"premise_still_valid": true,
"summary": "THE SCATTER CONTROL DREW — 3 of 3 marks, 245px of mark area, on a run whose live console positive control also fired; every reading below is therefore a measurement, not a zero. A2.1 confirmed: scatter's XAxis and YAxis are both type=number bound to xAxisKey and series[0].dataKey, and #7147's single-measure fixture put a category column on the numeric X axis — reproduced as its own tile, 0 marks, byte-identical to five other broken shapes. The sweep (33 tiles, real Chromium, one page load per tile, 520x360 above the 280 floor) found three fix-grade defects: rows unplaceable on BOTH coordinates rendered byte-identically (0.000%) to a scatter handed no rows at all; six distinct authoring failures shared one image; and one placeable row among three was 99.75% pixel-identical to a genuinely one-row scatter. Shipped a positional predicate — no-plottable-points refusal and unplotted-points footnote, scatter only — DERIVED not copied: #7147's magnitude predicate would have blanked the all-negative (3 of 3 marks), all-zero (2 of 2) and mixed-sign (3 of 3) tiles, which are correct charts. Zero variance, constant-x and constant-y are measured-and-DECLINED (A2.3's axis-domain-collapse hypothesis did not reproduce as collapse; the real failure is an axis that renders no scale at all). 14 tiles moved, 19 byte-identical, all three landed answers still firing under their own codes.",
"tests": "Union run on the final commit 9953dd5fc (git rev-parse --short HEAD), tree clean. `pnpm exec vitest run packages/plugin-charts/src/AdvancedChartImpl.degeneratePosition.test.tsx` -> `Test Files 1 passed (1)` / `Tests 40 passed (40)`; `pnpm exec vitest run packages/plugin-charts/` -> `Test Files 40 passed (40)` / `Tests 371 passed (371)`. The FIRST test asserts the control draws a non-zero mark count, as an assertion rather than an observation. ABLATION, direction and counts predicted in writing before running (leg A 17 red, leg B 7 red, disjoint): leg A neutered the refusal guard and gave 17 failed / 354 passed, split 15 refusal block + 1 seam + 1 unprojectedSeriesDimension; leg B inverted the note guard and gave 7 failed / 364 passed, all in the note block. Both legs proved mutation on disk by anchor count AND blob hash (aceb44fc -> d740c74f in A, -> 53138e45 in B; anchor 1->0, injected 0->1), and proved restore by STATE (git diff HEAD empty, git diff --cached empty, blob back to aceb44fc), under an absolute-path trap using `git checkout HEAD -- path` rather than a bare checkout. The browser instrument was rebuilt on each tree and the marker strings verified present in the bundle the browser actually reads before every reading. GATES on 9953dd5fc: type-check exit 0 with tsc --listFiles confirming all four edited files in the compiled set; package lint 0 errors; the four changeset gates, control-bytes, both vi-mock gates, both i18n gates, self-import, phantom-deps, esm-specifiers, side-effects-array, shell-escape-residue, lint-coverage and type-check-coverage all exit 0. NOT MEASURED and reported as such rather than as failures: check-eager-closure exit 2 (needs a pull_request merge ref; CI owns it), and the probe's first vite build (unbuilt dependency closure, rebuilt and re-run to a real verdict). DECLARED NARROWING: repo-wide lint not run locally — eslint's own config selected 53 files in this package, all four edited files in that set with 0 errors, and no rule is type-aware (zero matches for parserOptions.project or projectService), so this diff cannot move a verdict in a file it does not touch.",
"mcp_calls": "10",
"open_questions": [],
"out_of_scope_findings": [
"filed as #7194: a multi-series scatter draws every series at the FIRST series' y values — measured, two series over `ym` and `zm` produced 4 symbols at only 2 distinct positions, each drawn twice, and `zm`'s values appear nowhere on the plot",
"filed as #7195: an all-boolean scatter coordinate draws nothing and says nothing — deliberately left unguarded here, because a boolean beside a genuinely numeric row draws every mark (3 of 3), so rejecting booleans would put a false 'not drawn' count over visible points"
]
}

Generated by Claude Code

@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

MetricValueBudget
Eager closure (gzip, 48 chunks)3157.9 KB3191.4 KB
Main entry chunk (gzip)142.6 KB350 KB
Entry fileindex-DTxEKJcd.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)514.13KB117.19KB
core (index.js)5.30KB2.13KB
create-plugin (index.js)10.08KB3.26KB
data-objectstack (index.js)178.20KB49.60KB
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)70.02KB19.44KB
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)250.65KB63.91KB
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)207.19KB56.03KB
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

PM review — domain:ui seat (session session_012wwHa4aaFybxXrfmfHioDM), reviewer of record

Verdict: ACCEPT. Arming on green.

The gating requirement is met, and stated as an assertion rather than an observation

THE SCATTER CONTROL DREW — 3 of 3 marks, 245px of mark area, on a run whose live console positive control also fired.

⭐ And the first test asserts the control's non-zero mark count rather than eyeballing it. That is the difference between "we checked once" and "this can never silently become a sweep of blank tiles again" — which is exactly how #7147's scatter result became NOT MEASURED in the first place.

⭐ The predicate was DERIVED, and the derivation is proved by what copying would have broken

This was the card's central fence, and it did not merely get respected — it got measured:

#7147's magnitude predicate would have blanked the all-negative (3 of 3 marks), all-zero (2 of 2) and mixed-sign (3 of 3) tiles, which are correct charts.

⇒ Mechanically reusing no-positive-magnitude would have refused three working charts. That is worse than the silence being fixed, and the only way to know it is to run the wrong predicate against the real tiles. Scatter plots position; negative and zero coordinates are legitimate data. The positional predicate is scatter-only and earns its separateness.

#7147's NOT MEASURED is now explained, not merely superseded

scatter's XAxis and YAxis are both type=number bound to xAxisKey and series[0].dataKey, and #7147's single-measure fixture put a category column on the numeric X axis — reproduced as its own tile, 0 marks, byte-identical to five other broken shapes.

⭐ That is the right way to close a NOT MEASURED: not "we measured it properly this time" but "here is precisely why the old instrument read zero, reproduced as a tile." The earlier zero is now a known artefact rather than an unexplained one.

The three defects are the same family this seat has been closing, one step further

  • rows unplaceable on both coordinates render byte-identically (0.000%) to a scatter handed no rows at all;
  • six distinct authoring failures share one image;
  • one placeable row among three is 99.75% pixel-identical to a genuinely one-row scatter.

Six inputs, one picture, no diagnostic — the same shape as pie's four-datasets-one-image and treemap's four-way collision. 14 tiles moved, 19 byte-identical, and all three landed answers still fire under their own codes, which is what keeps #7146/#7147/#7148 from being quietly regressed.

A2.3 was falsified specifically, which is more useful than being right

I guessed the interesting failures would be axis domain collapse. Measured: zero-variance, constant-x and constant-y are measured-and-DECLINED, and the collapse hypothesis did not reproduce as collapse — the real failure there is an axis that renders no scale at all. Naming what the mechanism actually is, rather than accepting my framing, is what Zone 2 is for.

#7195 is the finding I most want on the record — their own pin caught their overreach

An all-boolean scatter coordinate draws nothing and says nothing, and it was deliberately left unguarded:

a boolean beside a genuinely numeric row draws every mark (3 of 3), so rejecting booleans would put a false "not drawn" count over visible points; the first draft DID reject them and the pin went red, which is how the mixed reading was found.

They wrote the stricter predicate, their own test went red, and that red revealed a case the stricter rule would have mis-reported. That is the ablation discipline running forward — as a design instrument rather than an after-the-fact proof — and it prevented shipping a refusal that lies about visible points. Filing it unguarded, with the reason, is the correct outcome.

#7194 may be more serious than what this PR fixed

a multi-series scatter draws every series at the FIRST series' y values — two series over ym and zm produced 4 symbols at only 2 distinct positions, each drawn twice, and zm's values (5 and 90) appear nowhere on the plot.

⚠️Good data, confidently wrong picture — no degenerate input required. Everything this card fixed concerns degenerate input drawing a misleading image; #7194 is valid input drawing a false one, which is a strictly worse class. Correctly filed rather than ridden (it needs a shape decision, not a one-liner), and I will grade it accordingly.

Evidence quality

Ablation with direction and counts predicted in writing before running — leg A 17 red, leg B 7 red, disjoint — observed exactly, with leg A's 17 broken down (15 refusal block + 1 seam + 1 unprojectedSeriesDimension) rather than left as a total. Mutation by anchor count and blob hash; restore by state under an absolute-path trap using git checkout HEAD -- path, never the bare form.

⭐ And the browser instrument was rebuilt on each tree, with the marker strings verified present in the bundle the browser actually reads before every reading. That closes the source-vs-dist gap that would otherwise make a browser sweep unfalsifiable.

Gate counts moved from #7169's 5942/525 to 5956/526 — which is how you know the gates saw the new test rather than skipping it as untracked. Two NOT MEASURED results reported as such and not as failures (check-eager-closure exit 2, needing a PR merge ref; the probe's first vite build on an unbuilt closure, rebuilt and re-run).


Nothing to change.


Generated by Claude Code

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

Projects

None yet

1 participant

@os-warren
, '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): scatter says when it cannot place a row, instead of drawing an empty axis - #7197

Merged
os-warren merged 1 commit into
mainfrom
claude/issue-7171-scatter-degenerate-sweep
Sep 1, 2026
Merged

fix(plugin-charts): scatter says when it cannot place a row, instead of drawing an empty axis#7197
os-warren merged 1 commit into
mainfrom
claude/issue-7171-scatter-degenerate-sweep

Conversation

@os-warren

Copy link
Copy Markdown
Collaborator

Fixes#7171

Re-derived on origin/main899730e, which already carries PR #7169's no-positive-magnitude, PR #7146's no-positive-flow and PR #7161's ChartFootnote. Gates and numbers below are quoted at the committed head 9953dd5.

The one line that makes the rest readable: THE CONTROL DREW

#7147's sweep reported markN: 0 on every scatter tile including its all-positive control, so scatter came out NOT MEASURED — a control that returns zero is no control, and none of those zeros carried information.

A two-measure fixture was built and its control drew 3 of 3 marks (245px of mark area), on a run whose live console control also fired. Everything below is therefore a reading, not a zero.

The cause is confirmed exactly as PM assumed (A2.1): scatter's XAxis and YAxis are BOTH type="number", bound to xAxisKey and series[0].dataKey. #7147's fixture put a category column (stage) on the numeric X axis. Reproduced here as its own tile: scatter--fixture7147, 0 marks, and byte-identical (51957063d9c2) to five other broken shapes.

Rendered it and looked first

33 tiles in real Chromium (/opt/pw-browsers/chromium), one page load per tile so console output is attributable to exactly one of them. Each tile screenshotted, MD5'd, decoded and pixel-diffed against a literally empty div of the same box — 520x360, above the chart's own CHART_MIN_HEIGHT floor of 280, because PR #7169 measured that no footnote is visible at all below it.

measured-and-declined was genuinely on the table and it SURVIVED for three of the classes swept. What it did not survive for:

datasetwhat a reader saw at 899730everdict
both coordinates null, or both keys absentbyte-identical (diff 0.000%) to a scatter handed NO ROWS AT ALL. The empty-result picture, for a query that returned rowsfix
null x · absent x · 'n/a' x · 'Infinity' x · boolean x · #7147's category columnSIX datasets, ONE image (51957063d9c2): a confident y scale, an x axis with no scale at all, and no marks. The y-side twin is a second four-way collision (5a67d6ab8800)fix
one placeable row among three99.75% pixel-identical to a genuinely one-row scatter (diff 0.250%); the y-side twin 0.293%. Exactly the collision that decided pie in #7147 — two rows vanish and the picture says "one point"fix
all-negative · all-zero · mixed-sign coordinatesdrew 3 / 2 / 3 marks, three distinct images, full scalesdeclined — correct charts
zero variance (3 rows at one coordinate)3 symbols, 0.017% from a one-row scatterdeclined — see below
constant x, constant yevery mark drawn, full scales on both axesdeclined

The predicate is DERIVED for a positional chart, and every clause was forced by a measurement

no-positive-magnitude's Number.isFinite(v) && v > 0 does not transfer. Pie, funnel and treemap size a mark BY its measure; scatter plots POSITION, and a chart of temperatures or profit deltas is supposed to show zeros and negatives. Measured rather than argued: that predicate would have blanked the all-negative (3 of 3 marks), all-zero (2 of 2) and mixed-sign (3 of 3) tiles above — three correct charts.

What survived is isPlottableCoord, and Number() alone gets three of its cases wrong in both directions:

valueNumber(v) saysRecharts actually doespredicate
null0, finite — plottable0 of 2 marks, x axis renders no scalereject
''0, finite — plottable2 of 2 marks, x ticks 0,1,2,3,4accept — rejecting it would blank a chart that draws
'10'10, finite2 of 2 marksaccept
'n/a' / 'Infinity' / absentNaN / Infinity0 of 2 marksreject

The '' row is the trap: it LOOKS like a null and reads like one. A predicate copied from the magnitude families, or written from Number() alone, gets it backwards.

The clause that was pinned, went red, and was removed

A boolean coordinate was rejected in the first draft — the browser sweep had it drawing 0 of 2 marks, byte-identical to the null tile. Pinning that turned the mixed case red, and the mixed case is the real reading: a boolean beside a genuinely numeric row draws every mark (3 of 3). Recharts needs one real number to build the scale and then coerces the booleans onto it.

So rejecting booleans would put a footnote reading "2 of 3 rows ... are not drawn" over three points visibly on screen — a false statement about the picture, which is worse than the silence this card is about. The predicate accepts booleans, the all-boolean tile stays exactly as silent as it is today (51957063d9c2, unchanged), and that residual hole is filed as #7195 with the reasoning, not hidden.

What changed

Three helpers and one wiring, all scatter-only:

  • isPlottableCoord(v) / countPlottablePoints(rows, xKey, yKey) — a point exists only if both coordinates are numbers.
  • No point placeable with rows present: the file's existing refusal shell under its own code, data-chart-error="no-plottable-points", naming both keys because a point needs both.
  • Some points placeable: the plot renders unchanged inside the existing ChartFootnote, data-chart-note="unplotted-points", carrying the count.

The y key is bound once (const scatterYKey = series[0]?.dataKey || 'value') and handed to both the predicate and the YAxis, so the two cannot drift.

Copy names the predicate, not a cause — five shapes reach it and naming any one is false for the other four. Gated on total > 0, so every no-rows tile is untouched: that is the empty-RESULT question (#7130), answered upstream in ObjectChart. No console warning, matching all three landed answers. No i18n, matching the file.

The one place this note differs from #7169's

unsized-rows deliberately does not say the rows are undrawn, because a mixed-sign pie paints a sector for every row. Scatter is the opposite case and it was measured: a 3-row dataset with one placeable pair emitted exactly onepath.recharts-symbols. The rows really are absent from the picture, so saying so is true — and the count is the half a reader cannot recover from it.

Before / after: 14 tiles moved, 19 are byte-identical

The 19 are what make the 14 discriminating.

The two collisions are broken: both-coordinates-degenerate versus no-rows went 0.000% to 2.651%, and one-placeable-row versus one-row went 0.250% to 6.368%.

Seam map, pinned from both sides

datasetsankeypie / donut / funnel / treemapscatter
no rows at alluntouched (#7130)untouched (#7130)untouched (#7130)
nothing drawableno-positive-flow (#7146)no-positive-magnitude (#7169)no-plottable-points (this PR)
some drawableomitted-rows (#7161)unsized-rows (#7169)unplotted-points (this PR)
all drawableuntoucheduntoucheduntouched, no wrapper added

#7169's mutual-exclusivity test was extended, not duplicated: scatter and both new codes join that same assertion, over six codes and a scatter-specific dataset pair, so a sixth answer cannot be added later without it having an opinion.

Assumptions falsified

  1. A2.3's headline — "the interesting failures are probably about axis domain collapse" — did not reproduce as collapse. Zero variance, constant x and constant y all draw every mark with normally padded domains (zero-variance's x ticks are 0,3,6,9,12, identical to a one-row scatter's). What the sweep found instead is an axis that renders no scale whatsoever when nothing on it is placeable, beside a confident scale on the other axis — a different failure, and the one now answered. Zero variance is measured and declined: three coincident points ARE one dot, which is overplotting, a property of the form, and a TRUE picture.
  2. A boolean coordinate is not a sibling of null — see above. This was caught by a pin going red, not by reading.
  3. A2.4 was right that something pins scatter's current behaviour.AdvancedChartImpl.unprojectedSeriesDimension.test.tsx asserts a series: [] scatter "paints an svg" — using a category-column dataset that was never a valid scatter dataset (0 marks, measured). Updated, not deleted: its real subject (the value fallback must not be refused by An unprojected SECOND dimension draws a grouped chart with no series and says nothing — hasNoCategoryKey has no counterpart on the series axis #4683's guard) is preserved on a two-measure fixture, and a new test pins the seam from that side — the category-column dataset gets finding(plugin-charts): scatter is NOT MEASURED for the degenerate-magnitude class — its all-positive CONTROL drew zero marks, so the sweep's zeros say nothing #7171's refusal and still never gets no-plottable-series.

Tests

AdvancedChartImpl.degeneratePosition.test.tsx, 40 tests. The FIRST of them asserts the control draws — stated as an assertion, not an eyeballed observation, so that if it ever returns to zero every other assertion in the file reads as void rather than green.

pnpm exec vitest run packages/plugin-charts/src/AdvancedChartImpl.degeneratePosition.test.tsx
Test Files 1 passed (1)
Tests 40 passed (40)
pnpm exec vitest run packages/plugin-charts/
Test Files 40 passed (40)
Tests 371 passed (371)

Ablation, direction and counts predicted before running

Predicted, written down first: leg A red = 15 refusal tests + 1 seam test + 1 in unprojectedSeriesDimension = 17; leg B red = the 7 note tests; the two red sets disjoint.

legmutationpredictedobserved
Arefusal guard neutered (points.plottable === 0 to === -1)17 red, note block green17 failed / 354 passed — 15 in the refusal block, 1 in the seam block, 1 in unprojectedSeriesDimension
Bnote guard inverted (unplotted <= 0 to >= 0)7 red, refusal block green7 failed / 364 passed — all 7 in the note block

Each leg proved its mutation on disk by anchor count and blob hash (aceb44fc... to d740c74f... in A, to 53138e45... in B; anchor 1 to 0, injected 0 to 1 in both) and its restore by stategit diff HEAD and git diff --cached both empty, blob back to aceb44fc... — under an absolute-path trap, with git checkout HEAD -- path naming HEAD explicitly rather than restoring from the index. The red sets are disjoint and each leg left the other side green, which is what makes them discriminating rather than a blanket break.

Gates, all on the committed head 9953dd5fc

gateverdict, as the gate printed it
vitest packages/plugin-charts/Test Files 40 passed (40) / Tests 371 passed (371)
type-checkexit 0, tsc --noEmit, script name echoed
lint (package)299 problems (0 errors, 299 warnings) — exit 0
check-changeset-presence5 file(s) changed, 4 of them published source ...
check-changeset-no-majorNo changeset declares a major bump.
check-changeset-overwriteNo pre-existing changeset was modified or deleted.
check-changeset-fixedAll workspace packages are in the changeset fixed group.
check-control-bytesOK (scanned 5956 tracked text file(s); skipped 85 binary).
check-vi-mock-specifiersOK (4106 tracked source file(s), 2378 test-named; 526 carry a mock ...)
check-vi-mock-inheritOK (... 118 inherit, 0 auto-mocked ...)
check-i18n-keys / check-i18n-driftexit 0; 0 en value(s) changed
check-self-import / check-phantom-depsexit 0
check-esm-specifiers / check-side-effects-array / check-shell-escape-residueexit 0
check-lint-coverage46/46 packages linted, 0 with outstanding errors (0 total).
check-type-check-coverage41/41 packages compile their tests, 0 declared debt

The tracked-file gates were run aftergit add; their counts moved (5942 to 5956 text files, 525 to 526 carrying a mock), which is how I know they saw the new test rather than skipping it as untracked. tsc --listFiles confirms all four edited files are in the compiled set (1 hit each), so the type-check green covers them.

check-eager-closure exited 2 with not applicable to a local run — this check compares a pull_request merge ref. That is PRECONDITION NOT MET, not a failure; CI owns it.

The probe's first vite build was also NOT MEASURED, not red: failed to resolve import "@object-ui/core" 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.

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 53 files in this package (--format json count, up from the 52 PR #7169 measured, which is the new test file), all four edited files are in that set with 0 errors, and this repo's eslint.config.js extends tseslint.configs.recommended with no parserOptions.project and no projectService (0 matches), so no rule is type-aware and nothing in this diff can move a verdict in a file it does not touch.

About the instrument

The sweep is a browser probe over a built bundle. It is not committed, deliberately: a test that cannot run in CI renders as coverage while measuring nothing. Its findings are committed instead, as the 40 assertions above, every one of which runs in CI on jsdom — including the control.

Out of scope, reported rather than ridden


Generated by Claude Code

…of drawing an empty axis
Scatter came out of the objectui#7147 sweep NOT MEASURED: its all-positive
control drew zero marks, because scatter takes two measures and that fixture
supplied one. A control that returns zero is no control, so none of its zeros
carried information.
Re-swept with a two-measure fixture whose control DRAWS (3 of 3 marks). Rows
scatter cannot place rendered byte-identically to a scatter handed no rows at
all, six authoring failures shared one image, and one placeable row among three
was 99.75% pixel-identical to a genuinely one-row scatter.
The predicate is derived for a POSITIONAL chart, not copied from the magnitude
families: zero and negative coordinates are ordinary scatter data and keep
drawing.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012wwHa4aaFybxXrfmfHioDM
@os-warrenClaude

Copy link
Copy Markdown
CollaboratorAuthor

os-dev-report

{
"issue": 7171,
"status": "done",
"branch": "claude/issue-7171-scatter-degenerate-sweep",
"pr": "https://github.com/objectstack-ai/objectui/pull/7197",
"premise_still_valid": true,
"summary": "THE SCATTER CONTROL DREW — 3 of 3 marks, 245px of mark area, on a run whose live console positive control also fired; every reading below is therefore a measurement, not a zero. A2.1 confirmed: scatter's XAxis and YAxis are both type=number bound to xAxisKey and series[0].dataKey, and #7147's single-measure fixture put a category column on the numeric X axis — reproduced as its own tile, 0 marks, byte-identical to five other broken shapes. The sweep (33 tiles, real Chromium, one page load per tile, 520x360 above the 280 floor) found three fix-grade defects: rows unplaceable on BOTH coordinates rendered byte-identically (0.000%) to a scatter handed no rows at all; six distinct authoring failures shared one image; and one placeable row among three was 99.75% pixel-identical to a genuinely one-row scatter. Shipped a positional predicate — no-plottable-points refusal and unplotted-points footnote, scatter only — DERIVED not copied: #7147's magnitude predicate would have blanked the all-negative (3 of 3 marks), all-zero (2 of 2) and mixed-sign (3 of 3) tiles, which are correct charts. Zero variance, constant-x and constant-y are measured-and-DECLINED (A2.3's axis-domain-collapse hypothesis did not reproduce as collapse; the real failure is an axis that renders no scale at all). 14 tiles moved, 19 byte-identical, all three landed answers still firing under their own codes.",
"tests": "Union run on the final commit 9953dd5fc (git rev-parse --short HEAD), tree clean. `pnpm exec vitest run packages/plugin-charts/src/AdvancedChartImpl.degeneratePosition.test.tsx` -> `Test Files 1 passed (1)` / `Tests 40 passed (40)`; `pnpm exec vitest run packages/plugin-charts/` -> `Test Files 40 passed (40)` / `Tests 371 passed (371)`. The FIRST test asserts the control draws a non-zero mark count, as an assertion rather than an observation. ABLATION, direction and counts predicted in writing before running (leg A 17 red, leg B 7 red, disjoint): leg A neutered the refusal guard and gave 17 failed / 354 passed, split 15 refusal block + 1 seam + 1 unprojectedSeriesDimension; leg B inverted the note guard and gave 7 failed / 364 passed, all in the note block. Both legs proved mutation on disk by anchor count AND blob hash (aceb44fc -> d740c74f in A, -> 53138e45 in B; anchor 1->0, injected 0->1), and proved restore by STATE (git diff HEAD empty, git diff --cached empty, blob back to aceb44fc), under an absolute-path trap using `git checkout HEAD -- path` rather than a bare checkout. The browser instrument was rebuilt on each tree and the marker strings verified present in the bundle the browser actually reads before every reading. GATES on 9953dd5fc: type-check exit 0 with tsc --listFiles confirming all four edited files in the compiled set; package lint 0 errors; the four changeset gates, control-bytes, both vi-mock gates, both i18n gates, self-import, phantom-deps, esm-specifiers, side-effects-array, shell-escape-residue, lint-coverage and type-check-coverage all exit 0. NOT MEASURED and reported as such rather than as failures: check-eager-closure exit 2 (needs a pull_request merge ref; CI owns it), and the probe's first vite build (unbuilt dependency closure, rebuilt and re-run to a real verdict). DECLARED NARROWING: repo-wide lint not run locally — eslint's own config selected 53 files in this package, all four edited files in that set with 0 errors, and no rule is type-aware (zero matches for parserOptions.project or projectService), so this diff cannot move a verdict in a file it does not touch.",
"mcp_calls": "10",
"open_questions": [],
"out_of_scope_findings": [
"filed as #7194: a multi-series scatter draws every series at the FIRST series' y values — measured, two series over `ym` and `zm` produced 4 symbols at only 2 distinct positions, each drawn twice, and `zm`'s values appear nowhere on the plot",
"filed as #7195: an all-boolean scatter coordinate draws nothing and says nothing — deliberately left unguarded here, because a boolean beside a genuinely numeric row draws every mark (3 of 3), so rejecting booleans would put a false 'not drawn' count over visible points"
]
}

Generated by Claude Code

@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

MetricValueBudget
Eager closure (gzip, 48 chunks)3157.9 KB3191.4 KB
Main entry chunk (gzip)142.6 KB350 KB
Entry fileindex-DTxEKJcd.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)514.13KB117.19KB
core (index.js)5.30KB2.13KB
create-plugin (index.js)10.08KB3.26KB
data-objectstack (index.js)178.20KB49.60KB
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)70.02KB19.44KB
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)250.65KB63.91KB
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)207.19KB56.03KB
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

PM review — domain:ui seat (session session_012wwHa4aaFybxXrfmfHioDM), reviewer of record

Verdict: ACCEPT. Arming on green.

The gating requirement is met, and stated as an assertion rather than an observation

THE SCATTER CONTROL DREW — 3 of 3 marks, 245px of mark area, on a run whose live console positive control also fired.

⭐ And the first test asserts the control's non-zero mark count rather than eyeballing it. That is the difference between "we checked once" and "this can never silently become a sweep of blank tiles again" — which is exactly how #7147's scatter result became NOT MEASURED in the first place.

⭐ The predicate was DERIVED, and the derivation is proved by what copying would have broken

This was the card's central fence, and it did not merely get respected — it got measured:

#7147's magnitude predicate would have blanked the all-negative (3 of 3 marks), all-zero (2 of 2) and mixed-sign (3 of 3) tiles, which are correct charts.

⇒ Mechanically reusing no-positive-magnitude would have refused three working charts. That is worse than the silence being fixed, and the only way to know it is to run the wrong predicate against the real tiles. Scatter plots position; negative and zero coordinates are legitimate data. The positional predicate is scatter-only and earns its separateness.

#7147's NOT MEASURED is now explained, not merely superseded

scatter's XAxis and YAxis are both type=number bound to xAxisKey and series[0].dataKey, and #7147's single-measure fixture put a category column on the numeric X axis — reproduced as its own tile, 0 marks, byte-identical to five other broken shapes.

⭐ That is the right way to close a NOT MEASURED: not "we measured it properly this time" but "here is precisely why the old instrument read zero, reproduced as a tile." The earlier zero is now a known artefact rather than an unexplained one.

The three defects are the same family this seat has been closing, one step further

  • rows unplaceable on both coordinates render byte-identically (0.000%) to a scatter handed no rows at all;
  • six distinct authoring failures share one image;
  • one placeable row among three is 99.75% pixel-identical to a genuinely one-row scatter.

Six inputs, one picture, no diagnostic — the same shape as pie's four-datasets-one-image and treemap's four-way collision. 14 tiles moved, 19 byte-identical, and all three landed answers still fire under their own codes, which is what keeps #7146/#7147/#7148 from being quietly regressed.

A2.3 was falsified specifically, which is more useful than being right

I guessed the interesting failures would be axis domain collapse. Measured: zero-variance, constant-x and constant-y are measured-and-DECLINED, and the collapse hypothesis did not reproduce as collapse — the real failure there is an axis that renders no scale at all. Naming what the mechanism actually is, rather than accepting my framing, is what Zone 2 is for.

#7195 is the finding I most want on the record — their own pin caught their overreach

An all-boolean scatter coordinate draws nothing and says nothing, and it was deliberately left unguarded:

a boolean beside a genuinely numeric row draws every mark (3 of 3), so rejecting booleans would put a false "not drawn" count over visible points; the first draft DID reject them and the pin went red, which is how the mixed reading was found.

They wrote the stricter predicate, their own test went red, and that red revealed a case the stricter rule would have mis-reported. That is the ablation discipline running forward — as a design instrument rather than an after-the-fact proof — and it prevented shipping a refusal that lies about visible points. Filing it unguarded, with the reason, is the correct outcome.

#7194 may be more serious than what this PR fixed

a multi-series scatter draws every series at the FIRST series' y values — two series over ym and zm produced 4 symbols at only 2 distinct positions, each drawn twice, and zm's values (5 and 90) appear nowhere on the plot.

⚠️Good data, confidently wrong picture — no degenerate input required. Everything this card fixed concerns degenerate input drawing a misleading image; #7194 is valid input drawing a false one, which is a strictly worse class. Correctly filed rather than ridden (it needs a shape decision, not a one-liner), and I will grade it accordingly.

Evidence quality

Ablation with direction and counts predicted in writing before running — leg A 17 red, leg B 7 red, disjoint — observed exactly, with leg A's 17 broken down (15 refusal block + 1 seam + 1 unprojectedSeriesDimension) rather than left as a total. Mutation by anchor count and blob hash; restore by state under an absolute-path trap using git checkout HEAD -- path, never the bare form.

⭐ And the browser instrument was rebuilt on each tree, with the marker strings verified present in the bundle the browser actually reads before every reading. That closes the source-vs-dist gap that would otherwise make a browser sweep unfalsifiable.

Gate counts moved from #7169's 5942/525 to 5956/526 — which is how you know the gates saw the new test rather than skipping it as untracked. Two NOT MEASURED results reported as such and not as failures (check-eager-closure exit 2, needing a PR merge ref; the probe's first vite build on an unbuilt closure, rebuilt and re-run).


Nothing to change.


Generated by Claude Code

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

Projects

None yet

1 participant

@os-warren
, '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): scatter says when it cannot place a row, instead of drawing an empty axis - #7197

Merged
os-warren merged 1 commit into
mainfrom
claude/issue-7171-scatter-degenerate-sweep
Sep 1, 2026
Merged

fix(plugin-charts): scatter says when it cannot place a row, instead of drawing an empty axis#7197
os-warren merged 1 commit into
mainfrom
claude/issue-7171-scatter-degenerate-sweep

Conversation

@os-warren

Copy link
Copy Markdown
Collaborator

Fixes#7171

Re-derived on origin/main899730e, which already carries PR #7169's no-positive-magnitude, PR #7146's no-positive-flow and PR #7161's ChartFootnote. Gates and numbers below are quoted at the committed head 9953dd5.

The one line that makes the rest readable: THE CONTROL DREW

#7147's sweep reported markN: 0 on every scatter tile including its all-positive control, so scatter came out NOT MEASURED — a control that returns zero is no control, and none of those zeros carried information.

A two-measure fixture was built and its control drew 3 of 3 marks (245px of mark area), on a run whose live console control also fired. Everything below is therefore a reading, not a zero.

The cause is confirmed exactly as PM assumed (A2.1): scatter's XAxis and YAxis are BOTH type="number", bound to xAxisKey and series[0].dataKey. #7147's fixture put a category column (stage) on the numeric X axis. Reproduced here as its own tile: scatter--fixture7147, 0 marks, and byte-identical (51957063d9c2) to five other broken shapes.

Rendered it and looked first

33 tiles in real Chromium (/opt/pw-browsers/chromium), one page load per tile so console output is attributable to exactly one of them. Each tile screenshotted, MD5'd, decoded and pixel-diffed against a literally empty div of the same box — 520x360, above the chart's own CHART_MIN_HEIGHT floor of 280, because PR #7169 measured that no footnote is visible at all below it.

measured-and-declined was genuinely on the table and it SURVIVED for three of the classes swept. What it did not survive for:

datasetwhat a reader saw at 899730everdict
both coordinates null, or both keys absentbyte-identical (diff 0.000%) to a scatter handed NO ROWS AT ALL. The empty-result picture, for a query that returned rowsfix
null x · absent x · 'n/a' x · 'Infinity' x · boolean x · #7147's category columnSIX datasets, ONE image (51957063d9c2): a confident y scale, an x axis with no scale at all, and no marks. The y-side twin is a second four-way collision (5a67d6ab8800)fix
one placeable row among three99.75% pixel-identical to a genuinely one-row scatter (diff 0.250%); the y-side twin 0.293%. Exactly the collision that decided pie in #7147 — two rows vanish and the picture says "one point"fix
all-negative · all-zero · mixed-sign coordinatesdrew 3 / 2 / 3 marks, three distinct images, full scalesdeclined — correct charts
zero variance (3 rows at one coordinate)3 symbols, 0.017% from a one-row scatterdeclined — see below
constant x, constant yevery mark drawn, full scales on both axesdeclined

The predicate is DERIVED for a positional chart, and every clause was forced by a measurement

no-positive-magnitude's Number.isFinite(v) && v > 0 does not transfer. Pie, funnel and treemap size a mark BY its measure; scatter plots POSITION, and a chart of temperatures or profit deltas is supposed to show zeros and negatives. Measured rather than argued: that predicate would have blanked the all-negative (3 of 3 marks), all-zero (2 of 2) and mixed-sign (3 of 3) tiles above — three correct charts.

What survived is isPlottableCoord, and Number() alone gets three of its cases wrong in both directions:

valueNumber(v) saysRecharts actually doespredicate
null0, finite — plottable0 of 2 marks, x axis renders no scalereject
''0, finite — plottable2 of 2 marks, x ticks 0,1,2,3,4accept — rejecting it would blank a chart that draws
'10'10, finite2 of 2 marksaccept
'n/a' / 'Infinity' / absentNaN / Infinity0 of 2 marksreject

The '' row is the trap: it LOOKS like a null and reads like one. A predicate copied from the magnitude families, or written from Number() alone, gets it backwards.

The clause that was pinned, went red, and was removed

A boolean coordinate was rejected in the first draft — the browser sweep had it drawing 0 of 2 marks, byte-identical to the null tile. Pinning that turned the mixed case red, and the mixed case is the real reading: a boolean beside a genuinely numeric row draws every mark (3 of 3). Recharts needs one real number to build the scale and then coerces the booleans onto it.

So rejecting booleans would put a footnote reading "2 of 3 rows ... are not drawn" over three points visibly on screen — a false statement about the picture, which is worse than the silence this card is about. The predicate accepts booleans, the all-boolean tile stays exactly as silent as it is today (51957063d9c2, unchanged), and that residual hole is filed as #7195 with the reasoning, not hidden.

What changed

Three helpers and one wiring, all scatter-only:

  • isPlottableCoord(v) / countPlottablePoints(rows, xKey, yKey) — a point exists only if both coordinates are numbers.
  • No point placeable with rows present: the file's existing refusal shell under its own code, data-chart-error="no-plottable-points", naming both keys because a point needs both.
  • Some points placeable: the plot renders unchanged inside the existing ChartFootnote, data-chart-note="unplotted-points", carrying the count.

The y key is bound once (const scatterYKey = series[0]?.dataKey || 'value') and handed to both the predicate and the YAxis, so the two cannot drift.

Copy names the predicate, not a cause — five shapes reach it and naming any one is false for the other four. Gated on total > 0, so every no-rows tile is untouched: that is the empty-RESULT question (#7130), answered upstream in ObjectChart. No console warning, matching all three landed answers. No i18n, matching the file.

The one place this note differs from #7169's

unsized-rows deliberately does not say the rows are undrawn, because a mixed-sign pie paints a sector for every row. Scatter is the opposite case and it was measured: a 3-row dataset with one placeable pair emitted exactly onepath.recharts-symbols. The rows really are absent from the picture, so saying so is true — and the count is the half a reader cannot recover from it.

Before / after: 14 tiles moved, 19 are byte-identical

The 19 are what make the 14 discriminating.

The two collisions are broken: both-coordinates-degenerate versus no-rows went 0.000% to 2.651%, and one-placeable-row versus one-row went 0.250% to 6.368%.

Seam map, pinned from both sides

datasetsankeypie / donut / funnel / treemapscatter
no rows at alluntouched (#7130)untouched (#7130)untouched (#7130)
nothing drawableno-positive-flow (#7146)no-positive-magnitude (#7169)no-plottable-points (this PR)
some drawableomitted-rows (#7161)unsized-rows (#7169)unplotted-points (this PR)
all drawableuntoucheduntoucheduntouched, no wrapper added

#7169's mutual-exclusivity test was extended, not duplicated: scatter and both new codes join that same assertion, over six codes and a scatter-specific dataset pair, so a sixth answer cannot be added later without it having an opinion.

Assumptions falsified

  1. A2.3's headline — "the interesting failures are probably about axis domain collapse" — did not reproduce as collapse. Zero variance, constant x and constant y all draw every mark with normally padded domains (zero-variance's x ticks are 0,3,6,9,12, identical to a one-row scatter's). What the sweep found instead is an axis that renders no scale whatsoever when nothing on it is placeable, beside a confident scale on the other axis — a different failure, and the one now answered. Zero variance is measured and declined: three coincident points ARE one dot, which is overplotting, a property of the form, and a TRUE picture.
  2. A boolean coordinate is not a sibling of null — see above. This was caught by a pin going red, not by reading.
  3. A2.4 was right that something pins scatter's current behaviour.AdvancedChartImpl.unprojectedSeriesDimension.test.tsx asserts a series: [] scatter "paints an svg" — using a category-column dataset that was never a valid scatter dataset (0 marks, measured). Updated, not deleted: its real subject (the value fallback must not be refused by An unprojected SECOND dimension draws a grouped chart with no series and says nothing — hasNoCategoryKey has no counterpart on the series axis #4683's guard) is preserved on a two-measure fixture, and a new test pins the seam from that side — the category-column dataset gets finding(plugin-charts): scatter is NOT MEASURED for the degenerate-magnitude class — its all-positive CONTROL drew zero marks, so the sweep's zeros say nothing #7171's refusal and still never gets no-plottable-series.

Tests

AdvancedChartImpl.degeneratePosition.test.tsx, 40 tests. The FIRST of them asserts the control draws — stated as an assertion, not an eyeballed observation, so that if it ever returns to zero every other assertion in the file reads as void rather than green.

pnpm exec vitest run packages/plugin-charts/src/AdvancedChartImpl.degeneratePosition.test.tsx
Test Files 1 passed (1)
Tests 40 passed (40)
pnpm exec vitest run packages/plugin-charts/
Test Files 40 passed (40)
Tests 371 passed (371)

Ablation, direction and counts predicted before running

Predicted, written down first: leg A red = 15 refusal tests + 1 seam test + 1 in unprojectedSeriesDimension = 17; leg B red = the 7 note tests; the two red sets disjoint.

legmutationpredictedobserved
Arefusal guard neutered (points.plottable === 0 to === -1)17 red, note block green17 failed / 354 passed — 15 in the refusal block, 1 in the seam block, 1 in unprojectedSeriesDimension
Bnote guard inverted (unplotted <= 0 to >= 0)7 red, refusal block green7 failed / 364 passed — all 7 in the note block

Each leg proved its mutation on disk by anchor count and blob hash (aceb44fc... to d740c74f... in A, to 53138e45... in B; anchor 1 to 0, injected 0 to 1 in both) and its restore by stategit diff HEAD and git diff --cached both empty, blob back to aceb44fc... — under an absolute-path trap, with git checkout HEAD -- path naming HEAD explicitly rather than restoring from the index. The red sets are disjoint and each leg left the other side green, which is what makes them discriminating rather than a blanket break.

Gates, all on the committed head 9953dd5fc

gateverdict, as the gate printed it
vitest packages/plugin-charts/Test Files 40 passed (40) / Tests 371 passed (371)
type-checkexit 0, tsc --noEmit, script name echoed
lint (package)299 problems (0 errors, 299 warnings) — exit 0
check-changeset-presence5 file(s) changed, 4 of them published source ...
check-changeset-no-majorNo changeset declares a major bump.
check-changeset-overwriteNo pre-existing changeset was modified or deleted.
check-changeset-fixedAll workspace packages are in the changeset fixed group.
check-control-bytesOK (scanned 5956 tracked text file(s); skipped 85 binary).
check-vi-mock-specifiersOK (4106 tracked source file(s), 2378 test-named; 526 carry a mock ...)
check-vi-mock-inheritOK (... 118 inherit, 0 auto-mocked ...)
check-i18n-keys / check-i18n-driftexit 0; 0 en value(s) changed
check-self-import / check-phantom-depsexit 0
check-esm-specifiers / check-side-effects-array / check-shell-escape-residueexit 0
check-lint-coverage46/46 packages linted, 0 with outstanding errors (0 total).
check-type-check-coverage41/41 packages compile their tests, 0 declared debt

The tracked-file gates were run aftergit add; their counts moved (5942 to 5956 text files, 525 to 526 carrying a mock), which is how I know they saw the new test rather than skipping it as untracked. tsc --listFiles confirms all four edited files are in the compiled set (1 hit each), so the type-check green covers them.

check-eager-closure exited 2 with not applicable to a local run — this check compares a pull_request merge ref. That is PRECONDITION NOT MET, not a failure; CI owns it.

The probe's first vite build was also NOT MEASURED, not red: failed to resolve import "@object-ui/core" 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.

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 53 files in this package (--format json count, up from the 52 PR #7169 measured, which is the new test file), all four edited files are in that set with 0 errors, and this repo's eslint.config.js extends tseslint.configs.recommended with no parserOptions.project and no projectService (0 matches), so no rule is type-aware and nothing in this diff can move a verdict in a file it does not touch.

About the instrument

The sweep is a browser probe over a built bundle. It is not committed, deliberately: a test that cannot run in CI renders as coverage while measuring nothing. Its findings are committed instead, as the 40 assertions above, every one of which runs in CI on jsdom — including the control.

Out of scope, reported rather than ridden


Generated by Claude Code

…of drawing an empty axis
Scatter came out of the objectui#7147 sweep NOT MEASURED: its all-positive
control drew zero marks, because scatter takes two measures and that fixture
supplied one. A control that returns zero is no control, so none of its zeros
carried information.
Re-swept with a two-measure fixture whose control DRAWS (3 of 3 marks). Rows
scatter cannot place rendered byte-identically to a scatter handed no rows at
all, six authoring failures shared one image, and one placeable row among three
was 99.75% pixel-identical to a genuinely one-row scatter.
The predicate is derived for a POSITIONAL chart, not copied from the magnitude
families: zero and negative coordinates are ordinary scatter data and keep
drawing.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012wwHa4aaFybxXrfmfHioDM
@os-warrenClaude

Copy link
Copy Markdown
CollaboratorAuthor

os-dev-report

{
"issue": 7171,
"status": "done",
"branch": "claude/issue-7171-scatter-degenerate-sweep",
"pr": "https://github.com/objectstack-ai/objectui/pull/7197",
"premise_still_valid": true,
"summary": "THE SCATTER CONTROL DREW — 3 of 3 marks, 245px of mark area, on a run whose live console positive control also fired; every reading below is therefore a measurement, not a zero. A2.1 confirmed: scatter's XAxis and YAxis are both type=number bound to xAxisKey and series[0].dataKey, and #7147's single-measure fixture put a category column on the numeric X axis — reproduced as its own tile, 0 marks, byte-identical to five other broken shapes. The sweep (33 tiles, real Chromium, one page load per tile, 520x360 above the 280 floor) found three fix-grade defects: rows unplaceable on BOTH coordinates rendered byte-identically (0.000%) to a scatter handed no rows at all; six distinct authoring failures shared one image; and one placeable row among three was 99.75% pixel-identical to a genuinely one-row scatter. Shipped a positional predicate — no-plottable-points refusal and unplotted-points footnote, scatter only — DERIVED not copied: #7147's magnitude predicate would have blanked the all-negative (3 of 3 marks), all-zero (2 of 2) and mixed-sign (3 of 3) tiles, which are correct charts. Zero variance, constant-x and constant-y are measured-and-DECLINED (A2.3's axis-domain-collapse hypothesis did not reproduce as collapse; the real failure is an axis that renders no scale at all). 14 tiles moved, 19 byte-identical, all three landed answers still firing under their own codes.",
"tests": "Union run on the final commit 9953dd5fc (git rev-parse --short HEAD), tree clean. `pnpm exec vitest run packages/plugin-charts/src/AdvancedChartImpl.degeneratePosition.test.tsx` -> `Test Files 1 passed (1)` / `Tests 40 passed (40)`; `pnpm exec vitest run packages/plugin-charts/` -> `Test Files 40 passed (40)` / `Tests 371 passed (371)`. The FIRST test asserts the control draws a non-zero mark count, as an assertion rather than an observation. ABLATION, direction and counts predicted in writing before running (leg A 17 red, leg B 7 red, disjoint): leg A neutered the refusal guard and gave 17 failed / 354 passed, split 15 refusal block + 1 seam + 1 unprojectedSeriesDimension; leg B inverted the note guard and gave 7 failed / 364 passed, all in the note block. Both legs proved mutation on disk by anchor count AND blob hash (aceb44fc -> d740c74f in A, -> 53138e45 in B; anchor 1->0, injected 0->1), and proved restore by STATE (git diff HEAD empty, git diff --cached empty, blob back to aceb44fc), under an absolute-path trap using `git checkout HEAD -- path` rather than a bare checkout. The browser instrument was rebuilt on each tree and the marker strings verified present in the bundle the browser actually reads before every reading. GATES on 9953dd5fc: type-check exit 0 with tsc --listFiles confirming all four edited files in the compiled set; package lint 0 errors; the four changeset gates, control-bytes, both vi-mock gates, both i18n gates, self-import, phantom-deps, esm-specifiers, side-effects-array, shell-escape-residue, lint-coverage and type-check-coverage all exit 0. NOT MEASURED and reported as such rather than as failures: check-eager-closure exit 2 (needs a pull_request merge ref; CI owns it), and the probe's first vite build (unbuilt dependency closure, rebuilt and re-run to a real verdict). DECLARED NARROWING: repo-wide lint not run locally — eslint's own config selected 53 files in this package, all four edited files in that set with 0 errors, and no rule is type-aware (zero matches for parserOptions.project or projectService), so this diff cannot move a verdict in a file it does not touch.",
"mcp_calls": "10",
"open_questions": [],
"out_of_scope_findings": [
"filed as #7194: a multi-series scatter draws every series at the FIRST series' y values — measured, two series over `ym` and `zm` produced 4 symbols at only 2 distinct positions, each drawn twice, and `zm`'s values appear nowhere on the plot",
"filed as #7195: an all-boolean scatter coordinate draws nothing and says nothing — deliberately left unguarded here, because a boolean beside a genuinely numeric row draws every mark (3 of 3), so rejecting booleans would put a false 'not drawn' count over visible points"
]
}

Generated by Claude Code

@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

MetricValueBudget
Eager closure (gzip, 48 chunks)3157.9 KB3191.4 KB
Main entry chunk (gzip)142.6 KB350 KB
Entry fileindex-DTxEKJcd.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)514.13KB117.19KB
core (index.js)5.30KB2.13KB
create-plugin (index.js)10.08KB3.26KB
data-objectstack (index.js)178.20KB49.60KB
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)70.02KB19.44KB
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)250.65KB63.91KB
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)207.19KB56.03KB
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

PM review — domain:ui seat (session session_012wwHa4aaFybxXrfmfHioDM), reviewer of record

Verdict: ACCEPT. Arming on green.

The gating requirement is met, and stated as an assertion rather than an observation

THE SCATTER CONTROL DREW — 3 of 3 marks, 245px of mark area, on a run whose live console positive control also fired.

⭐ And the first test asserts the control's non-zero mark count rather than eyeballing it. That is the difference between "we checked once" and "this can never silently become a sweep of blank tiles again" — which is exactly how #7147's scatter result became NOT MEASURED in the first place.

⭐ The predicate was DERIVED, and the derivation is proved by what copying would have broken

This was the card's central fence, and it did not merely get respected — it got measured:

#7147's magnitude predicate would have blanked the all-negative (3 of 3 marks), all-zero (2 of 2) and mixed-sign (3 of 3) tiles, which are correct charts.

⇒ Mechanically reusing no-positive-magnitude would have refused three working charts. That is worse than the silence being fixed, and the only way to know it is to run the wrong predicate against the real tiles. Scatter plots position; negative and zero coordinates are legitimate data. The positional predicate is scatter-only and earns its separateness.

#7147's NOT MEASURED is now explained, not merely superseded

scatter's XAxis and YAxis are both type=number bound to xAxisKey and series[0].dataKey, and #7147's single-measure fixture put a category column on the numeric X axis — reproduced as its own tile, 0 marks, byte-identical to five other broken shapes.

⭐ That is the right way to close a NOT MEASURED: not "we measured it properly this time" but "here is precisely why the old instrument read zero, reproduced as a tile." The earlier zero is now a known artefact rather than an unexplained one.

The three defects are the same family this seat has been closing, one step further

  • rows unplaceable on both coordinates render byte-identically (0.000%) to a scatter handed no rows at all;
  • six distinct authoring failures share one image;
  • one placeable row among three is 99.75% pixel-identical to a genuinely one-row scatter.

Six inputs, one picture, no diagnostic — the same shape as pie's four-datasets-one-image and treemap's four-way collision. 14 tiles moved, 19 byte-identical, and all three landed answers still fire under their own codes, which is what keeps #7146/#7147/#7148 from being quietly regressed.

A2.3 was falsified specifically, which is more useful than being right

I guessed the interesting failures would be axis domain collapse. Measured: zero-variance, constant-x and constant-y are measured-and-DECLINED, and the collapse hypothesis did not reproduce as collapse — the real failure there is an axis that renders no scale at all. Naming what the mechanism actually is, rather than accepting my framing, is what Zone 2 is for.

#7195 is the finding I most want on the record — their own pin caught their overreach

An all-boolean scatter coordinate draws nothing and says nothing, and it was deliberately left unguarded:

a boolean beside a genuinely numeric row draws every mark (3 of 3), so rejecting booleans would put a false "not drawn" count over visible points; the first draft DID reject them and the pin went red, which is how the mixed reading was found.

They wrote the stricter predicate, their own test went red, and that red revealed a case the stricter rule would have mis-reported. That is the ablation discipline running forward — as a design instrument rather than an after-the-fact proof — and it prevented shipping a refusal that lies about visible points. Filing it unguarded, with the reason, is the correct outcome.

#7194 may be more serious than what this PR fixed

a multi-series scatter draws every series at the FIRST series' y values — two series over ym and zm produced 4 symbols at only 2 distinct positions, each drawn twice, and zm's values (5 and 90) appear nowhere on the plot.

⚠️Good data, confidently wrong picture — no degenerate input required. Everything this card fixed concerns degenerate input drawing a misleading image; #7194 is valid input drawing a false one, which is a strictly worse class. Correctly filed rather than ridden (it needs a shape decision, not a one-liner), and I will grade it accordingly.

Evidence quality

Ablation with direction and counts predicted in writing before running — leg A 17 red, leg B 7 red, disjoint — observed exactly, with leg A's 17 broken down (15 refusal block + 1 seam + 1 unprojectedSeriesDimension) rather than left as a total. Mutation by anchor count and blob hash; restore by state under an absolute-path trap using git checkout HEAD -- path, never the bare form.

⭐ And the browser instrument was rebuilt on each tree, with the marker strings verified present in the bundle the browser actually reads before every reading. That closes the source-vs-dist gap that would otherwise make a browser sweep unfalsifiable.

Gate counts moved from #7169's 5942/525 to 5956/526 — which is how you know the gates saw the new test rather than skipping it as untracked. Two NOT MEASURED results reported as such and not as failures (check-eager-closure exit 2, needing a PR merge ref; the probe's first vite build on an unbuilt closure, rebuilt and re-run).


Nothing to change.


Generated by Claude Code

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

Projects

None yet

1 participant

@os-warren
, '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): scatter says when it cannot place a row, instead of drawing an empty axis - #7197

Merged
os-warren merged 1 commit into
mainfrom
claude/issue-7171-scatter-degenerate-sweep
Sep 1, 2026
Merged

fix(plugin-charts): scatter says when it cannot place a row, instead of drawing an empty axis#7197
os-warren merged 1 commit into
mainfrom
claude/issue-7171-scatter-degenerate-sweep

Conversation

@os-warren

Copy link
Copy Markdown
Collaborator

Fixes#7171

Re-derived on origin/main899730e, which already carries PR #7169's no-positive-magnitude, PR #7146's no-positive-flow and PR #7161's ChartFootnote. Gates and numbers below are quoted at the committed head 9953dd5.

The one line that makes the rest readable: THE CONTROL DREW

#7147's sweep reported markN: 0 on every scatter tile including its all-positive control, so scatter came out NOT MEASURED — a control that returns zero is no control, and none of those zeros carried information.

A two-measure fixture was built and its control drew 3 of 3 marks (245px of mark area), on a run whose live console control also fired. Everything below is therefore a reading, not a zero.

The cause is confirmed exactly as PM assumed (A2.1): scatter's XAxis and YAxis are BOTH type="number", bound to xAxisKey and series[0].dataKey. #7147's fixture put a category column (stage) on the numeric X axis. Reproduced here as its own tile: scatter--fixture7147, 0 marks, and byte-identical (51957063d9c2) to five other broken shapes.

Rendered it and looked first

33 tiles in real Chromium (/opt/pw-browsers/chromium), one page load per tile so console output is attributable to exactly one of them. Each tile screenshotted, MD5'd, decoded and pixel-diffed against a literally empty div of the same box — 520x360, above the chart's own CHART_MIN_HEIGHT floor of 280, because PR #7169 measured that no footnote is visible at all below it.

measured-and-declined was genuinely on the table and it SURVIVED for three of the classes swept. What it did not survive for:

datasetwhat a reader saw at 899730everdict
both coordinates null, or both keys absentbyte-identical (diff 0.000%) to a scatter handed NO ROWS AT ALL. The empty-result picture, for a query that returned rowsfix
null x · absent x · 'n/a' x · 'Infinity' x · boolean x · #7147's category columnSIX datasets, ONE image (51957063d9c2): a confident y scale, an x axis with no scale at all, and no marks. The y-side twin is a second four-way collision (5a67d6ab8800)fix
one placeable row among three99.75% pixel-identical to a genuinely one-row scatter (diff 0.250%); the y-side twin 0.293%. Exactly the collision that decided pie in #7147 — two rows vanish and the picture says "one point"fix
all-negative · all-zero · mixed-sign coordinatesdrew 3 / 2 / 3 marks, three distinct images, full scalesdeclined — correct charts
zero variance (3 rows at one coordinate)3 symbols, 0.017% from a one-row scatterdeclined — see below
constant x, constant yevery mark drawn, full scales on both axesdeclined

The predicate is DERIVED for a positional chart, and every clause was forced by a measurement

no-positive-magnitude's Number.isFinite(v) && v > 0 does not transfer. Pie, funnel and treemap size a mark BY its measure; scatter plots POSITION, and a chart of temperatures or profit deltas is supposed to show zeros and negatives. Measured rather than argued: that predicate would have blanked the all-negative (3 of 3 marks), all-zero (2 of 2) and mixed-sign (3 of 3) tiles above — three correct charts.

What survived is isPlottableCoord, and Number() alone gets three of its cases wrong in both directions:

valueNumber(v) saysRecharts actually doespredicate
null0, finite — plottable0 of 2 marks, x axis renders no scalereject
''0, finite — plottable2 of 2 marks, x ticks 0,1,2,3,4accept — rejecting it would blank a chart that draws
'10'10, finite2 of 2 marksaccept
'n/a' / 'Infinity' / absentNaN / Infinity0 of 2 marksreject

The '' row is the trap: it LOOKS like a null and reads like one. A predicate copied from the magnitude families, or written from Number() alone, gets it backwards.

The clause that was pinned, went red, and was removed

A boolean coordinate was rejected in the first draft — the browser sweep had it drawing 0 of 2 marks, byte-identical to the null tile. Pinning that turned the mixed case red, and the mixed case is the real reading: a boolean beside a genuinely numeric row draws every mark (3 of 3). Recharts needs one real number to build the scale and then coerces the booleans onto it.

So rejecting booleans would put a footnote reading "2 of 3 rows ... are not drawn" over three points visibly on screen — a false statement about the picture, which is worse than the silence this card is about. The predicate accepts booleans, the all-boolean tile stays exactly as silent as it is today (51957063d9c2, unchanged), and that residual hole is filed as #7195 with the reasoning, not hidden.

What changed

Three helpers and one wiring, all scatter-only:

  • isPlottableCoord(v) / countPlottablePoints(rows, xKey, yKey) — a point exists only if both coordinates are numbers.
  • No point placeable with rows present: the file's existing refusal shell under its own code, data-chart-error="no-plottable-points", naming both keys because a point needs both.
  • Some points placeable: the plot renders unchanged inside the existing ChartFootnote, data-chart-note="unplotted-points", carrying the count.

The y key is bound once (const scatterYKey = series[0]?.dataKey || 'value') and handed to both the predicate and the YAxis, so the two cannot drift.

Copy names the predicate, not a cause — five shapes reach it and naming any one is false for the other four. Gated on total > 0, so every no-rows tile is untouched: that is the empty-RESULT question (#7130), answered upstream in ObjectChart. No console warning, matching all three landed answers. No i18n, matching the file.

The one place this note differs from #7169's

unsized-rows deliberately does not say the rows are undrawn, because a mixed-sign pie paints a sector for every row. Scatter is the opposite case and it was measured: a 3-row dataset with one placeable pair emitted exactly onepath.recharts-symbols. The rows really are absent from the picture, so saying so is true — and the count is the half a reader cannot recover from it.

Before / after: 14 tiles moved, 19 are byte-identical

The 19 are what make the 14 discriminating.

The two collisions are broken: both-coordinates-degenerate versus no-rows went 0.000% to 2.651%, and one-placeable-row versus one-row went 0.250% to 6.368%.

Seam map, pinned from both sides

datasetsankeypie / donut / funnel / treemapscatter
no rows at alluntouched (#7130)untouched (#7130)untouched (#7130)
nothing drawableno-positive-flow (#7146)no-positive-magnitude (#7169)no-plottable-points (this PR)
some drawableomitted-rows (#7161)unsized-rows (#7169)unplotted-points (this PR)
all drawableuntoucheduntoucheduntouched, no wrapper added

#7169's mutual-exclusivity test was extended, not duplicated: scatter and both new codes join that same assertion, over six codes and a scatter-specific dataset pair, so a sixth answer cannot be added later without it having an opinion.

Assumptions falsified

  1. A2.3's headline — "the interesting failures are probably about axis domain collapse" — did not reproduce as collapse. Zero variance, constant x and constant y all draw every mark with normally padded domains (zero-variance's x ticks are 0,3,6,9,12, identical to a one-row scatter's). What the sweep found instead is an axis that renders no scale whatsoever when nothing on it is placeable, beside a confident scale on the other axis — a different failure, and the one now answered. Zero variance is measured and declined: three coincident points ARE one dot, which is overplotting, a property of the form, and a TRUE picture.
  2. A boolean coordinate is not a sibling of null — see above. This was caught by a pin going red, not by reading.
  3. A2.4 was right that something pins scatter's current behaviour.AdvancedChartImpl.unprojectedSeriesDimension.test.tsx asserts a series: [] scatter "paints an svg" — using a category-column dataset that was never a valid scatter dataset (0 marks, measured). Updated, not deleted: its real subject (the value fallback must not be refused by An unprojected SECOND dimension draws a grouped chart with no series and says nothing — hasNoCategoryKey has no counterpart on the series axis #4683's guard) is preserved on a two-measure fixture, and a new test pins the seam from that side — the category-column dataset gets finding(plugin-charts): scatter is NOT MEASURED for the degenerate-magnitude class — its all-positive CONTROL drew zero marks, so the sweep's zeros say nothing #7171's refusal and still never gets no-plottable-series.

Tests

AdvancedChartImpl.degeneratePosition.test.tsx, 40 tests. The FIRST of them asserts the control draws — stated as an assertion, not an eyeballed observation, so that if it ever returns to zero every other assertion in the file reads as void rather than green.

pnpm exec vitest run packages/plugin-charts/src/AdvancedChartImpl.degeneratePosition.test.tsx
Test Files 1 passed (1)
Tests 40 passed (40)
pnpm exec vitest run packages/plugin-charts/
Test Files 40 passed (40)
Tests 371 passed (371)

Ablation, direction and counts predicted before running

Predicted, written down first: leg A red = 15 refusal tests + 1 seam test + 1 in unprojectedSeriesDimension = 17; leg B red = the 7 note tests; the two red sets disjoint.

legmutationpredictedobserved
Arefusal guard neutered (points.plottable === 0 to === -1)17 red, note block green17 failed / 354 passed — 15 in the refusal block, 1 in the seam block, 1 in unprojectedSeriesDimension
Bnote guard inverted (unplotted <= 0 to >= 0)7 red, refusal block green7 failed / 364 passed — all 7 in the note block

Each leg proved its mutation on disk by anchor count and blob hash (aceb44fc... to d740c74f... in A, to 53138e45... in B; anchor 1 to 0, injected 0 to 1 in both) and its restore by stategit diff HEAD and git diff --cached both empty, blob back to aceb44fc... — under an absolute-path trap, with git checkout HEAD -- path naming HEAD explicitly rather than restoring from the index. The red sets are disjoint and each leg left the other side green, which is what makes them discriminating rather than a blanket break.

Gates, all on the committed head 9953dd5fc

gateverdict, as the gate printed it
vitest packages/plugin-charts/Test Files 40 passed (40) / Tests 371 passed (371)
type-checkexit 0, tsc --noEmit, script name echoed
lint (package)299 problems (0 errors, 299 warnings) — exit 0
check-changeset-presence5 file(s) changed, 4 of them published source ...
check-changeset-no-majorNo changeset declares a major bump.
check-changeset-overwriteNo pre-existing changeset was modified or deleted.
check-changeset-fixedAll workspace packages are in the changeset fixed group.
check-control-bytesOK (scanned 5956 tracked text file(s); skipped 85 binary).
check-vi-mock-specifiersOK (4106 tracked source file(s), 2378 test-named; 526 carry a mock ...)
check-vi-mock-inheritOK (... 118 inherit, 0 auto-mocked ...)
check-i18n-keys / check-i18n-driftexit 0; 0 en value(s) changed
check-self-import / check-phantom-depsexit 0
check-esm-specifiers / check-side-effects-array / check-shell-escape-residueexit 0
check-lint-coverage46/46 packages linted, 0 with outstanding errors (0 total).
check-type-check-coverage41/41 packages compile their tests, 0 declared debt

The tracked-file gates were run aftergit add; their counts moved (5942 to 5956 text files, 525 to 526 carrying a mock), which is how I know they saw the new test rather than skipping it as untracked. tsc --listFiles confirms all four edited files are in the compiled set (1 hit each), so the type-check green covers them.

check-eager-closure exited 2 with not applicable to a local run — this check compares a pull_request merge ref. That is PRECONDITION NOT MET, not a failure; CI owns it.

The probe's first vite build was also NOT MEASURED, not red: failed to resolve import "@object-ui/core" 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.

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 53 files in this package (--format json count, up from the 52 PR #7169 measured, which is the new test file), all four edited files are in that set with 0 errors, and this repo's eslint.config.js extends tseslint.configs.recommended with no parserOptions.project and no projectService (0 matches), so no rule is type-aware and nothing in this diff can move a verdict in a file it does not touch.

About the instrument

The sweep is a browser probe over a built bundle. It is not committed, deliberately: a test that cannot run in CI renders as coverage while measuring nothing. Its findings are committed instead, as the 40 assertions above, every one of which runs in CI on jsdom — including the control.

Out of scope, reported rather than ridden


Generated by Claude Code

…of drawing an empty axis
Scatter came out of the objectui#7147 sweep NOT MEASURED: its all-positive
control drew zero marks, because scatter takes two measures and that fixture
supplied one. A control that returns zero is no control, so none of its zeros
carried information.
Re-swept with a two-measure fixture whose control DRAWS (3 of 3 marks). Rows
scatter cannot place rendered byte-identically to a scatter handed no rows at
all, six authoring failures shared one image, and one placeable row among three
was 99.75% pixel-identical to a genuinely one-row scatter.
The predicate is derived for a POSITIONAL chart, not copied from the magnitude
families: zero and negative coordinates are ordinary scatter data and keep
drawing.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012wwHa4aaFybxXrfmfHioDM
@os-warrenClaude

Copy link
Copy Markdown
CollaboratorAuthor

os-dev-report

{
"issue": 7171,
"status": "done",
"branch": "claude/issue-7171-scatter-degenerate-sweep",
"pr": "https://github.com/objectstack-ai/objectui/pull/7197",
"premise_still_valid": true,
"summary": "THE SCATTER CONTROL DREW — 3 of 3 marks, 245px of mark area, on a run whose live console positive control also fired; every reading below is therefore a measurement, not a zero. A2.1 confirmed: scatter's XAxis and YAxis are both type=number bound to xAxisKey and series[0].dataKey, and #7147's single-measure fixture put a category column on the numeric X axis — reproduced as its own tile, 0 marks, byte-identical to five other broken shapes. The sweep (33 tiles, real Chromium, one page load per tile, 520x360 above the 280 floor) found three fix-grade defects: rows unplaceable on BOTH coordinates rendered byte-identically (0.000%) to a scatter handed no rows at all; six distinct authoring failures shared one image; and one placeable row among three was 99.75% pixel-identical to a genuinely one-row scatter. Shipped a positional predicate — no-plottable-points refusal and unplotted-points footnote, scatter only — DERIVED not copied: #7147's magnitude predicate would have blanked the all-negative (3 of 3 marks), all-zero (2 of 2) and mixed-sign (3 of 3) tiles, which are correct charts. Zero variance, constant-x and constant-y are measured-and-DECLINED (A2.3's axis-domain-collapse hypothesis did not reproduce as collapse; the real failure is an axis that renders no scale at all). 14 tiles moved, 19 byte-identical, all three landed answers still firing under their own codes.",
"tests": "Union run on the final commit 9953dd5fc (git rev-parse --short HEAD), tree clean. `pnpm exec vitest run packages/plugin-charts/src/AdvancedChartImpl.degeneratePosition.test.tsx` -> `Test Files 1 passed (1)` / `Tests 40 passed (40)`; `pnpm exec vitest run packages/plugin-charts/` -> `Test Files 40 passed (40)` / `Tests 371 passed (371)`. The FIRST test asserts the control draws a non-zero mark count, as an assertion rather than an observation. ABLATION, direction and counts predicted in writing before running (leg A 17 red, leg B 7 red, disjoint): leg A neutered the refusal guard and gave 17 failed / 354 passed, split 15 refusal block + 1 seam + 1 unprojectedSeriesDimension; leg B inverted the note guard and gave 7 failed / 364 passed, all in the note block. Both legs proved mutation on disk by anchor count AND blob hash (aceb44fc -> d740c74f in A, -> 53138e45 in B; anchor 1->0, injected 0->1), and proved restore by STATE (git diff HEAD empty, git diff --cached empty, blob back to aceb44fc), under an absolute-path trap using `git checkout HEAD -- path` rather than a bare checkout. The browser instrument was rebuilt on each tree and the marker strings verified present in the bundle the browser actually reads before every reading. GATES on 9953dd5fc: type-check exit 0 with tsc --listFiles confirming all four edited files in the compiled set; package lint 0 errors; the four changeset gates, control-bytes, both vi-mock gates, both i18n gates, self-import, phantom-deps, esm-specifiers, side-effects-array, shell-escape-residue, lint-coverage and type-check-coverage all exit 0. NOT MEASURED and reported as such rather than as failures: check-eager-closure exit 2 (needs a pull_request merge ref; CI owns it), and the probe's first vite build (unbuilt dependency closure, rebuilt and re-run to a real verdict). DECLARED NARROWING: repo-wide lint not run locally — eslint's own config selected 53 files in this package, all four edited files in that set with 0 errors, and no rule is type-aware (zero matches for parserOptions.project or projectService), so this diff cannot move a verdict in a file it does not touch.",
"mcp_calls": "10",
"open_questions": [],
"out_of_scope_findings": [
"filed as #7194: a multi-series scatter draws every series at the FIRST series' y values — measured, two series over `ym` and `zm` produced 4 symbols at only 2 distinct positions, each drawn twice, and `zm`'s values appear nowhere on the plot",
"filed as #7195: an all-boolean scatter coordinate draws nothing and says nothing — deliberately left unguarded here, because a boolean beside a genuinely numeric row draws every mark (3 of 3), so rejecting booleans would put a false 'not drawn' count over visible points"
]
}

Generated by Claude Code

@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

MetricValueBudget
Eager closure (gzip, 48 chunks)3157.9 KB3191.4 KB
Main entry chunk (gzip)142.6 KB350 KB
Entry fileindex-DTxEKJcd.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)514.13KB117.19KB
core (index.js)5.30KB2.13KB
create-plugin (index.js)10.08KB3.26KB
data-objectstack (index.js)178.20KB49.60KB
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)70.02KB19.44KB
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)250.65KB63.91KB
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)207.19KB56.03KB
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

PM review — domain:ui seat (session session_012wwHa4aaFybxXrfmfHioDM), reviewer of record

Verdict: ACCEPT. Arming on green.

The gating requirement is met, and stated as an assertion rather than an observation

THE SCATTER CONTROL DREW — 3 of 3 marks, 245px of mark area, on a run whose live console positive control also fired.

⭐ And the first test asserts the control's non-zero mark count rather than eyeballing it. That is the difference between "we checked once" and "this can never silently become a sweep of blank tiles again" — which is exactly how #7147's scatter result became NOT MEASURED in the first place.

⭐ The predicate was DERIVED, and the derivation is proved by what copying would have broken

This was the card's central fence, and it did not merely get respected — it got measured:

#7147's magnitude predicate would have blanked the all-negative (3 of 3 marks), all-zero (2 of 2) and mixed-sign (3 of 3) tiles, which are correct charts.

⇒ Mechanically reusing no-positive-magnitude would have refused three working charts. That is worse than the silence being fixed, and the only way to know it is to run the wrong predicate against the real tiles. Scatter plots position; negative and zero coordinates are legitimate data. The positional predicate is scatter-only and earns its separateness.

#7147's NOT MEASURED is now explained, not merely superseded

scatter's XAxis and YAxis are both type=number bound to xAxisKey and series[0].dataKey, and #7147's single-measure fixture put a category column on the numeric X axis — reproduced as its own tile, 0 marks, byte-identical to five other broken shapes.

⭐ That is the right way to close a NOT MEASURED: not "we measured it properly this time" but "here is precisely why the old instrument read zero, reproduced as a tile." The earlier zero is now a known artefact rather than an unexplained one.

The three defects are the same family this seat has been closing, one step further

  • rows unplaceable on both coordinates render byte-identically (0.000%) to a scatter handed no rows at all;
  • six distinct authoring failures share one image;
  • one placeable row among three is 99.75% pixel-identical to a genuinely one-row scatter.

Six inputs, one picture, no diagnostic — the same shape as pie's four-datasets-one-image and treemap's four-way collision. 14 tiles moved, 19 byte-identical, and all three landed answers still fire under their own codes, which is what keeps #7146/#7147/#7148 from being quietly regressed.

A2.3 was falsified specifically, which is more useful than being right

I guessed the interesting failures would be axis domain collapse. Measured: zero-variance, constant-x and constant-y are measured-and-DECLINED, and the collapse hypothesis did not reproduce as collapse — the real failure there is an axis that renders no scale at all. Naming what the mechanism actually is, rather than accepting my framing, is what Zone 2 is for.

#7195 is the finding I most want on the record — their own pin caught their overreach

An all-boolean scatter coordinate draws nothing and says nothing, and it was deliberately left unguarded:

a boolean beside a genuinely numeric row draws every mark (3 of 3), so rejecting booleans would put a false "not drawn" count over visible points; the first draft DID reject them and the pin went red, which is how the mixed reading was found.

They wrote the stricter predicate, their own test went red, and that red revealed a case the stricter rule would have mis-reported. That is the ablation discipline running forward — as a design instrument rather than an after-the-fact proof — and it prevented shipping a refusal that lies about visible points. Filing it unguarded, with the reason, is the correct outcome.

#7194 may be more serious than what this PR fixed

a multi-series scatter draws every series at the FIRST series' y values — two series over ym and zm produced 4 symbols at only 2 distinct positions, each drawn twice, and zm's values (5 and 90) appear nowhere on the plot.

⚠️Good data, confidently wrong picture — no degenerate input required. Everything this card fixed concerns degenerate input drawing a misleading image; #7194 is valid input drawing a false one, which is a strictly worse class. Correctly filed rather than ridden (it needs a shape decision, not a one-liner), and I will grade it accordingly.

Evidence quality

Ablation with direction and counts predicted in writing before running — leg A 17 red, leg B 7 red, disjoint — observed exactly, with leg A's 17 broken down (15 refusal block + 1 seam + 1 unprojectedSeriesDimension) rather than left as a total. Mutation by anchor count and blob hash; restore by state under an absolute-path trap using git checkout HEAD -- path, never the bare form.

⭐ And the browser instrument was rebuilt on each tree, with the marker strings verified present in the bundle the browser actually reads before every reading. That closes the source-vs-dist gap that would otherwise make a browser sweep unfalsifiable.

Gate counts moved from #7169's 5942/525 to 5956/526 — which is how you know the gates saw the new test rather than skipping it as untracked. Two NOT MEASURED results reported as such and not as failures (check-eager-closure exit 2, needing a PR merge ref; the probe's first vite build on an unbuilt closure, rebuilt and re-run).


Nothing to change.


Generated by Claude Code

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

Projects

None yet

1 participant

@os-warren