From 3fb4a23ff4f516dc00f92cb791ec920400803a61 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 4 Sep 2026 07:33:00 +0000 Subject: [PATCH] fix(dashboard): retire the dashboard-root `title` read on all five surfaces MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The five dashboard-ROOT `title` read arms retire together under ADR-0049 (objectui#7509, maintainer ruling 2026-09-04, decision batch #29, option C). `label` — REQUIRED on `@objectstack/spec`'s `DashboardSchema` — becomes the only header source, then the raw `name`. `DashboardSchema` refuses a root `title` BY NAME (`unrecognized_keys(title)`) and the save route answers 422, so no authored document can acquire the key; what retires is compatibility with documents stored before that refusal. Five surfaces read the legacy spelling independently, so one stored document could show one header in the console and a different one in the designer — which is why the ruling refused retiring any single arm. Widget-level `widget.title` (`DashboardWidget.title`, the spec's `I18nLabel`) is a different DECLARED key and is untouched; root and widget arms were separated by receiver, not by grep, and every pin carries a widget control. Each arm gets a refusal-shaped pin in the #5830 / #5852 form. Two existing pins asserted the retired behaviour and were rewritten rather than deleted: `dashboardAuthoredInputs`' "the legacy `title` read stays" block (its #5742 exclusion of `title` from published inputs is unchanged) and `DashboardGridLayout.inlineLocaleLabel`'s precedence case. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01KbJQ1y1J12nZxYzFWhP8Q3 --- .../7509-dashboard-root-title-retired.md | 43 +++++ .../DashboardView.rootTitleRetired.test.tsx | 171 ++++++++++++++++++ .../app-shell/src/views/DashboardView.tsx | 48 +++-- .../src/DashboardGridLayout.tsx | 21 ++- .../src/DashboardRenderer.tsx | 23 ++- ...boardGridLayout.inlineLocaleLabel.test.tsx | 21 ++- ...hboardGridLayout.rootTitleRetired.test.tsx | 112 ++++++++++++ .../DashboardRenderer.headerActions.test.tsx | 13 +- ...ashboardRenderer.rootTitleRetired.test.tsx | 98 ++++++++++ .../dashboardAuthoredInputs.test.tsx | 42 ++++- .../plugin-designer/src/DashboardEditor.tsx | 22 ++- ...hboardDesignPage.rootTitleRetired.test.tsx | 129 +++++++++++++ .../DashboardEditor.rootTitleRetired.test.tsx | 117 ++++++++++++ .../src/pages/DashboardDesignPage.tsx | 19 +- 14 files changed, 832 insertions(+), 47 deletions(-) create mode 100644 .changeset/7509-dashboard-root-title-retired.md create mode 100644 packages/app-shell/src/views/DashboardView.rootTitleRetired.test.tsx create mode 100644 packages/plugin-dashboard/src/__tests__/DashboardGridLayout.rootTitleRetired.test.tsx create mode 100644 packages/plugin-dashboard/src/__tests__/DashboardRenderer.rootTitleRetired.test.tsx create mode 100644 packages/plugin-designer/src/__tests__/DashboardDesignPage.rootTitleRetired.test.tsx create mode 100644 packages/plugin-designer/src/__tests__/DashboardEditor.rootTitleRetired.test.tsx 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}