Repository navigation
Converge the empty-state defaults onto DataEmptyState — four packages now answer "is it broken or is it young" four different ways, and two rulings just fixed two of them locally #7132
Description
Activity
- addeddomain:uiobjectui ui stream: fix lands on the published library or apps — objectui execution seatobjectui ui stream: fix lands on the published library or apps — objectui execution seatand removed
on Sep 1, 2026 🔒 Claimed —
domain:uiexecution seat (objectui)- Session:
session_012wwHa4aaFybxXrfmfHioDM· Branch:claude/issue-7132-empty-state-convergence - Build tier: opus (
⚠️ Fable 5 quota exhausted; the exemption lowers the build tier only)
✅ Unblocked — and by the mechanism this card was written to protect
This card exists because #7124's implementer identified changing
DataEmptyState's defaults as a smaller fix spanning both rulings, and declined it, because two agents designing one cross-surface abstraction in parallel is worse than two honest local fixes. Both local fixes have since landed (eeb6c2f2b,220c18d05).⭐ The fence held a second time, unprompted. #7130 (
ObjectChart) was dispatched with the same instruction — if the answer is "promote the seam to a shared package", stop and report here. Its implementer measured four package-boundary options and reportedpromoted_to_7132: false, taking the one that changes nothing structural: consumeDataEmptyState, which was already shared and already a declared dependency. ⇒ Nothing has been pre-decided for this card by either sibling.⚠️ It is now FIVE call sites, not fourPR #7139 (#7130) is in flight and adds
ObjectChartas a fifth consumer ofDataEmptyState, deliberately so that it "collapses with the other four when #7132 converges the defaults." ⛔ Do not treat that as a decision already taken — it is a call site placed to be convergeable, not an argument that convergence is right.Measure on
main, and account for #7139 landing during the work. ⛔ Do not editpackages/plugin-charts— that is its surface.The fences from the card, restated as binding
- ⛔ Do not re-litigate Dashboard/analytics widgets render a bare 「暂无数据行」 that is indistinguishable from a load failure — make the DEFAULT empty state self-explaining, platform-wide #7063 or Flip the empty-section DEFAULT: sparse records keep the section skeleton — stop forcing
hideEmpty ?? trueover DetailSection's own stated heuristic #7064. Both ruled and landed. If convergence would change either surface's behaviour, that is a new question for the maintainer, not a refactor. - ⛔ Do not assume the answer is "hoist it into
DataEmptyState". That is the hypothesis this card tests, not its conclusion. - ⛔ Do not widen any authoring surface. Both rulings closed the per-app-compensation route; adding an
emptyStatekey to a new surface would reopen it one card later — and it is clause ②, needing a review the exhausted quota blocks. Built-but-unlandable, the state finding(types/plugin-charts): the staticChartSchemanode's two data keys are declared-but-unread —ChartDataSeries.datais required and never read, andcategoriesis read as a SERIES list, not axis labels #6896 / PR feat(types)!: retire ChartDataSeries.data, correct categories' prose (#6896) #7111 has been in all session. - ⭐ "They legitimately differ, here is the evidence" is a FULL PASS. Close it measured-and-declined and I will accept it as one. finding(plugin-charts):
ObjectCharthas NO empty branch at all — an empty result draws a bare chart frame, the fourth distinct answer on this surface to "is it broken or is it young" #7130 was dispatched on exactly those terms and its measurement is what turned a plausible decline into a warranted fix — the reverse outcome is equally valid.
⚠️ One datum that already argues against naive hoisting: #7124 measured that metric/KPI widgets deliberately render0over an empty dataset rather than an empty state, because a KPI reading "no data" reads as broken.DatasetWidget.tsx:726-728says so in the code. At least one consumer family legitimately wants different behaviour. Whether that generalises is what you are measuring.
Generated by Claude Code
- Session:
os-dev-report
{ "issue": 7132, "status": "done", "branch": "claude/issue-7132-empty-state-convergence", "pr": "https://github.com/objectstack-ai/objectui/pull/7144", "premise_still_valid": true, "summary": "MEASURED FIRST, then converged. Rendered all surfaces and read role/aria-live/data-slot/text-node-count/text directly at 2b3964aff. Result: DataEmptyState carried NO role while its own siblings in the same file did (DataLoadingState=status, DataErrorState=alert). Three surfaces had each hand-typed role=status at their own call site (kanban, dashboard #7063, ObjectChart PR #7139) while plugin-list, plugin-detail's two timelines and the ui:empty renderer silently had none. So they do NOT legitimately differ: it is one missing platform default bought retail three times, the objectstack#13848 tax at package level. Gave DataEmptyState a role=status DEFAULT (spread from props, so call sites keep the last word). Property 2 (self-describing without authored copy) is measured-and-DECLINED: every shipped surface already supplies its own copy, and changing the 'No data' title default would be a copy change reaching all consumers plus an i18n project in a component with no translation hook. The measurement also found a real defect: plugin-list renders its LOAD FAILURE through DataEmptyState, so a 403 and an empty list were the same node shape, and a naive default would have announced an outage as a routine status. That call site now declares role=alert. Both ruled surfaces are structurally inert: #7064's fix lives in record-details.tsx which does not consume the primitive at all, and #7063 passes role=status explicitly.", "tests": "ABLATION, direction predicted before running: removing the role=status default reddens the two DEFAULT arms plus the two ListView arms naming status, leaving 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, 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 HEAD AND git diff --cached both empty. No rebuild needed for the ablation and that is verified, not assumed: root vitest.config.mts aliases every @object-ui/* specifier to packages/*/src, so a src mutation is what the run loads. Suites at final commit ff43f39f7: two new pins 'Test Files 2 passed (2) / Tests 8 passed (8)'; five DIRECT consumer packages 'Test Files 504 passed (504) / Tests 4780 passed (4780)'; eight INDIRECT packages (app-shell, apps/console, fields, plugin-gantt, plugin-timeline, i18n, plugin-charts, plugin-view) 'Test Files 1003 passed (1003) / Tests 10939 passed | 1 skipped (10940)'. RECORDER CONTROL: the measurement harness asserted its own row count, that no row was ELEMENT-NOT-FOUND, and that the bare primitive read exactly 'No data' -- it fired on the first run and caught my own miscount rather than printing a plausible table; console output turned out to be swallowed by the vitest config, so the recorder was switched to a file with a read-back assertion.", "mcp_calls": "11", "open_questions": [], "out_of_scope_findings": [ "filed as #7142: ActivityTimeline's empty title is a hardcoded English literal (title=\"No activity recorded\") while its sibling RecordActivityTimeline uses t('detail.noActivity') -- stays English in all ten packs", "filed as #7143: [finding] ListView renders its load FAILURE through DataEmptyState while DataErrorState sits unused beside it; #7144 fixes the role collision but not the structural misuse, which needs its own visual review because DataErrorState hardcodes its icon and has no icon/showIcon props" ], "consumer_set_rederived_with_control": "YES, and the PM count was wrong. SIX call sites in FOUR packages on main, not four: plugin-list has TWO (empty at ListView.tsx:3675 AND the load-error panel at :3584), plugin-detail has TWO (ActivityTimeline.tsx:138, RecordActivityTimeline.tsx:415), plus plugin-kanban KanbanImpl.tsx:645 and packages/components' OWN ui:empty schema renderer at renderers/feedback/empty.tsx:36 -- which the card did not list at all. plugin-dashboard reaches it via WidgetEmptyState.tsx:101. PR #7139 adds ObjectChart as a seventh. The alias export EmptyState has ZERO consumers -- a real zero, control git grep -nw hits the alias line itself at view-states.tsx:199. OUTSIDE packages/*: zero code consumers, control 238 files outside packages/* reference @object-ui/components so the channel reads.", "other_shared_primitives_found": "PM ASSUMPTION FALSIFIED. DataEmptyState is not the only shared empty-state primitive in packages/components. custom/empty.tsx holds a second independent family: Empty, EmptyHeader, EmptyMedia, EmptyTitle, EmptyDescription, EmptyContent, EmptyValue. Also BuiltinSelectEmptyState (renderers/form/form.tsx:3337) and CommandEmpty (ui/command.tsx:113). None is a view-level data empty state and none is touched, but 'the only shared primitive' was not true. Control: the same regex hits 3 'function Data*' in view-states.tsx.", "per_surface": [ {"surface": "DataEmptyState bare default (the primitive)", "what_empty_renders": "inbox glyph + h3 'No data', 1 text node", "role_attr": "NONE (before) / status (after)", "self_describing": false, "distinguishable": false}, {"surface": "plugin-list ListView EMPTY", "what_empty_renders": "'Nothing here yet' + 'Create your first record to get started.' + Add-record button, 2 text nodes", "role_attr": "NONE (before) / status (after)", "self_describing": true, "distinguishable": false}, {"surface": "plugin-list ListView LOAD ERROR (403)", "what_empty_renders": "'You don't have access' + permission explanation + Retry, 3 text nodes -- rendered through DataEmptyState, not DataErrorState", "role_attr": "NONE (before) / alert (after)", "self_describing": true, "distinguishable": false}, {"surface": "plugin-detail ActivityTimeline", "what_empty_renders": "'No activity recorded', 1 text node, hardcoded English", "role_attr": "NONE (before) / status (after)", "self_describing": false, "distinguishable": false}, {"surface": "plugin-detail RecordActivityTimeline", "what_empty_renders": "t('detail.noActivity') or authored emptyLabel", "role_attr": "NONE (before) / status (after)", "self_describing": true, "distinguishable": false}, {"surface": "packages/components ui:empty schema renderer", "what_empty_renders": "authored title/description, defaulting to 'No data'", "role_attr": "NONE (before) / status (after)", "self_describing": false, "distinguishable": false}, {"surface": "plugin-dashboard WidgetEmptyState (#7063, RULED)", "what_empty_renders": "'No data yet' + 'This widget loaded successfully and its query returned no records yet.' + 'Source: work_order', 4 text nodes", "role_attr": "status, typed explicitly at the call site -- UNCHANGED by this PR", "self_describing": true, "distinguishable": true}, {"surface": "plugin-kanban empty board", "what_empty_renders": "'No cards' + '2 columns', 2 text nodes", "role_attr": "status + aria-live=polite, typed explicitly -- UNCHANGED by this PR", "self_describing": true, "distinguishable": true}, {"surface": "plugin-charts ObjectChart (PR #7139, in flight)", "what_empty_renders": "'No data yet' + explanation", "role_attr": "status, typed explicitly -- UNCHANGED by this PR", "self_describing": true, "distinguishable": true}, {"surface": "plugin-detail record SECTIONS (#7064, RULED)", "what_empty_renders": "heading + labels + em-dash placeholders, heuristic-owned in record-details.tsx", "role_attr": "n/a -- does NOT consume DataEmptyState, so unreachable from this change", "self_describing": true, "distinguishable": true} ], "do_they_legitimately_differ": false, "verdict": "CONVERGENCE WARRANTED for property 1 (distinguishable from a load failure), DECLINED for property 2 (self-describing copy). The surfaces were not legitimately differing on the structural test: the primitive's own siblings in the same file both declare a role and it alone declared none, so three surfaces hand-copied the same line and three did not get it. Naive hoisting was nonetheless NOT safe -- plugin-list renders its load error through the empty primitive, so a bare role=status default would have announced a 403 as a routine status. The correct convergence is therefore two lines, not one: the default on the primitive, plus the misusing call site declaring role=alert.", "blast_radius_measured": "packages/components is broad-blast so the whole consumer graph was run, not sampled. DIRECT (components, plugin-list, plugin-kanban, plugin-detail, plugin-dashboard): 504 test files / 4780 tests, all passed, exit 0. INDIRECT (app-shell, apps/console, fields, plugin-gantt, plugin-timeline, i18n, plugin-charts, plugin-view -- the packages holding all 27 out-of-package test files that touch empty-state DOM or roles): 1003 test files / 10939 passed + 1 skipped, exit 0. Combined 1507 test files, zero failures. Snapshot exposure zero: no snapshot contains a DataEmptyState render, control 10 data-slot hits in snapshot.test.tsx.snap prove the grep reads.", "kpi_family_generalises": false, "authoring_surface_widened": false, "spec_key_needed": false, "files_changed": [ "packages/components/src/custom/view-states.tsx", "packages/plugin-list/src/ListView.tsx", "packages/components/src/__tests__/data-empty-state-role-7132.test.tsx", "packages/plugin-list/src/__tests__/ListView.emptyVsErrorRole-7132.test.tsx", ".changeset/7132-empty-state-role-default.md" ], "pins_added": [ "packages/components/src/__tests__/data-empty-state-role-7132.test.tsx -- 5 arms: the default; the default alongside real call-site props; an override to alert; the ruled surfaces' explicit role=status override; the sibling contrast (error=alert, loading=status)", "packages/plugin-list/src/__tests__/ListView.emptyVsErrorRole-7132.test.tsx -- 3 arms: empty branch is status; error branch is alert; both branches asserted BY EXACT VALUE rather than by inequality, because on main both were null and a partial fix leaving empty at null would still satisfy null !== 'alert' and pass a mere-difference assertion" ], "pin_ablation": "PROVEN ABLE TO FAIL. Predicted before running, then observed exactly: 'Tests 4 failed | 4 passed (8)'. Red: the two DEFAULT arms and the two ListView arms naming status. Green (negative controls, correctly unmoved): both OVERRIDE arms, the SIBLING CONTRAST arm, the ERROR arm -- which is itself the proof that the two already-ruled surfaces are inert under this change, since they pass role explicitly. Mutation proven on disk before the run (blob 294d845a to 9e24fccf, guard-line count 2 to 1); restore proven by state (blob back, guard-lines back to 2, git diff HEAD and git diff --cached both empty). Ablation script carried a trap ... EXIT INT TERM with an absolute REPO_ROOT path.", "changeset_gate_verdict_verbatim": "STAGED FIRST so the population is non-empty. check-changeset-presence exit 0: 'Compared the working tree with 2b3964aff (merge-base with origin/main): 5 file(s) changed, 4 of them published source of a package the release covers, 0 of them a manifest whose published contract moved, 0 under a package changesets ignores, 1 changeset(s) added.' then 'OK 4 source file(s) of 2 released package(s) changed, and this change declares 1 changeset(s): .changeset/7132-empty-state-role-default.md.' check-changeset-overwrite exit 0: 'No pre-existing changeset was modified or deleted.' check-changeset-fixed exit 0: 'All workspace packages are in the changeset fixed group.' check-changeset-no-major exit 0: 'No changeset declares a major bump.'", "gates": [ {"name": "vitest run (two new pins)", "exit": 0, "verdict": "Test Files 2 passed (2) / Tests 8 passed (8)", "raw": "at final commit ff43f39f7"}, {"name": "vitest run (5 direct consumer packages)", "exit": 0, "verdict": "Test Files 504 passed (504) / Tests 4780 passed (4780)", "raw": "components, plugin-list, plugin-kanban, plugin-detail, plugin-dashboard"}, {"name": "vitest run (8 indirect packages)", "exit": 0, "verdict": "Test Files 1003 passed (1003) / Tests 10939 passed | 1 skipped (10940)", "raw": "app-shell, apps/console, fields, plugin-gantt, plugin-timeline, i18n, plugin-charts, plugin-view"}, {"name": "tsc --noEmit + tsc -p tsconfig.test.json (components)", "exit": 0, "verdict": "clean, both passes", "raw": "NOT MEASURED on the first attempt -- Cannot find module @object-ui/core, unbuilt closure; re-run after turbo run build --filter=@object-ui/plugin-list^... which reported 12 successful, 12 total"}, {"name": "tsc --noEmit + tsc -p tsconfig.test.json (plugin-list)", "exit": 0, "verdict": "clean, both passes", "raw": "same closure build"}, {"name": "tsc --listFiles coverage control", "exit": 0, "verdict": "components pin 1 of 1895 checked files; plugin-list pin 1 of 1625; edited sources 1 of 1367 and 1 of 1334; control -- src pass contains 0 .test.tsx", "raw": "packages/components/tsconfig.json excludes src/__tests__ and **/*.test.tsx, so a clean typecheck alone would have said NOTHING about the new pins; the second tsconfig.test.json pass is what covers them"}, {"name": "eslint (plain form, 4 changed files)", "exit": 0, "verdict": "184 problems (0 errors, 184 warnings)", "raw": "all pre-existing no-explicit-any in ListView.tsx; the new pin contributes 2, matching ListView.loadErrorKind.test.tsx's own profile at the identical construct"}, {"name": "check-control-bytes", "exit": 0, "verdict": "OK (scanned 5917 tracked text file(s); skipped 85 binary)", "raw": ""}, {"name": "check-i18n-call-site-keys", "exit": 0, "verdict": "every in-scope call-site key resolves against the en pack (2845 keys)", "raw": "no new i18n key added by this PR"}, {"name": "check-i18n-en-drift", "exit": 0, "verdict": "No en value changed in this range.", "raw": ""}, {"name": "check-phantom-dependencies", "exit": 0, "verdict": "Every in-scope import is declared by the package that publishes it.", "raw": "scanned 40 released packages, 3681 source files, 18510 specifiers"}, {"name": "repo-wide pnpm lint + full check:* farm", "exit": null, "verdict": "DECLARED NARROWING -- not run locally, left to CI which runs the farm regardless", "raw": ""} ], "controls_used": [ "alias census: git grep -nw EmptyState returned zero consumers; re-taken because the first attempt's grep -v filter had removed the very line that was the control; the control then hit at view-states.tsx:199, making the zero a real reading", "outside-packages census: zero code consumers, control 238 files outside packages/* reference @object-ui/components", "other-primitives sweep: control confirmed the regex hits 3 'function Data*' in view-states.tsx; filename sweep control 442 tracked files under packages/components/src", "snapshot exposure: 0 data-empty-state hits, control 10 data-slot hits in snapshot.test.tsx.snap", "indirect-exposure scoping: 27 test files outside the 5 direct packages touch empty-state DOM or roles, control 13 hits inside those 5 packages proves the grep reads", "recorder self-control: row count, no ELEMENT-NOT-FOUND row, and bare primitive text === 'No data'; it FIRED on run 1 and caught a miscount", "recorder file read-back: after console output proved to be swallowed by the vitest config, the harness wrote to a file and asserted the file contained the primitive row", "typecheck coverage control: 0 .test.tsx in the src pass, confirming the exclude is real and the second pass is what covers the pins" ], "assumptions_falsified": [ "FALSE: 'it is five call sites, not four'. It is SIX on main in four packages, seven with #7139. plugin-list has two (empty AND error) and plugin-detail has two, and packages/components' own ui:empty renderer is a consumer the card never listed.", "FALSE: 'DataEmptyState is the only shared empty-state primitive'. custom/empty.tsx holds a whole second family (Empty/EmptyHeader/EmptyMedia/EmptyTitle/EmptyDescription/EmptyContent/EmptyValue), plus BuiltinSelectEmptyState and CommandEmpty.", "FALSE: 'all consumers pass compatible props'. plugin-list's load-ERROR panel passes error copy, destructive icons and a retry action to a component named DataEmptyState -- structurally different use, and the reason a naive default would have been actively wrong.", "TRUE (confirmed): the four-or-five packages are the whole consumer set in the sense that nothing outside packages/* consumes it.", "TRUE (confirmed) but does NOT generalise: the KPI carve-out is real and is a branch gate (rows.length === 0 && !isMetric) on whether an empty state renders AT ALL, upstream of the primitive. It answers a different question than this card's, so it neither blocks nor supports the structural convergence." ], "open_questions_note": "none -- no decision was needed; the measurement settled it in both directions without touching a ruled surface or widening an authoring surface" }⚠️ For the PM arming landing: PR #7144 is draft, base has moved (mergeable_state: behind, base nowe8e4c4df5vs the2b3964affthis branch measured and forked from). CI was not waited on per the dispatch contract. Ready was not flipped and auto-merge was not enabled.
Generated by Claude Code
🟢 Landed —
d1842abcfPR #7144 merged;
mainadvanced.Fixesclosed this card.Verified by content with a differential control
probe pre ( dd35800af)post ( d1842abcf)view-states.tsx— coderole=sites2 3 ListView.tsx—role="alert"1 2 CONTROL — Data*components inview-states.tsx3 3 ✅ CONTROL — DataEmptyStateinListView.tsx3 4 ✅ The three new code sites are
:31(DataLoadingState, pre-existing),:130(the newDataEmptyStatedefault) and:195(DataErrorState, pre-existing) — so the file went from two of three siblings declaring a role to all three.⚠️ My own probe needed correcting first. A line-count grep returned 6, not the 3 I predicted, and I nearly recorded that as a discrepancy. Three of those six are inside the doc comment this PR added (:90,:106,:109) — the instrument was counting prose alongside implementation on a file the change deliberately documented. My prediction was right about the code; my measurement of it was not. Splitting code from comment is what made the reading true.Why this landed as-is
Property 2 came back measured-and-DECLINED, and that is a full pass. Self-describing default copy would be a change reaching every consumer plus an i18n project in a component with no translation hook, and every shipped surface already supplies its own copy. A card that converges one property and explicitly declines the other has answered its question.
A naive hoist would have been actively harmful.
plugin-listrenders its load failure throughDataEmptyState, so a barerole="status"default would have announced a 403 as a routine status update. The correct convergence is two lines, not one — the default plusrole="alert"at that call site. That is invisible to a source read:view-states.tsxshows one component missing one attribute and looks like a one-line fix. Only rendering the surfaces revealed that one of them was doing the other's job.My briefed consumer count was wrong and I verified the correction myself: I said four call sites; it is six in four packages, seven with objectui#7139.
ListView.tsxalone has two, andpackages/components' ownui:emptyrenderer was never listed on the card.Three of my assumptions were falsified and reported rather than absorbed — that
DataEmptyStatewas the only shared empty-state primitive (custom/empty.tsxholds a whole second family), that all consumers pass compatible props, and the consumer count above. The KPI carve-out was confirmed but explicitly non-generalising: it is a branch gate on whether an empty state renders at all, upstream of this component.Follow-up now unblocked
objectui#7143 —
ListViewrenders its load failure throughDataEmptyStatewhileDataErrorStatesits unused. This PR fixed the accessibility collision, not the structural one. Its file collision with this PR has cleared; its Clause ② fork has not.
Generated by Claude Code
Filed by the
domain:uiseat (sessionsession_012wwHa4aaFybxXrfmfHioDM) as the deliberately deferred convergence half of two maintainer rulings that landed within ten minutes of each other:hideEmpty ?? trueover DetailSection's own stated heuristic #7064 / PR fix(plugin-detail): let DetailSection's heuristic own the empty-section default #7123 —eeb6c2f2b, record-detail empty sections (hotcrm#1247 ruling)220c18d05, dashboard widget empty state (hotcrm#1212 ruling)Both rest on the same principle — objectstack#13848: apps are simplified business implementations; uniform behaviour belongs to the platform, and per-widget/per-section authored copy is the per-app tax being ruled against.
⭐ Why this card exists rather than having been done inside one of those PRs
DataEmptyState(packages/components/src/custom/view-states.tsx) is already consumed by four packages:plugin-list,plugin-kanban,plugin-dashboardandplugin-detail. Its title default is literally'No data'.⇒ Changing its defaults would have been a smaller diff that repaired both rulings at once, and #7124's implementer identified exactly that and declined it on purpose, consuming the primitive unchanged and recording the reason in the new component's file header.
That was the correct call, and it is the reason this card is filed rather than lost: the two rulings were dispatched in parallel to two agents who could not see each other's reasoning, and both dispatch orders explicitly forbade designing a cross-surface abstraction, requiring stop-and-report instead.⚠️ A shared abstraction designed simultaneously by two agents is worse than two honest local fixes — and it would have been invisible as a problem, because the diff would have looked elegant.
Both local fixes have now landed. The precondition for doing it properly, once, is satisfied.
The measured spread — four packages, four answers
plugin-detailrecord sectionsplugin-dashboarddataset/table/pivot widgetsWidgetEmptyState—role="status", title + explanation, names the data sourceplugin-list,plugin-kanbanDataEmptyStatedefaults — terse, unexamined against either rulingplugin-chartsObjectChart⇒ The two rulings did not establish a platform-wide answer; they established it on two surfaces. This card is about whether the other two should follow, and whether the shared primitive should carry the answer.
⛔ What this card must NOT do
hideEmpty ?? trueover DetailSection's own stated heuristic #7064. Both are ruled and landed. If convergence would change either surface's behaviour, that is a new question for the maintainer, not a refactor.DataEmptyState". That is the hypothesis, not the ruling. A list and a record-detail section and a chart may legitimately want different empty states — finding(plugin-charts):ObjectCharthas NO empty branch at all — an empty result draws a bare chart frame, the fourth distinct answer on this surface to "is it broken or is it young" #7130 makes exactly that argument for charts (axes may already be self-describing).emptyStatekey to a new surface would reopen it one card later.ChartSchemanode's two data keys are declared-but-unread —ChartDataSeries.datais required and never read, andcategoriesis read as a SERIES list, not axis labels #6896 / PR feat(types)!: retire ChartDataSeries.data, correct categories' prose (#6896) #7111 is in.Suggested first step — measurement, not refactor
Render all four surfaces' empty states side by side and judge them against the two rulings' own stated properties:
role="status"vsrole="alert"is the structural test fix(plugin-dashboard,i18n): a self-explaining default empty state for dashboard widgets #7124 used);Then answer: does the shared primitive carry those properties, or does each surface legitimately differ? ⭐ "They legitimately differ, here is the evidence" is a fully valid outcome — close it measured-and-declined.
Sequencing
packages/componentsis consumed by all four packages, so a defaults change there is broad-blast. Check thepackages/app-shellPR backlog before starting.Refs: #7063 · #7064 · PR #7123 · PR #7124 · #7130 (ObjectChart) · #7125 (dead keys from #7124) · objectstack#13848 · hotcrm#1212 · hotcrm#1247.