diff --git a/.changeset/7623-retire-dashboard-root-title-declaration.md b/.changeset/7623-retire-dashboard-root-title-declaration.md new file mode 100644 index 0000000000..8cf4f79fa3 --- /dev/null +++ b/.changeset/7623-retire-dashboard-root-title-declaration.md @@ -0,0 +1,35 @@ +--- +'@object-ui/types': minor +--- + +**Published TS surface narrowed:** `DashboardComponentSchema` no longer declares the +dashboard-root `title` member (objectui#7623). + +Its doc comment said "Dashboard title displayed in the header", and that stopped being +true one release earlier: objectui#7509 retired all five dashboard-root `title` read +arms under ADR-0049, leaving the key declared, documented as rendering, and inert. The +header text is the spec-canonical `label` on `BaseSchema`, resolved through +`pickLocalized`. + +What an author loses is the **type-level suggestion** only, and there is **no runtime +and no validation behaviour change**. `BaseSchema`'s index signature means an existing +`title:` line still compiles; objectui's Zod twin extends `.passthrough()` `BaseSchema`, +so it parsed a root `title` before this release and still does; and a document validated +against `@objectstack/spec`'s strict `DashboardSchema` was already refused there +(`unrecognized_keys: ['title']`) long before it reached a renderer. Nothing that rendered +stops rendering — the read arms were already gone. + +**Unlike `aria` (objectui#5830), no tombstone comes with this.** The spec refuses a root +`title` as an unrecognized key, not with a named removal message, so there is nothing to +inherit by reference and none was invented; the pin +(`packages/types/src/__tests__/dashboard-title-retired-declaration.test.ts`) asserts the +key-set half only — `title` is out of the interface's declared members while `columns`, +`widgets` and `header` stay in. + +**Not affected, despite the shared name:** widget-level `DashboardWidget.title` — the +spec's `I18nLabel` on a different receiver, live, declared and read by +`DashboardRenderer`, `DashboardGridLayout` and `DashboardWithConfig`. The pin carries it +as an explicit control. + +**Migration:** delete a dashboard-root `title` from any authored dashboard and use +`label` (it accepts a string or a per-locale map). Widget titles are unchanged. diff --git a/packages/types/src/__tests__/dashboard-title-retired-declaration.test.ts b/packages/types/src/__tests__/dashboard-title-retired-declaration.test.ts new file mode 100644 index 0000000000..185791ed16 --- /dev/null +++ b/packages/types/src/__tests__/dashboard-title-retired-declaration.test.ts @@ -0,0 +1,89 @@ +/** + * 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 — dashboard-ROOT `DashboardComponentSchema.title` (objectui#7623). + * + * `title` was declared on objectui's own dashboard component type under the comment + * "Dashboard title displayed in the header". objectui#7509 (PR #7622) retired all + * five dashboard-root `title` read arms under ADR-0049, so the key became declared, + * documented as rendering, and inert — the enforce-or-remove candidate this card + * removes. The header text is the spec-canonical `label` on `BaseSchema`; + * `plugin-dashboard` has no dashboard-root `title` read site left (pinned by that + * package's `__tests__/dashboardAuthoredInputs.test.tsx`). + * + * ## Why this pin has ONE half, where objectui#5830's `aria` pin has two + * + * `dashboard-aria-retired-contract-twins.test.ts` is the model — same interface, + * same removal shape — and its SECOND half asserts that the Zod twin refuses the + * retired key BY NAME, because the spec turned `dashboard.aria` into a tombstone + * that objectui inherits by reference through `SpecDashboardFields`. + * + * ⛔ That half does NOT transfer here, and writing it would be a fabrication. + * `@objectstack/spec`'s strict `DashboardSchema` refuses a root `title` as an + * UNRECOGNIZED key — there is no named removal message to inherit — and objectui's + * twin (`zod/complex.zod.ts`) extends `.passthrough()` `BaseSchema`, so this + * deletion changes no parse verdict at all. The spec's own refusal is already + * measured where it belongs, against the spec schema: + * `plugin-dashboard/src/__tests__/dashboardAuthoredInputs.test.tsx` asserts + * `unrecognizedKeys(specVerdict({ title })).toContain('title')`. Ruled at dispatch + * (2026-09-04): ship the key-set half only; do not invent a tombstone. + * + * ## The trap: why this is a key-set probe and NOT `@ts-expect-error` + * + * ⚠️ `BaseSchema` carries `[key: string]: any`, so an authored `title:` on a + * dashboard literal STILL COMPILES after the member is gone — it falls to the index + * signature. A `@ts-expect-error` pin on an authored literal therefore cannot stick: + * it would pass for the wrong reason before the removal and fail after it. The + * pinnable effect is that `title` stops being a DECLARED member. The probe below + * extracts the interface's literal key set — the index signature is filtered out by + * `string extends K` — and asserts `title` is out while its neighbours stay in. + * Real enforcement because `packages/types/tsconfig.test.json` is chained from this + * package's `type-check` script (objectui#3009). + */ + +import { describe, it, expect } from 'vitest'; +import type { DashboardComponentSchema, DashboardWidgetSchema } from '../complex'; + +// Literal (declared) keys of T: `string extends K` is true only for the index +// signature's key, so mapping it to `never` leaves exactly the authored members. +type DeclaredKeys = { [K in keyof T as string extends K ? never : K]: T[K] }; +type Declared = keyof DeclaredKeys; +type WidgetDeclared = keyof DeclaredKeys; + +describe('the TS interface no longer declares a dashboard-root `title` (objectui#7623)', () => { + it('`title` is not a declared member; the neighbours it stood beside still are', () => { + // Type-level pin, erased at runtime: if the member came back, the first + // annotation would collapse to `false` and this file would fail `type-check`. + // (Reverse-verified at the PR: with the member restored, `tsc -p + // tsconfig.test.json` goes red on exactly this line.) + const titleNotDeclared: 'title' extends Declared ? false : true = true; + // Positive controls through the same extraction: a probe that saw no members at + // all would also report `title` absent. `columns` and `widgets` are what the + // member was declared between; `header` owns the header block whose rendering + // the deleted doc comment claimed. + const columnsDeclared: 'columns' extends Declared ? true : false = true; + const widgetsDeclared: 'widgets' extends Declared ? true : false = true; + const headerDeclared: 'header' extends Declared ? true : false = true; + expect(titleNotDeclared && columnsDeclared && widgetsDeclared && headerDeclared).toBe(true); + }); + + it('the WIDGET-level `title` is a different receiver and stays DECLARED', () => { + // ⛔ Not the same key. `DashboardWidgetSchema.title` is the spec's `I18nLabel`, + // live and read (`DashboardRenderer.tsx`, `DashboardGridLayout.tsx`, + // `DashboardWithConfig.tsx`). Without this leg the pin above cannot tell the two + // apart: a sweep that deleted BOTH spellings would still read green. + const widgetTitleDeclared: 'title' extends WidgetDeclared ? true : false = true; + const widgetIdDeclared: 'id' extends WidgetDeclared ? true : false = true; + // Negative control on the widget extraction itself — it discriminates rather + // than answering `true` for everything (which is what a key set degenerated to + // bare `string` would do). + const widgetNonKeyAbsent: 'objectui7623NotAKey' extends WidgetDeclared ? false : true = true; + expect(widgetTitleDeclared && widgetIdDeclared && widgetNonKeyAbsent).toBe(true); + }); +}); diff --git a/packages/types/src/__tests__/zod-mirror-parity.test.ts b/packages/types/src/__tests__/zod-mirror-parity.test.ts index e83bffffd5..4175106948 100644 --- a/packages/types/src/__tests__/zod-mirror-parity.test.ts +++ b/packages/types/src/__tests__/zod-mirror-parity.test.ts @@ -71,25 +71,31 @@ * and 37 / 53 until objectui#7344 swept the string / `z.any()` handler mirrors: * `DetailSchema` and `DetailViewSchema` entered (one `onBack` each) and * `CalendarViewSchema` grew by `onEventClick`. - * - **16 entries** in `UnmirroredDeclared`, **97 keys** across them. It read 17 / 98 + * - **15 entries** in `UnmirroredDeclared`, **96 keys** across them. It read 17 / 98 * between objectui#6576, which SEEDED the new `ObjectDataTableSchema` pair with its * one measured key `drillDown` (a pair born ledgered, not growth on an existing * one), and objectui#7129, which RETIRED `DetailViewSectionSchema.hideEmpty` — * the ledger's first shrink by removing the DECLARATION rather than by mirroring - * it, and the entry's whole content, so the entry went too. ⚠️ It was + * it, and the entry's whole content, so the entry went too. objectui#7623 took + * that route a second time for `DashboardComponentSchema.title` (16 / 97 → + * 15 / 96), again the entry's whole content — and the first time it reached the + * SPEC-DERIVED half. ⚠️ It was * **121** when objectui#6058 seeded it; objectui#6152 moved 23 callback-shaped * keys to the ledger below by RECLASSIFICATION, not by fixing them, and * objectui#6639 MIRRORED `ObjectGridSchema.title` — one key actually repaired. * Anything citing "121" as the mirroring debt is citing a number that changed - * meaning — the comparable figure is 96 + 1 mirrored + 1 retired + 23 + * meaning — the comparable figure is 95 + 1 mirrored + 2 retired + 23 * reclassified. The full statement is on that ledger. * - **7 entries** in `RuntimeOnlyDeclared`, **24 keys** across them. Six of the - * seven are a subset of the 16 pairs above; `TreeViewSchema` is NOT — it is + * seven are a subset of the 15 pairs above; `TreeViewSchema` is NOT — it is * the first pair whose ONLY ledger entry is a runtime-only one * (objectui#6150 declared `onNodeClick` on an otherwise clean pair), so the * "no entry in either" population dropped by one to 141 — went to 142 when - * objectui#6576 added two pairs, one of them ledgered, and stands at **143** - * since objectui#7129 retired `DetailViewSectionSchema`'s only ledgered key. + * objectui#6576 added two pairs, one of them ledgered, went to 143 when + * objectui#7129 retired `DetailViewSectionSchema`'s only ledgered key, and + * stands at **144** since objectui#7623 retired `DashboardComponentSchema`'s + * only UNMIRRORED one (that pair keeps its `KnownDrift` entry — this + * population counts entries in the two unmirrored ledgers). * - 160 − 40 = **120**, the "pairs with no entry" `LedgerMismatch` speaks of. * * ## Two ratchets, because the forward comparison has two halves @@ -904,20 +910,25 @@ interface KnownDrift { * invites an author to write and the published validator has never heard of. * * ## ⚠️ READ THIS BEFORE QUOTING THE NUMBER — 121 became 98 by RECLASSIFICATION, - * ## then 97 by the first REPAIR + * ## then 97 by the first REPAIR, then 96 by RETIREMENT * - * objectui#6058 seeded this ledger at **121 keys**. It records **97**, and the two + * objectui#6058 seeded this ledger at **121 keys**. It records **96**, and the * movements are different facts. objectui#6152 measured the 23 callback-shaped * (`on*`) keys and ruled that mirroring is the wrong remedy for every one of them; * they moved, intact and still pinned, to `RuntimeOnlyDeclared` below — ⛔ nothing * was mirrored, no declaration was removed, nothing was waived by that move. Then * objectui#6639 MIRRORED `ObjectGridSchema.title` (census-directed maintainer * ruling 2026-08-29, declare branch) — one defect actually repaired, the ledger's - * first shrink by repair. - * - * So: **97 is the mirroring debt; 97 + 1 mirrored + 23 reclassified is what "121" - * used to mean.** A card that cites 121 as the size of the mirroring problem, or - * 98/97 as a shrink from it, is wrong in both directions. objectui#6141 is the + * first shrink by repair. Then objectui#7129 and objectui#7623 each RETIRED a key by + * deleting the DECLARATION (`DetailViewSectionSchema.hideEmpty`; + * `DashboardComponentSchema.title`) — shrinks by removal, which is neither a repair + * of the mirror nor a waiver: the key stops being offered to authors at all. + * + * So: **96 is the mirroring debt; 96 − 1 seeded + 1 mirrored + 2 retired + 23 + * reclassified is what "121" used to mean** (the seed is objectui#6576's + * `ObjectDataTableSchema.drillDown`, which was never part of the 121). A card that + * cites 121 as the size of the mirroring problem, or 98/97/96 as a shrink from it, + * is wrong in both directions. objectui#6141 is the * standing example of what a silently moved count costs — it is why this paragraph * is in the ledger rather than in a commit message. * @@ -945,18 +956,25 @@ interface KnownDrift { * explicit that forcing the 121 per-key decisions now would be wrong. Two splits * are recorded here so whoever works them off does not re-derive them: * - * - **SPEC-DERIVED (3 entries, 13 keys)** — `DashboardComponentSchema`, - * `DashboardWidgetSchema`, `ObjectViewSchema`. ⚠️ objectui#6705 invalidated the - * evidence for ONE of the three: `ObjectViewSchema` is no longer in + * - **SPEC-DERIVED (2 entries, 12 keys)** — `DashboardWidgetSchema`, + * `ObjectViewSchema`. It was 3 / 13 until objectui#7623 RETIRED + * `DashboardComponentSchema.title` by deleting the DECLARATION — the + * objectui#7129 route, reaching this half for the first time. That key was never + * going to be answered by #2231 unification: the spec's strict `DashboardSchema` + * refuses a root `title` outright, and objectui#7509 had already retired every + * read of it, so the local declaration was offering a member the spec models + * nowhere and no renderer consumed. ⚠️ objectui#6705 invalidated the + * evidence for ONE of the two that remain: `ObjectViewSchema` is no longer in * `SPEC_DERIVED_PAIRS` below, because it never referenced a spec schema — it is * `BaseSchema.extend({…})` of local literals, and the pre-#6705 text scanner - * charged it a neighbouring private const's `Spec…` token. The split is left - * STANDING as written, counts and all: re-routing those keys from #2231's + * charged it a neighbouring private const's `Spec…` token. That misclassification + * is left STANDING as #6705 found it — neither the entry's classification nor its + * key count moved for it: re-routing those keys from #2231's * unification question to a local mirror edit is a remedy decision on the * `UnmirroredDeclared` ledger, which #6705 was fenced out of. Whoever works * this split off must re-derive `ObjectViewSchema`'s side first. - * For the other two the reading is unchanged: - * their mirror takes its shape BY REFERENCE from `@objectstack/spec`. An + * For `DashboardWidgetSchema` the reading is unchanged: + * its mirror takes its shape BY REFERENCE from `@objectstack/spec`. An * unmirrored declared key there means the LOCAL declaration carries members the * spec schema does not model, which is objectui#2231's unification question and * NOT a local mirror edit. They are marked, not exempted: exempting them in the @@ -970,10 +988,11 @@ interface KnownDrift { * (107 → 85; 84 since objectui#6639 mirrored `ObjectGridSchema.title`, 83 since * objectui#7129 retired one). The pair COUNTS did not move under the * reclassification — every affected pair kept keys here — so the split read 16 - * entries / 97 keys after it. Two later changes moved the entry count itself: + * entries / 97 keys after it. Three later changes moved the entry count itself: * objectui#6576 SEEDED `ObjectDataTableSchema` (a 17th entry, in neither half - * above), and objectui#7129 RETIRED an entry from the LOCAL half. The ledger now - * totals **16 entries / 97 keys** — 3 / 13 spec-derived, 12 / 83 local, plus the + * above), objectui#7129 RETIRED an entry from the LOCAL half, and objectui#7623 + * RETIRED one from the SPEC-DERIVED half (13 → 12 keys there). The ledger now + * totals **15 entries / 96 keys** — 2 / 12 spec-derived, 12 / 83 local, plus the * seeded pair. * * ## How this was measured, and the trap that makes the number hard to get @@ -1008,14 +1027,17 @@ interface UnmirroredDeclared { * heard of. */ 'complex.zod.ts#ChatbotSchema': 'displayMode' | 'floatingConfig' | 'requestBody'; + // `complex.zod.ts#DashboardComponentSchema` recorded `title` here (SPEC-DERIVED) + // until objectui#7623 RETIRED the declaration — the objectui#7129 route, not a + // mirror edit: the spec's strict `DashboardSchema` refuses a root `title` outright, + // and objectui#7509 had already retired every read of it. The pair keeps its + // `KnownDrift` entry above (three TYPE-drifted keys) and records nothing here. /** * SPEC-DERIVED → objectui#2231. The mirror takes its shape by reference from - * `@objectstack/spec`, so `title` is a member the LOCAL declaration carries and the - * spec schema does not model. Its three TYPE-drifted keys are in `KnownDrift` above - * and route the same way. + * `@objectstack/spec`, so these are members the LOCAL declaration carries and the + * spec schema does not model. Its one TYPE-drifted key is in `KnownDrift` above and + * routes the same way. */ - 'complex.zod.ts#DashboardComponentSchema': 'title'; - /** SPEC-DERIVED → objectui#2231, same reading as `DashboardComponentSchema`. */ 'complex.zod.ts#DashboardWidgetSchema': 'pagination' | 'searchable'; /** LOCAL. Declared in `../data-display.ts`, absent from the mirror. */ 'data-display.zod.ts#ChartSchema': 'drillDown'; @@ -1399,14 +1421,16 @@ export const assertionDriftMatchesLedger: never = 0 as unknown as LedgerMismatch /** * The SECOND half of the forward comparison: every pair's declared-but-unmirrored - * key set equals what the two ledgers TOGETHER record for it — `never` for the 143 - * pairs with no entry in either (160 − 17). Six of `RuntimeOnlyDeclared`'s seven - * pairs are a measured subset of `UnmirroredDeclared`'s 16, so objectui#6152's + * key set equals what the two ledgers TOGETHER record for it — `never` for the 144 + * pairs with no entry in either (160 − 16). Six of `RuntimeOnlyDeclared`'s seven + * pairs are a measured subset of `UnmirroredDeclared`'s 15, so objectui#6152's * reclassification left the clean population unchanged; objectui#6150 then added - * `TreeViewSchema`, whose only entry is runtime-only, which is why the union is 17 - * pairs and not 16. (objectui#6576 took the union to 18; objectui#7129 brought it - * back to 17 by retiring `DetailViewSectionSchema`'s only ledgered key, leaving - * that pair with no entry in either half.) + * `TreeViewSchema`, whose only entry is runtime-only, which is why the union is 16 + * pairs and not 15. (objectui#6576 took the union to 18; objectui#7129 brought it + * back to 17 by retiring `DetailViewSectionSchema`'s only ledgered key, and + * objectui#7623 to 16 by retiring `DashboardComponentSchema`'s — each leaving its + * pair with no entry in either half. ⚠️ That pair still carries a `KnownDrift` + * entry: "no entry in either" is about the two UNMIRRORED ledgers.) * * ⚠️ **The discriminating signal is the PER-PAIR set, not this file's exit code.** * The exit code is a whole-file verdict, so it moves only while the rest of the diff --git a/packages/types/src/complex.ts b/packages/types/src/complex.ts index a4b8534e6d..17fe955713 100644 --- a/packages/types/src/complex.ts +++ b/packages/types/src/complex.ts @@ -1086,8 +1086,24 @@ export interface DashboardWidgetSchema */ export interface DashboardComponentSchema extends BaseSchema { type: 'dashboard'; - /** Dashboard title displayed in the header */ - title?: string; + // `title` was DECLARED here until objectui#7623, under the comment "Dashboard + // title displayed in the header" — by then a description of behaviour that had + // stopped existing: objectui#7509 retired all five dashboard-root `title` read + // arms under ADR-0049 (PR #7622), so the key was declared, documented as + // rendering, and inert. The header text is the spec-canonical `label` + // (`BaseSchema`), resolved through `pickLocalized`; `plugin-dashboard` has no + // dashboard-root `title` read site left (pinned by that package's + // `__tests__/dashboardAuthoredInputs.test.tsx`). + // + // Unlike `aria` below there is NO spec tombstone to inherit: `@objectstack/spec`'s + // strict `DashboardSchema` refuses a root `title` as an UNRECOGNIZED key, not with + // a named removal message, so the Zod twin (`zod/complex.zod.ts`, `.passthrough()` + // via `BaseSchema`) gains no refusal from this deletion and ⛔ must not be given a + // hand-written one — that would assert a spec behaviour that does not exist. + // Note `BaseSchema`'s index signature still types an authored `title` as `any`: + // this deletion removes the type-level suggestion and the false rendering claim, + // not a key that ever rendered. Pinned by + // `__tests__/dashboard-title-retired-declaration.test.ts`. columns?: number; gap?: number; widgets: DashboardWidgetSchema[];