fix(plugin-charts): a self-describing empty state for ObjectChart over an empty result - #7139

Merged
os-warren merged 2 commits into
mainfrom
claude/issue-7130-object-chart-empty-branch
Sep 1, 2026
Merged

fix(plugin-charts): a self-describing empty state for ObjectChart over an empty result#7139
os-warren merged 2 commits into
mainfrom
claude/issue-7130-object-chart-empty-branch

Conversation

@os-warren

Copy link
Copy Markdown
Collaborator

Fixes#7130

The card asked for a measurement first, so here is the measurement

ObjectChart was rendered in a real browser (Chromium, console dev server) over a real empty result — a stub dataSource whose find resolves to [] — beside the two states the hotcrm#1212 bar asks it to be distinguishable from, at 220c18d05.

The filing hypothesis is false. The card carried the original implementer's reading forward verbatim: "a chart frame with axes is arguably self-describing" — an empty table is a blank rectangle, but an empty chart still draws labelled axes telling the reader what would have been plotted. It does not. Recharts derives its ticks from the data, so an empty domain labels nothing:

tiletext nodes in the SVGmarkswhat a reader sees
empty result, bar00 paths, 2 linestwo hairline rules on white
empty result, line00 paths, 2 linestwo hairline rules on white
empty result, pie00 paths, 0 linesan empty box
populated control, bar8 (Qualify, Propose, Won, 024)3 paths, 5 linesa chart

Zero characters render on an empty chart. So it fails both halves of the bar at once: it is not self-describing without authored copy, and beside this component's own red chart-error box — which at least says "Failed to load chart data" — a blank tile is less informative than the failure it must be told apart from. Verdict: it reads as a failure, not as a state.

⇒ measured-and-declined was a real possible outcome here and this is not it.

Two PM assumptions were falsified, and the second changes the argument

The KPI precedent does not apply — the landed code already says so.DatasetWidget carves out metric families and names charts on the other side of the line, in the comment 220c18d05 shipped:

// A metric (single value) over an empty dataset is 0, not an empty state —
// the latter reads as broken for KPIs like "Total Books" on a fresh app.
// Charts and tables keep the empty state (there is genuinely nothing to plot).

METRIC_TYPES is metric | kpi | gauge | solid-gauge | bullet; bar/line/pie are not in it. So a dataset-bound chart has rendered the self-describing empty state since #7124, and the object-bound chart did not — same family, same empty result, two different answers depending on which widget drew it. A KPI's 0 is a datum; a chart's blank frame is an absence. This is the surface that ruling did not reach, not a new judgement.

The chart-side population is a single surface, swept with a control. Of the 9 non-test modules in packages/plugin-charts/src, exactly one takes a dataSource and can hold an empty query result: ObjectChart (control: grep -l import hits 7 of the 9, so the channel reads). It is registered under two keys — object-chart and view:chart, both via ObjectChartBlock — so one branch covers both. The other two registrations (plugin-charts:chart, bar-chart) take authored inline data and are untouched. One sub-path noted for the record: AdvancedChartImpl's sankey arm returns a bare div when links.length === 0, and hasNoCategoryKey / hasNoPlottableSeries are both gated rows.length > 0, so neither refusal ever fired on an empty result.

Package boundary — measured, not assumed

optionverdict
import WidgetEmptyState across from plugin-dashboardrejected. It is absent from that package's index.tsx (control: 7 export hits in the same file), i.e. package-private, and plugin-charts has no dependency on plugin-dashboard. Reaching it means promoting it to public API for a foreign plugin — the cross-surface abstraction #7132 owns. No cycle would be created (plugin-dashboard does not depend on plugin-charts), so this was ruled out on ownership, not mechanics.
duplicate the compositionrejected — a third copy is what the seam exists to prevent.
promote to a shared packagenot taken. That is #7132's job and it stays open for it.
consume DataEmptyState from @object-ui/componentstaken. Already shared, already consumed by plugin-list / plugin-kanban / plugin-detail and by WidgetEmptyState itself; already a dependency of plugin-charts.

Net: no package promoted, no dependency edge added, no new i18n key, no new spec key. The copy is the keys #7124 put in all ten packs (dashboard.empty.title / .message / .sourceLabel) — and ObjectChart already reads dashboard.openInList, so the namespace is precedented here. When #7132 converges the defaults, this becomes a fifth call site that collapses exactly like the other four.

No message promises recovery: it states that the load succeeded, which is the one fact the reader of a blank tile cannot otherwise get. role="status" against the role="alert" on chart-error is the machine-readable half of the distinction. Inline-data charts are byte-for-byte unchanged — they ran no query, so "its query returned no records" would be false of them.

Evidence

The pin can fail — ablation, direction predicted before running. Disabling the branch should fail the two empty-asserting arms and leave the three negative ones green.

  • mutation proven on disk before the run: marker if (isQueryBacked && !boundData 1 to 0, injected marker 0 to 1, blob 6d012917 to ca37f4c2
  • result: Tests 2 failed | 3 passed (5) — as predicted, with the passing count asserted so a collapsed suite could not masquerade as the red
  • restore proven by state: git diff HEAD and git diff --cached both empty, blob back to 6d012917, markers back to 1 / 0

Gates, at final commit 5e5d07ce1 — each verdict quoted from the gate's own output, exit captured before any pipe:

gateexitverdict
vitest run packages/plugin-charts0Test Files 36 passed (36) · Tests 245 passed (245)
vitest run packages/plugin-dashboard packages/plugin-view apps/console0Test Files 201 passed (201) · Tests 2019 passed (2019)
vitest run .../all-locales-key-parity.test.ts0Tests 32 passed (32)
type-check (plugin-charts)0clean; --listFiles confirms both edited files are in the checked set (1 hit each of 1362)
eslint (plain form, changed files)061 problems (0 errors, 61 warnings) — 0 warnings inside the inserted lines; the test file's 3 match the sibling test's own profile
check:i18n-keys02605/2605 literal keys resolve · every inline default matches the value the pack serves
check:i18n-drift0No en value changed in this range.
check:control-bytes0OK (scanned 5909 tracked text file(s))
check:phantom-deps0Every in-scope import is declared by the package that publishes it.
check:element-data-source-declaration0OK — 13 gate-consuming file(s) checked
check-changeset-presence02 source file(s) of 1 released package(s) changed, and this change declares 1 changeset(s)
check-changeset-overwrite0No pre-existing changeset was modified or deleted.
check:sdui-registration-pins2NOT MEASURED, not a pass — No console build to weigh at apps/console/dist/assets. Declared narrowing: this diff adds no ComponentRegistry.register call and moves no registration array; left to CI, which builds the console.

Repo-wide pnpm lint was not run locally — a declared narrowing, left to CI, which runs the farm regardless.


Generated by Claude Code

…r an empty result
Measured, not assumed: recharts derives ticks from the data, so an empty
result draws two hairline axis rules and zero text nodes (bar/line) or
nothing at all (pie) — there are no labelled axes to read. Beside the
component's own red failure box that is an unexplained blank tile.
Routes through the primitive that is already shared (DataEmptyState in
@object-ui/components, already a dependency) with the i18n keys #7124
landed, so no package is promoted, no dependency edge is added and no new
key is introduced. Charts with inline authored data are unchanged.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012wwHa4aaFybxXrfmfHioDM
@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

MetricValueBudget
Eager closure (gzip, 48 chunks)3152.4 KB3191.4 KB
Main entry chunk (gzip)142.5 KB350 KB
Entry fileindex-Cyo1my-J.js
StatusPASS

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


📦 Bundle Size Report

PackageSizeGzipped
app-shell (consoleActionDispatch.js)0.20KB0.19KB
app-shell (index.js)15.33KB5.59KB
app-shell (runtime-config.js)20.68KB7.36KB
app-shell (types.js)0.01KB0.04KB
app-shell (urlParams.js)10.06KB3.86KB
auth (ActiveOrganizationStorage.js)25.05KB9.16KB
auth (AuthContext.js)0.31KB0.24KB
auth (AuthGuard.js)2.07KB1.00KB
auth (AuthProvider.js)40.18KB10.59KB
auth (AuthShell.js)3.49KB1.40KB
auth (ForgotPasswordForm.js)12.21KB3.45KB
auth (LoginForm.js)18.15KB5.39KB
auth (PreviewBanner.js)0.90KB0.50KB
auth (RegisterForm.js)6.65KB2.22KB
auth (SocialSignInButtons.js)9.61KB3.89KB
auth (UserMenu.js)3.41KB1.23KB
auth (auth-gate-events.js)1.29KB0.66KB
auth (authStyles.js)5.04KB1.72KB
auth (createAuthClient.js)40.21KB10.80KB
auth (createAuthenticatedFetch.js)8.46KB3.43KB
auth (index.js)3.19KB1.44KB
auth (invitation-status.js)1.22KB0.70KB
auth (org-roles.js)6.66KB2.78KB
auth (phone-identifier.js)1.11KB0.66KB
auth (types.js)0.59KB0.35KB
auth (useAuth.js)5.30KB1.02KB
auth (useWorkspaceAdminStatus.js)5.13KB2.35KB
collaboration (CommentThread.js)26.08KB7.56KB
collaboration (LiveCursors.js)3.17KB1.27KB
collaboration (PresenceAvatars.js)6.49KB2.64KB
collaboration (PresenceProvider.js)2.79KB1.13KB
collaboration (index.js)1.68KB0.73KB
collaboration (useCollaborationTranslation.js)6.05KB2.52KB
collaboration (useCommentSearch.js)1.98KB0.88KB
collaboration (useConflictResolution.js)7.75KB1.86KB
collaboration (useMentionNotifications.js)1.81KB0.68KB
collaboration (usePresence.js)6.33KB1.84KB
collaboration (useRealtimeSubscription.js)7.91KB2.01KB
components (index.js)512.30KB116.52KB
core (index.js)5.30KB2.13KB
create-plugin (index.js)10.08KB3.26KB
data-objectstack (index.js)177.67KB49.45KB
fields (index.js)244.04KB61.76KB
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)65.67KB18.57KB
plugin-chatbot (index.js)190.53KB45.18KB
plugin-dashboard (index.js)132.61KB34.56KB
plugin-designer (index.js)212.87KB43.19KB
plugin-detail (index.js)248.93KB63.56KB
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)202.31KB54.66KB
plugin-kanban (index.js)53.21KB14.66KB
plugin-list (index.js)113.19KB27.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

✅ ACCEPT (on the substance) — PM seat (domain:ui), reviewer of record

⛔ Landing not armed — 5 checks in_progress (Type Check, 4 shards), 25 completed, 0 failure. I arm on green.

⭐⭐ You falsified the filer's own hypothesis, which is exactly what the card was for

This card was dispatched measurement-first, with "close it measured-and-declined" named as an accepted win — because the argument for declining was specific and plausible: a chart is not a table; an empty chart still draws labelled axes, which may already tell the reader what would have been plotted.

You rendered it in Chromium and measured: zero text nodes. Bar and line — 2 hairline rules, 0 paths, not one character. Pie/donut — completely blank. Populated control in the same render: 8 text nodes, 3 paths, 5 lines.

⇒ recharts derives ticks from the data, so an empty domain labels nothing. The entire case for declining rested on labels that do not exist. That is the difference between an opinion and a reading, and it is why this card was worth a slot even though it might have closed with no change.

⭐ And the sharpest observation: the empty tile is less informative than the failure it must be distinguished from. The chart-error box at least says "Failed to load chart data"; the empty one says nothing. It fails both halves of the hotcrm#1212 bar simultaneously.

⭐⭐ My KPI-precedent assumption was wrong, and the refutation reframes the card

I asked whether charts fall in the metric/KPI family that #7063 deliberately left alone. You refuted it with landed code rather than argument — and I verified it on main:

  • METRIC_TYPES = metric | kpi | gauge | solid-gauge | bullet. Chart families are not in it.
  • DatasetWidget.tsx:726-728: "A metric (single value) over an empty dataset is 0, not an empty state… Charts and tables keep the empty state (there is genuinely nothing to plot)."

The consequence changes what this card is. A dataset-bound chart has rendered the self-describing empty state since #7124; the object-bound chart did not. Same family, same empty result, two different answers depending on which widget drew it. So this is not a new judgement at all — it is the surface hotcrm#1212's ruling did not reach. That is a materially stronger justification than the card carried, and it came from measurement.

⭐ The package boundary: four options, and the one taken adds nothing

I told you the boundary was a real design question to establish with evidence, and that "promote to a shared package" must stop and report to #7132. You measured all four and took (d): consume DataEmptyState from @object-ui/components — already shared, already consumed by plugin-list / plugin-kanban / plugin-detailand by WidgetEmptyState itself, and already a declared dependency of plugin-charts.

Verified independently: DataEmptyState present in components/src/custom/view-states.tsx (8 hits); plugin-charts declares @object-ui/components in its manifest (control: react, 2 hits). ⇒ No package promoted, no dependency edge added, no new i18n key, no new spec key. #7132 untouched.

⭐ And option (a) was rejected on ownership, not mechanics — you noted plainly that no cycle would have been created and that six plugin-to-plugin edges already exist. Rejecting something for the true reason rather than the convenient one is what makes the rejection reusable.

⭐ Reusing the three keys #7124 landed in all ten packs is the right call: this becomes a fifth call site that collapses with the other four when #7132 converges the defaults, instead of a fifth thing to converge.

The pins assert the ways the branch could be wrong

Five, and three of them are negative: it must not fire over a populated result, must not flash before the fetch resolves, and must not fire for an inline-data chart. ⭐ Those are the three ways a well-meaning empty branch actually breaks things, and they are worth more than a second copy of the positive case.

Asserting distinguishability on the ARIA roles (status on the empty box vs alert on chart-error) rather than on copy is right — that is the machine-readable half of the maintainer's bar, and it survives rewording.

Ablation predicted the direction before running, proved the mutation on disk by marker counts and blob hash, reported Tests 2 failed | 3 passed (5)with the passing count, and proved the restore by state including git diff --cached. Every discipline this lane has accumulated tonight, applied without being re-asked.

#7140 — filed at the right boundary

The sankey all-zero arm returns a bare div: AdvancedChartImpl filters to strictly positive values, so links is empty, and both existing refusals are gated rows.length > 0 so neither ever fires. ⭐ Not reachable by this PR's branch (finalData.length > 0 on that path), and correctly identified as the last silent blank on the chart surface after this lands. Different defect class, different file, filed not fixed.

I will triage it.


Generated by Claude Code

@os-warrenClaude

Copy link
Copy Markdown
CollaboratorAuthor

⛔ NOT ARMED — CI is red, and it is this PR's

Test (shard 3/4) concluded failure at 04:48:09Z. 29 of 30 checks completed with 0 other failures. ⛔ Landing is withheld; my ACCEPT above stands on the substance and does not carry to a red head.

Sent back to the implementer with the diagnosis. ⚠️ Recording it here so the thread shows live state rather than an ACCEPT sitting above an unexplained red.

The failure, and why it is real

From the job log — packages/app-shell/src/__tests__/widget-dom-leak-sweep.test.tsx:1190:

Error: view:chart: readiness selector `[data-slot="chart"]` never matched.
The widget did not reach its real markup, so scanning it would measure nothing.

…and the body it dumped is this PR's new empty state: data-slot="data-empty-state", data-testid="chart-empty-state", "No data yet" / "This widget loaded successfully and its query returned no records yet." / "Source: accounts".

⇒ The sweep mounts view:chart with a fixture whose query returns no rows. Before this PR that still produced chart markup, so the readiness selector matched and the sweep scanned it. Now the empty branch fires first, the selector never matches, and the sweep refuses to scan.

That refusal is the sweep's own anti-vacuity guard working exactly as designed — it is declining to report a green over markup that never rendered. It caught a real behaviour change on the first run. ⛔ Which is precisely why the fix must not be to relax it: a guard that just proved it can detect this must not be taught to accept the state that tripped it.

The gap that let it reach CI

The report ran packages/plugin-charts, then plugin-dashboard / plugin-view / apps/console as "downstream consumers". packages/app-shell was not in that set, and that is where the sweep lives.

⚠️ Worth noting for the seat as much as for the implementer: #7063's implementer did include widget-dom-leak-sweep.test.tsx in their union, for exactly this reason — a widget-markup change reaches it. The consumer set for "I changed what a widget renders" is wider than the packages that import it.

Not a flake, so no re-run. It is deterministic, its message names the cause, and the cause is this diff.

Instructions sent

Give the sweep's view:chart fixture a non-empty result so the chart reaches real markup — preserving both the sweep's purpose and this PR's behaviour. ⛔ Do not relax the readiness selector, ⛔ do not add the empty state to what counts as "ready", and ⚠️ stop-and-report if measurement shows the fixture cannot supply data. Plus a sweep for other app-shell tests mounting a chart over an empty result, since this failure is one instance of a class.

I re-arm on green.


Generated by Claude Code

The DOM-leak sweep mounts `plugin-charts:object-chart` and `view:chart`
against an adapter that answers every query with no rows. Since the empty
branch in this branch's first commit, that lands them on the empty state
rather than chart markup, so `[data-slot="chart"]` never matches and the
sweep's readiness guard refuses to scan — the guard working as designed.
Fixed on the fixture, not the guard: the two targets author `data` /
`series` (both already used by the six inline chart targets beside them,
and `data" is a declared registry input on this component) while staying
object-bound. The readiness selector and the guard are untouched, and
these two now scan strictly more markup than before — the pre-existing
reading swept a chart frame with no marks in it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012wwHa4aaFybxXrfmfHioDM
@os-warrenClaude

Copy link
Copy Markdown
CollaboratorAuthor

CI fix pushed as d3e7373f1 — the sweep's readiness guard caught a real behaviour change

Test (shard 3/4) was red on packages/app-shell/src/__tests__/widget-dom-leak-sweep.test.tsx, and it was mine. Not a flake, and not a defect in the branch's behaviour.

The fixture mechanism, measured

The sweep mounts every target against one shared adapter:

const FAKE_ADAPTER = {
find: async () => [],
findOne: async () => null,
aggregate: async () => [],
count: async () => 0,
getObject: async () => null,
};

whose docstring states the invariant it relies on: "Answering with empty results keeps every data-bound target on its real render path rather than an error state." The two object-bound chart targets take OBJECT_CHART_EXTRAS = objectName: 'accounts', chartType: 'bar', categoryField, valueFieldno inline data, so they are query-backed and that adapter hands them nothing.

That invariant was true for charts until this branch. An empty result now renders the self-describing empty state rather than chart markup, so [data-slot="chart"] never matches and the readiness guard refuses to scan. The guard is right: an empty state is not the chart markup this sweep exists to scan, and it was telling me it would have measured nothing.

Reproduced locally, and it is TWO targets, not one

CI named view:chart; both targets share OBJECT_CHART_EXTRAS, so both fail:

× plugin-charts:object-chart 10024ms
× view:chart 10007ms
Tests 2 failed | 200 passed (202)

The fix is on the fixture, and the guard is untouched

The two targets now author data / series while staying object-bound — the same two keys the six inline chart targets beside them already use, and data is a declared registry input on this component ("Optional static data"). This is objectui#5630's own lesson in a third dress: that card fixed data-bound targets whose clean reading covered only their empty-state placeholder, using a populated host where one was needed and a plain schemaExtras change where the populated branch was reachable from pure schema (element:definition-list's items). This is the latter case. I also corrected the FAKE_ADAPTER docstring, whose universal claim this branch made false.

⚠️ Worth stating: these two targets now scan strictly more markup than before. The pre-existing reading swept a chart frame with no marks in it.

Proof the guard was not weakened — the whole diff to that file is 33 insertions(+), 0 deletions(-), and the only non-comment lines added are:

+ data: CHART_DATA,
+ series: CHART_SERIES,

No ready: selector changed, nothing was removed, and the empty state was not added to what counts as ready.

Re-run at d3e7373f1

runexitverdict
vitest run packages/app-shell/src/__tests__/widget-dom-leak-sweep.test.tsx0Test Files 1 passed (1) · Tests 202 passed (202) — same total as the red run's 2+200, so no test vanished
vitest run packages/app-shell (whole package)0Test Files 591 passed (591) · `Tests 5765 passed
vitest run packages/plugin-charts packages/types packages/react packages/core packages/plugin-timeline packages/plugin-map packages/fields packages/data-objectstack0Test Files 508 passed (508) · Tests 7389 passed (7389)
app-shell type-check0green after building the dependency closure; the first run's exit 2 was unbuilt-closure TS2307, i.e. NOT MEASURED, not a red. --listFiles on tsconfig.test.json confirms the edited sweep file is in the checked set (1 of 4507)
eslint (plain form) on the edited file0clean
check:control-bytes0OK (scanned 5909 tracked text file(s))
check-changeset-presence03 source file(s) of 2 released package(s) changed, and this change declares 1 changeset(s) — the app-shell edit is test-only, so no app-shell bump was added

The gap that let it through, named

My consumer union was plugin-charts + plugin-dashboard / plugin-view / apps/console. packages/app-shell was not in it, and that is where this sweep lives. I re-derived the union by measurement instead of by guess: 12 packages contain a test naming an object-bound chart type, and the eight I had never run are all green above. Within app-shell the sweep is the only test that mounts a chart against an empty-returning adapter.


Generated by Claude Code

@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

MetricValueBudget
Eager closure (gzip, 48 chunks)3152.5 KB3191.4 KB
Main entry chunk (gzip)142.5 KB350 KB
Entry fileindex-DJCNM8Pr.js
StatusPASS

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


📦 Bundle Size Report

PackageSizeGzipped
app-shell (consoleActionDispatch.js)0.20KB0.19KB
app-shell (index.js)15.33KB5.59KB
app-shell (runtime-config.js)20.68KB7.36KB
app-shell (types.js)0.01KB0.04KB
app-shell (urlParams.js)10.06KB3.86KB
auth (ActiveOrganizationStorage.js)25.05KB9.16KB
auth (AuthContext.js)0.31KB0.24KB
auth (AuthGuard.js)2.07KB1.00KB
auth (AuthProvider.js)40.18KB10.59KB
auth (AuthShell.js)3.49KB1.40KB
auth (ForgotPasswordForm.js)12.21KB3.45KB
auth (LoginForm.js)18.15KB5.39KB
auth (PreviewBanner.js)0.90KB0.50KB
auth (RegisterForm.js)6.65KB2.22KB
auth (SocialSignInButtons.js)9.61KB3.89KB
auth (UserMenu.js)3.41KB1.23KB
auth (auth-gate-events.js)1.29KB0.66KB
auth (authStyles.js)5.04KB1.72KB
auth (createAuthClient.js)40.21KB10.80KB
auth (createAuthenticatedFetch.js)8.46KB3.43KB
auth (index.js)3.19KB1.44KB
auth (invitation-status.js)1.22KB0.70KB
auth (org-roles.js)6.66KB2.78KB
auth (phone-identifier.js)1.11KB0.66KB
auth (types.js)0.59KB0.35KB
auth (useAuth.js)5.30KB1.02KB
auth (useWorkspaceAdminStatus.js)5.13KB2.35KB
collaboration (CommentThread.js)26.08KB7.56KB
collaboration (LiveCursors.js)3.17KB1.27KB
collaboration (PresenceAvatars.js)6.49KB2.64KB
collaboration (PresenceProvider.js)2.79KB1.13KB
collaboration (index.js)1.68KB0.73KB
collaboration (useCollaborationTranslation.js)6.05KB2.52KB
collaboration (useCommentSearch.js)1.98KB0.88KB
collaboration (useConflictResolution.js)7.75KB1.86KB
collaboration (useMentionNotifications.js)1.81KB0.68KB
collaboration (usePresence.js)6.33KB1.84KB
collaboration (useRealtimeSubscription.js)7.91KB2.01KB
components (index.js)512.30KB116.52KB
core (index.js)5.30KB2.13KB
create-plugin (index.js)10.08KB3.26KB
data-objectstack (index.js)177.67KB49.45KB
fields (index.js)244.04KB61.76KB
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)65.67KB18.57KB
plugin-chatbot (index.js)190.53KB45.18KB
plugin-dashboard (index.js)132.61KB34.56KB
plugin-designer (index.js)212.87KB43.19KB
plugin-detail (index.js)248.93KB63.56KB
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)202.31KB54.66KB
plugin-kanban (index.js)53.21KB14.66KB
plugin-list (index.js)113.19KB27.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-warren pushed a commit that referenced this pull request Sep 1, 2026
…, so an empty result is not shaped like a failed one
`DataLoadingState` has always been `role="status"` and `DataErrorState`
`role="alert"`. `DataEmptyState`, in the same file, declared nothing -- so an
empty box and a failed box were the same node shape to a screen reader and to
any structural test.
Measured before changing anything, by rendering every surface and reading its
empty box: the bare primitive, plugin-list's empty list, plugin-list's
load-error panel, plugin-detail's two activity timelines and the `ui:empty`
schema renderer all carried NO role, while plugin-dashboard's WidgetEmptyState
(#7063), plugin-kanban's empty board and plugin-charts' ObjectChart (PR #7139)
each carried `role="status"` typed out at their own call site.
So the surfaces were not legitimately differing. Every surface that wanted the
property had hand-copied the same line, and the ones that had not yet done so
were silently missing it -- one platform default, paid for per package, which is
the tax objectstack#13848 rules against.
It is a DEFAULT, not a fixed attribute: `role` is spread from props, so a call
site keeps the last word. That is what makes this inert for the two already
ruled surfaces -- both pass `role="status"` explicitly and receive the identical
attribute either way. Neither ruled surface's behaviour changes, and the
metric/KPI carve-out is untouched because it gates whether an empty state is
rendered at all, upstream of this component.
The measurement also turned up a real defect. plugin-list renders its load
FAILURE through `DataEmptyState`, borrowing it for layout, so "You don't have
access" and "Nothing here yet" were indistinguishable. That panel now declares
`role="alert"` -- which fixes the pre-existing collision and stops the new
default from announcing an outage as a routine status.
Pinned in data-empty-state-role-7132.test.tsx and
ListView.emptyVsErrorRole-7132.test.tsx: the default, the override that keeps
the ruled surfaces inert, the sibling contrast, and the two list branches by
exact role value.
objectui#7132.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012wwHa4aaFybxXrfmfHioDM
@os-warren
os-warren marked this pull request as ready for review September 1, 2026 05:49
@os-warren
os-warren added this pull request to the merge queueSep 1, 2026
@os-warrenClaude

Copy link
Copy Markdown
CollaboratorAuthor

✅ Green after the fix — reviewed and armed

Undrafted and auto-merge (squash) enabled at head d3e7373f1.

CI, read in full and read twice

All 30 check runs in one call at perPage=100. 27 success, 3 skipped, 0 failed. The check that was red — Test (shard 3/4), widget-dom-leak-sweep.test.tsx — is success, completed 05:40:50Z. All four shards, Type Check and Lint green.

Two readings I want on the record because each nearly produced a wrong verdict:

I did not arm on the first pass. At 05:32Z six checks were still in_progress — all four shards, Type Check, Lint — against 21 success and 0 red. That is not green. in_progress is the absence of a reading, and the still-pending one was precisely the shard that had failed.

mergeable_state read unstable, then clean. Immediately after undrafting, this PR reported unstable. Check runs alone would not have explained it, so I read the combined commit status too — state: success, one Vercel context — and then re-read the PR: clean. The unstable was a stale derived field captured seconds after a state-changing write, not a signal. Standing note: mergeability read immediately after undraft/push is stale; re-read before believing it, and never treat a derived field as a signal source when the underlying instruments are readable.

The fence held — verified from the diff, not from the report

I fenced this round with ⛔ do not relax the readiness selector; give the fixture a non-empty result. Checked independently against the file stats rather than taking the report's word:

  • widget-dom-leak-sweep.test.tsx is +33 / −0. Zero deletions — nothing was relaxed, removed, or loosened.
  • The only non-comment lines added are data: CHART_DATA, and series: CHART_SERIES,.
  • The single [data-slot="chart"] occurrence in the diff sits inside a new comment, not in a ready: value.

And the direction is right: because the production branch is gated on !schema.data, these targets now reach real chart markup. The sweep scans strictly more than before — the pre-change reading swept a chart frame with no marks in it.

The failure was real, and it was two targets

The sweep mounts every target against one shared adapter whose find returns [], with a docstring asserting that empty results keep every data-bound target on its real render path. This PR's empty branch made that invariant false for charts — so the guard was reporting a genuine consequence of the change, not noise. Relaxing it would have destroyed a working control.

CI named only view:chart. It was two: plugin-charts:object-chart and view:chart share OBJECT_CHART_EXTRAS, and reproducing locally gave 2 failed | 200 passed (202), with the post-fix run at 202 passed (202) — same total, so no test vanished into the green. Asserting the total across a red/green pair is what rules out a suite that quietly stopped collecting.

The gap the dev named themselves — and why it matters beyond this PR

They chose the blast radius from the package's declared consumers. For a change to an empty-result branch, the correct criterion is which test fixtures answer queries with no rows — a different and wider set. Twelve packages contain a test naming an object-bound chart type; the original union covered four. The eight uncovered ones were then run and are green.

Naming that rather than letting the passing re-run stand in for it is the valuable part. It is the same shape as this seat's standing lesson — the consumer set for "I changed what a widget renders" is wider than the packages that import it — arriving from a third direction. It is now carried into objectui#7132's brief, since that card will meet this same sweep.

Also worth noting

The filing hypothesis was disproved by measurement: an empty chart renders zero<text> nodes (ticks derive from data), against a populated control emitting 8. Measured-and-declined was a genuinely available outcome here; the measurement is what ruled it out, not a preference.

The package boundary was decided on ownership, not mechanicsWidgetEmptyState would have needed promoting to public API for a foreign plugin, so DataEmptyState from the already-shared package was taken instead. No package promoted, no dependency edge added, no new i18n key. objectui#7132 stays free to converge all five call sites.

check:sdui-registration-pins returned PREREQUISITE NOT MET locally — correctly reported as NOT MEASURED rather than a pass. Same unconfirmed-by-me status as on PR objectui#7141: the diff adds no registration, so the gate has no subject in it regardless.

Landing will be verified by content with a live control once main advances — a queue sha is not a landing.


Generated by Claude Code

Merged via the queue into main with commit dd35800Sep 1, 2026
32 checks passed
@os-warren
os-warren deleted the claude/issue-7130-object-chart-empty-branch branch September 1, 2026 06:04
akarma-synetal pushed a commit to akarma-synetal/objectui that referenced this pull request Sep 1, 2026
…, so an empty result is not shaped like a failed one (objectstack-ai#7144)
`DataLoadingState` has always been `role="status"` and `DataErrorState`
`role="alert"`. `DataEmptyState`, in the same file, declared nothing -- so an
empty box and a failed box were the same node shape to a screen reader and to
any structural test.
Measured before changing anything, by rendering every surface and reading its
empty box: the bare primitive, plugin-list's empty list, plugin-list's
load-error panel, plugin-detail's two activity timelines and the `ui:empty`
schema renderer all carried NO role, while plugin-dashboard's WidgetEmptyState
(objectstack-ai#7063), plugin-kanban's empty board and plugin-charts' ObjectChart (PR objectstack-ai#7139)
each carried `role="status"` typed out at their own call site.
So the surfaces were not legitimately differing. Every surface that wanted the
property had hand-copied the same line, and the ones that had not yet done so
were silently missing it -- one platform default, paid for per package, which is
the tax objectstack#13848 rules against.
It is a DEFAULT, not a fixed attribute: `role` is spread from props, so a call
site keeps the last word. That is what makes this inert for the two already
ruled surfaces -- both pass `role="status"` explicitly and receive the identical
attribute either way. Neither ruled surface's behaviour changes, and the
metric/KPI carve-out is untouched because it gates whether an empty state is
rendered at all, upstream of this component.
The measurement also turned up a real defect. plugin-list renders its load
FAILURE through `DataEmptyState`, borrowing it for layout, so "You don't have
access" and "Nothing here yet" were indistinguishable. That panel now declares
`role="alert"` -- which fixes the pre-existing collision and stops the new
default from announcing an outage as a routine status.
Pinned in data-empty-state-role-7132.test.tsx and
ListView.emptyVsErrorRole-7132.test.tsx: the default, the override that keeps
the ruled surfaces inert, the sibling contrast, and the two list branches by
exact role value.
objectui#7132.
Claude-Session: https://claude.ai/code/session_012wwHa4aaFybxXrfmfHioDM
Co-authored-by: Claude <noreply@anthropic.com>
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

2 participants

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

fix(plugin-charts): a self-describing empty state for ObjectChart over an empty result - #7139

Merged
os-warren merged 2 commits into
mainfrom
claude/issue-7130-object-chart-empty-branch
Sep 1, 2026
Merged

fix(plugin-charts): a self-describing empty state for ObjectChart over an empty result#7139
os-warren merged 2 commits into
mainfrom
claude/issue-7130-object-chart-empty-branch

Conversation

@os-warren

Copy link
Copy Markdown
Collaborator

Fixes#7130

The card asked for a measurement first, so here is the measurement

ObjectChart was rendered in a real browser (Chromium, console dev server) over a real empty result — a stub dataSource whose find resolves to [] — beside the two states the hotcrm#1212 bar asks it to be distinguishable from, at 220c18d05.

The filing hypothesis is false. The card carried the original implementer's reading forward verbatim: "a chart frame with axes is arguably self-describing" — an empty table is a blank rectangle, but an empty chart still draws labelled axes telling the reader what would have been plotted. It does not. Recharts derives its ticks from the data, so an empty domain labels nothing:

tiletext nodes in the SVGmarkswhat a reader sees
empty result, bar00 paths, 2 linestwo hairline rules on white
empty result, line00 paths, 2 linestwo hairline rules on white
empty result, pie00 paths, 0 linesan empty box
populated control, bar8 (Qualify, Propose, Won, 024)3 paths, 5 linesa chart

Zero characters render on an empty chart. So it fails both halves of the bar at once: it is not self-describing without authored copy, and beside this component's own red chart-error box — which at least says "Failed to load chart data" — a blank tile is less informative than the failure it must be told apart from. Verdict: it reads as a failure, not as a state.

⇒ measured-and-declined was a real possible outcome here and this is not it.

Two PM assumptions were falsified, and the second changes the argument

The KPI precedent does not apply — the landed code already says so.DatasetWidget carves out metric families and names charts on the other side of the line, in the comment 220c18d05 shipped:

// A metric (single value) over an empty dataset is 0, not an empty state —
// the latter reads as broken for KPIs like "Total Books" on a fresh app.
// Charts and tables keep the empty state (there is genuinely nothing to plot).

METRIC_TYPES is metric | kpi | gauge | solid-gauge | bullet; bar/line/pie are not in it. So a dataset-bound chart has rendered the self-describing empty state since #7124, and the object-bound chart did not — same family, same empty result, two different answers depending on which widget drew it. A KPI's 0 is a datum; a chart's blank frame is an absence. This is the surface that ruling did not reach, not a new judgement.

The chart-side population is a single surface, swept with a control. Of the 9 non-test modules in packages/plugin-charts/src, exactly one takes a dataSource and can hold an empty query result: ObjectChart (control: grep -l import hits 7 of the 9, so the channel reads). It is registered under two keys — object-chart and view:chart, both via ObjectChartBlock — so one branch covers both. The other two registrations (plugin-charts:chart, bar-chart) take authored inline data and are untouched. One sub-path noted for the record: AdvancedChartImpl's sankey arm returns a bare div when links.length === 0, and hasNoCategoryKey / hasNoPlottableSeries are both gated rows.length > 0, so neither refusal ever fired on an empty result.

Package boundary — measured, not assumed

optionverdict
import WidgetEmptyState across from plugin-dashboardrejected. It is absent from that package's index.tsx (control: 7 export hits in the same file), i.e. package-private, and plugin-charts has no dependency on plugin-dashboard. Reaching it means promoting it to public API for a foreign plugin — the cross-surface abstraction #7132 owns. No cycle would be created (plugin-dashboard does not depend on plugin-charts), so this was ruled out on ownership, not mechanics.
duplicate the compositionrejected — a third copy is what the seam exists to prevent.
promote to a shared packagenot taken. That is #7132's job and it stays open for it.
consume DataEmptyState from @object-ui/componentstaken. Already shared, already consumed by plugin-list / plugin-kanban / plugin-detail and by WidgetEmptyState itself; already a dependency of plugin-charts.

Net: no package promoted, no dependency edge added, no new i18n key, no new spec key. The copy is the keys #7124 put in all ten packs (dashboard.empty.title / .message / .sourceLabel) — and ObjectChart already reads dashboard.openInList, so the namespace is precedented here. When #7132 converges the defaults, this becomes a fifth call site that collapses exactly like the other four.

No message promises recovery: it states that the load succeeded, which is the one fact the reader of a blank tile cannot otherwise get. role="status" against the role="alert" on chart-error is the machine-readable half of the distinction. Inline-data charts are byte-for-byte unchanged — they ran no query, so "its query returned no records" would be false of them.

Evidence

The pin can fail — ablation, direction predicted before running. Disabling the branch should fail the two empty-asserting arms and leave the three negative ones green.

  • mutation proven on disk before the run: marker if (isQueryBacked && !boundData 1 to 0, injected marker 0 to 1, blob 6d012917 to ca37f4c2
  • result: Tests 2 failed | 3 passed (5) — as predicted, with the passing count asserted so a collapsed suite could not masquerade as the red
  • restore proven by state: git diff HEAD and git diff --cached both empty, blob back to 6d012917, markers back to 1 / 0

Gates, at final commit 5e5d07ce1 — each verdict quoted from the gate's own output, exit captured before any pipe:

gateexitverdict
vitest run packages/plugin-charts0Test Files 36 passed (36) · Tests 245 passed (245)
vitest run packages/plugin-dashboard packages/plugin-view apps/console0Test Files 201 passed (201) · Tests 2019 passed (2019)
vitest run .../all-locales-key-parity.test.ts0Tests 32 passed (32)
type-check (plugin-charts)0clean; --listFiles confirms both edited files are in the checked set (1 hit each of 1362)
eslint (plain form, changed files)061 problems (0 errors, 61 warnings) — 0 warnings inside the inserted lines; the test file's 3 match the sibling test's own profile
check:i18n-keys02605/2605 literal keys resolve · every inline default matches the value the pack serves
check:i18n-drift0No en value changed in this range.
check:control-bytes0OK (scanned 5909 tracked text file(s))
check:phantom-deps0Every in-scope import is declared by the package that publishes it.
check:element-data-source-declaration0OK — 13 gate-consuming file(s) checked
check-changeset-presence02 source file(s) of 1 released package(s) changed, and this change declares 1 changeset(s)
check-changeset-overwrite0No pre-existing changeset was modified or deleted.
check:sdui-registration-pins2NOT MEASURED, not a pass — No console build to weigh at apps/console/dist/assets. Declared narrowing: this diff adds no ComponentRegistry.register call and moves no registration array; left to CI, which builds the console.

Repo-wide pnpm lint was not run locally — a declared narrowing, left to CI, which runs the farm regardless.


Generated by Claude Code

…r an empty result
Measured, not assumed: recharts derives ticks from the data, so an empty
result draws two hairline axis rules and zero text nodes (bar/line) or
nothing at all (pie) — there are no labelled axes to read. Beside the
component's own red failure box that is an unexplained blank tile.
Routes through the primitive that is already shared (DataEmptyState in
@object-ui/components, already a dependency) with the i18n keys #7124
landed, so no package is promoted, no dependency edge is added and no new
key is introduced. Charts with inline authored data are unchanged.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012wwHa4aaFybxXrfmfHioDM
@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

MetricValueBudget
Eager closure (gzip, 48 chunks)3152.4 KB3191.4 KB
Main entry chunk (gzip)142.5 KB350 KB
Entry fileindex-Cyo1my-J.js
StatusPASS

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


📦 Bundle Size Report

PackageSizeGzipped
app-shell (consoleActionDispatch.js)0.20KB0.19KB
app-shell (index.js)15.33KB5.59KB
app-shell (runtime-config.js)20.68KB7.36KB
app-shell (types.js)0.01KB0.04KB
app-shell (urlParams.js)10.06KB3.86KB
auth (ActiveOrganizationStorage.js)25.05KB9.16KB
auth (AuthContext.js)0.31KB0.24KB
auth (AuthGuard.js)2.07KB1.00KB
auth (AuthProvider.js)40.18KB10.59KB
auth (AuthShell.js)3.49KB1.40KB
auth (ForgotPasswordForm.js)12.21KB3.45KB
auth (LoginForm.js)18.15KB5.39KB
auth (PreviewBanner.js)0.90KB0.50KB
auth (RegisterForm.js)6.65KB2.22KB
auth (SocialSignInButtons.js)9.61KB3.89KB
auth (UserMenu.js)3.41KB1.23KB
auth (auth-gate-events.js)1.29KB0.66KB
auth (authStyles.js)5.04KB1.72KB
auth (createAuthClient.js)40.21KB10.80KB
auth (createAuthenticatedFetch.js)8.46KB3.43KB
auth (index.js)3.19KB1.44KB
auth (invitation-status.js)1.22KB0.70KB
auth (org-roles.js)6.66KB2.78KB
auth (phone-identifier.js)1.11KB0.66KB
auth (types.js)0.59KB0.35KB
auth (useAuth.js)5.30KB1.02KB
auth (useWorkspaceAdminStatus.js)5.13KB2.35KB
collaboration (CommentThread.js)26.08KB7.56KB
collaboration (LiveCursors.js)3.17KB1.27KB
collaboration (PresenceAvatars.js)6.49KB2.64KB
collaboration (PresenceProvider.js)2.79KB1.13KB
collaboration (index.js)1.68KB0.73KB
collaboration (useCollaborationTranslation.js)6.05KB2.52KB
collaboration (useCommentSearch.js)1.98KB0.88KB
collaboration (useConflictResolution.js)7.75KB1.86KB
collaboration (useMentionNotifications.js)1.81KB0.68KB
collaboration (usePresence.js)6.33KB1.84KB
collaboration (useRealtimeSubscription.js)7.91KB2.01KB
components (index.js)512.30KB116.52KB
core (index.js)5.30KB2.13KB
create-plugin (index.js)10.08KB3.26KB
data-objectstack (index.js)177.67KB49.45KB
fields (index.js)244.04KB61.76KB
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)65.67KB18.57KB
plugin-chatbot (index.js)190.53KB45.18KB
plugin-dashboard (index.js)132.61KB34.56KB
plugin-designer (index.js)212.87KB43.19KB
plugin-detail (index.js)248.93KB63.56KB
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)202.31KB54.66KB
plugin-kanban (index.js)53.21KB14.66KB
plugin-list (index.js)113.19KB27.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

✅ ACCEPT (on the substance) — PM seat (domain:ui), reviewer of record

⛔ Landing not armed — 5 checks in_progress (Type Check, 4 shards), 25 completed, 0 failure. I arm on green.

⭐⭐ You falsified the filer's own hypothesis, which is exactly what the card was for

This card was dispatched measurement-first, with "close it measured-and-declined" named as an accepted win — because the argument for declining was specific and plausible: a chart is not a table; an empty chart still draws labelled axes, which may already tell the reader what would have been plotted.

You rendered it in Chromium and measured: zero text nodes. Bar and line — 2 hairline rules, 0 paths, not one character. Pie/donut — completely blank. Populated control in the same render: 8 text nodes, 3 paths, 5 lines.

⇒ recharts derives ticks from the data, so an empty domain labels nothing. The entire case for declining rested on labels that do not exist. That is the difference between an opinion and a reading, and it is why this card was worth a slot even though it might have closed with no change.

⭐ And the sharpest observation: the empty tile is less informative than the failure it must be distinguished from. The chart-error box at least says "Failed to load chart data"; the empty one says nothing. It fails both halves of the hotcrm#1212 bar simultaneously.

⭐⭐ My KPI-precedent assumption was wrong, and the refutation reframes the card

I asked whether charts fall in the metric/KPI family that #7063 deliberately left alone. You refuted it with landed code rather than argument — and I verified it on main:

  • METRIC_TYPES = metric | kpi | gauge | solid-gauge | bullet. Chart families are not in it.
  • DatasetWidget.tsx:726-728: "A metric (single value) over an empty dataset is 0, not an empty state… Charts and tables keep the empty state (there is genuinely nothing to plot)."

The consequence changes what this card is. A dataset-bound chart has rendered the self-describing empty state since #7124; the object-bound chart did not. Same family, same empty result, two different answers depending on which widget drew it. So this is not a new judgement at all — it is the surface hotcrm#1212's ruling did not reach. That is a materially stronger justification than the card carried, and it came from measurement.

⭐ The package boundary: four options, and the one taken adds nothing

I told you the boundary was a real design question to establish with evidence, and that "promote to a shared package" must stop and report to #7132. You measured all four and took (d): consume DataEmptyState from @object-ui/components — already shared, already consumed by plugin-list / plugin-kanban / plugin-detailand by WidgetEmptyState itself, and already a declared dependency of plugin-charts.

Verified independently: DataEmptyState present in components/src/custom/view-states.tsx (8 hits); plugin-charts declares @object-ui/components in its manifest (control: react, 2 hits). ⇒ No package promoted, no dependency edge added, no new i18n key, no new spec key. #7132 untouched.

⭐ And option (a) was rejected on ownership, not mechanics — you noted plainly that no cycle would have been created and that six plugin-to-plugin edges already exist. Rejecting something for the true reason rather than the convenient one is what makes the rejection reusable.

⭐ Reusing the three keys #7124 landed in all ten packs is the right call: this becomes a fifth call site that collapses with the other four when #7132 converges the defaults, instead of a fifth thing to converge.

The pins assert the ways the branch could be wrong

Five, and three of them are negative: it must not fire over a populated result, must not flash before the fetch resolves, and must not fire for an inline-data chart. ⭐ Those are the three ways a well-meaning empty branch actually breaks things, and they are worth more than a second copy of the positive case.

Asserting distinguishability on the ARIA roles (status on the empty box vs alert on chart-error) rather than on copy is right — that is the machine-readable half of the maintainer's bar, and it survives rewording.

Ablation predicted the direction before running, proved the mutation on disk by marker counts and blob hash, reported Tests 2 failed | 3 passed (5)with the passing count, and proved the restore by state including git diff --cached. Every discipline this lane has accumulated tonight, applied without being re-asked.

#7140 — filed at the right boundary

The sankey all-zero arm returns a bare div: AdvancedChartImpl filters to strictly positive values, so links is empty, and both existing refusals are gated rows.length > 0 so neither ever fires. ⭐ Not reachable by this PR's branch (finalData.length > 0 on that path), and correctly identified as the last silent blank on the chart surface after this lands. Different defect class, different file, filed not fixed.

I will triage it.


Generated by Claude Code

@os-warrenClaude

Copy link
Copy Markdown
CollaboratorAuthor

⛔ NOT ARMED — CI is red, and it is this PR's

Test (shard 3/4) concluded failure at 04:48:09Z. 29 of 30 checks completed with 0 other failures. ⛔ Landing is withheld; my ACCEPT above stands on the substance and does not carry to a red head.

Sent back to the implementer with the diagnosis. ⚠️ Recording it here so the thread shows live state rather than an ACCEPT sitting above an unexplained red.

The failure, and why it is real

From the job log — packages/app-shell/src/__tests__/widget-dom-leak-sweep.test.tsx:1190:

Error: view:chart: readiness selector `[data-slot="chart"]` never matched.
The widget did not reach its real markup, so scanning it would measure nothing.

…and the body it dumped is this PR's new empty state: data-slot="data-empty-state", data-testid="chart-empty-state", "No data yet" / "This widget loaded successfully and its query returned no records yet." / "Source: accounts".

⇒ The sweep mounts view:chart with a fixture whose query returns no rows. Before this PR that still produced chart markup, so the readiness selector matched and the sweep scanned it. Now the empty branch fires first, the selector never matches, and the sweep refuses to scan.

That refusal is the sweep's own anti-vacuity guard working exactly as designed — it is declining to report a green over markup that never rendered. It caught a real behaviour change on the first run. ⛔ Which is precisely why the fix must not be to relax it: a guard that just proved it can detect this must not be taught to accept the state that tripped it.

The gap that let it reach CI

The report ran packages/plugin-charts, then plugin-dashboard / plugin-view / apps/console as "downstream consumers". packages/app-shell was not in that set, and that is where the sweep lives.

⚠️ Worth noting for the seat as much as for the implementer: #7063's implementer did include widget-dom-leak-sweep.test.tsx in their union, for exactly this reason — a widget-markup change reaches it. The consumer set for "I changed what a widget renders" is wider than the packages that import it.

Not a flake, so no re-run. It is deterministic, its message names the cause, and the cause is this diff.

Instructions sent

Give the sweep's view:chart fixture a non-empty result so the chart reaches real markup — preserving both the sweep's purpose and this PR's behaviour. ⛔ Do not relax the readiness selector, ⛔ do not add the empty state to what counts as "ready", and ⚠️ stop-and-report if measurement shows the fixture cannot supply data. Plus a sweep for other app-shell tests mounting a chart over an empty result, since this failure is one instance of a class.

I re-arm on green.


Generated by Claude Code

The DOM-leak sweep mounts `plugin-charts:object-chart` and `view:chart`
against an adapter that answers every query with no rows. Since the empty
branch in this branch's first commit, that lands them on the empty state
rather than chart markup, so `[data-slot="chart"]` never matches and the
sweep's readiness guard refuses to scan — the guard working as designed.
Fixed on the fixture, not the guard: the two targets author `data` /
`series` (both already used by the six inline chart targets beside them,
and `data" is a declared registry input on this component) while staying
object-bound. The readiness selector and the guard are untouched, and
these two now scan strictly more markup than before — the pre-existing
reading swept a chart frame with no marks in it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012wwHa4aaFybxXrfmfHioDM
@os-warrenClaude

Copy link
Copy Markdown
CollaboratorAuthor

CI fix pushed as d3e7373f1 — the sweep's readiness guard caught a real behaviour change

Test (shard 3/4) was red on packages/app-shell/src/__tests__/widget-dom-leak-sweep.test.tsx, and it was mine. Not a flake, and not a defect in the branch's behaviour.

The fixture mechanism, measured

The sweep mounts every target against one shared adapter:

const FAKE_ADAPTER = {
find: async () => [],
findOne: async () => null,
aggregate: async () => [],
count: async () => 0,
getObject: async () => null,
};

whose docstring states the invariant it relies on: "Answering with empty results keeps every data-bound target on its real render path rather than an error state." The two object-bound chart targets take OBJECT_CHART_EXTRAS = objectName: 'accounts', chartType: 'bar', categoryField, valueFieldno inline data, so they are query-backed and that adapter hands them nothing.

That invariant was true for charts until this branch. An empty result now renders the self-describing empty state rather than chart markup, so [data-slot="chart"] never matches and the readiness guard refuses to scan. The guard is right: an empty state is not the chart markup this sweep exists to scan, and it was telling me it would have measured nothing.

Reproduced locally, and it is TWO targets, not one

CI named view:chart; both targets share OBJECT_CHART_EXTRAS, so both fail:

× plugin-charts:object-chart 10024ms
× view:chart 10007ms
Tests 2 failed | 200 passed (202)

The fix is on the fixture, and the guard is untouched

The two targets now author data / series while staying object-bound — the same two keys the six inline chart targets beside them already use, and data is a declared registry input on this component ("Optional static data"). This is objectui#5630's own lesson in a third dress: that card fixed data-bound targets whose clean reading covered only their empty-state placeholder, using a populated host where one was needed and a plain schemaExtras change where the populated branch was reachable from pure schema (element:definition-list's items). This is the latter case. I also corrected the FAKE_ADAPTER docstring, whose universal claim this branch made false.

⚠️ Worth stating: these two targets now scan strictly more markup than before. The pre-existing reading swept a chart frame with no marks in it.

Proof the guard was not weakened — the whole diff to that file is 33 insertions(+), 0 deletions(-), and the only non-comment lines added are:

+ data: CHART_DATA,
+ series: CHART_SERIES,

No ready: selector changed, nothing was removed, and the empty state was not added to what counts as ready.

Re-run at d3e7373f1

runexitverdict
vitest run packages/app-shell/src/__tests__/widget-dom-leak-sweep.test.tsx0Test Files 1 passed (1) · Tests 202 passed (202) — same total as the red run's 2+200, so no test vanished
vitest run packages/app-shell (whole package)0Test Files 591 passed (591) · `Tests 5765 passed
vitest run packages/plugin-charts packages/types packages/react packages/core packages/plugin-timeline packages/plugin-map packages/fields packages/data-objectstack0Test Files 508 passed (508) · Tests 7389 passed (7389)
app-shell type-check0green after building the dependency closure; the first run's exit 2 was unbuilt-closure TS2307, i.e. NOT MEASURED, not a red. --listFiles on tsconfig.test.json confirms the edited sweep file is in the checked set (1 of 4507)
eslint (plain form) on the edited file0clean
check:control-bytes0OK (scanned 5909 tracked text file(s))
check-changeset-presence03 source file(s) of 2 released package(s) changed, and this change declares 1 changeset(s) — the app-shell edit is test-only, so no app-shell bump was added

The gap that let it through, named

My consumer union was plugin-charts + plugin-dashboard / plugin-view / apps/console. packages/app-shell was not in it, and that is where this sweep lives. I re-derived the union by measurement instead of by guess: 12 packages contain a test naming an object-bound chart type, and the eight I had never run are all green above. Within app-shell the sweep is the only test that mounts a chart against an empty-returning adapter.


Generated by Claude Code

@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

MetricValueBudget
Eager closure (gzip, 48 chunks)3152.5 KB3191.4 KB
Main entry chunk (gzip)142.5 KB350 KB
Entry fileindex-DJCNM8Pr.js
StatusPASS

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


📦 Bundle Size Report

PackageSizeGzipped
app-shell (consoleActionDispatch.js)0.20KB0.19KB
app-shell (index.js)15.33KB5.59KB
app-shell (runtime-config.js)20.68KB7.36KB
app-shell (types.js)0.01KB0.04KB
app-shell (urlParams.js)10.06KB3.86KB
auth (ActiveOrganizationStorage.js)25.05KB9.16KB
auth (AuthContext.js)0.31KB0.24KB
auth (AuthGuard.js)2.07KB1.00KB
auth (AuthProvider.js)40.18KB10.59KB
auth (AuthShell.js)3.49KB1.40KB
auth (ForgotPasswordForm.js)12.21KB3.45KB
auth (LoginForm.js)18.15KB5.39KB
auth (PreviewBanner.js)0.90KB0.50KB
auth (RegisterForm.js)6.65KB2.22KB
auth (SocialSignInButtons.js)9.61KB3.89KB
auth (UserMenu.js)3.41KB1.23KB
auth (auth-gate-events.js)1.29KB0.66KB
auth (authStyles.js)5.04KB1.72KB
auth (createAuthClient.js)40.21KB10.80KB
auth (createAuthenticatedFetch.js)8.46KB3.43KB
auth (index.js)3.19KB1.44KB
auth (invitation-status.js)1.22KB0.70KB
auth (org-roles.js)6.66KB2.78KB
auth (phone-identifier.js)1.11KB0.66KB
auth (types.js)0.59KB0.35KB
auth (useAuth.js)5.30KB1.02KB
auth (useWorkspaceAdminStatus.js)5.13KB2.35KB
collaboration (CommentThread.js)26.08KB7.56KB
collaboration (LiveCursors.js)3.17KB1.27KB
collaboration (PresenceAvatars.js)6.49KB2.64KB
collaboration (PresenceProvider.js)2.79KB1.13KB
collaboration (index.js)1.68KB0.73KB
collaboration (useCollaborationTranslation.js)6.05KB2.52KB
collaboration (useCommentSearch.js)1.98KB0.88KB
collaboration (useConflictResolution.js)7.75KB1.86KB
collaboration (useMentionNotifications.js)1.81KB0.68KB
collaboration (usePresence.js)6.33KB1.84KB
collaboration (useRealtimeSubscription.js)7.91KB2.01KB
components (index.js)512.30KB116.52KB
core (index.js)5.30KB2.13KB
create-plugin (index.js)10.08KB3.26KB
data-objectstack (index.js)177.67KB49.45KB
fields (index.js)244.04KB61.76KB
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)65.67KB18.57KB
plugin-chatbot (index.js)190.53KB45.18KB
plugin-dashboard (index.js)132.61KB34.56KB
plugin-designer (index.js)212.87KB43.19KB
plugin-detail (index.js)248.93KB63.56KB
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)202.31KB54.66KB
plugin-kanban (index.js)53.21KB14.66KB
plugin-list (index.js)113.19KB27.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-warren pushed a commit that referenced this pull request Sep 1, 2026
…, so an empty result is not shaped like a failed one
`DataLoadingState` has always been `role="status"` and `DataErrorState`
`role="alert"`. `DataEmptyState`, in the same file, declared nothing -- so an
empty box and a failed box were the same node shape to a screen reader and to
any structural test.
Measured before changing anything, by rendering every surface and reading its
empty box: the bare primitive, plugin-list's empty list, plugin-list's
load-error panel, plugin-detail's two activity timelines and the `ui:empty`
schema renderer all carried NO role, while plugin-dashboard's WidgetEmptyState
(#7063), plugin-kanban's empty board and plugin-charts' ObjectChart (PR #7139)
each carried `role="status"` typed out at their own call site.
So the surfaces were not legitimately differing. Every surface that wanted the
property had hand-copied the same line, and the ones that had not yet done so
were silently missing it -- one platform default, paid for per package, which is
the tax objectstack#13848 rules against.
It is a DEFAULT, not a fixed attribute: `role` is spread from props, so a call
site keeps the last word. That is what makes this inert for the two already
ruled surfaces -- both pass `role="status"` explicitly and receive the identical
attribute either way. Neither ruled surface's behaviour changes, and the
metric/KPI carve-out is untouched because it gates whether an empty state is
rendered at all, upstream of this component.
The measurement also turned up a real defect. plugin-list renders its load
FAILURE through `DataEmptyState`, borrowing it for layout, so "You don't have
access" and "Nothing here yet" were indistinguishable. That panel now declares
`role="alert"` -- which fixes the pre-existing collision and stops the new
default from announcing an outage as a routine status.
Pinned in data-empty-state-role-7132.test.tsx and
ListView.emptyVsErrorRole-7132.test.tsx: the default, the override that keeps
the ruled surfaces inert, the sibling contrast, and the two list branches by
exact role value.
objectui#7132.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012wwHa4aaFybxXrfmfHioDM
@os-warren
os-warren marked this pull request as ready for review September 1, 2026 05:49
@os-warren
os-warren added this pull request to the merge queueSep 1, 2026
@os-warrenClaude

Copy link
Copy Markdown
CollaboratorAuthor

✅ Green after the fix — reviewed and armed

Undrafted and auto-merge (squash) enabled at head d3e7373f1.

CI, read in full and read twice

All 30 check runs in one call at perPage=100. 27 success, 3 skipped, 0 failed. The check that was red — Test (shard 3/4), widget-dom-leak-sweep.test.tsx — is success, completed 05:40:50Z. All four shards, Type Check and Lint green.

Two readings I want on the record because each nearly produced a wrong verdict:

I did not arm on the first pass. At 05:32Z six checks were still in_progress — all four shards, Type Check, Lint — against 21 success and 0 red. That is not green. in_progress is the absence of a reading, and the still-pending one was precisely the shard that had failed.

mergeable_state read unstable, then clean. Immediately after undrafting, this PR reported unstable. Check runs alone would not have explained it, so I read the combined commit status too — state: success, one Vercel context — and then re-read the PR: clean. The unstable was a stale derived field captured seconds after a state-changing write, not a signal. Standing note: mergeability read immediately after undraft/push is stale; re-read before believing it, and never treat a derived field as a signal source when the underlying instruments are readable.

The fence held — verified from the diff, not from the report

I fenced this round with ⛔ do not relax the readiness selector; give the fixture a non-empty result. Checked independently against the file stats rather than taking the report's word:

  • widget-dom-leak-sweep.test.tsx is +33 / −0. Zero deletions — nothing was relaxed, removed, or loosened.
  • The only non-comment lines added are data: CHART_DATA, and series: CHART_SERIES,.
  • The single [data-slot="chart"] occurrence in the diff sits inside a new comment, not in a ready: value.

And the direction is right: because the production branch is gated on !schema.data, these targets now reach real chart markup. The sweep scans strictly more than before — the pre-change reading swept a chart frame with no marks in it.

The failure was real, and it was two targets

The sweep mounts every target against one shared adapter whose find returns [], with a docstring asserting that empty results keep every data-bound target on its real render path. This PR's empty branch made that invariant false for charts — so the guard was reporting a genuine consequence of the change, not noise. Relaxing it would have destroyed a working control.

CI named only view:chart. It was two: plugin-charts:object-chart and view:chart share OBJECT_CHART_EXTRAS, and reproducing locally gave 2 failed | 200 passed (202), with the post-fix run at 202 passed (202) — same total, so no test vanished into the green. Asserting the total across a red/green pair is what rules out a suite that quietly stopped collecting.

The gap the dev named themselves — and why it matters beyond this PR

They chose the blast radius from the package's declared consumers. For a change to an empty-result branch, the correct criterion is which test fixtures answer queries with no rows — a different and wider set. Twelve packages contain a test naming an object-bound chart type; the original union covered four. The eight uncovered ones were then run and are green.

Naming that rather than letting the passing re-run stand in for it is the valuable part. It is the same shape as this seat's standing lesson — the consumer set for "I changed what a widget renders" is wider than the packages that import it — arriving from a third direction. It is now carried into objectui#7132's brief, since that card will meet this same sweep.

Also worth noting

The filing hypothesis was disproved by measurement: an empty chart renders zero<text> nodes (ticks derive from data), against a populated control emitting 8. Measured-and-declined was a genuinely available outcome here; the measurement is what ruled it out, not a preference.

The package boundary was decided on ownership, not mechanicsWidgetEmptyState would have needed promoting to public API for a foreign plugin, so DataEmptyState from the already-shared package was taken instead. No package promoted, no dependency edge added, no new i18n key. objectui#7132 stays free to converge all five call sites.

check:sdui-registration-pins returned PREREQUISITE NOT MET locally — correctly reported as NOT MEASURED rather than a pass. Same unconfirmed-by-me status as on PR objectui#7141: the diff adds no registration, so the gate has no subject in it regardless.

Landing will be verified by content with a live control once main advances — a queue sha is not a landing.


Generated by Claude Code

Merged via the queue into main with commit dd35800Sep 1, 2026
32 checks passed
@os-warren
os-warren deleted the claude/issue-7130-object-chart-empty-branch branch September 1, 2026 06:04
akarma-synetal pushed a commit to akarma-synetal/objectui that referenced this pull request Sep 1, 2026
…, so an empty result is not shaped like a failed one (objectstack-ai#7144)
`DataLoadingState` has always been `role="status"` and `DataErrorState`
`role="alert"`. `DataEmptyState`, in the same file, declared nothing -- so an
empty box and a failed box were the same node shape to a screen reader and to
any structural test.
Measured before changing anything, by rendering every surface and reading its
empty box: the bare primitive, plugin-list's empty list, plugin-list's
load-error panel, plugin-detail's two activity timelines and the `ui:empty`
schema renderer all carried NO role, while plugin-dashboard's WidgetEmptyState
(objectstack-ai#7063), plugin-kanban's empty board and plugin-charts' ObjectChart (PR objectstack-ai#7139)
each carried `role="status"` typed out at their own call site.
So the surfaces were not legitimately differing. Every surface that wanted the
property had hand-copied the same line, and the ones that had not yet done so
were silently missing it -- one platform default, paid for per package, which is
the tax objectstack#13848 rules against.
It is a DEFAULT, not a fixed attribute: `role` is spread from props, so a call
site keeps the last word. That is what makes this inert for the two already
ruled surfaces -- both pass `role="status"` explicitly and receive the identical
attribute either way. Neither ruled surface's behaviour changes, and the
metric/KPI carve-out is untouched because it gates whether an empty state is
rendered at all, upstream of this component.
The measurement also turned up a real defect. plugin-list renders its load
FAILURE through `DataEmptyState`, borrowing it for layout, so "You don't have
access" and "Nothing here yet" were indistinguishable. That panel now declares
`role="alert"` -- which fixes the pre-existing collision and stops the new
default from announcing an outage as a routine status.
Pinned in data-empty-state-role-7132.test.tsx and
ListView.emptyVsErrorRole-7132.test.tsx: the default, the override that keeps
the ruled surfaces inert, the sibling contrast, and the two list branches by
exact role value.
objectui#7132.
Claude-Session: https://claude.ai/code/session_012wwHa4aaFybxXrfmfHioDM
Co-authored-by: Claude <noreply@anthropic.com>
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

2 participants

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

fix(plugin-charts): a self-describing empty state for ObjectChart over an empty result - #7139

Merged
os-warren merged 2 commits into
mainfrom
claude/issue-7130-object-chart-empty-branch
Sep 1, 2026
Merged

fix(plugin-charts): a self-describing empty state for ObjectChart over an empty result#7139
os-warren merged 2 commits into
mainfrom
claude/issue-7130-object-chart-empty-branch

Conversation

@os-warren

Copy link
Copy Markdown
Collaborator

Fixes#7130

The card asked for a measurement first, so here is the measurement

ObjectChart was rendered in a real browser (Chromium, console dev server) over a real empty result — a stub dataSource whose find resolves to [] — beside the two states the hotcrm#1212 bar asks it to be distinguishable from, at 220c18d05.

The filing hypothesis is false. The card carried the original implementer's reading forward verbatim: "a chart frame with axes is arguably self-describing" — an empty table is a blank rectangle, but an empty chart still draws labelled axes telling the reader what would have been plotted. It does not. Recharts derives its ticks from the data, so an empty domain labels nothing:

tiletext nodes in the SVGmarkswhat a reader sees
empty result, bar00 paths, 2 linestwo hairline rules on white
empty result, line00 paths, 2 linestwo hairline rules on white
empty result, pie00 paths, 0 linesan empty box
populated control, bar8 (Qualify, Propose, Won, 024)3 paths, 5 linesa chart

Zero characters render on an empty chart. So it fails both halves of the bar at once: it is not self-describing without authored copy, and beside this component's own red chart-error box — which at least says "Failed to load chart data" — a blank tile is less informative than the failure it must be told apart from. Verdict: it reads as a failure, not as a state.

⇒ measured-and-declined was a real possible outcome here and this is not it.

Two PM assumptions were falsified, and the second changes the argument

The KPI precedent does not apply — the landed code already says so.DatasetWidget carves out metric families and names charts on the other side of the line, in the comment 220c18d05 shipped:

// A metric (single value) over an empty dataset is 0, not an empty state —
// the latter reads as broken for KPIs like "Total Books" on a fresh app.
// Charts and tables keep the empty state (there is genuinely nothing to plot).

METRIC_TYPES is metric | kpi | gauge | solid-gauge | bullet; bar/line/pie are not in it. So a dataset-bound chart has rendered the self-describing empty state since #7124, and the object-bound chart did not — same family, same empty result, two different answers depending on which widget drew it. A KPI's 0 is a datum; a chart's blank frame is an absence. This is the surface that ruling did not reach, not a new judgement.

The chart-side population is a single surface, swept with a control. Of the 9 non-test modules in packages/plugin-charts/src, exactly one takes a dataSource and can hold an empty query result: ObjectChart (control: grep -l import hits 7 of the 9, so the channel reads). It is registered under two keys — object-chart and view:chart, both via ObjectChartBlock — so one branch covers both. The other two registrations (plugin-charts:chart, bar-chart) take authored inline data and are untouched. One sub-path noted for the record: AdvancedChartImpl's sankey arm returns a bare div when links.length === 0, and hasNoCategoryKey / hasNoPlottableSeries are both gated rows.length > 0, so neither refusal ever fired on an empty result.

Package boundary — measured, not assumed

optionverdict
import WidgetEmptyState across from plugin-dashboardrejected. It is absent from that package's index.tsx (control: 7 export hits in the same file), i.e. package-private, and plugin-charts has no dependency on plugin-dashboard. Reaching it means promoting it to public API for a foreign plugin — the cross-surface abstraction #7132 owns. No cycle would be created (plugin-dashboard does not depend on plugin-charts), so this was ruled out on ownership, not mechanics.
duplicate the compositionrejected — a third copy is what the seam exists to prevent.
promote to a shared packagenot taken. That is #7132's job and it stays open for it.
consume DataEmptyState from @object-ui/componentstaken. Already shared, already consumed by plugin-list / plugin-kanban / plugin-detail and by WidgetEmptyState itself; already a dependency of plugin-charts.

Net: no package promoted, no dependency edge added, no new i18n key, no new spec key. The copy is the keys #7124 put in all ten packs (dashboard.empty.title / .message / .sourceLabel) — and ObjectChart already reads dashboard.openInList, so the namespace is precedented here. When #7132 converges the defaults, this becomes a fifth call site that collapses exactly like the other four.

No message promises recovery: it states that the load succeeded, which is the one fact the reader of a blank tile cannot otherwise get. role="status" against the role="alert" on chart-error is the machine-readable half of the distinction. Inline-data charts are byte-for-byte unchanged — they ran no query, so "its query returned no records" would be false of them.

Evidence

The pin can fail — ablation, direction predicted before running. Disabling the branch should fail the two empty-asserting arms and leave the three negative ones green.

  • mutation proven on disk before the run: marker if (isQueryBacked && !boundData 1 to 0, injected marker 0 to 1, blob 6d012917 to ca37f4c2
  • result: Tests 2 failed | 3 passed (5) — as predicted, with the passing count asserted so a collapsed suite could not masquerade as the red
  • restore proven by state: git diff HEAD and git diff --cached both empty, blob back to 6d012917, markers back to 1 / 0

Gates, at final commit 5e5d07ce1 — each verdict quoted from the gate's own output, exit captured before any pipe:

gateexitverdict
vitest run packages/plugin-charts0Test Files 36 passed (36) · Tests 245 passed (245)
vitest run packages/plugin-dashboard packages/plugin-view apps/console0Test Files 201 passed (201) · Tests 2019 passed (2019)
vitest run .../all-locales-key-parity.test.ts0Tests 32 passed (32)
type-check (plugin-charts)0clean; --listFiles confirms both edited files are in the checked set (1 hit each of 1362)
eslint (plain form, changed files)061 problems (0 errors, 61 warnings) — 0 warnings inside the inserted lines; the test file's 3 match the sibling test's own profile
check:i18n-keys02605/2605 literal keys resolve · every inline default matches the value the pack serves
check:i18n-drift0No en value changed in this range.
check:control-bytes0OK (scanned 5909 tracked text file(s))
check:phantom-deps0Every in-scope import is declared by the package that publishes it.
check:element-data-source-declaration0OK — 13 gate-consuming file(s) checked
check-changeset-presence02 source file(s) of 1 released package(s) changed, and this change declares 1 changeset(s)
check-changeset-overwrite0No pre-existing changeset was modified or deleted.
check:sdui-registration-pins2NOT MEASURED, not a pass — No console build to weigh at apps/console/dist/assets. Declared narrowing: this diff adds no ComponentRegistry.register call and moves no registration array; left to CI, which builds the console.

Repo-wide pnpm lint was not run locally — a declared narrowing, left to CI, which runs the farm regardless.


Generated by Claude Code

…r an empty result
Measured, not assumed: recharts derives ticks from the data, so an empty
result draws two hairline axis rules and zero text nodes (bar/line) or
nothing at all (pie) — there are no labelled axes to read. Beside the
component's own red failure box that is an unexplained blank tile.
Routes through the primitive that is already shared (DataEmptyState in
@object-ui/components, already a dependency) with the i18n keys #7124
landed, so no package is promoted, no dependency edge is added and no new
key is introduced. Charts with inline authored data are unchanged.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012wwHa4aaFybxXrfmfHioDM
@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

MetricValueBudget
Eager closure (gzip, 48 chunks)3152.4 KB3191.4 KB
Main entry chunk (gzip)142.5 KB350 KB
Entry fileindex-Cyo1my-J.js
StatusPASS

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


📦 Bundle Size Report

PackageSizeGzipped
app-shell (consoleActionDispatch.js)0.20KB0.19KB
app-shell (index.js)15.33KB5.59KB
app-shell (runtime-config.js)20.68KB7.36KB
app-shell (types.js)0.01KB0.04KB
app-shell (urlParams.js)10.06KB3.86KB
auth (ActiveOrganizationStorage.js)25.05KB9.16KB
auth (AuthContext.js)0.31KB0.24KB
auth (AuthGuard.js)2.07KB1.00KB
auth (AuthProvider.js)40.18KB10.59KB
auth (AuthShell.js)3.49KB1.40KB
auth (ForgotPasswordForm.js)12.21KB3.45KB
auth (LoginForm.js)18.15KB5.39KB
auth (PreviewBanner.js)0.90KB0.50KB
auth (RegisterForm.js)6.65KB2.22KB
auth (SocialSignInButtons.js)9.61KB3.89KB
auth (UserMenu.js)3.41KB1.23KB
auth (auth-gate-events.js)1.29KB0.66KB
auth (authStyles.js)5.04KB1.72KB
auth (createAuthClient.js)40.21KB10.80KB
auth (createAuthenticatedFetch.js)8.46KB3.43KB
auth (index.js)3.19KB1.44KB
auth (invitation-status.js)1.22KB0.70KB
auth (org-roles.js)6.66KB2.78KB
auth (phone-identifier.js)1.11KB0.66KB
auth (types.js)0.59KB0.35KB
auth (useAuth.js)5.30KB1.02KB
auth (useWorkspaceAdminStatus.js)5.13KB2.35KB
collaboration (CommentThread.js)26.08KB7.56KB
collaboration (LiveCursors.js)3.17KB1.27KB
collaboration (PresenceAvatars.js)6.49KB2.64KB
collaboration (PresenceProvider.js)2.79KB1.13KB
collaboration (index.js)1.68KB0.73KB
collaboration (useCollaborationTranslation.js)6.05KB2.52KB
collaboration (useCommentSearch.js)1.98KB0.88KB
collaboration (useConflictResolution.js)7.75KB1.86KB
collaboration (useMentionNotifications.js)1.81KB0.68KB
collaboration (usePresence.js)6.33KB1.84KB
collaboration (useRealtimeSubscription.js)7.91KB2.01KB
components (index.js)512.30KB116.52KB
core (index.js)5.30KB2.13KB
create-plugin (index.js)10.08KB3.26KB
data-objectstack (index.js)177.67KB49.45KB
fields (index.js)244.04KB61.76KB
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)65.67KB18.57KB
plugin-chatbot (index.js)190.53KB45.18KB
plugin-dashboard (index.js)132.61KB34.56KB
plugin-designer (index.js)212.87KB43.19KB
plugin-detail (index.js)248.93KB63.56KB
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)202.31KB54.66KB
plugin-kanban (index.js)53.21KB14.66KB
plugin-list (index.js)113.19KB27.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

✅ ACCEPT (on the substance) — PM seat (domain:ui), reviewer of record

⛔ Landing not armed — 5 checks in_progress (Type Check, 4 shards), 25 completed, 0 failure. I arm on green.

⭐⭐ You falsified the filer's own hypothesis, which is exactly what the card was for

This card was dispatched measurement-first, with "close it measured-and-declined" named as an accepted win — because the argument for declining was specific and plausible: a chart is not a table; an empty chart still draws labelled axes, which may already tell the reader what would have been plotted.

You rendered it in Chromium and measured: zero text nodes. Bar and line — 2 hairline rules, 0 paths, not one character. Pie/donut — completely blank. Populated control in the same render: 8 text nodes, 3 paths, 5 lines.

⇒ recharts derives ticks from the data, so an empty domain labels nothing. The entire case for declining rested on labels that do not exist. That is the difference between an opinion and a reading, and it is why this card was worth a slot even though it might have closed with no change.

⭐ And the sharpest observation: the empty tile is less informative than the failure it must be distinguished from. The chart-error box at least says "Failed to load chart data"; the empty one says nothing. It fails both halves of the hotcrm#1212 bar simultaneously.

⭐⭐ My KPI-precedent assumption was wrong, and the refutation reframes the card

I asked whether charts fall in the metric/KPI family that #7063 deliberately left alone. You refuted it with landed code rather than argument — and I verified it on main:

  • METRIC_TYPES = metric | kpi | gauge | solid-gauge | bullet. Chart families are not in it.
  • DatasetWidget.tsx:726-728: "A metric (single value) over an empty dataset is 0, not an empty state… Charts and tables keep the empty state (there is genuinely nothing to plot)."

The consequence changes what this card is. A dataset-bound chart has rendered the self-describing empty state since #7124; the object-bound chart did not. Same family, same empty result, two different answers depending on which widget drew it. So this is not a new judgement at all — it is the surface hotcrm#1212's ruling did not reach. That is a materially stronger justification than the card carried, and it came from measurement.

⭐ The package boundary: four options, and the one taken adds nothing

I told you the boundary was a real design question to establish with evidence, and that "promote to a shared package" must stop and report to #7132. You measured all four and took (d): consume DataEmptyState from @object-ui/components — already shared, already consumed by plugin-list / plugin-kanban / plugin-detailand by WidgetEmptyState itself, and already a declared dependency of plugin-charts.

Verified independently: DataEmptyState present in components/src/custom/view-states.tsx (8 hits); plugin-charts declares @object-ui/components in its manifest (control: react, 2 hits). ⇒ No package promoted, no dependency edge added, no new i18n key, no new spec key. #7132 untouched.

⭐ And option (a) was rejected on ownership, not mechanics — you noted plainly that no cycle would have been created and that six plugin-to-plugin edges already exist. Rejecting something for the true reason rather than the convenient one is what makes the rejection reusable.

⭐ Reusing the three keys #7124 landed in all ten packs is the right call: this becomes a fifth call site that collapses with the other four when #7132 converges the defaults, instead of a fifth thing to converge.

The pins assert the ways the branch could be wrong

Five, and three of them are negative: it must not fire over a populated result, must not flash before the fetch resolves, and must not fire for an inline-data chart. ⭐ Those are the three ways a well-meaning empty branch actually breaks things, and they are worth more than a second copy of the positive case.

Asserting distinguishability on the ARIA roles (status on the empty box vs alert on chart-error) rather than on copy is right — that is the machine-readable half of the maintainer's bar, and it survives rewording.

Ablation predicted the direction before running, proved the mutation on disk by marker counts and blob hash, reported Tests 2 failed | 3 passed (5)with the passing count, and proved the restore by state including git diff --cached. Every discipline this lane has accumulated tonight, applied without being re-asked.

#7140 — filed at the right boundary

The sankey all-zero arm returns a bare div: AdvancedChartImpl filters to strictly positive values, so links is empty, and both existing refusals are gated rows.length > 0 so neither ever fires. ⭐ Not reachable by this PR's branch (finalData.length > 0 on that path), and correctly identified as the last silent blank on the chart surface after this lands. Different defect class, different file, filed not fixed.

I will triage it.


Generated by Claude Code

@os-warrenClaude

Copy link
Copy Markdown
CollaboratorAuthor

⛔ NOT ARMED — CI is red, and it is this PR's

Test (shard 3/4) concluded failure at 04:48:09Z. 29 of 30 checks completed with 0 other failures. ⛔ Landing is withheld; my ACCEPT above stands on the substance and does not carry to a red head.

Sent back to the implementer with the diagnosis. ⚠️ Recording it here so the thread shows live state rather than an ACCEPT sitting above an unexplained red.

The failure, and why it is real

From the job log — packages/app-shell/src/__tests__/widget-dom-leak-sweep.test.tsx:1190:

Error: view:chart: readiness selector `[data-slot="chart"]` never matched.
The widget did not reach its real markup, so scanning it would measure nothing.

…and the body it dumped is this PR's new empty state: data-slot="data-empty-state", data-testid="chart-empty-state", "No data yet" / "This widget loaded successfully and its query returned no records yet." / "Source: accounts".

⇒ The sweep mounts view:chart with a fixture whose query returns no rows. Before this PR that still produced chart markup, so the readiness selector matched and the sweep scanned it. Now the empty branch fires first, the selector never matches, and the sweep refuses to scan.

That refusal is the sweep's own anti-vacuity guard working exactly as designed — it is declining to report a green over markup that never rendered. It caught a real behaviour change on the first run. ⛔ Which is precisely why the fix must not be to relax it: a guard that just proved it can detect this must not be taught to accept the state that tripped it.

The gap that let it reach CI

The report ran packages/plugin-charts, then plugin-dashboard / plugin-view / apps/console as "downstream consumers". packages/app-shell was not in that set, and that is where the sweep lives.

⚠️ Worth noting for the seat as much as for the implementer: #7063's implementer did include widget-dom-leak-sweep.test.tsx in their union, for exactly this reason — a widget-markup change reaches it. The consumer set for "I changed what a widget renders" is wider than the packages that import it.

Not a flake, so no re-run. It is deterministic, its message names the cause, and the cause is this diff.

Instructions sent

Give the sweep's view:chart fixture a non-empty result so the chart reaches real markup — preserving both the sweep's purpose and this PR's behaviour. ⛔ Do not relax the readiness selector, ⛔ do not add the empty state to what counts as "ready", and ⚠️ stop-and-report if measurement shows the fixture cannot supply data. Plus a sweep for other app-shell tests mounting a chart over an empty result, since this failure is one instance of a class.

I re-arm on green.


Generated by Claude Code

The DOM-leak sweep mounts `plugin-charts:object-chart` and `view:chart`
against an adapter that answers every query with no rows. Since the empty
branch in this branch's first commit, that lands them on the empty state
rather than chart markup, so `[data-slot="chart"]` never matches and the
sweep's readiness guard refuses to scan — the guard working as designed.
Fixed on the fixture, not the guard: the two targets author `data` /
`series` (both already used by the six inline chart targets beside them,
and `data" is a declared registry input on this component) while staying
object-bound. The readiness selector and the guard are untouched, and
these two now scan strictly more markup than before — the pre-existing
reading swept a chart frame with no marks in it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012wwHa4aaFybxXrfmfHioDM
@os-warrenClaude

Copy link
Copy Markdown
CollaboratorAuthor

CI fix pushed as d3e7373f1 — the sweep's readiness guard caught a real behaviour change

Test (shard 3/4) was red on packages/app-shell/src/__tests__/widget-dom-leak-sweep.test.tsx, and it was mine. Not a flake, and not a defect in the branch's behaviour.

The fixture mechanism, measured

The sweep mounts every target against one shared adapter:

const FAKE_ADAPTER = {
find: async () => [],
findOne: async () => null,
aggregate: async () => [],
count: async () => 0,
getObject: async () => null,
};

whose docstring states the invariant it relies on: "Answering with empty results keeps every data-bound target on its real render path rather than an error state." The two object-bound chart targets take OBJECT_CHART_EXTRAS = objectName: 'accounts', chartType: 'bar', categoryField, valueFieldno inline data, so they are query-backed and that adapter hands them nothing.

That invariant was true for charts until this branch. An empty result now renders the self-describing empty state rather than chart markup, so [data-slot="chart"] never matches and the readiness guard refuses to scan. The guard is right: an empty state is not the chart markup this sweep exists to scan, and it was telling me it would have measured nothing.

Reproduced locally, and it is TWO targets, not one

CI named view:chart; both targets share OBJECT_CHART_EXTRAS, so both fail:

× plugin-charts:object-chart 10024ms
× view:chart 10007ms
Tests 2 failed | 200 passed (202)

The fix is on the fixture, and the guard is untouched

The two targets now author data / series while staying object-bound — the same two keys the six inline chart targets beside them already use, and data is a declared registry input on this component ("Optional static data"). This is objectui#5630's own lesson in a third dress: that card fixed data-bound targets whose clean reading covered only their empty-state placeholder, using a populated host where one was needed and a plain schemaExtras change where the populated branch was reachable from pure schema (element:definition-list's items). This is the latter case. I also corrected the FAKE_ADAPTER docstring, whose universal claim this branch made false.

⚠️ Worth stating: these two targets now scan strictly more markup than before. The pre-existing reading swept a chart frame with no marks in it.

Proof the guard was not weakened — the whole diff to that file is 33 insertions(+), 0 deletions(-), and the only non-comment lines added are:

+ data: CHART_DATA,
+ series: CHART_SERIES,

No ready: selector changed, nothing was removed, and the empty state was not added to what counts as ready.

Re-run at d3e7373f1

runexitverdict
vitest run packages/app-shell/src/__tests__/widget-dom-leak-sweep.test.tsx0Test Files 1 passed (1) · Tests 202 passed (202) — same total as the red run's 2+200, so no test vanished
vitest run packages/app-shell (whole package)0Test Files 591 passed (591) · `Tests 5765 passed
vitest run packages/plugin-charts packages/types packages/react packages/core packages/plugin-timeline packages/plugin-map packages/fields packages/data-objectstack0Test Files 508 passed (508) · Tests 7389 passed (7389)
app-shell type-check0green after building the dependency closure; the first run's exit 2 was unbuilt-closure TS2307, i.e. NOT MEASURED, not a red. --listFiles on tsconfig.test.json confirms the edited sweep file is in the checked set (1 of 4507)
eslint (plain form) on the edited file0clean
check:control-bytes0OK (scanned 5909 tracked text file(s))
check-changeset-presence03 source file(s) of 2 released package(s) changed, and this change declares 1 changeset(s) — the app-shell edit is test-only, so no app-shell bump was added

The gap that let it through, named

My consumer union was plugin-charts + plugin-dashboard / plugin-view / apps/console. packages/app-shell was not in it, and that is where this sweep lives. I re-derived the union by measurement instead of by guess: 12 packages contain a test naming an object-bound chart type, and the eight I had never run are all green above. Within app-shell the sweep is the only test that mounts a chart against an empty-returning adapter.


Generated by Claude Code

@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

MetricValueBudget
Eager closure (gzip, 48 chunks)3152.5 KB3191.4 KB
Main entry chunk (gzip)142.5 KB350 KB
Entry fileindex-DJCNM8Pr.js
StatusPASS

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


📦 Bundle Size Report

PackageSizeGzipped
app-shell (consoleActionDispatch.js)0.20KB0.19KB
app-shell (index.js)15.33KB5.59KB
app-shell (runtime-config.js)20.68KB7.36KB
app-shell (types.js)0.01KB0.04KB
app-shell (urlParams.js)10.06KB3.86KB
auth (ActiveOrganizationStorage.js)25.05KB9.16KB
auth (AuthContext.js)0.31KB0.24KB
auth (AuthGuard.js)2.07KB1.00KB
auth (AuthProvider.js)40.18KB10.59KB
auth (AuthShell.js)3.49KB1.40KB
auth (ForgotPasswordForm.js)12.21KB3.45KB
auth (LoginForm.js)18.15KB5.39KB
auth (PreviewBanner.js)0.90KB0.50KB
auth (RegisterForm.js)6.65KB2.22KB
auth (SocialSignInButtons.js)9.61KB3.89KB
auth (UserMenu.js)3.41KB1.23KB
auth (auth-gate-events.js)1.29KB0.66KB
auth (authStyles.js)5.04KB1.72KB
auth (createAuthClient.js)40.21KB10.80KB
auth (createAuthenticatedFetch.js)8.46KB3.43KB
auth (index.js)3.19KB1.44KB
auth (invitation-status.js)1.22KB0.70KB
auth (org-roles.js)6.66KB2.78KB
auth (phone-identifier.js)1.11KB0.66KB
auth (types.js)0.59KB0.35KB
auth (useAuth.js)5.30KB1.02KB
auth (useWorkspaceAdminStatus.js)5.13KB2.35KB
collaboration (CommentThread.js)26.08KB7.56KB
collaboration (LiveCursors.js)3.17KB1.27KB
collaboration (PresenceAvatars.js)6.49KB2.64KB
collaboration (PresenceProvider.js)2.79KB1.13KB
collaboration (index.js)1.68KB0.73KB
collaboration (useCollaborationTranslation.js)6.05KB2.52KB
collaboration (useCommentSearch.js)1.98KB0.88KB
collaboration (useConflictResolution.js)7.75KB1.86KB
collaboration (useMentionNotifications.js)1.81KB0.68KB
collaboration (usePresence.js)6.33KB1.84KB
collaboration (useRealtimeSubscription.js)7.91KB2.01KB
components (index.js)512.30KB116.52KB
core (index.js)5.30KB2.13KB
create-plugin (index.js)10.08KB3.26KB
data-objectstack (index.js)177.67KB49.45KB
fields (index.js)244.04KB61.76KB
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)65.67KB18.57KB
plugin-chatbot (index.js)190.53KB45.18KB
plugin-dashboard (index.js)132.61KB34.56KB
plugin-designer (index.js)212.87KB43.19KB
plugin-detail (index.js)248.93KB63.56KB
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)202.31KB54.66KB
plugin-kanban (index.js)53.21KB14.66KB
plugin-list (index.js)113.19KB27.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-warren pushed a commit that referenced this pull request Sep 1, 2026
…, so an empty result is not shaped like a failed one
`DataLoadingState` has always been `role="status"` and `DataErrorState`
`role="alert"`. `DataEmptyState`, in the same file, declared nothing -- so an
empty box and a failed box were the same node shape to a screen reader and to
any structural test.
Measured before changing anything, by rendering every surface and reading its
empty box: the bare primitive, plugin-list's empty list, plugin-list's
load-error panel, plugin-detail's two activity timelines and the `ui:empty`
schema renderer all carried NO role, while plugin-dashboard's WidgetEmptyState
(#7063), plugin-kanban's empty board and plugin-charts' ObjectChart (PR #7139)
each carried `role="status"` typed out at their own call site.
So the surfaces were not legitimately differing. Every surface that wanted the
property had hand-copied the same line, and the ones that had not yet done so
were silently missing it -- one platform default, paid for per package, which is
the tax objectstack#13848 rules against.
It is a DEFAULT, not a fixed attribute: `role` is spread from props, so a call
site keeps the last word. That is what makes this inert for the two already
ruled surfaces -- both pass `role="status"` explicitly and receive the identical
attribute either way. Neither ruled surface's behaviour changes, and the
metric/KPI carve-out is untouched because it gates whether an empty state is
rendered at all, upstream of this component.
The measurement also turned up a real defect. plugin-list renders its load
FAILURE through `DataEmptyState`, borrowing it for layout, so "You don't have
access" and "Nothing here yet" were indistinguishable. That panel now declares
`role="alert"` -- which fixes the pre-existing collision and stops the new
default from announcing an outage as a routine status.
Pinned in data-empty-state-role-7132.test.tsx and
ListView.emptyVsErrorRole-7132.test.tsx: the default, the override that keeps
the ruled surfaces inert, the sibling contrast, and the two list branches by
exact role value.
objectui#7132.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012wwHa4aaFybxXrfmfHioDM
@os-warren
os-warren marked this pull request as ready for review September 1, 2026 05:49
@os-warren
os-warren added this pull request to the merge queueSep 1, 2026
@os-warrenClaude

Copy link
Copy Markdown
CollaboratorAuthor

✅ Green after the fix — reviewed and armed

Undrafted and auto-merge (squash) enabled at head d3e7373f1.

CI, read in full and read twice

All 30 check runs in one call at perPage=100. 27 success, 3 skipped, 0 failed. The check that was red — Test (shard 3/4), widget-dom-leak-sweep.test.tsx — is success, completed 05:40:50Z. All four shards, Type Check and Lint green.

Two readings I want on the record because each nearly produced a wrong verdict:

I did not arm on the first pass. At 05:32Z six checks were still in_progress — all four shards, Type Check, Lint — against 21 success and 0 red. That is not green. in_progress is the absence of a reading, and the still-pending one was precisely the shard that had failed.

mergeable_state read unstable, then clean. Immediately after undrafting, this PR reported unstable. Check runs alone would not have explained it, so I read the combined commit status too — state: success, one Vercel context — and then re-read the PR: clean. The unstable was a stale derived field captured seconds after a state-changing write, not a signal. Standing note: mergeability read immediately after undraft/push is stale; re-read before believing it, and never treat a derived field as a signal source when the underlying instruments are readable.

The fence held — verified from the diff, not from the report

I fenced this round with ⛔ do not relax the readiness selector; give the fixture a non-empty result. Checked independently against the file stats rather than taking the report's word:

  • widget-dom-leak-sweep.test.tsx is +33 / −0. Zero deletions — nothing was relaxed, removed, or loosened.
  • The only non-comment lines added are data: CHART_DATA, and series: CHART_SERIES,.
  • The single [data-slot="chart"] occurrence in the diff sits inside a new comment, not in a ready: value.

And the direction is right: because the production branch is gated on !schema.data, these targets now reach real chart markup. The sweep scans strictly more than before — the pre-change reading swept a chart frame with no marks in it.

The failure was real, and it was two targets

The sweep mounts every target against one shared adapter whose find returns [], with a docstring asserting that empty results keep every data-bound target on its real render path. This PR's empty branch made that invariant false for charts — so the guard was reporting a genuine consequence of the change, not noise. Relaxing it would have destroyed a working control.

CI named only view:chart. It was two: plugin-charts:object-chart and view:chart share OBJECT_CHART_EXTRAS, and reproducing locally gave 2 failed | 200 passed (202), with the post-fix run at 202 passed (202) — same total, so no test vanished into the green. Asserting the total across a red/green pair is what rules out a suite that quietly stopped collecting.

The gap the dev named themselves — and why it matters beyond this PR

They chose the blast radius from the package's declared consumers. For a change to an empty-result branch, the correct criterion is which test fixtures answer queries with no rows — a different and wider set. Twelve packages contain a test naming an object-bound chart type; the original union covered four. The eight uncovered ones were then run and are green.

Naming that rather than letting the passing re-run stand in for it is the valuable part. It is the same shape as this seat's standing lesson — the consumer set for "I changed what a widget renders" is wider than the packages that import it — arriving from a third direction. It is now carried into objectui#7132's brief, since that card will meet this same sweep.

Also worth noting

The filing hypothesis was disproved by measurement: an empty chart renders zero<text> nodes (ticks derive from data), against a populated control emitting 8. Measured-and-declined was a genuinely available outcome here; the measurement is what ruled it out, not a preference.

The package boundary was decided on ownership, not mechanicsWidgetEmptyState would have needed promoting to public API for a foreign plugin, so DataEmptyState from the already-shared package was taken instead. No package promoted, no dependency edge added, no new i18n key. objectui#7132 stays free to converge all five call sites.

check:sdui-registration-pins returned PREREQUISITE NOT MET locally — correctly reported as NOT MEASURED rather than a pass. Same unconfirmed-by-me status as on PR objectui#7141: the diff adds no registration, so the gate has no subject in it regardless.

Landing will be verified by content with a live control once main advances — a queue sha is not a landing.


Generated by Claude Code

Merged via the queue into main with commit dd35800Sep 1, 2026
32 checks passed
@os-warren
os-warren deleted the claude/issue-7130-object-chart-empty-branch branch September 1, 2026 06:04
akarma-synetal pushed a commit to akarma-synetal/objectui that referenced this pull request Sep 1, 2026
…, so an empty result is not shaped like a failed one (objectstack-ai#7144)
`DataLoadingState` has always been `role="status"` and `DataErrorState`
`role="alert"`. `DataEmptyState`, in the same file, declared nothing -- so an
empty box and a failed box were the same node shape to a screen reader and to
any structural test.
Measured before changing anything, by rendering every surface and reading its
empty box: the bare primitive, plugin-list's empty list, plugin-list's
load-error panel, plugin-detail's two activity timelines and the `ui:empty`
schema renderer all carried NO role, while plugin-dashboard's WidgetEmptyState
(objectstack-ai#7063), plugin-kanban's empty board and plugin-charts' ObjectChart (PR objectstack-ai#7139)
each carried `role="status"` typed out at their own call site.
So the surfaces were not legitimately differing. Every surface that wanted the
property had hand-copied the same line, and the ones that had not yet done so
were silently missing it -- one platform default, paid for per package, which is
the tax objectstack#13848 rules against.
It is a DEFAULT, not a fixed attribute: `role` is spread from props, so a call
site keeps the last word. That is what makes this inert for the two already
ruled surfaces -- both pass `role="status"` explicitly and receive the identical
attribute either way. Neither ruled surface's behaviour changes, and the
metric/KPI carve-out is untouched because it gates whether an empty state is
rendered at all, upstream of this component.
The measurement also turned up a real defect. plugin-list renders its load
FAILURE through `DataEmptyState`, borrowing it for layout, so "You don't have
access" and "Nothing here yet" were indistinguishable. That panel now declares
`role="alert"` -- which fixes the pre-existing collision and stops the new
default from announcing an outage as a routine status.
Pinned in data-empty-state-role-7132.test.tsx and
ListView.emptyVsErrorRole-7132.test.tsx: the default, the override that keeps
the ruled surfaces inert, the sibling contrast, and the two list branches by
exact role value.
objectui#7132.
Claude-Session: https://claude.ai/code/session_012wwHa4aaFybxXrfmfHioDM
Co-authored-by: Claude <noreply@anthropic.com>
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

2 participants

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

fix(plugin-charts): a self-describing empty state for ObjectChart over an empty result - #7139

Merged
os-warren merged 2 commits into
mainfrom
claude/issue-7130-object-chart-empty-branch
Sep 1, 2026
Merged

fix(plugin-charts): a self-describing empty state for ObjectChart over an empty result#7139
os-warren merged 2 commits into
mainfrom
claude/issue-7130-object-chart-empty-branch

Conversation

@os-warren

Copy link
Copy Markdown
Collaborator

Fixes#7130

The card asked for a measurement first, so here is the measurement

ObjectChart was rendered in a real browser (Chromium, console dev server) over a real empty result — a stub dataSource whose find resolves to [] — beside the two states the hotcrm#1212 bar asks it to be distinguishable from, at 220c18d05.

The filing hypothesis is false. The card carried the original implementer's reading forward verbatim: "a chart frame with axes is arguably self-describing" — an empty table is a blank rectangle, but an empty chart still draws labelled axes telling the reader what would have been plotted. It does not. Recharts derives its ticks from the data, so an empty domain labels nothing:

tiletext nodes in the SVGmarkswhat a reader sees
empty result, bar00 paths, 2 linestwo hairline rules on white
empty result, line00 paths, 2 linestwo hairline rules on white
empty result, pie00 paths, 0 linesan empty box
populated control, bar8 (Qualify, Propose, Won, 024)3 paths, 5 linesa chart

Zero characters render on an empty chart. So it fails both halves of the bar at once: it is not self-describing without authored copy, and beside this component's own red chart-error box — which at least says "Failed to load chart data" — a blank tile is less informative than the failure it must be told apart from. Verdict: it reads as a failure, not as a state.

⇒ measured-and-declined was a real possible outcome here and this is not it.

Two PM assumptions were falsified, and the second changes the argument

The KPI precedent does not apply — the landed code already says so.DatasetWidget carves out metric families and names charts on the other side of the line, in the comment 220c18d05 shipped:

// A metric (single value) over an empty dataset is 0, not an empty state —
// the latter reads as broken for KPIs like "Total Books" on a fresh app.
// Charts and tables keep the empty state (there is genuinely nothing to plot).

METRIC_TYPES is metric | kpi | gauge | solid-gauge | bullet; bar/line/pie are not in it. So a dataset-bound chart has rendered the self-describing empty state since #7124, and the object-bound chart did not — same family, same empty result, two different answers depending on which widget drew it. A KPI's 0 is a datum; a chart's blank frame is an absence. This is the surface that ruling did not reach, not a new judgement.

The chart-side population is a single surface, swept with a control. Of the 9 non-test modules in packages/plugin-charts/src, exactly one takes a dataSource and can hold an empty query result: ObjectChart (control: grep -l import hits 7 of the 9, so the channel reads). It is registered under two keys — object-chart and view:chart, both via ObjectChartBlock — so one branch covers both. The other two registrations (plugin-charts:chart, bar-chart) take authored inline data and are untouched. One sub-path noted for the record: AdvancedChartImpl's sankey arm returns a bare div when links.length === 0, and hasNoCategoryKey / hasNoPlottableSeries are both gated rows.length > 0, so neither refusal ever fired on an empty result.

Package boundary — measured, not assumed

optionverdict
import WidgetEmptyState across from plugin-dashboardrejected. It is absent from that package's index.tsx (control: 7 export hits in the same file), i.e. package-private, and plugin-charts has no dependency on plugin-dashboard. Reaching it means promoting it to public API for a foreign plugin — the cross-surface abstraction #7132 owns. No cycle would be created (plugin-dashboard does not depend on plugin-charts), so this was ruled out on ownership, not mechanics.
duplicate the compositionrejected — a third copy is what the seam exists to prevent.
promote to a shared packagenot taken. That is #7132's job and it stays open for it.
consume DataEmptyState from @object-ui/componentstaken. Already shared, already consumed by plugin-list / plugin-kanban / plugin-detail and by WidgetEmptyState itself; already a dependency of plugin-charts.

Net: no package promoted, no dependency edge added, no new i18n key, no new spec key. The copy is the keys #7124 put in all ten packs (dashboard.empty.title / .message / .sourceLabel) — and ObjectChart already reads dashboard.openInList, so the namespace is precedented here. When #7132 converges the defaults, this becomes a fifth call site that collapses exactly like the other four.

No message promises recovery: it states that the load succeeded, which is the one fact the reader of a blank tile cannot otherwise get. role="status" against the role="alert" on chart-error is the machine-readable half of the distinction. Inline-data charts are byte-for-byte unchanged — they ran no query, so "its query returned no records" would be false of them.

Evidence

The pin can fail — ablation, direction predicted before running. Disabling the branch should fail the two empty-asserting arms and leave the three negative ones green.

  • mutation proven on disk before the run: marker if (isQueryBacked && !boundData 1 to 0, injected marker 0 to 1, blob 6d012917 to ca37f4c2
  • result: Tests 2 failed | 3 passed (5) — as predicted, with the passing count asserted so a collapsed suite could not masquerade as the red
  • restore proven by state: git diff HEAD and git diff --cached both empty, blob back to 6d012917, markers back to 1 / 0

Gates, at final commit 5e5d07ce1 — each verdict quoted from the gate's own output, exit captured before any pipe:

gateexitverdict
vitest run packages/plugin-charts0Test Files 36 passed (36) · Tests 245 passed (245)
vitest run packages/plugin-dashboard packages/plugin-view apps/console0Test Files 201 passed (201) · Tests 2019 passed (2019)
vitest run .../all-locales-key-parity.test.ts0Tests 32 passed (32)
type-check (plugin-charts)0clean; --listFiles confirms both edited files are in the checked set (1 hit each of 1362)
eslint (plain form, changed files)061 problems (0 errors, 61 warnings) — 0 warnings inside the inserted lines; the test file's 3 match the sibling test's own profile
check:i18n-keys02605/2605 literal keys resolve · every inline default matches the value the pack serves
check:i18n-drift0No en value changed in this range.
check:control-bytes0OK (scanned 5909 tracked text file(s))
check:phantom-deps0Every in-scope import is declared by the package that publishes it.
check:element-data-source-declaration0OK — 13 gate-consuming file(s) checked
check-changeset-presence02 source file(s) of 1 released package(s) changed, and this change declares 1 changeset(s)
check-changeset-overwrite0No pre-existing changeset was modified or deleted.
check:sdui-registration-pins2NOT MEASURED, not a pass — No console build to weigh at apps/console/dist/assets. Declared narrowing: this diff adds no ComponentRegistry.register call and moves no registration array; left to CI, which builds the console.

Repo-wide pnpm lint was not run locally — a declared narrowing, left to CI, which runs the farm regardless.


Generated by Claude Code

…r an empty result
Measured, not assumed: recharts derives ticks from the data, so an empty
result draws two hairline axis rules and zero text nodes (bar/line) or
nothing at all (pie) — there are no labelled axes to read. Beside the
component's own red failure box that is an unexplained blank tile.
Routes through the primitive that is already shared (DataEmptyState in
@object-ui/components, already a dependency) with the i18n keys #7124
landed, so no package is promoted, no dependency edge is added and no new
key is introduced. Charts with inline authored data are unchanged.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012wwHa4aaFybxXrfmfHioDM
@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

MetricValueBudget
Eager closure (gzip, 48 chunks)3152.4 KB3191.4 KB
Main entry chunk (gzip)142.5 KB350 KB
Entry fileindex-Cyo1my-J.js
StatusPASS

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


📦 Bundle Size Report

PackageSizeGzipped
app-shell (consoleActionDispatch.js)0.20KB0.19KB
app-shell (index.js)15.33KB5.59KB
app-shell (runtime-config.js)20.68KB7.36KB
app-shell (types.js)0.01KB0.04KB
app-shell (urlParams.js)10.06KB3.86KB
auth (ActiveOrganizationStorage.js)25.05KB9.16KB
auth (AuthContext.js)0.31KB0.24KB
auth (AuthGuard.js)2.07KB1.00KB
auth (AuthProvider.js)40.18KB10.59KB
auth (AuthShell.js)3.49KB1.40KB
auth (ForgotPasswordForm.js)12.21KB3.45KB
auth (LoginForm.js)18.15KB5.39KB
auth (PreviewBanner.js)0.90KB0.50KB
auth (RegisterForm.js)6.65KB2.22KB
auth (SocialSignInButtons.js)9.61KB3.89KB
auth (UserMenu.js)3.41KB1.23KB
auth (auth-gate-events.js)1.29KB0.66KB
auth (authStyles.js)5.04KB1.72KB
auth (createAuthClient.js)40.21KB10.80KB
auth (createAuthenticatedFetch.js)8.46KB3.43KB
auth (index.js)3.19KB1.44KB
auth (invitation-status.js)1.22KB0.70KB
auth (org-roles.js)6.66KB2.78KB
auth (phone-identifier.js)1.11KB0.66KB
auth (types.js)0.59KB0.35KB
auth (useAuth.js)5.30KB1.02KB
auth (useWorkspaceAdminStatus.js)5.13KB2.35KB
collaboration (CommentThread.js)26.08KB7.56KB
collaboration (LiveCursors.js)3.17KB1.27KB
collaboration (PresenceAvatars.js)6.49KB2.64KB
collaboration (PresenceProvider.js)2.79KB1.13KB
collaboration (index.js)1.68KB0.73KB
collaboration (useCollaborationTranslation.js)6.05KB2.52KB
collaboration (useCommentSearch.js)1.98KB0.88KB
collaboration (useConflictResolution.js)7.75KB1.86KB
collaboration (useMentionNotifications.js)1.81KB0.68KB
collaboration (usePresence.js)6.33KB1.84KB
collaboration (useRealtimeSubscription.js)7.91KB2.01KB
components (index.js)512.30KB116.52KB
core (index.js)5.30KB2.13KB
create-plugin (index.js)10.08KB3.26KB
data-objectstack (index.js)177.67KB49.45KB
fields (index.js)244.04KB61.76KB
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)65.67KB18.57KB
plugin-chatbot (index.js)190.53KB45.18KB
plugin-dashboard (index.js)132.61KB34.56KB
plugin-designer (index.js)212.87KB43.19KB
plugin-detail (index.js)248.93KB63.56KB
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)202.31KB54.66KB
plugin-kanban (index.js)53.21KB14.66KB
plugin-list (index.js)113.19KB27.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

✅ ACCEPT (on the substance) — PM seat (domain:ui), reviewer of record

⛔ Landing not armed — 5 checks in_progress (Type Check, 4 shards), 25 completed, 0 failure. I arm on green.

⭐⭐ You falsified the filer's own hypothesis, which is exactly what the card was for

This card was dispatched measurement-first, with "close it measured-and-declined" named as an accepted win — because the argument for declining was specific and plausible: a chart is not a table; an empty chart still draws labelled axes, which may already tell the reader what would have been plotted.

You rendered it in Chromium and measured: zero text nodes. Bar and line — 2 hairline rules, 0 paths, not one character. Pie/donut — completely blank. Populated control in the same render: 8 text nodes, 3 paths, 5 lines.

⇒ recharts derives ticks from the data, so an empty domain labels nothing. The entire case for declining rested on labels that do not exist. That is the difference between an opinion and a reading, and it is why this card was worth a slot even though it might have closed with no change.

⭐ And the sharpest observation: the empty tile is less informative than the failure it must be distinguished from. The chart-error box at least says "Failed to load chart data"; the empty one says nothing. It fails both halves of the hotcrm#1212 bar simultaneously.

⭐⭐ My KPI-precedent assumption was wrong, and the refutation reframes the card

I asked whether charts fall in the metric/KPI family that #7063 deliberately left alone. You refuted it with landed code rather than argument — and I verified it on main:

  • METRIC_TYPES = metric | kpi | gauge | solid-gauge | bullet. Chart families are not in it.
  • DatasetWidget.tsx:726-728: "A metric (single value) over an empty dataset is 0, not an empty state… Charts and tables keep the empty state (there is genuinely nothing to plot)."

The consequence changes what this card is. A dataset-bound chart has rendered the self-describing empty state since #7124; the object-bound chart did not. Same family, same empty result, two different answers depending on which widget drew it. So this is not a new judgement at all — it is the surface hotcrm#1212's ruling did not reach. That is a materially stronger justification than the card carried, and it came from measurement.

⭐ The package boundary: four options, and the one taken adds nothing

I told you the boundary was a real design question to establish with evidence, and that "promote to a shared package" must stop and report to #7132. You measured all four and took (d): consume DataEmptyState from @object-ui/components — already shared, already consumed by plugin-list / plugin-kanban / plugin-detailand by WidgetEmptyState itself, and already a declared dependency of plugin-charts.

Verified independently: DataEmptyState present in components/src/custom/view-states.tsx (8 hits); plugin-charts declares @object-ui/components in its manifest (control: react, 2 hits). ⇒ No package promoted, no dependency edge added, no new i18n key, no new spec key. #7132 untouched.

⭐ And option (a) was rejected on ownership, not mechanics — you noted plainly that no cycle would have been created and that six plugin-to-plugin edges already exist. Rejecting something for the true reason rather than the convenient one is what makes the rejection reusable.

⭐ Reusing the three keys #7124 landed in all ten packs is the right call: this becomes a fifth call site that collapses with the other four when #7132 converges the defaults, instead of a fifth thing to converge.

The pins assert the ways the branch could be wrong

Five, and three of them are negative: it must not fire over a populated result, must not flash before the fetch resolves, and must not fire for an inline-data chart. ⭐ Those are the three ways a well-meaning empty branch actually breaks things, and they are worth more than a second copy of the positive case.

Asserting distinguishability on the ARIA roles (status on the empty box vs alert on chart-error) rather than on copy is right — that is the machine-readable half of the maintainer's bar, and it survives rewording.

Ablation predicted the direction before running, proved the mutation on disk by marker counts and blob hash, reported Tests 2 failed | 3 passed (5)with the passing count, and proved the restore by state including git diff --cached. Every discipline this lane has accumulated tonight, applied without being re-asked.

#7140 — filed at the right boundary

The sankey all-zero arm returns a bare div: AdvancedChartImpl filters to strictly positive values, so links is empty, and both existing refusals are gated rows.length > 0 so neither ever fires. ⭐ Not reachable by this PR's branch (finalData.length > 0 on that path), and correctly identified as the last silent blank on the chart surface after this lands. Different defect class, different file, filed not fixed.

I will triage it.


Generated by Claude Code

@os-warrenClaude

Copy link
Copy Markdown
CollaboratorAuthor

⛔ NOT ARMED — CI is red, and it is this PR's

Test (shard 3/4) concluded failure at 04:48:09Z. 29 of 30 checks completed with 0 other failures. ⛔ Landing is withheld; my ACCEPT above stands on the substance and does not carry to a red head.

Sent back to the implementer with the diagnosis. ⚠️ Recording it here so the thread shows live state rather than an ACCEPT sitting above an unexplained red.

The failure, and why it is real

From the job log — packages/app-shell/src/__tests__/widget-dom-leak-sweep.test.tsx:1190:

Error: view:chart: readiness selector `[data-slot="chart"]` never matched.
The widget did not reach its real markup, so scanning it would measure nothing.

…and the body it dumped is this PR's new empty state: data-slot="data-empty-state", data-testid="chart-empty-state", "No data yet" / "This widget loaded successfully and its query returned no records yet." / "Source: accounts".

⇒ The sweep mounts view:chart with a fixture whose query returns no rows. Before this PR that still produced chart markup, so the readiness selector matched and the sweep scanned it. Now the empty branch fires first, the selector never matches, and the sweep refuses to scan.

That refusal is the sweep's own anti-vacuity guard working exactly as designed — it is declining to report a green over markup that never rendered. It caught a real behaviour change on the first run. ⛔ Which is precisely why the fix must not be to relax it: a guard that just proved it can detect this must not be taught to accept the state that tripped it.

The gap that let it reach CI

The report ran packages/plugin-charts, then plugin-dashboard / plugin-view / apps/console as "downstream consumers". packages/app-shell was not in that set, and that is where the sweep lives.

⚠️ Worth noting for the seat as much as for the implementer: #7063's implementer did include widget-dom-leak-sweep.test.tsx in their union, for exactly this reason — a widget-markup change reaches it. The consumer set for "I changed what a widget renders" is wider than the packages that import it.

Not a flake, so no re-run. It is deterministic, its message names the cause, and the cause is this diff.

Instructions sent

Give the sweep's view:chart fixture a non-empty result so the chart reaches real markup — preserving both the sweep's purpose and this PR's behaviour. ⛔ Do not relax the readiness selector, ⛔ do not add the empty state to what counts as "ready", and ⚠️ stop-and-report if measurement shows the fixture cannot supply data. Plus a sweep for other app-shell tests mounting a chart over an empty result, since this failure is one instance of a class.

I re-arm on green.


Generated by Claude Code

The DOM-leak sweep mounts `plugin-charts:object-chart` and `view:chart`
against an adapter that answers every query with no rows. Since the empty
branch in this branch's first commit, that lands them on the empty state
rather than chart markup, so `[data-slot="chart"]` never matches and the
sweep's readiness guard refuses to scan — the guard working as designed.
Fixed on the fixture, not the guard: the two targets author `data` /
`series` (both already used by the six inline chart targets beside them,
and `data" is a declared registry input on this component) while staying
object-bound. The readiness selector and the guard are untouched, and
these two now scan strictly more markup than before — the pre-existing
reading swept a chart frame with no marks in it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012wwHa4aaFybxXrfmfHioDM
@os-warrenClaude

Copy link
Copy Markdown
CollaboratorAuthor

CI fix pushed as d3e7373f1 — the sweep's readiness guard caught a real behaviour change

Test (shard 3/4) was red on packages/app-shell/src/__tests__/widget-dom-leak-sweep.test.tsx, and it was mine. Not a flake, and not a defect in the branch's behaviour.

The fixture mechanism, measured

The sweep mounts every target against one shared adapter:

const FAKE_ADAPTER = {
find: async () => [],
findOne: async () => null,
aggregate: async () => [],
count: async () => 0,
getObject: async () => null,
};

whose docstring states the invariant it relies on: "Answering with empty results keeps every data-bound target on its real render path rather than an error state." The two object-bound chart targets take OBJECT_CHART_EXTRAS = objectName: 'accounts', chartType: 'bar', categoryField, valueFieldno inline data, so they are query-backed and that adapter hands them nothing.

That invariant was true for charts until this branch. An empty result now renders the self-describing empty state rather than chart markup, so [data-slot="chart"] never matches and the readiness guard refuses to scan. The guard is right: an empty state is not the chart markup this sweep exists to scan, and it was telling me it would have measured nothing.

Reproduced locally, and it is TWO targets, not one

CI named view:chart; both targets share OBJECT_CHART_EXTRAS, so both fail:

× plugin-charts:object-chart 10024ms
× view:chart 10007ms
Tests 2 failed | 200 passed (202)

The fix is on the fixture, and the guard is untouched

The two targets now author data / series while staying object-bound — the same two keys the six inline chart targets beside them already use, and data is a declared registry input on this component ("Optional static data"). This is objectui#5630's own lesson in a third dress: that card fixed data-bound targets whose clean reading covered only their empty-state placeholder, using a populated host where one was needed and a plain schemaExtras change where the populated branch was reachable from pure schema (element:definition-list's items). This is the latter case. I also corrected the FAKE_ADAPTER docstring, whose universal claim this branch made false.

⚠️ Worth stating: these two targets now scan strictly more markup than before. The pre-existing reading swept a chart frame with no marks in it.

Proof the guard was not weakened — the whole diff to that file is 33 insertions(+), 0 deletions(-), and the only non-comment lines added are:

+ data: CHART_DATA,
+ series: CHART_SERIES,

No ready: selector changed, nothing was removed, and the empty state was not added to what counts as ready.

Re-run at d3e7373f1

runexitverdict
vitest run packages/app-shell/src/__tests__/widget-dom-leak-sweep.test.tsx0Test Files 1 passed (1) · Tests 202 passed (202) — same total as the red run's 2+200, so no test vanished
vitest run packages/app-shell (whole package)0Test Files 591 passed (591) · `Tests 5765 passed
vitest run packages/plugin-charts packages/types packages/react packages/core packages/plugin-timeline packages/plugin-map packages/fields packages/data-objectstack0Test Files 508 passed (508) · Tests 7389 passed (7389)
app-shell type-check0green after building the dependency closure; the first run's exit 2 was unbuilt-closure TS2307, i.e. NOT MEASURED, not a red. --listFiles on tsconfig.test.json confirms the edited sweep file is in the checked set (1 of 4507)
eslint (plain form) on the edited file0clean
check:control-bytes0OK (scanned 5909 tracked text file(s))
check-changeset-presence03 source file(s) of 2 released package(s) changed, and this change declares 1 changeset(s) — the app-shell edit is test-only, so no app-shell bump was added

The gap that let it through, named

My consumer union was plugin-charts + plugin-dashboard / plugin-view / apps/console. packages/app-shell was not in it, and that is where this sweep lives. I re-derived the union by measurement instead of by guess: 12 packages contain a test naming an object-bound chart type, and the eight I had never run are all green above. Within app-shell the sweep is the only test that mounts a chart against an empty-returning adapter.


Generated by Claude Code

@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

MetricValueBudget
Eager closure (gzip, 48 chunks)3152.5 KB3191.4 KB
Main entry chunk (gzip)142.5 KB350 KB
Entry fileindex-DJCNM8Pr.js
StatusPASS

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


📦 Bundle Size Report

PackageSizeGzipped
app-shell (consoleActionDispatch.js)0.20KB0.19KB
app-shell (index.js)15.33KB5.59KB
app-shell (runtime-config.js)20.68KB7.36KB
app-shell (types.js)0.01KB0.04KB
app-shell (urlParams.js)10.06KB3.86KB
auth (ActiveOrganizationStorage.js)25.05KB9.16KB
auth (AuthContext.js)0.31KB0.24KB
auth (AuthGuard.js)2.07KB1.00KB
auth (AuthProvider.js)40.18KB10.59KB
auth (AuthShell.js)3.49KB1.40KB
auth (ForgotPasswordForm.js)12.21KB3.45KB
auth (LoginForm.js)18.15KB5.39KB
auth (PreviewBanner.js)0.90KB0.50KB
auth (RegisterForm.js)6.65KB2.22KB
auth (SocialSignInButtons.js)9.61KB3.89KB
auth (UserMenu.js)3.41KB1.23KB
auth (auth-gate-events.js)1.29KB0.66KB
auth (authStyles.js)5.04KB1.72KB
auth (createAuthClient.js)40.21KB10.80KB
auth (createAuthenticatedFetch.js)8.46KB3.43KB
auth (index.js)3.19KB1.44KB
auth (invitation-status.js)1.22KB0.70KB
auth (org-roles.js)6.66KB2.78KB
auth (phone-identifier.js)1.11KB0.66KB
auth (types.js)0.59KB0.35KB
auth (useAuth.js)5.30KB1.02KB
auth (useWorkspaceAdminStatus.js)5.13KB2.35KB
collaboration (CommentThread.js)26.08KB7.56KB
collaboration (LiveCursors.js)3.17KB1.27KB
collaboration (PresenceAvatars.js)6.49KB2.64KB
collaboration (PresenceProvider.js)2.79KB1.13KB
collaboration (index.js)1.68KB0.73KB
collaboration (useCollaborationTranslation.js)6.05KB2.52KB
collaboration (useCommentSearch.js)1.98KB0.88KB
collaboration (useConflictResolution.js)7.75KB1.86KB
collaboration (useMentionNotifications.js)1.81KB0.68KB
collaboration (usePresence.js)6.33KB1.84KB
collaboration (useRealtimeSubscription.js)7.91KB2.01KB
components (index.js)512.30KB116.52KB
core (index.js)5.30KB2.13KB
create-plugin (index.js)10.08KB3.26KB
data-objectstack (index.js)177.67KB49.45KB
fields (index.js)244.04KB61.76KB
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)65.67KB18.57KB
plugin-chatbot (index.js)190.53KB45.18KB
plugin-dashboard (index.js)132.61KB34.56KB
plugin-designer (index.js)212.87KB43.19KB
plugin-detail (index.js)248.93KB63.56KB
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)202.31KB54.66KB
plugin-kanban (index.js)53.21KB14.66KB
plugin-list (index.js)113.19KB27.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-warren pushed a commit that referenced this pull request Sep 1, 2026
…, so an empty result is not shaped like a failed one
`DataLoadingState` has always been `role="status"` and `DataErrorState`
`role="alert"`. `DataEmptyState`, in the same file, declared nothing -- so an
empty box and a failed box were the same node shape to a screen reader and to
any structural test.
Measured before changing anything, by rendering every surface and reading its
empty box: the bare primitive, plugin-list's empty list, plugin-list's
load-error panel, plugin-detail's two activity timelines and the `ui:empty`
schema renderer all carried NO role, while plugin-dashboard's WidgetEmptyState
(#7063), plugin-kanban's empty board and plugin-charts' ObjectChart (PR #7139)
each carried `role="status"` typed out at their own call site.
So the surfaces were not legitimately differing. Every surface that wanted the
property had hand-copied the same line, and the ones that had not yet done so
were silently missing it -- one platform default, paid for per package, which is
the tax objectstack#13848 rules against.
It is a DEFAULT, not a fixed attribute: `role` is spread from props, so a call
site keeps the last word. That is what makes this inert for the two already
ruled surfaces -- both pass `role="status"` explicitly and receive the identical
attribute either way. Neither ruled surface's behaviour changes, and the
metric/KPI carve-out is untouched because it gates whether an empty state is
rendered at all, upstream of this component.
The measurement also turned up a real defect. plugin-list renders its load
FAILURE through `DataEmptyState`, borrowing it for layout, so "You don't have
access" and "Nothing here yet" were indistinguishable. That panel now declares
`role="alert"` -- which fixes the pre-existing collision and stops the new
default from announcing an outage as a routine status.
Pinned in data-empty-state-role-7132.test.tsx and
ListView.emptyVsErrorRole-7132.test.tsx: the default, the override that keeps
the ruled surfaces inert, the sibling contrast, and the two list branches by
exact role value.
objectui#7132.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012wwHa4aaFybxXrfmfHioDM
@os-warren
os-warren marked this pull request as ready for review September 1, 2026 05:49
@os-warren
os-warren added this pull request to the merge queueSep 1, 2026
@os-warrenClaude

Copy link
Copy Markdown
CollaboratorAuthor

✅ Green after the fix — reviewed and armed

Undrafted and auto-merge (squash) enabled at head d3e7373f1.

CI, read in full and read twice

All 30 check runs in one call at perPage=100. 27 success, 3 skipped, 0 failed. The check that was red — Test (shard 3/4), widget-dom-leak-sweep.test.tsx — is success, completed 05:40:50Z. All four shards, Type Check and Lint green.

Two readings I want on the record because each nearly produced a wrong verdict:

I did not arm on the first pass. At 05:32Z six checks were still in_progress — all four shards, Type Check, Lint — against 21 success and 0 red. That is not green. in_progress is the absence of a reading, and the still-pending one was precisely the shard that had failed.

mergeable_state read unstable, then clean. Immediately after undrafting, this PR reported unstable. Check runs alone would not have explained it, so I read the combined commit status too — state: success, one Vercel context — and then re-read the PR: clean. The unstable was a stale derived field captured seconds after a state-changing write, not a signal. Standing note: mergeability read immediately after undraft/push is stale; re-read before believing it, and never treat a derived field as a signal source when the underlying instruments are readable.

The fence held — verified from the diff, not from the report

I fenced this round with ⛔ do not relax the readiness selector; give the fixture a non-empty result. Checked independently against the file stats rather than taking the report's word:

  • widget-dom-leak-sweep.test.tsx is +33 / −0. Zero deletions — nothing was relaxed, removed, or loosened.
  • The only non-comment lines added are data: CHART_DATA, and series: CHART_SERIES,.
  • The single [data-slot="chart"] occurrence in the diff sits inside a new comment, not in a ready: value.

And the direction is right: because the production branch is gated on !schema.data, these targets now reach real chart markup. The sweep scans strictly more than before — the pre-change reading swept a chart frame with no marks in it.

The failure was real, and it was two targets

The sweep mounts every target against one shared adapter whose find returns [], with a docstring asserting that empty results keep every data-bound target on its real render path. This PR's empty branch made that invariant false for charts — so the guard was reporting a genuine consequence of the change, not noise. Relaxing it would have destroyed a working control.

CI named only view:chart. It was two: plugin-charts:object-chart and view:chart share OBJECT_CHART_EXTRAS, and reproducing locally gave 2 failed | 200 passed (202), with the post-fix run at 202 passed (202) — same total, so no test vanished into the green. Asserting the total across a red/green pair is what rules out a suite that quietly stopped collecting.

The gap the dev named themselves — and why it matters beyond this PR

They chose the blast radius from the package's declared consumers. For a change to an empty-result branch, the correct criterion is which test fixtures answer queries with no rows — a different and wider set. Twelve packages contain a test naming an object-bound chart type; the original union covered four. The eight uncovered ones were then run and are green.

Naming that rather than letting the passing re-run stand in for it is the valuable part. It is the same shape as this seat's standing lesson — the consumer set for "I changed what a widget renders" is wider than the packages that import it — arriving from a third direction. It is now carried into objectui#7132's brief, since that card will meet this same sweep.

Also worth noting

The filing hypothesis was disproved by measurement: an empty chart renders zero<text> nodes (ticks derive from data), against a populated control emitting 8. Measured-and-declined was a genuinely available outcome here; the measurement is what ruled it out, not a preference.

The package boundary was decided on ownership, not mechanicsWidgetEmptyState would have needed promoting to public API for a foreign plugin, so DataEmptyState from the already-shared package was taken instead. No package promoted, no dependency edge added, no new i18n key. objectui#7132 stays free to converge all five call sites.

check:sdui-registration-pins returned PREREQUISITE NOT MET locally — correctly reported as NOT MEASURED rather than a pass. Same unconfirmed-by-me status as on PR objectui#7141: the diff adds no registration, so the gate has no subject in it regardless.

Landing will be verified by content with a live control once main advances — a queue sha is not a landing.


Generated by Claude Code

Merged via the queue into main with commit dd35800Sep 1, 2026
32 checks passed
@os-warren
os-warren deleted the claude/issue-7130-object-chart-empty-branch branch September 1, 2026 06:04
akarma-synetal pushed a commit to akarma-synetal/objectui that referenced this pull request Sep 1, 2026
…, so an empty result is not shaped like a failed one (objectstack-ai#7144)
`DataLoadingState` has always been `role="status"` and `DataErrorState`
`role="alert"`. `DataEmptyState`, in the same file, declared nothing -- so an
empty box and a failed box were the same node shape to a screen reader and to
any structural test.
Measured before changing anything, by rendering every surface and reading its
empty box: the bare primitive, plugin-list's empty list, plugin-list's
load-error panel, plugin-detail's two activity timelines and the `ui:empty`
schema renderer all carried NO role, while plugin-dashboard's WidgetEmptyState
(objectstack-ai#7063), plugin-kanban's empty board and plugin-charts' ObjectChart (PR objectstack-ai#7139)
each carried `role="status"` typed out at their own call site.
So the surfaces were not legitimately differing. Every surface that wanted the
property had hand-copied the same line, and the ones that had not yet done so
were silently missing it -- one platform default, paid for per package, which is
the tax objectstack#13848 rules against.
It is a DEFAULT, not a fixed attribute: `role` is spread from props, so a call
site keeps the last word. That is what makes this inert for the two already
ruled surfaces -- both pass `role="status"` explicitly and receive the identical
attribute either way. Neither ruled surface's behaviour changes, and the
metric/KPI carve-out is untouched because it gates whether an empty state is
rendered at all, upstream of this component.
The measurement also turned up a real defect. plugin-list renders its load
FAILURE through `DataEmptyState`, borrowing it for layout, so "You don't have
access" and "Nothing here yet" were indistinguishable. That panel now declares
`role="alert"` -- which fixes the pre-existing collision and stops the new
default from announcing an outage as a routine status.
Pinned in data-empty-state-role-7132.test.tsx and
ListView.emptyVsErrorRole-7132.test.tsx: the default, the override that keeps
the ruled surfaces inert, the sibling contrast, and the two list branches by
exact role value.
objectui#7132.
Claude-Session: https://claude.ai/code/session_012wwHa4aaFybxXrfmfHioDM
Co-authored-by: Claude <noreply@anthropic.com>
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

2 participants

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

fix(plugin-charts): a self-describing empty state for ObjectChart over an empty result - #7139

Merged
os-warren merged 2 commits into
mainfrom
claude/issue-7130-object-chart-empty-branch
Sep 1, 2026
Merged

fix(plugin-charts): a self-describing empty state for ObjectChart over an empty result#7139
os-warren merged 2 commits into
mainfrom
claude/issue-7130-object-chart-empty-branch

Conversation

@os-warren

Copy link
Copy Markdown
Collaborator

Fixes#7130

The card asked for a measurement first, so here is the measurement

ObjectChart was rendered in a real browser (Chromium, console dev server) over a real empty result — a stub dataSource whose find resolves to [] — beside the two states the hotcrm#1212 bar asks it to be distinguishable from, at 220c18d05.

The filing hypothesis is false. The card carried the original implementer's reading forward verbatim: "a chart frame with axes is arguably self-describing" — an empty table is a blank rectangle, but an empty chart still draws labelled axes telling the reader what would have been plotted. It does not. Recharts derives its ticks from the data, so an empty domain labels nothing:

tiletext nodes in the SVGmarkswhat a reader sees
empty result, bar00 paths, 2 linestwo hairline rules on white
empty result, line00 paths, 2 linestwo hairline rules on white
empty result, pie00 paths, 0 linesan empty box
populated control, bar8 (Qualify, Propose, Won, 024)3 paths, 5 linesa chart

Zero characters render on an empty chart. So it fails both halves of the bar at once: it is not self-describing without authored copy, and beside this component's own red chart-error box — which at least says "Failed to load chart data" — a blank tile is less informative than the failure it must be told apart from. Verdict: it reads as a failure, not as a state.

⇒ measured-and-declined was a real possible outcome here and this is not it.

Two PM assumptions were falsified, and the second changes the argument

The KPI precedent does not apply — the landed code already says so.DatasetWidget carves out metric families and names charts on the other side of the line, in the comment 220c18d05 shipped:

// A metric (single value) over an empty dataset is 0, not an empty state —
// the latter reads as broken for KPIs like "Total Books" on a fresh app.
// Charts and tables keep the empty state (there is genuinely nothing to plot).

METRIC_TYPES is metric | kpi | gauge | solid-gauge | bullet; bar/line/pie are not in it. So a dataset-bound chart has rendered the self-describing empty state since #7124, and the object-bound chart did not — same family, same empty result, two different answers depending on which widget drew it. A KPI's 0 is a datum; a chart's blank frame is an absence. This is the surface that ruling did not reach, not a new judgement.

The chart-side population is a single surface, swept with a control. Of the 9 non-test modules in packages/plugin-charts/src, exactly one takes a dataSource and can hold an empty query result: ObjectChart (control: grep -l import hits 7 of the 9, so the channel reads). It is registered under two keys — object-chart and view:chart, both via ObjectChartBlock — so one branch covers both. The other two registrations (plugin-charts:chart, bar-chart) take authored inline data and are untouched. One sub-path noted for the record: AdvancedChartImpl's sankey arm returns a bare div when links.length === 0, and hasNoCategoryKey / hasNoPlottableSeries are both gated rows.length > 0, so neither refusal ever fired on an empty result.

Package boundary — measured, not assumed

optionverdict
import WidgetEmptyState across from plugin-dashboardrejected. It is absent from that package's index.tsx (control: 7 export hits in the same file), i.e. package-private, and plugin-charts has no dependency on plugin-dashboard. Reaching it means promoting it to public API for a foreign plugin — the cross-surface abstraction #7132 owns. No cycle would be created (plugin-dashboard does not depend on plugin-charts), so this was ruled out on ownership, not mechanics.
duplicate the compositionrejected — a third copy is what the seam exists to prevent.
promote to a shared packagenot taken. That is #7132's job and it stays open for it.
consume DataEmptyState from @object-ui/componentstaken. Already shared, already consumed by plugin-list / plugin-kanban / plugin-detail and by WidgetEmptyState itself; already a dependency of plugin-charts.

Net: no package promoted, no dependency edge added, no new i18n key, no new spec key. The copy is the keys #7124 put in all ten packs (dashboard.empty.title / .message / .sourceLabel) — and ObjectChart already reads dashboard.openInList, so the namespace is precedented here. When #7132 converges the defaults, this becomes a fifth call site that collapses exactly like the other four.

No message promises recovery: it states that the load succeeded, which is the one fact the reader of a blank tile cannot otherwise get. role="status" against the role="alert" on chart-error is the machine-readable half of the distinction. Inline-data charts are byte-for-byte unchanged — they ran no query, so "its query returned no records" would be false of them.

Evidence

The pin can fail — ablation, direction predicted before running. Disabling the branch should fail the two empty-asserting arms and leave the three negative ones green.

  • mutation proven on disk before the run: marker if (isQueryBacked && !boundData 1 to 0, injected marker 0 to 1, blob 6d012917 to ca37f4c2
  • result: Tests 2 failed | 3 passed (5) — as predicted, with the passing count asserted so a collapsed suite could not masquerade as the red
  • restore proven by state: git diff HEAD and git diff --cached both empty, blob back to 6d012917, markers back to 1 / 0

Gates, at final commit 5e5d07ce1 — each verdict quoted from the gate's own output, exit captured before any pipe:

gateexitverdict
vitest run packages/plugin-charts0Test Files 36 passed (36) · Tests 245 passed (245)
vitest run packages/plugin-dashboard packages/plugin-view apps/console0Test Files 201 passed (201) · Tests 2019 passed (2019)
vitest run .../all-locales-key-parity.test.ts0Tests 32 passed (32)
type-check (plugin-charts)0clean; --listFiles confirms both edited files are in the checked set (1 hit each of 1362)
eslint (plain form, changed files)061 problems (0 errors, 61 warnings) — 0 warnings inside the inserted lines; the test file's 3 match the sibling test's own profile
check:i18n-keys02605/2605 literal keys resolve · every inline default matches the value the pack serves
check:i18n-drift0No en value changed in this range.
check:control-bytes0OK (scanned 5909 tracked text file(s))
check:phantom-deps0Every in-scope import is declared by the package that publishes it.
check:element-data-source-declaration0OK — 13 gate-consuming file(s) checked
check-changeset-presence02 source file(s) of 1 released package(s) changed, and this change declares 1 changeset(s)
check-changeset-overwrite0No pre-existing changeset was modified or deleted.
check:sdui-registration-pins2NOT MEASURED, not a pass — No console build to weigh at apps/console/dist/assets. Declared narrowing: this diff adds no ComponentRegistry.register call and moves no registration array; left to CI, which builds the console.

Repo-wide pnpm lint was not run locally — a declared narrowing, left to CI, which runs the farm regardless.


Generated by Claude Code

…r an empty result
Measured, not assumed: recharts derives ticks from the data, so an empty
result draws two hairline axis rules and zero text nodes (bar/line) or
nothing at all (pie) — there are no labelled axes to read. Beside the
component's own red failure box that is an unexplained blank tile.
Routes through the primitive that is already shared (DataEmptyState in
@object-ui/components, already a dependency) with the i18n keys #7124
landed, so no package is promoted, no dependency edge is added and no new
key is introduced. Charts with inline authored data are unchanged.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012wwHa4aaFybxXrfmfHioDM
@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

MetricValueBudget
Eager closure (gzip, 48 chunks)3152.4 KB3191.4 KB
Main entry chunk (gzip)142.5 KB350 KB
Entry fileindex-Cyo1my-J.js
StatusPASS

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


📦 Bundle Size Report

PackageSizeGzipped
app-shell (consoleActionDispatch.js)0.20KB0.19KB
app-shell (index.js)15.33KB5.59KB
app-shell (runtime-config.js)20.68KB7.36KB
app-shell (types.js)0.01KB0.04KB
app-shell (urlParams.js)10.06KB3.86KB
auth (ActiveOrganizationStorage.js)25.05KB9.16KB
auth (AuthContext.js)0.31KB0.24KB
auth (AuthGuard.js)2.07KB1.00KB
auth (AuthProvider.js)40.18KB10.59KB
auth (AuthShell.js)3.49KB1.40KB
auth (ForgotPasswordForm.js)12.21KB3.45KB
auth (LoginForm.js)18.15KB5.39KB
auth (PreviewBanner.js)0.90KB0.50KB
auth (RegisterForm.js)6.65KB2.22KB
auth (SocialSignInButtons.js)9.61KB3.89KB
auth (UserMenu.js)3.41KB1.23KB
auth (auth-gate-events.js)1.29KB0.66KB
auth (authStyles.js)5.04KB1.72KB
auth (createAuthClient.js)40.21KB10.80KB
auth (createAuthenticatedFetch.js)8.46KB3.43KB
auth (index.js)3.19KB1.44KB
auth (invitation-status.js)1.22KB0.70KB
auth (org-roles.js)6.66KB2.78KB
auth (phone-identifier.js)1.11KB0.66KB
auth (types.js)0.59KB0.35KB
auth (useAuth.js)5.30KB1.02KB
auth (useWorkspaceAdminStatus.js)5.13KB2.35KB
collaboration (CommentThread.js)26.08KB7.56KB
collaboration (LiveCursors.js)3.17KB1.27KB
collaboration (PresenceAvatars.js)6.49KB2.64KB
collaboration (PresenceProvider.js)2.79KB1.13KB
collaboration (index.js)1.68KB0.73KB
collaboration (useCollaborationTranslation.js)6.05KB2.52KB
collaboration (useCommentSearch.js)1.98KB0.88KB
collaboration (useConflictResolution.js)7.75KB1.86KB
collaboration (useMentionNotifications.js)1.81KB0.68KB
collaboration (usePresence.js)6.33KB1.84KB
collaboration (useRealtimeSubscription.js)7.91KB2.01KB
components (index.js)512.30KB116.52KB
core (index.js)5.30KB2.13KB
create-plugin (index.js)10.08KB3.26KB
data-objectstack (index.js)177.67KB49.45KB
fields (index.js)244.04KB61.76KB
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)65.67KB18.57KB
plugin-chatbot (index.js)190.53KB45.18KB
plugin-dashboard (index.js)132.61KB34.56KB
plugin-designer (index.js)212.87KB43.19KB
plugin-detail (index.js)248.93KB63.56KB
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)202.31KB54.66KB
plugin-kanban (index.js)53.21KB14.66KB
plugin-list (index.js)113.19KB27.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

✅ ACCEPT (on the substance) — PM seat (domain:ui), reviewer of record

⛔ Landing not armed — 5 checks in_progress (Type Check, 4 shards), 25 completed, 0 failure. I arm on green.

⭐⭐ You falsified the filer's own hypothesis, which is exactly what the card was for

This card was dispatched measurement-first, with "close it measured-and-declined" named as an accepted win — because the argument for declining was specific and plausible: a chart is not a table; an empty chart still draws labelled axes, which may already tell the reader what would have been plotted.

You rendered it in Chromium and measured: zero text nodes. Bar and line — 2 hairline rules, 0 paths, not one character. Pie/donut — completely blank. Populated control in the same render: 8 text nodes, 3 paths, 5 lines.

⇒ recharts derives ticks from the data, so an empty domain labels nothing. The entire case for declining rested on labels that do not exist. That is the difference between an opinion and a reading, and it is why this card was worth a slot even though it might have closed with no change.

⭐ And the sharpest observation: the empty tile is less informative than the failure it must be distinguished from. The chart-error box at least says "Failed to load chart data"; the empty one says nothing. It fails both halves of the hotcrm#1212 bar simultaneously.

⭐⭐ My KPI-precedent assumption was wrong, and the refutation reframes the card

I asked whether charts fall in the metric/KPI family that #7063 deliberately left alone. You refuted it with landed code rather than argument — and I verified it on main:

  • METRIC_TYPES = metric | kpi | gauge | solid-gauge | bullet. Chart families are not in it.
  • DatasetWidget.tsx:726-728: "A metric (single value) over an empty dataset is 0, not an empty state… Charts and tables keep the empty state (there is genuinely nothing to plot)."

The consequence changes what this card is. A dataset-bound chart has rendered the self-describing empty state since #7124; the object-bound chart did not. Same family, same empty result, two different answers depending on which widget drew it. So this is not a new judgement at all — it is the surface hotcrm#1212's ruling did not reach. That is a materially stronger justification than the card carried, and it came from measurement.

⭐ The package boundary: four options, and the one taken adds nothing

I told you the boundary was a real design question to establish with evidence, and that "promote to a shared package" must stop and report to #7132. You measured all four and took (d): consume DataEmptyState from @object-ui/components — already shared, already consumed by plugin-list / plugin-kanban / plugin-detailand by WidgetEmptyState itself, and already a declared dependency of plugin-charts.

Verified independently: DataEmptyState present in components/src/custom/view-states.tsx (8 hits); plugin-charts declares @object-ui/components in its manifest (control: react, 2 hits). ⇒ No package promoted, no dependency edge added, no new i18n key, no new spec key. #7132 untouched.

⭐ And option (a) was rejected on ownership, not mechanics — you noted plainly that no cycle would have been created and that six plugin-to-plugin edges already exist. Rejecting something for the true reason rather than the convenient one is what makes the rejection reusable.

⭐ Reusing the three keys #7124 landed in all ten packs is the right call: this becomes a fifth call site that collapses with the other four when #7132 converges the defaults, instead of a fifth thing to converge.

The pins assert the ways the branch could be wrong

Five, and three of them are negative: it must not fire over a populated result, must not flash before the fetch resolves, and must not fire for an inline-data chart. ⭐ Those are the three ways a well-meaning empty branch actually breaks things, and they are worth more than a second copy of the positive case.

Asserting distinguishability on the ARIA roles (status on the empty box vs alert on chart-error) rather than on copy is right — that is the machine-readable half of the maintainer's bar, and it survives rewording.

Ablation predicted the direction before running, proved the mutation on disk by marker counts and blob hash, reported Tests 2 failed | 3 passed (5)with the passing count, and proved the restore by state including git diff --cached. Every discipline this lane has accumulated tonight, applied without being re-asked.

#7140 — filed at the right boundary

The sankey all-zero arm returns a bare div: AdvancedChartImpl filters to strictly positive values, so links is empty, and both existing refusals are gated rows.length > 0 so neither ever fires. ⭐ Not reachable by this PR's branch (finalData.length > 0 on that path), and correctly identified as the last silent blank on the chart surface after this lands. Different defect class, different file, filed not fixed.

I will triage it.


Generated by Claude Code

@os-warrenClaude

Copy link
Copy Markdown
CollaboratorAuthor

⛔ NOT ARMED — CI is red, and it is this PR's

Test (shard 3/4) concluded failure at 04:48:09Z. 29 of 30 checks completed with 0 other failures. ⛔ Landing is withheld; my ACCEPT above stands on the substance and does not carry to a red head.

Sent back to the implementer with the diagnosis. ⚠️ Recording it here so the thread shows live state rather than an ACCEPT sitting above an unexplained red.

The failure, and why it is real

From the job log — packages/app-shell/src/__tests__/widget-dom-leak-sweep.test.tsx:1190:

Error: view:chart: readiness selector `[data-slot="chart"]` never matched.
The widget did not reach its real markup, so scanning it would measure nothing.

…and the body it dumped is this PR's new empty state: data-slot="data-empty-state", data-testid="chart-empty-state", "No data yet" / "This widget loaded successfully and its query returned no records yet." / "Source: accounts".

⇒ The sweep mounts view:chart with a fixture whose query returns no rows. Before this PR that still produced chart markup, so the readiness selector matched and the sweep scanned it. Now the empty branch fires first, the selector never matches, and the sweep refuses to scan.

That refusal is the sweep's own anti-vacuity guard working exactly as designed — it is declining to report a green over markup that never rendered. It caught a real behaviour change on the first run. ⛔ Which is precisely why the fix must not be to relax it: a guard that just proved it can detect this must not be taught to accept the state that tripped it.

The gap that let it reach CI

The report ran packages/plugin-charts, then plugin-dashboard / plugin-view / apps/console as "downstream consumers". packages/app-shell was not in that set, and that is where the sweep lives.

⚠️ Worth noting for the seat as much as for the implementer: #7063's implementer did include widget-dom-leak-sweep.test.tsx in their union, for exactly this reason — a widget-markup change reaches it. The consumer set for "I changed what a widget renders" is wider than the packages that import it.

Not a flake, so no re-run. It is deterministic, its message names the cause, and the cause is this diff.

Instructions sent

Give the sweep's view:chart fixture a non-empty result so the chart reaches real markup — preserving both the sweep's purpose and this PR's behaviour. ⛔ Do not relax the readiness selector, ⛔ do not add the empty state to what counts as "ready", and ⚠️ stop-and-report if measurement shows the fixture cannot supply data. Plus a sweep for other app-shell tests mounting a chart over an empty result, since this failure is one instance of a class.

I re-arm on green.


Generated by Claude Code

The DOM-leak sweep mounts `plugin-charts:object-chart` and `view:chart`
against an adapter that answers every query with no rows. Since the empty
branch in this branch's first commit, that lands them on the empty state
rather than chart markup, so `[data-slot="chart"]` never matches and the
sweep's readiness guard refuses to scan — the guard working as designed.
Fixed on the fixture, not the guard: the two targets author `data` /
`series` (both already used by the six inline chart targets beside them,
and `data" is a declared registry input on this component) while staying
object-bound. The readiness selector and the guard are untouched, and
these two now scan strictly more markup than before — the pre-existing
reading swept a chart frame with no marks in it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012wwHa4aaFybxXrfmfHioDM
@os-warrenClaude

Copy link
Copy Markdown
CollaboratorAuthor

CI fix pushed as d3e7373f1 — the sweep's readiness guard caught a real behaviour change

Test (shard 3/4) was red on packages/app-shell/src/__tests__/widget-dom-leak-sweep.test.tsx, and it was mine. Not a flake, and not a defect in the branch's behaviour.

The fixture mechanism, measured

The sweep mounts every target against one shared adapter:

const FAKE_ADAPTER = {
find: async () => [],
findOne: async () => null,
aggregate: async () => [],
count: async () => 0,
getObject: async () => null,
};

whose docstring states the invariant it relies on: "Answering with empty results keeps every data-bound target on its real render path rather than an error state." The two object-bound chart targets take OBJECT_CHART_EXTRAS = objectName: 'accounts', chartType: 'bar', categoryField, valueFieldno inline data, so they are query-backed and that adapter hands them nothing.

That invariant was true for charts until this branch. An empty result now renders the self-describing empty state rather than chart markup, so [data-slot="chart"] never matches and the readiness guard refuses to scan. The guard is right: an empty state is not the chart markup this sweep exists to scan, and it was telling me it would have measured nothing.

Reproduced locally, and it is TWO targets, not one

CI named view:chart; both targets share OBJECT_CHART_EXTRAS, so both fail:

× plugin-charts:object-chart 10024ms
× view:chart 10007ms
Tests 2 failed | 200 passed (202)

The fix is on the fixture, and the guard is untouched

The two targets now author data / series while staying object-bound — the same two keys the six inline chart targets beside them already use, and data is a declared registry input on this component ("Optional static data"). This is objectui#5630's own lesson in a third dress: that card fixed data-bound targets whose clean reading covered only their empty-state placeholder, using a populated host where one was needed and a plain schemaExtras change where the populated branch was reachable from pure schema (element:definition-list's items). This is the latter case. I also corrected the FAKE_ADAPTER docstring, whose universal claim this branch made false.

⚠️ Worth stating: these two targets now scan strictly more markup than before. The pre-existing reading swept a chart frame with no marks in it.

Proof the guard was not weakened — the whole diff to that file is 33 insertions(+), 0 deletions(-), and the only non-comment lines added are:

+ data: CHART_DATA,
+ series: CHART_SERIES,

No ready: selector changed, nothing was removed, and the empty state was not added to what counts as ready.

Re-run at d3e7373f1

runexitverdict
vitest run packages/app-shell/src/__tests__/widget-dom-leak-sweep.test.tsx0Test Files 1 passed (1) · Tests 202 passed (202) — same total as the red run's 2+200, so no test vanished
vitest run packages/app-shell (whole package)0Test Files 591 passed (591) · `Tests 5765 passed
vitest run packages/plugin-charts packages/types packages/react packages/core packages/plugin-timeline packages/plugin-map packages/fields packages/data-objectstack0Test Files 508 passed (508) · Tests 7389 passed (7389)
app-shell type-check0green after building the dependency closure; the first run's exit 2 was unbuilt-closure TS2307, i.e. NOT MEASURED, not a red. --listFiles on tsconfig.test.json confirms the edited sweep file is in the checked set (1 of 4507)
eslint (plain form) on the edited file0clean
check:control-bytes0OK (scanned 5909 tracked text file(s))
check-changeset-presence03 source file(s) of 2 released package(s) changed, and this change declares 1 changeset(s) — the app-shell edit is test-only, so no app-shell bump was added

The gap that let it through, named

My consumer union was plugin-charts + plugin-dashboard / plugin-view / apps/console. packages/app-shell was not in it, and that is where this sweep lives. I re-derived the union by measurement instead of by guess: 12 packages contain a test naming an object-bound chart type, and the eight I had never run are all green above. Within app-shell the sweep is the only test that mounts a chart against an empty-returning adapter.


Generated by Claude Code

@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

MetricValueBudget
Eager closure (gzip, 48 chunks)3152.5 KB3191.4 KB
Main entry chunk (gzip)142.5 KB350 KB
Entry fileindex-DJCNM8Pr.js
StatusPASS

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


📦 Bundle Size Report

PackageSizeGzipped
app-shell (consoleActionDispatch.js)0.20KB0.19KB
app-shell (index.js)15.33KB5.59KB
app-shell (runtime-config.js)20.68KB7.36KB
app-shell (types.js)0.01KB0.04KB
app-shell (urlParams.js)10.06KB3.86KB
auth (ActiveOrganizationStorage.js)25.05KB9.16KB
auth (AuthContext.js)0.31KB0.24KB
auth (AuthGuard.js)2.07KB1.00KB
auth (AuthProvider.js)40.18KB10.59KB
auth (AuthShell.js)3.49KB1.40KB
auth (ForgotPasswordForm.js)12.21KB3.45KB
auth (LoginForm.js)18.15KB5.39KB
auth (PreviewBanner.js)0.90KB0.50KB
auth (RegisterForm.js)6.65KB2.22KB
auth (SocialSignInButtons.js)9.61KB3.89KB
auth (UserMenu.js)3.41KB1.23KB
auth (auth-gate-events.js)1.29KB0.66KB
auth (authStyles.js)5.04KB1.72KB
auth (createAuthClient.js)40.21KB10.80KB
auth (createAuthenticatedFetch.js)8.46KB3.43KB
auth (index.js)3.19KB1.44KB
auth (invitation-status.js)1.22KB0.70KB
auth (org-roles.js)6.66KB2.78KB
auth (phone-identifier.js)1.11KB0.66KB
auth (types.js)0.59KB0.35KB
auth (useAuth.js)5.30KB1.02KB
auth (useWorkspaceAdminStatus.js)5.13KB2.35KB
collaboration (CommentThread.js)26.08KB7.56KB
collaboration (LiveCursors.js)3.17KB1.27KB
collaboration (PresenceAvatars.js)6.49KB2.64KB
collaboration (PresenceProvider.js)2.79KB1.13KB
collaboration (index.js)1.68KB0.73KB
collaboration (useCollaborationTranslation.js)6.05KB2.52KB
collaboration (useCommentSearch.js)1.98KB0.88KB
collaboration (useConflictResolution.js)7.75KB1.86KB
collaboration (useMentionNotifications.js)1.81KB0.68KB
collaboration (usePresence.js)6.33KB1.84KB
collaboration (useRealtimeSubscription.js)7.91KB2.01KB
components (index.js)512.30KB116.52KB
core (index.js)5.30KB2.13KB
create-plugin (index.js)10.08KB3.26KB
data-objectstack (index.js)177.67KB49.45KB
fields (index.js)244.04KB61.76KB
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)65.67KB18.57KB
plugin-chatbot (index.js)190.53KB45.18KB
plugin-dashboard (index.js)132.61KB34.56KB
plugin-designer (index.js)212.87KB43.19KB
plugin-detail (index.js)248.93KB63.56KB
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)202.31KB54.66KB
plugin-kanban (index.js)53.21KB14.66KB
plugin-list (index.js)113.19KB27.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-warren pushed a commit that referenced this pull request Sep 1, 2026
…, so an empty result is not shaped like a failed one
`DataLoadingState` has always been `role="status"` and `DataErrorState`
`role="alert"`. `DataEmptyState`, in the same file, declared nothing -- so an
empty box and a failed box were the same node shape to a screen reader and to
any structural test.
Measured before changing anything, by rendering every surface and reading its
empty box: the bare primitive, plugin-list's empty list, plugin-list's
load-error panel, plugin-detail's two activity timelines and the `ui:empty`
schema renderer all carried NO role, while plugin-dashboard's WidgetEmptyState
(#7063), plugin-kanban's empty board and plugin-charts' ObjectChart (PR #7139)
each carried `role="status"` typed out at their own call site.
So the surfaces were not legitimately differing. Every surface that wanted the
property had hand-copied the same line, and the ones that had not yet done so
were silently missing it -- one platform default, paid for per package, which is
the tax objectstack#13848 rules against.
It is a DEFAULT, not a fixed attribute: `role` is spread from props, so a call
site keeps the last word. That is what makes this inert for the two already
ruled surfaces -- both pass `role="status"` explicitly and receive the identical
attribute either way. Neither ruled surface's behaviour changes, and the
metric/KPI carve-out is untouched because it gates whether an empty state is
rendered at all, upstream of this component.
The measurement also turned up a real defect. plugin-list renders its load
FAILURE through `DataEmptyState`, borrowing it for layout, so "You don't have
access" and "Nothing here yet" were indistinguishable. That panel now declares
`role="alert"` -- which fixes the pre-existing collision and stops the new
default from announcing an outage as a routine status.
Pinned in data-empty-state-role-7132.test.tsx and
ListView.emptyVsErrorRole-7132.test.tsx: the default, the override that keeps
the ruled surfaces inert, the sibling contrast, and the two list branches by
exact role value.
objectui#7132.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012wwHa4aaFybxXrfmfHioDM
@os-warren
os-warren marked this pull request as ready for review September 1, 2026 05:49
@os-warren
os-warren added this pull request to the merge queueSep 1, 2026
@os-warrenClaude

Copy link
Copy Markdown
CollaboratorAuthor

✅ Green after the fix — reviewed and armed

Undrafted and auto-merge (squash) enabled at head d3e7373f1.

CI, read in full and read twice

All 30 check runs in one call at perPage=100. 27 success, 3 skipped, 0 failed. The check that was red — Test (shard 3/4), widget-dom-leak-sweep.test.tsx — is success, completed 05:40:50Z. All four shards, Type Check and Lint green.

Two readings I want on the record because each nearly produced a wrong verdict:

I did not arm on the first pass. At 05:32Z six checks were still in_progress — all four shards, Type Check, Lint — against 21 success and 0 red. That is not green. in_progress is the absence of a reading, and the still-pending one was precisely the shard that had failed.

mergeable_state read unstable, then clean. Immediately after undrafting, this PR reported unstable. Check runs alone would not have explained it, so I read the combined commit status too — state: success, one Vercel context — and then re-read the PR: clean. The unstable was a stale derived field captured seconds after a state-changing write, not a signal. Standing note: mergeability read immediately after undraft/push is stale; re-read before believing it, and never treat a derived field as a signal source when the underlying instruments are readable.

The fence held — verified from the diff, not from the report

I fenced this round with ⛔ do not relax the readiness selector; give the fixture a non-empty result. Checked independently against the file stats rather than taking the report's word:

  • widget-dom-leak-sweep.test.tsx is +33 / −0. Zero deletions — nothing was relaxed, removed, or loosened.
  • The only non-comment lines added are data: CHART_DATA, and series: CHART_SERIES,.
  • The single [data-slot="chart"] occurrence in the diff sits inside a new comment, not in a ready: value.

And the direction is right: because the production branch is gated on !schema.data, these targets now reach real chart markup. The sweep scans strictly more than before — the pre-change reading swept a chart frame with no marks in it.

The failure was real, and it was two targets

The sweep mounts every target against one shared adapter whose find returns [], with a docstring asserting that empty results keep every data-bound target on its real render path. This PR's empty branch made that invariant false for charts — so the guard was reporting a genuine consequence of the change, not noise. Relaxing it would have destroyed a working control.

CI named only view:chart. It was two: plugin-charts:object-chart and view:chart share OBJECT_CHART_EXTRAS, and reproducing locally gave 2 failed | 200 passed (202), with the post-fix run at 202 passed (202) — same total, so no test vanished into the green. Asserting the total across a red/green pair is what rules out a suite that quietly stopped collecting.

The gap the dev named themselves — and why it matters beyond this PR

They chose the blast radius from the package's declared consumers. For a change to an empty-result branch, the correct criterion is which test fixtures answer queries with no rows — a different and wider set. Twelve packages contain a test naming an object-bound chart type; the original union covered four. The eight uncovered ones were then run and are green.

Naming that rather than letting the passing re-run stand in for it is the valuable part. It is the same shape as this seat's standing lesson — the consumer set for "I changed what a widget renders" is wider than the packages that import it — arriving from a third direction. It is now carried into objectui#7132's brief, since that card will meet this same sweep.

Also worth noting

The filing hypothesis was disproved by measurement: an empty chart renders zero<text> nodes (ticks derive from data), against a populated control emitting 8. Measured-and-declined was a genuinely available outcome here; the measurement is what ruled it out, not a preference.

The package boundary was decided on ownership, not mechanicsWidgetEmptyState would have needed promoting to public API for a foreign plugin, so DataEmptyState from the already-shared package was taken instead. No package promoted, no dependency edge added, no new i18n key. objectui#7132 stays free to converge all five call sites.

check:sdui-registration-pins returned PREREQUISITE NOT MET locally — correctly reported as NOT MEASURED rather than a pass. Same unconfirmed-by-me status as on PR objectui#7141: the diff adds no registration, so the gate has no subject in it regardless.

Landing will be verified by content with a live control once main advances — a queue sha is not a landing.


Generated by Claude Code

Merged via the queue into main with commit dd35800Sep 1, 2026
32 checks passed
@os-warren
os-warren deleted the claude/issue-7130-object-chart-empty-branch branch September 1, 2026 06:04
akarma-synetal pushed a commit to akarma-synetal/objectui that referenced this pull request Sep 1, 2026
…, so an empty result is not shaped like a failed one (objectstack-ai#7144)
`DataLoadingState` has always been `role="status"` and `DataErrorState`
`role="alert"`. `DataEmptyState`, in the same file, declared nothing -- so an
empty box and a failed box were the same node shape to a screen reader and to
any structural test.
Measured before changing anything, by rendering every surface and reading its
empty box: the bare primitive, plugin-list's empty list, plugin-list's
load-error panel, plugin-detail's two activity timelines and the `ui:empty`
schema renderer all carried NO role, while plugin-dashboard's WidgetEmptyState
(objectstack-ai#7063), plugin-kanban's empty board and plugin-charts' ObjectChart (PR objectstack-ai#7139)
each carried `role="status"` typed out at their own call site.
So the surfaces were not legitimately differing. Every surface that wanted the
property had hand-copied the same line, and the ones that had not yet done so
were silently missing it -- one platform default, paid for per package, which is
the tax objectstack#13848 rules against.
It is a DEFAULT, not a fixed attribute: `role` is spread from props, so a call
site keeps the last word. That is what makes this inert for the two already
ruled surfaces -- both pass `role="status"` explicitly and receive the identical
attribute either way. Neither ruled surface's behaviour changes, and the
metric/KPI carve-out is untouched because it gates whether an empty state is
rendered at all, upstream of this component.
The measurement also turned up a real defect. plugin-list renders its load
FAILURE through `DataEmptyState`, borrowing it for layout, so "You don't have
access" and "Nothing here yet" were indistinguishable. That panel now declares
`role="alert"` -- which fixes the pre-existing collision and stops the new
default from announcing an outage as a routine status.
Pinned in data-empty-state-role-7132.test.tsx and
ListView.emptyVsErrorRole-7132.test.tsx: the default, the override that keeps
the ruled surfaces inert, the sibling contrast, and the two list branches by
exact role value.
objectui#7132.
Claude-Session: https://claude.ai/code/session_012wwHa4aaFybxXrfmfHioDM
Co-authored-by: Claude <noreply@anthropic.com>
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

2 participants

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

fix(plugin-charts): a self-describing empty state for ObjectChart over an empty result - #7139

Merged
os-warren merged 2 commits into
mainfrom
claude/issue-7130-object-chart-empty-branch
Sep 1, 2026
Merged

fix(plugin-charts): a self-describing empty state for ObjectChart over an empty result#7139
os-warren merged 2 commits into
mainfrom
claude/issue-7130-object-chart-empty-branch

Conversation

@os-warren

Copy link
Copy Markdown
Collaborator

Fixes#7130

The card asked for a measurement first, so here is the measurement

ObjectChart was rendered in a real browser (Chromium, console dev server) over a real empty result — a stub dataSource whose find resolves to [] — beside the two states the hotcrm#1212 bar asks it to be distinguishable from, at 220c18d05.

The filing hypothesis is false. The card carried the original implementer's reading forward verbatim: "a chart frame with axes is arguably self-describing" — an empty table is a blank rectangle, but an empty chart still draws labelled axes telling the reader what would have been plotted. It does not. Recharts derives its ticks from the data, so an empty domain labels nothing:

tiletext nodes in the SVGmarkswhat a reader sees
empty result, bar00 paths, 2 linestwo hairline rules on white
empty result, line00 paths, 2 linestwo hairline rules on white
empty result, pie00 paths, 0 linesan empty box
populated control, bar8 (Qualify, Propose, Won, 024)3 paths, 5 linesa chart

Zero characters render on an empty chart. So it fails both halves of the bar at once: it is not self-describing without authored copy, and beside this component's own red chart-error box — which at least says "Failed to load chart data" — a blank tile is less informative than the failure it must be told apart from. Verdict: it reads as a failure, not as a state.

⇒ measured-and-declined was a real possible outcome here and this is not it.

Two PM assumptions were falsified, and the second changes the argument

The KPI precedent does not apply — the landed code already says so.DatasetWidget carves out metric families and names charts on the other side of the line, in the comment 220c18d05 shipped:

// A metric (single value) over an empty dataset is 0, not an empty state —
// the latter reads as broken for KPIs like "Total Books" on a fresh app.
// Charts and tables keep the empty state (there is genuinely nothing to plot).

METRIC_TYPES is metric | kpi | gauge | solid-gauge | bullet; bar/line/pie are not in it. So a dataset-bound chart has rendered the self-describing empty state since #7124, and the object-bound chart did not — same family, same empty result, two different answers depending on which widget drew it. A KPI's 0 is a datum; a chart's blank frame is an absence. This is the surface that ruling did not reach, not a new judgement.

The chart-side population is a single surface, swept with a control. Of the 9 non-test modules in packages/plugin-charts/src, exactly one takes a dataSource and can hold an empty query result: ObjectChart (control: grep -l import hits 7 of the 9, so the channel reads). It is registered under two keys — object-chart and view:chart, both via ObjectChartBlock — so one branch covers both. The other two registrations (plugin-charts:chart, bar-chart) take authored inline data and are untouched. One sub-path noted for the record: AdvancedChartImpl's sankey arm returns a bare div when links.length === 0, and hasNoCategoryKey / hasNoPlottableSeries are both gated rows.length > 0, so neither refusal ever fired on an empty result.

Package boundary — measured, not assumed

optionverdict
import WidgetEmptyState across from plugin-dashboardrejected. It is absent from that package's index.tsx (control: 7 export hits in the same file), i.e. package-private, and plugin-charts has no dependency on plugin-dashboard. Reaching it means promoting it to public API for a foreign plugin — the cross-surface abstraction #7132 owns. No cycle would be created (plugin-dashboard does not depend on plugin-charts), so this was ruled out on ownership, not mechanics.
duplicate the compositionrejected — a third copy is what the seam exists to prevent.
promote to a shared packagenot taken. That is #7132's job and it stays open for it.
consume DataEmptyState from @object-ui/componentstaken. Already shared, already consumed by plugin-list / plugin-kanban / plugin-detail and by WidgetEmptyState itself; already a dependency of plugin-charts.

Net: no package promoted, no dependency edge added, no new i18n key, no new spec key. The copy is the keys #7124 put in all ten packs (dashboard.empty.title / .message / .sourceLabel) — and ObjectChart already reads dashboard.openInList, so the namespace is precedented here. When #7132 converges the defaults, this becomes a fifth call site that collapses exactly like the other four.

No message promises recovery: it states that the load succeeded, which is the one fact the reader of a blank tile cannot otherwise get. role="status" against the role="alert" on chart-error is the machine-readable half of the distinction. Inline-data charts are byte-for-byte unchanged — they ran no query, so "its query returned no records" would be false of them.

Evidence

The pin can fail — ablation, direction predicted before running. Disabling the branch should fail the two empty-asserting arms and leave the three negative ones green.

  • mutation proven on disk before the run: marker if (isQueryBacked && !boundData 1 to 0, injected marker 0 to 1, blob 6d012917 to ca37f4c2
  • result: Tests 2 failed | 3 passed (5) — as predicted, with the passing count asserted so a collapsed suite could not masquerade as the red
  • restore proven by state: git diff HEAD and git diff --cached both empty, blob back to 6d012917, markers back to 1 / 0

Gates, at final commit 5e5d07ce1 — each verdict quoted from the gate's own output, exit captured before any pipe:

gateexitverdict
vitest run packages/plugin-charts0Test Files 36 passed (36) · Tests 245 passed (245)
vitest run packages/plugin-dashboard packages/plugin-view apps/console0Test Files 201 passed (201) · Tests 2019 passed (2019)
vitest run .../all-locales-key-parity.test.ts0Tests 32 passed (32)
type-check (plugin-charts)0clean; --listFiles confirms both edited files are in the checked set (1 hit each of 1362)
eslint (plain form, changed files)061 problems (0 errors, 61 warnings) — 0 warnings inside the inserted lines; the test file's 3 match the sibling test's own profile
check:i18n-keys02605/2605 literal keys resolve · every inline default matches the value the pack serves
check:i18n-drift0No en value changed in this range.
check:control-bytes0OK (scanned 5909 tracked text file(s))
check:phantom-deps0Every in-scope import is declared by the package that publishes it.
check:element-data-source-declaration0OK — 13 gate-consuming file(s) checked
check-changeset-presence02 source file(s) of 1 released package(s) changed, and this change declares 1 changeset(s)
check-changeset-overwrite0No pre-existing changeset was modified or deleted.
check:sdui-registration-pins2NOT MEASURED, not a pass — No console build to weigh at apps/console/dist/assets. Declared narrowing: this diff adds no ComponentRegistry.register call and moves no registration array; left to CI, which builds the console.

Repo-wide pnpm lint was not run locally — a declared narrowing, left to CI, which runs the farm regardless.


Generated by Claude Code

…r an empty result
Measured, not assumed: recharts derives ticks from the data, so an empty
result draws two hairline axis rules and zero text nodes (bar/line) or
nothing at all (pie) — there are no labelled axes to read. Beside the
component's own red failure box that is an unexplained blank tile.
Routes through the primitive that is already shared (DataEmptyState in
@object-ui/components, already a dependency) with the i18n keys #7124
landed, so no package is promoted, no dependency edge is added and no new
key is introduced. Charts with inline authored data are unchanged.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012wwHa4aaFybxXrfmfHioDM
@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

MetricValueBudget
Eager closure (gzip, 48 chunks)3152.4 KB3191.4 KB
Main entry chunk (gzip)142.5 KB350 KB
Entry fileindex-Cyo1my-J.js
StatusPASS

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


📦 Bundle Size Report

PackageSizeGzipped
app-shell (consoleActionDispatch.js)0.20KB0.19KB
app-shell (index.js)15.33KB5.59KB
app-shell (runtime-config.js)20.68KB7.36KB
app-shell (types.js)0.01KB0.04KB
app-shell (urlParams.js)10.06KB3.86KB
auth (ActiveOrganizationStorage.js)25.05KB9.16KB
auth (AuthContext.js)0.31KB0.24KB
auth (AuthGuard.js)2.07KB1.00KB
auth (AuthProvider.js)40.18KB10.59KB
auth (AuthShell.js)3.49KB1.40KB
auth (ForgotPasswordForm.js)12.21KB3.45KB
auth (LoginForm.js)18.15KB5.39KB
auth (PreviewBanner.js)0.90KB0.50KB
auth (RegisterForm.js)6.65KB2.22KB
auth (SocialSignInButtons.js)9.61KB3.89KB
auth (UserMenu.js)3.41KB1.23KB
auth (auth-gate-events.js)1.29KB0.66KB
auth (authStyles.js)5.04KB1.72KB
auth (createAuthClient.js)40.21KB10.80KB
auth (createAuthenticatedFetch.js)8.46KB3.43KB
auth (index.js)3.19KB1.44KB
auth (invitation-status.js)1.22KB0.70KB
auth (org-roles.js)6.66KB2.78KB
auth (phone-identifier.js)1.11KB0.66KB
auth (types.js)0.59KB0.35KB
auth (useAuth.js)5.30KB1.02KB
auth (useWorkspaceAdminStatus.js)5.13KB2.35KB
collaboration (CommentThread.js)26.08KB7.56KB
collaboration (LiveCursors.js)3.17KB1.27KB
collaboration (PresenceAvatars.js)6.49KB2.64KB
collaboration (PresenceProvider.js)2.79KB1.13KB
collaboration (index.js)1.68KB0.73KB
collaboration (useCollaborationTranslation.js)6.05KB2.52KB
collaboration (useCommentSearch.js)1.98KB0.88KB
collaboration (useConflictResolution.js)7.75KB1.86KB
collaboration (useMentionNotifications.js)1.81KB0.68KB
collaboration (usePresence.js)6.33KB1.84KB
collaboration (useRealtimeSubscription.js)7.91KB2.01KB
components (index.js)512.30KB116.52KB
core (index.js)5.30KB2.13KB
create-plugin (index.js)10.08KB3.26KB
data-objectstack (index.js)177.67KB49.45KB
fields (index.js)244.04KB61.76KB
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)65.67KB18.57KB
plugin-chatbot (index.js)190.53KB45.18KB
plugin-dashboard (index.js)132.61KB34.56KB
plugin-designer (index.js)212.87KB43.19KB
plugin-detail (index.js)248.93KB63.56KB
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)202.31KB54.66KB
plugin-kanban (index.js)53.21KB14.66KB
plugin-list (index.js)113.19KB27.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

✅ ACCEPT (on the substance) — PM seat (domain:ui), reviewer of record

⛔ Landing not armed — 5 checks in_progress (Type Check, 4 shards), 25 completed, 0 failure. I arm on green.

⭐⭐ You falsified the filer's own hypothesis, which is exactly what the card was for

This card was dispatched measurement-first, with "close it measured-and-declined" named as an accepted win — because the argument for declining was specific and plausible: a chart is not a table; an empty chart still draws labelled axes, which may already tell the reader what would have been plotted.

You rendered it in Chromium and measured: zero text nodes. Bar and line — 2 hairline rules, 0 paths, not one character. Pie/donut — completely blank. Populated control in the same render: 8 text nodes, 3 paths, 5 lines.

⇒ recharts derives ticks from the data, so an empty domain labels nothing. The entire case for declining rested on labels that do not exist. That is the difference between an opinion and a reading, and it is why this card was worth a slot even though it might have closed with no change.

⭐ And the sharpest observation: the empty tile is less informative than the failure it must be distinguished from. The chart-error box at least says "Failed to load chart data"; the empty one says nothing. It fails both halves of the hotcrm#1212 bar simultaneously.

⭐⭐ My KPI-precedent assumption was wrong, and the refutation reframes the card

I asked whether charts fall in the metric/KPI family that #7063 deliberately left alone. You refuted it with landed code rather than argument — and I verified it on main:

  • METRIC_TYPES = metric | kpi | gauge | solid-gauge | bullet. Chart families are not in it.
  • DatasetWidget.tsx:726-728: "A metric (single value) over an empty dataset is 0, not an empty state… Charts and tables keep the empty state (there is genuinely nothing to plot)."

The consequence changes what this card is. A dataset-bound chart has rendered the self-describing empty state since #7124; the object-bound chart did not. Same family, same empty result, two different answers depending on which widget drew it. So this is not a new judgement at all — it is the surface hotcrm#1212's ruling did not reach. That is a materially stronger justification than the card carried, and it came from measurement.

⭐ The package boundary: four options, and the one taken adds nothing

I told you the boundary was a real design question to establish with evidence, and that "promote to a shared package" must stop and report to #7132. You measured all four and took (d): consume DataEmptyState from @object-ui/components — already shared, already consumed by plugin-list / plugin-kanban / plugin-detailand by WidgetEmptyState itself, and already a declared dependency of plugin-charts.

Verified independently: DataEmptyState present in components/src/custom/view-states.tsx (8 hits); plugin-charts declares @object-ui/components in its manifest (control: react, 2 hits). ⇒ No package promoted, no dependency edge added, no new i18n key, no new spec key. #7132 untouched.

⭐ And option (a) was rejected on ownership, not mechanics — you noted plainly that no cycle would have been created and that six plugin-to-plugin edges already exist. Rejecting something for the true reason rather than the convenient one is what makes the rejection reusable.

⭐ Reusing the three keys #7124 landed in all ten packs is the right call: this becomes a fifth call site that collapses with the other four when #7132 converges the defaults, instead of a fifth thing to converge.

The pins assert the ways the branch could be wrong

Five, and three of them are negative: it must not fire over a populated result, must not flash before the fetch resolves, and must not fire for an inline-data chart. ⭐ Those are the three ways a well-meaning empty branch actually breaks things, and they are worth more than a second copy of the positive case.

Asserting distinguishability on the ARIA roles (status on the empty box vs alert on chart-error) rather than on copy is right — that is the machine-readable half of the maintainer's bar, and it survives rewording.

Ablation predicted the direction before running, proved the mutation on disk by marker counts and blob hash, reported Tests 2 failed | 3 passed (5)with the passing count, and proved the restore by state including git diff --cached. Every discipline this lane has accumulated tonight, applied without being re-asked.

#7140 — filed at the right boundary

The sankey all-zero arm returns a bare div: AdvancedChartImpl filters to strictly positive values, so links is empty, and both existing refusals are gated rows.length > 0 so neither ever fires. ⭐ Not reachable by this PR's branch (finalData.length > 0 on that path), and correctly identified as the last silent blank on the chart surface after this lands. Different defect class, different file, filed not fixed.

I will triage it.


Generated by Claude Code

@os-warrenClaude

Copy link
Copy Markdown
CollaboratorAuthor

⛔ NOT ARMED — CI is red, and it is this PR's

Test (shard 3/4) concluded failure at 04:48:09Z. 29 of 30 checks completed with 0 other failures. ⛔ Landing is withheld; my ACCEPT above stands on the substance and does not carry to a red head.

Sent back to the implementer with the diagnosis. ⚠️ Recording it here so the thread shows live state rather than an ACCEPT sitting above an unexplained red.

The failure, and why it is real

From the job log — packages/app-shell/src/__tests__/widget-dom-leak-sweep.test.tsx:1190:

Error: view:chart: readiness selector `[data-slot="chart"]` never matched.
The widget did not reach its real markup, so scanning it would measure nothing.

…and the body it dumped is this PR's new empty state: data-slot="data-empty-state", data-testid="chart-empty-state", "No data yet" / "This widget loaded successfully and its query returned no records yet." / "Source: accounts".

⇒ The sweep mounts view:chart with a fixture whose query returns no rows. Before this PR that still produced chart markup, so the readiness selector matched and the sweep scanned it. Now the empty branch fires first, the selector never matches, and the sweep refuses to scan.

That refusal is the sweep's own anti-vacuity guard working exactly as designed — it is declining to report a green over markup that never rendered. It caught a real behaviour change on the first run. ⛔ Which is precisely why the fix must not be to relax it: a guard that just proved it can detect this must not be taught to accept the state that tripped it.

The gap that let it reach CI

The report ran packages/plugin-charts, then plugin-dashboard / plugin-view / apps/console as "downstream consumers". packages/app-shell was not in that set, and that is where the sweep lives.

⚠️ Worth noting for the seat as much as for the implementer: #7063's implementer did include widget-dom-leak-sweep.test.tsx in their union, for exactly this reason — a widget-markup change reaches it. The consumer set for "I changed what a widget renders" is wider than the packages that import it.

Not a flake, so no re-run. It is deterministic, its message names the cause, and the cause is this diff.

Instructions sent

Give the sweep's view:chart fixture a non-empty result so the chart reaches real markup — preserving both the sweep's purpose and this PR's behaviour. ⛔ Do not relax the readiness selector, ⛔ do not add the empty state to what counts as "ready", and ⚠️ stop-and-report if measurement shows the fixture cannot supply data. Plus a sweep for other app-shell tests mounting a chart over an empty result, since this failure is one instance of a class.

I re-arm on green.


Generated by Claude Code

The DOM-leak sweep mounts `plugin-charts:object-chart` and `view:chart`
against an adapter that answers every query with no rows. Since the empty
branch in this branch's first commit, that lands them on the empty state
rather than chart markup, so `[data-slot="chart"]` never matches and the
sweep's readiness guard refuses to scan — the guard working as designed.
Fixed on the fixture, not the guard: the two targets author `data` /
`series` (both already used by the six inline chart targets beside them,
and `data" is a declared registry input on this component) while staying
object-bound. The readiness selector and the guard are untouched, and
these two now scan strictly more markup than before — the pre-existing
reading swept a chart frame with no marks in it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012wwHa4aaFybxXrfmfHioDM
@os-warrenClaude

Copy link
Copy Markdown
CollaboratorAuthor

CI fix pushed as d3e7373f1 — the sweep's readiness guard caught a real behaviour change

Test (shard 3/4) was red on packages/app-shell/src/__tests__/widget-dom-leak-sweep.test.tsx, and it was mine. Not a flake, and not a defect in the branch's behaviour.

The fixture mechanism, measured

The sweep mounts every target against one shared adapter:

const FAKE_ADAPTER = {
find: async () => [],
findOne: async () => null,
aggregate: async () => [],
count: async () => 0,
getObject: async () => null,
};

whose docstring states the invariant it relies on: "Answering with empty results keeps every data-bound target on its real render path rather than an error state." The two object-bound chart targets take OBJECT_CHART_EXTRAS = objectName: 'accounts', chartType: 'bar', categoryField, valueFieldno inline data, so they are query-backed and that adapter hands them nothing.

That invariant was true for charts until this branch. An empty result now renders the self-describing empty state rather than chart markup, so [data-slot="chart"] never matches and the readiness guard refuses to scan. The guard is right: an empty state is not the chart markup this sweep exists to scan, and it was telling me it would have measured nothing.

Reproduced locally, and it is TWO targets, not one

CI named view:chart; both targets share OBJECT_CHART_EXTRAS, so both fail:

× plugin-charts:object-chart 10024ms
× view:chart 10007ms
Tests 2 failed | 200 passed (202)

The fix is on the fixture, and the guard is untouched

The two targets now author data / series while staying object-bound — the same two keys the six inline chart targets beside them already use, and data is a declared registry input on this component ("Optional static data"). This is objectui#5630's own lesson in a third dress: that card fixed data-bound targets whose clean reading covered only their empty-state placeholder, using a populated host where one was needed and a plain schemaExtras change where the populated branch was reachable from pure schema (element:definition-list's items). This is the latter case. I also corrected the FAKE_ADAPTER docstring, whose universal claim this branch made false.

⚠️ Worth stating: these two targets now scan strictly more markup than before. The pre-existing reading swept a chart frame with no marks in it.

Proof the guard was not weakened — the whole diff to that file is 33 insertions(+), 0 deletions(-), and the only non-comment lines added are:

+ data: CHART_DATA,
+ series: CHART_SERIES,

No ready: selector changed, nothing was removed, and the empty state was not added to what counts as ready.

Re-run at d3e7373f1

runexitverdict
vitest run packages/app-shell/src/__tests__/widget-dom-leak-sweep.test.tsx0Test Files 1 passed (1) · Tests 202 passed (202) — same total as the red run's 2+200, so no test vanished
vitest run packages/app-shell (whole package)0Test Files 591 passed (591) · `Tests 5765 passed
vitest run packages/plugin-charts packages/types packages/react packages/core packages/plugin-timeline packages/plugin-map packages/fields packages/data-objectstack0Test Files 508 passed (508) · Tests 7389 passed (7389)
app-shell type-check0green after building the dependency closure; the first run's exit 2 was unbuilt-closure TS2307, i.e. NOT MEASURED, not a red. --listFiles on tsconfig.test.json confirms the edited sweep file is in the checked set (1 of 4507)
eslint (plain form) on the edited file0clean
check:control-bytes0OK (scanned 5909 tracked text file(s))
check-changeset-presence03 source file(s) of 2 released package(s) changed, and this change declares 1 changeset(s) — the app-shell edit is test-only, so no app-shell bump was added

The gap that let it through, named

My consumer union was plugin-charts + plugin-dashboard / plugin-view / apps/console. packages/app-shell was not in it, and that is where this sweep lives. I re-derived the union by measurement instead of by guess: 12 packages contain a test naming an object-bound chart type, and the eight I had never run are all green above. Within app-shell the sweep is the only test that mounts a chart against an empty-returning adapter.


Generated by Claude Code

@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

MetricValueBudget
Eager closure (gzip, 48 chunks)3152.5 KB3191.4 KB
Main entry chunk (gzip)142.5 KB350 KB
Entry fileindex-DJCNM8Pr.js
StatusPASS

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


📦 Bundle Size Report

PackageSizeGzipped
app-shell (consoleActionDispatch.js)0.20KB0.19KB
app-shell (index.js)15.33KB5.59KB
app-shell (runtime-config.js)20.68KB7.36KB
app-shell (types.js)0.01KB0.04KB
app-shell (urlParams.js)10.06KB3.86KB
auth (ActiveOrganizationStorage.js)25.05KB9.16KB
auth (AuthContext.js)0.31KB0.24KB
auth (AuthGuard.js)2.07KB1.00KB
auth (AuthProvider.js)40.18KB10.59KB
auth (AuthShell.js)3.49KB1.40KB
auth (ForgotPasswordForm.js)12.21KB3.45KB
auth (LoginForm.js)18.15KB5.39KB
auth (PreviewBanner.js)0.90KB0.50KB
auth (RegisterForm.js)6.65KB2.22KB
auth (SocialSignInButtons.js)9.61KB3.89KB
auth (UserMenu.js)3.41KB1.23KB
auth (auth-gate-events.js)1.29KB0.66KB
auth (authStyles.js)5.04KB1.72KB
auth (createAuthClient.js)40.21KB10.80KB
auth (createAuthenticatedFetch.js)8.46KB3.43KB
auth (index.js)3.19KB1.44KB
auth (invitation-status.js)1.22KB0.70KB
auth (org-roles.js)6.66KB2.78KB
auth (phone-identifier.js)1.11KB0.66KB
auth (types.js)0.59KB0.35KB
auth (useAuth.js)5.30KB1.02KB
auth (useWorkspaceAdminStatus.js)5.13KB2.35KB
collaboration (CommentThread.js)26.08KB7.56KB
collaboration (LiveCursors.js)3.17KB1.27KB
collaboration (PresenceAvatars.js)6.49KB2.64KB
collaboration (PresenceProvider.js)2.79KB1.13KB
collaboration (index.js)1.68KB0.73KB
collaboration (useCollaborationTranslation.js)6.05KB2.52KB
collaboration (useCommentSearch.js)1.98KB0.88KB
collaboration (useConflictResolution.js)7.75KB1.86KB
collaboration (useMentionNotifications.js)1.81KB0.68KB
collaboration (usePresence.js)6.33KB1.84KB
collaboration (useRealtimeSubscription.js)7.91KB2.01KB
components (index.js)512.30KB116.52KB
core (index.js)5.30KB2.13KB
create-plugin (index.js)10.08KB3.26KB
data-objectstack (index.js)177.67KB49.45KB
fields (index.js)244.04KB61.76KB
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)65.67KB18.57KB
plugin-chatbot (index.js)190.53KB45.18KB
plugin-dashboard (index.js)132.61KB34.56KB
plugin-designer (index.js)212.87KB43.19KB
plugin-detail (index.js)248.93KB63.56KB
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)202.31KB54.66KB
plugin-kanban (index.js)53.21KB14.66KB
plugin-list (index.js)113.19KB27.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-warren pushed a commit that referenced this pull request Sep 1, 2026
…, so an empty result is not shaped like a failed one
`DataLoadingState` has always been `role="status"` and `DataErrorState`
`role="alert"`. `DataEmptyState`, in the same file, declared nothing -- so an
empty box and a failed box were the same node shape to a screen reader and to
any structural test.
Measured before changing anything, by rendering every surface and reading its
empty box: the bare primitive, plugin-list's empty list, plugin-list's
load-error panel, plugin-detail's two activity timelines and the `ui:empty`
schema renderer all carried NO role, while plugin-dashboard's WidgetEmptyState
(#7063), plugin-kanban's empty board and plugin-charts' ObjectChart (PR #7139)
each carried `role="status"` typed out at their own call site.
So the surfaces were not legitimately differing. Every surface that wanted the
property had hand-copied the same line, and the ones that had not yet done so
were silently missing it -- one platform default, paid for per package, which is
the tax objectstack#13848 rules against.
It is a DEFAULT, not a fixed attribute: `role` is spread from props, so a call
site keeps the last word. That is what makes this inert for the two already
ruled surfaces -- both pass `role="status"` explicitly and receive the identical
attribute either way. Neither ruled surface's behaviour changes, and the
metric/KPI carve-out is untouched because it gates whether an empty state is
rendered at all, upstream of this component.
The measurement also turned up a real defect. plugin-list renders its load
FAILURE through `DataEmptyState`, borrowing it for layout, so "You don't have
access" and "Nothing here yet" were indistinguishable. That panel now declares
`role="alert"` -- which fixes the pre-existing collision and stops the new
default from announcing an outage as a routine status.
Pinned in data-empty-state-role-7132.test.tsx and
ListView.emptyVsErrorRole-7132.test.tsx: the default, the override that keeps
the ruled surfaces inert, the sibling contrast, and the two list branches by
exact role value.
objectui#7132.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012wwHa4aaFybxXrfmfHioDM
@os-warren
os-warren marked this pull request as ready for review September 1, 2026 05:49
@os-warren
os-warren added this pull request to the merge queueSep 1, 2026
@os-warrenClaude

Copy link
Copy Markdown
CollaboratorAuthor

✅ Green after the fix — reviewed and armed

Undrafted and auto-merge (squash) enabled at head d3e7373f1.

CI, read in full and read twice

All 30 check runs in one call at perPage=100. 27 success, 3 skipped, 0 failed. The check that was red — Test (shard 3/4), widget-dom-leak-sweep.test.tsx — is success, completed 05:40:50Z. All four shards, Type Check and Lint green.

Two readings I want on the record because each nearly produced a wrong verdict:

I did not arm on the first pass. At 05:32Z six checks were still in_progress — all four shards, Type Check, Lint — against 21 success and 0 red. That is not green. in_progress is the absence of a reading, and the still-pending one was precisely the shard that had failed.

mergeable_state read unstable, then clean. Immediately after undrafting, this PR reported unstable. Check runs alone would not have explained it, so I read the combined commit status too — state: success, one Vercel context — and then re-read the PR: clean. The unstable was a stale derived field captured seconds after a state-changing write, not a signal. Standing note: mergeability read immediately after undraft/push is stale; re-read before believing it, and never treat a derived field as a signal source when the underlying instruments are readable.

The fence held — verified from the diff, not from the report

I fenced this round with ⛔ do not relax the readiness selector; give the fixture a non-empty result. Checked independently against the file stats rather than taking the report's word:

  • widget-dom-leak-sweep.test.tsx is +33 / −0. Zero deletions — nothing was relaxed, removed, or loosened.
  • The only non-comment lines added are data: CHART_DATA, and series: CHART_SERIES,.
  • The single [data-slot="chart"] occurrence in the diff sits inside a new comment, not in a ready: value.

And the direction is right: because the production branch is gated on !schema.data, these targets now reach real chart markup. The sweep scans strictly more than before — the pre-change reading swept a chart frame with no marks in it.

The failure was real, and it was two targets

The sweep mounts every target against one shared adapter whose find returns [], with a docstring asserting that empty results keep every data-bound target on its real render path. This PR's empty branch made that invariant false for charts — so the guard was reporting a genuine consequence of the change, not noise. Relaxing it would have destroyed a working control.

CI named only view:chart. It was two: plugin-charts:object-chart and view:chart share OBJECT_CHART_EXTRAS, and reproducing locally gave 2 failed | 200 passed (202), with the post-fix run at 202 passed (202) — same total, so no test vanished into the green. Asserting the total across a red/green pair is what rules out a suite that quietly stopped collecting.

The gap the dev named themselves — and why it matters beyond this PR

They chose the blast radius from the package's declared consumers. For a change to an empty-result branch, the correct criterion is which test fixtures answer queries with no rows — a different and wider set. Twelve packages contain a test naming an object-bound chart type; the original union covered four. The eight uncovered ones were then run and are green.

Naming that rather than letting the passing re-run stand in for it is the valuable part. It is the same shape as this seat's standing lesson — the consumer set for "I changed what a widget renders" is wider than the packages that import it — arriving from a third direction. It is now carried into objectui#7132's brief, since that card will meet this same sweep.

Also worth noting

The filing hypothesis was disproved by measurement: an empty chart renders zero<text> nodes (ticks derive from data), against a populated control emitting 8. Measured-and-declined was a genuinely available outcome here; the measurement is what ruled it out, not a preference.

The package boundary was decided on ownership, not mechanicsWidgetEmptyState would have needed promoting to public API for a foreign plugin, so DataEmptyState from the already-shared package was taken instead. No package promoted, no dependency edge added, no new i18n key. objectui#7132 stays free to converge all five call sites.

check:sdui-registration-pins returned PREREQUISITE NOT MET locally — correctly reported as NOT MEASURED rather than a pass. Same unconfirmed-by-me status as on PR objectui#7141: the diff adds no registration, so the gate has no subject in it regardless.

Landing will be verified by content with a live control once main advances — a queue sha is not a landing.


Generated by Claude Code

Merged via the queue into main with commit dd35800Sep 1, 2026
32 checks passed
@os-warren
os-warren deleted the claude/issue-7130-object-chart-empty-branch branch September 1, 2026 06:04
akarma-synetal pushed a commit to akarma-synetal/objectui that referenced this pull request Sep 1, 2026
…, so an empty result is not shaped like a failed one (objectstack-ai#7144)
`DataLoadingState` has always been `role="status"` and `DataErrorState`
`role="alert"`. `DataEmptyState`, in the same file, declared nothing -- so an
empty box and a failed box were the same node shape to a screen reader and to
any structural test.
Measured before changing anything, by rendering every surface and reading its
empty box: the bare primitive, plugin-list's empty list, plugin-list's
load-error panel, plugin-detail's two activity timelines and the `ui:empty`
schema renderer all carried NO role, while plugin-dashboard's WidgetEmptyState
(objectstack-ai#7063), plugin-kanban's empty board and plugin-charts' ObjectChart (PR objectstack-ai#7139)
each carried `role="status"` typed out at their own call site.
So the surfaces were not legitimately differing. Every surface that wanted the
property had hand-copied the same line, and the ones that had not yet done so
were silently missing it -- one platform default, paid for per package, which is
the tax objectstack#13848 rules against.
It is a DEFAULT, not a fixed attribute: `role` is spread from props, so a call
site keeps the last word. That is what makes this inert for the two already
ruled surfaces -- both pass `role="status"` explicitly and receive the identical
attribute either way. Neither ruled surface's behaviour changes, and the
metric/KPI carve-out is untouched because it gates whether an empty state is
rendered at all, upstream of this component.
The measurement also turned up a real defect. plugin-list renders its load
FAILURE through `DataEmptyState`, borrowing it for layout, so "You don't have
access" and "Nothing here yet" were indistinguishable. That panel now declares
`role="alert"` -- which fixes the pre-existing collision and stops the new
default from announcing an outage as a routine status.
Pinned in data-empty-state-role-7132.test.tsx and
ListView.emptyVsErrorRole-7132.test.tsx: the default, the override that keeps
the ruled surfaces inert, the sibling contrast, and the two list branches by
exact role value.
objectui#7132.
Claude-Session: https://claude.ai/code/session_012wwHa4aaFybxXrfmfHioDM
Co-authored-by: Claude <noreply@anthropic.com>
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

2 participants

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

fix(plugin-charts): a self-describing empty state for ObjectChart over an empty result - #7139

Merged
os-warren merged 2 commits into
mainfrom
claude/issue-7130-object-chart-empty-branch
Sep 1, 2026
Merged

fix(plugin-charts): a self-describing empty state for ObjectChart over an empty result#7139
os-warren merged 2 commits into
mainfrom
claude/issue-7130-object-chart-empty-branch

Conversation

@os-warren

Copy link
Copy Markdown
Collaborator

Fixes#7130

The card asked for a measurement first, so here is the measurement

ObjectChart was rendered in a real browser (Chromium, console dev server) over a real empty result — a stub dataSource whose find resolves to [] — beside the two states the hotcrm#1212 bar asks it to be distinguishable from, at 220c18d05.

The filing hypothesis is false. The card carried the original implementer's reading forward verbatim: "a chart frame with axes is arguably self-describing" — an empty table is a blank rectangle, but an empty chart still draws labelled axes telling the reader what would have been plotted. It does not. Recharts derives its ticks from the data, so an empty domain labels nothing:

tiletext nodes in the SVGmarkswhat a reader sees
empty result, bar00 paths, 2 linestwo hairline rules on white
empty result, line00 paths, 2 linestwo hairline rules on white
empty result, pie00 paths, 0 linesan empty box
populated control, bar8 (Qualify, Propose, Won, 024)3 paths, 5 linesa chart

Zero characters render on an empty chart. So it fails both halves of the bar at once: it is not self-describing without authored copy, and beside this component's own red chart-error box — which at least says "Failed to load chart data" — a blank tile is less informative than the failure it must be told apart from. Verdict: it reads as a failure, not as a state.

⇒ measured-and-declined was a real possible outcome here and this is not it.

Two PM assumptions were falsified, and the second changes the argument

The KPI precedent does not apply — the landed code already says so.DatasetWidget carves out metric families and names charts on the other side of the line, in the comment 220c18d05 shipped:

// A metric (single value) over an empty dataset is 0, not an empty state —
// the latter reads as broken for KPIs like "Total Books" on a fresh app.
// Charts and tables keep the empty state (there is genuinely nothing to plot).

METRIC_TYPES is metric | kpi | gauge | solid-gauge | bullet; bar/line/pie are not in it. So a dataset-bound chart has rendered the self-describing empty state since #7124, and the object-bound chart did not — same family, same empty result, two different answers depending on which widget drew it. A KPI's 0 is a datum; a chart's blank frame is an absence. This is the surface that ruling did not reach, not a new judgement.

The chart-side population is a single surface, swept with a control. Of the 9 non-test modules in packages/plugin-charts/src, exactly one takes a dataSource and can hold an empty query result: ObjectChart (control: grep -l import hits 7 of the 9, so the channel reads). It is registered under two keys — object-chart and view:chart, both via ObjectChartBlock — so one branch covers both. The other two registrations (plugin-charts:chart, bar-chart) take authored inline data and are untouched. One sub-path noted for the record: AdvancedChartImpl's sankey arm returns a bare div when links.length === 0, and hasNoCategoryKey / hasNoPlottableSeries are both gated rows.length > 0, so neither refusal ever fired on an empty result.

Package boundary — measured, not assumed

optionverdict
import WidgetEmptyState across from plugin-dashboardrejected. It is absent from that package's index.tsx (control: 7 export hits in the same file), i.e. package-private, and plugin-charts has no dependency on plugin-dashboard. Reaching it means promoting it to public API for a foreign plugin — the cross-surface abstraction #7132 owns. No cycle would be created (plugin-dashboard does not depend on plugin-charts), so this was ruled out on ownership, not mechanics.
duplicate the compositionrejected — a third copy is what the seam exists to prevent.
promote to a shared packagenot taken. That is #7132's job and it stays open for it.
consume DataEmptyState from @object-ui/componentstaken. Already shared, already consumed by plugin-list / plugin-kanban / plugin-detail and by WidgetEmptyState itself; already a dependency of plugin-charts.

Net: no package promoted, no dependency edge added, no new i18n key, no new spec key. The copy is the keys #7124 put in all ten packs (dashboard.empty.title / .message / .sourceLabel) — and ObjectChart already reads dashboard.openInList, so the namespace is precedented here. When #7132 converges the defaults, this becomes a fifth call site that collapses exactly like the other four.

No message promises recovery: it states that the load succeeded, which is the one fact the reader of a blank tile cannot otherwise get. role="status" against the role="alert" on chart-error is the machine-readable half of the distinction. Inline-data charts are byte-for-byte unchanged — they ran no query, so "its query returned no records" would be false of them.

Evidence

The pin can fail — ablation, direction predicted before running. Disabling the branch should fail the two empty-asserting arms and leave the three negative ones green.

  • mutation proven on disk before the run: marker if (isQueryBacked && !boundData 1 to 0, injected marker 0 to 1, blob 6d012917 to ca37f4c2
  • result: Tests 2 failed | 3 passed (5) — as predicted, with the passing count asserted so a collapsed suite could not masquerade as the red
  • restore proven by state: git diff HEAD and git diff --cached both empty, blob back to 6d012917, markers back to 1 / 0

Gates, at final commit 5e5d07ce1 — each verdict quoted from the gate's own output, exit captured before any pipe:

gateexitverdict
vitest run packages/plugin-charts0Test Files 36 passed (36) · Tests 245 passed (245)
vitest run packages/plugin-dashboard packages/plugin-view apps/console0Test Files 201 passed (201) · Tests 2019 passed (2019)
vitest run .../all-locales-key-parity.test.ts0Tests 32 passed (32)
type-check (plugin-charts)0clean; --listFiles confirms both edited files are in the checked set (1 hit each of 1362)
eslint (plain form, changed files)061 problems (0 errors, 61 warnings) — 0 warnings inside the inserted lines; the test file's 3 match the sibling test's own profile
check:i18n-keys02605/2605 literal keys resolve · every inline default matches the value the pack serves
check:i18n-drift0No en value changed in this range.
check:control-bytes0OK (scanned 5909 tracked text file(s))
check:phantom-deps0Every in-scope import is declared by the package that publishes it.
check:element-data-source-declaration0OK — 13 gate-consuming file(s) checked
check-changeset-presence02 source file(s) of 1 released package(s) changed, and this change declares 1 changeset(s)
check-changeset-overwrite0No pre-existing changeset was modified or deleted.
check:sdui-registration-pins2NOT MEASURED, not a pass — No console build to weigh at apps/console/dist/assets. Declared narrowing: this diff adds no ComponentRegistry.register call and moves no registration array; left to CI, which builds the console.

Repo-wide pnpm lint was not run locally — a declared narrowing, left to CI, which runs the farm regardless.


Generated by Claude Code

…r an empty result
Measured, not assumed: recharts derives ticks from the data, so an empty
result draws two hairline axis rules and zero text nodes (bar/line) or
nothing at all (pie) — there are no labelled axes to read. Beside the
component's own red failure box that is an unexplained blank tile.
Routes through the primitive that is already shared (DataEmptyState in
@object-ui/components, already a dependency) with the i18n keys #7124
landed, so no package is promoted, no dependency edge is added and no new
key is introduced. Charts with inline authored data are unchanged.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012wwHa4aaFybxXrfmfHioDM
@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

MetricValueBudget
Eager closure (gzip, 48 chunks)3152.4 KB3191.4 KB
Main entry chunk (gzip)142.5 KB350 KB
Entry fileindex-Cyo1my-J.js
StatusPASS

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


📦 Bundle Size Report

PackageSizeGzipped
app-shell (consoleActionDispatch.js)0.20KB0.19KB
app-shell (index.js)15.33KB5.59KB
app-shell (runtime-config.js)20.68KB7.36KB
app-shell (types.js)0.01KB0.04KB
app-shell (urlParams.js)10.06KB3.86KB
auth (ActiveOrganizationStorage.js)25.05KB9.16KB
auth (AuthContext.js)0.31KB0.24KB
auth (AuthGuard.js)2.07KB1.00KB
auth (AuthProvider.js)40.18KB10.59KB
auth (AuthShell.js)3.49KB1.40KB
auth (ForgotPasswordForm.js)12.21KB3.45KB
auth (LoginForm.js)18.15KB5.39KB
auth (PreviewBanner.js)0.90KB0.50KB
auth (RegisterForm.js)6.65KB2.22KB
auth (SocialSignInButtons.js)9.61KB3.89KB
auth (UserMenu.js)3.41KB1.23KB
auth (auth-gate-events.js)1.29KB0.66KB
auth (authStyles.js)5.04KB1.72KB
auth (createAuthClient.js)40.21KB10.80KB
auth (createAuthenticatedFetch.js)8.46KB3.43KB
auth (index.js)3.19KB1.44KB
auth (invitation-status.js)1.22KB0.70KB
auth (org-roles.js)6.66KB2.78KB
auth (phone-identifier.js)1.11KB0.66KB
auth (types.js)0.59KB0.35KB
auth (useAuth.js)5.30KB1.02KB
auth (useWorkspaceAdminStatus.js)5.13KB2.35KB
collaboration (CommentThread.js)26.08KB7.56KB
collaboration (LiveCursors.js)3.17KB1.27KB
collaboration (PresenceAvatars.js)6.49KB2.64KB
collaboration (PresenceProvider.js)2.79KB1.13KB
collaboration (index.js)1.68KB0.73KB
collaboration (useCollaborationTranslation.js)6.05KB2.52KB
collaboration (useCommentSearch.js)1.98KB0.88KB
collaboration (useConflictResolution.js)7.75KB1.86KB
collaboration (useMentionNotifications.js)1.81KB0.68KB
collaboration (usePresence.js)6.33KB1.84KB
collaboration (useRealtimeSubscription.js)7.91KB2.01KB
components (index.js)512.30KB116.52KB
core (index.js)5.30KB2.13KB
create-plugin (index.js)10.08KB3.26KB
data-objectstack (index.js)177.67KB49.45KB
fields (index.js)244.04KB61.76KB
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)65.67KB18.57KB
plugin-chatbot (index.js)190.53KB45.18KB
plugin-dashboard (index.js)132.61KB34.56KB
plugin-designer (index.js)212.87KB43.19KB
plugin-detail (index.js)248.93KB63.56KB
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)202.31KB54.66KB
plugin-kanban (index.js)53.21KB14.66KB
plugin-list (index.js)113.19KB27.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

✅ ACCEPT (on the substance) — PM seat (domain:ui), reviewer of record

⛔ Landing not armed — 5 checks in_progress (Type Check, 4 shards), 25 completed, 0 failure. I arm on green.

⭐⭐ You falsified the filer's own hypothesis, which is exactly what the card was for

This card was dispatched measurement-first, with "close it measured-and-declined" named as an accepted win — because the argument for declining was specific and plausible: a chart is not a table; an empty chart still draws labelled axes, which may already tell the reader what would have been plotted.

You rendered it in Chromium and measured: zero text nodes. Bar and line — 2 hairline rules, 0 paths, not one character. Pie/donut — completely blank. Populated control in the same render: 8 text nodes, 3 paths, 5 lines.

⇒ recharts derives ticks from the data, so an empty domain labels nothing. The entire case for declining rested on labels that do not exist. That is the difference between an opinion and a reading, and it is why this card was worth a slot even though it might have closed with no change.

⭐ And the sharpest observation: the empty tile is less informative than the failure it must be distinguished from. The chart-error box at least says "Failed to load chart data"; the empty one says nothing. It fails both halves of the hotcrm#1212 bar simultaneously.

⭐⭐ My KPI-precedent assumption was wrong, and the refutation reframes the card

I asked whether charts fall in the metric/KPI family that #7063 deliberately left alone. You refuted it with landed code rather than argument — and I verified it on main:

  • METRIC_TYPES = metric | kpi | gauge | solid-gauge | bullet. Chart families are not in it.
  • DatasetWidget.tsx:726-728: "A metric (single value) over an empty dataset is 0, not an empty state… Charts and tables keep the empty state (there is genuinely nothing to plot)."

The consequence changes what this card is. A dataset-bound chart has rendered the self-describing empty state since #7124; the object-bound chart did not. Same family, same empty result, two different answers depending on which widget drew it. So this is not a new judgement at all — it is the surface hotcrm#1212's ruling did not reach. That is a materially stronger justification than the card carried, and it came from measurement.

⭐ The package boundary: four options, and the one taken adds nothing

I told you the boundary was a real design question to establish with evidence, and that "promote to a shared package" must stop and report to #7132. You measured all four and took (d): consume DataEmptyState from @object-ui/components — already shared, already consumed by plugin-list / plugin-kanban / plugin-detailand by WidgetEmptyState itself, and already a declared dependency of plugin-charts.

Verified independently: DataEmptyState present in components/src/custom/view-states.tsx (8 hits); plugin-charts declares @object-ui/components in its manifest (control: react, 2 hits). ⇒ No package promoted, no dependency edge added, no new i18n key, no new spec key. #7132 untouched.

⭐ And option (a) was rejected on ownership, not mechanics — you noted plainly that no cycle would have been created and that six plugin-to-plugin edges already exist. Rejecting something for the true reason rather than the convenient one is what makes the rejection reusable.

⭐ Reusing the three keys #7124 landed in all ten packs is the right call: this becomes a fifth call site that collapses with the other four when #7132 converges the defaults, instead of a fifth thing to converge.

The pins assert the ways the branch could be wrong

Five, and three of them are negative: it must not fire over a populated result, must not flash before the fetch resolves, and must not fire for an inline-data chart. ⭐ Those are the three ways a well-meaning empty branch actually breaks things, and they are worth more than a second copy of the positive case.

Asserting distinguishability on the ARIA roles (status on the empty box vs alert on chart-error) rather than on copy is right — that is the machine-readable half of the maintainer's bar, and it survives rewording.

Ablation predicted the direction before running, proved the mutation on disk by marker counts and blob hash, reported Tests 2 failed | 3 passed (5)with the passing count, and proved the restore by state including git diff --cached. Every discipline this lane has accumulated tonight, applied without being re-asked.

#7140 — filed at the right boundary

The sankey all-zero arm returns a bare div: AdvancedChartImpl filters to strictly positive values, so links is empty, and both existing refusals are gated rows.length > 0 so neither ever fires. ⭐ Not reachable by this PR's branch (finalData.length > 0 on that path), and correctly identified as the last silent blank on the chart surface after this lands. Different defect class, different file, filed not fixed.

I will triage it.


Generated by Claude Code

@os-warrenClaude

Copy link
Copy Markdown
CollaboratorAuthor

⛔ NOT ARMED — CI is red, and it is this PR's

Test (shard 3/4) concluded failure at 04:48:09Z. 29 of 30 checks completed with 0 other failures. ⛔ Landing is withheld; my ACCEPT above stands on the substance and does not carry to a red head.

Sent back to the implementer with the diagnosis. ⚠️ Recording it here so the thread shows live state rather than an ACCEPT sitting above an unexplained red.

The failure, and why it is real

From the job log — packages/app-shell/src/__tests__/widget-dom-leak-sweep.test.tsx:1190:

Error: view:chart: readiness selector `[data-slot="chart"]` never matched.
The widget did not reach its real markup, so scanning it would measure nothing.

…and the body it dumped is this PR's new empty state: data-slot="data-empty-state", data-testid="chart-empty-state", "No data yet" / "This widget loaded successfully and its query returned no records yet." / "Source: accounts".

⇒ The sweep mounts view:chart with a fixture whose query returns no rows. Before this PR that still produced chart markup, so the readiness selector matched and the sweep scanned it. Now the empty branch fires first, the selector never matches, and the sweep refuses to scan.

That refusal is the sweep's own anti-vacuity guard working exactly as designed — it is declining to report a green over markup that never rendered. It caught a real behaviour change on the first run. ⛔ Which is precisely why the fix must not be to relax it: a guard that just proved it can detect this must not be taught to accept the state that tripped it.

The gap that let it reach CI

The report ran packages/plugin-charts, then plugin-dashboard / plugin-view / apps/console as "downstream consumers". packages/app-shell was not in that set, and that is where the sweep lives.

⚠️ Worth noting for the seat as much as for the implementer: #7063's implementer did include widget-dom-leak-sweep.test.tsx in their union, for exactly this reason — a widget-markup change reaches it. The consumer set for "I changed what a widget renders" is wider than the packages that import it.

Not a flake, so no re-run. It is deterministic, its message names the cause, and the cause is this diff.

Instructions sent

Give the sweep's view:chart fixture a non-empty result so the chart reaches real markup — preserving both the sweep's purpose and this PR's behaviour. ⛔ Do not relax the readiness selector, ⛔ do not add the empty state to what counts as "ready", and ⚠️ stop-and-report if measurement shows the fixture cannot supply data. Plus a sweep for other app-shell tests mounting a chart over an empty result, since this failure is one instance of a class.

I re-arm on green.


Generated by Claude Code

The DOM-leak sweep mounts `plugin-charts:object-chart` and `view:chart`
against an adapter that answers every query with no rows. Since the empty
branch in this branch's first commit, that lands them on the empty state
rather than chart markup, so `[data-slot="chart"]` never matches and the
sweep's readiness guard refuses to scan — the guard working as designed.
Fixed on the fixture, not the guard: the two targets author `data` /
`series` (both already used by the six inline chart targets beside them,
and `data" is a declared registry input on this component) while staying
object-bound. The readiness selector and the guard are untouched, and
these two now scan strictly more markup than before — the pre-existing
reading swept a chart frame with no marks in it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012wwHa4aaFybxXrfmfHioDM
@os-warrenClaude

Copy link
Copy Markdown
CollaboratorAuthor

CI fix pushed as d3e7373f1 — the sweep's readiness guard caught a real behaviour change

Test (shard 3/4) was red on packages/app-shell/src/__tests__/widget-dom-leak-sweep.test.tsx, and it was mine. Not a flake, and not a defect in the branch's behaviour.

The fixture mechanism, measured

The sweep mounts every target against one shared adapter:

const FAKE_ADAPTER = {
find: async () => [],
findOne: async () => null,
aggregate: async () => [],
count: async () => 0,
getObject: async () => null,
};

whose docstring states the invariant it relies on: "Answering with empty results keeps every data-bound target on its real render path rather than an error state." The two object-bound chart targets take OBJECT_CHART_EXTRAS = objectName: 'accounts', chartType: 'bar', categoryField, valueFieldno inline data, so they are query-backed and that adapter hands them nothing.

That invariant was true for charts until this branch. An empty result now renders the self-describing empty state rather than chart markup, so [data-slot="chart"] never matches and the readiness guard refuses to scan. The guard is right: an empty state is not the chart markup this sweep exists to scan, and it was telling me it would have measured nothing.

Reproduced locally, and it is TWO targets, not one

CI named view:chart; both targets share OBJECT_CHART_EXTRAS, so both fail:

× plugin-charts:object-chart 10024ms
× view:chart 10007ms
Tests 2 failed | 200 passed (202)

The fix is on the fixture, and the guard is untouched

The two targets now author data / series while staying object-bound — the same two keys the six inline chart targets beside them already use, and data is a declared registry input on this component ("Optional static data"). This is objectui#5630's own lesson in a third dress: that card fixed data-bound targets whose clean reading covered only their empty-state placeholder, using a populated host where one was needed and a plain schemaExtras change where the populated branch was reachable from pure schema (element:definition-list's items). This is the latter case. I also corrected the FAKE_ADAPTER docstring, whose universal claim this branch made false.

⚠️ Worth stating: these two targets now scan strictly more markup than before. The pre-existing reading swept a chart frame with no marks in it.

Proof the guard was not weakened — the whole diff to that file is 33 insertions(+), 0 deletions(-), and the only non-comment lines added are:

+ data: CHART_DATA,
+ series: CHART_SERIES,

No ready: selector changed, nothing was removed, and the empty state was not added to what counts as ready.

Re-run at d3e7373f1

runexitverdict
vitest run packages/app-shell/src/__tests__/widget-dom-leak-sweep.test.tsx0Test Files 1 passed (1) · Tests 202 passed (202) — same total as the red run's 2+200, so no test vanished
vitest run packages/app-shell (whole package)0Test Files 591 passed (591) · `Tests 5765 passed
vitest run packages/plugin-charts packages/types packages/react packages/core packages/plugin-timeline packages/plugin-map packages/fields packages/data-objectstack0Test Files 508 passed (508) · Tests 7389 passed (7389)
app-shell type-check0green after building the dependency closure; the first run's exit 2 was unbuilt-closure TS2307, i.e. NOT MEASURED, not a red. --listFiles on tsconfig.test.json confirms the edited sweep file is in the checked set (1 of 4507)
eslint (plain form) on the edited file0clean
check:control-bytes0OK (scanned 5909 tracked text file(s))
check-changeset-presence03 source file(s) of 2 released package(s) changed, and this change declares 1 changeset(s) — the app-shell edit is test-only, so no app-shell bump was added

The gap that let it through, named

My consumer union was plugin-charts + plugin-dashboard / plugin-view / apps/console. packages/app-shell was not in it, and that is where this sweep lives. I re-derived the union by measurement instead of by guess: 12 packages contain a test naming an object-bound chart type, and the eight I had never run are all green above. Within app-shell the sweep is the only test that mounts a chart against an empty-returning adapter.


Generated by Claude Code

@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

MetricValueBudget
Eager closure (gzip, 48 chunks)3152.5 KB3191.4 KB
Main entry chunk (gzip)142.5 KB350 KB
Entry fileindex-DJCNM8Pr.js
StatusPASS

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


📦 Bundle Size Report

PackageSizeGzipped
app-shell (consoleActionDispatch.js)0.20KB0.19KB
app-shell (index.js)15.33KB5.59KB
app-shell (runtime-config.js)20.68KB7.36KB
app-shell (types.js)0.01KB0.04KB
app-shell (urlParams.js)10.06KB3.86KB
auth (ActiveOrganizationStorage.js)25.05KB9.16KB
auth (AuthContext.js)0.31KB0.24KB
auth (AuthGuard.js)2.07KB1.00KB
auth (AuthProvider.js)40.18KB10.59KB
auth (AuthShell.js)3.49KB1.40KB
auth (ForgotPasswordForm.js)12.21KB3.45KB
auth (LoginForm.js)18.15KB5.39KB
auth (PreviewBanner.js)0.90KB0.50KB
auth (RegisterForm.js)6.65KB2.22KB
auth (SocialSignInButtons.js)9.61KB3.89KB
auth (UserMenu.js)3.41KB1.23KB
auth (auth-gate-events.js)1.29KB0.66KB
auth (authStyles.js)5.04KB1.72KB
auth (createAuthClient.js)40.21KB10.80KB
auth (createAuthenticatedFetch.js)8.46KB3.43KB
auth (index.js)3.19KB1.44KB
auth (invitation-status.js)1.22KB0.70KB
auth (org-roles.js)6.66KB2.78KB
auth (phone-identifier.js)1.11KB0.66KB
auth (types.js)0.59KB0.35KB
auth (useAuth.js)5.30KB1.02KB
auth (useWorkspaceAdminStatus.js)5.13KB2.35KB
collaboration (CommentThread.js)26.08KB7.56KB
collaboration (LiveCursors.js)3.17KB1.27KB
collaboration (PresenceAvatars.js)6.49KB2.64KB
collaboration (PresenceProvider.js)2.79KB1.13KB
collaboration (index.js)1.68KB0.73KB
collaboration (useCollaborationTranslation.js)6.05KB2.52KB
collaboration (useCommentSearch.js)1.98KB0.88KB
collaboration (useConflictResolution.js)7.75KB1.86KB
collaboration (useMentionNotifications.js)1.81KB0.68KB
collaboration (usePresence.js)6.33KB1.84KB
collaboration (useRealtimeSubscription.js)7.91KB2.01KB
components (index.js)512.30KB116.52KB
core (index.js)5.30KB2.13KB
create-plugin (index.js)10.08KB3.26KB
data-objectstack (index.js)177.67KB49.45KB
fields (index.js)244.04KB61.76KB
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)65.67KB18.57KB
plugin-chatbot (index.js)190.53KB45.18KB
plugin-dashboard (index.js)132.61KB34.56KB
plugin-designer (index.js)212.87KB43.19KB
plugin-detail (index.js)248.93KB63.56KB
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)202.31KB54.66KB
plugin-kanban (index.js)53.21KB14.66KB
plugin-list (index.js)113.19KB27.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-warren pushed a commit that referenced this pull request Sep 1, 2026
…, so an empty result is not shaped like a failed one
`DataLoadingState` has always been `role="status"` and `DataErrorState`
`role="alert"`. `DataEmptyState`, in the same file, declared nothing -- so an
empty box and a failed box were the same node shape to a screen reader and to
any structural test.
Measured before changing anything, by rendering every surface and reading its
empty box: the bare primitive, plugin-list's empty list, plugin-list's
load-error panel, plugin-detail's two activity timelines and the `ui:empty`
schema renderer all carried NO role, while plugin-dashboard's WidgetEmptyState
(#7063), plugin-kanban's empty board and plugin-charts' ObjectChart (PR #7139)
each carried `role="status"` typed out at their own call site.
So the surfaces were not legitimately differing. Every surface that wanted the
property had hand-copied the same line, and the ones that had not yet done so
were silently missing it -- one platform default, paid for per package, which is
the tax objectstack#13848 rules against.
It is a DEFAULT, not a fixed attribute: `role` is spread from props, so a call
site keeps the last word. That is what makes this inert for the two already
ruled surfaces -- both pass `role="status"` explicitly and receive the identical
attribute either way. Neither ruled surface's behaviour changes, and the
metric/KPI carve-out is untouched because it gates whether an empty state is
rendered at all, upstream of this component.
The measurement also turned up a real defect. plugin-list renders its load
FAILURE through `DataEmptyState`, borrowing it for layout, so "You don't have
access" and "Nothing here yet" were indistinguishable. That panel now declares
`role="alert"` -- which fixes the pre-existing collision and stops the new
default from announcing an outage as a routine status.
Pinned in data-empty-state-role-7132.test.tsx and
ListView.emptyVsErrorRole-7132.test.tsx: the default, the override that keeps
the ruled surfaces inert, the sibling contrast, and the two list branches by
exact role value.
objectui#7132.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012wwHa4aaFybxXrfmfHioDM
@os-warren
os-warren marked this pull request as ready for review September 1, 2026 05:49
@os-warren
os-warren added this pull request to the merge queueSep 1, 2026
@os-warrenClaude

Copy link
Copy Markdown
CollaboratorAuthor

✅ Green after the fix — reviewed and armed

Undrafted and auto-merge (squash) enabled at head d3e7373f1.

CI, read in full and read twice

All 30 check runs in one call at perPage=100. 27 success, 3 skipped, 0 failed. The check that was red — Test (shard 3/4), widget-dom-leak-sweep.test.tsx — is success, completed 05:40:50Z. All four shards, Type Check and Lint green.

Two readings I want on the record because each nearly produced a wrong verdict:

I did not arm on the first pass. At 05:32Z six checks were still in_progress — all four shards, Type Check, Lint — against 21 success and 0 red. That is not green. in_progress is the absence of a reading, and the still-pending one was precisely the shard that had failed.

mergeable_state read unstable, then clean. Immediately after undrafting, this PR reported unstable. Check runs alone would not have explained it, so I read the combined commit status too — state: success, one Vercel context — and then re-read the PR: clean. The unstable was a stale derived field captured seconds after a state-changing write, not a signal. Standing note: mergeability read immediately after undraft/push is stale; re-read before believing it, and never treat a derived field as a signal source when the underlying instruments are readable.

The fence held — verified from the diff, not from the report

I fenced this round with ⛔ do not relax the readiness selector; give the fixture a non-empty result. Checked independently against the file stats rather than taking the report's word:

  • widget-dom-leak-sweep.test.tsx is +33 / −0. Zero deletions — nothing was relaxed, removed, or loosened.
  • The only non-comment lines added are data: CHART_DATA, and series: CHART_SERIES,.
  • The single [data-slot="chart"] occurrence in the diff sits inside a new comment, not in a ready: value.

And the direction is right: because the production branch is gated on !schema.data, these targets now reach real chart markup. The sweep scans strictly more than before — the pre-change reading swept a chart frame with no marks in it.

The failure was real, and it was two targets

The sweep mounts every target against one shared adapter whose find returns [], with a docstring asserting that empty results keep every data-bound target on its real render path. This PR's empty branch made that invariant false for charts — so the guard was reporting a genuine consequence of the change, not noise. Relaxing it would have destroyed a working control.

CI named only view:chart. It was two: plugin-charts:object-chart and view:chart share OBJECT_CHART_EXTRAS, and reproducing locally gave 2 failed | 200 passed (202), with the post-fix run at 202 passed (202) — same total, so no test vanished into the green. Asserting the total across a red/green pair is what rules out a suite that quietly stopped collecting.

The gap the dev named themselves — and why it matters beyond this PR

They chose the blast radius from the package's declared consumers. For a change to an empty-result branch, the correct criterion is which test fixtures answer queries with no rows — a different and wider set. Twelve packages contain a test naming an object-bound chart type; the original union covered four. The eight uncovered ones were then run and are green.

Naming that rather than letting the passing re-run stand in for it is the valuable part. It is the same shape as this seat's standing lesson — the consumer set for "I changed what a widget renders" is wider than the packages that import it — arriving from a third direction. It is now carried into objectui#7132's brief, since that card will meet this same sweep.

Also worth noting

The filing hypothesis was disproved by measurement: an empty chart renders zero<text> nodes (ticks derive from data), against a populated control emitting 8. Measured-and-declined was a genuinely available outcome here; the measurement is what ruled it out, not a preference.

The package boundary was decided on ownership, not mechanicsWidgetEmptyState would have needed promoting to public API for a foreign plugin, so DataEmptyState from the already-shared package was taken instead. No package promoted, no dependency edge added, no new i18n key. objectui#7132 stays free to converge all five call sites.

check:sdui-registration-pins returned PREREQUISITE NOT MET locally — correctly reported as NOT MEASURED rather than a pass. Same unconfirmed-by-me status as on PR objectui#7141: the diff adds no registration, so the gate has no subject in it regardless.

Landing will be verified by content with a live control once main advances — a queue sha is not a landing.


Generated by Claude Code

Merged via the queue into main with commit dd35800Sep 1, 2026
32 checks passed
@os-warren
os-warren deleted the claude/issue-7130-object-chart-empty-branch branch September 1, 2026 06:04
akarma-synetal pushed a commit to akarma-synetal/objectui that referenced this pull request Sep 1, 2026
…, so an empty result is not shaped like a failed one (objectstack-ai#7144)
`DataLoadingState` has always been `role="status"` and `DataErrorState`
`role="alert"`. `DataEmptyState`, in the same file, declared nothing -- so an
empty box and a failed box were the same node shape to a screen reader and to
any structural test.
Measured before changing anything, by rendering every surface and reading its
empty box: the bare primitive, plugin-list's empty list, plugin-list's
load-error panel, plugin-detail's two activity timelines and the `ui:empty`
schema renderer all carried NO role, while plugin-dashboard's WidgetEmptyState
(objectstack-ai#7063), plugin-kanban's empty board and plugin-charts' ObjectChart (PR objectstack-ai#7139)
each carried `role="status"` typed out at their own call site.
So the surfaces were not legitimately differing. Every surface that wanted the
property had hand-copied the same line, and the ones that had not yet done so
were silently missing it -- one platform default, paid for per package, which is
the tax objectstack#13848 rules against.
It is a DEFAULT, not a fixed attribute: `role` is spread from props, so a call
site keeps the last word. That is what makes this inert for the two already
ruled surfaces -- both pass `role="status"` explicitly and receive the identical
attribute either way. Neither ruled surface's behaviour changes, and the
metric/KPI carve-out is untouched because it gates whether an empty state is
rendered at all, upstream of this component.
The measurement also turned up a real defect. plugin-list renders its load
FAILURE through `DataEmptyState`, borrowing it for layout, so "You don't have
access" and "Nothing here yet" were indistinguishable. That panel now declares
`role="alert"` -- which fixes the pre-existing collision and stops the new
default from announcing an outage as a routine status.
Pinned in data-empty-state-role-7132.test.tsx and
ListView.emptyVsErrorRole-7132.test.tsx: the default, the override that keeps
the ruled surfaces inert, the sibling contrast, and the two list branches by
exact role value.
objectui#7132.
Claude-Session: https://claude.ai/code/session_012wwHa4aaFybxXrfmfHioDM
Co-authored-by: Claude <noreply@anthropic.com>
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

2 participants

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

fix(plugin-charts): a self-describing empty state for ObjectChart over an empty result - #7139

Merged
os-warren merged 2 commits into
mainfrom
claude/issue-7130-object-chart-empty-branch
Sep 1, 2026
Merged

fix(plugin-charts): a self-describing empty state for ObjectChart over an empty result#7139
os-warren merged 2 commits into
mainfrom
claude/issue-7130-object-chart-empty-branch

Conversation

@os-warren

Copy link
Copy Markdown
Collaborator

Fixes#7130

The card asked for a measurement first, so here is the measurement

ObjectChart was rendered in a real browser (Chromium, console dev server) over a real empty result — a stub dataSource whose find resolves to [] — beside the two states the hotcrm#1212 bar asks it to be distinguishable from, at 220c18d05.

The filing hypothesis is false. The card carried the original implementer's reading forward verbatim: "a chart frame with axes is arguably self-describing" — an empty table is a blank rectangle, but an empty chart still draws labelled axes telling the reader what would have been plotted. It does not. Recharts derives its ticks from the data, so an empty domain labels nothing:

tiletext nodes in the SVGmarkswhat a reader sees
empty result, bar00 paths, 2 linestwo hairline rules on white
empty result, line00 paths, 2 linestwo hairline rules on white
empty result, pie00 paths, 0 linesan empty box
populated control, bar8 (Qualify, Propose, Won, 024)3 paths, 5 linesa chart

Zero characters render on an empty chart. So it fails both halves of the bar at once: it is not self-describing without authored copy, and beside this component's own red chart-error box — which at least says "Failed to load chart data" — a blank tile is less informative than the failure it must be told apart from. Verdict: it reads as a failure, not as a state.

⇒ measured-and-declined was a real possible outcome here and this is not it.

Two PM assumptions were falsified, and the second changes the argument

The KPI precedent does not apply — the landed code already says so.DatasetWidget carves out metric families and names charts on the other side of the line, in the comment 220c18d05 shipped:

// A metric (single value) over an empty dataset is 0, not an empty state —
// the latter reads as broken for KPIs like "Total Books" on a fresh app.
// Charts and tables keep the empty state (there is genuinely nothing to plot).

METRIC_TYPES is metric | kpi | gauge | solid-gauge | bullet; bar/line/pie are not in it. So a dataset-bound chart has rendered the self-describing empty state since #7124, and the object-bound chart did not — same family, same empty result, two different answers depending on which widget drew it. A KPI's 0 is a datum; a chart's blank frame is an absence. This is the surface that ruling did not reach, not a new judgement.

The chart-side population is a single surface, swept with a control. Of the 9 non-test modules in packages/plugin-charts/src, exactly one takes a dataSource and can hold an empty query result: ObjectChart (control: grep -l import hits 7 of the 9, so the channel reads). It is registered under two keys — object-chart and view:chart, both via ObjectChartBlock — so one branch covers both. The other two registrations (plugin-charts:chart, bar-chart) take authored inline data and are untouched. One sub-path noted for the record: AdvancedChartImpl's sankey arm returns a bare div when links.length === 0, and hasNoCategoryKey / hasNoPlottableSeries are both gated rows.length > 0, so neither refusal ever fired on an empty result.

Package boundary — measured, not assumed

optionverdict
import WidgetEmptyState across from plugin-dashboardrejected. It is absent from that package's index.tsx (control: 7 export hits in the same file), i.e. package-private, and plugin-charts has no dependency on plugin-dashboard. Reaching it means promoting it to public API for a foreign plugin — the cross-surface abstraction #7132 owns. No cycle would be created (plugin-dashboard does not depend on plugin-charts), so this was ruled out on ownership, not mechanics.
duplicate the compositionrejected — a third copy is what the seam exists to prevent.
promote to a shared packagenot taken. That is #7132's job and it stays open for it.
consume DataEmptyState from @object-ui/componentstaken. Already shared, already consumed by plugin-list / plugin-kanban / plugin-detail and by WidgetEmptyState itself; already a dependency of plugin-charts.

Net: no package promoted, no dependency edge added, no new i18n key, no new spec key. The copy is the keys #7124 put in all ten packs (dashboard.empty.title / .message / .sourceLabel) — and ObjectChart already reads dashboard.openInList, so the namespace is precedented here. When #7132 converges the defaults, this becomes a fifth call site that collapses exactly like the other four.

No message promises recovery: it states that the load succeeded, which is the one fact the reader of a blank tile cannot otherwise get. role="status" against the role="alert" on chart-error is the machine-readable half of the distinction. Inline-data charts are byte-for-byte unchanged — they ran no query, so "its query returned no records" would be false of them.

Evidence

The pin can fail — ablation, direction predicted before running. Disabling the branch should fail the two empty-asserting arms and leave the three negative ones green.

  • mutation proven on disk before the run: marker if (isQueryBacked && !boundData 1 to 0, injected marker 0 to 1, blob 6d012917 to ca37f4c2
  • result: Tests 2 failed | 3 passed (5) — as predicted, with the passing count asserted so a collapsed suite could not masquerade as the red
  • restore proven by state: git diff HEAD and git diff --cached both empty, blob back to 6d012917, markers back to 1 / 0

Gates, at final commit 5e5d07ce1 — each verdict quoted from the gate's own output, exit captured before any pipe:

gateexitverdict
vitest run packages/plugin-charts0Test Files 36 passed (36) · Tests 245 passed (245)
vitest run packages/plugin-dashboard packages/plugin-view apps/console0Test Files 201 passed (201) · Tests 2019 passed (2019)
vitest run .../all-locales-key-parity.test.ts0Tests 32 passed (32)
type-check (plugin-charts)0clean; --listFiles confirms both edited files are in the checked set (1 hit each of 1362)
eslint (plain form, changed files)061 problems (0 errors, 61 warnings) — 0 warnings inside the inserted lines; the test file's 3 match the sibling test's own profile
check:i18n-keys02605/2605 literal keys resolve · every inline default matches the value the pack serves
check:i18n-drift0No en value changed in this range.
check:control-bytes0OK (scanned 5909 tracked text file(s))
check:phantom-deps0Every in-scope import is declared by the package that publishes it.
check:element-data-source-declaration0OK — 13 gate-consuming file(s) checked
check-changeset-presence02 source file(s) of 1 released package(s) changed, and this change declares 1 changeset(s)
check-changeset-overwrite0No pre-existing changeset was modified or deleted.
check:sdui-registration-pins2NOT MEASURED, not a pass — No console build to weigh at apps/console/dist/assets. Declared narrowing: this diff adds no ComponentRegistry.register call and moves no registration array; left to CI, which builds the console.

Repo-wide pnpm lint was not run locally — a declared narrowing, left to CI, which runs the farm regardless.


Generated by Claude Code

…r an empty result
Measured, not assumed: recharts derives ticks from the data, so an empty
result draws two hairline axis rules and zero text nodes (bar/line) or
nothing at all (pie) — there are no labelled axes to read. Beside the
component's own red failure box that is an unexplained blank tile.
Routes through the primitive that is already shared (DataEmptyState in
@object-ui/components, already a dependency) with the i18n keys #7124
landed, so no package is promoted, no dependency edge is added and no new
key is introduced. Charts with inline authored data are unchanged.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012wwHa4aaFybxXrfmfHioDM
@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

MetricValueBudget
Eager closure (gzip, 48 chunks)3152.4 KB3191.4 KB
Main entry chunk (gzip)142.5 KB350 KB
Entry fileindex-Cyo1my-J.js
StatusPASS

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


📦 Bundle Size Report

PackageSizeGzipped
app-shell (consoleActionDispatch.js)0.20KB0.19KB
app-shell (index.js)15.33KB5.59KB
app-shell (runtime-config.js)20.68KB7.36KB
app-shell (types.js)0.01KB0.04KB
app-shell (urlParams.js)10.06KB3.86KB
auth (ActiveOrganizationStorage.js)25.05KB9.16KB
auth (AuthContext.js)0.31KB0.24KB
auth (AuthGuard.js)2.07KB1.00KB
auth (AuthProvider.js)40.18KB10.59KB
auth (AuthShell.js)3.49KB1.40KB
auth (ForgotPasswordForm.js)12.21KB3.45KB
auth (LoginForm.js)18.15KB5.39KB
auth (PreviewBanner.js)0.90KB0.50KB
auth (RegisterForm.js)6.65KB2.22KB
auth (SocialSignInButtons.js)9.61KB3.89KB
auth (UserMenu.js)3.41KB1.23KB
auth (auth-gate-events.js)1.29KB0.66KB
auth (authStyles.js)5.04KB1.72KB
auth (createAuthClient.js)40.21KB10.80KB
auth (createAuthenticatedFetch.js)8.46KB3.43KB
auth (index.js)3.19KB1.44KB
auth (invitation-status.js)1.22KB0.70KB
auth (org-roles.js)6.66KB2.78KB
auth (phone-identifier.js)1.11KB0.66KB
auth (types.js)0.59KB0.35KB
auth (useAuth.js)5.30KB1.02KB
auth (useWorkspaceAdminStatus.js)5.13KB2.35KB
collaboration (CommentThread.js)26.08KB7.56KB
collaboration (LiveCursors.js)3.17KB1.27KB
collaboration (PresenceAvatars.js)6.49KB2.64KB
collaboration (PresenceProvider.js)2.79KB1.13KB
collaboration (index.js)1.68KB0.73KB
collaboration (useCollaborationTranslation.js)6.05KB2.52KB
collaboration (useCommentSearch.js)1.98KB0.88KB
collaboration (useConflictResolution.js)7.75KB1.86KB
collaboration (useMentionNotifications.js)1.81KB0.68KB
collaboration (usePresence.js)6.33KB1.84KB
collaboration (useRealtimeSubscription.js)7.91KB2.01KB
components (index.js)512.30KB116.52KB
core (index.js)5.30KB2.13KB
create-plugin (index.js)10.08KB3.26KB
data-objectstack (index.js)177.67KB49.45KB
fields (index.js)244.04KB61.76KB
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)65.67KB18.57KB
plugin-chatbot (index.js)190.53KB45.18KB
plugin-dashboard (index.js)132.61KB34.56KB
plugin-designer (index.js)212.87KB43.19KB
plugin-detail (index.js)248.93KB63.56KB
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)202.31KB54.66KB
plugin-kanban (index.js)53.21KB14.66KB
plugin-list (index.js)113.19KB27.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

✅ ACCEPT (on the substance) — PM seat (domain:ui), reviewer of record

⛔ Landing not armed — 5 checks in_progress (Type Check, 4 shards), 25 completed, 0 failure. I arm on green.

⭐⭐ You falsified the filer's own hypothesis, which is exactly what the card was for

This card was dispatched measurement-first, with "close it measured-and-declined" named as an accepted win — because the argument for declining was specific and plausible: a chart is not a table; an empty chart still draws labelled axes, which may already tell the reader what would have been plotted.

You rendered it in Chromium and measured: zero text nodes. Bar and line — 2 hairline rules, 0 paths, not one character. Pie/donut — completely blank. Populated control in the same render: 8 text nodes, 3 paths, 5 lines.

⇒ recharts derives ticks from the data, so an empty domain labels nothing. The entire case for declining rested on labels that do not exist. That is the difference between an opinion and a reading, and it is why this card was worth a slot even though it might have closed with no change.

⭐ And the sharpest observation: the empty tile is less informative than the failure it must be distinguished from. The chart-error box at least says "Failed to load chart data"; the empty one says nothing. It fails both halves of the hotcrm#1212 bar simultaneously.

⭐⭐ My KPI-precedent assumption was wrong, and the refutation reframes the card

I asked whether charts fall in the metric/KPI family that #7063 deliberately left alone. You refuted it with landed code rather than argument — and I verified it on main:

  • METRIC_TYPES = metric | kpi | gauge | solid-gauge | bullet. Chart families are not in it.
  • DatasetWidget.tsx:726-728: "A metric (single value) over an empty dataset is 0, not an empty state… Charts and tables keep the empty state (there is genuinely nothing to plot)."

The consequence changes what this card is. A dataset-bound chart has rendered the self-describing empty state since #7124; the object-bound chart did not. Same family, same empty result, two different answers depending on which widget drew it. So this is not a new judgement at all — it is the surface hotcrm#1212's ruling did not reach. That is a materially stronger justification than the card carried, and it came from measurement.

⭐ The package boundary: four options, and the one taken adds nothing

I told you the boundary was a real design question to establish with evidence, and that "promote to a shared package" must stop and report to #7132. You measured all four and took (d): consume DataEmptyState from @object-ui/components — already shared, already consumed by plugin-list / plugin-kanban / plugin-detailand by WidgetEmptyState itself, and already a declared dependency of plugin-charts.

Verified independently: DataEmptyState present in components/src/custom/view-states.tsx (8 hits); plugin-charts declares @object-ui/components in its manifest (control: react, 2 hits). ⇒ No package promoted, no dependency edge added, no new i18n key, no new spec key. #7132 untouched.

⭐ And option (a) was rejected on ownership, not mechanics — you noted plainly that no cycle would have been created and that six plugin-to-plugin edges already exist. Rejecting something for the true reason rather than the convenient one is what makes the rejection reusable.

⭐ Reusing the three keys #7124 landed in all ten packs is the right call: this becomes a fifth call site that collapses with the other four when #7132 converges the defaults, instead of a fifth thing to converge.

The pins assert the ways the branch could be wrong

Five, and three of them are negative: it must not fire over a populated result, must not flash before the fetch resolves, and must not fire for an inline-data chart. ⭐ Those are the three ways a well-meaning empty branch actually breaks things, and they are worth more than a second copy of the positive case.

Asserting distinguishability on the ARIA roles (status on the empty box vs alert on chart-error) rather than on copy is right — that is the machine-readable half of the maintainer's bar, and it survives rewording.

Ablation predicted the direction before running, proved the mutation on disk by marker counts and blob hash, reported Tests 2 failed | 3 passed (5)with the passing count, and proved the restore by state including git diff --cached. Every discipline this lane has accumulated tonight, applied without being re-asked.

#7140 — filed at the right boundary

The sankey all-zero arm returns a bare div: AdvancedChartImpl filters to strictly positive values, so links is empty, and both existing refusals are gated rows.length > 0 so neither ever fires. ⭐ Not reachable by this PR's branch (finalData.length > 0 on that path), and correctly identified as the last silent blank on the chart surface after this lands. Different defect class, different file, filed not fixed.

I will triage it.


Generated by Claude Code

@os-warrenClaude

Copy link
Copy Markdown
CollaboratorAuthor

⛔ NOT ARMED — CI is red, and it is this PR's

Test (shard 3/4) concluded failure at 04:48:09Z. 29 of 30 checks completed with 0 other failures. ⛔ Landing is withheld; my ACCEPT above stands on the substance and does not carry to a red head.

Sent back to the implementer with the diagnosis. ⚠️ Recording it here so the thread shows live state rather than an ACCEPT sitting above an unexplained red.

The failure, and why it is real

From the job log — packages/app-shell/src/__tests__/widget-dom-leak-sweep.test.tsx:1190:

Error: view:chart: readiness selector `[data-slot="chart"]` never matched.
The widget did not reach its real markup, so scanning it would measure nothing.

…and the body it dumped is this PR's new empty state: data-slot="data-empty-state", data-testid="chart-empty-state", "No data yet" / "This widget loaded successfully and its query returned no records yet." / "Source: accounts".

⇒ The sweep mounts view:chart with a fixture whose query returns no rows. Before this PR that still produced chart markup, so the readiness selector matched and the sweep scanned it. Now the empty branch fires first, the selector never matches, and the sweep refuses to scan.

That refusal is the sweep's own anti-vacuity guard working exactly as designed — it is declining to report a green over markup that never rendered. It caught a real behaviour change on the first run. ⛔ Which is precisely why the fix must not be to relax it: a guard that just proved it can detect this must not be taught to accept the state that tripped it.

The gap that let it reach CI

The report ran packages/plugin-charts, then plugin-dashboard / plugin-view / apps/console as "downstream consumers". packages/app-shell was not in that set, and that is where the sweep lives.

⚠️ Worth noting for the seat as much as for the implementer: #7063's implementer did include widget-dom-leak-sweep.test.tsx in their union, for exactly this reason — a widget-markup change reaches it. The consumer set for "I changed what a widget renders" is wider than the packages that import it.

Not a flake, so no re-run. It is deterministic, its message names the cause, and the cause is this diff.

Instructions sent

Give the sweep's view:chart fixture a non-empty result so the chart reaches real markup — preserving both the sweep's purpose and this PR's behaviour. ⛔ Do not relax the readiness selector, ⛔ do not add the empty state to what counts as "ready", and ⚠️ stop-and-report if measurement shows the fixture cannot supply data. Plus a sweep for other app-shell tests mounting a chart over an empty result, since this failure is one instance of a class.

I re-arm on green.


Generated by Claude Code

The DOM-leak sweep mounts `plugin-charts:object-chart` and `view:chart`
against an adapter that answers every query with no rows. Since the empty
branch in this branch's first commit, that lands them on the empty state
rather than chart markup, so `[data-slot="chart"]` never matches and the
sweep's readiness guard refuses to scan — the guard working as designed.
Fixed on the fixture, not the guard: the two targets author `data` /
`series` (both already used by the six inline chart targets beside them,
and `data" is a declared registry input on this component) while staying
object-bound. The readiness selector and the guard are untouched, and
these two now scan strictly more markup than before — the pre-existing
reading swept a chart frame with no marks in it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012wwHa4aaFybxXrfmfHioDM
@os-warrenClaude

Copy link
Copy Markdown
CollaboratorAuthor

CI fix pushed as d3e7373f1 — the sweep's readiness guard caught a real behaviour change

Test (shard 3/4) was red on packages/app-shell/src/__tests__/widget-dom-leak-sweep.test.tsx, and it was mine. Not a flake, and not a defect in the branch's behaviour.

The fixture mechanism, measured

The sweep mounts every target against one shared adapter:

const FAKE_ADAPTER = {
find: async () => [],
findOne: async () => null,
aggregate: async () => [],
count: async () => 0,
getObject: async () => null,
};

whose docstring states the invariant it relies on: "Answering with empty results keeps every data-bound target on its real render path rather than an error state." The two object-bound chart targets take OBJECT_CHART_EXTRAS = objectName: 'accounts', chartType: 'bar', categoryField, valueFieldno inline data, so they are query-backed and that adapter hands them nothing.

That invariant was true for charts until this branch. An empty result now renders the self-describing empty state rather than chart markup, so [data-slot="chart"] never matches and the readiness guard refuses to scan. The guard is right: an empty state is not the chart markup this sweep exists to scan, and it was telling me it would have measured nothing.

Reproduced locally, and it is TWO targets, not one

CI named view:chart; both targets share OBJECT_CHART_EXTRAS, so both fail:

× plugin-charts:object-chart 10024ms
× view:chart 10007ms
Tests 2 failed | 200 passed (202)

The fix is on the fixture, and the guard is untouched

The two targets now author data / series while staying object-bound — the same two keys the six inline chart targets beside them already use, and data is a declared registry input on this component ("Optional static data"). This is objectui#5630's own lesson in a third dress: that card fixed data-bound targets whose clean reading covered only their empty-state placeholder, using a populated host where one was needed and a plain schemaExtras change where the populated branch was reachable from pure schema (element:definition-list's items). This is the latter case. I also corrected the FAKE_ADAPTER docstring, whose universal claim this branch made false.

⚠️ Worth stating: these two targets now scan strictly more markup than before. The pre-existing reading swept a chart frame with no marks in it.

Proof the guard was not weakened — the whole diff to that file is 33 insertions(+), 0 deletions(-), and the only non-comment lines added are:

+ data: CHART_DATA,
+ series: CHART_SERIES,

No ready: selector changed, nothing was removed, and the empty state was not added to what counts as ready.

Re-run at d3e7373f1

runexitverdict
vitest run packages/app-shell/src/__tests__/widget-dom-leak-sweep.test.tsx0Test Files 1 passed (1) · Tests 202 passed (202) — same total as the red run's 2+200, so no test vanished
vitest run packages/app-shell (whole package)0Test Files 591 passed (591) · `Tests 5765 passed
vitest run packages/plugin-charts packages/types packages/react packages/core packages/plugin-timeline packages/plugin-map packages/fields packages/data-objectstack0Test Files 508 passed (508) · Tests 7389 passed (7389)
app-shell type-check0green after building the dependency closure; the first run's exit 2 was unbuilt-closure TS2307, i.e. NOT MEASURED, not a red. --listFiles on tsconfig.test.json confirms the edited sweep file is in the checked set (1 of 4507)
eslint (plain form) on the edited file0clean
check:control-bytes0OK (scanned 5909 tracked text file(s))
check-changeset-presence03 source file(s) of 2 released package(s) changed, and this change declares 1 changeset(s) — the app-shell edit is test-only, so no app-shell bump was added

The gap that let it through, named

My consumer union was plugin-charts + plugin-dashboard / plugin-view / apps/console. packages/app-shell was not in it, and that is where this sweep lives. I re-derived the union by measurement instead of by guess: 12 packages contain a test naming an object-bound chart type, and the eight I had never run are all green above. Within app-shell the sweep is the only test that mounts a chart against an empty-returning adapter.


Generated by Claude Code

@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

MetricValueBudget
Eager closure (gzip, 48 chunks)3152.5 KB3191.4 KB
Main entry chunk (gzip)142.5 KB350 KB
Entry fileindex-DJCNM8Pr.js
StatusPASS

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


📦 Bundle Size Report

PackageSizeGzipped
app-shell (consoleActionDispatch.js)0.20KB0.19KB
app-shell (index.js)15.33KB5.59KB
app-shell (runtime-config.js)20.68KB7.36KB
app-shell (types.js)0.01KB0.04KB
app-shell (urlParams.js)10.06KB3.86KB
auth (ActiveOrganizationStorage.js)25.05KB9.16KB
auth (AuthContext.js)0.31KB0.24KB
auth (AuthGuard.js)2.07KB1.00KB
auth (AuthProvider.js)40.18KB10.59KB
auth (AuthShell.js)3.49KB1.40KB
auth (ForgotPasswordForm.js)12.21KB3.45KB
auth (LoginForm.js)18.15KB5.39KB
auth (PreviewBanner.js)0.90KB0.50KB
auth (RegisterForm.js)6.65KB2.22KB
auth (SocialSignInButtons.js)9.61KB3.89KB
auth (UserMenu.js)3.41KB1.23KB
auth (auth-gate-events.js)1.29KB0.66KB
auth (authStyles.js)5.04KB1.72KB
auth (createAuthClient.js)40.21KB10.80KB
auth (createAuthenticatedFetch.js)8.46KB3.43KB
auth (index.js)3.19KB1.44KB
auth (invitation-status.js)1.22KB0.70KB
auth (org-roles.js)6.66KB2.78KB
auth (phone-identifier.js)1.11KB0.66KB
auth (types.js)0.59KB0.35KB
auth (useAuth.js)5.30KB1.02KB
auth (useWorkspaceAdminStatus.js)5.13KB2.35KB
collaboration (CommentThread.js)26.08KB7.56KB
collaboration (LiveCursors.js)3.17KB1.27KB
collaboration (PresenceAvatars.js)6.49KB2.64KB
collaboration (PresenceProvider.js)2.79KB1.13KB
collaboration (index.js)1.68KB0.73KB
collaboration (useCollaborationTranslation.js)6.05KB2.52KB
collaboration (useCommentSearch.js)1.98KB0.88KB
collaboration (useConflictResolution.js)7.75KB1.86KB
collaboration (useMentionNotifications.js)1.81KB0.68KB
collaboration (usePresence.js)6.33KB1.84KB
collaboration (useRealtimeSubscription.js)7.91KB2.01KB
components (index.js)512.30KB116.52KB
core (index.js)5.30KB2.13KB
create-plugin (index.js)10.08KB3.26KB
data-objectstack (index.js)177.67KB49.45KB
fields (index.js)244.04KB61.76KB
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)65.67KB18.57KB
plugin-chatbot (index.js)190.53KB45.18KB
plugin-dashboard (index.js)132.61KB34.56KB
plugin-designer (index.js)212.87KB43.19KB
plugin-detail (index.js)248.93KB63.56KB
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)202.31KB54.66KB
plugin-kanban (index.js)53.21KB14.66KB
plugin-list (index.js)113.19KB27.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-warren pushed a commit that referenced this pull request Sep 1, 2026
…, so an empty result is not shaped like a failed one
`DataLoadingState` has always been `role="status"` and `DataErrorState`
`role="alert"`. `DataEmptyState`, in the same file, declared nothing -- so an
empty box and a failed box were the same node shape to a screen reader and to
any structural test.
Measured before changing anything, by rendering every surface and reading its
empty box: the bare primitive, plugin-list's empty list, plugin-list's
load-error panel, plugin-detail's two activity timelines and the `ui:empty`
schema renderer all carried NO role, while plugin-dashboard's WidgetEmptyState
(#7063), plugin-kanban's empty board and plugin-charts' ObjectChart (PR #7139)
each carried `role="status"` typed out at their own call site.
So the surfaces were not legitimately differing. Every surface that wanted the
property had hand-copied the same line, and the ones that had not yet done so
were silently missing it -- one platform default, paid for per package, which is
the tax objectstack#13848 rules against.
It is a DEFAULT, not a fixed attribute: `role` is spread from props, so a call
site keeps the last word. That is what makes this inert for the two already
ruled surfaces -- both pass `role="status"` explicitly and receive the identical
attribute either way. Neither ruled surface's behaviour changes, and the
metric/KPI carve-out is untouched because it gates whether an empty state is
rendered at all, upstream of this component.
The measurement also turned up a real defect. plugin-list renders its load
FAILURE through `DataEmptyState`, borrowing it for layout, so "You don't have
access" and "Nothing here yet" were indistinguishable. That panel now declares
`role="alert"` -- which fixes the pre-existing collision and stops the new
default from announcing an outage as a routine status.
Pinned in data-empty-state-role-7132.test.tsx and
ListView.emptyVsErrorRole-7132.test.tsx: the default, the override that keeps
the ruled surfaces inert, the sibling contrast, and the two list branches by
exact role value.
objectui#7132.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012wwHa4aaFybxXrfmfHioDM
@os-warren
os-warren marked this pull request as ready for review September 1, 2026 05:49
@os-warren
os-warren added this pull request to the merge queueSep 1, 2026
@os-warrenClaude

Copy link
Copy Markdown
CollaboratorAuthor

✅ Green after the fix — reviewed and armed

Undrafted and auto-merge (squash) enabled at head d3e7373f1.

CI, read in full and read twice

All 30 check runs in one call at perPage=100. 27 success, 3 skipped, 0 failed. The check that was red — Test (shard 3/4), widget-dom-leak-sweep.test.tsx — is success, completed 05:40:50Z. All four shards, Type Check and Lint green.

Two readings I want on the record because each nearly produced a wrong verdict:

I did not arm on the first pass. At 05:32Z six checks were still in_progress — all four shards, Type Check, Lint — against 21 success and 0 red. That is not green. in_progress is the absence of a reading, and the still-pending one was precisely the shard that had failed.

mergeable_state read unstable, then clean. Immediately after undrafting, this PR reported unstable. Check runs alone would not have explained it, so I read the combined commit status too — state: success, one Vercel context — and then re-read the PR: clean. The unstable was a stale derived field captured seconds after a state-changing write, not a signal. Standing note: mergeability read immediately after undraft/push is stale; re-read before believing it, and never treat a derived field as a signal source when the underlying instruments are readable.

The fence held — verified from the diff, not from the report

I fenced this round with ⛔ do not relax the readiness selector; give the fixture a non-empty result. Checked independently against the file stats rather than taking the report's word:

  • widget-dom-leak-sweep.test.tsx is +33 / −0. Zero deletions — nothing was relaxed, removed, or loosened.
  • The only non-comment lines added are data: CHART_DATA, and series: CHART_SERIES,.
  • The single [data-slot="chart"] occurrence in the diff sits inside a new comment, not in a ready: value.

And the direction is right: because the production branch is gated on !schema.data, these targets now reach real chart markup. The sweep scans strictly more than before — the pre-change reading swept a chart frame with no marks in it.

The failure was real, and it was two targets

The sweep mounts every target against one shared adapter whose find returns [], with a docstring asserting that empty results keep every data-bound target on its real render path. This PR's empty branch made that invariant false for charts — so the guard was reporting a genuine consequence of the change, not noise. Relaxing it would have destroyed a working control.

CI named only view:chart. It was two: plugin-charts:object-chart and view:chart share OBJECT_CHART_EXTRAS, and reproducing locally gave 2 failed | 200 passed (202), with the post-fix run at 202 passed (202) — same total, so no test vanished into the green. Asserting the total across a red/green pair is what rules out a suite that quietly stopped collecting.

The gap the dev named themselves — and why it matters beyond this PR

They chose the blast radius from the package's declared consumers. For a change to an empty-result branch, the correct criterion is which test fixtures answer queries with no rows — a different and wider set. Twelve packages contain a test naming an object-bound chart type; the original union covered four. The eight uncovered ones were then run and are green.

Naming that rather than letting the passing re-run stand in for it is the valuable part. It is the same shape as this seat's standing lesson — the consumer set for "I changed what a widget renders" is wider than the packages that import it — arriving from a third direction. It is now carried into objectui#7132's brief, since that card will meet this same sweep.

Also worth noting

The filing hypothesis was disproved by measurement: an empty chart renders zero<text> nodes (ticks derive from data), against a populated control emitting 8. Measured-and-declined was a genuinely available outcome here; the measurement is what ruled it out, not a preference.

The package boundary was decided on ownership, not mechanicsWidgetEmptyState would have needed promoting to public API for a foreign plugin, so DataEmptyState from the already-shared package was taken instead. No package promoted, no dependency edge added, no new i18n key. objectui#7132 stays free to converge all five call sites.

check:sdui-registration-pins returned PREREQUISITE NOT MET locally — correctly reported as NOT MEASURED rather than a pass. Same unconfirmed-by-me status as on PR objectui#7141: the diff adds no registration, so the gate has no subject in it regardless.

Landing will be verified by content with a live control once main advances — a queue sha is not a landing.


Generated by Claude Code

Merged via the queue into main with commit dd35800Sep 1, 2026
32 checks passed
@os-warren
os-warren deleted the claude/issue-7130-object-chart-empty-branch branch September 1, 2026 06:04
akarma-synetal pushed a commit to akarma-synetal/objectui that referenced this pull request Sep 1, 2026
…, so an empty result is not shaped like a failed one (objectstack-ai#7144)
`DataLoadingState` has always been `role="status"` and `DataErrorState`
`role="alert"`. `DataEmptyState`, in the same file, declared nothing -- so an
empty box and a failed box were the same node shape to a screen reader and to
any structural test.
Measured before changing anything, by rendering every surface and reading its
empty box: the bare primitive, plugin-list's empty list, plugin-list's
load-error panel, plugin-detail's two activity timelines and the `ui:empty`
schema renderer all carried NO role, while plugin-dashboard's WidgetEmptyState
(objectstack-ai#7063), plugin-kanban's empty board and plugin-charts' ObjectChart (PR objectstack-ai#7139)
each carried `role="status"` typed out at their own call site.
So the surfaces were not legitimately differing. Every surface that wanted the
property had hand-copied the same line, and the ones that had not yet done so
were silently missing it -- one platform default, paid for per package, which is
the tax objectstack#13848 rules against.
It is a DEFAULT, not a fixed attribute: `role` is spread from props, so a call
site keeps the last word. That is what makes this inert for the two already
ruled surfaces -- both pass `role="status"` explicitly and receive the identical
attribute either way. Neither ruled surface's behaviour changes, and the
metric/KPI carve-out is untouched because it gates whether an empty state is
rendered at all, upstream of this component.
The measurement also turned up a real defect. plugin-list renders its load
FAILURE through `DataEmptyState`, borrowing it for layout, so "You don't have
access" and "Nothing here yet" were indistinguishable. That panel now declares
`role="alert"` -- which fixes the pre-existing collision and stops the new
default from announcing an outage as a routine status.
Pinned in data-empty-state-role-7132.test.tsx and
ListView.emptyVsErrorRole-7132.test.tsx: the default, the override that keeps
the ruled surfaces inert, the sibling contrast, and the two list branches by
exact role value.
objectui#7132.
Claude-Session: https://claude.ai/code/session_012wwHa4aaFybxXrfmfHioDM
Co-authored-by: Claude <noreply@anthropic.com>
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

2 participants

@os-warren@claude