fix(components,plugin-list): let DataEmptyState declare role="status", so an empty result is not shaped like a failed one - #7144

Merged
os-warren merged 1 commit into
mainfrom
claude/issue-7132-empty-state-convergence
Sep 1, 2026
Merged

fix(components,plugin-list): let DataEmptyState declare role="status", so an empty result is not shaped like a failed one#7144
os-warren merged 1 commit into
mainfrom
claude/issue-7132-empty-state-convergence

Conversation

@os-warren

Copy link
Copy Markdown
Collaborator

Fixes#7132

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

Every surface was rendered and its empty box read directly — role, aria-live, data-slot, non-blank text-node count and text — at 2b3964aff. Not read off the source; rendered.

surfaceroletextself-describingdistinguishable from a failure
DataEmptyState bare defaultnoneNo datano — terse, names nothingno
plugin-list empty listnoneNothing here yet + Create your first record…yesno
plugin-list load ERROR (403)noneYou don't have access… + Retryyesno
plugin-detailActivityTimelinenoneNo activity recordedpartlyno
ui:empty schema renderernoneauthoredauthoredno
plugin-dashboardWidgetEmptyState (#7063)statusNo data yet + explanation + Source: work_orderyesyes
plugin-kanban empty boardstatus + aria-live=politeNo cards + 2 columnsyesyes
plugin-chartsObjectChart (PR #7139)statusNo data yet + explanationyesyes

The answer: they do NOT legitimately differ

The two properties the rulings name are not in tension across these surfaces — one surface had simply never been given the platform default, and the rest had each bought it retail.

DataLoadingState has always been role="status" and DataErrorStaterole="alert". DataEmptyState, in the same file, declared nothing. So every surface that wanted the property typed it at its own call site, and three independently did — kanban, dashboard, and the in-flight ObjectChart — while plugin-list, plugin-detail's two timelines and the ui:empty renderer silently did not.

Three hand-copies of one line, paid per package, is the per-app tax objectstack#13848 rules against, and the spread was not principled divergence but the presence or absence of one attribute nobody owned.

⇒ The primitive carries property 1 now. It still does not carry property 2 and deliberately is not given it: every shipped surface already supplies its own self-describing copy, and changing the 'No data' title default would be a copy change reaching all consumers plus an i18n project in a component that currently has no translation hook. That half is measured-and-declined, recorded here rather than done quietly.

The fences, each checked rather than asserted

#7063 and #7064 are not re-litigated — neither surface's behaviour changes, structurally.

No authoring surface is widened. No new prop, no new schema key, no new i18n key. ListView already read schema.emptyState?.title / .message before this change and still does, untouched. Clause ② is not engaged, so the blocked Fable 5 quota is not in the path.

The KPI datum does not generalise, and the code says why.DatasetWidget's carve-out is if (state.rows.length === 0 && !isMetric) — a branch gate on whether an empty state is rendered at all, upstream of this component. A metric widget never reaches DataEmptyState, so a KPI still reads 0. That decision and this one are about different questions: whether to show an empty state vs what structure the empty state has. Untouched either way.

One real defect fell out of the measurement

plugin-list renders its load failure through DataEmptyState, borrowing it for layout. With no role on either branch, "You don't have access" and "Nothing here yet" were the same node shape — and a role="status" default alone would have made it worse, announcing a 403 as a routine status update. That call site now declares role="alert".

This is why naive hoisting was not safe, and it is the one thing the card's hypothesis did not anticipate. The deeper structural question — that DataErrorState sits unused next to it — is filed as #7143 rather than refactored here.

Consumer census, re-derived with controls

Both exported names were swept, not just the canonical one:

  • DataEmptyState6 call sites in 4 packages on main, not the four the card assumed: plugin-list (two — empty and error), plugin-kanban, plugin-detail (twoActivityTimeline and RecordActivityTimeline), and packages/components' own ui:empty renderer, which the card did not list at all. PR fix(plugin-charts): a self-describing empty state for ObjectChart over an empty result #7139 adds ObjectChart as a seventh.
  • EmptyState — the alias re-export has zero consumers. Real zero: the control (git grep -nw against the alias line itself) hits at view-states.tsx:199. It is public API of a published package, so this is an observation, not a defect.
  • Outside packages/*: zero code consumers — one changeset mentions the name in prose. Control: 238 files outside packages/* reference @object-ui/components, so the channel reads.
  • PM assumption falsified:DataEmptyState is not the only shared empty-state primitive in packages/components. custom/empty.tsx carries a second, independent family — Empty / EmptyHeader / EmptyMedia / EmptyTitle / EmptyDescription / EmptyContent / EmptyValue — plus BuiltinSelectEmptyState and CommandEmpty elsewhere. None of them is a view-level data empty state and none is touched here, but "the only shared primitive" was not true.

Blast radius, measured before proposing it

packages/components is broad-blast, so the whole consumer graph was run, not sampled.

scoperesult
direct consumers — components, plugin-list, plugin-kanban, plugin-detail, plugin-dashboardTest Files 504 passed (504) · Tests 4780 passed (4780)
indirect — app-shell, apps/console, fields, plugin-gantt, plugin-timeline, i18n, plugin-charts, plugin-viewTest Files 1003 passed (1003) · Tests 10939 passed | 1 skipped (10940)

Zero snapshot exposure: no snapshot file contains a DataEmptyState render (control: 10 data-slot hits in snapshot.test.tsx.snap prove the grep reads).

Evidence

The pins can fail — ablation, direction predicted before running. Predicted: removing the role="status" default reddens the two DEFAULT arms and the two ListView arms that name status, and leaves the two OVERRIDE arms, the SIBLING CONTRAST arm and the ERROR arm green.

  • mutation proven on disk before the run: blob 294d845a to 9e24fccf, guard-line count 2 to 1, git diff --stat non-empty
  • result: Tests 4 failed | 4 passed (8) — exactly the four predicted arms, with the passing count asserted so a collapsed suite could not masquerade as the red
  • restore proven by state: blob back to 294d845a, guard-lines back to 2, git diff HEADandgit diff --cached both empty

The three-value arm is asserted by exact value, not by inequality: on main both roles were null, and a partial fix leaving the empty branch at null would still satisfy null !== 'alert' and pass a mere-difference assertion. Naming both values is what makes that arm able to fail.

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

gateexitverdict
vitest run (the two new pins)0Test Files 2 passed (2) · Tests 8 passed (8)
vitest run (5 direct consumer packages)0Test Files 504 passed (504) · Tests 4780 passed (4780)
vitest run (8 indirect packages)0Test Files 1003 passed (1003) · Tests 10939 passed | 1 skipped
tsc --noEmit + tsc -p tsconfig.test.json (components)0 / 0clean
tsc --noEmit + tsc -p tsconfig.test.json (plugin-list)0 / 0clean
eslint (plain form, 4 changed files)0184 problems (0 errors, 184 warnings) — all pre-existing no-explicit-any; the new pin's 2 match ListView.loadErrorKind.test.tsx's own profile exactly
check-changeset-presence04 source file(s) of 2 released package(s) changed, and this change declares 1 changeset(s)
check-changeset-fixed0All workspace packages are in the changeset fixed group.
check-changeset-no-major0No changeset declares a major bump.
check-changeset-overwrite0No pre-existing changeset was modified or deleted.
check-control-bytes0OK (scanned 5917 tracked text file(s); skipped 85 binary)
check-i18n-call-site-keys02845 keys — every in-scope call-site key resolves
check-i18n-en-drift0No en value changed in this range.
check-phantom-dependencies0Every in-scope import is declared by the package that publishes it.

⚠️typecheck clean would have been a false green on its ownpackages/components/tsconfig.json excludes src/__tests__ and **/*.test.tsx. Proven covered by the second pass instead: --listFiles puts the components pin at 1 of 1895 checked files and the plugin-list pin at 1 of 1625; both edited sources are in the src pass (1 of 1367, 1 of 1334); control — the src pass contains 0.test.tsx, confirming the two-pass split is what covers them.

Declared narrowing: repo-wide pnpm lint and the full check:* farm were not run locally; CI runs them regardless. pnpm install, the closure build (turbo run build --filter='@object-ui/plugin-list^...', 12 successful) and every run above happened in a dedicated worktree off 2b3964aff.

On PR #7139: measured on main as instructed; it is still open and based on the same 2b3964aff. It hand-types role="status" at its call site, so it is unaffected by this default whichever order the two land in — and afterwards that line becomes removable, which is the collapse its author placed it for. No file in packages/plugin-charts is touched here.

Out-of-scope findings


Generated by Claude Code

…, 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
@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.6 KB350 KB
Entry fileindex-Bd59YEPm.js
StatusPASS

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


📦 Bundle Size Report

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

Size Limits

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

@os-warrenClaude

Copy link
Copy Markdown
CollaboratorAuthor

✅ Reviewed and armed

All 30 check runs read in one call at perPage=100: 27 success, 3 skipped, 0 failed, 0 in_progress. Undrafted (a draft cannot enter the queue), auto-merge SQUASH enabled at ff43f39f7.

Why this one goes in without a round trip

Property 2 came back measured-and-DECLINED, and that is a FULL PASS. Giving DataEmptyState self-describing copy would be a copy change reaching every consumer plus an i18n project in a component with no translation hook — and every shipped surface already supplies its own copy. Recording that half as declined, with the reason, is worth more than doing it quietly. A card that converges one property and explicitly declines the other has answered the question it was asked.

A naive hoist would have been actively harmful, and only rendering found that.plugin-list renders its load failure through DataEmptyState, borrowing it for layout. With no role on either branch, a 403 and "Nothing here yet" were the same node shape — and a bare role="status" default would have made it worse, announcing an outage as a routine status update. The correct convergence is two lines, not one: the default plus role="alert" at that call site.

That is invisible to a source read. A reader of view-states.tsx sees one component missing one attribute and a one-line fix; the danger only appears when you render the surfaces and find one of them is doing the other's job.

⚠️ My briefed consumer count was wrong, and I verified the correction myself

I dispatched this saying four call sites. It is six in four packages, seven once objectui#7139 landed. Rather than accept that on report, I re-measured on main:

proberesult
<DataEmptyState sites in ListView.tsx2:3584 (load error) and :3675 (empty)
DataErrorState in ListView.tsx0 — control: DataEmptyState hits 3× in the same file, so the zero reads
role= declarations in view-states.tsx vs Data* components2 vs 3DataEmptyState alone declared none
consuming files across packages/6, including components' own ui:empty renderer, which the card never listed

The dev swept both exported names, not just the canonical one, and reported EmptyState's alias as a real zero — with the control hitting the alias line itself at view-states.tsx:199, which is what separates "no consumers" from "the query missed."

Assumptions falsified and reported rather than absorbed

  • "DataEmptyState is the only shared empty-state primitive."Falsecustom/empty.tsx carries a whole second family, plus BuiltinSelectEmptyState and CommandEmpty. None is a view-level data empty state and none was touched, but the claim was wrong and saying so is the right call.
  • "All consumers pass compatible props."False, and it is exactly why the naive default was unsafe.
  • The KPI carve-out confirmed but explicitly non-generalising: rows.length === 0 && !isMetric is a branch gate on whether an empty state renders at all, upstream of this component — a different question from what structure it has.

Evidence

Ablation predicted before running, observed exactly: Tests 4 failed | 4 passed (8), the four survivors being both override arms, the sibling-contrast arm and the error arm — which are themselves the proof that objectui#7063's and objectui#7064's ruled surfaces are inert. Mutation proven on disk by blob hash and guard-line count; restore proven by state.

The three-value arm is asserted by exact value, not by inequality — on main both roles were null, so a partial fix leaving the empty branch at null would still satisfy null !== 'alert' and pass a mere-difference check. Naming both values is what makes that arm able to fail.

typecheck clean would have been a false green on its own (packages/components/tsconfig.json excludes src/__tests__ and **/*.test.tsx); proven covered by the second pass instead, with --listFiles placing both pins in the checked set and a control showing 0.test.tsx in the src pass.

Blast radius run rather than sampled: 1507 test files across direct and indirect consumers, zero failures.

Carried into this lane and it paid

objectui#7130's dev named a gap on themselves — blast radius chosen from declared consumers when the right criterion is which fixtures answer queries with no rows. This lane ran the full graph instead of sampling.

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 d1842abSep 1, 2026
32 checks passed
@os-warren
os-warren deleted the claude/issue-7132-empty-state-convergence branch September 1, 2026 06:32
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(components,plugin-list): let DataEmptyState declare role="status", so an empty result is not shaped like a failed one - #7144

Merged
os-warren merged 1 commit into
mainfrom
claude/issue-7132-empty-state-convergence
Sep 1, 2026
Merged

fix(components,plugin-list): let DataEmptyState declare role="status", so an empty result is not shaped like a failed one#7144
os-warren merged 1 commit into
mainfrom
claude/issue-7132-empty-state-convergence

Conversation

@os-warren

Copy link
Copy Markdown
Collaborator

Fixes#7132

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

Every surface was rendered and its empty box read directly — role, aria-live, data-slot, non-blank text-node count and text — at 2b3964aff. Not read off the source; rendered.

surfaceroletextself-describingdistinguishable from a failure
DataEmptyState bare defaultnoneNo datano — terse, names nothingno
plugin-list empty listnoneNothing here yet + Create your first record…yesno
plugin-list load ERROR (403)noneYou don't have access… + Retryyesno
plugin-detailActivityTimelinenoneNo activity recordedpartlyno
ui:empty schema renderernoneauthoredauthoredno
plugin-dashboardWidgetEmptyState (#7063)statusNo data yet + explanation + Source: work_orderyesyes
plugin-kanban empty boardstatus + aria-live=politeNo cards + 2 columnsyesyes
plugin-chartsObjectChart (PR #7139)statusNo data yet + explanationyesyes

The answer: they do NOT legitimately differ

The two properties the rulings name are not in tension across these surfaces — one surface had simply never been given the platform default, and the rest had each bought it retail.

DataLoadingState has always been role="status" and DataErrorStaterole="alert". DataEmptyState, in the same file, declared nothing. So every surface that wanted the property typed it at its own call site, and three independently did — kanban, dashboard, and the in-flight ObjectChart — while plugin-list, plugin-detail's two timelines and the ui:empty renderer silently did not.

Three hand-copies of one line, paid per package, is the per-app tax objectstack#13848 rules against, and the spread was not principled divergence but the presence or absence of one attribute nobody owned.

⇒ The primitive carries property 1 now. It still does not carry property 2 and deliberately is not given it: every shipped surface already supplies its own self-describing copy, and changing the 'No data' title default would be a copy change reaching all consumers plus an i18n project in a component that currently has no translation hook. That half is measured-and-declined, recorded here rather than done quietly.

The fences, each checked rather than asserted

#7063 and #7064 are not re-litigated — neither surface's behaviour changes, structurally.

No authoring surface is widened. No new prop, no new schema key, no new i18n key. ListView already read schema.emptyState?.title / .message before this change and still does, untouched. Clause ② is not engaged, so the blocked Fable 5 quota is not in the path.

The KPI datum does not generalise, and the code says why.DatasetWidget's carve-out is if (state.rows.length === 0 && !isMetric) — a branch gate on whether an empty state is rendered at all, upstream of this component. A metric widget never reaches DataEmptyState, so a KPI still reads 0. That decision and this one are about different questions: whether to show an empty state vs what structure the empty state has. Untouched either way.

One real defect fell out of the measurement

plugin-list renders its load failure through DataEmptyState, borrowing it for layout. With no role on either branch, "You don't have access" and "Nothing here yet" were the same node shape — and a role="status" default alone would have made it worse, announcing a 403 as a routine status update. That call site now declares role="alert".

This is why naive hoisting was not safe, and it is the one thing the card's hypothesis did not anticipate. The deeper structural question — that DataErrorState sits unused next to it — is filed as #7143 rather than refactored here.

Consumer census, re-derived with controls

Both exported names were swept, not just the canonical one:

  • DataEmptyState6 call sites in 4 packages on main, not the four the card assumed: plugin-list (two — empty and error), plugin-kanban, plugin-detail (twoActivityTimeline and RecordActivityTimeline), and packages/components' own ui:empty renderer, which the card did not list at all. PR fix(plugin-charts): a self-describing empty state for ObjectChart over an empty result #7139 adds ObjectChart as a seventh.
  • EmptyState — the alias re-export has zero consumers. Real zero: the control (git grep -nw against the alias line itself) hits at view-states.tsx:199. It is public API of a published package, so this is an observation, not a defect.
  • Outside packages/*: zero code consumers — one changeset mentions the name in prose. Control: 238 files outside packages/* reference @object-ui/components, so the channel reads.
  • PM assumption falsified:DataEmptyState is not the only shared empty-state primitive in packages/components. custom/empty.tsx carries a second, independent family — Empty / EmptyHeader / EmptyMedia / EmptyTitle / EmptyDescription / EmptyContent / EmptyValue — plus BuiltinSelectEmptyState and CommandEmpty elsewhere. None of them is a view-level data empty state and none is touched here, but "the only shared primitive" was not true.

Blast radius, measured before proposing it

packages/components is broad-blast, so the whole consumer graph was run, not sampled.

scoperesult
direct consumers — components, plugin-list, plugin-kanban, plugin-detail, plugin-dashboardTest Files 504 passed (504) · Tests 4780 passed (4780)
indirect — app-shell, apps/console, fields, plugin-gantt, plugin-timeline, i18n, plugin-charts, plugin-viewTest Files 1003 passed (1003) · Tests 10939 passed | 1 skipped (10940)

Zero snapshot exposure: no snapshot file contains a DataEmptyState render (control: 10 data-slot hits in snapshot.test.tsx.snap prove the grep reads).

Evidence

The pins can fail — ablation, direction predicted before running. Predicted: removing the role="status" default reddens the two DEFAULT arms and the two ListView arms that name status, and leaves the two OVERRIDE arms, the SIBLING CONTRAST arm and the ERROR arm green.

  • mutation proven on disk before the run: blob 294d845a to 9e24fccf, guard-line count 2 to 1, git diff --stat non-empty
  • result: Tests 4 failed | 4 passed (8) — exactly the four predicted arms, with the passing count asserted so a collapsed suite could not masquerade as the red
  • restore proven by state: blob back to 294d845a, guard-lines back to 2, git diff HEADandgit diff --cached both empty

The three-value arm is asserted by exact value, not by inequality: on main both roles were null, and a partial fix leaving the empty branch at null would still satisfy null !== 'alert' and pass a mere-difference assertion. Naming both values is what makes that arm able to fail.

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

gateexitverdict
vitest run (the two new pins)0Test Files 2 passed (2) · Tests 8 passed (8)
vitest run (5 direct consumer packages)0Test Files 504 passed (504) · Tests 4780 passed (4780)
vitest run (8 indirect packages)0Test Files 1003 passed (1003) · Tests 10939 passed | 1 skipped
tsc --noEmit + tsc -p tsconfig.test.json (components)0 / 0clean
tsc --noEmit + tsc -p tsconfig.test.json (plugin-list)0 / 0clean
eslint (plain form, 4 changed files)0184 problems (0 errors, 184 warnings) — all pre-existing no-explicit-any; the new pin's 2 match ListView.loadErrorKind.test.tsx's own profile exactly
check-changeset-presence04 source file(s) of 2 released package(s) changed, and this change declares 1 changeset(s)
check-changeset-fixed0All workspace packages are in the changeset fixed group.
check-changeset-no-major0No changeset declares a major bump.
check-changeset-overwrite0No pre-existing changeset was modified or deleted.
check-control-bytes0OK (scanned 5917 tracked text file(s); skipped 85 binary)
check-i18n-call-site-keys02845 keys — every in-scope call-site key resolves
check-i18n-en-drift0No en value changed in this range.
check-phantom-dependencies0Every in-scope import is declared by the package that publishes it.

⚠️typecheck clean would have been a false green on its ownpackages/components/tsconfig.json excludes src/__tests__ and **/*.test.tsx. Proven covered by the second pass instead: --listFiles puts the components pin at 1 of 1895 checked files and the plugin-list pin at 1 of 1625; both edited sources are in the src pass (1 of 1367, 1 of 1334); control — the src pass contains 0.test.tsx, confirming the two-pass split is what covers them.

Declared narrowing: repo-wide pnpm lint and the full check:* farm were not run locally; CI runs them regardless. pnpm install, the closure build (turbo run build --filter='@object-ui/plugin-list^...', 12 successful) and every run above happened in a dedicated worktree off 2b3964aff.

On PR #7139: measured on main as instructed; it is still open and based on the same 2b3964aff. It hand-types role="status" at its call site, so it is unaffected by this default whichever order the two land in — and afterwards that line becomes removable, which is the collapse its author placed it for. No file in packages/plugin-charts is touched here.

Out-of-scope findings


Generated by Claude Code

…, 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
@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.6 KB350 KB
Entry fileindex-Bd59YEPm.js
StatusPASS

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


📦 Bundle Size Report

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

Size Limits

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

@os-warrenClaude

Copy link
Copy Markdown
CollaboratorAuthor

✅ Reviewed and armed

All 30 check runs read in one call at perPage=100: 27 success, 3 skipped, 0 failed, 0 in_progress. Undrafted (a draft cannot enter the queue), auto-merge SQUASH enabled at ff43f39f7.

Why this one goes in without a round trip

Property 2 came back measured-and-DECLINED, and that is a FULL PASS. Giving DataEmptyState self-describing copy would be a copy change reaching every consumer plus an i18n project in a component with no translation hook — and every shipped surface already supplies its own copy. Recording that half as declined, with the reason, is worth more than doing it quietly. A card that converges one property and explicitly declines the other has answered the question it was asked.

A naive hoist would have been actively harmful, and only rendering found that.plugin-list renders its load failure through DataEmptyState, borrowing it for layout. With no role on either branch, a 403 and "Nothing here yet" were the same node shape — and a bare role="status" default would have made it worse, announcing an outage as a routine status update. The correct convergence is two lines, not one: the default plus role="alert" at that call site.

That is invisible to a source read. A reader of view-states.tsx sees one component missing one attribute and a one-line fix; the danger only appears when you render the surfaces and find one of them is doing the other's job.

⚠️ My briefed consumer count was wrong, and I verified the correction myself

I dispatched this saying four call sites. It is six in four packages, seven once objectui#7139 landed. Rather than accept that on report, I re-measured on main:

proberesult
<DataEmptyState sites in ListView.tsx2:3584 (load error) and :3675 (empty)
DataErrorState in ListView.tsx0 — control: DataEmptyState hits 3× in the same file, so the zero reads
role= declarations in view-states.tsx vs Data* components2 vs 3DataEmptyState alone declared none
consuming files across packages/6, including components' own ui:empty renderer, which the card never listed

The dev swept both exported names, not just the canonical one, and reported EmptyState's alias as a real zero — with the control hitting the alias line itself at view-states.tsx:199, which is what separates "no consumers" from "the query missed."

Assumptions falsified and reported rather than absorbed

  • "DataEmptyState is the only shared empty-state primitive."Falsecustom/empty.tsx carries a whole second family, plus BuiltinSelectEmptyState and CommandEmpty. None is a view-level data empty state and none was touched, but the claim was wrong and saying so is the right call.
  • "All consumers pass compatible props."False, and it is exactly why the naive default was unsafe.
  • The KPI carve-out confirmed but explicitly non-generalising: rows.length === 0 && !isMetric is a branch gate on whether an empty state renders at all, upstream of this component — a different question from what structure it has.

Evidence

Ablation predicted before running, observed exactly: Tests 4 failed | 4 passed (8), the four survivors being both override arms, the sibling-contrast arm and the error arm — which are themselves the proof that objectui#7063's and objectui#7064's ruled surfaces are inert. Mutation proven on disk by blob hash and guard-line count; restore proven by state.

The three-value arm is asserted by exact value, not by inequality — on main both roles were null, so a partial fix leaving the empty branch at null would still satisfy null !== 'alert' and pass a mere-difference check. Naming both values is what makes that arm able to fail.

typecheck clean would have been a false green on its own (packages/components/tsconfig.json excludes src/__tests__ and **/*.test.tsx); proven covered by the second pass instead, with --listFiles placing both pins in the checked set and a control showing 0.test.tsx in the src pass.

Blast radius run rather than sampled: 1507 test files across direct and indirect consumers, zero failures.

Carried into this lane and it paid

objectui#7130's dev named a gap on themselves — blast radius chosen from declared consumers when the right criterion is which fixtures answer queries with no rows. This lane ran the full graph instead of sampling.

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 d1842abSep 1, 2026
32 checks passed
@os-warren
os-warren deleted the claude/issue-7132-empty-state-convergence branch September 1, 2026 06:32
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(components,plugin-list): let DataEmptyState declare role="status", so an empty result is not shaped like a failed one - #7144

Merged
os-warren merged 1 commit into
mainfrom
claude/issue-7132-empty-state-convergence
Sep 1, 2026
Merged

fix(components,plugin-list): let DataEmptyState declare role="status", so an empty result is not shaped like a failed one#7144
os-warren merged 1 commit into
mainfrom
claude/issue-7132-empty-state-convergence

Conversation

@os-warren

Copy link
Copy Markdown
Collaborator

Fixes#7132

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

Every surface was rendered and its empty box read directly — role, aria-live, data-slot, non-blank text-node count and text — at 2b3964aff. Not read off the source; rendered.

surfaceroletextself-describingdistinguishable from a failure
DataEmptyState bare defaultnoneNo datano — terse, names nothingno
plugin-list empty listnoneNothing here yet + Create your first record…yesno
plugin-list load ERROR (403)noneYou don't have access… + Retryyesno
plugin-detailActivityTimelinenoneNo activity recordedpartlyno
ui:empty schema renderernoneauthoredauthoredno
plugin-dashboardWidgetEmptyState (#7063)statusNo data yet + explanation + Source: work_orderyesyes
plugin-kanban empty boardstatus + aria-live=politeNo cards + 2 columnsyesyes
plugin-chartsObjectChart (PR #7139)statusNo data yet + explanationyesyes

The answer: they do NOT legitimately differ

The two properties the rulings name are not in tension across these surfaces — one surface had simply never been given the platform default, and the rest had each bought it retail.

DataLoadingState has always been role="status" and DataErrorStaterole="alert". DataEmptyState, in the same file, declared nothing. So every surface that wanted the property typed it at its own call site, and three independently did — kanban, dashboard, and the in-flight ObjectChart — while plugin-list, plugin-detail's two timelines and the ui:empty renderer silently did not.

Three hand-copies of one line, paid per package, is the per-app tax objectstack#13848 rules against, and the spread was not principled divergence but the presence or absence of one attribute nobody owned.

⇒ The primitive carries property 1 now. It still does not carry property 2 and deliberately is not given it: every shipped surface already supplies its own self-describing copy, and changing the 'No data' title default would be a copy change reaching all consumers plus an i18n project in a component that currently has no translation hook. That half is measured-and-declined, recorded here rather than done quietly.

The fences, each checked rather than asserted

#7063 and #7064 are not re-litigated — neither surface's behaviour changes, structurally.

No authoring surface is widened. No new prop, no new schema key, no new i18n key. ListView already read schema.emptyState?.title / .message before this change and still does, untouched. Clause ② is not engaged, so the blocked Fable 5 quota is not in the path.

The KPI datum does not generalise, and the code says why.DatasetWidget's carve-out is if (state.rows.length === 0 && !isMetric) — a branch gate on whether an empty state is rendered at all, upstream of this component. A metric widget never reaches DataEmptyState, so a KPI still reads 0. That decision and this one are about different questions: whether to show an empty state vs what structure the empty state has. Untouched either way.

One real defect fell out of the measurement

plugin-list renders its load failure through DataEmptyState, borrowing it for layout. With no role on either branch, "You don't have access" and "Nothing here yet" were the same node shape — and a role="status" default alone would have made it worse, announcing a 403 as a routine status update. That call site now declares role="alert".

This is why naive hoisting was not safe, and it is the one thing the card's hypothesis did not anticipate. The deeper structural question — that DataErrorState sits unused next to it — is filed as #7143 rather than refactored here.

Consumer census, re-derived with controls

Both exported names were swept, not just the canonical one:

  • DataEmptyState6 call sites in 4 packages on main, not the four the card assumed: plugin-list (two — empty and error), plugin-kanban, plugin-detail (twoActivityTimeline and RecordActivityTimeline), and packages/components' own ui:empty renderer, which the card did not list at all. PR fix(plugin-charts): a self-describing empty state for ObjectChart over an empty result #7139 adds ObjectChart as a seventh.
  • EmptyState — the alias re-export has zero consumers. Real zero: the control (git grep -nw against the alias line itself) hits at view-states.tsx:199. It is public API of a published package, so this is an observation, not a defect.
  • Outside packages/*: zero code consumers — one changeset mentions the name in prose. Control: 238 files outside packages/* reference @object-ui/components, so the channel reads.
  • PM assumption falsified:DataEmptyState is not the only shared empty-state primitive in packages/components. custom/empty.tsx carries a second, independent family — Empty / EmptyHeader / EmptyMedia / EmptyTitle / EmptyDescription / EmptyContent / EmptyValue — plus BuiltinSelectEmptyState and CommandEmpty elsewhere. None of them is a view-level data empty state and none is touched here, but "the only shared primitive" was not true.

Blast radius, measured before proposing it

packages/components is broad-blast, so the whole consumer graph was run, not sampled.

scoperesult
direct consumers — components, plugin-list, plugin-kanban, plugin-detail, plugin-dashboardTest Files 504 passed (504) · Tests 4780 passed (4780)
indirect — app-shell, apps/console, fields, plugin-gantt, plugin-timeline, i18n, plugin-charts, plugin-viewTest Files 1003 passed (1003) · Tests 10939 passed | 1 skipped (10940)

Zero snapshot exposure: no snapshot file contains a DataEmptyState render (control: 10 data-slot hits in snapshot.test.tsx.snap prove the grep reads).

Evidence

The pins can fail — ablation, direction predicted before running. Predicted: removing the role="status" default reddens the two DEFAULT arms and the two ListView arms that name status, and leaves the two OVERRIDE arms, the SIBLING CONTRAST arm and the ERROR arm green.

  • mutation proven on disk before the run: blob 294d845a to 9e24fccf, guard-line count 2 to 1, git diff --stat non-empty
  • result: Tests 4 failed | 4 passed (8) — exactly the four predicted arms, with the passing count asserted so a collapsed suite could not masquerade as the red
  • restore proven by state: blob back to 294d845a, guard-lines back to 2, git diff HEADandgit diff --cached both empty

The three-value arm is asserted by exact value, not by inequality: on main both roles were null, and a partial fix leaving the empty branch at null would still satisfy null !== 'alert' and pass a mere-difference assertion. Naming both values is what makes that arm able to fail.

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

gateexitverdict
vitest run (the two new pins)0Test Files 2 passed (2) · Tests 8 passed (8)
vitest run (5 direct consumer packages)0Test Files 504 passed (504) · Tests 4780 passed (4780)
vitest run (8 indirect packages)0Test Files 1003 passed (1003) · Tests 10939 passed | 1 skipped
tsc --noEmit + tsc -p tsconfig.test.json (components)0 / 0clean
tsc --noEmit + tsc -p tsconfig.test.json (plugin-list)0 / 0clean
eslint (plain form, 4 changed files)0184 problems (0 errors, 184 warnings) — all pre-existing no-explicit-any; the new pin's 2 match ListView.loadErrorKind.test.tsx's own profile exactly
check-changeset-presence04 source file(s) of 2 released package(s) changed, and this change declares 1 changeset(s)
check-changeset-fixed0All workspace packages are in the changeset fixed group.
check-changeset-no-major0No changeset declares a major bump.
check-changeset-overwrite0No pre-existing changeset was modified or deleted.
check-control-bytes0OK (scanned 5917 tracked text file(s); skipped 85 binary)
check-i18n-call-site-keys02845 keys — every in-scope call-site key resolves
check-i18n-en-drift0No en value changed in this range.
check-phantom-dependencies0Every in-scope import is declared by the package that publishes it.

⚠️typecheck clean would have been a false green on its ownpackages/components/tsconfig.json excludes src/__tests__ and **/*.test.tsx. Proven covered by the second pass instead: --listFiles puts the components pin at 1 of 1895 checked files and the plugin-list pin at 1 of 1625; both edited sources are in the src pass (1 of 1367, 1 of 1334); control — the src pass contains 0.test.tsx, confirming the two-pass split is what covers them.

Declared narrowing: repo-wide pnpm lint and the full check:* farm were not run locally; CI runs them regardless. pnpm install, the closure build (turbo run build --filter='@object-ui/plugin-list^...', 12 successful) and every run above happened in a dedicated worktree off 2b3964aff.

On PR #7139: measured on main as instructed; it is still open and based on the same 2b3964aff. It hand-types role="status" at its call site, so it is unaffected by this default whichever order the two land in — and afterwards that line becomes removable, which is the collapse its author placed it for. No file in packages/plugin-charts is touched here.

Out-of-scope findings


Generated by Claude Code

…, 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
@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.6 KB350 KB
Entry fileindex-Bd59YEPm.js
StatusPASS

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


📦 Bundle Size Report

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

Size Limits

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

@os-warrenClaude

Copy link
Copy Markdown
CollaboratorAuthor

✅ Reviewed and armed

All 30 check runs read in one call at perPage=100: 27 success, 3 skipped, 0 failed, 0 in_progress. Undrafted (a draft cannot enter the queue), auto-merge SQUASH enabled at ff43f39f7.

Why this one goes in without a round trip

Property 2 came back measured-and-DECLINED, and that is a FULL PASS. Giving DataEmptyState self-describing copy would be a copy change reaching every consumer plus an i18n project in a component with no translation hook — and every shipped surface already supplies its own copy. Recording that half as declined, with the reason, is worth more than doing it quietly. A card that converges one property and explicitly declines the other has answered the question it was asked.

A naive hoist would have been actively harmful, and only rendering found that.plugin-list renders its load failure through DataEmptyState, borrowing it for layout. With no role on either branch, a 403 and "Nothing here yet" were the same node shape — and a bare role="status" default would have made it worse, announcing an outage as a routine status update. The correct convergence is two lines, not one: the default plus role="alert" at that call site.

That is invisible to a source read. A reader of view-states.tsx sees one component missing one attribute and a one-line fix; the danger only appears when you render the surfaces and find one of them is doing the other's job.

⚠️ My briefed consumer count was wrong, and I verified the correction myself

I dispatched this saying four call sites. It is six in four packages, seven once objectui#7139 landed. Rather than accept that on report, I re-measured on main:

proberesult
<DataEmptyState sites in ListView.tsx2:3584 (load error) and :3675 (empty)
DataErrorState in ListView.tsx0 — control: DataEmptyState hits 3× in the same file, so the zero reads
role= declarations in view-states.tsx vs Data* components2 vs 3DataEmptyState alone declared none
consuming files across packages/6, including components' own ui:empty renderer, which the card never listed

The dev swept both exported names, not just the canonical one, and reported EmptyState's alias as a real zero — with the control hitting the alias line itself at view-states.tsx:199, which is what separates "no consumers" from "the query missed."

Assumptions falsified and reported rather than absorbed

  • "DataEmptyState is the only shared empty-state primitive."Falsecustom/empty.tsx carries a whole second family, plus BuiltinSelectEmptyState and CommandEmpty. None is a view-level data empty state and none was touched, but the claim was wrong and saying so is the right call.
  • "All consumers pass compatible props."False, and it is exactly why the naive default was unsafe.
  • The KPI carve-out confirmed but explicitly non-generalising: rows.length === 0 && !isMetric is a branch gate on whether an empty state renders at all, upstream of this component — a different question from what structure it has.

Evidence

Ablation predicted before running, observed exactly: Tests 4 failed | 4 passed (8), the four survivors being both override arms, the sibling-contrast arm and the error arm — which are themselves the proof that objectui#7063's and objectui#7064's ruled surfaces are inert. Mutation proven on disk by blob hash and guard-line count; restore proven by state.

The three-value arm is asserted by exact value, not by inequality — on main both roles were null, so a partial fix leaving the empty branch at null would still satisfy null !== 'alert' and pass a mere-difference check. Naming both values is what makes that arm able to fail.

typecheck clean would have been a false green on its own (packages/components/tsconfig.json excludes src/__tests__ and **/*.test.tsx); proven covered by the second pass instead, with --listFiles placing both pins in the checked set and a control showing 0.test.tsx in the src pass.

Blast radius run rather than sampled: 1507 test files across direct and indirect consumers, zero failures.

Carried into this lane and it paid

objectui#7130's dev named a gap on themselves — blast radius chosen from declared consumers when the right criterion is which fixtures answer queries with no rows. This lane ran the full graph instead of sampling.

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 d1842abSep 1, 2026
32 checks passed
@os-warren
os-warren deleted the claude/issue-7132-empty-state-convergence branch September 1, 2026 06:32
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(components,plugin-list): let DataEmptyState declare role="status", so an empty result is not shaped like a failed one - #7144

Merged
os-warren merged 1 commit into
mainfrom
claude/issue-7132-empty-state-convergence
Sep 1, 2026
Merged

fix(components,plugin-list): let DataEmptyState declare role="status", so an empty result is not shaped like a failed one#7144
os-warren merged 1 commit into
mainfrom
claude/issue-7132-empty-state-convergence

Conversation

@os-warren

Copy link
Copy Markdown
Collaborator

Fixes#7132

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

Every surface was rendered and its empty box read directly — role, aria-live, data-slot, non-blank text-node count and text — at 2b3964aff. Not read off the source; rendered.

surfaceroletextself-describingdistinguishable from a failure
DataEmptyState bare defaultnoneNo datano — terse, names nothingno
plugin-list empty listnoneNothing here yet + Create your first record…yesno
plugin-list load ERROR (403)noneYou don't have access… + Retryyesno
plugin-detailActivityTimelinenoneNo activity recordedpartlyno
ui:empty schema renderernoneauthoredauthoredno
plugin-dashboardWidgetEmptyState (#7063)statusNo data yet + explanation + Source: work_orderyesyes
plugin-kanban empty boardstatus + aria-live=politeNo cards + 2 columnsyesyes
plugin-chartsObjectChart (PR #7139)statusNo data yet + explanationyesyes

The answer: they do NOT legitimately differ

The two properties the rulings name are not in tension across these surfaces — one surface had simply never been given the platform default, and the rest had each bought it retail.

DataLoadingState has always been role="status" and DataErrorStaterole="alert". DataEmptyState, in the same file, declared nothing. So every surface that wanted the property typed it at its own call site, and three independently did — kanban, dashboard, and the in-flight ObjectChart — while plugin-list, plugin-detail's two timelines and the ui:empty renderer silently did not.

Three hand-copies of one line, paid per package, is the per-app tax objectstack#13848 rules against, and the spread was not principled divergence but the presence or absence of one attribute nobody owned.

⇒ The primitive carries property 1 now. It still does not carry property 2 and deliberately is not given it: every shipped surface already supplies its own self-describing copy, and changing the 'No data' title default would be a copy change reaching all consumers plus an i18n project in a component that currently has no translation hook. That half is measured-and-declined, recorded here rather than done quietly.

The fences, each checked rather than asserted

#7063 and #7064 are not re-litigated — neither surface's behaviour changes, structurally.

No authoring surface is widened. No new prop, no new schema key, no new i18n key. ListView already read schema.emptyState?.title / .message before this change and still does, untouched. Clause ② is not engaged, so the blocked Fable 5 quota is not in the path.

The KPI datum does not generalise, and the code says why.DatasetWidget's carve-out is if (state.rows.length === 0 && !isMetric) — a branch gate on whether an empty state is rendered at all, upstream of this component. A metric widget never reaches DataEmptyState, so a KPI still reads 0. That decision and this one are about different questions: whether to show an empty state vs what structure the empty state has. Untouched either way.

One real defect fell out of the measurement

plugin-list renders its load failure through DataEmptyState, borrowing it for layout. With no role on either branch, "You don't have access" and "Nothing here yet" were the same node shape — and a role="status" default alone would have made it worse, announcing a 403 as a routine status update. That call site now declares role="alert".

This is why naive hoisting was not safe, and it is the one thing the card's hypothesis did not anticipate. The deeper structural question — that DataErrorState sits unused next to it — is filed as #7143 rather than refactored here.

Consumer census, re-derived with controls

Both exported names were swept, not just the canonical one:

  • DataEmptyState6 call sites in 4 packages on main, not the four the card assumed: plugin-list (two — empty and error), plugin-kanban, plugin-detail (twoActivityTimeline and RecordActivityTimeline), and packages/components' own ui:empty renderer, which the card did not list at all. PR fix(plugin-charts): a self-describing empty state for ObjectChart over an empty result #7139 adds ObjectChart as a seventh.
  • EmptyState — the alias re-export has zero consumers. Real zero: the control (git grep -nw against the alias line itself) hits at view-states.tsx:199. It is public API of a published package, so this is an observation, not a defect.
  • Outside packages/*: zero code consumers — one changeset mentions the name in prose. Control: 238 files outside packages/* reference @object-ui/components, so the channel reads.
  • PM assumption falsified:DataEmptyState is not the only shared empty-state primitive in packages/components. custom/empty.tsx carries a second, independent family — Empty / EmptyHeader / EmptyMedia / EmptyTitle / EmptyDescription / EmptyContent / EmptyValue — plus BuiltinSelectEmptyState and CommandEmpty elsewhere. None of them is a view-level data empty state and none is touched here, but "the only shared primitive" was not true.

Blast radius, measured before proposing it

packages/components is broad-blast, so the whole consumer graph was run, not sampled.

scoperesult
direct consumers — components, plugin-list, plugin-kanban, plugin-detail, plugin-dashboardTest Files 504 passed (504) · Tests 4780 passed (4780)
indirect — app-shell, apps/console, fields, plugin-gantt, plugin-timeline, i18n, plugin-charts, plugin-viewTest Files 1003 passed (1003) · Tests 10939 passed | 1 skipped (10940)

Zero snapshot exposure: no snapshot file contains a DataEmptyState render (control: 10 data-slot hits in snapshot.test.tsx.snap prove the grep reads).

Evidence

The pins can fail — ablation, direction predicted before running. Predicted: removing the role="status" default reddens the two DEFAULT arms and the two ListView arms that name status, and leaves the two OVERRIDE arms, the SIBLING CONTRAST arm and the ERROR arm green.

  • mutation proven on disk before the run: blob 294d845a to 9e24fccf, guard-line count 2 to 1, git diff --stat non-empty
  • result: Tests 4 failed | 4 passed (8) — exactly the four predicted arms, with the passing count asserted so a collapsed suite could not masquerade as the red
  • restore proven by state: blob back to 294d845a, guard-lines back to 2, git diff HEADandgit diff --cached both empty

The three-value arm is asserted by exact value, not by inequality: on main both roles were null, and a partial fix leaving the empty branch at null would still satisfy null !== 'alert' and pass a mere-difference assertion. Naming both values is what makes that arm able to fail.

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

gateexitverdict
vitest run (the two new pins)0Test Files 2 passed (2) · Tests 8 passed (8)
vitest run (5 direct consumer packages)0Test Files 504 passed (504) · Tests 4780 passed (4780)
vitest run (8 indirect packages)0Test Files 1003 passed (1003) · Tests 10939 passed | 1 skipped
tsc --noEmit + tsc -p tsconfig.test.json (components)0 / 0clean
tsc --noEmit + tsc -p tsconfig.test.json (plugin-list)0 / 0clean
eslint (plain form, 4 changed files)0184 problems (0 errors, 184 warnings) — all pre-existing no-explicit-any; the new pin's 2 match ListView.loadErrorKind.test.tsx's own profile exactly
check-changeset-presence04 source file(s) of 2 released package(s) changed, and this change declares 1 changeset(s)
check-changeset-fixed0All workspace packages are in the changeset fixed group.
check-changeset-no-major0No changeset declares a major bump.
check-changeset-overwrite0No pre-existing changeset was modified or deleted.
check-control-bytes0OK (scanned 5917 tracked text file(s); skipped 85 binary)
check-i18n-call-site-keys02845 keys — every in-scope call-site key resolves
check-i18n-en-drift0No en value changed in this range.
check-phantom-dependencies0Every in-scope import is declared by the package that publishes it.

⚠️typecheck clean would have been a false green on its ownpackages/components/tsconfig.json excludes src/__tests__ and **/*.test.tsx. Proven covered by the second pass instead: --listFiles puts the components pin at 1 of 1895 checked files and the plugin-list pin at 1 of 1625; both edited sources are in the src pass (1 of 1367, 1 of 1334); control — the src pass contains 0.test.tsx, confirming the two-pass split is what covers them.

Declared narrowing: repo-wide pnpm lint and the full check:* farm were not run locally; CI runs them regardless. pnpm install, the closure build (turbo run build --filter='@object-ui/plugin-list^...', 12 successful) and every run above happened in a dedicated worktree off 2b3964aff.

On PR #7139: measured on main as instructed; it is still open and based on the same 2b3964aff. It hand-types role="status" at its call site, so it is unaffected by this default whichever order the two land in — and afterwards that line becomes removable, which is the collapse its author placed it for. No file in packages/plugin-charts is touched here.

Out-of-scope findings


Generated by Claude Code

…, 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
@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.6 KB350 KB
Entry fileindex-Bd59YEPm.js
StatusPASS

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


📦 Bundle Size Report

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

Size Limits

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

@os-warrenClaude

Copy link
Copy Markdown
CollaboratorAuthor

✅ Reviewed and armed

All 30 check runs read in one call at perPage=100: 27 success, 3 skipped, 0 failed, 0 in_progress. Undrafted (a draft cannot enter the queue), auto-merge SQUASH enabled at ff43f39f7.

Why this one goes in without a round trip

Property 2 came back measured-and-DECLINED, and that is a FULL PASS. Giving DataEmptyState self-describing copy would be a copy change reaching every consumer plus an i18n project in a component with no translation hook — and every shipped surface already supplies its own copy. Recording that half as declined, with the reason, is worth more than doing it quietly. A card that converges one property and explicitly declines the other has answered the question it was asked.

A naive hoist would have been actively harmful, and only rendering found that.plugin-list renders its load failure through DataEmptyState, borrowing it for layout. With no role on either branch, a 403 and "Nothing here yet" were the same node shape — and a bare role="status" default would have made it worse, announcing an outage as a routine status update. The correct convergence is two lines, not one: the default plus role="alert" at that call site.

That is invisible to a source read. A reader of view-states.tsx sees one component missing one attribute and a one-line fix; the danger only appears when you render the surfaces and find one of them is doing the other's job.

⚠️ My briefed consumer count was wrong, and I verified the correction myself

I dispatched this saying four call sites. It is six in four packages, seven once objectui#7139 landed. Rather than accept that on report, I re-measured on main:

proberesult
<DataEmptyState sites in ListView.tsx2:3584 (load error) and :3675 (empty)
DataErrorState in ListView.tsx0 — control: DataEmptyState hits 3× in the same file, so the zero reads
role= declarations in view-states.tsx vs Data* components2 vs 3DataEmptyState alone declared none
consuming files across packages/6, including components' own ui:empty renderer, which the card never listed

The dev swept both exported names, not just the canonical one, and reported EmptyState's alias as a real zero — with the control hitting the alias line itself at view-states.tsx:199, which is what separates "no consumers" from "the query missed."

Assumptions falsified and reported rather than absorbed

  • "DataEmptyState is the only shared empty-state primitive."Falsecustom/empty.tsx carries a whole second family, plus BuiltinSelectEmptyState and CommandEmpty. None is a view-level data empty state and none was touched, but the claim was wrong and saying so is the right call.
  • "All consumers pass compatible props."False, and it is exactly why the naive default was unsafe.
  • The KPI carve-out confirmed but explicitly non-generalising: rows.length === 0 && !isMetric is a branch gate on whether an empty state renders at all, upstream of this component — a different question from what structure it has.

Evidence

Ablation predicted before running, observed exactly: Tests 4 failed | 4 passed (8), the four survivors being both override arms, the sibling-contrast arm and the error arm — which are themselves the proof that objectui#7063's and objectui#7064's ruled surfaces are inert. Mutation proven on disk by blob hash and guard-line count; restore proven by state.

The three-value arm is asserted by exact value, not by inequality — on main both roles were null, so a partial fix leaving the empty branch at null would still satisfy null !== 'alert' and pass a mere-difference check. Naming both values is what makes that arm able to fail.

typecheck clean would have been a false green on its own (packages/components/tsconfig.json excludes src/__tests__ and **/*.test.tsx); proven covered by the second pass instead, with --listFiles placing both pins in the checked set and a control showing 0.test.tsx in the src pass.

Blast radius run rather than sampled: 1507 test files across direct and indirect consumers, zero failures.

Carried into this lane and it paid

objectui#7130's dev named a gap on themselves — blast radius chosen from declared consumers when the right criterion is which fixtures answer queries with no rows. This lane ran the full graph instead of sampling.

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 d1842abSep 1, 2026
32 checks passed
@os-warren
os-warren deleted the claude/issue-7132-empty-state-convergence branch September 1, 2026 06:32
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(components,plugin-list): let DataEmptyState declare role="status", so an empty result is not shaped like a failed one - #7144

Merged
os-warren merged 1 commit into
mainfrom
claude/issue-7132-empty-state-convergence
Sep 1, 2026
Merged

fix(components,plugin-list): let DataEmptyState declare role="status", so an empty result is not shaped like a failed one#7144
os-warren merged 1 commit into
mainfrom
claude/issue-7132-empty-state-convergence

Conversation

@os-warren

Copy link
Copy Markdown
Collaborator

Fixes#7132

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

Every surface was rendered and its empty box read directly — role, aria-live, data-slot, non-blank text-node count and text — at 2b3964aff. Not read off the source; rendered.

surfaceroletextself-describingdistinguishable from a failure
DataEmptyState bare defaultnoneNo datano — terse, names nothingno
plugin-list empty listnoneNothing here yet + Create your first record…yesno
plugin-list load ERROR (403)noneYou don't have access… + Retryyesno
plugin-detailActivityTimelinenoneNo activity recordedpartlyno
ui:empty schema renderernoneauthoredauthoredno
plugin-dashboardWidgetEmptyState (#7063)statusNo data yet + explanation + Source: work_orderyesyes
plugin-kanban empty boardstatus + aria-live=politeNo cards + 2 columnsyesyes
plugin-chartsObjectChart (PR #7139)statusNo data yet + explanationyesyes

The answer: they do NOT legitimately differ

The two properties the rulings name are not in tension across these surfaces — one surface had simply never been given the platform default, and the rest had each bought it retail.

DataLoadingState has always been role="status" and DataErrorStaterole="alert". DataEmptyState, in the same file, declared nothing. So every surface that wanted the property typed it at its own call site, and three independently did — kanban, dashboard, and the in-flight ObjectChart — while plugin-list, plugin-detail's two timelines and the ui:empty renderer silently did not.

Three hand-copies of one line, paid per package, is the per-app tax objectstack#13848 rules against, and the spread was not principled divergence but the presence or absence of one attribute nobody owned.

⇒ The primitive carries property 1 now. It still does not carry property 2 and deliberately is not given it: every shipped surface already supplies its own self-describing copy, and changing the 'No data' title default would be a copy change reaching all consumers plus an i18n project in a component that currently has no translation hook. That half is measured-and-declined, recorded here rather than done quietly.

The fences, each checked rather than asserted

#7063 and #7064 are not re-litigated — neither surface's behaviour changes, structurally.

No authoring surface is widened. No new prop, no new schema key, no new i18n key. ListView already read schema.emptyState?.title / .message before this change and still does, untouched. Clause ② is not engaged, so the blocked Fable 5 quota is not in the path.

The KPI datum does not generalise, and the code says why.DatasetWidget's carve-out is if (state.rows.length === 0 && !isMetric) — a branch gate on whether an empty state is rendered at all, upstream of this component. A metric widget never reaches DataEmptyState, so a KPI still reads 0. That decision and this one are about different questions: whether to show an empty state vs what structure the empty state has. Untouched either way.

One real defect fell out of the measurement

plugin-list renders its load failure through DataEmptyState, borrowing it for layout. With no role on either branch, "You don't have access" and "Nothing here yet" were the same node shape — and a role="status" default alone would have made it worse, announcing a 403 as a routine status update. That call site now declares role="alert".

This is why naive hoisting was not safe, and it is the one thing the card's hypothesis did not anticipate. The deeper structural question — that DataErrorState sits unused next to it — is filed as #7143 rather than refactored here.

Consumer census, re-derived with controls

Both exported names were swept, not just the canonical one:

  • DataEmptyState6 call sites in 4 packages on main, not the four the card assumed: plugin-list (two — empty and error), plugin-kanban, plugin-detail (twoActivityTimeline and RecordActivityTimeline), and packages/components' own ui:empty renderer, which the card did not list at all. PR fix(plugin-charts): a self-describing empty state for ObjectChart over an empty result #7139 adds ObjectChart as a seventh.
  • EmptyState — the alias re-export has zero consumers. Real zero: the control (git grep -nw against the alias line itself) hits at view-states.tsx:199. It is public API of a published package, so this is an observation, not a defect.
  • Outside packages/*: zero code consumers — one changeset mentions the name in prose. Control: 238 files outside packages/* reference @object-ui/components, so the channel reads.
  • PM assumption falsified:DataEmptyState is not the only shared empty-state primitive in packages/components. custom/empty.tsx carries a second, independent family — Empty / EmptyHeader / EmptyMedia / EmptyTitle / EmptyDescription / EmptyContent / EmptyValue — plus BuiltinSelectEmptyState and CommandEmpty elsewhere. None of them is a view-level data empty state and none is touched here, but "the only shared primitive" was not true.

Blast radius, measured before proposing it

packages/components is broad-blast, so the whole consumer graph was run, not sampled.

scoperesult
direct consumers — components, plugin-list, plugin-kanban, plugin-detail, plugin-dashboardTest Files 504 passed (504) · Tests 4780 passed (4780)
indirect — app-shell, apps/console, fields, plugin-gantt, plugin-timeline, i18n, plugin-charts, plugin-viewTest Files 1003 passed (1003) · Tests 10939 passed | 1 skipped (10940)

Zero snapshot exposure: no snapshot file contains a DataEmptyState render (control: 10 data-slot hits in snapshot.test.tsx.snap prove the grep reads).

Evidence

The pins can fail — ablation, direction predicted before running. Predicted: removing the role="status" default reddens the two DEFAULT arms and the two ListView arms that name status, and leaves the two OVERRIDE arms, the SIBLING CONTRAST arm and the ERROR arm green.

  • mutation proven on disk before the run: blob 294d845a to 9e24fccf, guard-line count 2 to 1, git diff --stat non-empty
  • result: Tests 4 failed | 4 passed (8) — exactly the four predicted arms, with the passing count asserted so a collapsed suite could not masquerade as the red
  • restore proven by state: blob back to 294d845a, guard-lines back to 2, git diff HEADandgit diff --cached both empty

The three-value arm is asserted by exact value, not by inequality: on main both roles were null, and a partial fix leaving the empty branch at null would still satisfy null !== 'alert' and pass a mere-difference assertion. Naming both values is what makes that arm able to fail.

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

gateexitverdict
vitest run (the two new pins)0Test Files 2 passed (2) · Tests 8 passed (8)
vitest run (5 direct consumer packages)0Test Files 504 passed (504) · Tests 4780 passed (4780)
vitest run (8 indirect packages)0Test Files 1003 passed (1003) · Tests 10939 passed | 1 skipped
tsc --noEmit + tsc -p tsconfig.test.json (components)0 / 0clean
tsc --noEmit + tsc -p tsconfig.test.json (plugin-list)0 / 0clean
eslint (plain form, 4 changed files)0184 problems (0 errors, 184 warnings) — all pre-existing no-explicit-any; the new pin's 2 match ListView.loadErrorKind.test.tsx's own profile exactly
check-changeset-presence04 source file(s) of 2 released package(s) changed, and this change declares 1 changeset(s)
check-changeset-fixed0All workspace packages are in the changeset fixed group.
check-changeset-no-major0No changeset declares a major bump.
check-changeset-overwrite0No pre-existing changeset was modified or deleted.
check-control-bytes0OK (scanned 5917 tracked text file(s); skipped 85 binary)
check-i18n-call-site-keys02845 keys — every in-scope call-site key resolves
check-i18n-en-drift0No en value changed in this range.
check-phantom-dependencies0Every in-scope import is declared by the package that publishes it.

⚠️typecheck clean would have been a false green on its ownpackages/components/tsconfig.json excludes src/__tests__ and **/*.test.tsx. Proven covered by the second pass instead: --listFiles puts the components pin at 1 of 1895 checked files and the plugin-list pin at 1 of 1625; both edited sources are in the src pass (1 of 1367, 1 of 1334); control — the src pass contains 0.test.tsx, confirming the two-pass split is what covers them.

Declared narrowing: repo-wide pnpm lint and the full check:* farm were not run locally; CI runs them regardless. pnpm install, the closure build (turbo run build --filter='@object-ui/plugin-list^...', 12 successful) and every run above happened in a dedicated worktree off 2b3964aff.

On PR #7139: measured on main as instructed; it is still open and based on the same 2b3964aff. It hand-types role="status" at its call site, so it is unaffected by this default whichever order the two land in — and afterwards that line becomes removable, which is the collapse its author placed it for. No file in packages/plugin-charts is touched here.

Out-of-scope findings


Generated by Claude Code

…, 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
@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.6 KB350 KB
Entry fileindex-Bd59YEPm.js
StatusPASS

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


📦 Bundle Size Report

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

Size Limits

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

@os-warrenClaude

Copy link
Copy Markdown
CollaboratorAuthor

✅ Reviewed and armed

All 30 check runs read in one call at perPage=100: 27 success, 3 skipped, 0 failed, 0 in_progress. Undrafted (a draft cannot enter the queue), auto-merge SQUASH enabled at ff43f39f7.

Why this one goes in without a round trip

Property 2 came back measured-and-DECLINED, and that is a FULL PASS. Giving DataEmptyState self-describing copy would be a copy change reaching every consumer plus an i18n project in a component with no translation hook — and every shipped surface already supplies its own copy. Recording that half as declined, with the reason, is worth more than doing it quietly. A card that converges one property and explicitly declines the other has answered the question it was asked.

A naive hoist would have been actively harmful, and only rendering found that.plugin-list renders its load failure through DataEmptyState, borrowing it for layout. With no role on either branch, a 403 and "Nothing here yet" were the same node shape — and a bare role="status" default would have made it worse, announcing an outage as a routine status update. The correct convergence is two lines, not one: the default plus role="alert" at that call site.

That is invisible to a source read. A reader of view-states.tsx sees one component missing one attribute and a one-line fix; the danger only appears when you render the surfaces and find one of them is doing the other's job.

⚠️ My briefed consumer count was wrong, and I verified the correction myself

I dispatched this saying four call sites. It is six in four packages, seven once objectui#7139 landed. Rather than accept that on report, I re-measured on main:

proberesult
<DataEmptyState sites in ListView.tsx2:3584 (load error) and :3675 (empty)
DataErrorState in ListView.tsx0 — control: DataEmptyState hits 3× in the same file, so the zero reads
role= declarations in view-states.tsx vs Data* components2 vs 3DataEmptyState alone declared none
consuming files across packages/6, including components' own ui:empty renderer, which the card never listed

The dev swept both exported names, not just the canonical one, and reported EmptyState's alias as a real zero — with the control hitting the alias line itself at view-states.tsx:199, which is what separates "no consumers" from "the query missed."

Assumptions falsified and reported rather than absorbed

  • "DataEmptyState is the only shared empty-state primitive."Falsecustom/empty.tsx carries a whole second family, plus BuiltinSelectEmptyState and CommandEmpty. None is a view-level data empty state and none was touched, but the claim was wrong and saying so is the right call.
  • "All consumers pass compatible props."False, and it is exactly why the naive default was unsafe.
  • The KPI carve-out confirmed but explicitly non-generalising: rows.length === 0 && !isMetric is a branch gate on whether an empty state renders at all, upstream of this component — a different question from what structure it has.

Evidence

Ablation predicted before running, observed exactly: Tests 4 failed | 4 passed (8), the four survivors being both override arms, the sibling-contrast arm and the error arm — which are themselves the proof that objectui#7063's and objectui#7064's ruled surfaces are inert. Mutation proven on disk by blob hash and guard-line count; restore proven by state.

The three-value arm is asserted by exact value, not by inequality — on main both roles were null, so a partial fix leaving the empty branch at null would still satisfy null !== 'alert' and pass a mere-difference check. Naming both values is what makes that arm able to fail.

typecheck clean would have been a false green on its own (packages/components/tsconfig.json excludes src/__tests__ and **/*.test.tsx); proven covered by the second pass instead, with --listFiles placing both pins in the checked set and a control showing 0.test.tsx in the src pass.

Blast radius run rather than sampled: 1507 test files across direct and indirect consumers, zero failures.

Carried into this lane and it paid

objectui#7130's dev named a gap on themselves — blast radius chosen from declared consumers when the right criterion is which fixtures answer queries with no rows. This lane ran the full graph instead of sampling.

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 d1842abSep 1, 2026
32 checks passed
@os-warren
os-warren deleted the claude/issue-7132-empty-state-convergence branch September 1, 2026 06:32
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(components,plugin-list): let DataEmptyState declare role="status", so an empty result is not shaped like a failed one - #7144

Merged
os-warren merged 1 commit into
mainfrom
claude/issue-7132-empty-state-convergence
Sep 1, 2026
Merged

fix(components,plugin-list): let DataEmptyState declare role="status", so an empty result is not shaped like a failed one#7144
os-warren merged 1 commit into
mainfrom
claude/issue-7132-empty-state-convergence

Conversation

@os-warren

Copy link
Copy Markdown
Collaborator

Fixes#7132

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

Every surface was rendered and its empty box read directly — role, aria-live, data-slot, non-blank text-node count and text — at 2b3964aff. Not read off the source; rendered.

surfaceroletextself-describingdistinguishable from a failure
DataEmptyState bare defaultnoneNo datano — terse, names nothingno
plugin-list empty listnoneNothing here yet + Create your first record…yesno
plugin-list load ERROR (403)noneYou don't have access… + Retryyesno
plugin-detailActivityTimelinenoneNo activity recordedpartlyno
ui:empty schema renderernoneauthoredauthoredno
plugin-dashboardWidgetEmptyState (#7063)statusNo data yet + explanation + Source: work_orderyesyes
plugin-kanban empty boardstatus + aria-live=politeNo cards + 2 columnsyesyes
plugin-chartsObjectChart (PR #7139)statusNo data yet + explanationyesyes

The answer: they do NOT legitimately differ

The two properties the rulings name are not in tension across these surfaces — one surface had simply never been given the platform default, and the rest had each bought it retail.

DataLoadingState has always been role="status" and DataErrorStaterole="alert". DataEmptyState, in the same file, declared nothing. So every surface that wanted the property typed it at its own call site, and three independently did — kanban, dashboard, and the in-flight ObjectChart — while plugin-list, plugin-detail's two timelines and the ui:empty renderer silently did not.

Three hand-copies of one line, paid per package, is the per-app tax objectstack#13848 rules against, and the spread was not principled divergence but the presence or absence of one attribute nobody owned.

⇒ The primitive carries property 1 now. It still does not carry property 2 and deliberately is not given it: every shipped surface already supplies its own self-describing copy, and changing the 'No data' title default would be a copy change reaching all consumers plus an i18n project in a component that currently has no translation hook. That half is measured-and-declined, recorded here rather than done quietly.

The fences, each checked rather than asserted

#7063 and #7064 are not re-litigated — neither surface's behaviour changes, structurally.

No authoring surface is widened. No new prop, no new schema key, no new i18n key. ListView already read schema.emptyState?.title / .message before this change and still does, untouched. Clause ② is not engaged, so the blocked Fable 5 quota is not in the path.

The KPI datum does not generalise, and the code says why.DatasetWidget's carve-out is if (state.rows.length === 0 && !isMetric) — a branch gate on whether an empty state is rendered at all, upstream of this component. A metric widget never reaches DataEmptyState, so a KPI still reads 0. That decision and this one are about different questions: whether to show an empty state vs what structure the empty state has. Untouched either way.

One real defect fell out of the measurement

plugin-list renders its load failure through DataEmptyState, borrowing it for layout. With no role on either branch, "You don't have access" and "Nothing here yet" were the same node shape — and a role="status" default alone would have made it worse, announcing a 403 as a routine status update. That call site now declares role="alert".

This is why naive hoisting was not safe, and it is the one thing the card's hypothesis did not anticipate. The deeper structural question — that DataErrorState sits unused next to it — is filed as #7143 rather than refactored here.

Consumer census, re-derived with controls

Both exported names were swept, not just the canonical one:

  • DataEmptyState6 call sites in 4 packages on main, not the four the card assumed: plugin-list (two — empty and error), plugin-kanban, plugin-detail (twoActivityTimeline and RecordActivityTimeline), and packages/components' own ui:empty renderer, which the card did not list at all. PR fix(plugin-charts): a self-describing empty state for ObjectChart over an empty result #7139 adds ObjectChart as a seventh.
  • EmptyState — the alias re-export has zero consumers. Real zero: the control (git grep -nw against the alias line itself) hits at view-states.tsx:199. It is public API of a published package, so this is an observation, not a defect.
  • Outside packages/*: zero code consumers — one changeset mentions the name in prose. Control: 238 files outside packages/* reference @object-ui/components, so the channel reads.
  • PM assumption falsified:DataEmptyState is not the only shared empty-state primitive in packages/components. custom/empty.tsx carries a second, independent family — Empty / EmptyHeader / EmptyMedia / EmptyTitle / EmptyDescription / EmptyContent / EmptyValue — plus BuiltinSelectEmptyState and CommandEmpty elsewhere. None of them is a view-level data empty state and none is touched here, but "the only shared primitive" was not true.

Blast radius, measured before proposing it

packages/components is broad-blast, so the whole consumer graph was run, not sampled.

scoperesult
direct consumers — components, plugin-list, plugin-kanban, plugin-detail, plugin-dashboardTest Files 504 passed (504) · Tests 4780 passed (4780)
indirect — app-shell, apps/console, fields, plugin-gantt, plugin-timeline, i18n, plugin-charts, plugin-viewTest Files 1003 passed (1003) · Tests 10939 passed | 1 skipped (10940)

Zero snapshot exposure: no snapshot file contains a DataEmptyState render (control: 10 data-slot hits in snapshot.test.tsx.snap prove the grep reads).

Evidence

The pins can fail — ablation, direction predicted before running. Predicted: removing the role="status" default reddens the two DEFAULT arms and the two ListView arms that name status, and leaves the two OVERRIDE arms, the SIBLING CONTRAST arm and the ERROR arm green.

  • mutation proven on disk before the run: blob 294d845a to 9e24fccf, guard-line count 2 to 1, git diff --stat non-empty
  • result: Tests 4 failed | 4 passed (8) — exactly the four predicted arms, with the passing count asserted so a collapsed suite could not masquerade as the red
  • restore proven by state: blob back to 294d845a, guard-lines back to 2, git diff HEADandgit diff --cached both empty

The three-value arm is asserted by exact value, not by inequality: on main both roles were null, and a partial fix leaving the empty branch at null would still satisfy null !== 'alert' and pass a mere-difference assertion. Naming both values is what makes that arm able to fail.

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

gateexitverdict
vitest run (the two new pins)0Test Files 2 passed (2) · Tests 8 passed (8)
vitest run (5 direct consumer packages)0Test Files 504 passed (504) · Tests 4780 passed (4780)
vitest run (8 indirect packages)0Test Files 1003 passed (1003) · Tests 10939 passed | 1 skipped
tsc --noEmit + tsc -p tsconfig.test.json (components)0 / 0clean
tsc --noEmit + tsc -p tsconfig.test.json (plugin-list)0 / 0clean
eslint (plain form, 4 changed files)0184 problems (0 errors, 184 warnings) — all pre-existing no-explicit-any; the new pin's 2 match ListView.loadErrorKind.test.tsx's own profile exactly
check-changeset-presence04 source file(s) of 2 released package(s) changed, and this change declares 1 changeset(s)
check-changeset-fixed0All workspace packages are in the changeset fixed group.
check-changeset-no-major0No changeset declares a major bump.
check-changeset-overwrite0No pre-existing changeset was modified or deleted.
check-control-bytes0OK (scanned 5917 tracked text file(s); skipped 85 binary)
check-i18n-call-site-keys02845 keys — every in-scope call-site key resolves
check-i18n-en-drift0No en value changed in this range.
check-phantom-dependencies0Every in-scope import is declared by the package that publishes it.

⚠️typecheck clean would have been a false green on its ownpackages/components/tsconfig.json excludes src/__tests__ and **/*.test.tsx. Proven covered by the second pass instead: --listFiles puts the components pin at 1 of 1895 checked files and the plugin-list pin at 1 of 1625; both edited sources are in the src pass (1 of 1367, 1 of 1334); control — the src pass contains 0.test.tsx, confirming the two-pass split is what covers them.

Declared narrowing: repo-wide pnpm lint and the full check:* farm were not run locally; CI runs them regardless. pnpm install, the closure build (turbo run build --filter='@object-ui/plugin-list^...', 12 successful) and every run above happened in a dedicated worktree off 2b3964aff.

On PR #7139: measured on main as instructed; it is still open and based on the same 2b3964aff. It hand-types role="status" at its call site, so it is unaffected by this default whichever order the two land in — and afterwards that line becomes removable, which is the collapse its author placed it for. No file in packages/plugin-charts is touched here.

Out-of-scope findings


Generated by Claude Code

…, 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
@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.6 KB350 KB
Entry fileindex-Bd59YEPm.js
StatusPASS

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


📦 Bundle Size Report

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

Size Limits

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

@os-warrenClaude

Copy link
Copy Markdown
CollaboratorAuthor

✅ Reviewed and armed

All 30 check runs read in one call at perPage=100: 27 success, 3 skipped, 0 failed, 0 in_progress. Undrafted (a draft cannot enter the queue), auto-merge SQUASH enabled at ff43f39f7.

Why this one goes in without a round trip

Property 2 came back measured-and-DECLINED, and that is a FULL PASS. Giving DataEmptyState self-describing copy would be a copy change reaching every consumer plus an i18n project in a component with no translation hook — and every shipped surface already supplies its own copy. Recording that half as declined, with the reason, is worth more than doing it quietly. A card that converges one property and explicitly declines the other has answered the question it was asked.

A naive hoist would have been actively harmful, and only rendering found that.plugin-list renders its load failure through DataEmptyState, borrowing it for layout. With no role on either branch, a 403 and "Nothing here yet" were the same node shape — and a bare role="status" default would have made it worse, announcing an outage as a routine status update. The correct convergence is two lines, not one: the default plus role="alert" at that call site.

That is invisible to a source read. A reader of view-states.tsx sees one component missing one attribute and a one-line fix; the danger only appears when you render the surfaces and find one of them is doing the other's job.

⚠️ My briefed consumer count was wrong, and I verified the correction myself

I dispatched this saying four call sites. It is six in four packages, seven once objectui#7139 landed. Rather than accept that on report, I re-measured on main:

proberesult
<DataEmptyState sites in ListView.tsx2:3584 (load error) and :3675 (empty)
DataErrorState in ListView.tsx0 — control: DataEmptyState hits 3× in the same file, so the zero reads
role= declarations in view-states.tsx vs Data* components2 vs 3DataEmptyState alone declared none
consuming files across packages/6, including components' own ui:empty renderer, which the card never listed

The dev swept both exported names, not just the canonical one, and reported EmptyState's alias as a real zero — with the control hitting the alias line itself at view-states.tsx:199, which is what separates "no consumers" from "the query missed."

Assumptions falsified and reported rather than absorbed

  • "DataEmptyState is the only shared empty-state primitive."Falsecustom/empty.tsx carries a whole second family, plus BuiltinSelectEmptyState and CommandEmpty. None is a view-level data empty state and none was touched, but the claim was wrong and saying so is the right call.
  • "All consumers pass compatible props."False, and it is exactly why the naive default was unsafe.
  • The KPI carve-out confirmed but explicitly non-generalising: rows.length === 0 && !isMetric is a branch gate on whether an empty state renders at all, upstream of this component — a different question from what structure it has.

Evidence

Ablation predicted before running, observed exactly: Tests 4 failed | 4 passed (8), the four survivors being both override arms, the sibling-contrast arm and the error arm — which are themselves the proof that objectui#7063's and objectui#7064's ruled surfaces are inert. Mutation proven on disk by blob hash and guard-line count; restore proven by state.

The three-value arm is asserted by exact value, not by inequality — on main both roles were null, so a partial fix leaving the empty branch at null would still satisfy null !== 'alert' and pass a mere-difference check. Naming both values is what makes that arm able to fail.

typecheck clean would have been a false green on its own (packages/components/tsconfig.json excludes src/__tests__ and **/*.test.tsx); proven covered by the second pass instead, with --listFiles placing both pins in the checked set and a control showing 0.test.tsx in the src pass.

Blast radius run rather than sampled: 1507 test files across direct and indirect consumers, zero failures.

Carried into this lane and it paid

objectui#7130's dev named a gap on themselves — blast radius chosen from declared consumers when the right criterion is which fixtures answer queries with no rows. This lane ran the full graph instead of sampling.

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 d1842abSep 1, 2026
32 checks passed
@os-warren
os-warren deleted the claude/issue-7132-empty-state-convergence branch September 1, 2026 06:32
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(components,plugin-list): let DataEmptyState declare role="status", so an empty result is not shaped like a failed one - #7144

Merged
os-warren merged 1 commit into
mainfrom
claude/issue-7132-empty-state-convergence
Sep 1, 2026
Merged

fix(components,plugin-list): let DataEmptyState declare role="status", so an empty result is not shaped like a failed one#7144
os-warren merged 1 commit into
mainfrom
claude/issue-7132-empty-state-convergence

Conversation

@os-warren

Copy link
Copy Markdown
Collaborator

Fixes#7132

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

Every surface was rendered and its empty box read directly — role, aria-live, data-slot, non-blank text-node count and text — at 2b3964aff. Not read off the source; rendered.

surfaceroletextself-describingdistinguishable from a failure
DataEmptyState bare defaultnoneNo datano — terse, names nothingno
plugin-list empty listnoneNothing here yet + Create your first record…yesno
plugin-list load ERROR (403)noneYou don't have access… + Retryyesno
plugin-detailActivityTimelinenoneNo activity recordedpartlyno
ui:empty schema renderernoneauthoredauthoredno
plugin-dashboardWidgetEmptyState (#7063)statusNo data yet + explanation + Source: work_orderyesyes
plugin-kanban empty boardstatus + aria-live=politeNo cards + 2 columnsyesyes
plugin-chartsObjectChart (PR #7139)statusNo data yet + explanationyesyes

The answer: they do NOT legitimately differ

The two properties the rulings name are not in tension across these surfaces — one surface had simply never been given the platform default, and the rest had each bought it retail.

DataLoadingState has always been role="status" and DataErrorStaterole="alert". DataEmptyState, in the same file, declared nothing. So every surface that wanted the property typed it at its own call site, and three independently did — kanban, dashboard, and the in-flight ObjectChart — while plugin-list, plugin-detail's two timelines and the ui:empty renderer silently did not.

Three hand-copies of one line, paid per package, is the per-app tax objectstack#13848 rules against, and the spread was not principled divergence but the presence or absence of one attribute nobody owned.

⇒ The primitive carries property 1 now. It still does not carry property 2 and deliberately is not given it: every shipped surface already supplies its own self-describing copy, and changing the 'No data' title default would be a copy change reaching all consumers plus an i18n project in a component that currently has no translation hook. That half is measured-and-declined, recorded here rather than done quietly.

The fences, each checked rather than asserted

#7063 and #7064 are not re-litigated — neither surface's behaviour changes, structurally.

No authoring surface is widened. No new prop, no new schema key, no new i18n key. ListView already read schema.emptyState?.title / .message before this change and still does, untouched. Clause ② is not engaged, so the blocked Fable 5 quota is not in the path.

The KPI datum does not generalise, and the code says why.DatasetWidget's carve-out is if (state.rows.length === 0 && !isMetric) — a branch gate on whether an empty state is rendered at all, upstream of this component. A metric widget never reaches DataEmptyState, so a KPI still reads 0. That decision and this one are about different questions: whether to show an empty state vs what structure the empty state has. Untouched either way.

One real defect fell out of the measurement

plugin-list renders its load failure through DataEmptyState, borrowing it for layout. With no role on either branch, "You don't have access" and "Nothing here yet" were the same node shape — and a role="status" default alone would have made it worse, announcing a 403 as a routine status update. That call site now declares role="alert".

This is why naive hoisting was not safe, and it is the one thing the card's hypothesis did not anticipate. The deeper structural question — that DataErrorState sits unused next to it — is filed as #7143 rather than refactored here.

Consumer census, re-derived with controls

Both exported names were swept, not just the canonical one:

  • DataEmptyState6 call sites in 4 packages on main, not the four the card assumed: plugin-list (two — empty and error), plugin-kanban, plugin-detail (twoActivityTimeline and RecordActivityTimeline), and packages/components' own ui:empty renderer, which the card did not list at all. PR fix(plugin-charts): a self-describing empty state for ObjectChart over an empty result #7139 adds ObjectChart as a seventh.
  • EmptyState — the alias re-export has zero consumers. Real zero: the control (git grep -nw against the alias line itself) hits at view-states.tsx:199. It is public API of a published package, so this is an observation, not a defect.
  • Outside packages/*: zero code consumers — one changeset mentions the name in prose. Control: 238 files outside packages/* reference @object-ui/components, so the channel reads.
  • PM assumption falsified:DataEmptyState is not the only shared empty-state primitive in packages/components. custom/empty.tsx carries a second, independent family — Empty / EmptyHeader / EmptyMedia / EmptyTitle / EmptyDescription / EmptyContent / EmptyValue — plus BuiltinSelectEmptyState and CommandEmpty elsewhere. None of them is a view-level data empty state and none is touched here, but "the only shared primitive" was not true.

Blast radius, measured before proposing it

packages/components is broad-blast, so the whole consumer graph was run, not sampled.

scoperesult
direct consumers — components, plugin-list, plugin-kanban, plugin-detail, plugin-dashboardTest Files 504 passed (504) · Tests 4780 passed (4780)
indirect — app-shell, apps/console, fields, plugin-gantt, plugin-timeline, i18n, plugin-charts, plugin-viewTest Files 1003 passed (1003) · Tests 10939 passed | 1 skipped (10940)

Zero snapshot exposure: no snapshot file contains a DataEmptyState render (control: 10 data-slot hits in snapshot.test.tsx.snap prove the grep reads).

Evidence

The pins can fail — ablation, direction predicted before running. Predicted: removing the role="status" default reddens the two DEFAULT arms and the two ListView arms that name status, and leaves the two OVERRIDE arms, the SIBLING CONTRAST arm and the ERROR arm green.

  • mutation proven on disk before the run: blob 294d845a to 9e24fccf, guard-line count 2 to 1, git diff --stat non-empty
  • result: Tests 4 failed | 4 passed (8) — exactly the four predicted arms, with the passing count asserted so a collapsed suite could not masquerade as the red
  • restore proven by state: blob back to 294d845a, guard-lines back to 2, git diff HEADandgit diff --cached both empty

The three-value arm is asserted by exact value, not by inequality: on main both roles were null, and a partial fix leaving the empty branch at null would still satisfy null !== 'alert' and pass a mere-difference assertion. Naming both values is what makes that arm able to fail.

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

gateexitverdict
vitest run (the two new pins)0Test Files 2 passed (2) · Tests 8 passed (8)
vitest run (5 direct consumer packages)0Test Files 504 passed (504) · Tests 4780 passed (4780)
vitest run (8 indirect packages)0Test Files 1003 passed (1003) · Tests 10939 passed | 1 skipped
tsc --noEmit + tsc -p tsconfig.test.json (components)0 / 0clean
tsc --noEmit + tsc -p tsconfig.test.json (plugin-list)0 / 0clean
eslint (plain form, 4 changed files)0184 problems (0 errors, 184 warnings) — all pre-existing no-explicit-any; the new pin's 2 match ListView.loadErrorKind.test.tsx's own profile exactly
check-changeset-presence04 source file(s) of 2 released package(s) changed, and this change declares 1 changeset(s)
check-changeset-fixed0All workspace packages are in the changeset fixed group.
check-changeset-no-major0No changeset declares a major bump.
check-changeset-overwrite0No pre-existing changeset was modified or deleted.
check-control-bytes0OK (scanned 5917 tracked text file(s); skipped 85 binary)
check-i18n-call-site-keys02845 keys — every in-scope call-site key resolves
check-i18n-en-drift0No en value changed in this range.
check-phantom-dependencies0Every in-scope import is declared by the package that publishes it.

⚠️typecheck clean would have been a false green on its ownpackages/components/tsconfig.json excludes src/__tests__ and **/*.test.tsx. Proven covered by the second pass instead: --listFiles puts the components pin at 1 of 1895 checked files and the plugin-list pin at 1 of 1625; both edited sources are in the src pass (1 of 1367, 1 of 1334); control — the src pass contains 0.test.tsx, confirming the two-pass split is what covers them.

Declared narrowing: repo-wide pnpm lint and the full check:* farm were not run locally; CI runs them regardless. pnpm install, the closure build (turbo run build --filter='@object-ui/plugin-list^...', 12 successful) and every run above happened in a dedicated worktree off 2b3964aff.

On PR #7139: measured on main as instructed; it is still open and based on the same 2b3964aff. It hand-types role="status" at its call site, so it is unaffected by this default whichever order the two land in — and afterwards that line becomes removable, which is the collapse its author placed it for. No file in packages/plugin-charts is touched here.

Out-of-scope findings


Generated by Claude Code

…, 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
@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.6 KB350 KB
Entry fileindex-Bd59YEPm.js
StatusPASS

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


📦 Bundle Size Report

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

Size Limits

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

@os-warrenClaude

Copy link
Copy Markdown
CollaboratorAuthor

✅ Reviewed and armed

All 30 check runs read in one call at perPage=100: 27 success, 3 skipped, 0 failed, 0 in_progress. Undrafted (a draft cannot enter the queue), auto-merge SQUASH enabled at ff43f39f7.

Why this one goes in without a round trip

Property 2 came back measured-and-DECLINED, and that is a FULL PASS. Giving DataEmptyState self-describing copy would be a copy change reaching every consumer plus an i18n project in a component with no translation hook — and every shipped surface already supplies its own copy. Recording that half as declined, with the reason, is worth more than doing it quietly. A card that converges one property and explicitly declines the other has answered the question it was asked.

A naive hoist would have been actively harmful, and only rendering found that.plugin-list renders its load failure through DataEmptyState, borrowing it for layout. With no role on either branch, a 403 and "Nothing here yet" were the same node shape — and a bare role="status" default would have made it worse, announcing an outage as a routine status update. The correct convergence is two lines, not one: the default plus role="alert" at that call site.

That is invisible to a source read. A reader of view-states.tsx sees one component missing one attribute and a one-line fix; the danger only appears when you render the surfaces and find one of them is doing the other's job.

⚠️ My briefed consumer count was wrong, and I verified the correction myself

I dispatched this saying four call sites. It is six in four packages, seven once objectui#7139 landed. Rather than accept that on report, I re-measured on main:

proberesult
<DataEmptyState sites in ListView.tsx2:3584 (load error) and :3675 (empty)
DataErrorState in ListView.tsx0 — control: DataEmptyState hits 3× in the same file, so the zero reads
role= declarations in view-states.tsx vs Data* components2 vs 3DataEmptyState alone declared none
consuming files across packages/6, including components' own ui:empty renderer, which the card never listed

The dev swept both exported names, not just the canonical one, and reported EmptyState's alias as a real zero — with the control hitting the alias line itself at view-states.tsx:199, which is what separates "no consumers" from "the query missed."

Assumptions falsified and reported rather than absorbed

  • "DataEmptyState is the only shared empty-state primitive."Falsecustom/empty.tsx carries a whole second family, plus BuiltinSelectEmptyState and CommandEmpty. None is a view-level data empty state and none was touched, but the claim was wrong and saying so is the right call.
  • "All consumers pass compatible props."False, and it is exactly why the naive default was unsafe.
  • The KPI carve-out confirmed but explicitly non-generalising: rows.length === 0 && !isMetric is a branch gate on whether an empty state renders at all, upstream of this component — a different question from what structure it has.

Evidence

Ablation predicted before running, observed exactly: Tests 4 failed | 4 passed (8), the four survivors being both override arms, the sibling-contrast arm and the error arm — which are themselves the proof that objectui#7063's and objectui#7064's ruled surfaces are inert. Mutation proven on disk by blob hash and guard-line count; restore proven by state.

The three-value arm is asserted by exact value, not by inequality — on main both roles were null, so a partial fix leaving the empty branch at null would still satisfy null !== 'alert' and pass a mere-difference check. Naming both values is what makes that arm able to fail.

typecheck clean would have been a false green on its own (packages/components/tsconfig.json excludes src/__tests__ and **/*.test.tsx); proven covered by the second pass instead, with --listFiles placing both pins in the checked set and a control showing 0.test.tsx in the src pass.

Blast radius run rather than sampled: 1507 test files across direct and indirect consumers, zero failures.

Carried into this lane and it paid

objectui#7130's dev named a gap on themselves — blast radius chosen from declared consumers when the right criterion is which fixtures answer queries with no rows. This lane ran the full graph instead of sampling.

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 d1842abSep 1, 2026
32 checks passed
@os-warren
os-warren deleted the claude/issue-7132-empty-state-convergence branch September 1, 2026 06:32
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(components,plugin-list): let DataEmptyState declare role="status", so an empty result is not shaped like a failed one - #7144

Merged
os-warren merged 1 commit into
mainfrom
claude/issue-7132-empty-state-convergence
Sep 1, 2026
Merged

fix(components,plugin-list): let DataEmptyState declare role="status", so an empty result is not shaped like a failed one#7144
os-warren merged 1 commit into
mainfrom
claude/issue-7132-empty-state-convergence

Conversation

@os-warren

Copy link
Copy Markdown
Collaborator

Fixes#7132

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

Every surface was rendered and its empty box read directly — role, aria-live, data-slot, non-blank text-node count and text — at 2b3964aff. Not read off the source; rendered.

surfaceroletextself-describingdistinguishable from a failure
DataEmptyState bare defaultnoneNo datano — terse, names nothingno
plugin-list empty listnoneNothing here yet + Create your first record…yesno
plugin-list load ERROR (403)noneYou don't have access… + Retryyesno
plugin-detailActivityTimelinenoneNo activity recordedpartlyno
ui:empty schema renderernoneauthoredauthoredno
plugin-dashboardWidgetEmptyState (#7063)statusNo data yet + explanation + Source: work_orderyesyes
plugin-kanban empty boardstatus + aria-live=politeNo cards + 2 columnsyesyes
plugin-chartsObjectChart (PR #7139)statusNo data yet + explanationyesyes

The answer: they do NOT legitimately differ

The two properties the rulings name are not in tension across these surfaces — one surface had simply never been given the platform default, and the rest had each bought it retail.

DataLoadingState has always been role="status" and DataErrorStaterole="alert". DataEmptyState, in the same file, declared nothing. So every surface that wanted the property typed it at its own call site, and three independently did — kanban, dashboard, and the in-flight ObjectChart — while plugin-list, plugin-detail's two timelines and the ui:empty renderer silently did not.

Three hand-copies of one line, paid per package, is the per-app tax objectstack#13848 rules against, and the spread was not principled divergence but the presence or absence of one attribute nobody owned.

⇒ The primitive carries property 1 now. It still does not carry property 2 and deliberately is not given it: every shipped surface already supplies its own self-describing copy, and changing the 'No data' title default would be a copy change reaching all consumers plus an i18n project in a component that currently has no translation hook. That half is measured-and-declined, recorded here rather than done quietly.

The fences, each checked rather than asserted

#7063 and #7064 are not re-litigated — neither surface's behaviour changes, structurally.

No authoring surface is widened. No new prop, no new schema key, no new i18n key. ListView already read schema.emptyState?.title / .message before this change and still does, untouched. Clause ② is not engaged, so the blocked Fable 5 quota is not in the path.

The KPI datum does not generalise, and the code says why.DatasetWidget's carve-out is if (state.rows.length === 0 && !isMetric) — a branch gate on whether an empty state is rendered at all, upstream of this component. A metric widget never reaches DataEmptyState, so a KPI still reads 0. That decision and this one are about different questions: whether to show an empty state vs what structure the empty state has. Untouched either way.

One real defect fell out of the measurement

plugin-list renders its load failure through DataEmptyState, borrowing it for layout. With no role on either branch, "You don't have access" and "Nothing here yet" were the same node shape — and a role="status" default alone would have made it worse, announcing a 403 as a routine status update. That call site now declares role="alert".

This is why naive hoisting was not safe, and it is the one thing the card's hypothesis did not anticipate. The deeper structural question — that DataErrorState sits unused next to it — is filed as #7143 rather than refactored here.

Consumer census, re-derived with controls

Both exported names were swept, not just the canonical one:

  • DataEmptyState6 call sites in 4 packages on main, not the four the card assumed: plugin-list (two — empty and error), plugin-kanban, plugin-detail (twoActivityTimeline and RecordActivityTimeline), and packages/components' own ui:empty renderer, which the card did not list at all. PR fix(plugin-charts): a self-describing empty state for ObjectChart over an empty result #7139 adds ObjectChart as a seventh.
  • EmptyState — the alias re-export has zero consumers. Real zero: the control (git grep -nw against the alias line itself) hits at view-states.tsx:199. It is public API of a published package, so this is an observation, not a defect.
  • Outside packages/*: zero code consumers — one changeset mentions the name in prose. Control: 238 files outside packages/* reference @object-ui/components, so the channel reads.
  • PM assumption falsified:DataEmptyState is not the only shared empty-state primitive in packages/components. custom/empty.tsx carries a second, independent family — Empty / EmptyHeader / EmptyMedia / EmptyTitle / EmptyDescription / EmptyContent / EmptyValue — plus BuiltinSelectEmptyState and CommandEmpty elsewhere. None of them is a view-level data empty state and none is touched here, but "the only shared primitive" was not true.

Blast radius, measured before proposing it

packages/components is broad-blast, so the whole consumer graph was run, not sampled.

scoperesult
direct consumers — components, plugin-list, plugin-kanban, plugin-detail, plugin-dashboardTest Files 504 passed (504) · Tests 4780 passed (4780)
indirect — app-shell, apps/console, fields, plugin-gantt, plugin-timeline, i18n, plugin-charts, plugin-viewTest Files 1003 passed (1003) · Tests 10939 passed | 1 skipped (10940)

Zero snapshot exposure: no snapshot file contains a DataEmptyState render (control: 10 data-slot hits in snapshot.test.tsx.snap prove the grep reads).

Evidence

The pins can fail — ablation, direction predicted before running. Predicted: removing the role="status" default reddens the two DEFAULT arms and the two ListView arms that name status, and leaves the two OVERRIDE arms, the SIBLING CONTRAST arm and the ERROR arm green.

  • mutation proven on disk before the run: blob 294d845a to 9e24fccf, guard-line count 2 to 1, git diff --stat non-empty
  • result: Tests 4 failed | 4 passed (8) — exactly the four predicted arms, with the passing count asserted so a collapsed suite could not masquerade as the red
  • restore proven by state: blob back to 294d845a, guard-lines back to 2, git diff HEADandgit diff --cached both empty

The three-value arm is asserted by exact value, not by inequality: on main both roles were null, and a partial fix leaving the empty branch at null would still satisfy null !== 'alert' and pass a mere-difference assertion. Naming both values is what makes that arm able to fail.

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

gateexitverdict
vitest run (the two new pins)0Test Files 2 passed (2) · Tests 8 passed (8)
vitest run (5 direct consumer packages)0Test Files 504 passed (504) · Tests 4780 passed (4780)
vitest run (8 indirect packages)0Test Files 1003 passed (1003) · Tests 10939 passed | 1 skipped
tsc --noEmit + tsc -p tsconfig.test.json (components)0 / 0clean
tsc --noEmit + tsc -p tsconfig.test.json (plugin-list)0 / 0clean
eslint (plain form, 4 changed files)0184 problems (0 errors, 184 warnings) — all pre-existing no-explicit-any; the new pin's 2 match ListView.loadErrorKind.test.tsx's own profile exactly
check-changeset-presence04 source file(s) of 2 released package(s) changed, and this change declares 1 changeset(s)
check-changeset-fixed0All workspace packages are in the changeset fixed group.
check-changeset-no-major0No changeset declares a major bump.
check-changeset-overwrite0No pre-existing changeset was modified or deleted.
check-control-bytes0OK (scanned 5917 tracked text file(s); skipped 85 binary)
check-i18n-call-site-keys02845 keys — every in-scope call-site key resolves
check-i18n-en-drift0No en value changed in this range.
check-phantom-dependencies0Every in-scope import is declared by the package that publishes it.

⚠️typecheck clean would have been a false green on its ownpackages/components/tsconfig.json excludes src/__tests__ and **/*.test.tsx. Proven covered by the second pass instead: --listFiles puts the components pin at 1 of 1895 checked files and the plugin-list pin at 1 of 1625; both edited sources are in the src pass (1 of 1367, 1 of 1334); control — the src pass contains 0.test.tsx, confirming the two-pass split is what covers them.

Declared narrowing: repo-wide pnpm lint and the full check:* farm were not run locally; CI runs them regardless. pnpm install, the closure build (turbo run build --filter='@object-ui/plugin-list^...', 12 successful) and every run above happened in a dedicated worktree off 2b3964aff.

On PR #7139: measured on main as instructed; it is still open and based on the same 2b3964aff. It hand-types role="status" at its call site, so it is unaffected by this default whichever order the two land in — and afterwards that line becomes removable, which is the collapse its author placed it for. No file in packages/plugin-charts is touched here.

Out-of-scope findings


Generated by Claude Code

…, 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
@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.6 KB350 KB
Entry fileindex-Bd59YEPm.js
StatusPASS

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


📦 Bundle Size Report

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

Size Limits

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

@os-warrenClaude

Copy link
Copy Markdown
CollaboratorAuthor

✅ Reviewed and armed

All 30 check runs read in one call at perPage=100: 27 success, 3 skipped, 0 failed, 0 in_progress. Undrafted (a draft cannot enter the queue), auto-merge SQUASH enabled at ff43f39f7.

Why this one goes in without a round trip

Property 2 came back measured-and-DECLINED, and that is a FULL PASS. Giving DataEmptyState self-describing copy would be a copy change reaching every consumer plus an i18n project in a component with no translation hook — and every shipped surface already supplies its own copy. Recording that half as declined, with the reason, is worth more than doing it quietly. A card that converges one property and explicitly declines the other has answered the question it was asked.

A naive hoist would have been actively harmful, and only rendering found that.plugin-list renders its load failure through DataEmptyState, borrowing it for layout. With no role on either branch, a 403 and "Nothing here yet" were the same node shape — and a bare role="status" default would have made it worse, announcing an outage as a routine status update. The correct convergence is two lines, not one: the default plus role="alert" at that call site.

That is invisible to a source read. A reader of view-states.tsx sees one component missing one attribute and a one-line fix; the danger only appears when you render the surfaces and find one of them is doing the other's job.

⚠️ My briefed consumer count was wrong, and I verified the correction myself

I dispatched this saying four call sites. It is six in four packages, seven once objectui#7139 landed. Rather than accept that on report, I re-measured on main:

proberesult
<DataEmptyState sites in ListView.tsx2:3584 (load error) and :3675 (empty)
DataErrorState in ListView.tsx0 — control: DataEmptyState hits 3× in the same file, so the zero reads
role= declarations in view-states.tsx vs Data* components2 vs 3DataEmptyState alone declared none
consuming files across packages/6, including components' own ui:empty renderer, which the card never listed

The dev swept both exported names, not just the canonical one, and reported EmptyState's alias as a real zero — with the control hitting the alias line itself at view-states.tsx:199, which is what separates "no consumers" from "the query missed."

Assumptions falsified and reported rather than absorbed

  • "DataEmptyState is the only shared empty-state primitive."Falsecustom/empty.tsx carries a whole second family, plus BuiltinSelectEmptyState and CommandEmpty. None is a view-level data empty state and none was touched, but the claim was wrong and saying so is the right call.
  • "All consumers pass compatible props."False, and it is exactly why the naive default was unsafe.
  • The KPI carve-out confirmed but explicitly non-generalising: rows.length === 0 && !isMetric is a branch gate on whether an empty state renders at all, upstream of this component — a different question from what structure it has.

Evidence

Ablation predicted before running, observed exactly: Tests 4 failed | 4 passed (8), the four survivors being both override arms, the sibling-contrast arm and the error arm — which are themselves the proof that objectui#7063's and objectui#7064's ruled surfaces are inert. Mutation proven on disk by blob hash and guard-line count; restore proven by state.

The three-value arm is asserted by exact value, not by inequality — on main both roles were null, so a partial fix leaving the empty branch at null would still satisfy null !== 'alert' and pass a mere-difference check. Naming both values is what makes that arm able to fail.

typecheck clean would have been a false green on its own (packages/components/tsconfig.json excludes src/__tests__ and **/*.test.tsx); proven covered by the second pass instead, with --listFiles placing both pins in the checked set and a control showing 0.test.tsx in the src pass.

Blast radius run rather than sampled: 1507 test files across direct and indirect consumers, zero failures.

Carried into this lane and it paid

objectui#7130's dev named a gap on themselves — blast radius chosen from declared consumers when the right criterion is which fixtures answer queries with no rows. This lane ran the full graph instead of sampling.

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 d1842abSep 1, 2026
32 checks passed
@os-warren
os-warren deleted the claude/issue-7132-empty-state-convergence branch September 1, 2026 06:32
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