Skip to content

fix(plugin-charts): name the scatter legend's series so its swatch stops reading as a stray point - #7398

Merged
hotlong merged 2 commits into
mainfrom
claude/issue-7248-scatter-y-domain
Sep 2, 2026
Merged

fix(plugin-charts): name the scatter legend's series so its swatch stops reading as a stray point#7398
hotlong merged 2 commits into
mainfrom
claude/issue-7248-scatter-y-domain

Conversation

@hotlong

@hotlong hotlong commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

Fixes #7248

The reported point is not a point — it is the legend swatch

The card reported the Chart Gallery scatter ("Estimate vs Progress") drawing a seventh
point below the x-axis at "x≈40, y≈-10", and asked for the y domain to include every
plotted value. Measured on the running showcase in real Chromium, that diagnosis is
false and the requested fix would have been in the wrong place.

ChartLegendContent resolves a label as config[nameKey || item.dataKey || 'value'].
A Scatter carries no dataKey — scatter's keys live on the XAxis/YAxis, not on
the mark — so the key collapsed to the literal string 'value', missed a config keyed
by measure name, and the legend entry rendered its colour swatch with no text beside
it. An 8x8 square in --chart-1, the same colour as the marks, sitting under the
x-axis.

Captured from recharts 3.10.1 rather than assumed, the scatter's legend payload item is:

{ color: '#3b82f6', type: 'circle', value: 'Avg Estimate',
  payload: { name: 'Avg Estimate', ... } }          // no `dataKey`

The series name was in value the whole time; the legend never read it.

Browser measurement — before and after

Showcase Chart Gallery, /apps/com.example.showcase/dashboard/showcase_chart_gallery,
real Chromium (Playwright 1.62.1), viewport 1440x1000, widget svg 510x350.

Before — plot area y 5..295, six marks, all inside it:

progress (x) avg_estimate (y) cx cy inside plot?
0 16.5 53 215.3 yes
45 40 256.4 101.7 yes
55 60 301.6 5 yes
80 24 414.6 179 yes
90 30 459.8 150 yes
100 12 505 237 yes

The seventh "point" was the legend swatch: cx 255, cy 341, innerText === "". The
arithmetic closes the identification — the plot bottom (y=0) is cy 295 and the y scale
is 4.835 px per unit, so cy 341 is y = -9.5, at x ≈ 45. That is the card's
"x≈40, y≈-10", to the pixel.

Swept viewport widths 1440, 1100, 980, 900, 820, 760, 700, 640, 560, 480: zero marks
outside the plot area at any width
, so the symptom was never width-dependent either.

After — plot area y 5..287, same six marks all inside (cy 209.5, 99, 5, 174.2, 146,
230.6), and the legend now reads Avg Estimate beside its swatch instead of painting an
anonymous dot.

The y domain was not the defect and is unchanged

Clamping the y domain — the fix the card asked for — would have created the bug it
described. Two adversarial fixtures are pinned in this PR: mixed-sign data
(ym: 40, -15, 25) draws 3 of 3 marks and all-negative data (ym: -40, -15) draws 2 of
2, because recharts already extends the domain to cover negative values. Forcing
[0, 'auto'] is exactly what would push a negative point below the baseline.

The live data was read from the running showcase API and rules out the "bad row"
hypothesis independently: 10 seeded tasks, every progress a finite non-negative
number, no null / negative / string values, aggregating to the six buckets above.

Two changes

  1. AdvancedChartImpl.tsx, scatter branch — pass nameKey={scatterYKey} so the
    legend resolves the measure's config entry, the same way the pie branch already
    passes nameKey={xAxisKey}.

  2. ChartContainerImpl.tsx, ChartLegendContent — fall back to item.value, the
    series name recharts itself put on the legend item, when the config lookup misses.

The second is a deliberate bounded in-place fix rather than a drive-by: it is the same
defect class, at the layer that decides whether a swatch gets a name, and it closes the
class instead of this one instance. The swatch renders unconditionally, so a config
miss must never leave it anonymous. The helper directly below it already documents the
pie form of this same bug ("legend swatches with NO text at all"); that one was closed
by making one family's key resolve, this closes the general case. Charts whose config
already resolves are unaffected — only a currently-empty label can change.

Reverse verification (ablation)

Each half was ablated independently from the committed state, with a restore trap on
absolute paths, the mutation proved on disk by HEAD-blob-hash comparison plus an anchor
count, and the tree confirmed clean (git diff HEAD empty) after every leg. No dist
is involved — the test imports ./AdvancedChartImpl, which imports
./ChartContainerImpl, both relative source paths — so no rebuild leg applies.

ablation mutation landed result
baseline (both halves) 7 passed
remove nameKey only 640e2290 → 52a84b18, anchor count 0 1 red — the config-resolution pin
remove ?? item.value only 117306ba → 525ac855, anchor count 0 2 red
remove both (origin/main behaviour) both, anchor counts 0 4 red, incl. the headline assertion

The first run of this ablation found a real problem and changed the PR: removing
nameKey alone left all assertions green, because the item.value fallback masks
it on the label. Rather than ship an unenforced change, a pin was added for the one
branch the fallback cannot stand in for — itemConfig.icon, which only resolves when
the config lookup hits, so a config-declared legend icon is silently ignored for scatter
without nameKey while every other family honours it. That pin is the single red in
ablation 1 above.

Per-facet answers to the dispatch

Gates

Run on the final commit d2537532b:

  • pnpm exec vitest run packages/plugin-charts/ --maxWorkers=242 files, 390 tests
    passed
    (run from the repo root; the package-scoped pnpm --filter ... test form is
    the silent-green trap AGENTS.md bans)
  • pnpm --filter @object-ui/plugin-charts type-check — exit 0. tsc --listFiles
    confirms both edited files and the new test are inside the program, so this green
    actually covers the diff rather than excluding the test file
  • pnpm exec eslint . in packages/plugin-charts — exit 0, 0 errors, 318 pre-existing
    warnings, 55 files linted (count read from --format json), all three touched
    files present in the linted set
  • changeset: .changeset/7248-scatter-legend-names-its-series.md, @object-ui/plugin-charts patch

Declared narrowing. Lint was run at package scope rather than repo-wide. The
population comes from eslint's own config resolution, the count from --format json
(55 files), and the config is not type-aware (no parserOptions.project /
projectService), so this diff cannot move the verdict on any untouched file. CI runs
the full farm regardless.

Verify-lock declaration, verbatim from the tool on every heavy run:

os-verify-lock: VERDICT command-exit 0 · UNLOCKED (declared) · no usable `flock` on this
host, so the shared verify lock was NEVER taken and NOTHING was serialized

Out-of-scope findings filed

  • objectui#7396 — a scatter's extreme points are half-clipped by the plot edge (the
    numeric axes get no domain padding). Measured here: plot x 53..505 with marks at
    cx 53 and cx 505, so about half of each extreme symbol paints outside the plot area.
    Real, different mechanism, and changing it would alter every scatter's appearance.
  • objectui#7397 — packages/components/src/ui/chart.tsx is an unreferenced duplicate of
    the plugin-charts chart primitives carrying this same legend hole. Imported by
    nothing and absent from the barrel, so it is currently unreachable; left untouched
    here rather than widening this PR across a second published package.

Generated by Claude Code

hotlong and others added 2 commits September 3, 2026 01:20
…ops reading as a stray point (#7248)

The Chart Gallery scatter appeared to draw a seventh point below the x-axis.
It was the legend swatch: ChartLegendContent resolves a label as
config[nameKey || item.dataKey || 'value'], and a <Scatter> carries no
dataKey, so the key collapsed to 'value', missed the config and rendered a
colour swatch with no text.

Measured in real Chromium: swatch at cy 341 against a plot area ending at
cy 295, scale 4.835px/unit -> y = -9.5 at x ~ 45, matching the report's
'x~40, y~-10'. All six real marks were inside the plot area at every
viewport width from 1440 down to 480, so the y domain is left untouched.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Ablation showed removing nameKey alone left every assertion green: the
item.value fallback masks it on the label. The config lookup is still
load-bearing for itemConfig.icon, which the fallback cannot stand in for,
so pin that branch instead of shipping an unenforced change.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@github-actions

github-actions Bot commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

Metric Value Budget
Eager closure (gzip, 48 chunks) 3177.2 KB 3191.4 KB
Main entry chunk (gzip) 143.2 KB 350 KB
Entry file index-CDQS-v_Q.js
Status PASS

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

Package Size Gzipped
app-shell (consoleActionDispatch.js) 0.20KB 0.19KB
app-shell (index.js) 15.67KB 5.75KB
app-shell (runtime-config.js) 20.68KB 7.36KB
app-shell (types.js) 0.01KB 0.04KB
app-shell (urlParams.js) 10.06KB 3.86KB
auth (ActiveOrganizationStorage.js) 25.05KB 9.16KB
auth (AuthContext.js) 0.31KB 0.24KB
auth (AuthGuard.js) 2.07KB 1.00KB
auth (AuthProvider.js) 40.18KB 10.59KB
auth (AuthShell.js) 3.49KB 1.40KB
auth (ForgotPasswordForm.js) 12.21KB 3.45KB
auth (LoginForm.js) 18.15KB 5.39KB
auth (PreviewBanner.js) 0.90KB 0.50KB
auth (RegisterForm.js) 6.65KB 2.22KB
auth (SocialSignInButtons.js) 9.61KB 3.89KB
auth (UserMenu.js) 3.41KB 1.23KB
auth (auth-gate-events.js) 1.29KB 0.66KB
auth (authStyles.js) 5.04KB 1.72KB
auth (createAuthClient.js) 40.21KB 10.80KB
auth (createAuthenticatedFetch.js) 8.46KB 3.43KB
auth (index.js) 3.19KB 1.44KB
auth (invitation-status.js) 1.22KB 0.70KB
auth (org-roles.js) 6.66KB 2.78KB
auth (phone-identifier.js) 1.11KB 0.66KB
auth (types.js) 0.59KB 0.35KB
auth (useAuth.js) 5.30KB 1.02KB
auth (useWorkspaceAdminStatus.js) 5.13KB 2.35KB
collaboration (CommentThread.js) 26.08KB 7.56KB
collaboration (LiveCursors.js) 3.17KB 1.27KB
collaboration (PresenceAvatars.js) 6.49KB 2.64KB
collaboration (PresenceProvider.js) 2.79KB 1.13KB
collaboration (index.js) 1.68KB 0.73KB
collaboration (useCollaborationTranslation.js) 6.05KB 2.52KB
collaboration (useCommentSearch.js) 1.98KB 0.88KB
collaboration (useConflictResolution.js) 7.75KB 1.86KB
collaboration (useMentionNotifications.js) 1.81KB 0.68KB
collaboration (usePresence.js) 6.33KB 1.84KB
collaboration (useRealtimeSubscription.js) 7.91KB 2.01KB
components (index.js) 514.87KB 117.50KB
core (index.js) 5.80KB 2.32KB
create-plugin (index.js) 10.08KB 3.26KB
data-objectstack (index.js) 178.20KB 49.60KB
fields (index.js) 244.08KB 61.71KB
i18n (LocalizationContext.js) 1.76KB 0.96KB
i18n (currency.js) 1.22KB 0.64KB
i18n (fallbackInterpolation.js) 6.25KB 2.77KB
i18n (i18n.js) 4.28KB 1.75KB
i18n (index.js) 3.44KB 1.39KB
i18n (pickLocalized.js) 7.62KB 3.26KB
i18n (provider.js) 26.89KB 9.04KB
i18n (useDisplayLocale.js) 2.85KB 1.45KB
i18n (useObjectLabel.js) 33.40KB 8.71KB
i18n (useSafeTranslation.js) 5.60KB 2.33KB
layout (index.js) 38.98KB 10.98KB
mobile (MobileProvider.js) 0.92KB 0.49KB
mobile (ResponsiveContainer.js) 0.94KB 0.38KB
mobile (breakpoints.js) 1.51KB 0.70KB
mobile (createOfflineDataSource.js) 5.61KB 1.75KB
mobile (index.js) 1.55KB 0.62KB
mobile (offlineQueue.js) 3.91KB 1.35KB
mobile (pwa.js) 0.97KB 0.49KB
mobile (serviceWorker.js) 1.48KB 0.62KB
mobile (serviceWorkerSource.js) 3.41KB 1.48KB
mobile (useBreakpoint.js) 1.54KB 0.65KB
mobile (useGesture.js) 6.96KB 1.98KB
mobile (useOfflineSync.js) 1.99KB 0.72KB
mobile (usePullToRefresh.js) 2.53KB 0.85KB
mobile (useResponsive.js) 0.72KB 0.42KB
mobile (useResponsiveConfig.js) 1.37KB 0.63KB
mobile (useSpecGesture.js) 4.32KB 1.64KB
mobile (useTouchTarget.js) 1.01KB 0.54KB
permissions (MePermissionsProvider.js) 11.71KB 4.29KB
permissions (PermissionContext.js) 0.31KB 0.25KB
permissions (PermissionGuard.js) 0.89KB 0.45KB
permissions (PermissionProvider.js) 6.24KB 2.16KB
permissions (discardProofCache.js) 1.04KB 0.55KB
permissions (evaluator.js) 5.12KB 1.74KB
permissions (index.js) 0.93KB 0.41KB
permissions (store.js) 0.91KB 0.42KB
permissions (useFieldPermissions.js) 1.28KB 0.53KB
permissions (usePermissions.js) 4.83KB 2.27KB
plugin-ai (index.js) 15.75KB 3.80KB
plugin-calendar (index.js) 47.00KB 12.97KB
plugin-charts (index.js) 70.31KB 19.55KB
plugin-chatbot (index.js) 196.19KB 46.43KB
plugin-dashboard (index.js) 132.63KB 34.56KB
plugin-designer (index.js) 212.87KB 43.19KB
plugin-detail (index.js) 251.20KB 64.18KB
plugin-editor (index.js) 2.46KB 1.10KB
plugin-form (index.js) 132.87KB 32.66KB
plugin-gantt (index.js) 166.93KB 40.82KB
plugin-grid (index.js) 209.10KB 56.65KB
plugin-kanban (index.js) 53.21KB 14.66KB
plugin-list (index.js) 113.51KB 27.67KB
plugin-map (index.js) 20.20KB 6.66KB
plugin-markdown (index.js) 13.72KB 4.69KB
plugin-report (index.js) 43.51KB 11.94KB
plugin-timeline (index.js) 30.21KB 8.66KB
plugin-tree (index.js) 8.98KB 3.08KB
plugin-view (index.js) 85.90KB 21.12KB
providers (DataSourceProvider.js) 0.75KB 0.39KB
providers (MetadataProvider.js) 1.37KB 0.59KB
providers (ThemeProvider.js) 1.90KB 0.85KB
providers (UploadProvider.js) 11.66KB 3.50KB
providers (index.js) 0.45KB 0.23KB
providers (types.js) 0.01KB 0.04KB
react-runtime (index.js) 5.62KB 2.34KB
react (LazyPluginLoader.js) 4.47KB 1.63KB
react (SchemaRenderer.js) 81.07KB 26.86KB
react (data-invalidation.js) 5.05KB 2.08KB
react (index.js) 3.11KB 1.48KB
react (schema-input.js) 2.32KB 1.24KB
react (spec-input.js) 0.20KB 0.18KB
sdui-parser (codegen.js) 5.41KB 2.34KB
sdui-parser (dashboard-widget-options.js) 3.08KB 1.30KB
sdui-parser (index.js) 4.93KB 2.24KB
sdui-parser (input-type.js) 2.84KB 1.40KB
sdui-parser (parse.js) 20.57KB 5.88KB
sdui-parser (provenance.js) 3.66KB 1.82KB
sdui-parser (types.js) 0.28KB 0.23KB
sdui-parser (validate.js) 10.35KB 3.60KB
types (ai.js) 0.20KB 0.17KB
types (api-types.js) 0.20KB 0.18KB
types (app.js) 2.87KB 0.99KB
types (base.js) 0.20KB 0.18KB
types (blocks.js) 0.20KB 0.18KB
types (complex.js) 2.74KB 1.41KB
types (crud.js) 0.20KB 0.18KB
types (dashboard-filter-alias.js) 6.23KB 2.74KB
types (data-display.js) 3.75KB 1.85KB
types (data-protocol.js) 0.20KB 0.19KB
types (data.js) 0.20KB 0.18KB
types (designer.js) 1.85KB 0.85KB
types (disclosure.js) 0.20KB 0.18KB
types (error-code.js) 1.54KB 0.88KB
types (feedback.js) 0.20KB 0.18KB
types (field-types.js) 0.20KB 0.18KB
types (form.js) 0.20KB 0.18KB
types (http-inflight.js) 8.87KB 3.73KB
types (http-retry.js) 4.32KB 2.02KB
types (icon-key-migration.js) 4.26KB 1.63KB
types (index.js) 4.74KB 2.25KB
types (layout.js) 0.20KB 0.18KB
types (managed-by.js) 0.19KB 0.18KB
types (mobile.js) 2.59KB 1.31KB
types (navigation.js) 0.20KB 0.18KB
types (objectql.js) 0.20KB 0.18KB
types (overlay.js) 0.20KB 0.18KB
types (permissions.js) 0.20KB 0.18KB
types (plugin-scope.js) 0.20KB 0.18KB
types (record-components.js) 0.20KB 0.19KB
types (record-semantics.js) 1.28KB 0.67KB
types (registry.js) 0.20KB 0.18KB
types (reports.js) 0.20KB 0.18KB
types (spec-report.js) 5.05KB 1.93KB
types (spec-ui-namespace.js) 0.20KB 0.19KB
types (system-fields.js) 3.33KB 1.54KB
types (theme.js) 6.28KB 2.87KB
types (ui-action.js) 8.11KB 3.32KB
types (views.js) 0.20KB 0.18KB
types (widget.js) 0.20KB 0.18KB

Size Limits

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

@hotlong
hotlong marked this pull request as ready for review September 2, 2026 17:43
@hotlong
hotlong added this pull request to the merge queue Sep 2, 2026
Merged via the queue into main with commit bb459ea Sep 2, 2026
32 checks passed
@hotlong
hotlong deleted the claude/issue-7248-scatter-y-domain branch September 2, 2026 17:58
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

plugin-charts: the Chart Gallery scatter ("Estimate vs Progress") draws one point below the x-axis, outside the plot area

1 participant