diff --git a/.changeset/7509-dashboard-root-title-retired.md b/.changeset/7509-dashboard-root-title-retired.md
new file mode 100644
index 0000000000..93994209bf
--- /dev/null
+++ b/.changeset/7509-dashboard-root-title-retired.md
@@ -0,0 +1,43 @@
+---
+'@object-ui/app-shell': minor
+'@object-ui/plugin-dashboard': minor
+'@object-ui/plugin-designer': minor
+---
+
+Retire the dashboard-**root** `title` read across all five surfaces (objectui#7509,
+maintainer ruling 2026-09-04, decision batch #29, option C, under ADR-0049).
+
+**What changes for an operator.** A stored dashboard whose header came from a legacy
+root `title` now shows its `label`. `label` is the only header source, then the raw
+`name`.
+
+Per surface:
+
+- Console dashboard page (`DashboardView`) — header falls to `label`, then `name`.
+- Standalone dashboard embed (`DashboardRenderer`) — `header` shows `label`; a document
+ with no `label` now shows no header title at all.
+- The `dashboard-grid` SDUI component (`DashboardGridLayout`) — heading falls to
+ `label`, then the generic `Dashboard`.
+- Studio dashboard designer (`DashboardEditor` preview panel, `DashboardDesignPage`
+ heading) — both fall to `label`, then `name` / the generic heading.
+
+**Why now.** `@objectstack/spec`'s `DashboardSchema` refuses a root `title` **by name**
+(`unrecognized_keys(title)`), and the save route answers `422 INVALID_METADATA` — so no
+authored dashboard can acquire the key, and what retires is compatibility with documents
+stored before that refusal existed. Until now five surfaces read the legacy spelling
+independently, which meant a legacy document could show one header in the console and a
+different one in the designer. One spelling now answers everywhere.
+
+**Migration.** `label` is REQUIRED on `DashboardSchema`, so a spec-valid stored dashboard
+already carries it and needs no change — it simply starts showing that `label` instead of
+the legacy `title`. A document carrying `title` and no `label` was already invalid; give
+it a `label`. No in-repo document needed migrating: a sweep of all 627 tracked JSON found
+9 dashboard-shaped nodes, and the 6 carrying a root `title` are `type: 'dashboard'`
+component examples that declare no `header`, so none of them rendered a header title
+either before or after.
+
+**Not affected: widget titles.** `DashboardWidget.title` is a different, spec-**declared**
+key (the spec's `I18nLabel`) on a different receiver, and is untouched — widget headings,
+the designer's widget-title input and its per-locale write path all behave exactly as
+before. Root and widget arms were separated by receiver, and the retirement's pins carry
+widget-level controls on every surface for that reason.
diff --git a/packages/app-shell/src/views/DashboardView.rootTitleRetired.test.tsx b/packages/app-shell/src/views/DashboardView.rootTitleRetired.test.tsx
new file mode 100644
index 0000000000..64cf55bee4
--- /dev/null
+++ b/packages/app-shell/src/views/DashboardView.rootTitleRetired.test.tsx
@@ -0,0 +1,171 @@
+/**
+ * ObjectUI
+ * Copyright (c) 2024-present ObjectStack Inc.
+ *
+ * This source code is licensed under the MIT license found in the
+ * LICENSE file in the root directory of this source tree.
+ */
+
+/**
+ * Retirement pin — the dashboard-ROOT `title` read arm (objectui#7509).
+ *
+ * Maintainer ruling 2026-09-04 (decision batch #29, option C): the five root
+ * `title` read arms retire together under ADR-0049, and `label` — REQUIRED on
+ * `@objectstack/spec`'s `DashboardSchema` — becomes the only header source,
+ * then the raw `name`. This file pins THIS view's arm; the four siblings carry
+ * their own, in the same shape.
+ *
+ * Shaped like the #5830 / #5852 retirements: the assertion is what a document
+ * carrying the retired key RENDERS, not that the code still compiles. A
+ * compile-only pin would have passed with the arm still in place.
+ *
+ * Why the retired key can still arrive at all: the spec refuses root `title` BY
+ * NAME (`unrecognized_keys(title)` at the document root), so the save route
+ * answers `422 INVALID_METADATA` and no AUTHORED document can acquire it. What
+ * retired is compatibility with documents STORED before the refusal existed —
+ * a renderer cannot refuse to receive stored metadata, so it is pinned rather
+ * than assumed away.
+ *
+ * ⛔ Widget-level `widget.title` is a DIFFERENT, DECLARED key
+ * (`DashboardWidget.title`, the spec's `I18nLabel`) and is NOT retired. The
+ * last case is the negative control for exactly that: root and widget arms are
+ * told apart by RECEIVER, never by grep, and a sweep that confused them would
+ * delete live contract-declared behaviour.
+ */
+
+import * as React from 'react';
+import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
+import { render, screen, waitFor, cleanup } from '@testing-library/react';
+import { MetadataCtx } from '@object-ui/react';
+
+// The renderer is stubbed: the header
under test is rendered by the VIEW,
+// and capturing the props also proves the widgets (with their own `title`)
+// reach the renderer untouched.
+const cap = vi.hoisted(() => ({ props: null as any }));
+vi.mock('@object-ui/plugin-dashboard', () => ({
+ DashboardRenderer: (props: any) => {
+ cap.props = props;
+ return null;
+ },
+}));
+
+const meta = vi.hoisted(() => ({ value: null as any }));
+vi.mock('../providers/MetadataProvider', () => ({ useMetadata: () => meta.value }));
+
+vi.mock('react-router-dom', () => ({
+ useParams: () => ({ dashboardName: 'sales_overview' }),
+ useNavigate: () => vi.fn(),
+ useLocation: () => ({ pathname: '/dashboards/sales_overview', search: '' }),
+}));
+
+vi.mock('./useOpenRecordList', () => ({ useOpenRecordList: () => vi.fn() }));
+vi.mock('./MetadataInspector', () => ({
+ MetadataPanel: () => null,
+ useMetadataInspector: () => ({ showDebug: false }),
+}));
+vi.mock('../providers/AdapterProvider', () => ({ useAdapter: () => ({}) }));
+vi.mock('../providers/ExpressionProvider', () => ({ useExpressionContext: () => ({ app: undefined }) }));
+vi.mock('@object-ui/i18n', () => ({
+ useObjectTranslation: () => ({ t: (k: string) => k }),
+ // Pass-through: the i18n bundle is a SEPARATE channel with its own tests, and
+ // resolving through it here would let a bundle entry answer for the key this
+ // file is measuring.
+ useObjectLabel: () => ({
+ dashboardLabel: ({ label, name }: any) => label ?? name,
+ dashboardDescription: ({ description }: any) => description,
+ }),
+ createSafeTranslation: (defaults: Record) => () => ({
+ t: (k: string) => defaults?.[k] ?? k,
+ }),
+}));
+
+import { DashboardView } from './DashboardView';
+
+const LEGACY_TITLE = 'Legacy Title From A Stored Document';
+const CANONICAL_LABEL = 'Sales Overview';
+
+/** Mount the view over exactly one stored dashboard document. */
+async function mountWith(dashboard: Record) {
+ meta.value = {
+ apps: [],
+ objects: [],
+ dashboards: [dashboard],
+ reports: [],
+ pages: [],
+ loading: false,
+ error: null,
+ refresh: async () => {},
+ invalidate: () => {},
+ ensureType: async () => [],
+ getItem: vi.fn(async () => null),
+ getItemsByType: () => [],
+ getTypeStatus: () => 'ready',
+ };
+
+ const { container } = render(
+
+
+ ,
+ );
+
+ // The view renders a skeleton first; the header only exists once loading ends.
+ await waitFor(() => expect(container.querySelector('h1')).not.toBeNull());
+ return container.querySelector('h1')!;
+}
+
+beforeEach(() => {
+ cap.props = null;
+ vi.clearAllMocks();
+});
+afterEach(cleanup);
+
+describe('DashboardView — the root `title` read arm is retired (objectui#7509)', () => {
+ it('renders the `label` header for a document carrying BOTH, and never the `title`', async () => {
+ // The ruling's stated, VISIBLE change: a legacy document that also carries
+ // the required `label` now shows the `label`.
+ const h1 = await mountWith({
+ name: 'sales_overview',
+ label: CANONICAL_LABEL,
+ title: LEGACY_TITLE,
+ widgets: [],
+ });
+
+ expect(h1.textContent).toBe(CANONICAL_LABEL);
+ expect(screen.queryByText(LEGACY_TITLE)).toBeNull();
+ });
+
+ it('falls through to the raw `name` for a document carrying ONLY the retired key', async () => {
+ // `label` is REQUIRED on DashboardSchema, so this document was already
+ // invalid; it is pinned because a renderer cannot refuse stored metadata,
+ // and because it is where the retirement is actually felt.
+ const h1 = await mountWith({ name: 'sales_overview', title: LEGACY_TITLE, widgets: [] });
+
+ expect(h1.textContent).toBe('sales_overview');
+ expect(screen.queryByText(LEGACY_TITLE)).toBeNull();
+ });
+
+ it('CONTROL — a document with only `label` renders it, so the two assertions above are not vacuous', async () => {
+ // Without this, "the title is absent" would also be satisfied by a header
+ // that renders nothing at all, and both cases above would pass for the
+ // wrong reason.
+ const h1 = await mountWith({ name: 'sales_overview', label: CANONICAL_LABEL, widgets: [] });
+
+ expect(h1.textContent).toBe(CANONICAL_LABEL);
+ });
+
+ it('CONTROL — widget-level `title` is a different DECLARED key and reaches the renderer intact', async () => {
+ // `DashboardWidget.title` is the spec's `I18nLabel`. A grep-driven sweep
+ // over these files would have taken it too; this is the receiver-level
+ // proof that it survived.
+ await mountWith({
+ name: 'sales_overview',
+ label: CANONICAL_LABEL,
+ title: LEGACY_TITLE,
+ widgets: [{ id: 'w1', type: 'metric', title: 'Revenue' }],
+ });
+
+ await waitFor(() => expect(cap.props).not.toBeNull());
+ expect(cap.props.schema.widgets).toHaveLength(1);
+ expect(cap.props.schema.widgets[0].title).toBe('Revenue');
+ });
+});
diff --git a/packages/app-shell/src/views/DashboardView.tsx b/packages/app-shell/src/views/DashboardView.tsx
index 1d57d32ba3..2e95d2a611 100644
--- a/packages/app-shell/src/views/DashboardView.tsx
+++ b/packages/app-shell/src/views/DashboardView.tsx
@@ -170,28 +170,36 @@ export function DashboardView({ dataSource }: { dataSource?: any }) {
{(() => {
- // `title` is NOT a spec key — it is the LEGACY objectui spelling,
- // read here only so a stored dashboard document that predates
- // `label` still gets a header. Measured on @objectstack/spec
- // 17.2.0: `DashboardSchema` refuses `title` BY NAME
- // (`unrecognized_keys(title)` at the document root) and spells the
- // display name `label`; `header` declares `showTitle` /
- // `showDescription` / `actions` only, so it TOGGLES a title and
- // never carries one. So authored dashboard metadata must use
- // `label`: writing `title` earns a `422 INVALID_METADATA` /
- // `unrecognized_keys` from the save route before persistence
- // (see `MetadataService`), not a header.
+ // Header source: `label`, then the raw `name`. There is no `title`
+ // arm — the legacy root `title` read RETIRED here under ADR-0049
+ // (objectui#7509, maintainer ruling 2026-09-04), together with the
+ // four sibling arms in `DashboardRenderer`, `DashboardGridLayout`,
+ // `DashboardEditor` and `DashboardDesignPage`, so one spelling
+ // answers on every surface instead of two disagreeing.
//
- // `previewSchema` is NOT a host-supplied preview channel — it is
- // this view's own widget-pruned copy of `dashboard` (above), so
- // either arm of `headerSrc` reads the same stored document.
- // `DashboardRenderer` reads the same legacy-then-canonical pair.
- // Order: legacy `title`, then `label`, then the raw `name`.
- const headerSrc = (previewSchema as any) || dashboard;
- const resolvedTitle = resolveKeyedI18nLabel(headerSrc.title, t);
+ // Measured on @objectstack/spec 17.2.0: `DashboardSchema` refuses
+ // `title` BY NAME (`unrecognized_keys(title)` at the document root)
+ // and spells the display name `label`, which is REQUIRED; `header`
+ // declares `showTitle` / `showDescription` / `actions` only, so it
+ // TOGGLES a title and never carries one. Writing `title` earns a
+ // `422 INVALID_METADATA` from the save route before persistence
+ // (see `MetadataService`), not a header — so no authored document
+ // can acquire the key, and what retired is legacy-document
+ // compatibility only. A spec-valid stored document always carries
+ // `label`, so a legacy document holding BOTH now shows its `label`;
+ // one holding `title` and no `label` was already invalid and falls
+ // through to `name`.
+ //
+ // ⛔ Widget-level `widget.title` is a DIFFERENT, DECLARED key
+ // (`DashboardWidget.title`, the spec's `I18nLabel`) and is
+ // untouched. Root and widget arms are told apart by RECEIVER.
+ //
+ // `previewSchema` was never a host-supplied preview channel — it is
+ // this view's own widget-pruned copy of `dashboard` (above) — so
+ // the retired arm read the same stored document either way, which
+ // is why it is gone rather than re-pointed.
const resolvedLabel = resolveKeyedI18nLabel(dashboard.label, t);
- const fallbackLabel = dashboardLabel({ name: dashboard.name, label: resolvedLabel });
- const display = resolvedTitle || fallbackLabel || dashboard.name;
+ const display = dashboardLabel({ name: dashboard.name, label: resolvedLabel }) || dashboard.name;
return (
{display}
);
diff --git a/packages/plugin-dashboard/src/DashboardGridLayout.tsx b/packages/plugin-dashboard/src/DashboardGridLayout.tsx
index 28a3a8670a..f8c660e419 100644
--- a/packages/plugin-dashboard/src/DashboardGridLayout.tsx
+++ b/packages/plugin-dashboard/src/DashboardGridLayout.tsx
@@ -386,12 +386,25 @@ export const DashboardGridLayout: React.FC
= ({
component: `pickLocalized` is objectui's limb-for-limb twin of the spec's
`resolveI18nLabel` (objectstack#6765), differing only in how it spells a
miss (`''` vs `undefined`) — pinned in
- `plugin-list/src/__tests__/i18nLabel-resolver-parity.test.ts`. The `||`
- chain is preserved exactly: a miss yields `''`, which is falsy, so
- `'Dashboard'` still backstops it.
+ `plugin-list/src/__tests__/i18nLabel-resolver-parity.test.ts`. A miss
+ yields `''`, which is falsy, so `'Dashboard'` still backstops it.
+
+ `schema.label` is the ONLY header source. A legacy root `title` used
+ to be read ahead of it; that arm RETIRED under ADR-0049
+ (objectui#7509, maintainer ruling 2026-09-04) together with the four
+ sibling root arms in `DashboardView`, `DashboardRenderer`,
+ `DashboardEditor` and `DashboardDesignPage` — @objectstack/spec's
+ `DashboardSchema` refuses root `title` BY NAME
+ (`unrecognized_keys(title)`) and requires `label`, so what retired is
+ legacy-document compatibility, not an authoring surface.
+
+ ⛔ NOT the widget arm: `widget.title` is `DashboardWidget.title`, the
+ spec's `I18nLabel` — a different DECLARED key, read ~100 lines below
+ and untouched. The two are told apart by RECEIVER; this one's receiver
+ is the dashboard ROOT.
*/}
- {schema.title || pickLocalized(schema.label, language) || 'Dashboard'}
+ {pickLocalized(schema.label, language) || 'Dashboard'}
{editMode ? (
diff --git a/packages/plugin-dashboard/src/DashboardRenderer.tsx b/packages/plugin-dashboard/src/DashboardRenderer.tsx
index 7d69acca18..c3c855cf9a 100644
--- a/packages/plugin-dashboard/src/DashboardRenderer.tsx
+++ b/packages/plugin-dashboard/src/DashboardRenderer.tsx
@@ -940,11 +940,24 @@ const DashboardRendererInner = forwardRef
{renderedNode};
};
- // The spec-canonical dashboard display name is `label` (@objectstack/spec
- // DashboardSchema); `title` is the legacy objectui spelling. Read both so
- // spec-compliant dashboards get their header title (framework#1878/#1891;
- // mirrors the DashboardGridLayout fallback from #2666).
- const headerTitle = schema.title || schema.label;
+ // The dashboard display name is `label` (@objectstack/spec
+ // `DashboardSchema`, where it is REQUIRED) and nothing else. The legacy
+ // objectui `title` spelling used to be read first here; that arm RETIRED
+ // under ADR-0049 (objectui#7509, maintainer ruling 2026-09-04) together
+ // with the four sibling root arms in `DashboardView`,
+ // `DashboardGridLayout`, `DashboardEditor` and `DashboardDesignPage`, so
+ // the same stored document can no longer show one header in the console and
+ // a different one in the designer (framework#1878/#1891 record where the
+ // legacy spelling came from; #2666 is the fallback this mirrored).
+ //
+ // The spec refuses root `title` BY NAME (`unrecognized_keys(title)`), so no
+ // authored document can acquire the key and only legacy-document
+ // compatibility retires here.
+ //
+ // ⛔ NOT the widget arm: `widget.title` is `DashboardWidget.title`, the
+ // spec's `I18nLabel`, a different DECLARED key that stays. The two are told
+ // apart by RECEIVER — this one's receiver is the dashboard ROOT.
+ const headerTitle = schema.label;
/**
* Decide what the header would actually SHOW before deciding whether to
* render its wrapper at all.
diff --git a/packages/plugin-dashboard/src/__tests__/DashboardGridLayout.inlineLocaleLabel.test.tsx b/packages/plugin-dashboard/src/__tests__/DashboardGridLayout.inlineLocaleLabel.test.tsx
index 393fe2da1d..c8606737c2 100644
--- a/packages/plugin-dashboard/src/__tests__/DashboardGridLayout.inlineLocaleLabel.test.tsx
+++ b/packages/plugin-dashboard/src/__tests__/DashboardGridLayout.inlineLocaleLabel.test.tsx
@@ -85,15 +85,22 @@ describe('DashboardGridLayout heading — inline locale map label (objectui#4580
});
/**
- * The `||` chain around the label is preserved exactly, in both directions.
- * A resolver miss yields `''` (falsy), so the `'Dashboard'` backstop still
- * fires — if the resolution had been spelled with the spec resolver's
- * `undefined` miss it would behave the same here, but a `?? ''` written in the
- * wrong place would have swallowed the backstop.
+ * INVERTED by objectui#7509 (maintainer ruling 2026-09-04, decision batch
+ * #29). This case used to read "keeps `title` ahead of `label` in the
+ * precedence chain" and asserted `Pipeline`. The dashboard-root `title` read
+ * arm retired under ADR-0049 across all five surfaces, so `label` is now the
+ * only header source — and the case is inverted rather than deleted, because
+ * a legacy `title` sitting in front of the map is precisely what used to stop
+ * this file's subject (the resolver) from running at all.
+ *
+ * That makes this the strongest non-vacuity control in the file: before the
+ * retirement, a document carrying both NEVER exercised `pickLocalized`.
*/
- it('keeps `title` ahead of `label` in the precedence chain', () => {
+ it('resolves the `label` map even when a legacy root `title` is also present', () => {
renderGrid({ title: 'Pipeline', label: INLINE_MAP }, 'zh-CN');
- expect(screen.getByRole('heading', { level: 2 })).toHaveTextContent('Pipeline');
+ const heading = screen.getByRole('heading', { level: 2 });
+ expect(heading).toHaveTextContent('负责人');
+ expect(heading).not.toHaveTextContent('Pipeline');
});
it("falls back to 'Dashboard' when the map resolves to nothing", () => {
diff --git a/packages/plugin-dashboard/src/__tests__/DashboardGridLayout.rootTitleRetired.test.tsx b/packages/plugin-dashboard/src/__tests__/DashboardGridLayout.rootTitleRetired.test.tsx
new file mode 100644
index 0000000000..14e46db372
--- /dev/null
+++ b/packages/plugin-dashboard/src/__tests__/DashboardGridLayout.rootTitleRetired.test.tsx
@@ -0,0 +1,112 @@
+/**
+ * ObjectUI
+ * Copyright (c) 2024-present ObjectStack Inc.
+ *
+ * This source code is licensed under the MIT license found in the
+ * LICENSE file in the root directory of this source tree.
+ */
+
+/**
+ * Retirement pin — the dashboard-ROOT `title` read arm (objectui#7509).
+ *
+ * Maintainer ruling 2026-09-04 (decision batch #29, option C): the five root
+ * `title` read arms retire together under ADR-0049, `label` is the only header
+ * source. This file pins THIS surface's arm — the `` that used to read
+ * `schema.title || pickLocalized(schema.label, language) || 'Dashboard'`.
+ *
+ * This surface is the reason the ruling refused option B (retire the console's
+ * arm alone): `dashboard-grid` is separately registered as an SDUI component,
+ * so leaving its arm would have shown ONE stored document under two different
+ * headers depending on which surface opened it.
+ *
+ * Shaped like the #5830 / #5852 retirements — what a document carrying the
+ * retired key RENDERS, not that it compiles.
+ *
+ * ⛔ Four of this file's five `.title` occurrences are widget-level
+ * (`DashboardWidget.title`, the spec's `I18nLabel`) and are NOT retired; the
+ * last two cases are their control.
+ */
+
+import * as React from 'react';
+import { describe, it, expect, vi, afterEach } from 'vitest';
+import { render, screen, cleanup } from '@testing-library/react';
+import type { DashboardComponentSchema } from '@object-ui/types';
+import { DashboardGridLayout } from '../DashboardGridLayout';
+
+// The grid renders each widget through `SchemaRenderer`; this suite is about
+// the chrome around it, so the inner renderer is stubbed (same treatment as
+// `DashboardGridLayout.i18nTitle.test.tsx`).
+vi.mock('@object-ui/react', async (importOriginal) => {
+ const actual = await importOriginal();
+ return {
+ ...actual,
+ SchemaRenderer: () => ,
+ };
+});
+
+afterEach(cleanup);
+
+const LEGACY_TITLE = 'Legacy Title From A Stored Document';
+const CANONICAL_LABEL = 'Sales Overview';
+
+const dash = (root: Record): DashboardComponentSchema =>
+ ({ type: 'dashboard', name: 'sales', widgets: [], ...root }) as unknown as DashboardComponentSchema;
+
+/** The dashboard heading — the widget cards use `CardTitle`, never `h2`. */
+const heading = (container: HTMLElement) => container.querySelector('h2');
+
+describe('DashboardGridLayout — the root `title` read arm is retired (objectui#7509)', () => {
+ it('renders the `label` header for a document carrying BOTH, and never the `title`', () => {
+ const { container } = render(
+ ,
+ );
+
+ expect(heading(container)!.textContent).toBe(CANONICAL_LABEL);
+ expect(screen.queryByText(LEGACY_TITLE)).toBeNull();
+ });
+
+ it('falls through to the generic heading for a document carrying ONLY the retired key', () => {
+ // This surface has no `name` arm — its last resort is the literal
+ // `'Dashboard'`, and the retirement does not change that backstop.
+ const { container } = render();
+
+ expect(heading(container)!.textContent).toBe('Dashboard');
+ expect(screen.queryByText(LEGACY_TITLE)).toBeNull();
+ });
+
+ it('CONTROL — a document with only `label` renders it, so the two assertions above are not vacuous', () => {
+ const { container } = render();
+
+ expect(heading(container)!.textContent).toBe(CANONICAL_LABEL);
+ });
+
+ it('CONTROL — an inline per-locale `label` still resolves through `pickLocalized`', () => {
+ // The resolver the retired arm used to short-circuit whenever a legacy
+ // `title` was present: with `title` read first, a document carrying both
+ // never exercised `pickLocalized` at all (objectui#4580).
+ const { container } = render(
+ ,
+ );
+
+ expect(heading(container)!.textContent).toBe('Pipeline');
+ expect(container.innerHTML).not.toContain('[object Object]');
+ });
+
+ it('CONTROL — widget-level `title` is a different DECLARED key and still renders', () => {
+ render(
+ ,
+ );
+
+ const widgetHeading = screen.getByText('Revenue');
+ expect(widgetHeading).toBeTruthy();
+ expect(widgetHeading.getAttribute('title')).toBe('Revenue');
+ });
+});
diff --git a/packages/plugin-dashboard/src/__tests__/DashboardRenderer.headerActions.test.tsx b/packages/plugin-dashboard/src/__tests__/DashboardRenderer.headerActions.test.tsx
index a79ff99f18..9f345c9926 100644
--- a/packages/plugin-dashboard/src/__tests__/DashboardRenderer.headerActions.test.tsx
+++ b/packages/plugin-dashboard/src/__tests__/DashboardRenderer.headerActions.test.tsx
@@ -39,7 +39,11 @@ afterEach(cleanup);
function dashboardWith(actionType: string): DashboardComponentSchema {
return {
type: 'dashboard',
- title: 'Ops',
+ // `label`, not the retired root `title` (objectui#7509) — the header's name
+ // source. Nothing here asserts it; renamed so this file's fixtures spell
+ // the one live key, alongside `textHeaderDashboard` below whose assertions
+ // DO read it.
+ label: 'Ops',
widgets: [],
header: {
actions: [{ label: 'Convert Lead', actionUrl: 'convert_lead_wizard', actionType }],
@@ -91,7 +95,12 @@ function textHeaderDashboard(
): DashboardComponentSchema {
return {
type: 'dashboard',
- title: 'Executive Dashboard',
+ // `label` is the header's ONE name source since the root `title` arm
+ // retired (objectui#7509, ADR-0049). This fixture pins the header WRAPPER's
+ // geometry, not the spelling of the name — so the key moves and every
+ // assertion below stands unchanged, which is what makes it a rename rather
+ // than a rewrite.
+ label: 'Executive Dashboard',
description: 'Pipeline and revenue at a glance',
widgets: [],
header,
diff --git a/packages/plugin-dashboard/src/__tests__/DashboardRenderer.rootTitleRetired.test.tsx b/packages/plugin-dashboard/src/__tests__/DashboardRenderer.rootTitleRetired.test.tsx
new file mode 100644
index 0000000000..629d5964ed
--- /dev/null
+++ b/packages/plugin-dashboard/src/__tests__/DashboardRenderer.rootTitleRetired.test.tsx
@@ -0,0 +1,98 @@
+/**
+ * ObjectUI
+ * Copyright (c) 2024-present ObjectStack Inc.
+ *
+ * This source code is licensed under the MIT license found in the
+ * LICENSE file in the root directory of this source tree.
+ */
+
+/**
+ * Retirement pin — the dashboard-ROOT `title` read arm (objectui#7509).
+ *
+ * Maintainer ruling 2026-09-04 (decision batch #29, option C): the five root
+ * `title` read arms retire together under ADR-0049. `label` — REQUIRED on
+ * `@objectstack/spec`'s `DashboardSchema` — is the only header source. This
+ * file pins THIS surface's arm (`const headerTitle = schema.label`); the four
+ * siblings carry their own.
+ *
+ * Shaped like the #5830 / #5852 retirements: the assertion is what a document
+ * carrying the retired key RENDERS, not that the code compiles. Only ONE of
+ * this file's five `.title` occurrences was the retired arm — the rest are
+ * widget-level, and the last case here is their control.
+ *
+ * Why this surface is the load-bearing one: `DashboardView` renders its own
+ * header and passes `hideHeaderText`, so a standalone embed is where a stored
+ * document's root name actually reaches the DOM through this component. The
+ * header wrapper is gated on `header` being DECLARED (objectui#5812), so every
+ * fixture below declares it — a fixture without `header` would assert the
+ * absence of a title that the wrapper gate had already removed, and would pass
+ * with the retired arm fully intact.
+ */
+
+import * as React from 'react';
+import { describe, it, expect, afterEach } from 'vitest';
+import { render, screen, cleanup } from '@testing-library/react';
+import type { DashboardComponentSchema } from '@object-ui/types';
+// From the barrel, so the ComponentRegistry is populated for the widget control.
+import { DashboardRenderer } from '../index';
+
+afterEach(cleanup);
+
+const LEGACY_TITLE = 'Legacy Title From A Stored Document';
+const CANONICAL_LABEL = 'Executive Overview';
+
+const dash = (root: Record): DashboardComponentSchema =>
+ ({
+ type: 'dashboard',
+ widgets: [],
+ // Declared so the header wrapper exists at all; `showTitle` defaults on.
+ header: { showTitle: true },
+ ...root,
+ }) as unknown as DashboardComponentSchema;
+
+describe('DashboardRenderer — the root `title` read arm is retired (objectui#7509)', () => {
+ it('renders the `label` header for a document carrying BOTH, and never the `title`', () => {
+ render();
+
+ expect(screen.getByText(CANONICAL_LABEL)).toBeTruthy();
+ expect(screen.queryByText(LEGACY_TITLE)).toBeNull();
+ });
+
+ it('renders NO header text for a document carrying ONLY the retired key', () => {
+ // `label` is REQUIRED on DashboardSchema, so such a document was already
+ // invalid. It is pinned anyway: a renderer cannot refuse to receive stored
+ // metadata, and this is where the retirement is actually felt.
+ const { container } = render();
+
+ expect(screen.queryByText(LEGACY_TITLE)).toBeNull();
+ expect(container.querySelector('h2')).toBeNull();
+ });
+
+ it('CONTROL — a document with only `label` renders it, so the two assertions above are not vacuous', () => {
+ // Without this, "the legacy title is absent" would also be satisfied by a
+ // renderer that draws no header at all under any input.
+ render();
+
+ const heading = screen.getByText(CANONICAL_LABEL);
+ expect(heading).toBeTruthy();
+ expect(heading.tagName).toBe('H2');
+ });
+
+ it('CONTROL — widget-level `title` is a different DECLARED key and still renders', () => {
+ // `DashboardWidget.title` is the spec's `I18nLabel`; the ruling keeps it.
+ // Four of this file's five `.title` occurrences are on this receiver, so a
+ // grep-driven sweep would have deleted live contract-declared behaviour.
+ render(
+ ,
+ );
+
+ expect(screen.getByText('Revenue')).toBeTruthy();
+ expect(screen.queryByText(LEGACY_TITLE)).toBeNull();
+ });
+});
diff --git a/packages/plugin-dashboard/src/__tests__/dashboardAuthoredInputs.test.tsx b/packages/plugin-dashboard/src/__tests__/dashboardAuthoredInputs.test.tsx
index 06318c0a8a..bd77df2dcb 100644
--- a/packages/plugin-dashboard/src/__tests__/dashboardAuthoredInputs.test.tsx
+++ b/packages/plugin-dashboard/src/__tests__/dashboardAuthoredInputs.test.tsx
@@ -30,9 +30,21 @@
*
* - `title` — the legacy objectui spelling of the spec-canonical `label`
* (framework#1878). The spec REJECTS it by name, so declaring it would
- * publish a key an author could not save. The `schema.title ||
+ * publish a key an author could not save.
+ *
+ * ⚠️ SUPERSEDED IN PART (objectui#7509, maintainer ruling 2026-09-04,
+ * decision batch #29). This file used to add "and the `schema.title ||
* schema.label` read STAYS — documents in the wild carry it — which is
- * exactly the #5091 shape: non-author surface, still read.
+ * exactly the #5091 shape: non-author surface, still read." That read is
+ * GONE: the five dashboard-root `title` read arms retired together under
+ * ADR-0049, and `label` is now the only header source. What is unchanged
+ * is this file's own subject — `title` stays OUT of the published `inputs`
+ * — and its reason only got stronger: the key is now neither authorable
+ * nor read. The retirement's own pins live in
+ * `DashboardRenderer.rootTitleRetired.test.tsx` and its four siblings; the
+ * block at the bottom of this file was rewritten to match, rather than
+ * deleted, because "the renderer's behaviour on the key" is a claim this
+ * file makes and must therefore keep making — correctly.
* - `aria` — the spec carries a TOMBSTONE for `dashboard.aria` (removed at
* the #3896 audit close-out, "no dashboard renderer ever applied it").
* Measured here too: this package has NO read site for `schema.aria`, so
@@ -269,7 +281,19 @@ describe('the two ruled-out keys stay unpublished — and checkably so (objectui
});
});
-describe('the legacy `title` read stays — non-author surface, still honoured (objectui#5742)', () => {
+/**
+ * REWRITTEN by objectui#7509 (maintainer ruling 2026-09-04, decision batch
+ * #29). This block used to be headed "the legacy `title` read stays —
+ * non-author surface, still honoured (objectui#5742)" and asserted that a wild
+ * document carrying only the legacy spelling KEPT its header title. That arm
+ * retired under ADR-0049, so the assertion is inverted rather than deleted: the
+ * claim "what this renderer does with a wild `title`" belongs to this file, and
+ * a file that simply dropped the case would leave the claim unmade.
+ *
+ * `title` stays out of the published `inputs` either way — that part of #5742
+ * is untouched, and is asserted by the `NON_AUTHOR` rows above.
+ */
+describe('the legacy `title` read is RETIRED — unpublished AND unread (objectui#7509)', () => {
const renderDashboard = (schema: Record) =>
render(
@@ -277,12 +301,18 @@ describe('the legacy `title` read stays — non-author surface, still honoured (
,
);
- it('a wild document carrying only the legacy spelling keeps its header title', () => {
+ it('a wild document carrying only the legacy spelling gets NO header title', () => {
renderDashboard({ title: 'Legacy Ops' });
- expect(screen.getByRole('heading', { name: 'Legacy Ops' })).toBeInTheDocument();
+ expect(screen.queryByRole('heading', { name: 'Legacy Ops' })).not.toBeInTheDocument();
+ });
+
+ it('a wild document carrying BOTH shows the `label` — the visible change the ruling names', () => {
+ renderDashboard({ title: 'Legacy Ops', label: 'Canonical Ops' });
+ expect(screen.getByRole('heading', { name: 'Canonical Ops' })).toBeInTheDocument();
+ expect(screen.queryByRole('heading', { name: 'Legacy Ops' })).not.toBeInTheDocument();
});
- it('the canonical spelling renders too — the read above is the fallback, not the contract', () => {
+ it('the canonical spelling renders — the control, without which the two above are vacuous', () => {
renderDashboard({ label: 'Canonical Ops' });
expect(screen.getByRole('heading', { name: 'Canonical Ops' })).toBeInTheDocument();
});
diff --git a/packages/plugin-designer/src/DashboardEditor.tsx b/packages/plugin-designer/src/DashboardEditor.tsx
index 8701911743..ae5de3c1da 100644
--- a/packages/plugin-designer/src/DashboardEditor.tsx
+++ b/packages/plugin-designer/src/DashboardEditor.tsx
@@ -464,7 +464,27 @@ function DashboardPreview({ schema }: { schema: DashboardComponentSchema }) {
const widgets = schema.widgets || [];
return (
-
{schema.title || t('appDesigner.dashboardPreview')}
+ {/*
+ `schema.label` is the ONLY dashboard-name source here. A legacy root
+ `title` used to be read instead; that arm RETIRED under ADR-0049
+ (objectui#7509, maintainer ruling 2026-09-04) together with the four
+ sibling root arms in `DashboardView`, `DashboardRenderer`,
+ `DashboardGridLayout` and `DashboardDesignPage`, so the designer and the
+ console can no longer disagree about one stored document's header.
+ @objectstack/spec's `DashboardSchema` refuses root `title` BY NAME
+ (`unrecognized_keys(title)`) and requires `label`.
+
+ Resolved with `pickLocalized` because `label` is the spec's `I18nLabel`
+ (objectui#4580) — the same resolver `resolveWidgetTitle` above uses, so
+ this component keeps ONE locale channel. A miss yields `''`, which is
+ falsy, so the generic heading still backstops it.
+
+ ⛔ NOT the widget arm: `widget.title` is `DashboardWidget.title`, a
+ different DECLARED key, read through `resolveWidgetTitle` and untouched.
+ Root and widget arms are told apart by RECEIVER — this receiver is the
+ dashboard ROOT.
+ */}
+
{pickLocalized(schema.label, language) || t('appDesigner.dashboardPreview')}
{widgets.length === 0 ? (
{t('appDesigner.noWidgetsPreview')}
) : (
diff --git a/packages/plugin-designer/src/__tests__/DashboardDesignPage.rootTitleRetired.test.tsx b/packages/plugin-designer/src/__tests__/DashboardDesignPage.rootTitleRetired.test.tsx
new file mode 100644
index 0000000000..86f5c5b209
--- /dev/null
+++ b/packages/plugin-designer/src/__tests__/DashboardDesignPage.rootTitleRetired.test.tsx
@@ -0,0 +1,129 @@
+/**
+ * ObjectUI
+ * Copyright (c) 2024-present ObjectStack Inc.
+ *
+ * This source code is licensed under the MIT license found in the
+ * LICENSE file in the root directory of this source tree.
+ */
+
+/**
+ * Retirement pin — the dashboard-ROOT `title` read arm (objectui#7509).
+ *
+ * Maintainer ruling 2026-09-04 (decision batch #29, option C): the five root
+ * `title` read arms retire together under ADR-0049, `label` is the only name
+ * source, then the raw `name`. This file pins THIS surface's arm — the page
+ * heading that used to read
+ * `(dashboard as any).label || (dashboard as any).title || dashboardName`.
+ *
+ * This page is one half of why the ruling refused option B: it is the DESIGNER
+ * side of the same stored document the console renders. Retiring the console's
+ * arm alone would have left one document titled here and untitled there.
+ *
+ * The second describe block pins the other edit this card makes on this file:
+ * the not-found seed literal — the only dashboard-document literal objectui
+ * authors — now spells `label` rather than the retired root `title`.
+ *
+ * Shaped like the #5830 / #5852 retirements: what a document carrying the
+ * retired key RENDERS, not that it compiles.
+ */
+
+import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
+import React from 'react';
+import { render, screen, cleanup } from '@testing-library/react';
+import type { DashboardComponentSchema } from '@object-ui/types';
+
+const update = vi.fn().mockResolvedValue(undefined);
+const dashboards: any[] = [];
+
+vi.mock('react-router-dom', () => ({
+ useParams: () => ({ dashboardName: 'sales' }),
+ useNavigate: () => vi.fn(),
+}));
+
+vi.mock('@object-ui/react', async (importOriginal) => {
+ const actual = await importOriginal
>();
+ return {
+ ...actual,
+ useAdapter: () => ({ update }),
+ useMetadata: () => ({ dashboards, refresh: () => Promise.resolve() }),
+ };
+});
+
+vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn() } }));
+
+import { DashboardDesignPage } from '../pages/DashboardDesignPage';
+
+const LEGACY_TITLE = 'Legacy Title From A Stored Document';
+const CANONICAL_LABEL = 'Sales Overview';
+
+const stored = (root: Record): DashboardComponentSchema =>
+ ({
+ type: 'dashboard',
+ name: 'sales',
+ columns: 2,
+ widgets: [{ id: 'w1', type: 'metric', title: 'Revenue' }],
+ ...root,
+ }) as unknown as DashboardComponentSchema;
+
+/** Load one stored document into the page and hand back its heading. */
+function headingFor(doc: DashboardComponentSchema) {
+ dashboards.length = 0;
+ dashboards.push(doc);
+ render();
+ return screen.getByRole('heading', { level: 1 });
+}
+
+beforeEach(() => {
+ update.mockClear();
+ vi.spyOn(console, 'warn').mockImplementation(() => {});
+});
+afterEach(() => {
+ cleanup();
+ vi.restoreAllMocks();
+});
+
+describe('DashboardDesignPage — the root `title` read arm is retired (objectui#7509)', () => {
+ it('heads the page with `label` for a document carrying BOTH, never with the `title`', () => {
+ const h1 = headingFor(stored({ label: CANONICAL_LABEL, title: LEGACY_TITLE }));
+
+ expect(h1.textContent).toContain(CANONICAL_LABEL);
+ expect(h1.textContent).not.toContain(LEGACY_TITLE);
+ expect(screen.queryByText(LEGACY_TITLE)).toBeNull();
+ });
+
+ it('falls through to the raw `name` for a document carrying ONLY the retired key', () => {
+ // `label` is REQUIRED on DashboardSchema, so this document was already
+ // invalid; pinned because the designer cannot refuse stored metadata.
+ const h1 = headingFor(stored({ title: LEGACY_TITLE }));
+
+ expect(h1.textContent).toContain('sales');
+ expect(h1.textContent).not.toContain(LEGACY_TITLE);
+ });
+
+ it('CONTROL — a document with only `label` heads the page with it, so the above is not vacuous', () => {
+ const h1 = headingFor(stored({ label: CANONICAL_LABEL }));
+
+ expect(h1.textContent).toContain(CANONICAL_LABEL);
+ });
+
+ it('CONTROL — widget-level `title` is a different DECLARED key and survives into the editor', () => {
+ headingFor(stored({ label: CANONICAL_LABEL, title: LEGACY_TITLE }));
+
+ expect(screen.getByTestId('dashboard-widget-w1').textContent).toContain('Revenue');
+ });
+});
+
+describe('DashboardDesignPage — the not-found seed spells `label`, not the retired key (objectui#7509)', () => {
+ it('renders the not-found state, and puts NO root `title` on screen', () => {
+ // The seed literal is reachable only on this branch, and this branch
+ // early-returns without persisting — which is why moving the key moves no
+ // behaviour. What is pinnable is that the branch still behaves, and that
+ // nothing here re-introduces the retired spelling into the DOM.
+ dashboards.length = 0;
+ const { container } = render();
+
+ expect(container.textContent).toContain('not found');
+ expect(container.querySelector('[data-testid="dashboard-design-page"]')).toBeNull();
+ expect(update).not.toHaveBeenCalled();
+ });
+});
diff --git a/packages/plugin-designer/src/__tests__/DashboardEditor.rootTitleRetired.test.tsx b/packages/plugin-designer/src/__tests__/DashboardEditor.rootTitleRetired.test.tsx
new file mode 100644
index 0000000000..79ffa7f8cf
--- /dev/null
+++ b/packages/plugin-designer/src/__tests__/DashboardEditor.rootTitleRetired.test.tsx
@@ -0,0 +1,117 @@
+/**
+ * ObjectUI
+ * Copyright (c) 2024-present ObjectStack Inc.
+ *
+ * This source code is licensed under the MIT license found in the
+ * LICENSE file in the root directory of this source tree.
+ */
+
+/**
+ * Retirement pin — the dashboard-ROOT `title` read arm (objectui#7509).
+ *
+ * Maintainer ruling 2026-09-04 (decision batch #29, option C): the five root
+ * `title` read arms retire together under ADR-0049, `label` is the only name
+ * source. This file pins THIS surface's arm — the preview panel's ``, which
+ * used to read `schema.title || t('appDesigner.dashboardPreview')` and now
+ * reads `pickLocalized(schema.label, language) || …`.
+ *
+ * ⛔ This file's subject has NINE `.title` occurrences and EIGHT of them are the
+ * widget-level `DashboardWidget.title` — the spec's `I18nLabel`, a different
+ * DECLARED key with its own display/authoring split (`resolveWidgetTitle` /
+ * `writeWidgetTitle`, objectui#4169 / #5301). Root and widget arms are told
+ * apart by RECEIVER, never by grep: the last two cases here are the control
+ * that the widget half survived intact, on BOTH its read and its write side.
+ *
+ * Shaped like the #5830 / #5852 retirements: what a document carrying the
+ * retired key RENDERS, not that it compiles.
+ */
+
+import * as React from 'react';
+import { describe, it, expect, vi, afterEach } from 'vitest';
+import { render, screen, fireEvent, cleanup, within } from '@testing-library/react';
+import type { DashboardComponentSchema } from '@object-ui/types';
+import { DashboardEditor } from '../DashboardEditor';
+
+vi.mock('@object-ui/plugin-grid', () => import('./__mocks__/plugin-grid'));
+vi.mock('@object-ui/plugin-form', () => import('./__mocks__/plugin-form'));
+
+afterEach(cleanup);
+
+const LEGACY_TITLE = 'Legacy Title From A Stored Document';
+const CANONICAL_LABEL = 'Sales Overview';
+/** The generic heading `useDesignerTranslation` resolves without a provider. */
+const GENERIC_HEADING = 'Dashboard Preview';
+
+const dash = (root: Record): DashboardComponentSchema =>
+ ({
+ type: 'dashboard',
+ name: 'sales',
+ widgets: [{ id: 'w1', type: 'metric', title: 'Revenue' }],
+ ...root,
+ }) as unknown as DashboardComponentSchema;
+
+/** Mount the editor and switch it into preview mode, where the panel lives. */
+function renderPreview(schema: DashboardComponentSchema) {
+ render( {}} />);
+ fireEvent.click(screen.getByTestId('dashboard-preview-toggle'));
+ return screen.getByTestId('dashboard-preview');
+}
+
+describe('DashboardEditor — the root `title` read arm is retired (objectui#7509)', () => {
+ it('heads the preview with `label` for a document carrying BOTH, never with the `title`', () => {
+ const panel = renderPreview(dash({ label: CANONICAL_LABEL, title: LEGACY_TITLE }));
+
+ expect(within(panel).getByRole('heading', { level: 4 }).textContent).toBe(CANONICAL_LABEL);
+ expect(screen.queryByText(LEGACY_TITLE)).toBeNull();
+ });
+
+ it('falls through to the generic heading for a document carrying ONLY the retired key', () => {
+ // `label` is REQUIRED on DashboardSchema, so this document was already
+ // invalid; it is pinned because a designer cannot refuse stored metadata.
+ const panel = renderPreview(dash({ title: LEGACY_TITLE }));
+
+ expect(within(panel).getByRole('heading', { level: 4 }).textContent).toBe(GENERIC_HEADING);
+ expect(screen.queryByText(LEGACY_TITLE)).toBeNull();
+ });
+
+ it('CONTROL — a document with only `label` heads the preview with it, so the above is not vacuous', () => {
+ const panel = renderPreview(dash({ label: CANONICAL_LABEL }));
+
+ expect(within(panel).getByRole('heading', { level: 4 }).textContent).toBe(CANONICAL_LABEL);
+ });
+
+ it('CONTROL — an inline per-locale `label` resolves, rather than stringifying', () => {
+ // `label` is the spec's `I18nLabel`, so the arm that replaced `title` must
+ // go through `pickLocalized` — the resolver this component already uses for
+ // widget titles, kept as ONE locale channel.
+ const panel = renderPreview(dash({ label: { en: 'Pipeline', 'zh-CN': '销售漏斗' } }));
+
+ expect(within(panel).getByRole('heading', { level: 4 }).textContent).toBe('Pipeline');
+ expect(panel.innerHTML).not.toContain('[object Object]');
+ });
+
+ it('CONTROL — widget-level `title` still DISPLAYS on the widget card', () => {
+ render( {}} />);
+
+ expect(screen.getByTestId('dashboard-widget-w1').textContent).toContain('Revenue');
+ });
+
+ it('CONTROL — widget-level `title` still AUTHORS through the inspector input', () => {
+ // The write half of the widget key (`writeWidgetTitle`). A sweep that took
+ // the widget arm with the root arm would have removed the only way to name
+ // a widget in the designer.
+ const onChange = vi.fn();
+ render();
+
+ fireEvent.click(screen.getByTestId('dashboard-widget-w1'));
+ const input = screen.getByTestId('widget-prop-title') as HTMLInputElement;
+ expect(input.value).toBe('Revenue');
+
+ fireEvent.change(input, { target: { value: 'Net Revenue' } });
+ expect(onChange).toHaveBeenCalled();
+ // Index arithmetic, not `.at(-1)`: this package's `lib` predates ES2022.
+ const calls = onChange.mock.calls;
+ const next = calls[calls.length - 1][0] as DashboardComponentSchema;
+ expect(next.widgets![0].title).toBe('Net Revenue');
+ });
+});
diff --git a/packages/plugin-designer/src/pages/DashboardDesignPage.tsx b/packages/plugin-designer/src/pages/DashboardDesignPage.tsx
index 3a05044716..c7630f836f 100644
--- a/packages/plugin-designer/src/pages/DashboardDesignPage.tsx
+++ b/packages/plugin-designer/src/pages/DashboardDesignPage.tsx
@@ -39,7 +39,12 @@ export function DashboardDesignPage() {
liftLegacyDashboardFilterDefaults(dashboard as DashboardComponentSchema) || {
type: 'dashboard',
name: dashboardName ?? '',
- title: dashboardName ?? '',
+ // `label`, not the retired root `title` (objectui#7509): this seed is
+ // the only dashboard-document literal this repo authors, so it spells
+ // the key the spec accepts. Reachable only on the `!dashboard` branch,
+ // which early-returns "not found" below and never persists, so the
+ // spelling change moves no behaviour — it keeps the corpus canonical.
+ label: dashboardName ?? '',
columns: 2,
widgets: [],
},
@@ -134,8 +139,18 @@ export function DashboardDesignPage() {
>
+ {/*
+ `label`, then the raw `name`. The legacy root `title` arm that sat
+ between them RETIRED under ADR-0049 (objectui#7509, maintainer ruling
+ 2026-09-04) together with the four sibling root arms in
+ `DashboardView`, `DashboardRenderer`, `DashboardGridLayout` and
+ `DashboardEditor` — @objectstack/spec's `DashboardSchema` refuses root
+ `title` BY NAME (`unrecognized_keys(title)`) and requires `label`, so
+ a stored document that carried both now shows its `label` here and in
+ the console alike, instead of the two surfaces disagreeing.
+ */}
- Edit Dashboard: {(dashboard as any).label || (dashboard as any).title || dashboardName}
+ Edit Dashboard: {(dashboard as any).label || dashboardName}