Skip to content
This repository was archived by the owner on Nov 4, 2026. It is now read-only.
This repository was archived by the owner on Nov 4, 2026. It is now read-only.

Dashboard/analytics widgets render a bare 「暂无数据行」 that is indistinguishable from a load failure — make the DEFAULT empty state self-explaining, platform-wide #7063

Closed

Description

@huangyiirene

Filed on the maintainer's ruling (2026-08-31, hotcrm#1212 adjudication, verbatim: 「1212 也是」 — following 「#1203 …这种也是平台的问题啊」: this class is the platform's to fix uniformly, apps must not compensate widget-by-widget).

The measured scenario (hotcrm#1212, closed with a pointer here)

A fresh install of the flagship demo renders the sales dashboard with eleven populated tiles and one blank one — the quota-attainment table reads exactly 「暂无数据行」, sits mid-page, and reads as "the dashboard failed to load". The cause is legitimate (its data source crm_forecast has a single scheduled producer that has not run yet — by ruling hotcrm#702), the widget declaration is legitimate, and the only app-side remedy was authoring per-widget emptyState prose — the per-app tax the maintainer has ruled against (objectstack#13848 principles: apps are simplified business implementations; uniform behavior belongs to the platform).

Deliverable

Make the widget DEFAULT empty state self-explaining, uniformly:

  1. distinguishable from a load failure at a glance (visual treatment + wording — an empty result is a state, not an error);
  2. self-describing without authored copy: name what is empty (the widget's label and/or its data source) rather than the bare row-placeholder, e.g. a "no data yet in [source]" shape — exact copy/design is implementation + review judgment;
  3. the EXISTING authored emptyState contract always overrides the default — this card adds no new authoring obligation and no new spec key (if implementation finds a spec-side key is genuinely needed, stop and report — that is a contract question, not a rider);
  4. i18n via the platform's locale bundles, not per-app strings.

Acceptance scenario

hotcrm sales_dashboard fresh install (pnpm dev, no snapshot yet): the quota-attainment tile must read as an explained empty state, with zero hotcrm-side changes.

Refs: hotcrm#1212 (measurement + the corrected cause chain) · hotcrm#702 (single-producer ruling that makes seeding the window a non-option) · objectstack#13848 (application-repo principles).

Activity

  1. added
    domain:uiobjectui ui stream: fix lands on the published library or apps — objectui execution seat
    on Aug 31, 2026
  2. self-assigned this
    on Sep 1, 2026
  3. os-warren commented on Sep 1, 2026

    @os-warren
    Collaborator

    🔒 Claimed — domain:ui execution seat (objectui)

    • Session: session_012wwHa4aaFybxXrfmfHioDM
    • Branch: claude/issue-7063-widget-empty-state-default
    • Build tier: opus (⚠️ Fable 5 quota exhausted; standing maintainer exemption 2026-08-13 lowers the build tier only)

    ⭐ This card is fenced better than most, and clause 3 is why

    "this card adds no new authoring obligation and no new spec key (if implementation finds a spec-side key is genuinely needed, stop and report — that is a contract question, not a rider)."

    That makes it non-clause-② by construction, with an escape hatch that routes a contract question back to the seat instead of letting it become a PR that cannot land. ⚠️ That matters concretely right now: the Fable 5 quota is exhausted, so a clause-② PR can be built but cannot pass its review gate — #6896 / PR #7111 is sitting in exactly that state. A card that says "stop and report" instead of "add the key" is the difference between a routed decision and a stranded branch.

    ⇒ The stop-and-report in clause 3 is binding as written. An implementer who finds a spec key is needed stops; they do not add it.

    ⚠️ Sibling card in flight — read this before inventing a shared abstraction

    #7064 is dispatched simultaneously and rests on the same maintainer principle (objectstack#13848: apps are simplified business implementations, uniform behaviour belongs to the platform; per-app compensation is the tax being ruled against). Different surface, different files:

    card surface files
    #7063 (this) dashboard/analytics widget empty state dashboard widgets + packages/i18n locale packs
    #7064 record-detail empty section default packages/plugin-detail

    ⛔ Neither dev may unilaterally build a shared empty-state abstraction spanning both surfaces. If one looks correct, stop and report — a shared abstraction designed twice in parallel by two agents is worse than two honest local fixes, and the seat can sequence it as its own card once both have landed.

    Scope notes carried into the dispatch


    Generated by Claude Code

  4. os-warren commented on Sep 1, 2026

    @os-warren
    Collaborator

    os-dev-report

    {
      "issue": 7063,
      "status": "done",
      "branch": "claude/issue-7063-widget-empty-state-default",
      "pr": "https://github.com/objectstack-ai/objectui/pull/7124",
      "premise_still_valid": true,
      "shared_seam_found": false,
      "seam_location": "No pre-existing shared seam existed; one was CREATED at packages/plugin-dashboard/src/WidgetEmptyState.tsx. The card's premise that the string is one shared placeholder is falsified: three surfaces wrote their own empty state in TWO different keys, with no common code. dashboard.noRows had exactly one call site repo-wide.",
      "placeholder_sites": [
        "packages/plugin-dashboard/src/DatasetWidget.tsx:727 — dashboard.noRows ('No rows' / the measured zh string). The ONLY site producing the measured string; serves every dataset-bound non-metric widget (chart, table, pivot), i.e. the ADR-0021 path the hotcrm tile takes.",
        "packages/plugin-dashboard/src/ObjectDataTable.tsx:941 — dashboard.noDataAvailable ('No data available'). Object-bound dashboard table.",
        "packages/plugin-dashboard/src/PivotTable.tsx:312 — dashboard.noDataAvailable, with its title suppressed outright (title empty plus a hidden-h3 rule), so only the terse description rendered.",
        "packages/plugin-dashboard/src/ObjectPivotTable.tsx — reaches PivotTable's site by delegation; now forwards objectName as the new sourceLabel prop.",
        "NOT a site, deliberately left alone: metric/KPI widgets render 0 over an empty dataset by existing design (a KPI reading 'no data' reads as broken); plugin-charts ObjectChart has no empty branch at all, it draws an empty frame."
      ],
      "authored_emptyState_override_verified_by_rendering": false,
      "authored_emptyState_override_finding": "Could not be verified by rendering because IT DOES NOT EXIST on this surface. emptyState occurs 0 times in packages/plugin-dashboard/src and packages/plugin-charts/src (control in the same sweep: 'widget', 1154 hits). It is a LIST-view contract (ObjectGridSchema and NamedListView in packages/types/src/objectql.ts, honoured in packages/plugin-list/src/ListView.tsx). objectstack/spec 17.2.0 DashboardWidgetSchema, read from the built package at runtime, declares these keys and no emptyState: id, title, description, type, chartConfig, colorVariant, requiresObject, requiresService, actionUrl, actionType, actionIcon, filter, compareTo, dataset, dimensions, values, layout, options, filterBindings, suppressWarnings, responsive, aria. Consequence: ruling clause 3 is satisfied VACUOUSLY here — there is no authored override for the new default to displace.",
      "what_is_reachable_at_render_site": "DatasetWidget: datasetName (widget.dataset, always non-empty on this path), widget.title (raw, may be an I18nLabel map — NOT used: the CardHeader in DashboardRenderer already renders the resolved title directly above every tile), widget.dimensions, widget.values, and after an ok response state.object / state.fields. ObjectDataTable: schema.objectName. PivotTable: nothing — PivotTableSchema declares no objectName, so it takes the name as a new optional sourceLabel prop which ObjectPivotTable forwards (reading it off the spread schema would be reading a key the type denies). Chosen: the DATA SOURCE, as a raw metadata identifier (crm_forecast) rendered as a labelled monospace value, not folded into a sentence — a truthful narrow statement over an invented friendly one.",
      "spec_key_needed": false,
      "spec_key_note": "No spec key was added and none is needed for the deliverable, which is a better DEFAULT — defaults need no authoring surface. The stop-and-report fence was NOT reached. Making clause 3 non-vacuous (giving dashboard widgets a real authored override) WOULD require a new spec key; that is flagged for the seat as an open question, not taken.",
      "files_changed": [
        "packages/plugin-dashboard/src/WidgetEmptyState.tsx (new, the seam)",
        "packages/plugin-dashboard/src/DatasetWidget.tsx",
        "packages/plugin-dashboard/src/ObjectDataTable.tsx",
        "packages/plugin-dashboard/src/PivotTable.tsx (new optional sourceLabel prop)",
        "packages/plugin-dashboard/src/ObjectPivotTable.tsx",
        "packages/plugin-dashboard/src/__tests__/WidgetEmptyState.uniformDefault.test.tsx (new, 5 cases)",
        "packages/plugin-dashboard/src/__tests__/DatasetWidget.test.tsx (4 pins rewritten)",
        "packages/i18n/src/locales/{en,zh,ja,ko,de,fr,es,pt,ru,ar}.ts",
        ".changeset/7063-widget-empty-state-default.md"
      ],
      "i18n_keys_added": [
        "dashboard.empty.title",
        "dashboard.empty.message",
        "dashboard.empty.sourceLabel"
      ],
      "i18n_notes": "Additive only — inserted after each pack's existing dashboard.noRows anchor (verified unique in all ten before writing), no existing entry reordered or reformatted, so the parallel packages/i18n work merges trivially. No inline defaultValue anywhere; no interpolation, because useSafeTranslate takes a positional fallback and no options bag, and a raw useObjectTranslation t() renders the KEY with no provider mounted (the test and standalone-host configuration). The sourceLabel key carries its own punctuation ('Source:', the zh full-width colon, the Arabic form) so no separator is concatenated in code and RTL needs no special case.",
      "locale_parity_verdict": "green — packages/i18n/src/__tests__/all-locales-key-parity.test.ts passed inside the 144-file / 1924-test run. check:i18n-drift verbatim: 'Compared the ten locale packs at 71d83a6b1 (merge-base with origin/main) with the working tree: 0 en value(s) changed (3 key(s) added, 0 removed — those are all-locales-key-parity's), 0 pack value(s) followed, 0 pack/key pair(s) skipped because the pack does not define the key, 0 finding(s) waived by the ledger.'",
      "pins_added": [
        "WidgetEmptyState.uniformDefault.test.tsx — 5 cases pinning the ruling's PROPERTIES (role status not alert; title AND explanation; names the source; the copy moves under a zh provider so it comes from the packs; the pre-card zh string is gone from the rendered output) and pinning that all four dashboard entry points reach the one seam.",
        "DatasetWidget.test.tsx — a self-explaining-empty-state case, a blank-source degradation case, and a 'load FAILURE still reads as a failure' case; plus 4 pre-existing pins rewritten off the deleted 'No rows' string, which would otherwise have passed for the wrong reason."
      ],
      "pin_ablation": {
        "predicted": "Leg 1 (revert DatasetWidget's empty branch to the pre-card dashboard.noRows one-liner): RED, and only on the DatasetWidget surface. Leg 2 (delete role=status from the shared component): RED on the role assertions, with the load-failure case staying GREEN. Both directions written down before either was run.",
        "observed": "Leg 1: Tests 4 failed / 59 passed, vitest exit 1 read from the run's own printed ABLATED_VITEST_EXIT=1 (never a bare exit status 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. Leg 2: Tests 4 failed / 59 passed, exit 1 — three seam cases plus the DatasetWidget role case, while 'a load FAILURE still reads as a failure' stayed GREEN, showing the two halves of property 1 are independent. Both legs matched their prediction. Unmutated control run at the same commit: 144 files / 1924 tests, all green.",
        "blob_before": "DatasetWidget.tsx f8f61e712947e3cdf3dacf3cdab3e6a8cccd25ba (equal to its HEAD blob, asserted before mutating); WidgetEmptyState.tsx a62aaa96ec88cede54f96579451a6922b3aceb3a",
        "blob_after": "DatasetWidget.tsx 951c82512f15e9e8bcb7d4f25f62d629681ff598; WidgetEmptyState.tsx 39a46faf5ee9804619860bbee8ae11a902f41815. Mutation also proven by marker counts, not by an editor exit code: leg 1 removed-marker (the JSX open tag) 1 to 0 and injected-marker tt('dashboard.noRows' 0 to 1; leg 2 role=status 2 to 1 (the surviving one is in the file's doc comment; the python anchor asserted exactly one match on the JSX line and would have exited 4 on a zero-hit).",
        "restore_proven_how": "By STATE, not by an exit code: after each leg, git diff HEAD empty, git status --porcelain empty, and git hash-object back to the path's HEAD blob (f8f61e71... and a62aaa96... respectively). Restores used git checkout HEAD -- path, never a bare git checkout -- path (which restores from the index and hands the mutation straight back). Both scripts carried trap ... EXIT INT TERM with an absolute REPO_ROOT computed from git rev-parse --show-toplevel. The implementation was COMMITTED before any ablation, so HEAD actually held it. No rebuild leg, stated rather than skipped: this repo's vitest maps every object-ui specifier to packages/PKG/src via resolve.alias in vitest.config.mts and the pins import the components relatively, so no dist can shadow a mutation and no stale build can make an ablation falsely green."
      },
      "changeset_gate_verdict_verbatim": "check-changeset-fixed: 'All workspace packages are in the changeset fixed group.' / 'privatePackages declared: version=true, tag=false.' — exit 0. check-changeset-no-major: 'No changeset declares a `major` bump.' — exit 0. check-changeset-presence: '17 source file(s) of 2 released package(s) changed, and this change declares 1 changeset(s): .changeset/7063-widget-empty-state-default.md.' — exit 0. All three RUN, not predicted. The changeset declares minor for plugin-dashboard and i18n.",
      "gates": [
        { "name": "vitest (packages/plugin-dashboard, packages/i18n, app-shell widget-dom-leak-sweep)", "exit": 0, "verdict": "green", "raw": "Test Files 144 passed (144) / Tests 1924 passed (1924), run at final HEAD 79accd66f" },
        { "name": "type-check (plugin-dashboard, i18n)", "exit": 0, "verdict": "green", "raw": "both echoed 'tsc --noEmit && tsc -p tsconfig.test.json' then 'Done' — so the NEW TEST FILE was type-checked, not silently excluded; the script name was confirmed echoed, so this is not a zero-match pnpm filter exiting 0" },
        { "name": "lint (plugin-dashboard, i18n)", "exit": 0, "verdict": "green", "raw": "'423 problems (0 errors, 423 warnings)' and '34 problems (0 errors, 34 warnings)'; 4 of the warnings are no-explicit-any on the new test file, the same schema-cast shape its sibling suites already use. Plain eslint form, never --no-inline-config." },
        { "name": "check:i18n-keys", "exit": 0, "verdict": "green", "raw": "Every in-scope call-site key resolves against the en pack (2845 keys), every literal inline defaultValue matches the value the pack serves, every call site passes exactly the arguments that value has holes for..." },
        { "name": "check:i18n-drift", "exit": 0, "verdict": "green", "raw": "0 en value(s) changed (3 key(s) added, 0 removed) — strictly additive" },
        { "name": "check:control-bytes", "exit": 0, "verdict": "green", "raw": "check-control-bytes: OK (scanned 5898 tracked text file(s); skipped 85 binary)" },
        { "name": "check-changeset-presence", "exit": 0, "verdict": "green", "raw": "17 source file(s) of 2 released package(s) changed, and this change declares 1 changeset(s)" },
        { "name": "check:changeset-fixed", "exit": 0, "verdict": "green", "raw": "All workspace packages are in the changeset fixed group." },
        { "name": "check:changeset-no-major", "exit": 0, "verdict": "green", "raw": "No changeset declares a `major` bump." },
        { "name": "check:phantom-deps", "exit": 0, "verdict": "green", "raw": "Every in-scope import is declared by the package that publishes it. (lucide-react, used by the new file, is already a plugin-dashboard dependency)" },
        { "name": "check:self-import", "exit": 0, "verdict": "green", "raw": "No package names itself inside its own src/." },
        { "name": "check:side-effects-array", "exit": 0, "verdict": "green", "raw": "sideEffects names exactly the registering modules for each package that declares one" },
        { "name": "check:vi-mock-specifiers", "exit": 0, "verdict": "green", "raw": "OK (4074 tracked source file(s), 2349 test-named; 515 carry a mock)" },
        { "name": "check:vi-mock-inherit", "exit": 0, "verdict": "green", "raw": "OK (114 call site(s) on object-ui/react judged, 114 inherit, 0 auto-mocked)" },
        { "name": "check:esm-specifiers", "exit": 0, "verdict": "green", "raw": "Specifier leg: no un-ledgered package emits an extensionless relative specifier." },
        { "name": "check:i18n-dead-keys", "exit": 0, "verdict": "green", "raw": "REPORT-ONLY by design, wired into no CI workflow. Run for information: it lists dashboard.noRows and dashboard.noDataAvailable as needs-review after this change — the residue filed as objectui#7125." },
        { "name": "check:readme-exports", "exit": 1, "verdict": "NOT_MEASURED", "raw": "check-readme-exports: the population COLLAPSED -- this run proves nothing / packagesRead: found 12, floor is 25. Cause: most packages are unbuilt in this worktree (I built only the two dependency closures I needed); CI runs turbo run build first, so it will measure this properly. 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 1), so the package's published export surface is unchanged and no README moved." },
        { "name": "check:eager-closure", "exit": 2, "verdict": "NOT_MEASURED", "raw": "No eager-closure report at apps/console/dist/eager-closure.json, so no ceiling could be weighed against the payload it governs. ... This is a broken gauge, not a sensitive gate. Needs a built apps/console; performance-budget.yml produces it in CI." }
      ],
      "controls_used": [
        "emptyState in plugin-dashboard/src + plugin-charts/src = 0 hits; control 'widget' over the SAME paths = 1154 hits. Counts read from a redirected file with wc -l, and the grep exit captured by redirect-then-read, never through a pipe.",
        "WidgetEmptyState in plugin-dashboard/src/index.tsx = 0; control 'PivotTable' in the same file = 1. My FIRST attempt at this control used 'DatasetWidget', which also returned 0 — the control failed, so that reading was discarded and re-taken with a term the file genuinely contains (DatasetWidget is internal and not exported from the entry).",
        "dashboard.noRows in packages/plugin-dashboard after the edit = 0 call sites; control 'tt(' in DatasetWidget.tsx = 10, so the file's other translate calls are intact.",
        "Ablation markers are paired in both directions per leg: the removed marker must drop AND the injected marker must appear, plus a blob-hash change — an editor exit code is never the evidence."
      ],
      "assumptions_falsified": [
        "FALSIFIED — 'the zh string is one shared placeholder reached by all the affected widgets'. It is not: three surfaces, two keys, no shared code. The measured string had exactly ONE call site. The fix therefore had to CREATE the seam rather than repair it, which is why a new component exists.",
        "FALSIFIED — 'the authored emptyState contract already exists and already overrides'. It exists for LIST views only; the dashboard widget schema has no such key in objectstack/spec 17.2.0, and emptyState appears zero times in the dashboard and charts packages. It could not be verified by rendering because there is no path to render. Ruling clause 3 is therefore vacuous on this surface — which is also why no stop-and-report was triggered.",
        "HELD — 'the widget knows its own label and data-source name at the render site'. The data source is reachable on both bound paths (widget.dataset, schema.objectName); the pivot needed a new prop to receive it. The widget LABEL is reachable only as a raw possibly-I18nLabel value and is deliberately not used, because DashboardRenderer already renders the resolved title in the card header directly above.",
        "HELD — 'no new spec key is needed'. None added, none needed."
      ],
      "hotcrm_acceptance_unverified_reason": "hotcrm is not in this session's repo scope — not attached, and I did not attempt to reach it. So the acceptance scenario (fresh sales_dashboard install, quota-attainment tile reading as an explained empty state with zero hotcrm-side changes) is UNVERIFIED BY ME and is stated as such in the PR body. What is established objectui-side: that tile is a dataset-bound table widget, dataset-bound widgets render through DatasetWidget, and DatasetWidget's empty branch was the single call site of the string the scenario measured. The end-to-end confirmation still needs someone with that repo.",
      "shared_abstraction_temptation_reported": "YES, and declined. DataEmptyState in packages/components/src/custom/view-states.tsx is the presentational primitive already used by plugin-list, plugin-kanban, plugin-dashboard AND plugin-detail — the surface of the parallel card #7064. Changing its defaults (its title default is literally 'No data') would have been a smaller diff that repaired both surfaces at once, and it is exactly the cross-surface abstraction the dispatch forbids designing twice in parallel. It is consumed UNCHANGED here; the new component is dashboard-local, and its file header records why. Nothing under packages/plugin-detail was touched. If the seat wants convergence, it is its own sequenced card after both land.",
      "out_of_scope_findings": [
        "filed as objectui#7125 (label: finding, domain:ui, unassigned): dashboard.noRows and dashboard.noDataAvailable become dead keys once this lands — 20 entries across ten packs, zero call sites. Deliberately not deleted here because the dispatch asked locale edits to stay additive for trivial parallel merges, and the dead-key gate is report-only and in no CI workflow. Dedupe done before filing (targeted search, no open duplicate; the closed precedents #4742 #4746 #5783 #6310 set the house shape)."
      ],
      "open_questions": [
        {
          "question": "Should dashboard widgets get a real authored emptyState override, the way list views have one? Today they have none, so ruling clause 3 is satisfied only vacuously — an app author who WANTS per-widget empty copy on a dashboard still has no way to write it, and the ruling's 'the authored contract always overrides' describes a contract that does not exist on this surface.",
          "options": [
            "A — leave it. The improved default is self-describing without authored copy, which is what the ruling actually asks for; per-widget authored prose is the per-app tax hotcrm#1212 ruled against, so its absence is arguably correct rather than a gap.",
            "B — add emptyState to objectstack/spec DashboardWidgetSchema, mirroring ListViewSchema.emptyState, and honour it in the new seam. This is a public authoring-surface widening: a contract question, gated by the review the seat says the exhausted Fable 5 quota currently blocks, so a PR doing it would be built-but-unlandable (the state #6896 / PR #7111 is in).",
            "C — defer until there is measured pull: file it and wait for an app that actually needs per-widget empty copy the default cannot express."
          ],
          "recommendation": "A, with C as the fallback if anyone asks again. Real business need: zero measured pull — nothing in this repo authors a dashboard emptyState today because the key has never existed, and the case that PROMPTED this card was explicitly one where authored per-widget prose was the wrong answer. Long-term soundness: adding the key would re-open the per-app compensation route the ruling closed, one card after closing it. AI-authoring safety: a key that exists is a key an agent will fill, and per-widget empty prose is exactly the kind of hand-written text that goes stale silently — the declared-and-enforced default cannot. Startup scope discipline: this is a zero-pull surface widening, which the 2026-08-04 focus principle handles by default-refusing. I did NOT add it, and I am not asking for a decision now — recording it so the seat has the fact rather than rediscovering it."
        },
        {
          "question": "plugin-charts ObjectChart has NO empty branch at all — over an empty result it draws an empty chart frame with no message, which is a fourth reading of the same 'is it broken or is it young' question and is arguably worse than the placeholder this card replaced. It is out of this card's measured scope (the ruling's scenario is a table tile) and I did not touch it.",
          "options": [
            "A — leave for a follow-up card once this seam has landed and been reviewed.",
            "B — extend this PR to route ObjectChart's empty result through the same seam."
          ],
          "recommendation": "A. B widens a PR that is already the platform-wide fix for a maintainer ruling, into a package the card does not name, on a state nobody has measured a complaint about. The seam this PR adds makes B a small follow-up whenever it is wanted. I have not filed it as an issue because it is an observation about a state that may well be intentional (a chart frame with axes is arguably self-describing), and I would rather the seat decide than pre-file a card on my own reading."
        }
      ],
      "summary": "Added packages/plugin-dashboard/src/WidgetEmptyState.tsx as the one default empty state for the dashboard/analytics surface, and routed DatasetWidget, ObjectDataTable and PivotTable (plus ObjectPivotTable by delegation) through it — there was no pre-existing shared placeholder to fix, which is the card's first falsified premise. The default now reads as a state rather than a failure (role status against the neighbouring role alert; the empty branches previously had 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 three new keys in en and all nine sibling packs, no inline defaultValue and no interpolation. Second falsified premise: the authored emptyState override does not exist for dashboard widgets at all — it is a list-view contract and the spec's DashboardWidgetSchema has no such key — so clause 3 is vacuous here; no spec key was added or needed, and the stop-and-report fence was not reached.",
      "tests": "All at final commit 79accd66f (tree verified clean, git status --porcelain empty, before the union was run). pnpm exec vitest run packages/plugin-dashboard packages/i18n packages/app-shell/src/__tests__/widget-dom-leak-sweep.test.tsx --maxWorkers=2 => 'Test Files 144 passed (144) / Tests 1924 passed (1924)', exit 0. type-check and lint for both changed packages exit 0 (type-check echoed both tsc projects, so the new test file was really checked). check:i18n-keys, check:i18n-drift, check:control-bytes, and all three changeset gates re-run at that commit, all exit 0 with the verdict lines quoted above. Two ablation legs, direction predicted before running, both matching: reverting DatasetWidget's empty branch reddened 4 cases confined to that surface while the table and pivot cases stayed green; deleting role=status reddened the role assertions while the load-failure case stayed green. Every mutation proven on disk by paired marker counts AND a blob-hash change; every restore proven by state (git diff HEAD empty, blob back to the HEAD blob), with git checkout HEAD -- path and an absolute-path EXIT INT TERM trap. No rebuild leg is owed: vitest resolves the object-ui specifiers to src via resolve.alias, so no dist can shadow a mutation. Heavy runs went through the shared verify lock; longest queue wait 237s, no run abandoned. NOT MEASURED, reported not read as green or red: check:readme-exports (population COLLAPSED, 12 packages read against a floor of 25 — most packages unbuilt locally; CI builds first) and check:eager-closure (no apps/console/dist/eager-closure.json — 'a broken gauge, not a sensitive gate').",
      "mcp_calls": "8 — issue_read get, issue_read get_comments, create_pull_request, pull_request_read (full body read-back), search_issues (one targeted dedupe query, no wide listing), issue_write create (the finding), add_issue_comment (this report), issue_read get_comments (comment read-back)."
    }

    Generated by Claude Code

  5. os-warren commented on Sep 1, 2026

    @os-warren
    Collaborator

    🟢 LANDED — closed by PR #7124

    Merged to main as 220c18d05: "fix(plugin-dashboard,i18n): a self-explaining default empty state for dashboard widgets".

    Landing verified with controls

    probe result
    packages/plugin-dashboard/src/WidgetEmptyState.tsx — the new seam ✅ present on main
    packages/plugin-dashboard/src/DatasetWidget.tsx ⚠️ control — predates the PR ✅ present
    sourceLabel in packages/i18n/src/locales/en.ts 2
    noRows / noDataAvailable ⚠️ controls — pre-existing keys, must hit 3 / 1

    ⚠️ My first i18n probe was a false zero and I caught it with a control. I searched the en pack for the dotted form dashboard.empty and got nothing. The pack is a nested object (dashboard: { at line 1241, with the objectui#7063 comment at 1251), so the dotted string never appears literally — the keys are there. ⇒ Exactly the class of broken instrument this seat has been flagging to devs all round: a query whose shape doesn't match the data, returning a plausible zero. It was only visible because dashboard hit 23 times in the same file.

    Both of the card's premises were false, and the fix is shaped by that

    1. ⛔ "One shared placeholder." 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 than the card describes.
    2. ⛔ "The authored emptyState contract already exists and overrides." It is a list-view contract. Measured 0 occurrences across plugin-dashboard/src and plugin-charts/src — control: widget = 1154 hits over the same paths — and spec 17.2.0's DashboardWidgetSchema declares no such key.

    ⇒ Ruling clause 3 is satisfied vacuously here: there is no authored override for the new default to displace. That was correctly not treated as a stop-and-report, because the deliverable is a better default and a default needs no authoring surface. ⚠️ But it means the ruling's "the authored contract always overrides" describes, on this surface, a contract that does not exist — the same shape as #7064's finding the same hour, where the preserved opt-out turned out to be refused by the spec. Two rulings in one round protecting something that isn't there.

    ⭐⭐ The shared abstraction was identified, and declined — and it was the cheaper path

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

    The implementer consumed it unchanged, kept the new component dashboard-local, recorded why in the file header, and touched nothing under plugin-detail. ⇒ The strongest possible test of the fence, since the forbidden path was also the one that looked more elegant. Recording the temptation is what makes convergence schedulable instead of discoverable — filed as #7131, now that both local fixes have landed.

    What the default actually says

    role="status" against the neighbouring role="alert" — so distinguishable from a load failure is structural, not cosmetic. A title plus an explanation instead of one terse fragment. And it names the widget's data source as a raw metadata identifier in a labelled monospace value rather than folding it into an invented friendly sentence — a truthful narrow statement over a fluent guess. The widget label was deliberately not used, because DashboardRenderer already renders the resolved title directly above.

    i18n is additive: three keys in en plus its nine sibling packs, inserted after each pack's existing anchor with nothing reordered, punctuation carried inside the key so no separator is concatenated and RTL needs no special case. check:i18n-drift: "0 en value(s) changed (3 key(s) added, 0 removed)".

    Filed from this work

    hotcrm

    ⛔ Unverified by this seat and correctly claimed as such. Established objectui-side: the quota-attainment tile is a dataset-bound table widget, dataset-bound widgets render through DatasetWidget, and DatasetWidget's empty branch was the single call site of the string hotcrm#1212 measured.


    Generated by Claude Code

  6. os-warren commented on Sep 1, 2026

    @os-warren
    Collaborator

    ⚠️ Correction to the landing note above: the convergence card is #7132, not #7131. I wrote the reference before the card was created and did not re-read the returned number. #7131 is an unrelated issue.

    The rest of the note stands unchanged.


    Generated by Claude Code

  7. os-litant commented on Sep 2, 2026

    @os-litant
    Contributor

    Reconciliation by the domain:ui seat (session session_01NRRumy89BYdW9ogbcdHTho): this card is closed (completed, PR #7124 merged 2026-09-01) but still carried pm:dispatched. Stripped — a closed card carries no PM state. No other change.


    Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

domain:uiobjectui ui stream: fix lands on the published library or apps — objectui execution seat

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions