fix(plugin-dashboard,i18n): a self-explaining default empty state for dashboard widgets - #7124

Merged
os-warren merged 1 commit into
mainfrom
claude/issue-7063-widget-empty-state-default
Sep 1, 2026
Merged

fix(plugin-dashboard,i18n): a self-explaining default empty state for dashboard widgets#7124
os-warren merged 1 commit into
mainfrom
claude/issue-7063-widget-empty-state-default

Conversation

@os-warren

Copy link
Copy Markdown
Collaborator

Fixes#7063

Maintainer ruling 2026-08-31 (hotcrm#1212 adjudication, verbatim 「1212 也是」, following 「hotcrm#1203 …这种也是平台的问题啊」): this class is the platform's to fix uniformly; apps must not compensate widget by widget (objectstack#13848 — apps are simplified business implementations, uniform behaviour belongs to the platform).

All evidence below was measured at 79accd6 (the final commit on this branch).


The card's premise was half wrong, and that changed the shape of the fix

Two of the dispatch's stated assumptions did not survive measurement. Both are reported here rather than worked around.

1. 暂无数据行 is NOT one shared placeholder — there was no seam to fix

Three dashboard surfaces wrote their own empty state, in two different strings, with no common code:

surfacekeyenzh
DatasetWidget (the measured one)dashboard.noRowsNo rows暂无数据行
ObjectDataTabledashboard.noDataAvailableNo data available暂无数据
PivotTabledashboard.noDataAvailableNo data available暂无数据

dashboard.noRows had exactly one call site repo-wide (DatasetWidget.tsx:727), so the measured 暂无数据行 came from the dataset-bound path only — which is the ADR-0021 governed path the hotcrm quota-attainment tile takes.

So "uniformly" could not mean "fix the shared placeholder". The new WidgetEmptyStateis the seam: it is added, and the three surfaces are routed through it.

2. The authored emptyState override does not exist on this surface

Clause 3 assumes an existing authored emptyState contract that always overrides. Measured:

  • emptyState occurs 0 times in packages/plugin-dashboard/src and packages/plugin-charts/src. Control in the same sweep, same paths: widget, 1154 hits — so the zero is a reading, not a broken query.

  • emptyState is a list-view contract (ObjectGridSchema, NamedListView in packages/types/src/objectql.ts), honoured in packages/plugin-list/src/ListView.tsx.

  • @objectstack/spec@17.2.0's DashboardWidgetSchema declares no such key. Its full key set, read from the built package at runtime rather than recalled:

    id, title, description, type, chartConfig, colorVariant, requiresObject, requiresService, actionUrl, actionType, actionIcon, filter, compareTo, dataset, dimensions, values, layout, options, filterBindings, suppressWarnings, responsive, aria

Consequence, and why this is not the stop-and-report case. Clause 3 is satisfied here vacuously: there is no authored override on dashboard widgets for the default to lose to, so improving the default cannot displace one. No spec key was added and none is needed to deliver this card — the deliverable is a better DEFAULT, and defaults need no authoring surface. Making clause 3 non-vacuous (i.e. giving dashboard widgets a real authored override) would require a new spec key, which is the contract question the card fences off. That is flagged for the seat, not taken.


What changed

New:packages/plugin-dashboard/src/WidgetEmptyState.tsx — the dashboard-local seam.

The ruling's four properties, and how each is delivered:

  1. Distinguishable from a load failure at a glance.role="status", where the failure branches sitting beside these call sites are role="alert" on destructive colours. The empty branches previously carried no role at all, so assistive tech got a bare fragment with no state either way. Visually: muted treatment and an inbox glyph, never destructive colour and a warning triangle.
  2. Self-describing without authored copy. A title and an explanation where the placeholder was one terse fragment, plus the name of the widget's data source. The copy states that the widget loaded successfully — the one fact a reader of a blank tile cannot otherwise get.
  3. No new authoring obligation, no new spec key. Nothing was added to any schema. PivotTable gains one optional component prop (sourceLabel), which is React-side only.
  4. Platform i18n.dashboard.empty.title / .message / .sourceLabel in en and all nine sibling packs.

What source names, and why it is a raw identifier. The card allows the widget's label and/or its data source. The widget's title is already rendered by the card header directly above every tile (DashboardRenderer), so repeating it says nothing new; the data source is the half the reader cannot see. What is reachable at each render site is the authored binding — widget.dataset on the dataset path, schema.objectName on the object-bound table/pivot — i.e. a raw metadata name such as crm_forecast, not a localized label. It is rendered as a labelled monospace value rather than folded into a sentence: a truthful narrow statement beats an invented friendly one.

Why the copy carries no interpolation.useSafeTranslate takes a positional fallback and no options bag, so an interpolated key would have to come from a raw useObjectTranslationt() — and with no I18nProvider mounted (the standalone-host and test configuration, which vitest.setup.i18n-global.ts deliberately preserves) that renders the raw KEY unless the call site also carries an inline defaultValue, which #3517 rules out. A label that carries its own punctuation (Source: / 数据源: / مصدر البيانات:) needs neither, and concatenates no separator in code.

Files:WidgetEmptyState.tsx (new) · DatasetWidget.tsx · ObjectDataTable.tsx · PivotTable.tsx · ObjectPivotTable.tsx · ten locale packs · one new test file · DatasetWidget.test.tsx · one changeset.


Evidence

Tests

pnpm exec vitest run packages/plugin-dashboard packages/i18n packages/app-shell/src/__tests__/widget-dom-leak-sweep.test.tsx --maxWorkers=2 at 79accd6:

 Test Files 144 passed (144)
Tests 1924 passed (1924)

New pin: packages/plugin-dashboard/src/__tests__/WidgetEmptyState.uniformDefault.test.tsx (5 cases). It pins the ruling's properties, never the sentence — copy is review's to move. It also pins that all four dashboard entry points reach the one seam, which is the property that decays first: a fourth widget can quietly write a fifth placeholder while every per-widget test stays green.

Four pins in DatasetWidget.test.tsx were rewritten rather than left passing. Three of them asserted queryByText('No rows')).not.toBeInTheDocument() — after this change that string exists nowhere, so they would have passed for the wrong reason. They now select the empty state by test id.

Ablation — two legs, direction predicted before running

Leg 1 — revert DatasetWidget's empty branch to the pre-card dashboard.noRows one-liner. Predicted: RED, and only on the DatasetWidget surface.

  • mutation proven on disk: removed-marker WidgetEmptyState (as a JSX open tag) 1 to 0; injected-marker tt('dashboard.noRows' 0 to 1; blob f8f61e71... to 951c8251...
  • observed: Tests 4 failed | 59 passed, vitest exit 1 (read from the run's own printed ABLATED_VITEST_EXIT=1, not a bare $? behind a pipe). All four failures on the DatasetWidget surface; the ObjectDataTable, PivotTable and ObjectPivotTable cases stayed green, so the pins resolve per surface rather than firing as a block.
  • restore proven by state: git diff HEAD empty, git status --porcelain empty, blob back to f8f61e712947e3cdf3dacf3cdab3e6a8cccd25ba = its HEAD blob.

Leg 2 — delete role="status" from the shared component, i.e. ablate property (1) itself. Predicted: RED on the role assertions; the load-failure test stays green.

  • mutation proven on disk: marker role="status" 2 to 1 (the second occurrence is in the file's doc comment; the python anchor asserted exactly one match on the JSX line); blob a62aaa96... to 39a46faf...
  • observed: Tests 4 failed | 59 passed, exit 1 — the three seam cases plus the DatasetWidget.test.tsx role case. a load FAILURE still reads as a failure stayed green, so the two halves of property (1) are independent.
  • restore proven by state: git diff HEAD empty, blob back to a62aaa96ec88cede54f96579451a6922b3aceb3a.

Both legs used an EXIT INT TERM trap with an absolute REPO_ROOT path, and restored with git checkout HEAD -- path (never a bare git checkout -- path, which restores from the index and would hand back the mutation).

No rebuild leg, stated rather than skipped. This repo's vitest resolves @object-ui/* to packages/*/src via resolve.alias in vitest.config.mts, and the pins import the components by relative path, so no dist can shadow the mutation. A stale build cannot make an ablation falsely green here.

Gates

gateexitverdict
vitest (plugin-dashboard, i18n, widget-dom-leak-sweep)0green — 144 files, 1924 tests
pnpm --filter @object-ui/plugin-dashboard --filter @object-ui/i18n run type-check0green — both ran tsc --noEmit && tsc -p tsconfig.test.json, so the new test file was type-checked, not merely excluded
pnpm --filter @object-ui/plugin-dashboard --filter @object-ui/i18n run lint0green — 0 errors (423 + 34 pre-existing warnings; 4 of them no-explicit-any on the new test file, the same schema-cast shape its sibling suites use)
check:i18n-keys0green — "Every in-scope call-site key resolves against the en pack (2845 keys)…"
check:i18n-drift0green — "0 en value(s) changed (3 key(s) added, 0 removed…)" — strictly additive
check:control-bytes0green — "OK (scanned 5898 tracked text file(s); skipped 85 binary)"
check-changeset-presence0green — "17 source file(s) of 2 released package(s) changed, and this change declares 1 changeset(s)"
check:changeset-fixed0green — "All workspace packages are in the changeset fixed group."
check:changeset-no-major0green — "No changeset declares a major bump."
check:phantom-deps / check:self-import / check:side-effects-array / check:vi-mock-specifiers / check:vi-mock-inherit / check:esm-specifiers0green
check:readme-exports1NOT MEASURED — "the population COLLAPSED -- this run proves nothing / packagesRead: found 12, floor is 25". Local tree has most packages unbuilt; CI runs turbo run build first. Narrowing argument: packages/plugin-dashboard/src/index.tsx is untouched and does not export WidgetEmptyState (control in the same query: PivotTable, which it does export, hits), so the package's published export surface is unchanged and no README moved.
check:eager-closure2NOT MEASURED — "No eager-closure report at apps/console/dist/eager-closure.json … This is a broken gauge, not a sensitive gate." Needs a built apps/console; produced by performance-budget.yml in CI.

Changeset verdicts are quoted verbatim above rather than predicted; both gates were run.

Zero-hit readings, each with its control

queryscopehitscontrolcontrol hits
emptyStateplugin-dashboard/src + plugin-charts/src0widget, same scope1154
WidgetEmptyStateplugin-dashboard/src/index.tsx0PivotTable, same file1

What is NOT verified here, and why

The hotcrm acceptance scenario is unverified by me. The card's acceptance is a hotcrm sales_dashboard fresh install. hotcrm is outside this session's repo scope — it is not attached and I did not try to reach it. What I can state is the objectui-side mechanism: the quota-attainment tile is a dataset-bound table widget, which renders through DatasetWidget, whose empty branch is the one call site of the 暂无数据行 string this PR replaces. The end-to-end confirmation on a fresh hotcrm install still needs to be run by someone with that repo.

Shared-abstraction temptation, reported rather than taken.DataEmptyState in @object-ui/components is the presentational primitive already used by plugin-list, plugin-kanban, plugin-dashboardand plugin-detail — the surface of the parallel card #7064. Changing its defaults would have been a smaller diff that repaired both surfaces at once. It is deliberately consumed unchanged here: a shared empty-state abstraction designed twice in parallel by two agents is worse than two honest local fixes, and converging the two is its own sequenced card. Nothing under packages/plugin-detail is touched by this PR.

Residue this creates, not fixed here.dashboard.noRows and dashboard.noDataAvailable now have no call site. check:i18n-dead-keys (report-only, exit 0, not wired into CI) lists both as needs-review after this change. They are left in all ten packs deliberately: the dispatch asks locale edits to stay additive so parallel merges stay trivial, and the dead-key sweep is its own tracked workstream (objectui#4658). Filed as a follow-up rather than ridden on this PR.


Generated by Claude Code

… dashboard widgets
Maintainer ruling 2026-08-31 (hotcrm#1212, following hotcrm#1203): a widget that
renders a bare row-placeholder on an empty result is the platform's defect and
must be fixed uniformly — apps must not compensate widget by widget
(objectstack#13848).
There was no shared placeholder to fix. Three dashboard surfaces wrote their own,
in two different strings: DatasetWidget used `dashboard.noRows` ('No rows' /
暂无数据行), while ObjectDataTable and PivotTable used `dashboard.noDataAvailable`.
`WidgetEmptyState` is the seam they now share.
The default reads as a STATE rather than a failure — `role="status"` where the
branches beside it are `role="alert"` and the empty branches previously carried
no role at all — carries a title plus an explanation instead of one terse
fragment, and names the widget's data source with zero authored copy.
Copy is platform i18n: `dashboard.empty.title` / `.message` / `.sourceLabel` in
`en` and all nine sibling packs. No inline defaultValue, no interpolation.
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)3151.9 KB3191.4 KB
Main entry chunk (gzip)142.3 KB350 KB
Entry fileindex-DbjcEXZE.js
StatusPASS

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


📦 Bundle Size Report

PackageSizeGzipped
app-shell (consoleActionDispatch.js)0.20KB0.19KB
app-shell (index.js)15.33KB5.59KB
app-shell (runtime-config.js)20.68KB7.36KB
app-shell (types.js)0.01KB0.04KB
app-shell (urlParams.js)10.06KB3.86KB
auth (ActiveOrganizationStorage.js)25.05KB9.16KB
auth (AuthContext.js)0.31KB0.24KB
auth (AuthGuard.js)2.07KB1.00KB
auth (AuthProvider.js)40.18KB10.59KB
auth (AuthShell.js)3.49KB1.40KB
auth (ForgotPasswordForm.js)12.21KB3.45KB
auth (LoginForm.js)18.15KB5.39KB
auth (PreviewBanner.js)0.90KB0.50KB
auth (RegisterForm.js)6.65KB2.22KB
auth (SocialSignInButtons.js)9.61KB3.89KB
auth (UserMenu.js)3.41KB1.23KB
auth (auth-gate-events.js)1.29KB0.66KB
auth (authStyles.js)5.04KB1.72KB
auth (createAuthClient.js)40.21KB10.80KB
auth (createAuthenticatedFetch.js)8.46KB3.43KB
auth (index.js)3.19KB1.44KB
auth (invitation-status.js)1.22KB0.70KB
auth (org-roles.js)6.66KB2.78KB
auth (phone-identifier.js)1.11KB0.66KB
auth (types.js)0.59KB0.35KB
auth (useAuth.js)5.30KB1.02KB
auth (useWorkspaceAdminStatus.js)5.13KB2.35KB
collaboration (CommentThread.js)26.08KB7.56KB
collaboration (LiveCursors.js)3.17KB1.27KB
collaboration (PresenceAvatars.js)6.49KB2.64KB
collaboration (PresenceProvider.js)2.79KB1.13KB
collaboration (index.js)1.68KB0.73KB
collaboration (useCollaborationTranslation.js)6.05KB2.52KB
collaboration (useCommentSearch.js)1.98KB0.88KB
collaboration (useConflictResolution.js)7.75KB1.86KB
collaboration (useMentionNotifications.js)1.81KB0.68KB
collaboration (usePresence.js)6.33KB1.84KB
collaboration (useRealtimeSubscription.js)7.91KB2.01KB
components (index.js)512.30KB116.52KB
core (index.js)5.30KB2.13KB
create-plugin (index.js)10.08KB3.26KB
data-objectstack (index.js)177.67KB49.45KB
fields (index.js)243.64KB61.64KB
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.94KB63.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.14KB14.64KB
plugin-list (index.js)113.19KB27.60KB
plugin-map (index.js)20.20KB6.66KB
plugin-markdown (index.js)13.72KB4.69KB
plugin-report (index.js)43.51KB11.94KB
plugin-timeline (index.js)29.34KB8.47KB
plugin-tree (index.js)8.98KB3.08KB
plugin-view (index.js)85.90KB21.12KB
providers (DataSourceProvider.js)0.75KB0.39KB
providers (MetadataProvider.js)1.37KB0.59KB
providers (ThemeProvider.js)1.90KB0.85KB
providers (UploadProvider.js)11.66KB3.50KB
providers (index.js)0.45KB0.23KB
providers (types.js)0.01KB0.04KB
react-runtime (index.js)5.62KB2.34KB
react (LazyPluginLoader.js)4.47KB1.63KB
react (SchemaRenderer.js)81.07KB26.86KB
react (data-invalidation.js)5.05KB2.08KB
react (index.js)3.11KB1.48KB
react (schema-input.js)2.32KB1.24KB
react (spec-input.js)0.20KB0.18KB
sdui-parser (codegen.js)5.41KB2.34KB
sdui-parser (dashboard-widget-options.js)3.08KB1.30KB
sdui-parser (index.js)4.93KB2.24KB
sdui-parser (input-type.js)2.84KB1.40KB
sdui-parser (parse.js)20.57KB5.88KB
sdui-parser (provenance.js)3.66KB1.82KB
sdui-parser (types.js)0.28KB0.23KB
sdui-parser (validate.js)10.35KB3.60KB
types (ai.js)0.20KB0.17KB
types (api-types.js)0.20KB0.18KB
types (app.js)2.87KB0.99KB
types (base.js)0.20KB0.18KB
types (blocks.js)0.20KB0.18KB
types (complex.js)2.74KB1.41KB
types (crud.js)0.20KB0.18KB
types (dashboard-filter-alias.js)6.23KB2.74KB
types (data-display.js)3.75KB1.85KB
types (data-protocol.js)0.20KB0.19KB
types (data.js)0.20KB0.18KB
types (designer.js)1.85KB0.85KB
types (disclosure.js)0.20KB0.18KB
types (error-code.js)1.54KB0.88KB
types (feedback.js)0.20KB0.18KB
types (field-types.js)0.20KB0.18KB
types (form.js)0.20KB0.18KB
types (http-inflight.js)8.87KB3.73KB
types (http-retry.js)4.32KB2.02KB
types (icon-key-migration.js)4.26KB1.63KB
types (index.js)4.72KB2.24KB
types (layout.js)0.20KB0.18KB
types (managed-by.js)0.19KB0.18KB
types (mobile.js)2.59KB1.31KB
types (navigation.js)0.20KB0.18KB
types (objectql.js)0.20KB0.18KB
types (overlay.js)0.20KB0.18KB
types (permissions.js)0.20KB0.18KB
types (plugin-scope.js)0.20KB0.18KB
types (record-components.js)0.20KB0.19KB
types (record-semantics.js)1.28KB0.67KB
types (registry.js)0.20KB0.18KB
types (reports.js)0.20KB0.18KB
types (spec-report.js)5.05KB1.93KB
types (spec-ui-namespace.js)0.20KB0.19KB
types (system-fields.js)3.33KB1.54KB
types (theme.js)6.28KB2.87KB
types (ui-action.js)3.40KB1.71KB
types (views.js)0.20KB0.18KB
types (widget.js)0.20KB0.18KB

Size Limits

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

@os-warrenClaude

Copy link
Copy Markdown
CollaboratorAuthor

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

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

⭐⭐ You declined the shared abstraction, and the forbidden path was the cheaper one

DataEmptyState in packages/components/src/custom/view-states.tsx is already consumed by plugin-list, plugin-kanban, plugin-dashboardand plugin-detail — the exact surface of the parallel card #7064. Changing its defaults would have been a smaller diff repairing both surfaces at once.

That is the strongest possible test of the fence, and it held. A cross-surface abstraction designed simultaneously by two agents who cannot see each other's reasoning is worse than two honest local fixes — and it would have been invisible as a problem, because the diff would have looked elegant. You consumed the primitive unchanged, kept the new component dashboard-local, recorded why in the file header, and touched nothing under plugin-detail. ⭐ Recording the temptation is as valuable as resisting it — it tells me convergence is worth a sequenced card once both land, which I can now schedule instead of discover.

Both of the card's premises were false, and neither failure was papered over

  1. "One shared placeholder reached by all the affected widgets." ⛔ False: three surfaces, two different keys, no shared code — and the measured string dashboard.noRows had exactly one call site repo-wide. ⇒ There was no seam to repair; one had to be created. That is a materially different job from the one the card describes, and it is why a new component exists rather than a one-line default change.
  2. "The authored emptyState contract already exists and already overrides." ⛔ False on this surface: emptyState is a list-view contract. Measured 0 occurrences across plugin-dashboard/src and plugin-charts/src⚠️ against a control of 1154 hits for widget over the same paths — and spec 17.2.0's DashboardWidgetSchema, read from the built package at runtime, declares no such key.

⭐ Clause 3 is vacuous here, and you were right that this is not a stop-and-report

My order made clause 3's stop-and-report fence hard, because a spec key added on a hunch becomes an unlandable PR under the quota freeze. You reached the fence and correctly did not trigger it: the deliverable is a better default, and a default needs no authoring surface. Nothing was widened, so nothing was owed.

⚠️ But the fact stands and is worth the maintainer's eye: the ruling's "the existing authored contract always overrides" describes, on this surface, a contract that does not exist. Same shape as #7064's finding an hour ago, where the ruling's authored opt-out turned out to be refused by the spec. Two rulings in one round whose preservation clause protects something that isn't there. I'm routing that pattern rather than treating each as a one-off.

A failed control, caught and re-taken — the discipline that matters most

Your first control for "WidgetEmptyState is not exported from index.tsx" used DatasetWidget, which also returned 0. Rather than reading that as confirmation, you discarded the reading and re-took it with PivotTable (= 1). ⭐ A control that returns zero is not a weak control, it is no control — and DatasetWidget being internal is exactly the kind of thing that makes a plausible control silently useless. This is the sixth instrument failure surfaced on this lane today and the second caught by its own operator mid-measurement.

Ablation: two legs, both per-surface

Leg 1 (revert DatasetWidget's empty branch) reddened 4 cases confined to that surface while table and pivot cases stayed green — proving the pins resolve per surface rather than firing as a block, which a single seam makes easy to get wrong. Leg 2 (delete role="status") reddened the role assertions while "a load FAILURE still reads as a failure" stayed green, showing the two halves of requirement 1 are independent. Directions predicted before running; both matched.

The copy decision is right, and its restraint is the reason

Naming the data source as a raw metadata identifier (crm_forecast) as a labelled monospace value, rather than folding it into a friendly sentence, is a truthful narrow statement over an invented one — and declining to use widget.title because DashboardRenderer already renders the resolved title directly above avoids saying the same thing twice. role="status" against the neighbouring role="alert" is what makes requirement 1 structural rather than cosmetic.

The i18n handling — additive after each pack's existing anchor, verified unique in all ten before writing, punctuation carried inside the key so no separator is concatenated and RTL needs no special case — is exactly what keeps the parallel packages/i18n work merging trivially.

NOT MEASURED, reported as such, with narrowing arguments

check:readme-exports (population COLLAPSED, 12 packages read against a floor of 25) and check:eager-closure (no apps/console/dist/eager-closure.json"a broken gauge, not a sensitive gate"). ⭐ Both carry a reason and the first carries a narrowing that is itself controlled: index.tsx is untouched and does not export the new component (control: PivotTable, which it does export, hits 1). CI owns both.

Open question 1 — emptyState for dashboard widgets: I adopt A

No key added. Your reasoning holds and I'll add the decisive point: B is currently unbuildable-to-land — it is a public authoring-surface widening, so it needs the contract review the exhausted Fable 5 quota blocks, and it would join #6896 / PR #7111 in the stranded state. Beyond that, adding a per-widget authored-prose key one card after a ruling closed the per-app-compensation route would undo the ruling with its own follow-up. Zero measured pull. Recorded on #7129 alongside #7064's sibling question.

Open question 2 — I am filing it; here is why I am overriding your restraint

You declined to file the ObjectChart observation because the state may be intentional. That restraint was right by default, and I'd rather see it than a reflexive card. But I'm filing it, on three grounds: it is a measured absence (no empty branch at all, not a judgement about quality), the seam you just built makes the fix small, and an observation that lives only in a subagent report is lost. The card carries your framing verbatim — may be intentional, needs a reading before any fix — so it records the fact without pre-judging it. Filed as #7130.

#7125

Correctly filed and correctly not acted on: the dead keys are residue this PR creates, the dispatch asked locale edits to stay additive for trivial parallel merges, and the dead-key gate is report-only and wired into no workflow. Deleting them here would have traded a clean merge for a cosmetic win.


Generated by Claude Code

@os-warren
os-warren marked this pull request as ready for review September 1, 2026 03:32
@os-warren
os-warren added this pull request to the merge queueSep 1, 2026
Merged via the queue into main with commit 220c18dSep 1, 2026
32 checks passed
@os-warren
os-warren deleted the claude/issue-7063-widget-empty-state-default branch September 1, 2026 03:45
os-warren pushed a commit that referenced this pull request Sep 1, 2026
…r an empty result
Measured, not assumed: recharts derives ticks from the data, so an empty
result draws two hairline axis rules and zero text nodes (bar/line) or
nothing at all (pie) — there are no labelled axes to read. Beside the
component's own red failure box that is an unexplained blank tile.
Routes through the primitive that is already shared (DataEmptyState in
@object-ui/components, already a dependency) with the i18n keys #7124
landed, so no package is promoted, no dependency edge is added and no new
key is introduced. Charts with inline authored data are unchanged.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012wwHa4aaFybxXrfmfHioDM
akarma-synetal pushed a commit to akarma-synetal/objectui that referenced this pull request Sep 1, 2026
…r an empty result (objectstack-ai#7139)
* fix(plugin-charts): a self-describing empty state for ObjectChart over an empty result
Measured, not assumed: recharts derives ticks from the data, so an empty
result draws two hairline axis rules and zero text nodes (bar/line) or
nothing at all (pie) — there are no labelled axes to read. Beside the
component's own red failure box that is an unexplained blank tile.
Routes through the primitive that is already shared (DataEmptyState in
@object-ui/components, already a dependency) with the i18n keys objectstack-ai#7124
landed, so no package is promoted, no dependency edge is added and no new
key is introduced. Charts with inline authored data are unchanged.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012wwHa4aaFybxXrfmfHioDM
* test(app-shell): give the two object-bound chart sweep targets rows
The DOM-leak sweep mounts `plugin-charts:object-chart` and `view:chart`
against an adapter that answers every query with no rows. Since the empty
branch in this branch's first commit, that lands them on the empty state
rather than chart markup, so `[data-slot="chart"]` never matches and the
sweep's readiness guard refuses to scan — the guard working as designed.
Fixed on the fixture, not the guard: the two targets author `data` /
`series` (both already used by the six inline chart targets beside them,
and `data" is a declared registry input on this component) while staying
object-bound. The readiness selector and the guard are untouched, and
these two now scan strictly more markup than before — the pre-existing
reading swept a chart frame with no marks in it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012wwHa4aaFybxXrfmfHioDM
---------
Co-authored-by: Claude <noreply@anthropic.com>
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

1 participant

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

fix(plugin-dashboard,i18n): a self-explaining default empty state for dashboard widgets - #7124

Merged
os-warren merged 1 commit into
mainfrom
claude/issue-7063-widget-empty-state-default
Sep 1, 2026
Merged

fix(plugin-dashboard,i18n): a self-explaining default empty state for dashboard widgets#7124
os-warren merged 1 commit into
mainfrom
claude/issue-7063-widget-empty-state-default

Conversation

@os-warren

Copy link
Copy Markdown
Collaborator

Fixes#7063

Maintainer ruling 2026-08-31 (hotcrm#1212 adjudication, verbatim 「1212 也是」, following 「hotcrm#1203 …这种也是平台的问题啊」): this class is the platform's to fix uniformly; apps must not compensate widget by widget (objectstack#13848 — apps are simplified business implementations, uniform behaviour belongs to the platform).

All evidence below was measured at 79accd6 (the final commit on this branch).


The card's premise was half wrong, and that changed the shape of the fix

Two of the dispatch's stated assumptions did not survive measurement. Both are reported here rather than worked around.

1. 暂无数据行 is NOT one shared placeholder — there was no seam to fix

Three dashboard surfaces wrote their own empty state, in two different strings, with no common code:

surfacekeyenzh
DatasetWidget (the measured one)dashboard.noRowsNo rows暂无数据行
ObjectDataTabledashboard.noDataAvailableNo data available暂无数据
PivotTabledashboard.noDataAvailableNo data available暂无数据

dashboard.noRows had exactly one call site repo-wide (DatasetWidget.tsx:727), so the measured 暂无数据行 came from the dataset-bound path only — which is the ADR-0021 governed path the hotcrm quota-attainment tile takes.

So "uniformly" could not mean "fix the shared placeholder". The new WidgetEmptyStateis the seam: it is added, and the three surfaces are routed through it.

2. The authored emptyState override does not exist on this surface

Clause 3 assumes an existing authored emptyState contract that always overrides. Measured:

  • emptyState occurs 0 times in packages/plugin-dashboard/src and packages/plugin-charts/src. Control in the same sweep, same paths: widget, 1154 hits — so the zero is a reading, not a broken query.

  • emptyState is a list-view contract (ObjectGridSchema, NamedListView in packages/types/src/objectql.ts), honoured in packages/plugin-list/src/ListView.tsx.

  • @objectstack/spec@17.2.0's DashboardWidgetSchema declares no such key. Its full key set, read from the built package at runtime rather than recalled:

    id, title, description, type, chartConfig, colorVariant, requiresObject, requiresService, actionUrl, actionType, actionIcon, filter, compareTo, dataset, dimensions, values, layout, options, filterBindings, suppressWarnings, responsive, aria

Consequence, and why this is not the stop-and-report case. Clause 3 is satisfied here vacuously: there is no authored override on dashboard widgets for the default to lose to, so improving the default cannot displace one. No spec key was added and none is needed to deliver this card — the deliverable is a better DEFAULT, and defaults need no authoring surface. Making clause 3 non-vacuous (i.e. giving dashboard widgets a real authored override) would require a new spec key, which is the contract question the card fences off. That is flagged for the seat, not taken.


What changed

New:packages/plugin-dashboard/src/WidgetEmptyState.tsx — the dashboard-local seam.

The ruling's four properties, and how each is delivered:

  1. Distinguishable from a load failure at a glance.role="status", where the failure branches sitting beside these call sites are role="alert" on destructive colours. The empty branches previously carried no role at all, so assistive tech got a bare fragment with no state either way. Visually: muted treatment and an inbox glyph, never destructive colour and a warning triangle.
  2. Self-describing without authored copy. A title and an explanation where the placeholder was one terse fragment, plus the name of the widget's data source. The copy states that the widget loaded successfully — the one fact a reader of a blank tile cannot otherwise get.
  3. No new authoring obligation, no new spec key. Nothing was added to any schema. PivotTable gains one optional component prop (sourceLabel), which is React-side only.
  4. Platform i18n.dashboard.empty.title / .message / .sourceLabel in en and all nine sibling packs.

What source names, and why it is a raw identifier. The card allows the widget's label and/or its data source. The widget's title is already rendered by the card header directly above every tile (DashboardRenderer), so repeating it says nothing new; the data source is the half the reader cannot see. What is reachable at each render site is the authored binding — widget.dataset on the dataset path, schema.objectName on the object-bound table/pivot — i.e. a raw metadata name such as crm_forecast, not a localized label. It is rendered as a labelled monospace value rather than folded into a sentence: a truthful narrow statement beats an invented friendly one.

Why the copy carries no interpolation.useSafeTranslate takes a positional fallback and no options bag, so an interpolated key would have to come from a raw useObjectTranslationt() — and with no I18nProvider mounted (the standalone-host and test configuration, which vitest.setup.i18n-global.ts deliberately preserves) that renders the raw KEY unless the call site also carries an inline defaultValue, which #3517 rules out. A label that carries its own punctuation (Source: / 数据源: / مصدر البيانات:) needs neither, and concatenates no separator in code.

Files:WidgetEmptyState.tsx (new) · DatasetWidget.tsx · ObjectDataTable.tsx · PivotTable.tsx · ObjectPivotTable.tsx · ten locale packs · one new test file · DatasetWidget.test.tsx · one changeset.


Evidence

Tests

pnpm exec vitest run packages/plugin-dashboard packages/i18n packages/app-shell/src/__tests__/widget-dom-leak-sweep.test.tsx --maxWorkers=2 at 79accd6:

 Test Files 144 passed (144)
Tests 1924 passed (1924)

New pin: packages/plugin-dashboard/src/__tests__/WidgetEmptyState.uniformDefault.test.tsx (5 cases). It pins the ruling's properties, never the sentence — copy is review's to move. It also pins that all four dashboard entry points reach the one seam, which is the property that decays first: a fourth widget can quietly write a fifth placeholder while every per-widget test stays green.

Four pins in DatasetWidget.test.tsx were rewritten rather than left passing. Three of them asserted queryByText('No rows')).not.toBeInTheDocument() — after this change that string exists nowhere, so they would have passed for the wrong reason. They now select the empty state by test id.

Ablation — two legs, direction predicted before running

Leg 1 — revert DatasetWidget's empty branch to the pre-card dashboard.noRows one-liner. Predicted: RED, and only on the DatasetWidget surface.

  • mutation proven on disk: removed-marker WidgetEmptyState (as a JSX open tag) 1 to 0; injected-marker tt('dashboard.noRows' 0 to 1; blob f8f61e71... to 951c8251...
  • observed: Tests 4 failed | 59 passed, vitest exit 1 (read from the run's own printed ABLATED_VITEST_EXIT=1, not a bare $? behind a pipe). All four failures on the DatasetWidget surface; the ObjectDataTable, PivotTable and ObjectPivotTable cases stayed green, so the pins resolve per surface rather than firing as a block.
  • restore proven by state: git diff HEAD empty, git status --porcelain empty, blob back to f8f61e712947e3cdf3dacf3cdab3e6a8cccd25ba = its HEAD blob.

Leg 2 — delete role="status" from the shared component, i.e. ablate property (1) itself. Predicted: RED on the role assertions; the load-failure test stays green.

  • mutation proven on disk: marker role="status" 2 to 1 (the second occurrence is in the file's doc comment; the python anchor asserted exactly one match on the JSX line); blob a62aaa96... to 39a46faf...
  • observed: Tests 4 failed | 59 passed, exit 1 — the three seam cases plus the DatasetWidget.test.tsx role case. a load FAILURE still reads as a failure stayed green, so the two halves of property (1) are independent.
  • restore proven by state: git diff HEAD empty, blob back to a62aaa96ec88cede54f96579451a6922b3aceb3a.

Both legs used an EXIT INT TERM trap with an absolute REPO_ROOT path, and restored with git checkout HEAD -- path (never a bare git checkout -- path, which restores from the index and would hand back the mutation).

No rebuild leg, stated rather than skipped. This repo's vitest resolves @object-ui/* to packages/*/src via resolve.alias in vitest.config.mts, and the pins import the components by relative path, so no dist can shadow the mutation. A stale build cannot make an ablation falsely green here.

Gates

gateexitverdict
vitest (plugin-dashboard, i18n, widget-dom-leak-sweep)0green — 144 files, 1924 tests
pnpm --filter @object-ui/plugin-dashboard --filter @object-ui/i18n run type-check0green — both ran tsc --noEmit && tsc -p tsconfig.test.json, so the new test file was type-checked, not merely excluded
pnpm --filter @object-ui/plugin-dashboard --filter @object-ui/i18n run lint0green — 0 errors (423 + 34 pre-existing warnings; 4 of them no-explicit-any on the new test file, the same schema-cast shape its sibling suites use)
check:i18n-keys0green — "Every in-scope call-site key resolves against the en pack (2845 keys)…"
check:i18n-drift0green — "0 en value(s) changed (3 key(s) added, 0 removed…)" — strictly additive
check:control-bytes0green — "OK (scanned 5898 tracked text file(s); skipped 85 binary)"
check-changeset-presence0green — "17 source file(s) of 2 released package(s) changed, and this change declares 1 changeset(s)"
check:changeset-fixed0green — "All workspace packages are in the changeset fixed group."
check:changeset-no-major0green — "No changeset declares a major bump."
check:phantom-deps / check:self-import / check:side-effects-array / check:vi-mock-specifiers / check:vi-mock-inherit / check:esm-specifiers0green
check:readme-exports1NOT MEASURED — "the population COLLAPSED -- this run proves nothing / packagesRead: found 12, floor is 25". Local tree has most packages unbuilt; CI runs turbo run build first. Narrowing argument: packages/plugin-dashboard/src/index.tsx is untouched and does not export WidgetEmptyState (control in the same query: PivotTable, which it does export, hits), so the package's published export surface is unchanged and no README moved.
check:eager-closure2NOT MEASURED — "No eager-closure report at apps/console/dist/eager-closure.json … This is a broken gauge, not a sensitive gate." Needs a built apps/console; produced by performance-budget.yml in CI.

Changeset verdicts are quoted verbatim above rather than predicted; both gates were run.

Zero-hit readings, each with its control

queryscopehitscontrolcontrol hits
emptyStateplugin-dashboard/src + plugin-charts/src0widget, same scope1154
WidgetEmptyStateplugin-dashboard/src/index.tsx0PivotTable, same file1

What is NOT verified here, and why

The hotcrm acceptance scenario is unverified by me. The card's acceptance is a hotcrm sales_dashboard fresh install. hotcrm is outside this session's repo scope — it is not attached and I did not try to reach it. What I can state is the objectui-side mechanism: the quota-attainment tile is a dataset-bound table widget, which renders through DatasetWidget, whose empty branch is the one call site of the 暂无数据行 string this PR replaces. The end-to-end confirmation on a fresh hotcrm install still needs to be run by someone with that repo.

Shared-abstraction temptation, reported rather than taken.DataEmptyState in @object-ui/components is the presentational primitive already used by plugin-list, plugin-kanban, plugin-dashboardand plugin-detail — the surface of the parallel card #7064. Changing its defaults would have been a smaller diff that repaired both surfaces at once. It is deliberately consumed unchanged here: a shared empty-state abstraction designed twice in parallel by two agents is worse than two honest local fixes, and converging the two is its own sequenced card. Nothing under packages/plugin-detail is touched by this PR.

Residue this creates, not fixed here.dashboard.noRows and dashboard.noDataAvailable now have no call site. check:i18n-dead-keys (report-only, exit 0, not wired into CI) lists both as needs-review after this change. They are left in all ten packs deliberately: the dispatch asks locale edits to stay additive so parallel merges stay trivial, and the dead-key sweep is its own tracked workstream (objectui#4658). Filed as a follow-up rather than ridden on this PR.


Generated by Claude Code

… dashboard widgets
Maintainer ruling 2026-08-31 (hotcrm#1212, following hotcrm#1203): a widget that
renders a bare row-placeholder on an empty result is the platform's defect and
must be fixed uniformly — apps must not compensate widget by widget
(objectstack#13848).
There was no shared placeholder to fix. Three dashboard surfaces wrote their own,
in two different strings: DatasetWidget used `dashboard.noRows` ('No rows' /
暂无数据行), while ObjectDataTable and PivotTable used `dashboard.noDataAvailable`.
`WidgetEmptyState` is the seam they now share.
The default reads as a STATE rather than a failure — `role="status"` where the
branches beside it are `role="alert"` and the empty branches previously carried
no role at all — carries a title plus an explanation instead of one terse
fragment, and names the widget's data source with zero authored copy.
Copy is platform i18n: `dashboard.empty.title` / `.message` / `.sourceLabel` in
`en` and all nine sibling packs. No inline defaultValue, no interpolation.
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)3151.9 KB3191.4 KB
Main entry chunk (gzip)142.3 KB350 KB
Entry fileindex-DbjcEXZE.js
StatusPASS

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


📦 Bundle Size Report

PackageSizeGzipped
app-shell (consoleActionDispatch.js)0.20KB0.19KB
app-shell (index.js)15.33KB5.59KB
app-shell (runtime-config.js)20.68KB7.36KB
app-shell (types.js)0.01KB0.04KB
app-shell (urlParams.js)10.06KB3.86KB
auth (ActiveOrganizationStorage.js)25.05KB9.16KB
auth (AuthContext.js)0.31KB0.24KB
auth (AuthGuard.js)2.07KB1.00KB
auth (AuthProvider.js)40.18KB10.59KB
auth (AuthShell.js)3.49KB1.40KB
auth (ForgotPasswordForm.js)12.21KB3.45KB
auth (LoginForm.js)18.15KB5.39KB
auth (PreviewBanner.js)0.90KB0.50KB
auth (RegisterForm.js)6.65KB2.22KB
auth (SocialSignInButtons.js)9.61KB3.89KB
auth (UserMenu.js)3.41KB1.23KB
auth (auth-gate-events.js)1.29KB0.66KB
auth (authStyles.js)5.04KB1.72KB
auth (createAuthClient.js)40.21KB10.80KB
auth (createAuthenticatedFetch.js)8.46KB3.43KB
auth (index.js)3.19KB1.44KB
auth (invitation-status.js)1.22KB0.70KB
auth (org-roles.js)6.66KB2.78KB
auth (phone-identifier.js)1.11KB0.66KB
auth (types.js)0.59KB0.35KB
auth (useAuth.js)5.30KB1.02KB
auth (useWorkspaceAdminStatus.js)5.13KB2.35KB
collaboration (CommentThread.js)26.08KB7.56KB
collaboration (LiveCursors.js)3.17KB1.27KB
collaboration (PresenceAvatars.js)6.49KB2.64KB
collaboration (PresenceProvider.js)2.79KB1.13KB
collaboration (index.js)1.68KB0.73KB
collaboration (useCollaborationTranslation.js)6.05KB2.52KB
collaboration (useCommentSearch.js)1.98KB0.88KB
collaboration (useConflictResolution.js)7.75KB1.86KB
collaboration (useMentionNotifications.js)1.81KB0.68KB
collaboration (usePresence.js)6.33KB1.84KB
collaboration (useRealtimeSubscription.js)7.91KB2.01KB
components (index.js)512.30KB116.52KB
core (index.js)5.30KB2.13KB
create-plugin (index.js)10.08KB3.26KB
data-objectstack (index.js)177.67KB49.45KB
fields (index.js)243.64KB61.64KB
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.94KB63.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.14KB14.64KB
plugin-list (index.js)113.19KB27.60KB
plugin-map (index.js)20.20KB6.66KB
plugin-markdown (index.js)13.72KB4.69KB
plugin-report (index.js)43.51KB11.94KB
plugin-timeline (index.js)29.34KB8.47KB
plugin-tree (index.js)8.98KB3.08KB
plugin-view (index.js)85.90KB21.12KB
providers (DataSourceProvider.js)0.75KB0.39KB
providers (MetadataProvider.js)1.37KB0.59KB
providers (ThemeProvider.js)1.90KB0.85KB
providers (UploadProvider.js)11.66KB3.50KB
providers (index.js)0.45KB0.23KB
providers (types.js)0.01KB0.04KB
react-runtime (index.js)5.62KB2.34KB
react (LazyPluginLoader.js)4.47KB1.63KB
react (SchemaRenderer.js)81.07KB26.86KB
react (data-invalidation.js)5.05KB2.08KB
react (index.js)3.11KB1.48KB
react (schema-input.js)2.32KB1.24KB
react (spec-input.js)0.20KB0.18KB
sdui-parser (codegen.js)5.41KB2.34KB
sdui-parser (dashboard-widget-options.js)3.08KB1.30KB
sdui-parser (index.js)4.93KB2.24KB
sdui-parser (input-type.js)2.84KB1.40KB
sdui-parser (parse.js)20.57KB5.88KB
sdui-parser (provenance.js)3.66KB1.82KB
sdui-parser (types.js)0.28KB0.23KB
sdui-parser (validate.js)10.35KB3.60KB
types (ai.js)0.20KB0.17KB
types (api-types.js)0.20KB0.18KB
types (app.js)2.87KB0.99KB
types (base.js)0.20KB0.18KB
types (blocks.js)0.20KB0.18KB
types (complex.js)2.74KB1.41KB
types (crud.js)0.20KB0.18KB
types (dashboard-filter-alias.js)6.23KB2.74KB
types (data-display.js)3.75KB1.85KB
types (data-protocol.js)0.20KB0.19KB
types (data.js)0.20KB0.18KB
types (designer.js)1.85KB0.85KB
types (disclosure.js)0.20KB0.18KB
types (error-code.js)1.54KB0.88KB
types (feedback.js)0.20KB0.18KB
types (field-types.js)0.20KB0.18KB
types (form.js)0.20KB0.18KB
types (http-inflight.js)8.87KB3.73KB
types (http-retry.js)4.32KB2.02KB
types (icon-key-migration.js)4.26KB1.63KB
types (index.js)4.72KB2.24KB
types (layout.js)0.20KB0.18KB
types (managed-by.js)0.19KB0.18KB
types (mobile.js)2.59KB1.31KB
types (navigation.js)0.20KB0.18KB
types (objectql.js)0.20KB0.18KB
types (overlay.js)0.20KB0.18KB
types (permissions.js)0.20KB0.18KB
types (plugin-scope.js)0.20KB0.18KB
types (record-components.js)0.20KB0.19KB
types (record-semantics.js)1.28KB0.67KB
types (registry.js)0.20KB0.18KB
types (reports.js)0.20KB0.18KB
types (spec-report.js)5.05KB1.93KB
types (spec-ui-namespace.js)0.20KB0.19KB
types (system-fields.js)3.33KB1.54KB
types (theme.js)6.28KB2.87KB
types (ui-action.js)3.40KB1.71KB
types (views.js)0.20KB0.18KB
types (widget.js)0.20KB0.18KB

Size Limits

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

@os-warrenClaude

Copy link
Copy Markdown
CollaboratorAuthor

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

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

⭐⭐ You declined the shared abstraction, and the forbidden path was the cheaper one

DataEmptyState in packages/components/src/custom/view-states.tsx is already consumed by plugin-list, plugin-kanban, plugin-dashboardand plugin-detail — the exact surface of the parallel card #7064. Changing its defaults would have been a smaller diff repairing both surfaces at once.

That is the strongest possible test of the fence, and it held. A cross-surface abstraction designed simultaneously by two agents who cannot see each other's reasoning is worse than two honest local fixes — and it would have been invisible as a problem, because the diff would have looked elegant. You consumed the primitive unchanged, kept the new component dashboard-local, recorded why in the file header, and touched nothing under plugin-detail. ⭐ Recording the temptation is as valuable as resisting it — it tells me convergence is worth a sequenced card once both land, which I can now schedule instead of discover.

Both of the card's premises were false, and neither failure was papered over

  1. "One shared placeholder reached by all the affected widgets." ⛔ False: three surfaces, two different keys, no shared code — and the measured string dashboard.noRows had exactly one call site repo-wide. ⇒ There was no seam to repair; one had to be created. That is a materially different job from the one the card describes, and it is why a new component exists rather than a one-line default change.
  2. "The authored emptyState contract already exists and already overrides." ⛔ False on this surface: emptyState is a list-view contract. Measured 0 occurrences across plugin-dashboard/src and plugin-charts/src⚠️ against a control of 1154 hits for widget over the same paths — and spec 17.2.0's DashboardWidgetSchema, read from the built package at runtime, declares no such key.

⭐ Clause 3 is vacuous here, and you were right that this is not a stop-and-report

My order made clause 3's stop-and-report fence hard, because a spec key added on a hunch becomes an unlandable PR under the quota freeze. You reached the fence and correctly did not trigger it: the deliverable is a better default, and a default needs no authoring surface. Nothing was widened, so nothing was owed.

⚠️ But the fact stands and is worth the maintainer's eye: the ruling's "the existing authored contract always overrides" describes, on this surface, a contract that does not exist. Same shape as #7064's finding an hour ago, where the ruling's authored opt-out turned out to be refused by the spec. Two rulings in one round whose preservation clause protects something that isn't there. I'm routing that pattern rather than treating each as a one-off.

A failed control, caught and re-taken — the discipline that matters most

Your first control for "WidgetEmptyState is not exported from index.tsx" used DatasetWidget, which also returned 0. Rather than reading that as confirmation, you discarded the reading and re-took it with PivotTable (= 1). ⭐ A control that returns zero is not a weak control, it is no control — and DatasetWidget being internal is exactly the kind of thing that makes a plausible control silently useless. This is the sixth instrument failure surfaced on this lane today and the second caught by its own operator mid-measurement.

Ablation: two legs, both per-surface

Leg 1 (revert DatasetWidget's empty branch) reddened 4 cases confined to that surface while table and pivot cases stayed green — proving the pins resolve per surface rather than firing as a block, which a single seam makes easy to get wrong. Leg 2 (delete role="status") reddened the role assertions while "a load FAILURE still reads as a failure" stayed green, showing the two halves of requirement 1 are independent. Directions predicted before running; both matched.

The copy decision is right, and its restraint is the reason

Naming the data source as a raw metadata identifier (crm_forecast) as a labelled monospace value, rather than folding it into a friendly sentence, is a truthful narrow statement over an invented one — and declining to use widget.title because DashboardRenderer already renders the resolved title directly above avoids saying the same thing twice. role="status" against the neighbouring role="alert" is what makes requirement 1 structural rather than cosmetic.

The i18n handling — additive after each pack's existing anchor, verified unique in all ten before writing, punctuation carried inside the key so no separator is concatenated and RTL needs no special case — is exactly what keeps the parallel packages/i18n work merging trivially.

NOT MEASURED, reported as such, with narrowing arguments

check:readme-exports (population COLLAPSED, 12 packages read against a floor of 25) and check:eager-closure (no apps/console/dist/eager-closure.json"a broken gauge, not a sensitive gate"). ⭐ Both carry a reason and the first carries a narrowing that is itself controlled: index.tsx is untouched and does not export the new component (control: PivotTable, which it does export, hits 1). CI owns both.

Open question 1 — emptyState for dashboard widgets: I adopt A

No key added. Your reasoning holds and I'll add the decisive point: B is currently unbuildable-to-land — it is a public authoring-surface widening, so it needs the contract review the exhausted Fable 5 quota blocks, and it would join #6896 / PR #7111 in the stranded state. Beyond that, adding a per-widget authored-prose key one card after a ruling closed the per-app-compensation route would undo the ruling with its own follow-up. Zero measured pull. Recorded on #7129 alongside #7064's sibling question.

Open question 2 — I am filing it; here is why I am overriding your restraint

You declined to file the ObjectChart observation because the state may be intentional. That restraint was right by default, and I'd rather see it than a reflexive card. But I'm filing it, on three grounds: it is a measured absence (no empty branch at all, not a judgement about quality), the seam you just built makes the fix small, and an observation that lives only in a subagent report is lost. The card carries your framing verbatim — may be intentional, needs a reading before any fix — so it records the fact without pre-judging it. Filed as #7130.

#7125

Correctly filed and correctly not acted on: the dead keys are residue this PR creates, the dispatch asked locale edits to stay additive for trivial parallel merges, and the dead-key gate is report-only and wired into no workflow. Deleting them here would have traded a clean merge for a cosmetic win.


Generated by Claude Code

@os-warren
os-warren marked this pull request as ready for review September 1, 2026 03:32
@os-warren
os-warren added this pull request to the merge queueSep 1, 2026
Merged via the queue into main with commit 220c18dSep 1, 2026
32 checks passed
@os-warren
os-warren deleted the claude/issue-7063-widget-empty-state-default branch September 1, 2026 03:45
os-warren pushed a commit that referenced this pull request Sep 1, 2026
…r an empty result
Measured, not assumed: recharts derives ticks from the data, so an empty
result draws two hairline axis rules and zero text nodes (bar/line) or
nothing at all (pie) — there are no labelled axes to read. Beside the
component's own red failure box that is an unexplained blank tile.
Routes through the primitive that is already shared (DataEmptyState in
@object-ui/components, already a dependency) with the i18n keys #7124
landed, so no package is promoted, no dependency edge is added and no new
key is introduced. Charts with inline authored data are unchanged.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012wwHa4aaFybxXrfmfHioDM
akarma-synetal pushed a commit to akarma-synetal/objectui that referenced this pull request Sep 1, 2026
…r an empty result (objectstack-ai#7139)
* fix(plugin-charts): a self-describing empty state for ObjectChart over an empty result
Measured, not assumed: recharts derives ticks from the data, so an empty
result draws two hairline axis rules and zero text nodes (bar/line) or
nothing at all (pie) — there are no labelled axes to read. Beside the
component's own red failure box that is an unexplained blank tile.
Routes through the primitive that is already shared (DataEmptyState in
@object-ui/components, already a dependency) with the i18n keys objectstack-ai#7124
landed, so no package is promoted, no dependency edge is added and no new
key is introduced. Charts with inline authored data are unchanged.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012wwHa4aaFybxXrfmfHioDM
* test(app-shell): give the two object-bound chart sweep targets rows
The DOM-leak sweep mounts `plugin-charts:object-chart` and `view:chart`
against an adapter that answers every query with no rows. Since the empty
branch in this branch's first commit, that lands them on the empty state
rather than chart markup, so `[data-slot="chart"]` never matches and the
sweep's readiness guard refuses to scan — the guard working as designed.
Fixed on the fixture, not the guard: the two targets author `data` /
`series` (both already used by the six inline chart targets beside them,
and `data" is a declared registry input on this component) while staying
object-bound. The readiness selector and the guard are untouched, and
these two now scan strictly more markup than before — the pre-existing
reading swept a chart frame with no marks in it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012wwHa4aaFybxXrfmfHioDM
---------
Co-authored-by: Claude <noreply@anthropic.com>
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

1 participant

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

fix(plugin-dashboard,i18n): a self-explaining default empty state for dashboard widgets - #7124

Merged
os-warren merged 1 commit into
mainfrom
claude/issue-7063-widget-empty-state-default
Sep 1, 2026
Merged

fix(plugin-dashboard,i18n): a self-explaining default empty state for dashboard widgets#7124
os-warren merged 1 commit into
mainfrom
claude/issue-7063-widget-empty-state-default

Conversation

@os-warren

Copy link
Copy Markdown
Collaborator

Fixes#7063

Maintainer ruling 2026-08-31 (hotcrm#1212 adjudication, verbatim 「1212 也是」, following 「hotcrm#1203 …这种也是平台的问题啊」): this class is the platform's to fix uniformly; apps must not compensate widget by widget (objectstack#13848 — apps are simplified business implementations, uniform behaviour belongs to the platform).

All evidence below was measured at 79accd6 (the final commit on this branch).


The card's premise was half wrong, and that changed the shape of the fix

Two of the dispatch's stated assumptions did not survive measurement. Both are reported here rather than worked around.

1. 暂无数据行 is NOT one shared placeholder — there was no seam to fix

Three dashboard surfaces wrote their own empty state, in two different strings, with no common code:

surfacekeyenzh
DatasetWidget (the measured one)dashboard.noRowsNo rows暂无数据行
ObjectDataTabledashboard.noDataAvailableNo data available暂无数据
PivotTabledashboard.noDataAvailableNo data available暂无数据

dashboard.noRows had exactly one call site repo-wide (DatasetWidget.tsx:727), so the measured 暂无数据行 came from the dataset-bound path only — which is the ADR-0021 governed path the hotcrm quota-attainment tile takes.

So "uniformly" could not mean "fix the shared placeholder". The new WidgetEmptyStateis the seam: it is added, and the three surfaces are routed through it.

2. The authored emptyState override does not exist on this surface

Clause 3 assumes an existing authored emptyState contract that always overrides. Measured:

  • emptyState occurs 0 times in packages/plugin-dashboard/src and packages/plugin-charts/src. Control in the same sweep, same paths: widget, 1154 hits — so the zero is a reading, not a broken query.

  • emptyState is a list-view contract (ObjectGridSchema, NamedListView in packages/types/src/objectql.ts), honoured in packages/plugin-list/src/ListView.tsx.

  • @objectstack/spec@17.2.0's DashboardWidgetSchema declares no such key. Its full key set, read from the built package at runtime rather than recalled:

    id, title, description, type, chartConfig, colorVariant, requiresObject, requiresService, actionUrl, actionType, actionIcon, filter, compareTo, dataset, dimensions, values, layout, options, filterBindings, suppressWarnings, responsive, aria

Consequence, and why this is not the stop-and-report case. Clause 3 is satisfied here vacuously: there is no authored override on dashboard widgets for the default to lose to, so improving the default cannot displace one. No spec key was added and none is needed to deliver this card — the deliverable is a better DEFAULT, and defaults need no authoring surface. Making clause 3 non-vacuous (i.e. giving dashboard widgets a real authored override) would require a new spec key, which is the contract question the card fences off. That is flagged for the seat, not taken.


What changed

New:packages/plugin-dashboard/src/WidgetEmptyState.tsx — the dashboard-local seam.

The ruling's four properties, and how each is delivered:

  1. Distinguishable from a load failure at a glance.role="status", where the failure branches sitting beside these call sites are role="alert" on destructive colours. The empty branches previously carried no role at all, so assistive tech got a bare fragment with no state either way. Visually: muted treatment and an inbox glyph, never destructive colour and a warning triangle.
  2. Self-describing without authored copy. A title and an explanation where the placeholder was one terse fragment, plus the name of the widget's data source. The copy states that the widget loaded successfully — the one fact a reader of a blank tile cannot otherwise get.
  3. No new authoring obligation, no new spec key. Nothing was added to any schema. PivotTable gains one optional component prop (sourceLabel), which is React-side only.
  4. Platform i18n.dashboard.empty.title / .message / .sourceLabel in en and all nine sibling packs.

What source names, and why it is a raw identifier. The card allows the widget's label and/or its data source. The widget's title is already rendered by the card header directly above every tile (DashboardRenderer), so repeating it says nothing new; the data source is the half the reader cannot see. What is reachable at each render site is the authored binding — widget.dataset on the dataset path, schema.objectName on the object-bound table/pivot — i.e. a raw metadata name such as crm_forecast, not a localized label. It is rendered as a labelled monospace value rather than folded into a sentence: a truthful narrow statement beats an invented friendly one.

Why the copy carries no interpolation.useSafeTranslate takes a positional fallback and no options bag, so an interpolated key would have to come from a raw useObjectTranslationt() — and with no I18nProvider mounted (the standalone-host and test configuration, which vitest.setup.i18n-global.ts deliberately preserves) that renders the raw KEY unless the call site also carries an inline defaultValue, which #3517 rules out. A label that carries its own punctuation (Source: / 数据源: / مصدر البيانات:) needs neither, and concatenates no separator in code.

Files:WidgetEmptyState.tsx (new) · DatasetWidget.tsx · ObjectDataTable.tsx · PivotTable.tsx · ObjectPivotTable.tsx · ten locale packs · one new test file · DatasetWidget.test.tsx · one changeset.


Evidence

Tests

pnpm exec vitest run packages/plugin-dashboard packages/i18n packages/app-shell/src/__tests__/widget-dom-leak-sweep.test.tsx --maxWorkers=2 at 79accd6:

 Test Files 144 passed (144)
Tests 1924 passed (1924)

New pin: packages/plugin-dashboard/src/__tests__/WidgetEmptyState.uniformDefault.test.tsx (5 cases). It pins the ruling's properties, never the sentence — copy is review's to move. It also pins that all four dashboard entry points reach the one seam, which is the property that decays first: a fourth widget can quietly write a fifth placeholder while every per-widget test stays green.

Four pins in DatasetWidget.test.tsx were rewritten rather than left passing. Three of them asserted queryByText('No rows')).not.toBeInTheDocument() — after this change that string exists nowhere, so they would have passed for the wrong reason. They now select the empty state by test id.

Ablation — two legs, direction predicted before running

Leg 1 — revert DatasetWidget's empty branch to the pre-card dashboard.noRows one-liner. Predicted: RED, and only on the DatasetWidget surface.

  • mutation proven on disk: removed-marker WidgetEmptyState (as a JSX open tag) 1 to 0; injected-marker tt('dashboard.noRows' 0 to 1; blob f8f61e71... to 951c8251...
  • observed: Tests 4 failed | 59 passed, vitest exit 1 (read from the run's own printed ABLATED_VITEST_EXIT=1, not a bare $? behind a pipe). All four failures on the DatasetWidget surface; the ObjectDataTable, PivotTable and ObjectPivotTable cases stayed green, so the pins resolve per surface rather than firing as a block.
  • restore proven by state: git diff HEAD empty, git status --porcelain empty, blob back to f8f61e712947e3cdf3dacf3cdab3e6a8cccd25ba = its HEAD blob.

Leg 2 — delete role="status" from the shared component, i.e. ablate property (1) itself. Predicted: RED on the role assertions; the load-failure test stays green.

  • mutation proven on disk: marker role="status" 2 to 1 (the second occurrence is in the file's doc comment; the python anchor asserted exactly one match on the JSX line); blob a62aaa96... to 39a46faf...
  • observed: Tests 4 failed | 59 passed, exit 1 — the three seam cases plus the DatasetWidget.test.tsx role case. a load FAILURE still reads as a failure stayed green, so the two halves of property (1) are independent.
  • restore proven by state: git diff HEAD empty, blob back to a62aaa96ec88cede54f96579451a6922b3aceb3a.

Both legs used an EXIT INT TERM trap with an absolute REPO_ROOT path, and restored with git checkout HEAD -- path (never a bare git checkout -- path, which restores from the index and would hand back the mutation).

No rebuild leg, stated rather than skipped. This repo's vitest resolves @object-ui/* to packages/*/src via resolve.alias in vitest.config.mts, and the pins import the components by relative path, so no dist can shadow the mutation. A stale build cannot make an ablation falsely green here.

Gates

gateexitverdict
vitest (plugin-dashboard, i18n, widget-dom-leak-sweep)0green — 144 files, 1924 tests
pnpm --filter @object-ui/plugin-dashboard --filter @object-ui/i18n run type-check0green — both ran tsc --noEmit && tsc -p tsconfig.test.json, so the new test file was type-checked, not merely excluded
pnpm --filter @object-ui/plugin-dashboard --filter @object-ui/i18n run lint0green — 0 errors (423 + 34 pre-existing warnings; 4 of them no-explicit-any on the new test file, the same schema-cast shape its sibling suites use)
check:i18n-keys0green — "Every in-scope call-site key resolves against the en pack (2845 keys)…"
check:i18n-drift0green — "0 en value(s) changed (3 key(s) added, 0 removed…)" — strictly additive
check:control-bytes0green — "OK (scanned 5898 tracked text file(s); skipped 85 binary)"
check-changeset-presence0green — "17 source file(s) of 2 released package(s) changed, and this change declares 1 changeset(s)"
check:changeset-fixed0green — "All workspace packages are in the changeset fixed group."
check:changeset-no-major0green — "No changeset declares a major bump."
check:phantom-deps / check:self-import / check:side-effects-array / check:vi-mock-specifiers / check:vi-mock-inherit / check:esm-specifiers0green
check:readme-exports1NOT MEASURED — "the population COLLAPSED -- this run proves nothing / packagesRead: found 12, floor is 25". Local tree has most packages unbuilt; CI runs turbo run build first. Narrowing argument: packages/plugin-dashboard/src/index.tsx is untouched and does not export WidgetEmptyState (control in the same query: PivotTable, which it does export, hits), so the package's published export surface is unchanged and no README moved.
check:eager-closure2NOT MEASURED — "No eager-closure report at apps/console/dist/eager-closure.json … This is a broken gauge, not a sensitive gate." Needs a built apps/console; produced by performance-budget.yml in CI.

Changeset verdicts are quoted verbatim above rather than predicted; both gates were run.

Zero-hit readings, each with its control

queryscopehitscontrolcontrol hits
emptyStateplugin-dashboard/src + plugin-charts/src0widget, same scope1154
WidgetEmptyStateplugin-dashboard/src/index.tsx0PivotTable, same file1

What is NOT verified here, and why

The hotcrm acceptance scenario is unverified by me. The card's acceptance is a hotcrm sales_dashboard fresh install. hotcrm is outside this session's repo scope — it is not attached and I did not try to reach it. What I can state is the objectui-side mechanism: the quota-attainment tile is a dataset-bound table widget, which renders through DatasetWidget, whose empty branch is the one call site of the 暂无数据行 string this PR replaces. The end-to-end confirmation on a fresh hotcrm install still needs to be run by someone with that repo.

Shared-abstraction temptation, reported rather than taken.DataEmptyState in @object-ui/components is the presentational primitive already used by plugin-list, plugin-kanban, plugin-dashboardand plugin-detail — the surface of the parallel card #7064. Changing its defaults would have been a smaller diff that repaired both surfaces at once. It is deliberately consumed unchanged here: a shared empty-state abstraction designed twice in parallel by two agents is worse than two honest local fixes, and converging the two is its own sequenced card. Nothing under packages/plugin-detail is touched by this PR.

Residue this creates, not fixed here.dashboard.noRows and dashboard.noDataAvailable now have no call site. check:i18n-dead-keys (report-only, exit 0, not wired into CI) lists both as needs-review after this change. They are left in all ten packs deliberately: the dispatch asks locale edits to stay additive so parallel merges stay trivial, and the dead-key sweep is its own tracked workstream (objectui#4658). Filed as a follow-up rather than ridden on this PR.


Generated by Claude Code

… dashboard widgets
Maintainer ruling 2026-08-31 (hotcrm#1212, following hotcrm#1203): a widget that
renders a bare row-placeholder on an empty result is the platform's defect and
must be fixed uniformly — apps must not compensate widget by widget
(objectstack#13848).
There was no shared placeholder to fix. Three dashboard surfaces wrote their own,
in two different strings: DatasetWidget used `dashboard.noRows` ('No rows' /
暂无数据行), while ObjectDataTable and PivotTable used `dashboard.noDataAvailable`.
`WidgetEmptyState` is the seam they now share.
The default reads as a STATE rather than a failure — `role="status"` where the
branches beside it are `role="alert"` and the empty branches previously carried
no role at all — carries a title plus an explanation instead of one terse
fragment, and names the widget's data source with zero authored copy.
Copy is platform i18n: `dashboard.empty.title` / `.message` / `.sourceLabel` in
`en` and all nine sibling packs. No inline defaultValue, no interpolation.
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)3151.9 KB3191.4 KB
Main entry chunk (gzip)142.3 KB350 KB
Entry fileindex-DbjcEXZE.js
StatusPASS

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


📦 Bundle Size Report

PackageSizeGzipped
app-shell (consoleActionDispatch.js)0.20KB0.19KB
app-shell (index.js)15.33KB5.59KB
app-shell (runtime-config.js)20.68KB7.36KB
app-shell (types.js)0.01KB0.04KB
app-shell (urlParams.js)10.06KB3.86KB
auth (ActiveOrganizationStorage.js)25.05KB9.16KB
auth (AuthContext.js)0.31KB0.24KB
auth (AuthGuard.js)2.07KB1.00KB
auth (AuthProvider.js)40.18KB10.59KB
auth (AuthShell.js)3.49KB1.40KB
auth (ForgotPasswordForm.js)12.21KB3.45KB
auth (LoginForm.js)18.15KB5.39KB
auth (PreviewBanner.js)0.90KB0.50KB
auth (RegisterForm.js)6.65KB2.22KB
auth (SocialSignInButtons.js)9.61KB3.89KB
auth (UserMenu.js)3.41KB1.23KB
auth (auth-gate-events.js)1.29KB0.66KB
auth (authStyles.js)5.04KB1.72KB
auth (createAuthClient.js)40.21KB10.80KB
auth (createAuthenticatedFetch.js)8.46KB3.43KB
auth (index.js)3.19KB1.44KB
auth (invitation-status.js)1.22KB0.70KB
auth (org-roles.js)6.66KB2.78KB
auth (phone-identifier.js)1.11KB0.66KB
auth (types.js)0.59KB0.35KB
auth (useAuth.js)5.30KB1.02KB
auth (useWorkspaceAdminStatus.js)5.13KB2.35KB
collaboration (CommentThread.js)26.08KB7.56KB
collaboration (LiveCursors.js)3.17KB1.27KB
collaboration (PresenceAvatars.js)6.49KB2.64KB
collaboration (PresenceProvider.js)2.79KB1.13KB
collaboration (index.js)1.68KB0.73KB
collaboration (useCollaborationTranslation.js)6.05KB2.52KB
collaboration (useCommentSearch.js)1.98KB0.88KB
collaboration (useConflictResolution.js)7.75KB1.86KB
collaboration (useMentionNotifications.js)1.81KB0.68KB
collaboration (usePresence.js)6.33KB1.84KB
collaboration (useRealtimeSubscription.js)7.91KB2.01KB
components (index.js)512.30KB116.52KB
core (index.js)5.30KB2.13KB
create-plugin (index.js)10.08KB3.26KB
data-objectstack (index.js)177.67KB49.45KB
fields (index.js)243.64KB61.64KB
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.94KB63.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.14KB14.64KB
plugin-list (index.js)113.19KB27.60KB
plugin-map (index.js)20.20KB6.66KB
plugin-markdown (index.js)13.72KB4.69KB
plugin-report (index.js)43.51KB11.94KB
plugin-timeline (index.js)29.34KB8.47KB
plugin-tree (index.js)8.98KB3.08KB
plugin-view (index.js)85.90KB21.12KB
providers (DataSourceProvider.js)0.75KB0.39KB
providers (MetadataProvider.js)1.37KB0.59KB
providers (ThemeProvider.js)1.90KB0.85KB
providers (UploadProvider.js)11.66KB3.50KB
providers (index.js)0.45KB0.23KB
providers (types.js)0.01KB0.04KB
react-runtime (index.js)5.62KB2.34KB
react (LazyPluginLoader.js)4.47KB1.63KB
react (SchemaRenderer.js)81.07KB26.86KB
react (data-invalidation.js)5.05KB2.08KB
react (index.js)3.11KB1.48KB
react (schema-input.js)2.32KB1.24KB
react (spec-input.js)0.20KB0.18KB
sdui-parser (codegen.js)5.41KB2.34KB
sdui-parser (dashboard-widget-options.js)3.08KB1.30KB
sdui-parser (index.js)4.93KB2.24KB
sdui-parser (input-type.js)2.84KB1.40KB
sdui-parser (parse.js)20.57KB5.88KB
sdui-parser (provenance.js)3.66KB1.82KB
sdui-parser (types.js)0.28KB0.23KB
sdui-parser (validate.js)10.35KB3.60KB
types (ai.js)0.20KB0.17KB
types (api-types.js)0.20KB0.18KB
types (app.js)2.87KB0.99KB
types (base.js)0.20KB0.18KB
types (blocks.js)0.20KB0.18KB
types (complex.js)2.74KB1.41KB
types (crud.js)0.20KB0.18KB
types (dashboard-filter-alias.js)6.23KB2.74KB
types (data-display.js)3.75KB1.85KB
types (data-protocol.js)0.20KB0.19KB
types (data.js)0.20KB0.18KB
types (designer.js)1.85KB0.85KB
types (disclosure.js)0.20KB0.18KB
types (error-code.js)1.54KB0.88KB
types (feedback.js)0.20KB0.18KB
types (field-types.js)0.20KB0.18KB
types (form.js)0.20KB0.18KB
types (http-inflight.js)8.87KB3.73KB
types (http-retry.js)4.32KB2.02KB
types (icon-key-migration.js)4.26KB1.63KB
types (index.js)4.72KB2.24KB
types (layout.js)0.20KB0.18KB
types (managed-by.js)0.19KB0.18KB
types (mobile.js)2.59KB1.31KB
types (navigation.js)0.20KB0.18KB
types (objectql.js)0.20KB0.18KB
types (overlay.js)0.20KB0.18KB
types (permissions.js)0.20KB0.18KB
types (plugin-scope.js)0.20KB0.18KB
types (record-components.js)0.20KB0.19KB
types (record-semantics.js)1.28KB0.67KB
types (registry.js)0.20KB0.18KB
types (reports.js)0.20KB0.18KB
types (spec-report.js)5.05KB1.93KB
types (spec-ui-namespace.js)0.20KB0.19KB
types (system-fields.js)3.33KB1.54KB
types (theme.js)6.28KB2.87KB
types (ui-action.js)3.40KB1.71KB
types (views.js)0.20KB0.18KB
types (widget.js)0.20KB0.18KB

Size Limits

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

@os-warrenClaude

Copy link
Copy Markdown
CollaboratorAuthor

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

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

⭐⭐ You declined the shared abstraction, and the forbidden path was the cheaper one

DataEmptyState in packages/components/src/custom/view-states.tsx is already consumed by plugin-list, plugin-kanban, plugin-dashboardand plugin-detail — the exact surface of the parallel card #7064. Changing its defaults would have been a smaller diff repairing both surfaces at once.

That is the strongest possible test of the fence, and it held. A cross-surface abstraction designed simultaneously by two agents who cannot see each other's reasoning is worse than two honest local fixes — and it would have been invisible as a problem, because the diff would have looked elegant. You consumed the primitive unchanged, kept the new component dashboard-local, recorded why in the file header, and touched nothing under plugin-detail. ⭐ Recording the temptation is as valuable as resisting it — it tells me convergence is worth a sequenced card once both land, which I can now schedule instead of discover.

Both of the card's premises were false, and neither failure was papered over

  1. "One shared placeholder reached by all the affected widgets." ⛔ False: three surfaces, two different keys, no shared code — and the measured string dashboard.noRows had exactly one call site repo-wide. ⇒ There was no seam to repair; one had to be created. That is a materially different job from the one the card describes, and it is why a new component exists rather than a one-line default change.
  2. "The authored emptyState contract already exists and already overrides." ⛔ False on this surface: emptyState is a list-view contract. Measured 0 occurrences across plugin-dashboard/src and plugin-charts/src⚠️ against a control of 1154 hits for widget over the same paths — and spec 17.2.0's DashboardWidgetSchema, read from the built package at runtime, declares no such key.

⭐ Clause 3 is vacuous here, and you were right that this is not a stop-and-report

My order made clause 3's stop-and-report fence hard, because a spec key added on a hunch becomes an unlandable PR under the quota freeze. You reached the fence and correctly did not trigger it: the deliverable is a better default, and a default needs no authoring surface. Nothing was widened, so nothing was owed.

⚠️ But the fact stands and is worth the maintainer's eye: the ruling's "the existing authored contract always overrides" describes, on this surface, a contract that does not exist. Same shape as #7064's finding an hour ago, where the ruling's authored opt-out turned out to be refused by the spec. Two rulings in one round whose preservation clause protects something that isn't there. I'm routing that pattern rather than treating each as a one-off.

A failed control, caught and re-taken — the discipline that matters most

Your first control for "WidgetEmptyState is not exported from index.tsx" used DatasetWidget, which also returned 0. Rather than reading that as confirmation, you discarded the reading and re-took it with PivotTable (= 1). ⭐ A control that returns zero is not a weak control, it is no control — and DatasetWidget being internal is exactly the kind of thing that makes a plausible control silently useless. This is the sixth instrument failure surfaced on this lane today and the second caught by its own operator mid-measurement.

Ablation: two legs, both per-surface

Leg 1 (revert DatasetWidget's empty branch) reddened 4 cases confined to that surface while table and pivot cases stayed green — proving the pins resolve per surface rather than firing as a block, which a single seam makes easy to get wrong. Leg 2 (delete role="status") reddened the role assertions while "a load FAILURE still reads as a failure" stayed green, showing the two halves of requirement 1 are independent. Directions predicted before running; both matched.

The copy decision is right, and its restraint is the reason

Naming the data source as a raw metadata identifier (crm_forecast) as a labelled monospace value, rather than folding it into a friendly sentence, is a truthful narrow statement over an invented one — and declining to use widget.title because DashboardRenderer already renders the resolved title directly above avoids saying the same thing twice. role="status" against the neighbouring role="alert" is what makes requirement 1 structural rather than cosmetic.

The i18n handling — additive after each pack's existing anchor, verified unique in all ten before writing, punctuation carried inside the key so no separator is concatenated and RTL needs no special case — is exactly what keeps the parallel packages/i18n work merging trivially.

NOT MEASURED, reported as such, with narrowing arguments

check:readme-exports (population COLLAPSED, 12 packages read against a floor of 25) and check:eager-closure (no apps/console/dist/eager-closure.json"a broken gauge, not a sensitive gate"). ⭐ Both carry a reason and the first carries a narrowing that is itself controlled: index.tsx is untouched and does not export the new component (control: PivotTable, which it does export, hits 1). CI owns both.

Open question 1 — emptyState for dashboard widgets: I adopt A

No key added. Your reasoning holds and I'll add the decisive point: B is currently unbuildable-to-land — it is a public authoring-surface widening, so it needs the contract review the exhausted Fable 5 quota blocks, and it would join #6896 / PR #7111 in the stranded state. Beyond that, adding a per-widget authored-prose key one card after a ruling closed the per-app-compensation route would undo the ruling with its own follow-up. Zero measured pull. Recorded on #7129 alongside #7064's sibling question.

Open question 2 — I am filing it; here is why I am overriding your restraint

You declined to file the ObjectChart observation because the state may be intentional. That restraint was right by default, and I'd rather see it than a reflexive card. But I'm filing it, on three grounds: it is a measured absence (no empty branch at all, not a judgement about quality), the seam you just built makes the fix small, and an observation that lives only in a subagent report is lost. The card carries your framing verbatim — may be intentional, needs a reading before any fix — so it records the fact without pre-judging it. Filed as #7130.

#7125

Correctly filed and correctly not acted on: the dead keys are residue this PR creates, the dispatch asked locale edits to stay additive for trivial parallel merges, and the dead-key gate is report-only and wired into no workflow. Deleting them here would have traded a clean merge for a cosmetic win.


Generated by Claude Code

@os-warren
os-warren marked this pull request as ready for review September 1, 2026 03:32
@os-warren
os-warren added this pull request to the merge queueSep 1, 2026
Merged via the queue into main with commit 220c18dSep 1, 2026
32 checks passed
@os-warren
os-warren deleted the claude/issue-7063-widget-empty-state-default branch September 1, 2026 03:45
os-warren pushed a commit that referenced this pull request Sep 1, 2026
…r an empty result
Measured, not assumed: recharts derives ticks from the data, so an empty
result draws two hairline axis rules and zero text nodes (bar/line) or
nothing at all (pie) — there are no labelled axes to read. Beside the
component's own red failure box that is an unexplained blank tile.
Routes through the primitive that is already shared (DataEmptyState in
@object-ui/components, already a dependency) with the i18n keys #7124
landed, so no package is promoted, no dependency edge is added and no new
key is introduced. Charts with inline authored data are unchanged.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012wwHa4aaFybxXrfmfHioDM
akarma-synetal pushed a commit to akarma-synetal/objectui that referenced this pull request Sep 1, 2026
…r an empty result (objectstack-ai#7139)
* fix(plugin-charts): a self-describing empty state for ObjectChart over an empty result
Measured, not assumed: recharts derives ticks from the data, so an empty
result draws two hairline axis rules and zero text nodes (bar/line) or
nothing at all (pie) — there are no labelled axes to read. Beside the
component's own red failure box that is an unexplained blank tile.
Routes through the primitive that is already shared (DataEmptyState in
@object-ui/components, already a dependency) with the i18n keys objectstack-ai#7124
landed, so no package is promoted, no dependency edge is added and no new
key is introduced. Charts with inline authored data are unchanged.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012wwHa4aaFybxXrfmfHioDM
* test(app-shell): give the two object-bound chart sweep targets rows
The DOM-leak sweep mounts `plugin-charts:object-chart` and `view:chart`
against an adapter that answers every query with no rows. Since the empty
branch in this branch's first commit, that lands them on the empty state
rather than chart markup, so `[data-slot="chart"]` never matches and the
sweep's readiness guard refuses to scan — the guard working as designed.
Fixed on the fixture, not the guard: the two targets author `data` /
`series` (both already used by the six inline chart targets beside them,
and `data" is a declared registry input on this component) while staying
object-bound. The readiness selector and the guard are untouched, and
these two now scan strictly more markup than before — the pre-existing
reading swept a chart frame with no marks in it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012wwHa4aaFybxXrfmfHioDM
---------
Co-authored-by: Claude <noreply@anthropic.com>
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

1 participant

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

fix(plugin-dashboard,i18n): a self-explaining default empty state for dashboard widgets - #7124

Merged
os-warren merged 1 commit into
mainfrom
claude/issue-7063-widget-empty-state-default
Sep 1, 2026
Merged

fix(plugin-dashboard,i18n): a self-explaining default empty state for dashboard widgets#7124
os-warren merged 1 commit into
mainfrom
claude/issue-7063-widget-empty-state-default

Conversation

@os-warren

Copy link
Copy Markdown
Collaborator

Fixes#7063

Maintainer ruling 2026-08-31 (hotcrm#1212 adjudication, verbatim 「1212 也是」, following 「hotcrm#1203 …这种也是平台的问题啊」): this class is the platform's to fix uniformly; apps must not compensate widget by widget (objectstack#13848 — apps are simplified business implementations, uniform behaviour belongs to the platform).

All evidence below was measured at 79accd6 (the final commit on this branch).


The card's premise was half wrong, and that changed the shape of the fix

Two of the dispatch's stated assumptions did not survive measurement. Both are reported here rather than worked around.

1. 暂无数据行 is NOT one shared placeholder — there was no seam to fix

Three dashboard surfaces wrote their own empty state, in two different strings, with no common code:

surfacekeyenzh
DatasetWidget (the measured one)dashboard.noRowsNo rows暂无数据行
ObjectDataTabledashboard.noDataAvailableNo data available暂无数据
PivotTabledashboard.noDataAvailableNo data available暂无数据

dashboard.noRows had exactly one call site repo-wide (DatasetWidget.tsx:727), so the measured 暂无数据行 came from the dataset-bound path only — which is the ADR-0021 governed path the hotcrm quota-attainment tile takes.

So "uniformly" could not mean "fix the shared placeholder". The new WidgetEmptyStateis the seam: it is added, and the three surfaces are routed through it.

2. The authored emptyState override does not exist on this surface

Clause 3 assumes an existing authored emptyState contract that always overrides. Measured:

  • emptyState occurs 0 times in packages/plugin-dashboard/src and packages/plugin-charts/src. Control in the same sweep, same paths: widget, 1154 hits — so the zero is a reading, not a broken query.

  • emptyState is a list-view contract (ObjectGridSchema, NamedListView in packages/types/src/objectql.ts), honoured in packages/plugin-list/src/ListView.tsx.

  • @objectstack/spec@17.2.0's DashboardWidgetSchema declares no such key. Its full key set, read from the built package at runtime rather than recalled:

    id, title, description, type, chartConfig, colorVariant, requiresObject, requiresService, actionUrl, actionType, actionIcon, filter, compareTo, dataset, dimensions, values, layout, options, filterBindings, suppressWarnings, responsive, aria

Consequence, and why this is not the stop-and-report case. Clause 3 is satisfied here vacuously: there is no authored override on dashboard widgets for the default to lose to, so improving the default cannot displace one. No spec key was added and none is needed to deliver this card — the deliverable is a better DEFAULT, and defaults need no authoring surface. Making clause 3 non-vacuous (i.e. giving dashboard widgets a real authored override) would require a new spec key, which is the contract question the card fences off. That is flagged for the seat, not taken.


What changed

New:packages/plugin-dashboard/src/WidgetEmptyState.tsx — the dashboard-local seam.

The ruling's four properties, and how each is delivered:

  1. Distinguishable from a load failure at a glance.role="status", where the failure branches sitting beside these call sites are role="alert" on destructive colours. The empty branches previously carried no role at all, so assistive tech got a bare fragment with no state either way. Visually: muted treatment and an inbox glyph, never destructive colour and a warning triangle.
  2. Self-describing without authored copy. A title and an explanation where the placeholder was one terse fragment, plus the name of the widget's data source. The copy states that the widget loaded successfully — the one fact a reader of a blank tile cannot otherwise get.
  3. No new authoring obligation, no new spec key. Nothing was added to any schema. PivotTable gains one optional component prop (sourceLabel), which is React-side only.
  4. Platform i18n.dashboard.empty.title / .message / .sourceLabel in en and all nine sibling packs.

What source names, and why it is a raw identifier. The card allows the widget's label and/or its data source. The widget's title is already rendered by the card header directly above every tile (DashboardRenderer), so repeating it says nothing new; the data source is the half the reader cannot see. What is reachable at each render site is the authored binding — widget.dataset on the dataset path, schema.objectName on the object-bound table/pivot — i.e. a raw metadata name such as crm_forecast, not a localized label. It is rendered as a labelled monospace value rather than folded into a sentence: a truthful narrow statement beats an invented friendly one.

Why the copy carries no interpolation.useSafeTranslate takes a positional fallback and no options bag, so an interpolated key would have to come from a raw useObjectTranslationt() — and with no I18nProvider mounted (the standalone-host and test configuration, which vitest.setup.i18n-global.ts deliberately preserves) that renders the raw KEY unless the call site also carries an inline defaultValue, which #3517 rules out. A label that carries its own punctuation (Source: / 数据源: / مصدر البيانات:) needs neither, and concatenates no separator in code.

Files:WidgetEmptyState.tsx (new) · DatasetWidget.tsx · ObjectDataTable.tsx · PivotTable.tsx · ObjectPivotTable.tsx · ten locale packs · one new test file · DatasetWidget.test.tsx · one changeset.


Evidence

Tests

pnpm exec vitest run packages/plugin-dashboard packages/i18n packages/app-shell/src/__tests__/widget-dom-leak-sweep.test.tsx --maxWorkers=2 at 79accd6:

 Test Files 144 passed (144)
Tests 1924 passed (1924)

New pin: packages/plugin-dashboard/src/__tests__/WidgetEmptyState.uniformDefault.test.tsx (5 cases). It pins the ruling's properties, never the sentence — copy is review's to move. It also pins that all four dashboard entry points reach the one seam, which is the property that decays first: a fourth widget can quietly write a fifth placeholder while every per-widget test stays green.

Four pins in DatasetWidget.test.tsx were rewritten rather than left passing. Three of them asserted queryByText('No rows')).not.toBeInTheDocument() — after this change that string exists nowhere, so they would have passed for the wrong reason. They now select the empty state by test id.

Ablation — two legs, direction predicted before running

Leg 1 — revert DatasetWidget's empty branch to the pre-card dashboard.noRows one-liner. Predicted: RED, and only on the DatasetWidget surface.

  • mutation proven on disk: removed-marker WidgetEmptyState (as a JSX open tag) 1 to 0; injected-marker tt('dashboard.noRows' 0 to 1; blob f8f61e71... to 951c8251...
  • observed: Tests 4 failed | 59 passed, vitest exit 1 (read from the run's own printed ABLATED_VITEST_EXIT=1, not a bare $? behind a pipe). All four failures on the DatasetWidget surface; the ObjectDataTable, PivotTable and ObjectPivotTable cases stayed green, so the pins resolve per surface rather than firing as a block.
  • restore proven by state: git diff HEAD empty, git status --porcelain empty, blob back to f8f61e712947e3cdf3dacf3cdab3e6a8cccd25ba = its HEAD blob.

Leg 2 — delete role="status" from the shared component, i.e. ablate property (1) itself. Predicted: RED on the role assertions; the load-failure test stays green.

  • mutation proven on disk: marker role="status" 2 to 1 (the second occurrence is in the file's doc comment; the python anchor asserted exactly one match on the JSX line); blob a62aaa96... to 39a46faf...
  • observed: Tests 4 failed | 59 passed, exit 1 — the three seam cases plus the DatasetWidget.test.tsx role case. a load FAILURE still reads as a failure stayed green, so the two halves of property (1) are independent.
  • restore proven by state: git diff HEAD empty, blob back to a62aaa96ec88cede54f96579451a6922b3aceb3a.

Both legs used an EXIT INT TERM trap with an absolute REPO_ROOT path, and restored with git checkout HEAD -- path (never a bare git checkout -- path, which restores from the index and would hand back the mutation).

No rebuild leg, stated rather than skipped. This repo's vitest resolves @object-ui/* to packages/*/src via resolve.alias in vitest.config.mts, and the pins import the components by relative path, so no dist can shadow the mutation. A stale build cannot make an ablation falsely green here.

Gates

gateexitverdict
vitest (plugin-dashboard, i18n, widget-dom-leak-sweep)0green — 144 files, 1924 tests
pnpm --filter @object-ui/plugin-dashboard --filter @object-ui/i18n run type-check0green — both ran tsc --noEmit && tsc -p tsconfig.test.json, so the new test file was type-checked, not merely excluded
pnpm --filter @object-ui/plugin-dashboard --filter @object-ui/i18n run lint0green — 0 errors (423 + 34 pre-existing warnings; 4 of them no-explicit-any on the new test file, the same schema-cast shape its sibling suites use)
check:i18n-keys0green — "Every in-scope call-site key resolves against the en pack (2845 keys)…"
check:i18n-drift0green — "0 en value(s) changed (3 key(s) added, 0 removed…)" — strictly additive
check:control-bytes0green — "OK (scanned 5898 tracked text file(s); skipped 85 binary)"
check-changeset-presence0green — "17 source file(s) of 2 released package(s) changed, and this change declares 1 changeset(s)"
check:changeset-fixed0green — "All workspace packages are in the changeset fixed group."
check:changeset-no-major0green — "No changeset declares a major bump."
check:phantom-deps / check:self-import / check:side-effects-array / check:vi-mock-specifiers / check:vi-mock-inherit / check:esm-specifiers0green
check:readme-exports1NOT MEASURED — "the population COLLAPSED -- this run proves nothing / packagesRead: found 12, floor is 25". Local tree has most packages unbuilt; CI runs turbo run build first. Narrowing argument: packages/plugin-dashboard/src/index.tsx is untouched and does not export WidgetEmptyState (control in the same query: PivotTable, which it does export, hits), so the package's published export surface is unchanged and no README moved.
check:eager-closure2NOT MEASURED — "No eager-closure report at apps/console/dist/eager-closure.json … This is a broken gauge, not a sensitive gate." Needs a built apps/console; produced by performance-budget.yml in CI.

Changeset verdicts are quoted verbatim above rather than predicted; both gates were run.

Zero-hit readings, each with its control

queryscopehitscontrolcontrol hits
emptyStateplugin-dashboard/src + plugin-charts/src0widget, same scope1154
WidgetEmptyStateplugin-dashboard/src/index.tsx0PivotTable, same file1

What is NOT verified here, and why

The hotcrm acceptance scenario is unverified by me. The card's acceptance is a hotcrm sales_dashboard fresh install. hotcrm is outside this session's repo scope — it is not attached and I did not try to reach it. What I can state is the objectui-side mechanism: the quota-attainment tile is a dataset-bound table widget, which renders through DatasetWidget, whose empty branch is the one call site of the 暂无数据行 string this PR replaces. The end-to-end confirmation on a fresh hotcrm install still needs to be run by someone with that repo.

Shared-abstraction temptation, reported rather than taken.DataEmptyState in @object-ui/components is the presentational primitive already used by plugin-list, plugin-kanban, plugin-dashboardand plugin-detail — the surface of the parallel card #7064. Changing its defaults would have been a smaller diff that repaired both surfaces at once. It is deliberately consumed unchanged here: a shared empty-state abstraction designed twice in parallel by two agents is worse than two honest local fixes, and converging the two is its own sequenced card. Nothing under packages/plugin-detail is touched by this PR.

Residue this creates, not fixed here.dashboard.noRows and dashboard.noDataAvailable now have no call site. check:i18n-dead-keys (report-only, exit 0, not wired into CI) lists both as needs-review after this change. They are left in all ten packs deliberately: the dispatch asks locale edits to stay additive so parallel merges stay trivial, and the dead-key sweep is its own tracked workstream (objectui#4658). Filed as a follow-up rather than ridden on this PR.


Generated by Claude Code

… dashboard widgets
Maintainer ruling 2026-08-31 (hotcrm#1212, following hotcrm#1203): a widget that
renders a bare row-placeholder on an empty result is the platform's defect and
must be fixed uniformly — apps must not compensate widget by widget
(objectstack#13848).
There was no shared placeholder to fix. Three dashboard surfaces wrote their own,
in two different strings: DatasetWidget used `dashboard.noRows` ('No rows' /
暂无数据行), while ObjectDataTable and PivotTable used `dashboard.noDataAvailable`.
`WidgetEmptyState` is the seam they now share.
The default reads as a STATE rather than a failure — `role="status"` where the
branches beside it are `role="alert"` and the empty branches previously carried
no role at all — carries a title plus an explanation instead of one terse
fragment, and names the widget's data source with zero authored copy.
Copy is platform i18n: `dashboard.empty.title` / `.message` / `.sourceLabel` in
`en` and all nine sibling packs. No inline defaultValue, no interpolation.
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)3151.9 KB3191.4 KB
Main entry chunk (gzip)142.3 KB350 KB
Entry fileindex-DbjcEXZE.js
StatusPASS

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


📦 Bundle Size Report

PackageSizeGzipped
app-shell (consoleActionDispatch.js)0.20KB0.19KB
app-shell (index.js)15.33KB5.59KB
app-shell (runtime-config.js)20.68KB7.36KB
app-shell (types.js)0.01KB0.04KB
app-shell (urlParams.js)10.06KB3.86KB
auth (ActiveOrganizationStorage.js)25.05KB9.16KB
auth (AuthContext.js)0.31KB0.24KB
auth (AuthGuard.js)2.07KB1.00KB
auth (AuthProvider.js)40.18KB10.59KB
auth (AuthShell.js)3.49KB1.40KB
auth (ForgotPasswordForm.js)12.21KB3.45KB
auth (LoginForm.js)18.15KB5.39KB
auth (PreviewBanner.js)0.90KB0.50KB
auth (RegisterForm.js)6.65KB2.22KB
auth (SocialSignInButtons.js)9.61KB3.89KB
auth (UserMenu.js)3.41KB1.23KB
auth (auth-gate-events.js)1.29KB0.66KB
auth (authStyles.js)5.04KB1.72KB
auth (createAuthClient.js)40.21KB10.80KB
auth (createAuthenticatedFetch.js)8.46KB3.43KB
auth (index.js)3.19KB1.44KB
auth (invitation-status.js)1.22KB0.70KB
auth (org-roles.js)6.66KB2.78KB
auth (phone-identifier.js)1.11KB0.66KB
auth (types.js)0.59KB0.35KB
auth (useAuth.js)5.30KB1.02KB
auth (useWorkspaceAdminStatus.js)5.13KB2.35KB
collaboration (CommentThread.js)26.08KB7.56KB
collaboration (LiveCursors.js)3.17KB1.27KB
collaboration (PresenceAvatars.js)6.49KB2.64KB
collaboration (PresenceProvider.js)2.79KB1.13KB
collaboration (index.js)1.68KB0.73KB
collaboration (useCollaborationTranslation.js)6.05KB2.52KB
collaboration (useCommentSearch.js)1.98KB0.88KB
collaboration (useConflictResolution.js)7.75KB1.86KB
collaboration (useMentionNotifications.js)1.81KB0.68KB
collaboration (usePresence.js)6.33KB1.84KB
collaboration (useRealtimeSubscription.js)7.91KB2.01KB
components (index.js)512.30KB116.52KB
core (index.js)5.30KB2.13KB
create-plugin (index.js)10.08KB3.26KB
data-objectstack (index.js)177.67KB49.45KB
fields (index.js)243.64KB61.64KB
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.94KB63.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.14KB14.64KB
plugin-list (index.js)113.19KB27.60KB
plugin-map (index.js)20.20KB6.66KB
plugin-markdown (index.js)13.72KB4.69KB
plugin-report (index.js)43.51KB11.94KB
plugin-timeline (index.js)29.34KB8.47KB
plugin-tree (index.js)8.98KB3.08KB
plugin-view (index.js)85.90KB21.12KB
providers (DataSourceProvider.js)0.75KB0.39KB
providers (MetadataProvider.js)1.37KB0.59KB
providers (ThemeProvider.js)1.90KB0.85KB
providers (UploadProvider.js)11.66KB3.50KB
providers (index.js)0.45KB0.23KB
providers (types.js)0.01KB0.04KB
react-runtime (index.js)5.62KB2.34KB
react (LazyPluginLoader.js)4.47KB1.63KB
react (SchemaRenderer.js)81.07KB26.86KB
react (data-invalidation.js)5.05KB2.08KB
react (index.js)3.11KB1.48KB
react (schema-input.js)2.32KB1.24KB
react (spec-input.js)0.20KB0.18KB
sdui-parser (codegen.js)5.41KB2.34KB
sdui-parser (dashboard-widget-options.js)3.08KB1.30KB
sdui-parser (index.js)4.93KB2.24KB
sdui-parser (input-type.js)2.84KB1.40KB
sdui-parser (parse.js)20.57KB5.88KB
sdui-parser (provenance.js)3.66KB1.82KB
sdui-parser (types.js)0.28KB0.23KB
sdui-parser (validate.js)10.35KB3.60KB
types (ai.js)0.20KB0.17KB
types (api-types.js)0.20KB0.18KB
types (app.js)2.87KB0.99KB
types (base.js)0.20KB0.18KB
types (blocks.js)0.20KB0.18KB
types (complex.js)2.74KB1.41KB
types (crud.js)0.20KB0.18KB
types (dashboard-filter-alias.js)6.23KB2.74KB
types (data-display.js)3.75KB1.85KB
types (data-protocol.js)0.20KB0.19KB
types (data.js)0.20KB0.18KB
types (designer.js)1.85KB0.85KB
types (disclosure.js)0.20KB0.18KB
types (error-code.js)1.54KB0.88KB
types (feedback.js)0.20KB0.18KB
types (field-types.js)0.20KB0.18KB
types (form.js)0.20KB0.18KB
types (http-inflight.js)8.87KB3.73KB
types (http-retry.js)4.32KB2.02KB
types (icon-key-migration.js)4.26KB1.63KB
types (index.js)4.72KB2.24KB
types (layout.js)0.20KB0.18KB
types (managed-by.js)0.19KB0.18KB
types (mobile.js)2.59KB1.31KB
types (navigation.js)0.20KB0.18KB
types (objectql.js)0.20KB0.18KB
types (overlay.js)0.20KB0.18KB
types (permissions.js)0.20KB0.18KB
types (plugin-scope.js)0.20KB0.18KB
types (record-components.js)0.20KB0.19KB
types (record-semantics.js)1.28KB0.67KB
types (registry.js)0.20KB0.18KB
types (reports.js)0.20KB0.18KB
types (spec-report.js)5.05KB1.93KB
types (spec-ui-namespace.js)0.20KB0.19KB
types (system-fields.js)3.33KB1.54KB
types (theme.js)6.28KB2.87KB
types (ui-action.js)3.40KB1.71KB
types (views.js)0.20KB0.18KB
types (widget.js)0.20KB0.18KB

Size Limits

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

@os-warrenClaude

Copy link
Copy Markdown
CollaboratorAuthor

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

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

⭐⭐ You declined the shared abstraction, and the forbidden path was the cheaper one

DataEmptyState in packages/components/src/custom/view-states.tsx is already consumed by plugin-list, plugin-kanban, plugin-dashboardand plugin-detail — the exact surface of the parallel card #7064. Changing its defaults would have been a smaller diff repairing both surfaces at once.

That is the strongest possible test of the fence, and it held. A cross-surface abstraction designed simultaneously by two agents who cannot see each other's reasoning is worse than two honest local fixes — and it would have been invisible as a problem, because the diff would have looked elegant. You consumed the primitive unchanged, kept the new component dashboard-local, recorded why in the file header, and touched nothing under plugin-detail. ⭐ Recording the temptation is as valuable as resisting it — it tells me convergence is worth a sequenced card once both land, which I can now schedule instead of discover.

Both of the card's premises were false, and neither failure was papered over

  1. "One shared placeholder reached by all the affected widgets." ⛔ False: three surfaces, two different keys, no shared code — and the measured string dashboard.noRows had exactly one call site repo-wide. ⇒ There was no seam to repair; one had to be created. That is a materially different job from the one the card describes, and it is why a new component exists rather than a one-line default change.
  2. "The authored emptyState contract already exists and already overrides." ⛔ False on this surface: emptyState is a list-view contract. Measured 0 occurrences across plugin-dashboard/src and plugin-charts/src⚠️ against a control of 1154 hits for widget over the same paths — and spec 17.2.0's DashboardWidgetSchema, read from the built package at runtime, declares no such key.

⭐ Clause 3 is vacuous here, and you were right that this is not a stop-and-report

My order made clause 3's stop-and-report fence hard, because a spec key added on a hunch becomes an unlandable PR under the quota freeze. You reached the fence and correctly did not trigger it: the deliverable is a better default, and a default needs no authoring surface. Nothing was widened, so nothing was owed.

⚠️ But the fact stands and is worth the maintainer's eye: the ruling's "the existing authored contract always overrides" describes, on this surface, a contract that does not exist. Same shape as #7064's finding an hour ago, where the ruling's authored opt-out turned out to be refused by the spec. Two rulings in one round whose preservation clause protects something that isn't there. I'm routing that pattern rather than treating each as a one-off.

A failed control, caught and re-taken — the discipline that matters most

Your first control for "WidgetEmptyState is not exported from index.tsx" used DatasetWidget, which also returned 0. Rather than reading that as confirmation, you discarded the reading and re-took it with PivotTable (= 1). ⭐ A control that returns zero is not a weak control, it is no control — and DatasetWidget being internal is exactly the kind of thing that makes a plausible control silently useless. This is the sixth instrument failure surfaced on this lane today and the second caught by its own operator mid-measurement.

Ablation: two legs, both per-surface

Leg 1 (revert DatasetWidget's empty branch) reddened 4 cases confined to that surface while table and pivot cases stayed green — proving the pins resolve per surface rather than firing as a block, which a single seam makes easy to get wrong. Leg 2 (delete role="status") reddened the role assertions while "a load FAILURE still reads as a failure" stayed green, showing the two halves of requirement 1 are independent. Directions predicted before running; both matched.

The copy decision is right, and its restraint is the reason

Naming the data source as a raw metadata identifier (crm_forecast) as a labelled monospace value, rather than folding it into a friendly sentence, is a truthful narrow statement over an invented one — and declining to use widget.title because DashboardRenderer already renders the resolved title directly above avoids saying the same thing twice. role="status" against the neighbouring role="alert" is what makes requirement 1 structural rather than cosmetic.

The i18n handling — additive after each pack's existing anchor, verified unique in all ten before writing, punctuation carried inside the key so no separator is concatenated and RTL needs no special case — is exactly what keeps the parallel packages/i18n work merging trivially.

NOT MEASURED, reported as such, with narrowing arguments

check:readme-exports (population COLLAPSED, 12 packages read against a floor of 25) and check:eager-closure (no apps/console/dist/eager-closure.json"a broken gauge, not a sensitive gate"). ⭐ Both carry a reason and the first carries a narrowing that is itself controlled: index.tsx is untouched and does not export the new component (control: PivotTable, which it does export, hits 1). CI owns both.

Open question 1 — emptyState for dashboard widgets: I adopt A

No key added. Your reasoning holds and I'll add the decisive point: B is currently unbuildable-to-land — it is a public authoring-surface widening, so it needs the contract review the exhausted Fable 5 quota blocks, and it would join #6896 / PR #7111 in the stranded state. Beyond that, adding a per-widget authored-prose key one card after a ruling closed the per-app-compensation route would undo the ruling with its own follow-up. Zero measured pull. Recorded on #7129 alongside #7064's sibling question.

Open question 2 — I am filing it; here is why I am overriding your restraint

You declined to file the ObjectChart observation because the state may be intentional. That restraint was right by default, and I'd rather see it than a reflexive card. But I'm filing it, on three grounds: it is a measured absence (no empty branch at all, not a judgement about quality), the seam you just built makes the fix small, and an observation that lives only in a subagent report is lost. The card carries your framing verbatim — may be intentional, needs a reading before any fix — so it records the fact without pre-judging it. Filed as #7130.

#7125

Correctly filed and correctly not acted on: the dead keys are residue this PR creates, the dispatch asked locale edits to stay additive for trivial parallel merges, and the dead-key gate is report-only and wired into no workflow. Deleting them here would have traded a clean merge for a cosmetic win.


Generated by Claude Code

@os-warren
os-warren marked this pull request as ready for review September 1, 2026 03:32
@os-warren
os-warren added this pull request to the merge queueSep 1, 2026
Merged via the queue into main with commit 220c18dSep 1, 2026
32 checks passed
@os-warren
os-warren deleted the claude/issue-7063-widget-empty-state-default branch September 1, 2026 03:45
os-warren pushed a commit that referenced this pull request Sep 1, 2026
…r an empty result
Measured, not assumed: recharts derives ticks from the data, so an empty
result draws two hairline axis rules and zero text nodes (bar/line) or
nothing at all (pie) — there are no labelled axes to read. Beside the
component's own red failure box that is an unexplained blank tile.
Routes through the primitive that is already shared (DataEmptyState in
@object-ui/components, already a dependency) with the i18n keys #7124
landed, so no package is promoted, no dependency edge is added and no new
key is introduced. Charts with inline authored data are unchanged.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012wwHa4aaFybxXrfmfHioDM
akarma-synetal pushed a commit to akarma-synetal/objectui that referenced this pull request Sep 1, 2026
…r an empty result (objectstack-ai#7139)
* fix(plugin-charts): a self-describing empty state for ObjectChart over an empty result
Measured, not assumed: recharts derives ticks from the data, so an empty
result draws two hairline axis rules and zero text nodes (bar/line) or
nothing at all (pie) — there are no labelled axes to read. Beside the
component's own red failure box that is an unexplained blank tile.
Routes through the primitive that is already shared (DataEmptyState in
@object-ui/components, already a dependency) with the i18n keys objectstack-ai#7124
landed, so no package is promoted, no dependency edge is added and no new
key is introduced. Charts with inline authored data are unchanged.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012wwHa4aaFybxXrfmfHioDM
* test(app-shell): give the two object-bound chart sweep targets rows
The DOM-leak sweep mounts `plugin-charts:object-chart` and `view:chart`
against an adapter that answers every query with no rows. Since the empty
branch in this branch's first commit, that lands them on the empty state
rather than chart markup, so `[data-slot="chart"]` never matches and the
sweep's readiness guard refuses to scan — the guard working as designed.
Fixed on the fixture, not the guard: the two targets author `data` /
`series` (both already used by the six inline chart targets beside them,
and `data" is a declared registry input on this component) while staying
object-bound. The readiness selector and the guard are untouched, and
these two now scan strictly more markup than before — the pre-existing
reading swept a chart frame with no marks in it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012wwHa4aaFybxXrfmfHioDM
---------
Co-authored-by: Claude <noreply@anthropic.com>
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

1 participant

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

fix(plugin-dashboard,i18n): a self-explaining default empty state for dashboard widgets - #7124

Merged
os-warren merged 1 commit into
mainfrom
claude/issue-7063-widget-empty-state-default
Sep 1, 2026
Merged

fix(plugin-dashboard,i18n): a self-explaining default empty state for dashboard widgets#7124
os-warren merged 1 commit into
mainfrom
claude/issue-7063-widget-empty-state-default

Conversation

@os-warren

Copy link
Copy Markdown
Collaborator

Fixes#7063

Maintainer ruling 2026-08-31 (hotcrm#1212 adjudication, verbatim 「1212 也是」, following 「hotcrm#1203 …这种也是平台的问题啊」): this class is the platform's to fix uniformly; apps must not compensate widget by widget (objectstack#13848 — apps are simplified business implementations, uniform behaviour belongs to the platform).

All evidence below was measured at 79accd6 (the final commit on this branch).


The card's premise was half wrong, and that changed the shape of the fix

Two of the dispatch's stated assumptions did not survive measurement. Both are reported here rather than worked around.

1. 暂无数据行 is NOT one shared placeholder — there was no seam to fix

Three dashboard surfaces wrote their own empty state, in two different strings, with no common code:

surfacekeyenzh
DatasetWidget (the measured one)dashboard.noRowsNo rows暂无数据行
ObjectDataTabledashboard.noDataAvailableNo data available暂无数据
PivotTabledashboard.noDataAvailableNo data available暂无数据

dashboard.noRows had exactly one call site repo-wide (DatasetWidget.tsx:727), so the measured 暂无数据行 came from the dataset-bound path only — which is the ADR-0021 governed path the hotcrm quota-attainment tile takes.

So "uniformly" could not mean "fix the shared placeholder". The new WidgetEmptyStateis the seam: it is added, and the three surfaces are routed through it.

2. The authored emptyState override does not exist on this surface

Clause 3 assumes an existing authored emptyState contract that always overrides. Measured:

  • emptyState occurs 0 times in packages/plugin-dashboard/src and packages/plugin-charts/src. Control in the same sweep, same paths: widget, 1154 hits — so the zero is a reading, not a broken query.

  • emptyState is a list-view contract (ObjectGridSchema, NamedListView in packages/types/src/objectql.ts), honoured in packages/plugin-list/src/ListView.tsx.

  • @objectstack/spec@17.2.0's DashboardWidgetSchema declares no such key. Its full key set, read from the built package at runtime rather than recalled:

    id, title, description, type, chartConfig, colorVariant, requiresObject, requiresService, actionUrl, actionType, actionIcon, filter, compareTo, dataset, dimensions, values, layout, options, filterBindings, suppressWarnings, responsive, aria

Consequence, and why this is not the stop-and-report case. Clause 3 is satisfied here vacuously: there is no authored override on dashboard widgets for the default to lose to, so improving the default cannot displace one. No spec key was added and none is needed to deliver this card — the deliverable is a better DEFAULT, and defaults need no authoring surface. Making clause 3 non-vacuous (i.e. giving dashboard widgets a real authored override) would require a new spec key, which is the contract question the card fences off. That is flagged for the seat, not taken.


What changed

New:packages/plugin-dashboard/src/WidgetEmptyState.tsx — the dashboard-local seam.

The ruling's four properties, and how each is delivered:

  1. Distinguishable from a load failure at a glance.role="status", where the failure branches sitting beside these call sites are role="alert" on destructive colours. The empty branches previously carried no role at all, so assistive tech got a bare fragment with no state either way. Visually: muted treatment and an inbox glyph, never destructive colour and a warning triangle.
  2. Self-describing without authored copy. A title and an explanation where the placeholder was one terse fragment, plus the name of the widget's data source. The copy states that the widget loaded successfully — the one fact a reader of a blank tile cannot otherwise get.
  3. No new authoring obligation, no new spec key. Nothing was added to any schema. PivotTable gains one optional component prop (sourceLabel), which is React-side only.
  4. Platform i18n.dashboard.empty.title / .message / .sourceLabel in en and all nine sibling packs.

What source names, and why it is a raw identifier. The card allows the widget's label and/or its data source. The widget's title is already rendered by the card header directly above every tile (DashboardRenderer), so repeating it says nothing new; the data source is the half the reader cannot see. What is reachable at each render site is the authored binding — widget.dataset on the dataset path, schema.objectName on the object-bound table/pivot — i.e. a raw metadata name such as crm_forecast, not a localized label. It is rendered as a labelled monospace value rather than folded into a sentence: a truthful narrow statement beats an invented friendly one.

Why the copy carries no interpolation.useSafeTranslate takes a positional fallback and no options bag, so an interpolated key would have to come from a raw useObjectTranslationt() — and with no I18nProvider mounted (the standalone-host and test configuration, which vitest.setup.i18n-global.ts deliberately preserves) that renders the raw KEY unless the call site also carries an inline defaultValue, which #3517 rules out. A label that carries its own punctuation (Source: / 数据源: / مصدر البيانات:) needs neither, and concatenates no separator in code.

Files:WidgetEmptyState.tsx (new) · DatasetWidget.tsx · ObjectDataTable.tsx · PivotTable.tsx · ObjectPivotTable.tsx · ten locale packs · one new test file · DatasetWidget.test.tsx · one changeset.


Evidence

Tests

pnpm exec vitest run packages/plugin-dashboard packages/i18n packages/app-shell/src/__tests__/widget-dom-leak-sweep.test.tsx --maxWorkers=2 at 79accd6:

 Test Files 144 passed (144)
Tests 1924 passed (1924)

New pin: packages/plugin-dashboard/src/__tests__/WidgetEmptyState.uniformDefault.test.tsx (5 cases). It pins the ruling's properties, never the sentence — copy is review's to move. It also pins that all four dashboard entry points reach the one seam, which is the property that decays first: a fourth widget can quietly write a fifth placeholder while every per-widget test stays green.

Four pins in DatasetWidget.test.tsx were rewritten rather than left passing. Three of them asserted queryByText('No rows')).not.toBeInTheDocument() — after this change that string exists nowhere, so they would have passed for the wrong reason. They now select the empty state by test id.

Ablation — two legs, direction predicted before running

Leg 1 — revert DatasetWidget's empty branch to the pre-card dashboard.noRows one-liner. Predicted: RED, and only on the DatasetWidget surface.

  • mutation proven on disk: removed-marker WidgetEmptyState (as a JSX open tag) 1 to 0; injected-marker tt('dashboard.noRows' 0 to 1; blob f8f61e71... to 951c8251...
  • observed: Tests 4 failed | 59 passed, vitest exit 1 (read from the run's own printed ABLATED_VITEST_EXIT=1, not a bare $? behind a pipe). All four failures on the DatasetWidget surface; the ObjectDataTable, PivotTable and ObjectPivotTable cases stayed green, so the pins resolve per surface rather than firing as a block.
  • restore proven by state: git diff HEAD empty, git status --porcelain empty, blob back to f8f61e712947e3cdf3dacf3cdab3e6a8cccd25ba = its HEAD blob.

Leg 2 — delete role="status" from the shared component, i.e. ablate property (1) itself. Predicted: RED on the role assertions; the load-failure test stays green.

  • mutation proven on disk: marker role="status" 2 to 1 (the second occurrence is in the file's doc comment; the python anchor asserted exactly one match on the JSX line); blob a62aaa96... to 39a46faf...
  • observed: Tests 4 failed | 59 passed, exit 1 — the three seam cases plus the DatasetWidget.test.tsx role case. a load FAILURE still reads as a failure stayed green, so the two halves of property (1) are independent.
  • restore proven by state: git diff HEAD empty, blob back to a62aaa96ec88cede54f96579451a6922b3aceb3a.

Both legs used an EXIT INT TERM trap with an absolute REPO_ROOT path, and restored with git checkout HEAD -- path (never a bare git checkout -- path, which restores from the index and would hand back the mutation).

No rebuild leg, stated rather than skipped. This repo's vitest resolves @object-ui/* to packages/*/src via resolve.alias in vitest.config.mts, and the pins import the components by relative path, so no dist can shadow the mutation. A stale build cannot make an ablation falsely green here.

Gates

gateexitverdict
vitest (plugin-dashboard, i18n, widget-dom-leak-sweep)0green — 144 files, 1924 tests
pnpm --filter @object-ui/plugin-dashboard --filter @object-ui/i18n run type-check0green — both ran tsc --noEmit && tsc -p tsconfig.test.json, so the new test file was type-checked, not merely excluded
pnpm --filter @object-ui/plugin-dashboard --filter @object-ui/i18n run lint0green — 0 errors (423 + 34 pre-existing warnings; 4 of them no-explicit-any on the new test file, the same schema-cast shape its sibling suites use)
check:i18n-keys0green — "Every in-scope call-site key resolves against the en pack (2845 keys)…"
check:i18n-drift0green — "0 en value(s) changed (3 key(s) added, 0 removed…)" — strictly additive
check:control-bytes0green — "OK (scanned 5898 tracked text file(s); skipped 85 binary)"
check-changeset-presence0green — "17 source file(s) of 2 released package(s) changed, and this change declares 1 changeset(s)"
check:changeset-fixed0green — "All workspace packages are in the changeset fixed group."
check:changeset-no-major0green — "No changeset declares a major bump."
check:phantom-deps / check:self-import / check:side-effects-array / check:vi-mock-specifiers / check:vi-mock-inherit / check:esm-specifiers0green
check:readme-exports1NOT MEASURED — "the population COLLAPSED -- this run proves nothing / packagesRead: found 12, floor is 25". Local tree has most packages unbuilt; CI runs turbo run build first. Narrowing argument: packages/plugin-dashboard/src/index.tsx is untouched and does not export WidgetEmptyState (control in the same query: PivotTable, which it does export, hits), so the package's published export surface is unchanged and no README moved.
check:eager-closure2NOT MEASURED — "No eager-closure report at apps/console/dist/eager-closure.json … This is a broken gauge, not a sensitive gate." Needs a built apps/console; produced by performance-budget.yml in CI.

Changeset verdicts are quoted verbatim above rather than predicted; both gates were run.

Zero-hit readings, each with its control

queryscopehitscontrolcontrol hits
emptyStateplugin-dashboard/src + plugin-charts/src0widget, same scope1154
WidgetEmptyStateplugin-dashboard/src/index.tsx0PivotTable, same file1

What is NOT verified here, and why

The hotcrm acceptance scenario is unverified by me. The card's acceptance is a hotcrm sales_dashboard fresh install. hotcrm is outside this session's repo scope — it is not attached and I did not try to reach it. What I can state is the objectui-side mechanism: the quota-attainment tile is a dataset-bound table widget, which renders through DatasetWidget, whose empty branch is the one call site of the 暂无数据行 string this PR replaces. The end-to-end confirmation on a fresh hotcrm install still needs to be run by someone with that repo.

Shared-abstraction temptation, reported rather than taken.DataEmptyState in @object-ui/components is the presentational primitive already used by plugin-list, plugin-kanban, plugin-dashboardand plugin-detail — the surface of the parallel card #7064. Changing its defaults would have been a smaller diff that repaired both surfaces at once. It is deliberately consumed unchanged here: a shared empty-state abstraction designed twice in parallel by two agents is worse than two honest local fixes, and converging the two is its own sequenced card. Nothing under packages/plugin-detail is touched by this PR.

Residue this creates, not fixed here.dashboard.noRows and dashboard.noDataAvailable now have no call site. check:i18n-dead-keys (report-only, exit 0, not wired into CI) lists both as needs-review after this change. They are left in all ten packs deliberately: the dispatch asks locale edits to stay additive so parallel merges stay trivial, and the dead-key sweep is its own tracked workstream (objectui#4658). Filed as a follow-up rather than ridden on this PR.


Generated by Claude Code

… dashboard widgets
Maintainer ruling 2026-08-31 (hotcrm#1212, following hotcrm#1203): a widget that
renders a bare row-placeholder on an empty result is the platform's defect and
must be fixed uniformly — apps must not compensate widget by widget
(objectstack#13848).
There was no shared placeholder to fix. Three dashboard surfaces wrote their own,
in two different strings: DatasetWidget used `dashboard.noRows` ('No rows' /
暂无数据行), while ObjectDataTable and PivotTable used `dashboard.noDataAvailable`.
`WidgetEmptyState` is the seam they now share.
The default reads as a STATE rather than a failure — `role="status"` where the
branches beside it are `role="alert"` and the empty branches previously carried
no role at all — carries a title plus an explanation instead of one terse
fragment, and names the widget's data source with zero authored copy.
Copy is platform i18n: `dashboard.empty.title` / `.message` / `.sourceLabel` in
`en` and all nine sibling packs. No inline defaultValue, no interpolation.
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)3151.9 KB3191.4 KB
Main entry chunk (gzip)142.3 KB350 KB
Entry fileindex-DbjcEXZE.js
StatusPASS

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


📦 Bundle Size Report

PackageSizeGzipped
app-shell (consoleActionDispatch.js)0.20KB0.19KB
app-shell (index.js)15.33KB5.59KB
app-shell (runtime-config.js)20.68KB7.36KB
app-shell (types.js)0.01KB0.04KB
app-shell (urlParams.js)10.06KB3.86KB
auth (ActiveOrganizationStorage.js)25.05KB9.16KB
auth (AuthContext.js)0.31KB0.24KB
auth (AuthGuard.js)2.07KB1.00KB
auth (AuthProvider.js)40.18KB10.59KB
auth (AuthShell.js)3.49KB1.40KB
auth (ForgotPasswordForm.js)12.21KB3.45KB
auth (LoginForm.js)18.15KB5.39KB
auth (PreviewBanner.js)0.90KB0.50KB
auth (RegisterForm.js)6.65KB2.22KB
auth (SocialSignInButtons.js)9.61KB3.89KB
auth (UserMenu.js)3.41KB1.23KB
auth (auth-gate-events.js)1.29KB0.66KB
auth (authStyles.js)5.04KB1.72KB
auth (createAuthClient.js)40.21KB10.80KB
auth (createAuthenticatedFetch.js)8.46KB3.43KB
auth (index.js)3.19KB1.44KB
auth (invitation-status.js)1.22KB0.70KB
auth (org-roles.js)6.66KB2.78KB
auth (phone-identifier.js)1.11KB0.66KB
auth (types.js)0.59KB0.35KB
auth (useAuth.js)5.30KB1.02KB
auth (useWorkspaceAdminStatus.js)5.13KB2.35KB
collaboration (CommentThread.js)26.08KB7.56KB
collaboration (LiveCursors.js)3.17KB1.27KB
collaboration (PresenceAvatars.js)6.49KB2.64KB
collaboration (PresenceProvider.js)2.79KB1.13KB
collaboration (index.js)1.68KB0.73KB
collaboration (useCollaborationTranslation.js)6.05KB2.52KB
collaboration (useCommentSearch.js)1.98KB0.88KB
collaboration (useConflictResolution.js)7.75KB1.86KB
collaboration (useMentionNotifications.js)1.81KB0.68KB
collaboration (usePresence.js)6.33KB1.84KB
collaboration (useRealtimeSubscription.js)7.91KB2.01KB
components (index.js)512.30KB116.52KB
core (index.js)5.30KB2.13KB
create-plugin (index.js)10.08KB3.26KB
data-objectstack (index.js)177.67KB49.45KB
fields (index.js)243.64KB61.64KB
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.94KB63.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.14KB14.64KB
plugin-list (index.js)113.19KB27.60KB
plugin-map (index.js)20.20KB6.66KB
plugin-markdown (index.js)13.72KB4.69KB
plugin-report (index.js)43.51KB11.94KB
plugin-timeline (index.js)29.34KB8.47KB
plugin-tree (index.js)8.98KB3.08KB
plugin-view (index.js)85.90KB21.12KB
providers (DataSourceProvider.js)0.75KB0.39KB
providers (MetadataProvider.js)1.37KB0.59KB
providers (ThemeProvider.js)1.90KB0.85KB
providers (UploadProvider.js)11.66KB3.50KB
providers (index.js)0.45KB0.23KB
providers (types.js)0.01KB0.04KB
react-runtime (index.js)5.62KB2.34KB
react (LazyPluginLoader.js)4.47KB1.63KB
react (SchemaRenderer.js)81.07KB26.86KB
react (data-invalidation.js)5.05KB2.08KB
react (index.js)3.11KB1.48KB
react (schema-input.js)2.32KB1.24KB
react (spec-input.js)0.20KB0.18KB
sdui-parser (codegen.js)5.41KB2.34KB
sdui-parser (dashboard-widget-options.js)3.08KB1.30KB
sdui-parser (index.js)4.93KB2.24KB
sdui-parser (input-type.js)2.84KB1.40KB
sdui-parser (parse.js)20.57KB5.88KB
sdui-parser (provenance.js)3.66KB1.82KB
sdui-parser (types.js)0.28KB0.23KB
sdui-parser (validate.js)10.35KB3.60KB
types (ai.js)0.20KB0.17KB
types (api-types.js)0.20KB0.18KB
types (app.js)2.87KB0.99KB
types (base.js)0.20KB0.18KB
types (blocks.js)0.20KB0.18KB
types (complex.js)2.74KB1.41KB
types (crud.js)0.20KB0.18KB
types (dashboard-filter-alias.js)6.23KB2.74KB
types (data-display.js)3.75KB1.85KB
types (data-protocol.js)0.20KB0.19KB
types (data.js)0.20KB0.18KB
types (designer.js)1.85KB0.85KB
types (disclosure.js)0.20KB0.18KB
types (error-code.js)1.54KB0.88KB
types (feedback.js)0.20KB0.18KB
types (field-types.js)0.20KB0.18KB
types (form.js)0.20KB0.18KB
types (http-inflight.js)8.87KB3.73KB
types (http-retry.js)4.32KB2.02KB
types (icon-key-migration.js)4.26KB1.63KB
types (index.js)4.72KB2.24KB
types (layout.js)0.20KB0.18KB
types (managed-by.js)0.19KB0.18KB
types (mobile.js)2.59KB1.31KB
types (navigation.js)0.20KB0.18KB
types (objectql.js)0.20KB0.18KB
types (overlay.js)0.20KB0.18KB
types (permissions.js)0.20KB0.18KB
types (plugin-scope.js)0.20KB0.18KB
types (record-components.js)0.20KB0.19KB
types (record-semantics.js)1.28KB0.67KB
types (registry.js)0.20KB0.18KB
types (reports.js)0.20KB0.18KB
types (spec-report.js)5.05KB1.93KB
types (spec-ui-namespace.js)0.20KB0.19KB
types (system-fields.js)3.33KB1.54KB
types (theme.js)6.28KB2.87KB
types (ui-action.js)3.40KB1.71KB
types (views.js)0.20KB0.18KB
types (widget.js)0.20KB0.18KB

Size Limits

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

@os-warrenClaude

Copy link
Copy Markdown
CollaboratorAuthor

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

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

⭐⭐ You declined the shared abstraction, and the forbidden path was the cheaper one

DataEmptyState in packages/components/src/custom/view-states.tsx is already consumed by plugin-list, plugin-kanban, plugin-dashboardand plugin-detail — the exact surface of the parallel card #7064. Changing its defaults would have been a smaller diff repairing both surfaces at once.

That is the strongest possible test of the fence, and it held. A cross-surface abstraction designed simultaneously by two agents who cannot see each other's reasoning is worse than two honest local fixes — and it would have been invisible as a problem, because the diff would have looked elegant. You consumed the primitive unchanged, kept the new component dashboard-local, recorded why in the file header, and touched nothing under plugin-detail. ⭐ Recording the temptation is as valuable as resisting it — it tells me convergence is worth a sequenced card once both land, which I can now schedule instead of discover.

Both of the card's premises were false, and neither failure was papered over

  1. "One shared placeholder reached by all the affected widgets." ⛔ False: three surfaces, two different keys, no shared code — and the measured string dashboard.noRows had exactly one call site repo-wide. ⇒ There was no seam to repair; one had to be created. That is a materially different job from the one the card describes, and it is why a new component exists rather than a one-line default change.
  2. "The authored emptyState contract already exists and already overrides." ⛔ False on this surface: emptyState is a list-view contract. Measured 0 occurrences across plugin-dashboard/src and plugin-charts/src⚠️ against a control of 1154 hits for widget over the same paths — and spec 17.2.0's DashboardWidgetSchema, read from the built package at runtime, declares no such key.

⭐ Clause 3 is vacuous here, and you were right that this is not a stop-and-report

My order made clause 3's stop-and-report fence hard, because a spec key added on a hunch becomes an unlandable PR under the quota freeze. You reached the fence and correctly did not trigger it: the deliverable is a better default, and a default needs no authoring surface. Nothing was widened, so nothing was owed.

⚠️ But the fact stands and is worth the maintainer's eye: the ruling's "the existing authored contract always overrides" describes, on this surface, a contract that does not exist. Same shape as #7064's finding an hour ago, where the ruling's authored opt-out turned out to be refused by the spec. Two rulings in one round whose preservation clause protects something that isn't there. I'm routing that pattern rather than treating each as a one-off.

A failed control, caught and re-taken — the discipline that matters most

Your first control for "WidgetEmptyState is not exported from index.tsx" used DatasetWidget, which also returned 0. Rather than reading that as confirmation, you discarded the reading and re-took it with PivotTable (= 1). ⭐ A control that returns zero is not a weak control, it is no control — and DatasetWidget being internal is exactly the kind of thing that makes a plausible control silently useless. This is the sixth instrument failure surfaced on this lane today and the second caught by its own operator mid-measurement.

Ablation: two legs, both per-surface

Leg 1 (revert DatasetWidget's empty branch) reddened 4 cases confined to that surface while table and pivot cases stayed green — proving the pins resolve per surface rather than firing as a block, which a single seam makes easy to get wrong. Leg 2 (delete role="status") reddened the role assertions while "a load FAILURE still reads as a failure" stayed green, showing the two halves of requirement 1 are independent. Directions predicted before running; both matched.

The copy decision is right, and its restraint is the reason

Naming the data source as a raw metadata identifier (crm_forecast) as a labelled monospace value, rather than folding it into a friendly sentence, is a truthful narrow statement over an invented one — and declining to use widget.title because DashboardRenderer already renders the resolved title directly above avoids saying the same thing twice. role="status" against the neighbouring role="alert" is what makes requirement 1 structural rather than cosmetic.

The i18n handling — additive after each pack's existing anchor, verified unique in all ten before writing, punctuation carried inside the key so no separator is concatenated and RTL needs no special case — is exactly what keeps the parallel packages/i18n work merging trivially.

NOT MEASURED, reported as such, with narrowing arguments

check:readme-exports (population COLLAPSED, 12 packages read against a floor of 25) and check:eager-closure (no apps/console/dist/eager-closure.json"a broken gauge, not a sensitive gate"). ⭐ Both carry a reason and the first carries a narrowing that is itself controlled: index.tsx is untouched and does not export the new component (control: PivotTable, which it does export, hits 1). CI owns both.

Open question 1 — emptyState for dashboard widgets: I adopt A

No key added. Your reasoning holds and I'll add the decisive point: B is currently unbuildable-to-land — it is a public authoring-surface widening, so it needs the contract review the exhausted Fable 5 quota blocks, and it would join #6896 / PR #7111 in the stranded state. Beyond that, adding a per-widget authored-prose key one card after a ruling closed the per-app-compensation route would undo the ruling with its own follow-up. Zero measured pull. Recorded on #7129 alongside #7064's sibling question.

Open question 2 — I am filing it; here is why I am overriding your restraint

You declined to file the ObjectChart observation because the state may be intentional. That restraint was right by default, and I'd rather see it than a reflexive card. But I'm filing it, on three grounds: it is a measured absence (no empty branch at all, not a judgement about quality), the seam you just built makes the fix small, and an observation that lives only in a subagent report is lost. The card carries your framing verbatim — may be intentional, needs a reading before any fix — so it records the fact without pre-judging it. Filed as #7130.

#7125

Correctly filed and correctly not acted on: the dead keys are residue this PR creates, the dispatch asked locale edits to stay additive for trivial parallel merges, and the dead-key gate is report-only and wired into no workflow. Deleting them here would have traded a clean merge for a cosmetic win.


Generated by Claude Code

@os-warren
os-warren marked this pull request as ready for review September 1, 2026 03:32
@os-warren
os-warren added this pull request to the merge queueSep 1, 2026
Merged via the queue into main with commit 220c18dSep 1, 2026
32 checks passed
@os-warren
os-warren deleted the claude/issue-7063-widget-empty-state-default branch September 1, 2026 03:45
os-warren pushed a commit that referenced this pull request Sep 1, 2026
…r an empty result
Measured, not assumed: recharts derives ticks from the data, so an empty
result draws two hairline axis rules and zero text nodes (bar/line) or
nothing at all (pie) — there are no labelled axes to read. Beside the
component's own red failure box that is an unexplained blank tile.
Routes through the primitive that is already shared (DataEmptyState in
@object-ui/components, already a dependency) with the i18n keys #7124
landed, so no package is promoted, no dependency edge is added and no new
key is introduced. Charts with inline authored data are unchanged.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012wwHa4aaFybxXrfmfHioDM
akarma-synetal pushed a commit to akarma-synetal/objectui that referenced this pull request Sep 1, 2026
…r an empty result (objectstack-ai#7139)
* fix(plugin-charts): a self-describing empty state for ObjectChart over an empty result
Measured, not assumed: recharts derives ticks from the data, so an empty
result draws two hairline axis rules and zero text nodes (bar/line) or
nothing at all (pie) — there are no labelled axes to read. Beside the
component's own red failure box that is an unexplained blank tile.
Routes through the primitive that is already shared (DataEmptyState in
@object-ui/components, already a dependency) with the i18n keys objectstack-ai#7124
landed, so no package is promoted, no dependency edge is added and no new
key is introduced. Charts with inline authored data are unchanged.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012wwHa4aaFybxXrfmfHioDM
* test(app-shell): give the two object-bound chart sweep targets rows
The DOM-leak sweep mounts `plugin-charts:object-chart` and `view:chart`
against an adapter that answers every query with no rows. Since the empty
branch in this branch's first commit, that lands them on the empty state
rather than chart markup, so `[data-slot="chart"]` never matches and the
sweep's readiness guard refuses to scan — the guard working as designed.
Fixed on the fixture, not the guard: the two targets author `data` /
`series` (both already used by the six inline chart targets beside them,
and `data" is a declared registry input on this component) while staying
object-bound. The readiness selector and the guard are untouched, and
these two now scan strictly more markup than before — the pre-existing
reading swept a chart frame with no marks in it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012wwHa4aaFybxXrfmfHioDM
---------
Co-authored-by: Claude <noreply@anthropic.com>
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

1 participant

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

fix(plugin-dashboard,i18n): a self-explaining default empty state for dashboard widgets - #7124

Merged
os-warren merged 1 commit into
mainfrom
claude/issue-7063-widget-empty-state-default
Sep 1, 2026
Merged

fix(plugin-dashboard,i18n): a self-explaining default empty state for dashboard widgets#7124
os-warren merged 1 commit into
mainfrom
claude/issue-7063-widget-empty-state-default

Conversation

@os-warren

Copy link
Copy Markdown
Collaborator

Fixes#7063

Maintainer ruling 2026-08-31 (hotcrm#1212 adjudication, verbatim 「1212 也是」, following 「hotcrm#1203 …这种也是平台的问题啊」): this class is the platform's to fix uniformly; apps must not compensate widget by widget (objectstack#13848 — apps are simplified business implementations, uniform behaviour belongs to the platform).

All evidence below was measured at 79accd6 (the final commit on this branch).


The card's premise was half wrong, and that changed the shape of the fix

Two of the dispatch's stated assumptions did not survive measurement. Both are reported here rather than worked around.

1. 暂无数据行 is NOT one shared placeholder — there was no seam to fix

Three dashboard surfaces wrote their own empty state, in two different strings, with no common code:

surfacekeyenzh
DatasetWidget (the measured one)dashboard.noRowsNo rows暂无数据行
ObjectDataTabledashboard.noDataAvailableNo data available暂无数据
PivotTabledashboard.noDataAvailableNo data available暂无数据

dashboard.noRows had exactly one call site repo-wide (DatasetWidget.tsx:727), so the measured 暂无数据行 came from the dataset-bound path only — which is the ADR-0021 governed path the hotcrm quota-attainment tile takes.

So "uniformly" could not mean "fix the shared placeholder". The new WidgetEmptyStateis the seam: it is added, and the three surfaces are routed through it.

2. The authored emptyState override does not exist on this surface

Clause 3 assumes an existing authored emptyState contract that always overrides. Measured:

  • emptyState occurs 0 times in packages/plugin-dashboard/src and packages/plugin-charts/src. Control in the same sweep, same paths: widget, 1154 hits — so the zero is a reading, not a broken query.

  • emptyState is a list-view contract (ObjectGridSchema, NamedListView in packages/types/src/objectql.ts), honoured in packages/plugin-list/src/ListView.tsx.

  • @objectstack/spec@17.2.0's DashboardWidgetSchema declares no such key. Its full key set, read from the built package at runtime rather than recalled:

    id, title, description, type, chartConfig, colorVariant, requiresObject, requiresService, actionUrl, actionType, actionIcon, filter, compareTo, dataset, dimensions, values, layout, options, filterBindings, suppressWarnings, responsive, aria

Consequence, and why this is not the stop-and-report case. Clause 3 is satisfied here vacuously: there is no authored override on dashboard widgets for the default to lose to, so improving the default cannot displace one. No spec key was added and none is needed to deliver this card — the deliverable is a better DEFAULT, and defaults need no authoring surface. Making clause 3 non-vacuous (i.e. giving dashboard widgets a real authored override) would require a new spec key, which is the contract question the card fences off. That is flagged for the seat, not taken.


What changed

New:packages/plugin-dashboard/src/WidgetEmptyState.tsx — the dashboard-local seam.

The ruling's four properties, and how each is delivered:

  1. Distinguishable from a load failure at a glance.role="status", where the failure branches sitting beside these call sites are role="alert" on destructive colours. The empty branches previously carried no role at all, so assistive tech got a bare fragment with no state either way. Visually: muted treatment and an inbox glyph, never destructive colour and a warning triangle.
  2. Self-describing without authored copy. A title and an explanation where the placeholder was one terse fragment, plus the name of the widget's data source. The copy states that the widget loaded successfully — the one fact a reader of a blank tile cannot otherwise get.
  3. No new authoring obligation, no new spec key. Nothing was added to any schema. PivotTable gains one optional component prop (sourceLabel), which is React-side only.
  4. Platform i18n.dashboard.empty.title / .message / .sourceLabel in en and all nine sibling packs.

What source names, and why it is a raw identifier. The card allows the widget's label and/or its data source. The widget's title is already rendered by the card header directly above every tile (DashboardRenderer), so repeating it says nothing new; the data source is the half the reader cannot see. What is reachable at each render site is the authored binding — widget.dataset on the dataset path, schema.objectName on the object-bound table/pivot — i.e. a raw metadata name such as crm_forecast, not a localized label. It is rendered as a labelled monospace value rather than folded into a sentence: a truthful narrow statement beats an invented friendly one.

Why the copy carries no interpolation.useSafeTranslate takes a positional fallback and no options bag, so an interpolated key would have to come from a raw useObjectTranslationt() — and with no I18nProvider mounted (the standalone-host and test configuration, which vitest.setup.i18n-global.ts deliberately preserves) that renders the raw KEY unless the call site also carries an inline defaultValue, which #3517 rules out. A label that carries its own punctuation (Source: / 数据源: / مصدر البيانات:) needs neither, and concatenates no separator in code.

Files:WidgetEmptyState.tsx (new) · DatasetWidget.tsx · ObjectDataTable.tsx · PivotTable.tsx · ObjectPivotTable.tsx · ten locale packs · one new test file · DatasetWidget.test.tsx · one changeset.


Evidence

Tests

pnpm exec vitest run packages/plugin-dashboard packages/i18n packages/app-shell/src/__tests__/widget-dom-leak-sweep.test.tsx --maxWorkers=2 at 79accd6:

 Test Files 144 passed (144)
Tests 1924 passed (1924)

New pin: packages/plugin-dashboard/src/__tests__/WidgetEmptyState.uniformDefault.test.tsx (5 cases). It pins the ruling's properties, never the sentence — copy is review's to move. It also pins that all four dashboard entry points reach the one seam, which is the property that decays first: a fourth widget can quietly write a fifth placeholder while every per-widget test stays green.

Four pins in DatasetWidget.test.tsx were rewritten rather than left passing. Three of them asserted queryByText('No rows')).not.toBeInTheDocument() — after this change that string exists nowhere, so they would have passed for the wrong reason. They now select the empty state by test id.

Ablation — two legs, direction predicted before running

Leg 1 — revert DatasetWidget's empty branch to the pre-card dashboard.noRows one-liner. Predicted: RED, and only on the DatasetWidget surface.

  • mutation proven on disk: removed-marker WidgetEmptyState (as a JSX open tag) 1 to 0; injected-marker tt('dashboard.noRows' 0 to 1; blob f8f61e71... to 951c8251...
  • observed: Tests 4 failed | 59 passed, vitest exit 1 (read from the run's own printed ABLATED_VITEST_EXIT=1, not a bare $? behind a pipe). All four failures on the DatasetWidget surface; the ObjectDataTable, PivotTable and ObjectPivotTable cases stayed green, so the pins resolve per surface rather than firing as a block.
  • restore proven by state: git diff HEAD empty, git status --porcelain empty, blob back to f8f61e712947e3cdf3dacf3cdab3e6a8cccd25ba = its HEAD blob.

Leg 2 — delete role="status" from the shared component, i.e. ablate property (1) itself. Predicted: RED on the role assertions; the load-failure test stays green.

  • mutation proven on disk: marker role="status" 2 to 1 (the second occurrence is in the file's doc comment; the python anchor asserted exactly one match on the JSX line); blob a62aaa96... to 39a46faf...
  • observed: Tests 4 failed | 59 passed, exit 1 — the three seam cases plus the DatasetWidget.test.tsx role case. a load FAILURE still reads as a failure stayed green, so the two halves of property (1) are independent.
  • restore proven by state: git diff HEAD empty, blob back to a62aaa96ec88cede54f96579451a6922b3aceb3a.

Both legs used an EXIT INT TERM trap with an absolute REPO_ROOT path, and restored with git checkout HEAD -- path (never a bare git checkout -- path, which restores from the index and would hand back the mutation).

No rebuild leg, stated rather than skipped. This repo's vitest resolves @object-ui/* to packages/*/src via resolve.alias in vitest.config.mts, and the pins import the components by relative path, so no dist can shadow the mutation. A stale build cannot make an ablation falsely green here.

Gates

gateexitverdict
vitest (plugin-dashboard, i18n, widget-dom-leak-sweep)0green — 144 files, 1924 tests
pnpm --filter @object-ui/plugin-dashboard --filter @object-ui/i18n run type-check0green — both ran tsc --noEmit && tsc -p tsconfig.test.json, so the new test file was type-checked, not merely excluded
pnpm --filter @object-ui/plugin-dashboard --filter @object-ui/i18n run lint0green — 0 errors (423 + 34 pre-existing warnings; 4 of them no-explicit-any on the new test file, the same schema-cast shape its sibling suites use)
check:i18n-keys0green — "Every in-scope call-site key resolves against the en pack (2845 keys)…"
check:i18n-drift0green — "0 en value(s) changed (3 key(s) added, 0 removed…)" — strictly additive
check:control-bytes0green — "OK (scanned 5898 tracked text file(s); skipped 85 binary)"
check-changeset-presence0green — "17 source file(s) of 2 released package(s) changed, and this change declares 1 changeset(s)"
check:changeset-fixed0green — "All workspace packages are in the changeset fixed group."
check:changeset-no-major0green — "No changeset declares a major bump."
check:phantom-deps / check:self-import / check:side-effects-array / check:vi-mock-specifiers / check:vi-mock-inherit / check:esm-specifiers0green
check:readme-exports1NOT MEASURED — "the population COLLAPSED -- this run proves nothing / packagesRead: found 12, floor is 25". Local tree has most packages unbuilt; CI runs turbo run build first. Narrowing argument: packages/plugin-dashboard/src/index.tsx is untouched and does not export WidgetEmptyState (control in the same query: PivotTable, which it does export, hits), so the package's published export surface is unchanged and no README moved.
check:eager-closure2NOT MEASURED — "No eager-closure report at apps/console/dist/eager-closure.json … This is a broken gauge, not a sensitive gate." Needs a built apps/console; produced by performance-budget.yml in CI.

Changeset verdicts are quoted verbatim above rather than predicted; both gates were run.

Zero-hit readings, each with its control

queryscopehitscontrolcontrol hits
emptyStateplugin-dashboard/src + plugin-charts/src0widget, same scope1154
WidgetEmptyStateplugin-dashboard/src/index.tsx0PivotTable, same file1

What is NOT verified here, and why

The hotcrm acceptance scenario is unverified by me. The card's acceptance is a hotcrm sales_dashboard fresh install. hotcrm is outside this session's repo scope — it is not attached and I did not try to reach it. What I can state is the objectui-side mechanism: the quota-attainment tile is a dataset-bound table widget, which renders through DatasetWidget, whose empty branch is the one call site of the 暂无数据行 string this PR replaces. The end-to-end confirmation on a fresh hotcrm install still needs to be run by someone with that repo.

Shared-abstraction temptation, reported rather than taken.DataEmptyState in @object-ui/components is the presentational primitive already used by plugin-list, plugin-kanban, plugin-dashboardand plugin-detail — the surface of the parallel card #7064. Changing its defaults would have been a smaller diff that repaired both surfaces at once. It is deliberately consumed unchanged here: a shared empty-state abstraction designed twice in parallel by two agents is worse than two honest local fixes, and converging the two is its own sequenced card. Nothing under packages/plugin-detail is touched by this PR.

Residue this creates, not fixed here.dashboard.noRows and dashboard.noDataAvailable now have no call site. check:i18n-dead-keys (report-only, exit 0, not wired into CI) lists both as needs-review after this change. They are left in all ten packs deliberately: the dispatch asks locale edits to stay additive so parallel merges stay trivial, and the dead-key sweep is its own tracked workstream (objectui#4658). Filed as a follow-up rather than ridden on this PR.


Generated by Claude Code

… dashboard widgets
Maintainer ruling 2026-08-31 (hotcrm#1212, following hotcrm#1203): a widget that
renders a bare row-placeholder on an empty result is the platform's defect and
must be fixed uniformly — apps must not compensate widget by widget
(objectstack#13848).
There was no shared placeholder to fix. Three dashboard surfaces wrote their own,
in two different strings: DatasetWidget used `dashboard.noRows` ('No rows' /
暂无数据行), while ObjectDataTable and PivotTable used `dashboard.noDataAvailable`.
`WidgetEmptyState` is the seam they now share.
The default reads as a STATE rather than a failure — `role="status"` where the
branches beside it are `role="alert"` and the empty branches previously carried
no role at all — carries a title plus an explanation instead of one terse
fragment, and names the widget's data source with zero authored copy.
Copy is platform i18n: `dashboard.empty.title` / `.message` / `.sourceLabel` in
`en` and all nine sibling packs. No inline defaultValue, no interpolation.
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)3151.9 KB3191.4 KB
Main entry chunk (gzip)142.3 KB350 KB
Entry fileindex-DbjcEXZE.js
StatusPASS

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


📦 Bundle Size Report

PackageSizeGzipped
app-shell (consoleActionDispatch.js)0.20KB0.19KB
app-shell (index.js)15.33KB5.59KB
app-shell (runtime-config.js)20.68KB7.36KB
app-shell (types.js)0.01KB0.04KB
app-shell (urlParams.js)10.06KB3.86KB
auth (ActiveOrganizationStorage.js)25.05KB9.16KB
auth (AuthContext.js)0.31KB0.24KB
auth (AuthGuard.js)2.07KB1.00KB
auth (AuthProvider.js)40.18KB10.59KB
auth (AuthShell.js)3.49KB1.40KB
auth (ForgotPasswordForm.js)12.21KB3.45KB
auth (LoginForm.js)18.15KB5.39KB
auth (PreviewBanner.js)0.90KB0.50KB
auth (RegisterForm.js)6.65KB2.22KB
auth (SocialSignInButtons.js)9.61KB3.89KB
auth (UserMenu.js)3.41KB1.23KB
auth (auth-gate-events.js)1.29KB0.66KB
auth (authStyles.js)5.04KB1.72KB
auth (createAuthClient.js)40.21KB10.80KB
auth (createAuthenticatedFetch.js)8.46KB3.43KB
auth (index.js)3.19KB1.44KB
auth (invitation-status.js)1.22KB0.70KB
auth (org-roles.js)6.66KB2.78KB
auth (phone-identifier.js)1.11KB0.66KB
auth (types.js)0.59KB0.35KB
auth (useAuth.js)5.30KB1.02KB
auth (useWorkspaceAdminStatus.js)5.13KB2.35KB
collaboration (CommentThread.js)26.08KB7.56KB
collaboration (LiveCursors.js)3.17KB1.27KB
collaboration (PresenceAvatars.js)6.49KB2.64KB
collaboration (PresenceProvider.js)2.79KB1.13KB
collaboration (index.js)1.68KB0.73KB
collaboration (useCollaborationTranslation.js)6.05KB2.52KB
collaboration (useCommentSearch.js)1.98KB0.88KB
collaboration (useConflictResolution.js)7.75KB1.86KB
collaboration (useMentionNotifications.js)1.81KB0.68KB
collaboration (usePresence.js)6.33KB1.84KB
collaboration (useRealtimeSubscription.js)7.91KB2.01KB
components (index.js)512.30KB116.52KB
core (index.js)5.30KB2.13KB
create-plugin (index.js)10.08KB3.26KB
data-objectstack (index.js)177.67KB49.45KB
fields (index.js)243.64KB61.64KB
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.94KB63.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.14KB14.64KB
plugin-list (index.js)113.19KB27.60KB
plugin-map (index.js)20.20KB6.66KB
plugin-markdown (index.js)13.72KB4.69KB
plugin-report (index.js)43.51KB11.94KB
plugin-timeline (index.js)29.34KB8.47KB
plugin-tree (index.js)8.98KB3.08KB
plugin-view (index.js)85.90KB21.12KB
providers (DataSourceProvider.js)0.75KB0.39KB
providers (MetadataProvider.js)1.37KB0.59KB
providers (ThemeProvider.js)1.90KB0.85KB
providers (UploadProvider.js)11.66KB3.50KB
providers (index.js)0.45KB0.23KB
providers (types.js)0.01KB0.04KB
react-runtime (index.js)5.62KB2.34KB
react (LazyPluginLoader.js)4.47KB1.63KB
react (SchemaRenderer.js)81.07KB26.86KB
react (data-invalidation.js)5.05KB2.08KB
react (index.js)3.11KB1.48KB
react (schema-input.js)2.32KB1.24KB
react (spec-input.js)0.20KB0.18KB
sdui-parser (codegen.js)5.41KB2.34KB
sdui-parser (dashboard-widget-options.js)3.08KB1.30KB
sdui-parser (index.js)4.93KB2.24KB
sdui-parser (input-type.js)2.84KB1.40KB
sdui-parser (parse.js)20.57KB5.88KB
sdui-parser (provenance.js)3.66KB1.82KB
sdui-parser (types.js)0.28KB0.23KB
sdui-parser (validate.js)10.35KB3.60KB
types (ai.js)0.20KB0.17KB
types (api-types.js)0.20KB0.18KB
types (app.js)2.87KB0.99KB
types (base.js)0.20KB0.18KB
types (blocks.js)0.20KB0.18KB
types (complex.js)2.74KB1.41KB
types (crud.js)0.20KB0.18KB
types (dashboard-filter-alias.js)6.23KB2.74KB
types (data-display.js)3.75KB1.85KB
types (data-protocol.js)0.20KB0.19KB
types (data.js)0.20KB0.18KB
types (designer.js)1.85KB0.85KB
types (disclosure.js)0.20KB0.18KB
types (error-code.js)1.54KB0.88KB
types (feedback.js)0.20KB0.18KB
types (field-types.js)0.20KB0.18KB
types (form.js)0.20KB0.18KB
types (http-inflight.js)8.87KB3.73KB
types (http-retry.js)4.32KB2.02KB
types (icon-key-migration.js)4.26KB1.63KB
types (index.js)4.72KB2.24KB
types (layout.js)0.20KB0.18KB
types (managed-by.js)0.19KB0.18KB
types (mobile.js)2.59KB1.31KB
types (navigation.js)0.20KB0.18KB
types (objectql.js)0.20KB0.18KB
types (overlay.js)0.20KB0.18KB
types (permissions.js)0.20KB0.18KB
types (plugin-scope.js)0.20KB0.18KB
types (record-components.js)0.20KB0.19KB
types (record-semantics.js)1.28KB0.67KB
types (registry.js)0.20KB0.18KB
types (reports.js)0.20KB0.18KB
types (spec-report.js)5.05KB1.93KB
types (spec-ui-namespace.js)0.20KB0.19KB
types (system-fields.js)3.33KB1.54KB
types (theme.js)6.28KB2.87KB
types (ui-action.js)3.40KB1.71KB
types (views.js)0.20KB0.18KB
types (widget.js)0.20KB0.18KB

Size Limits

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

@os-warrenClaude

Copy link
Copy Markdown
CollaboratorAuthor

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

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

⭐⭐ You declined the shared abstraction, and the forbidden path was the cheaper one

DataEmptyState in packages/components/src/custom/view-states.tsx is already consumed by plugin-list, plugin-kanban, plugin-dashboardand plugin-detail — the exact surface of the parallel card #7064. Changing its defaults would have been a smaller diff repairing both surfaces at once.

That is the strongest possible test of the fence, and it held. A cross-surface abstraction designed simultaneously by two agents who cannot see each other's reasoning is worse than two honest local fixes — and it would have been invisible as a problem, because the diff would have looked elegant. You consumed the primitive unchanged, kept the new component dashboard-local, recorded why in the file header, and touched nothing under plugin-detail. ⭐ Recording the temptation is as valuable as resisting it — it tells me convergence is worth a sequenced card once both land, which I can now schedule instead of discover.

Both of the card's premises were false, and neither failure was papered over

  1. "One shared placeholder reached by all the affected widgets." ⛔ False: three surfaces, two different keys, no shared code — and the measured string dashboard.noRows had exactly one call site repo-wide. ⇒ There was no seam to repair; one had to be created. That is a materially different job from the one the card describes, and it is why a new component exists rather than a one-line default change.
  2. "The authored emptyState contract already exists and already overrides." ⛔ False on this surface: emptyState is a list-view contract. Measured 0 occurrences across plugin-dashboard/src and plugin-charts/src⚠️ against a control of 1154 hits for widget over the same paths — and spec 17.2.0's DashboardWidgetSchema, read from the built package at runtime, declares no such key.

⭐ Clause 3 is vacuous here, and you were right that this is not a stop-and-report

My order made clause 3's stop-and-report fence hard, because a spec key added on a hunch becomes an unlandable PR under the quota freeze. You reached the fence and correctly did not trigger it: the deliverable is a better default, and a default needs no authoring surface. Nothing was widened, so nothing was owed.

⚠️ But the fact stands and is worth the maintainer's eye: the ruling's "the existing authored contract always overrides" describes, on this surface, a contract that does not exist. Same shape as #7064's finding an hour ago, where the ruling's authored opt-out turned out to be refused by the spec. Two rulings in one round whose preservation clause protects something that isn't there. I'm routing that pattern rather than treating each as a one-off.

A failed control, caught and re-taken — the discipline that matters most

Your first control for "WidgetEmptyState is not exported from index.tsx" used DatasetWidget, which also returned 0. Rather than reading that as confirmation, you discarded the reading and re-took it with PivotTable (= 1). ⭐ A control that returns zero is not a weak control, it is no control — and DatasetWidget being internal is exactly the kind of thing that makes a plausible control silently useless. This is the sixth instrument failure surfaced on this lane today and the second caught by its own operator mid-measurement.

Ablation: two legs, both per-surface

Leg 1 (revert DatasetWidget's empty branch) reddened 4 cases confined to that surface while table and pivot cases stayed green — proving the pins resolve per surface rather than firing as a block, which a single seam makes easy to get wrong. Leg 2 (delete role="status") reddened the role assertions while "a load FAILURE still reads as a failure" stayed green, showing the two halves of requirement 1 are independent. Directions predicted before running; both matched.

The copy decision is right, and its restraint is the reason

Naming the data source as a raw metadata identifier (crm_forecast) as a labelled monospace value, rather than folding it into a friendly sentence, is a truthful narrow statement over an invented one — and declining to use widget.title because DashboardRenderer already renders the resolved title directly above avoids saying the same thing twice. role="status" against the neighbouring role="alert" is what makes requirement 1 structural rather than cosmetic.

The i18n handling — additive after each pack's existing anchor, verified unique in all ten before writing, punctuation carried inside the key so no separator is concatenated and RTL needs no special case — is exactly what keeps the parallel packages/i18n work merging trivially.

NOT MEASURED, reported as such, with narrowing arguments

check:readme-exports (population COLLAPSED, 12 packages read against a floor of 25) and check:eager-closure (no apps/console/dist/eager-closure.json"a broken gauge, not a sensitive gate"). ⭐ Both carry a reason and the first carries a narrowing that is itself controlled: index.tsx is untouched and does not export the new component (control: PivotTable, which it does export, hits 1). CI owns both.

Open question 1 — emptyState for dashboard widgets: I adopt A

No key added. Your reasoning holds and I'll add the decisive point: B is currently unbuildable-to-land — it is a public authoring-surface widening, so it needs the contract review the exhausted Fable 5 quota blocks, and it would join #6896 / PR #7111 in the stranded state. Beyond that, adding a per-widget authored-prose key one card after a ruling closed the per-app-compensation route would undo the ruling with its own follow-up. Zero measured pull. Recorded on #7129 alongside #7064's sibling question.

Open question 2 — I am filing it; here is why I am overriding your restraint

You declined to file the ObjectChart observation because the state may be intentional. That restraint was right by default, and I'd rather see it than a reflexive card. But I'm filing it, on three grounds: it is a measured absence (no empty branch at all, not a judgement about quality), the seam you just built makes the fix small, and an observation that lives only in a subagent report is lost. The card carries your framing verbatim — may be intentional, needs a reading before any fix — so it records the fact without pre-judging it. Filed as #7130.

#7125

Correctly filed and correctly not acted on: the dead keys are residue this PR creates, the dispatch asked locale edits to stay additive for trivial parallel merges, and the dead-key gate is report-only and wired into no workflow. Deleting them here would have traded a clean merge for a cosmetic win.


Generated by Claude Code

@os-warren
os-warren marked this pull request as ready for review September 1, 2026 03:32
@os-warren
os-warren added this pull request to the merge queueSep 1, 2026
Merged via the queue into main with commit 220c18dSep 1, 2026
32 checks passed
@os-warren
os-warren deleted the claude/issue-7063-widget-empty-state-default branch September 1, 2026 03:45
os-warren pushed a commit that referenced this pull request Sep 1, 2026
…r an empty result
Measured, not assumed: recharts derives ticks from the data, so an empty
result draws two hairline axis rules and zero text nodes (bar/line) or
nothing at all (pie) — there are no labelled axes to read. Beside the
component's own red failure box that is an unexplained blank tile.
Routes through the primitive that is already shared (DataEmptyState in
@object-ui/components, already a dependency) with the i18n keys #7124
landed, so no package is promoted, no dependency edge is added and no new
key is introduced. Charts with inline authored data are unchanged.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012wwHa4aaFybxXrfmfHioDM
akarma-synetal pushed a commit to akarma-synetal/objectui that referenced this pull request Sep 1, 2026
…r an empty result (objectstack-ai#7139)
* fix(plugin-charts): a self-describing empty state for ObjectChart over an empty result
Measured, not assumed: recharts derives ticks from the data, so an empty
result draws two hairline axis rules and zero text nodes (bar/line) or
nothing at all (pie) — there are no labelled axes to read. Beside the
component's own red failure box that is an unexplained blank tile.
Routes through the primitive that is already shared (DataEmptyState in
@object-ui/components, already a dependency) with the i18n keys objectstack-ai#7124
landed, so no package is promoted, no dependency edge is added and no new
key is introduced. Charts with inline authored data are unchanged.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012wwHa4aaFybxXrfmfHioDM
* test(app-shell): give the two object-bound chart sweep targets rows
The DOM-leak sweep mounts `plugin-charts:object-chart` and `view:chart`
against an adapter that answers every query with no rows. Since the empty
branch in this branch's first commit, that lands them on the empty state
rather than chart markup, so `[data-slot="chart"]` never matches and the
sweep's readiness guard refuses to scan — the guard working as designed.
Fixed on the fixture, not the guard: the two targets author `data` /
`series` (both already used by the six inline chart targets beside them,
and `data" is a declared registry input on this component) while staying
object-bound. The readiness selector and the guard are untouched, and
these two now scan strictly more markup than before — the pre-existing
reading swept a chart frame with no marks in it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012wwHa4aaFybxXrfmfHioDM
---------
Co-authored-by: Claude <noreply@anthropic.com>
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

1 participant

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

fix(plugin-dashboard,i18n): a self-explaining default empty state for dashboard widgets - #7124

Merged
os-warren merged 1 commit into
mainfrom
claude/issue-7063-widget-empty-state-default
Sep 1, 2026
Merged

fix(plugin-dashboard,i18n): a self-explaining default empty state for dashboard widgets#7124
os-warren merged 1 commit into
mainfrom
claude/issue-7063-widget-empty-state-default

Conversation

@os-warren

Copy link
Copy Markdown
Collaborator

Fixes#7063

Maintainer ruling 2026-08-31 (hotcrm#1212 adjudication, verbatim 「1212 也是」, following 「hotcrm#1203 …这种也是平台的问题啊」): this class is the platform's to fix uniformly; apps must not compensate widget by widget (objectstack#13848 — apps are simplified business implementations, uniform behaviour belongs to the platform).

All evidence below was measured at 79accd6 (the final commit on this branch).


The card's premise was half wrong, and that changed the shape of the fix

Two of the dispatch's stated assumptions did not survive measurement. Both are reported here rather than worked around.

1. 暂无数据行 is NOT one shared placeholder — there was no seam to fix

Three dashboard surfaces wrote their own empty state, in two different strings, with no common code:

surfacekeyenzh
DatasetWidget (the measured one)dashboard.noRowsNo rows暂无数据行
ObjectDataTabledashboard.noDataAvailableNo data available暂无数据
PivotTabledashboard.noDataAvailableNo data available暂无数据

dashboard.noRows had exactly one call site repo-wide (DatasetWidget.tsx:727), so the measured 暂无数据行 came from the dataset-bound path only — which is the ADR-0021 governed path the hotcrm quota-attainment tile takes.

So "uniformly" could not mean "fix the shared placeholder". The new WidgetEmptyStateis the seam: it is added, and the three surfaces are routed through it.

2. The authored emptyState override does not exist on this surface

Clause 3 assumes an existing authored emptyState contract that always overrides. Measured:

  • emptyState occurs 0 times in packages/plugin-dashboard/src and packages/plugin-charts/src. Control in the same sweep, same paths: widget, 1154 hits — so the zero is a reading, not a broken query.

  • emptyState is a list-view contract (ObjectGridSchema, NamedListView in packages/types/src/objectql.ts), honoured in packages/plugin-list/src/ListView.tsx.

  • @objectstack/spec@17.2.0's DashboardWidgetSchema declares no such key. Its full key set, read from the built package at runtime rather than recalled:

    id, title, description, type, chartConfig, colorVariant, requiresObject, requiresService, actionUrl, actionType, actionIcon, filter, compareTo, dataset, dimensions, values, layout, options, filterBindings, suppressWarnings, responsive, aria

Consequence, and why this is not the stop-and-report case. Clause 3 is satisfied here vacuously: there is no authored override on dashboard widgets for the default to lose to, so improving the default cannot displace one. No spec key was added and none is needed to deliver this card — the deliverable is a better DEFAULT, and defaults need no authoring surface. Making clause 3 non-vacuous (i.e. giving dashboard widgets a real authored override) would require a new spec key, which is the contract question the card fences off. That is flagged for the seat, not taken.


What changed

New:packages/plugin-dashboard/src/WidgetEmptyState.tsx — the dashboard-local seam.

The ruling's four properties, and how each is delivered:

  1. Distinguishable from a load failure at a glance.role="status", where the failure branches sitting beside these call sites are role="alert" on destructive colours. The empty branches previously carried no role at all, so assistive tech got a bare fragment with no state either way. Visually: muted treatment and an inbox glyph, never destructive colour and a warning triangle.
  2. Self-describing without authored copy. A title and an explanation where the placeholder was one terse fragment, plus the name of the widget's data source. The copy states that the widget loaded successfully — the one fact a reader of a blank tile cannot otherwise get.
  3. No new authoring obligation, no new spec key. Nothing was added to any schema. PivotTable gains one optional component prop (sourceLabel), which is React-side only.
  4. Platform i18n.dashboard.empty.title / .message / .sourceLabel in en and all nine sibling packs.

What source names, and why it is a raw identifier. The card allows the widget's label and/or its data source. The widget's title is already rendered by the card header directly above every tile (DashboardRenderer), so repeating it says nothing new; the data source is the half the reader cannot see. What is reachable at each render site is the authored binding — widget.dataset on the dataset path, schema.objectName on the object-bound table/pivot — i.e. a raw metadata name such as crm_forecast, not a localized label. It is rendered as a labelled monospace value rather than folded into a sentence: a truthful narrow statement beats an invented friendly one.

Why the copy carries no interpolation.useSafeTranslate takes a positional fallback and no options bag, so an interpolated key would have to come from a raw useObjectTranslationt() — and with no I18nProvider mounted (the standalone-host and test configuration, which vitest.setup.i18n-global.ts deliberately preserves) that renders the raw KEY unless the call site also carries an inline defaultValue, which #3517 rules out. A label that carries its own punctuation (Source: / 数据源: / مصدر البيانات:) needs neither, and concatenates no separator in code.

Files:WidgetEmptyState.tsx (new) · DatasetWidget.tsx · ObjectDataTable.tsx · PivotTable.tsx · ObjectPivotTable.tsx · ten locale packs · one new test file · DatasetWidget.test.tsx · one changeset.


Evidence

Tests

pnpm exec vitest run packages/plugin-dashboard packages/i18n packages/app-shell/src/__tests__/widget-dom-leak-sweep.test.tsx --maxWorkers=2 at 79accd6:

 Test Files 144 passed (144)
Tests 1924 passed (1924)

New pin: packages/plugin-dashboard/src/__tests__/WidgetEmptyState.uniformDefault.test.tsx (5 cases). It pins the ruling's properties, never the sentence — copy is review's to move. It also pins that all four dashboard entry points reach the one seam, which is the property that decays first: a fourth widget can quietly write a fifth placeholder while every per-widget test stays green.

Four pins in DatasetWidget.test.tsx were rewritten rather than left passing. Three of them asserted queryByText('No rows')).not.toBeInTheDocument() — after this change that string exists nowhere, so they would have passed for the wrong reason. They now select the empty state by test id.

Ablation — two legs, direction predicted before running

Leg 1 — revert DatasetWidget's empty branch to the pre-card dashboard.noRows one-liner. Predicted: RED, and only on the DatasetWidget surface.

  • mutation proven on disk: removed-marker WidgetEmptyState (as a JSX open tag) 1 to 0; injected-marker tt('dashboard.noRows' 0 to 1; blob f8f61e71... to 951c8251...
  • observed: Tests 4 failed | 59 passed, vitest exit 1 (read from the run's own printed ABLATED_VITEST_EXIT=1, not a bare $? behind a pipe). All four failures on the DatasetWidget surface; the ObjectDataTable, PivotTable and ObjectPivotTable cases stayed green, so the pins resolve per surface rather than firing as a block.
  • restore proven by state: git diff HEAD empty, git status --porcelain empty, blob back to f8f61e712947e3cdf3dacf3cdab3e6a8cccd25ba = its HEAD blob.

Leg 2 — delete role="status" from the shared component, i.e. ablate property (1) itself. Predicted: RED on the role assertions; the load-failure test stays green.

  • mutation proven on disk: marker role="status" 2 to 1 (the second occurrence is in the file's doc comment; the python anchor asserted exactly one match on the JSX line); blob a62aaa96... to 39a46faf...
  • observed: Tests 4 failed | 59 passed, exit 1 — the three seam cases plus the DatasetWidget.test.tsx role case. a load FAILURE still reads as a failure stayed green, so the two halves of property (1) are independent.
  • restore proven by state: git diff HEAD empty, blob back to a62aaa96ec88cede54f96579451a6922b3aceb3a.

Both legs used an EXIT INT TERM trap with an absolute REPO_ROOT path, and restored with git checkout HEAD -- path (never a bare git checkout -- path, which restores from the index and would hand back the mutation).

No rebuild leg, stated rather than skipped. This repo's vitest resolves @object-ui/* to packages/*/src via resolve.alias in vitest.config.mts, and the pins import the components by relative path, so no dist can shadow the mutation. A stale build cannot make an ablation falsely green here.

Gates

gateexitverdict
vitest (plugin-dashboard, i18n, widget-dom-leak-sweep)0green — 144 files, 1924 tests
pnpm --filter @object-ui/plugin-dashboard --filter @object-ui/i18n run type-check0green — both ran tsc --noEmit && tsc -p tsconfig.test.json, so the new test file was type-checked, not merely excluded
pnpm --filter @object-ui/plugin-dashboard --filter @object-ui/i18n run lint0green — 0 errors (423 + 34 pre-existing warnings; 4 of them no-explicit-any on the new test file, the same schema-cast shape its sibling suites use)
check:i18n-keys0green — "Every in-scope call-site key resolves against the en pack (2845 keys)…"
check:i18n-drift0green — "0 en value(s) changed (3 key(s) added, 0 removed…)" — strictly additive
check:control-bytes0green — "OK (scanned 5898 tracked text file(s); skipped 85 binary)"
check-changeset-presence0green — "17 source file(s) of 2 released package(s) changed, and this change declares 1 changeset(s)"
check:changeset-fixed0green — "All workspace packages are in the changeset fixed group."
check:changeset-no-major0green — "No changeset declares a major bump."
check:phantom-deps / check:self-import / check:side-effects-array / check:vi-mock-specifiers / check:vi-mock-inherit / check:esm-specifiers0green
check:readme-exports1NOT MEASURED — "the population COLLAPSED -- this run proves nothing / packagesRead: found 12, floor is 25". Local tree has most packages unbuilt; CI runs turbo run build first. Narrowing argument: packages/plugin-dashboard/src/index.tsx is untouched and does not export WidgetEmptyState (control in the same query: PivotTable, which it does export, hits), so the package's published export surface is unchanged and no README moved.
check:eager-closure2NOT MEASURED — "No eager-closure report at apps/console/dist/eager-closure.json … This is a broken gauge, not a sensitive gate." Needs a built apps/console; produced by performance-budget.yml in CI.

Changeset verdicts are quoted verbatim above rather than predicted; both gates were run.

Zero-hit readings, each with its control

queryscopehitscontrolcontrol hits
emptyStateplugin-dashboard/src + plugin-charts/src0widget, same scope1154
WidgetEmptyStateplugin-dashboard/src/index.tsx0PivotTable, same file1

What is NOT verified here, and why

The hotcrm acceptance scenario is unverified by me. The card's acceptance is a hotcrm sales_dashboard fresh install. hotcrm is outside this session's repo scope — it is not attached and I did not try to reach it. What I can state is the objectui-side mechanism: the quota-attainment tile is a dataset-bound table widget, which renders through DatasetWidget, whose empty branch is the one call site of the 暂无数据行 string this PR replaces. The end-to-end confirmation on a fresh hotcrm install still needs to be run by someone with that repo.

Shared-abstraction temptation, reported rather than taken.DataEmptyState in @object-ui/components is the presentational primitive already used by plugin-list, plugin-kanban, plugin-dashboardand plugin-detail — the surface of the parallel card #7064. Changing its defaults would have been a smaller diff that repaired both surfaces at once. It is deliberately consumed unchanged here: a shared empty-state abstraction designed twice in parallel by two agents is worse than two honest local fixes, and converging the two is its own sequenced card. Nothing under packages/plugin-detail is touched by this PR.

Residue this creates, not fixed here.dashboard.noRows and dashboard.noDataAvailable now have no call site. check:i18n-dead-keys (report-only, exit 0, not wired into CI) lists both as needs-review after this change. They are left in all ten packs deliberately: the dispatch asks locale edits to stay additive so parallel merges stay trivial, and the dead-key sweep is its own tracked workstream (objectui#4658). Filed as a follow-up rather than ridden on this PR.


Generated by Claude Code

… dashboard widgets
Maintainer ruling 2026-08-31 (hotcrm#1212, following hotcrm#1203): a widget that
renders a bare row-placeholder on an empty result is the platform's defect and
must be fixed uniformly — apps must not compensate widget by widget
(objectstack#13848).
There was no shared placeholder to fix. Three dashboard surfaces wrote their own,
in two different strings: DatasetWidget used `dashboard.noRows` ('No rows' /
暂无数据行), while ObjectDataTable and PivotTable used `dashboard.noDataAvailable`.
`WidgetEmptyState` is the seam they now share.
The default reads as a STATE rather than a failure — `role="status"` where the
branches beside it are `role="alert"` and the empty branches previously carried
no role at all — carries a title plus an explanation instead of one terse
fragment, and names the widget's data source with zero authored copy.
Copy is platform i18n: `dashboard.empty.title` / `.message` / `.sourceLabel` in
`en` and all nine sibling packs. No inline defaultValue, no interpolation.
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)3151.9 KB3191.4 KB
Main entry chunk (gzip)142.3 KB350 KB
Entry fileindex-DbjcEXZE.js
StatusPASS

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


📦 Bundle Size Report

PackageSizeGzipped
app-shell (consoleActionDispatch.js)0.20KB0.19KB
app-shell (index.js)15.33KB5.59KB
app-shell (runtime-config.js)20.68KB7.36KB
app-shell (types.js)0.01KB0.04KB
app-shell (urlParams.js)10.06KB3.86KB
auth (ActiveOrganizationStorage.js)25.05KB9.16KB
auth (AuthContext.js)0.31KB0.24KB
auth (AuthGuard.js)2.07KB1.00KB
auth (AuthProvider.js)40.18KB10.59KB
auth (AuthShell.js)3.49KB1.40KB
auth (ForgotPasswordForm.js)12.21KB3.45KB
auth (LoginForm.js)18.15KB5.39KB
auth (PreviewBanner.js)0.90KB0.50KB
auth (RegisterForm.js)6.65KB2.22KB
auth (SocialSignInButtons.js)9.61KB3.89KB
auth (UserMenu.js)3.41KB1.23KB
auth (auth-gate-events.js)1.29KB0.66KB
auth (authStyles.js)5.04KB1.72KB
auth (createAuthClient.js)40.21KB10.80KB
auth (createAuthenticatedFetch.js)8.46KB3.43KB
auth (index.js)3.19KB1.44KB
auth (invitation-status.js)1.22KB0.70KB
auth (org-roles.js)6.66KB2.78KB
auth (phone-identifier.js)1.11KB0.66KB
auth (types.js)0.59KB0.35KB
auth (useAuth.js)5.30KB1.02KB
auth (useWorkspaceAdminStatus.js)5.13KB2.35KB
collaboration (CommentThread.js)26.08KB7.56KB
collaboration (LiveCursors.js)3.17KB1.27KB
collaboration (PresenceAvatars.js)6.49KB2.64KB
collaboration (PresenceProvider.js)2.79KB1.13KB
collaboration (index.js)1.68KB0.73KB
collaboration (useCollaborationTranslation.js)6.05KB2.52KB
collaboration (useCommentSearch.js)1.98KB0.88KB
collaboration (useConflictResolution.js)7.75KB1.86KB
collaboration (useMentionNotifications.js)1.81KB0.68KB
collaboration (usePresence.js)6.33KB1.84KB
collaboration (useRealtimeSubscription.js)7.91KB2.01KB
components (index.js)512.30KB116.52KB
core (index.js)5.30KB2.13KB
create-plugin (index.js)10.08KB3.26KB
data-objectstack (index.js)177.67KB49.45KB
fields (index.js)243.64KB61.64KB
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.94KB63.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.14KB14.64KB
plugin-list (index.js)113.19KB27.60KB
plugin-map (index.js)20.20KB6.66KB
plugin-markdown (index.js)13.72KB4.69KB
plugin-report (index.js)43.51KB11.94KB
plugin-timeline (index.js)29.34KB8.47KB
plugin-tree (index.js)8.98KB3.08KB
plugin-view (index.js)85.90KB21.12KB
providers (DataSourceProvider.js)0.75KB0.39KB
providers (MetadataProvider.js)1.37KB0.59KB
providers (ThemeProvider.js)1.90KB0.85KB
providers (UploadProvider.js)11.66KB3.50KB
providers (index.js)0.45KB0.23KB
providers (types.js)0.01KB0.04KB
react-runtime (index.js)5.62KB2.34KB
react (LazyPluginLoader.js)4.47KB1.63KB
react (SchemaRenderer.js)81.07KB26.86KB
react (data-invalidation.js)5.05KB2.08KB
react (index.js)3.11KB1.48KB
react (schema-input.js)2.32KB1.24KB
react (spec-input.js)0.20KB0.18KB
sdui-parser (codegen.js)5.41KB2.34KB
sdui-parser (dashboard-widget-options.js)3.08KB1.30KB
sdui-parser (index.js)4.93KB2.24KB
sdui-parser (input-type.js)2.84KB1.40KB
sdui-parser (parse.js)20.57KB5.88KB
sdui-parser (provenance.js)3.66KB1.82KB
sdui-parser (types.js)0.28KB0.23KB
sdui-parser (validate.js)10.35KB3.60KB
types (ai.js)0.20KB0.17KB
types (api-types.js)0.20KB0.18KB
types (app.js)2.87KB0.99KB
types (base.js)0.20KB0.18KB
types (blocks.js)0.20KB0.18KB
types (complex.js)2.74KB1.41KB
types (crud.js)0.20KB0.18KB
types (dashboard-filter-alias.js)6.23KB2.74KB
types (data-display.js)3.75KB1.85KB
types (data-protocol.js)0.20KB0.19KB
types (data.js)0.20KB0.18KB
types (designer.js)1.85KB0.85KB
types (disclosure.js)0.20KB0.18KB
types (error-code.js)1.54KB0.88KB
types (feedback.js)0.20KB0.18KB
types (field-types.js)0.20KB0.18KB
types (form.js)0.20KB0.18KB
types (http-inflight.js)8.87KB3.73KB
types (http-retry.js)4.32KB2.02KB
types (icon-key-migration.js)4.26KB1.63KB
types (index.js)4.72KB2.24KB
types (layout.js)0.20KB0.18KB
types (managed-by.js)0.19KB0.18KB
types (mobile.js)2.59KB1.31KB
types (navigation.js)0.20KB0.18KB
types (objectql.js)0.20KB0.18KB
types (overlay.js)0.20KB0.18KB
types (permissions.js)0.20KB0.18KB
types (plugin-scope.js)0.20KB0.18KB
types (record-components.js)0.20KB0.19KB
types (record-semantics.js)1.28KB0.67KB
types (registry.js)0.20KB0.18KB
types (reports.js)0.20KB0.18KB
types (spec-report.js)5.05KB1.93KB
types (spec-ui-namespace.js)0.20KB0.19KB
types (system-fields.js)3.33KB1.54KB
types (theme.js)6.28KB2.87KB
types (ui-action.js)3.40KB1.71KB
types (views.js)0.20KB0.18KB
types (widget.js)0.20KB0.18KB

Size Limits

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

@os-warrenClaude

Copy link
Copy Markdown
CollaboratorAuthor

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

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

⭐⭐ You declined the shared abstraction, and the forbidden path was the cheaper one

DataEmptyState in packages/components/src/custom/view-states.tsx is already consumed by plugin-list, plugin-kanban, plugin-dashboardand plugin-detail — the exact surface of the parallel card #7064. Changing its defaults would have been a smaller diff repairing both surfaces at once.

That is the strongest possible test of the fence, and it held. A cross-surface abstraction designed simultaneously by two agents who cannot see each other's reasoning is worse than two honest local fixes — and it would have been invisible as a problem, because the diff would have looked elegant. You consumed the primitive unchanged, kept the new component dashboard-local, recorded why in the file header, and touched nothing under plugin-detail. ⭐ Recording the temptation is as valuable as resisting it — it tells me convergence is worth a sequenced card once both land, which I can now schedule instead of discover.

Both of the card's premises were false, and neither failure was papered over

  1. "One shared placeholder reached by all the affected widgets." ⛔ False: three surfaces, two different keys, no shared code — and the measured string dashboard.noRows had exactly one call site repo-wide. ⇒ There was no seam to repair; one had to be created. That is a materially different job from the one the card describes, and it is why a new component exists rather than a one-line default change.
  2. "The authored emptyState contract already exists and already overrides." ⛔ False on this surface: emptyState is a list-view contract. Measured 0 occurrences across plugin-dashboard/src and plugin-charts/src⚠️ against a control of 1154 hits for widget over the same paths — and spec 17.2.0's DashboardWidgetSchema, read from the built package at runtime, declares no such key.

⭐ Clause 3 is vacuous here, and you were right that this is not a stop-and-report

My order made clause 3's stop-and-report fence hard, because a spec key added on a hunch becomes an unlandable PR under the quota freeze. You reached the fence and correctly did not trigger it: the deliverable is a better default, and a default needs no authoring surface. Nothing was widened, so nothing was owed.

⚠️ But the fact stands and is worth the maintainer's eye: the ruling's "the existing authored contract always overrides" describes, on this surface, a contract that does not exist. Same shape as #7064's finding an hour ago, where the ruling's authored opt-out turned out to be refused by the spec. Two rulings in one round whose preservation clause protects something that isn't there. I'm routing that pattern rather than treating each as a one-off.

A failed control, caught and re-taken — the discipline that matters most

Your first control for "WidgetEmptyState is not exported from index.tsx" used DatasetWidget, which also returned 0. Rather than reading that as confirmation, you discarded the reading and re-took it with PivotTable (= 1). ⭐ A control that returns zero is not a weak control, it is no control — and DatasetWidget being internal is exactly the kind of thing that makes a plausible control silently useless. This is the sixth instrument failure surfaced on this lane today and the second caught by its own operator mid-measurement.

Ablation: two legs, both per-surface

Leg 1 (revert DatasetWidget's empty branch) reddened 4 cases confined to that surface while table and pivot cases stayed green — proving the pins resolve per surface rather than firing as a block, which a single seam makes easy to get wrong. Leg 2 (delete role="status") reddened the role assertions while "a load FAILURE still reads as a failure" stayed green, showing the two halves of requirement 1 are independent. Directions predicted before running; both matched.

The copy decision is right, and its restraint is the reason

Naming the data source as a raw metadata identifier (crm_forecast) as a labelled monospace value, rather than folding it into a friendly sentence, is a truthful narrow statement over an invented one — and declining to use widget.title because DashboardRenderer already renders the resolved title directly above avoids saying the same thing twice. role="status" against the neighbouring role="alert" is what makes requirement 1 structural rather than cosmetic.

The i18n handling — additive after each pack's existing anchor, verified unique in all ten before writing, punctuation carried inside the key so no separator is concatenated and RTL needs no special case — is exactly what keeps the parallel packages/i18n work merging trivially.

NOT MEASURED, reported as such, with narrowing arguments

check:readme-exports (population COLLAPSED, 12 packages read against a floor of 25) and check:eager-closure (no apps/console/dist/eager-closure.json"a broken gauge, not a sensitive gate"). ⭐ Both carry a reason and the first carries a narrowing that is itself controlled: index.tsx is untouched and does not export the new component (control: PivotTable, which it does export, hits 1). CI owns both.

Open question 1 — emptyState for dashboard widgets: I adopt A

No key added. Your reasoning holds and I'll add the decisive point: B is currently unbuildable-to-land — it is a public authoring-surface widening, so it needs the contract review the exhausted Fable 5 quota blocks, and it would join #6896 / PR #7111 in the stranded state. Beyond that, adding a per-widget authored-prose key one card after a ruling closed the per-app-compensation route would undo the ruling with its own follow-up. Zero measured pull. Recorded on #7129 alongside #7064's sibling question.

Open question 2 — I am filing it; here is why I am overriding your restraint

You declined to file the ObjectChart observation because the state may be intentional. That restraint was right by default, and I'd rather see it than a reflexive card. But I'm filing it, on three grounds: it is a measured absence (no empty branch at all, not a judgement about quality), the seam you just built makes the fix small, and an observation that lives only in a subagent report is lost. The card carries your framing verbatim — may be intentional, needs a reading before any fix — so it records the fact without pre-judging it. Filed as #7130.

#7125

Correctly filed and correctly not acted on: the dead keys are residue this PR creates, the dispatch asked locale edits to stay additive for trivial parallel merges, and the dead-key gate is report-only and wired into no workflow. Deleting them here would have traded a clean merge for a cosmetic win.


Generated by Claude Code

@os-warren
os-warren marked this pull request as ready for review September 1, 2026 03:32
@os-warren
os-warren added this pull request to the merge queueSep 1, 2026
Merged via the queue into main with commit 220c18dSep 1, 2026
32 checks passed
@os-warren
os-warren deleted the claude/issue-7063-widget-empty-state-default branch September 1, 2026 03:45
os-warren pushed a commit that referenced this pull request Sep 1, 2026
…r an empty result
Measured, not assumed: recharts derives ticks from the data, so an empty
result draws two hairline axis rules and zero text nodes (bar/line) or
nothing at all (pie) — there are no labelled axes to read. Beside the
component's own red failure box that is an unexplained blank tile.
Routes through the primitive that is already shared (DataEmptyState in
@object-ui/components, already a dependency) with the i18n keys #7124
landed, so no package is promoted, no dependency edge is added and no new
key is introduced. Charts with inline authored data are unchanged.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012wwHa4aaFybxXrfmfHioDM
akarma-synetal pushed a commit to akarma-synetal/objectui that referenced this pull request Sep 1, 2026
…r an empty result (objectstack-ai#7139)
* fix(plugin-charts): a self-describing empty state for ObjectChart over an empty result
Measured, not assumed: recharts derives ticks from the data, so an empty
result draws two hairline axis rules and zero text nodes (bar/line) or
nothing at all (pie) — there are no labelled axes to read. Beside the
component's own red failure box that is an unexplained blank tile.
Routes through the primitive that is already shared (DataEmptyState in
@object-ui/components, already a dependency) with the i18n keys objectstack-ai#7124
landed, so no package is promoted, no dependency edge is added and no new
key is introduced. Charts with inline authored data are unchanged.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012wwHa4aaFybxXrfmfHioDM
* test(app-shell): give the two object-bound chart sweep targets rows
The DOM-leak sweep mounts `plugin-charts:object-chart` and `view:chart`
against an adapter that answers every query with no rows. Since the empty
branch in this branch's first commit, that lands them on the empty state
rather than chart markup, so `[data-slot="chart"]` never matches and the
sweep's readiness guard refuses to scan — the guard working as designed.
Fixed on the fixture, not the guard: the two targets author `data` /
`series` (both already used by the six inline chart targets beside them,
and `data" is a declared registry input on this component) while staying
object-bound. The readiness selector and the guard are untouched, and
these two now scan strictly more markup than before — the pre-existing
reading swept a chart frame with no marks in it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012wwHa4aaFybxXrfmfHioDM
---------
Co-authored-by: Claude <noreply@anthropic.com>
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

1 participant

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

fix(plugin-dashboard,i18n): a self-explaining default empty state for dashboard widgets - #7124

Merged
os-warren merged 1 commit into
mainfrom
claude/issue-7063-widget-empty-state-default
Sep 1, 2026
Merged

fix(plugin-dashboard,i18n): a self-explaining default empty state for dashboard widgets#7124
os-warren merged 1 commit into
mainfrom
claude/issue-7063-widget-empty-state-default

Conversation

@os-warren

Copy link
Copy Markdown
Collaborator

Fixes#7063

Maintainer ruling 2026-08-31 (hotcrm#1212 adjudication, verbatim 「1212 也是」, following 「hotcrm#1203 …这种也是平台的问题啊」): this class is the platform's to fix uniformly; apps must not compensate widget by widget (objectstack#13848 — apps are simplified business implementations, uniform behaviour belongs to the platform).

All evidence below was measured at 79accd6 (the final commit on this branch).


The card's premise was half wrong, and that changed the shape of the fix

Two of the dispatch's stated assumptions did not survive measurement. Both are reported here rather than worked around.

1. 暂无数据行 is NOT one shared placeholder — there was no seam to fix

Three dashboard surfaces wrote their own empty state, in two different strings, with no common code:

surfacekeyenzh
DatasetWidget (the measured one)dashboard.noRowsNo rows暂无数据行
ObjectDataTabledashboard.noDataAvailableNo data available暂无数据
PivotTabledashboard.noDataAvailableNo data available暂无数据

dashboard.noRows had exactly one call site repo-wide (DatasetWidget.tsx:727), so the measured 暂无数据行 came from the dataset-bound path only — which is the ADR-0021 governed path the hotcrm quota-attainment tile takes.

So "uniformly" could not mean "fix the shared placeholder". The new WidgetEmptyStateis the seam: it is added, and the three surfaces are routed through it.

2. The authored emptyState override does not exist on this surface

Clause 3 assumes an existing authored emptyState contract that always overrides. Measured:

  • emptyState occurs 0 times in packages/plugin-dashboard/src and packages/plugin-charts/src. Control in the same sweep, same paths: widget, 1154 hits — so the zero is a reading, not a broken query.

  • emptyState is a list-view contract (ObjectGridSchema, NamedListView in packages/types/src/objectql.ts), honoured in packages/plugin-list/src/ListView.tsx.

  • @objectstack/spec@17.2.0's DashboardWidgetSchema declares no such key. Its full key set, read from the built package at runtime rather than recalled:

    id, title, description, type, chartConfig, colorVariant, requiresObject, requiresService, actionUrl, actionType, actionIcon, filter, compareTo, dataset, dimensions, values, layout, options, filterBindings, suppressWarnings, responsive, aria

Consequence, and why this is not the stop-and-report case. Clause 3 is satisfied here vacuously: there is no authored override on dashboard widgets for the default to lose to, so improving the default cannot displace one. No spec key was added and none is needed to deliver this card — the deliverable is a better DEFAULT, and defaults need no authoring surface. Making clause 3 non-vacuous (i.e. giving dashboard widgets a real authored override) would require a new spec key, which is the contract question the card fences off. That is flagged for the seat, not taken.


What changed

New:packages/plugin-dashboard/src/WidgetEmptyState.tsx — the dashboard-local seam.

The ruling's four properties, and how each is delivered:

  1. Distinguishable from a load failure at a glance.role="status", where the failure branches sitting beside these call sites are role="alert" on destructive colours. The empty branches previously carried no role at all, so assistive tech got a bare fragment with no state either way. Visually: muted treatment and an inbox glyph, never destructive colour and a warning triangle.
  2. Self-describing without authored copy. A title and an explanation where the placeholder was one terse fragment, plus the name of the widget's data source. The copy states that the widget loaded successfully — the one fact a reader of a blank tile cannot otherwise get.
  3. No new authoring obligation, no new spec key. Nothing was added to any schema. PivotTable gains one optional component prop (sourceLabel), which is React-side only.
  4. Platform i18n.dashboard.empty.title / .message / .sourceLabel in en and all nine sibling packs.

What source names, and why it is a raw identifier. The card allows the widget's label and/or its data source. The widget's title is already rendered by the card header directly above every tile (DashboardRenderer), so repeating it says nothing new; the data source is the half the reader cannot see. What is reachable at each render site is the authored binding — widget.dataset on the dataset path, schema.objectName on the object-bound table/pivot — i.e. a raw metadata name such as crm_forecast, not a localized label. It is rendered as a labelled monospace value rather than folded into a sentence: a truthful narrow statement beats an invented friendly one.

Why the copy carries no interpolation.useSafeTranslate takes a positional fallback and no options bag, so an interpolated key would have to come from a raw useObjectTranslationt() — and with no I18nProvider mounted (the standalone-host and test configuration, which vitest.setup.i18n-global.ts deliberately preserves) that renders the raw KEY unless the call site also carries an inline defaultValue, which #3517 rules out. A label that carries its own punctuation (Source: / 数据源: / مصدر البيانات:) needs neither, and concatenates no separator in code.

Files:WidgetEmptyState.tsx (new) · DatasetWidget.tsx · ObjectDataTable.tsx · PivotTable.tsx · ObjectPivotTable.tsx · ten locale packs · one new test file · DatasetWidget.test.tsx · one changeset.


Evidence

Tests

pnpm exec vitest run packages/plugin-dashboard packages/i18n packages/app-shell/src/__tests__/widget-dom-leak-sweep.test.tsx --maxWorkers=2 at 79accd6:

 Test Files 144 passed (144)
Tests 1924 passed (1924)

New pin: packages/plugin-dashboard/src/__tests__/WidgetEmptyState.uniformDefault.test.tsx (5 cases). It pins the ruling's properties, never the sentence — copy is review's to move. It also pins that all four dashboard entry points reach the one seam, which is the property that decays first: a fourth widget can quietly write a fifth placeholder while every per-widget test stays green.

Four pins in DatasetWidget.test.tsx were rewritten rather than left passing. Three of them asserted queryByText('No rows')).not.toBeInTheDocument() — after this change that string exists nowhere, so they would have passed for the wrong reason. They now select the empty state by test id.

Ablation — two legs, direction predicted before running

Leg 1 — revert DatasetWidget's empty branch to the pre-card dashboard.noRows one-liner. Predicted: RED, and only on the DatasetWidget surface.

  • mutation proven on disk: removed-marker WidgetEmptyState (as a JSX open tag) 1 to 0; injected-marker tt('dashboard.noRows' 0 to 1; blob f8f61e71... to 951c8251...
  • observed: Tests 4 failed | 59 passed, vitest exit 1 (read from the run's own printed ABLATED_VITEST_EXIT=1, not a bare $? behind a pipe). All four failures on the DatasetWidget surface; the ObjectDataTable, PivotTable and ObjectPivotTable cases stayed green, so the pins resolve per surface rather than firing as a block.
  • restore proven by state: git diff HEAD empty, git status --porcelain empty, blob back to f8f61e712947e3cdf3dacf3cdab3e6a8cccd25ba = its HEAD blob.

Leg 2 — delete role="status" from the shared component, i.e. ablate property (1) itself. Predicted: RED on the role assertions; the load-failure test stays green.

  • mutation proven on disk: marker role="status" 2 to 1 (the second occurrence is in the file's doc comment; the python anchor asserted exactly one match on the JSX line); blob a62aaa96... to 39a46faf...
  • observed: Tests 4 failed | 59 passed, exit 1 — the three seam cases plus the DatasetWidget.test.tsx role case. a load FAILURE still reads as a failure stayed green, so the two halves of property (1) are independent.
  • restore proven by state: git diff HEAD empty, blob back to a62aaa96ec88cede54f96579451a6922b3aceb3a.

Both legs used an EXIT INT TERM trap with an absolute REPO_ROOT path, and restored with git checkout HEAD -- path (never a bare git checkout -- path, which restores from the index and would hand back the mutation).

No rebuild leg, stated rather than skipped. This repo's vitest resolves @object-ui/* to packages/*/src via resolve.alias in vitest.config.mts, and the pins import the components by relative path, so no dist can shadow the mutation. A stale build cannot make an ablation falsely green here.

Gates

gateexitverdict
vitest (plugin-dashboard, i18n, widget-dom-leak-sweep)0green — 144 files, 1924 tests
pnpm --filter @object-ui/plugin-dashboard --filter @object-ui/i18n run type-check0green — both ran tsc --noEmit && tsc -p tsconfig.test.json, so the new test file was type-checked, not merely excluded
pnpm --filter @object-ui/plugin-dashboard --filter @object-ui/i18n run lint0green — 0 errors (423 + 34 pre-existing warnings; 4 of them no-explicit-any on the new test file, the same schema-cast shape its sibling suites use)
check:i18n-keys0green — "Every in-scope call-site key resolves against the en pack (2845 keys)…"
check:i18n-drift0green — "0 en value(s) changed (3 key(s) added, 0 removed…)" — strictly additive
check:control-bytes0green — "OK (scanned 5898 tracked text file(s); skipped 85 binary)"
check-changeset-presence0green — "17 source file(s) of 2 released package(s) changed, and this change declares 1 changeset(s)"
check:changeset-fixed0green — "All workspace packages are in the changeset fixed group."
check:changeset-no-major0green — "No changeset declares a major bump."
check:phantom-deps / check:self-import / check:side-effects-array / check:vi-mock-specifiers / check:vi-mock-inherit / check:esm-specifiers0green
check:readme-exports1NOT MEASURED — "the population COLLAPSED -- this run proves nothing / packagesRead: found 12, floor is 25". Local tree has most packages unbuilt; CI runs turbo run build first. Narrowing argument: packages/plugin-dashboard/src/index.tsx is untouched and does not export WidgetEmptyState (control in the same query: PivotTable, which it does export, hits), so the package's published export surface is unchanged and no README moved.
check:eager-closure2NOT MEASURED — "No eager-closure report at apps/console/dist/eager-closure.json … This is a broken gauge, not a sensitive gate." Needs a built apps/console; produced by performance-budget.yml in CI.

Changeset verdicts are quoted verbatim above rather than predicted; both gates were run.

Zero-hit readings, each with its control

queryscopehitscontrolcontrol hits
emptyStateplugin-dashboard/src + plugin-charts/src0widget, same scope1154
WidgetEmptyStateplugin-dashboard/src/index.tsx0PivotTable, same file1

What is NOT verified here, and why

The hotcrm acceptance scenario is unverified by me. The card's acceptance is a hotcrm sales_dashboard fresh install. hotcrm is outside this session's repo scope — it is not attached and I did not try to reach it. What I can state is the objectui-side mechanism: the quota-attainment tile is a dataset-bound table widget, which renders through DatasetWidget, whose empty branch is the one call site of the 暂无数据行 string this PR replaces. The end-to-end confirmation on a fresh hotcrm install still needs to be run by someone with that repo.

Shared-abstraction temptation, reported rather than taken.DataEmptyState in @object-ui/components is the presentational primitive already used by plugin-list, plugin-kanban, plugin-dashboardand plugin-detail — the surface of the parallel card #7064. Changing its defaults would have been a smaller diff that repaired both surfaces at once. It is deliberately consumed unchanged here: a shared empty-state abstraction designed twice in parallel by two agents is worse than two honest local fixes, and converging the two is its own sequenced card. Nothing under packages/plugin-detail is touched by this PR.

Residue this creates, not fixed here.dashboard.noRows and dashboard.noDataAvailable now have no call site. check:i18n-dead-keys (report-only, exit 0, not wired into CI) lists both as needs-review after this change. They are left in all ten packs deliberately: the dispatch asks locale edits to stay additive so parallel merges stay trivial, and the dead-key sweep is its own tracked workstream (objectui#4658). Filed as a follow-up rather than ridden on this PR.


Generated by Claude Code

… dashboard widgets
Maintainer ruling 2026-08-31 (hotcrm#1212, following hotcrm#1203): a widget that
renders a bare row-placeholder on an empty result is the platform's defect and
must be fixed uniformly — apps must not compensate widget by widget
(objectstack#13848).
There was no shared placeholder to fix. Three dashboard surfaces wrote their own,
in two different strings: DatasetWidget used `dashboard.noRows` ('No rows' /
暂无数据行), while ObjectDataTable and PivotTable used `dashboard.noDataAvailable`.
`WidgetEmptyState` is the seam they now share.
The default reads as a STATE rather than a failure — `role="status"` where the
branches beside it are `role="alert"` and the empty branches previously carried
no role at all — carries a title plus an explanation instead of one terse
fragment, and names the widget's data source with zero authored copy.
Copy is platform i18n: `dashboard.empty.title` / `.message` / `.sourceLabel` in
`en` and all nine sibling packs. No inline defaultValue, no interpolation.
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)3151.9 KB3191.4 KB
Main entry chunk (gzip)142.3 KB350 KB
Entry fileindex-DbjcEXZE.js
StatusPASS

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


📦 Bundle Size Report

PackageSizeGzipped
app-shell (consoleActionDispatch.js)0.20KB0.19KB
app-shell (index.js)15.33KB5.59KB
app-shell (runtime-config.js)20.68KB7.36KB
app-shell (types.js)0.01KB0.04KB
app-shell (urlParams.js)10.06KB3.86KB
auth (ActiveOrganizationStorage.js)25.05KB9.16KB
auth (AuthContext.js)0.31KB0.24KB
auth (AuthGuard.js)2.07KB1.00KB
auth (AuthProvider.js)40.18KB10.59KB
auth (AuthShell.js)3.49KB1.40KB
auth (ForgotPasswordForm.js)12.21KB3.45KB
auth (LoginForm.js)18.15KB5.39KB
auth (PreviewBanner.js)0.90KB0.50KB
auth (RegisterForm.js)6.65KB2.22KB
auth (SocialSignInButtons.js)9.61KB3.89KB
auth (UserMenu.js)3.41KB1.23KB
auth (auth-gate-events.js)1.29KB0.66KB
auth (authStyles.js)5.04KB1.72KB
auth (createAuthClient.js)40.21KB10.80KB
auth (createAuthenticatedFetch.js)8.46KB3.43KB
auth (index.js)3.19KB1.44KB
auth (invitation-status.js)1.22KB0.70KB
auth (org-roles.js)6.66KB2.78KB
auth (phone-identifier.js)1.11KB0.66KB
auth (types.js)0.59KB0.35KB
auth (useAuth.js)5.30KB1.02KB
auth (useWorkspaceAdminStatus.js)5.13KB2.35KB
collaboration (CommentThread.js)26.08KB7.56KB
collaboration (LiveCursors.js)3.17KB1.27KB
collaboration (PresenceAvatars.js)6.49KB2.64KB
collaboration (PresenceProvider.js)2.79KB1.13KB
collaboration (index.js)1.68KB0.73KB
collaboration (useCollaborationTranslation.js)6.05KB2.52KB
collaboration (useCommentSearch.js)1.98KB0.88KB
collaboration (useConflictResolution.js)7.75KB1.86KB
collaboration (useMentionNotifications.js)1.81KB0.68KB
collaboration (usePresence.js)6.33KB1.84KB
collaboration (useRealtimeSubscription.js)7.91KB2.01KB
components (index.js)512.30KB116.52KB
core (index.js)5.30KB2.13KB
create-plugin (index.js)10.08KB3.26KB
data-objectstack (index.js)177.67KB49.45KB
fields (index.js)243.64KB61.64KB
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.94KB63.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.14KB14.64KB
plugin-list (index.js)113.19KB27.60KB
plugin-map (index.js)20.20KB6.66KB
plugin-markdown (index.js)13.72KB4.69KB
plugin-report (index.js)43.51KB11.94KB
plugin-timeline (index.js)29.34KB8.47KB
plugin-tree (index.js)8.98KB3.08KB
plugin-view (index.js)85.90KB21.12KB
providers (DataSourceProvider.js)0.75KB0.39KB
providers (MetadataProvider.js)1.37KB0.59KB
providers (ThemeProvider.js)1.90KB0.85KB
providers (UploadProvider.js)11.66KB3.50KB
providers (index.js)0.45KB0.23KB
providers (types.js)0.01KB0.04KB
react-runtime (index.js)5.62KB2.34KB
react (LazyPluginLoader.js)4.47KB1.63KB
react (SchemaRenderer.js)81.07KB26.86KB
react (data-invalidation.js)5.05KB2.08KB
react (index.js)3.11KB1.48KB
react (schema-input.js)2.32KB1.24KB
react (spec-input.js)0.20KB0.18KB
sdui-parser (codegen.js)5.41KB2.34KB
sdui-parser (dashboard-widget-options.js)3.08KB1.30KB
sdui-parser (index.js)4.93KB2.24KB
sdui-parser (input-type.js)2.84KB1.40KB
sdui-parser (parse.js)20.57KB5.88KB
sdui-parser (provenance.js)3.66KB1.82KB
sdui-parser (types.js)0.28KB0.23KB
sdui-parser (validate.js)10.35KB3.60KB
types (ai.js)0.20KB0.17KB
types (api-types.js)0.20KB0.18KB
types (app.js)2.87KB0.99KB
types (base.js)0.20KB0.18KB
types (blocks.js)0.20KB0.18KB
types (complex.js)2.74KB1.41KB
types (crud.js)0.20KB0.18KB
types (dashboard-filter-alias.js)6.23KB2.74KB
types (data-display.js)3.75KB1.85KB
types (data-protocol.js)0.20KB0.19KB
types (data.js)0.20KB0.18KB
types (designer.js)1.85KB0.85KB
types (disclosure.js)0.20KB0.18KB
types (error-code.js)1.54KB0.88KB
types (feedback.js)0.20KB0.18KB
types (field-types.js)0.20KB0.18KB
types (form.js)0.20KB0.18KB
types (http-inflight.js)8.87KB3.73KB
types (http-retry.js)4.32KB2.02KB
types (icon-key-migration.js)4.26KB1.63KB
types (index.js)4.72KB2.24KB
types (layout.js)0.20KB0.18KB
types (managed-by.js)0.19KB0.18KB
types (mobile.js)2.59KB1.31KB
types (navigation.js)0.20KB0.18KB
types (objectql.js)0.20KB0.18KB
types (overlay.js)0.20KB0.18KB
types (permissions.js)0.20KB0.18KB
types (plugin-scope.js)0.20KB0.18KB
types (record-components.js)0.20KB0.19KB
types (record-semantics.js)1.28KB0.67KB
types (registry.js)0.20KB0.18KB
types (reports.js)0.20KB0.18KB
types (spec-report.js)5.05KB1.93KB
types (spec-ui-namespace.js)0.20KB0.19KB
types (system-fields.js)3.33KB1.54KB
types (theme.js)6.28KB2.87KB
types (ui-action.js)3.40KB1.71KB
types (views.js)0.20KB0.18KB
types (widget.js)0.20KB0.18KB

Size Limits

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

@os-warrenClaude

Copy link
Copy Markdown
CollaboratorAuthor

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

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

⭐⭐ You declined the shared abstraction, and the forbidden path was the cheaper one

DataEmptyState in packages/components/src/custom/view-states.tsx is already consumed by plugin-list, plugin-kanban, plugin-dashboardand plugin-detail — the exact surface of the parallel card #7064. Changing its defaults would have been a smaller diff repairing both surfaces at once.

That is the strongest possible test of the fence, and it held. A cross-surface abstraction designed simultaneously by two agents who cannot see each other's reasoning is worse than two honest local fixes — and it would have been invisible as a problem, because the diff would have looked elegant. You consumed the primitive unchanged, kept the new component dashboard-local, recorded why in the file header, and touched nothing under plugin-detail. ⭐ Recording the temptation is as valuable as resisting it — it tells me convergence is worth a sequenced card once both land, which I can now schedule instead of discover.

Both of the card's premises were false, and neither failure was papered over

  1. "One shared placeholder reached by all the affected widgets." ⛔ False: three surfaces, two different keys, no shared code — and the measured string dashboard.noRows had exactly one call site repo-wide. ⇒ There was no seam to repair; one had to be created. That is a materially different job from the one the card describes, and it is why a new component exists rather than a one-line default change.
  2. "The authored emptyState contract already exists and already overrides." ⛔ False on this surface: emptyState is a list-view contract. Measured 0 occurrences across plugin-dashboard/src and plugin-charts/src⚠️ against a control of 1154 hits for widget over the same paths — and spec 17.2.0's DashboardWidgetSchema, read from the built package at runtime, declares no such key.

⭐ Clause 3 is vacuous here, and you were right that this is not a stop-and-report

My order made clause 3's stop-and-report fence hard, because a spec key added on a hunch becomes an unlandable PR under the quota freeze. You reached the fence and correctly did not trigger it: the deliverable is a better default, and a default needs no authoring surface. Nothing was widened, so nothing was owed.

⚠️ But the fact stands and is worth the maintainer's eye: the ruling's "the existing authored contract always overrides" describes, on this surface, a contract that does not exist. Same shape as #7064's finding an hour ago, where the ruling's authored opt-out turned out to be refused by the spec. Two rulings in one round whose preservation clause protects something that isn't there. I'm routing that pattern rather than treating each as a one-off.

A failed control, caught and re-taken — the discipline that matters most

Your first control for "WidgetEmptyState is not exported from index.tsx" used DatasetWidget, which also returned 0. Rather than reading that as confirmation, you discarded the reading and re-took it with PivotTable (= 1). ⭐ A control that returns zero is not a weak control, it is no control — and DatasetWidget being internal is exactly the kind of thing that makes a plausible control silently useless. This is the sixth instrument failure surfaced on this lane today and the second caught by its own operator mid-measurement.

Ablation: two legs, both per-surface

Leg 1 (revert DatasetWidget's empty branch) reddened 4 cases confined to that surface while table and pivot cases stayed green — proving the pins resolve per surface rather than firing as a block, which a single seam makes easy to get wrong. Leg 2 (delete role="status") reddened the role assertions while "a load FAILURE still reads as a failure" stayed green, showing the two halves of requirement 1 are independent. Directions predicted before running; both matched.

The copy decision is right, and its restraint is the reason

Naming the data source as a raw metadata identifier (crm_forecast) as a labelled monospace value, rather than folding it into a friendly sentence, is a truthful narrow statement over an invented one — and declining to use widget.title because DashboardRenderer already renders the resolved title directly above avoids saying the same thing twice. role="status" against the neighbouring role="alert" is what makes requirement 1 structural rather than cosmetic.

The i18n handling — additive after each pack's existing anchor, verified unique in all ten before writing, punctuation carried inside the key so no separator is concatenated and RTL needs no special case — is exactly what keeps the parallel packages/i18n work merging trivially.

NOT MEASURED, reported as such, with narrowing arguments

check:readme-exports (population COLLAPSED, 12 packages read against a floor of 25) and check:eager-closure (no apps/console/dist/eager-closure.json"a broken gauge, not a sensitive gate"). ⭐ Both carry a reason and the first carries a narrowing that is itself controlled: index.tsx is untouched and does not export the new component (control: PivotTable, which it does export, hits 1). CI owns both.

Open question 1 — emptyState for dashboard widgets: I adopt A

No key added. Your reasoning holds and I'll add the decisive point: B is currently unbuildable-to-land — it is a public authoring-surface widening, so it needs the contract review the exhausted Fable 5 quota blocks, and it would join #6896 / PR #7111 in the stranded state. Beyond that, adding a per-widget authored-prose key one card after a ruling closed the per-app-compensation route would undo the ruling with its own follow-up. Zero measured pull. Recorded on #7129 alongside #7064's sibling question.

Open question 2 — I am filing it; here is why I am overriding your restraint

You declined to file the ObjectChart observation because the state may be intentional. That restraint was right by default, and I'd rather see it than a reflexive card. But I'm filing it, on three grounds: it is a measured absence (no empty branch at all, not a judgement about quality), the seam you just built makes the fix small, and an observation that lives only in a subagent report is lost. The card carries your framing verbatim — may be intentional, needs a reading before any fix — so it records the fact without pre-judging it. Filed as #7130.

#7125

Correctly filed and correctly not acted on: the dead keys are residue this PR creates, the dispatch asked locale edits to stay additive for trivial parallel merges, and the dead-key gate is report-only and wired into no workflow. Deleting them here would have traded a clean merge for a cosmetic win.


Generated by Claude Code

@os-warren
os-warren marked this pull request as ready for review September 1, 2026 03:32
@os-warren
os-warren added this pull request to the merge queueSep 1, 2026
Merged via the queue into main with commit 220c18dSep 1, 2026
32 checks passed
@os-warren
os-warren deleted the claude/issue-7063-widget-empty-state-default branch September 1, 2026 03:45
os-warren pushed a commit that referenced this pull request Sep 1, 2026
…r an empty result
Measured, not assumed: recharts derives ticks from the data, so an empty
result draws two hairline axis rules and zero text nodes (bar/line) or
nothing at all (pie) — there are no labelled axes to read. Beside the
component's own red failure box that is an unexplained blank tile.
Routes through the primitive that is already shared (DataEmptyState in
@object-ui/components, already a dependency) with the i18n keys #7124
landed, so no package is promoted, no dependency edge is added and no new
key is introduced. Charts with inline authored data are unchanged.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012wwHa4aaFybxXrfmfHioDM
akarma-synetal pushed a commit to akarma-synetal/objectui that referenced this pull request Sep 1, 2026
…r an empty result (objectstack-ai#7139)
* fix(plugin-charts): a self-describing empty state for ObjectChart over an empty result
Measured, not assumed: recharts derives ticks from the data, so an empty
result draws two hairline axis rules and zero text nodes (bar/line) or
nothing at all (pie) — there are no labelled axes to read. Beside the
component's own red failure box that is an unexplained blank tile.
Routes through the primitive that is already shared (DataEmptyState in
@object-ui/components, already a dependency) with the i18n keys objectstack-ai#7124
landed, so no package is promoted, no dependency edge is added and no new
key is introduced. Charts with inline authored data are unchanged.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012wwHa4aaFybxXrfmfHioDM
* test(app-shell): give the two object-bound chart sweep targets rows
The DOM-leak sweep mounts `plugin-charts:object-chart` and `view:chart`
against an adapter that answers every query with no rows. Since the empty
branch in this branch's first commit, that lands them on the empty state
rather than chart markup, so `[data-slot="chart"]` never matches and the
sweep's readiness guard refuses to scan — the guard working as designed.
Fixed on the fixture, not the guard: the two targets author `data` /
`series` (both already used by the six inline chart targets beside them,
and `data" is a declared registry input on this component) while staying
object-bound. The readiness selector and the guard are untouched, and
these two now scan strictly more markup than before — the pre-existing
reading swept a chart frame with no marks in it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012wwHa4aaFybxXrfmfHioDM
---------
Co-authored-by: Claude <noreply@anthropic.com>
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

1 participant

@os-warren