Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 60 additions & 0 deletions .changeset/6881-retire-data-table-toolbar.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
---
'@object-ui/types': minor
---

**Breaking for authored metadata:** `DataTableSchema.toolbar` is RETIRED
(objectui#6881, maintainer ruling 2026-08-31). A `data-table` node that authors
`toolbar` no longer validates — the parse fails loudly on the `toolbar` path
with the remediation in the message — and the TS member is a `?: never`
tombstone, so the same document is refused at compile time.

**What was measured.** The key was declared on both published faces —
`data-display.ts` (`toolbar?: SchemaNode[]`, "Table toolbar actions/content")
and the Zod mirror (`SchemaNode | SchemaNode[]`) — documented, mirrored, and
read by NOTHING: `data-table.tsx`, the registered renderer for
`type: 'data-table'`, contains the word only in two prose comments and never
reads `schema.toolbar`. The sibling `emptyAction` slot on the same interface IS
mounted through `SchemaRenderer`, so the census zero is a reading, not a blind
query. An author who wrote a toolbar got a green document and a blank result,
with no signal anywhere that said so — the declared-vs-enforced failure mode
that is worst for AI-authored metadata, which has nothing but the declaration
to go on.

**Who is affected — a `toolbar` authored directly onto a `data-table` node,
in either spelling:**

```json
{ "type": "data-table",
"columns": [{ "header": "Name", "accessorKey": "name" }],
"data": [],
"toolbar": [{ "type": "button", "label": "Refresh" }] } // ← was tolerated, rendered nothing
```

now fails validation with:

> RETIRED (objectui#6881) — never mounted by the data-table renderer; use the
> built-in toolbar chrome (searchable / exportable), or compose nodes beside
> the table

The single-node spelling `"toolbar": { … }` — which only the Zod mirror ever
accepted; the TS face always refused it — is refused the same way, so the two
faces now agree by refusing both.

**Who is NOT affected.** A document that never wrote the key is untouched
(`absent` stays valid), and every other `SchemaNode` slot — `emptyAction`
included — is unchanged. No fixture, example, catalog entry, doc page or app
in this repository authored the key (measured: all five
`components-complex-data-table` catalog schemas are toolbar-free, and every
other `toolbar` occurrence repo-wide is an i18n key, an ARIA role, or an
unrelated React prop of the same name).

**Migration:** use the built-in toolbar chrome (`searchable` / `exportable`),
or compose your own nodes beside the table. Per the ruling, a real toolbar
slot must arrive as a redesigned proposal WITH its enforcing reader — published
zero-consumer capability gets no sunk-cost exemption.

Graded `minor`, not `patch`: this narrows the accepted input set, which is
breaking for any author who wrote the tolerated key. It is not `major` per
this repo's fixed-group convention (objectui's own breaking changes ship as
`minor`; the group's major tracks `@objectstack` — AGENTS.md 版本号策略,
mechanically enforced by `scripts/check-changeset-no-major.mjs`).
194 changes: 194 additions & 0 deletions packages/types/src/__tests__/data-table-toolbar-retired.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,194 @@
/**
* 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 — `DataTableSchema.toolbar` is REFUSED, not silently ignored
* (objectui#6881, maintainer ruling 2026-08-31: retire, do NOT wire).
*
* ## The failure this pin exists to prevent
*
* `toolbar` was declared on both published faces — `data-display.ts` and the
* Zod mirror — documented, mirrored, and read by NOTHING: `data-table.tsx`
* never mounts it (the word appears there only in two prose comments), while
* the sibling `emptyAction` slot on the SAME interface is mounted through
* `SchemaRenderer`, so the census zero is a reading, not a blind query. An
* author who wrote a toolbar got a green document and a blank result, with no
* signal anywhere that said so — the declared-vs-enforced failure mode, worst
* for AI-authored metadata, which has nothing but the declaration to go on.
*
* So the deliverable is not "toolbar renders". It is: **an authored `toolbar`
* is refused loudly at the authoring boundary**, with the remediation in the
* refusal (the built-in chrome: `searchable` / `exportable`). The ruling
* records that a real toolbar slot must arrive as a redesigned proposal WITH
* its enforcing reader — not by reviving this key.
*
* ## Why the tombstone, and not simply deleting the key
*
* `BaseSchema` is `.passthrough()` on the Zod side and carries a
* `[key: string]: any` index signature on the TS side. An UNDECLARED key is
* accepted by both halves, unvalidated — deleting `toolbar` outright would
* hand the authored spelling exactly the silent no-op this card exists to
* close. `?: never` / `retirementTombstone()` is this package's convention —
* {@link StaticTableColumn} (objectui#5474), `crud.ts` `confirm`
* (objectui#4314), `TimelineSchema.timeScale` (objectui#6355) — and it is
* lockstep: both halves or neither.
*
* ## The rider the retirement settles by construction
*
* Before this card the two faces disagreed on the SHAPE: TS said
* `SchemaNode[]`, the mirror admitted `SchemaNode | SchemaNode[]` — a
* mirror-wider-than-declared drift no parity ledger watches. Retiring both
* faces in the same stroke makes them agree by refusing BOTH spellings, so
* this file pins the single-node spelling refused too — the half only the
* mirror ever accepted.
*/

import { describe, it, expect } from 'vitest';
import { DataTableSchema } from '../zod/data-display.zod.js';
import type { DataTableSchema as DataTableSchemaTS, TableColumn } from '../data-display.js';

const GUIDANCE =
'RETIRED (objectui#6881) — never mounted by the data-table renderer; use the built-in toolbar chrome (searchable / exportable), or compose nodes beside the table';

/** A minimal document that is valid TODAY and stays valid — the inside of the boundary. */
const VALID_TABLE = {
type: 'data-table',
columns: [{ header: 'Name', accessorKey: 'name' }],
data: [],
} as const;

describe('DataTableSchema.toolbar is RETIRED — the Zod half of the tombstone (objectui#6881)', () => {
it('REFUSES the array spelling, naming the retired key', () => {
// The pin. Before the retirement this document parsed GREEN (`toolbar` was
// `z.union([SchemaNodeSchema, z.array(SchemaNodeSchema)]).optional()`),
// measured ACCEPTED on the retiring PR's base. Asserting the ENVELOPE —
// not merely `success:false` — so the pin cannot be satisfied by an
// unrelated rejection.
const result = DataTableSchema.safeParse({
...VALID_TABLE,
toolbar: [{ type: 'button', label: 'Refresh' }],
});
expect(result.success, 'an authored toolbar was ACCEPTED — it will render as a blank result with no signal').toBe(false);
if (result.success) return;

const issue = result.error.issues.find((i) => i.path[0] === 'toolbar');
expect(issue, 'parse failed, but not on the `toolbar` path').toBeTruthy();
expect(issue?.code).toBe('invalid_type');
expect((issue as { expected?: string } | undefined)?.expected).toBe('never');
});

it('REFUSES the single-node spelling — the half only the mirror ever accepted', () => {
// Pre-retirement, TS refused this spelling while the mirror admitted it
// (the drift recorded on objectui#6881 as the secondary observation). The
// two faces now agree by refusing both.
const result = DataTableSchema.safeParse({
...VALID_TABLE,
toolbar: { type: 'button', label: 'Refresh' },
});
expect(result.success).toBe(false);
if (result.success) return;
const issue = result.error.issues.find((i) => i.path[0] === 'toolbar');
expect(issue?.code).toBe('invalid_type');
});

it('the refusal CARRIES the remediation text, not zod\'s generic message', () => {
// `retirementTombstone()` writes the guidance once into the parse message
// and `.describe()` both (objectui#6931) — the author is told what to
// write instead: the built-in chrome.
const result = DataTableSchema.safeParse({
...VALID_TABLE,
toolbar: [{ type: 'button', label: 'Refresh' }],
});
expect(result.success).toBe(false);
if (result.success) return;

const issue = result.error.issues.find((i) => i.path[0] === 'toolbar');
expect(issue?.message).not.toContain('Invalid input: expected never, received ');
expect(issue?.message).toBe(GUIDANCE);
// ONE string, BOTH channels — asserted derived, so parse message and
// generated-docs metadata cannot drift apart.
expect(issue?.message).toBe(
(DataTableSchema.shape.toolbar as { description?: string }).description,
);
});

it('leaves a document that never wrote the key untouched — the inside of the boundary', () => {
// `absent` stays valid — `.optional()` on the tombstone. The retirement
// narrows exactly one key and nothing else.
expect(DataTableSchema.safeParse(VALID_TABLE).success).toBe(true);
});

it('still ACCEPTS the sibling `emptyAction` SchemaNode slot — the counter-probe', () => {
// `emptyAction` is the slot the census used as its positive control: same
// interface, same SchemaNode shape, actually mounted (data-table.tsx, via
// SchemaRenderer). Without this leg the refusals above would be satisfied
// by a schema that refuses every SchemaNode slot — a narrowing that
// refuses too much would pass a refusal-only test.
const result = DataTableSchema.safeParse({
...VALID_TABLE,
emptyAction: { type: 'button', label: 'New' },
});
expect(result.success ? null : result.error.issues).toBe(null);
});

it('keeps `toolbar` DECLARED — a tombstone, not a deletion', () => {
// The route guard. `BaseSchema` is `.passthrough()`, so removing the key
// from the mirror would make the authored spelling parse green again and
// do nothing — the silent no-op reintroduced by the very edit meant to
// remove it.
expect(
Object.keys(DataTableSchema.shape),
'toolbar left the mirror — under .passthrough() the retired key becomes a SILENT no-op again',
).toContain('toolbar');
});
});

describe('DataTableSchema.toolbar is RETIRED — the TS half of the tombstone (objectui#6881)', () => {
it('refuses the retired key at compile time', () => {
// On the pre-fix tree `toolbar` is `SchemaNode[] | undefined`, so the
// assignment is LEGAL, the directive below is unused, and `tsc` fails the
// build with TS2578 naming the key — this leg is red before the fix in
// `type-check`, not in vitest, which strips types.

// @ts-expect-error — `toolbar` is RETIRED (objectui#6881): declared `?: never`, so no value is authorable.
const retired: DataTableSchemaTS['toolbar'] = [{ type: 'button' }];

// Counter-probe on the same surface: the sibling SchemaNode slot still
// accepts a node, so the directive above pins the KEY's retirement and not
// a blanket narrowing of the interface.
const sibling: DataTableSchemaTS['emptyAction'] = { type: 'button' };

expect([retired, sibling]).toHaveLength(2);
});

it('refuses the retired key in the form authors actually write', () => {
// The leg that proves the tombstone survives `BaseSchema`'s
// `[key: string]: any`: if the index signature won, `toolbar` would widen
// back to `any` here and the directive would go unused (TS2578).
const columns: TableColumn[] = [{ header: 'Name', accessorKey: 'name' }];

const retiredDocument: DataTableSchemaTS = {
type: 'data-table',
columns,
data: [],
// @ts-expect-error — `toolbar` is RETIRED (objectui#6881); use the built-in chrome (`searchable` / `exportable`).
toolbar: [{ type: 'button', label: 'Refresh' }],
};

// The migrated document — built-in chrome instead — still type-checks.
const migratedDocument: DataTableSchemaTS = {
type: 'data-table',
columns,
data: [],
searchable: true,
exportable: true,
};

expect([retiredDocument, migratedDocument]).toHaveLength(2);
});
});
26 changes: 24 additions & 2 deletions packages/types/src/data-display.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -670,9 +670,31 @@ export interface DataTableSchema extends BaseSchema {
*/
caption?: string;
/**
* Table toolbar actions/content
* ADR-0049 RETIREMENT TOMBSTONE — `toolbar` (objectui#6881, maintainer
* ruling 2026-08-31: retire, do NOT wire).
*
* What was measured (objectui#6881, re-measured on the retiring PR's base):
* declared on both published faces, documented, mirrored — and read by
* NOTHING. `data-table.tsx`, the registered renderer for `type:
* 'data-table'`, contains the word only in two prose comments and never
* reads `schema.toolbar`; the sibling `emptyAction` slot on this same
* interface IS mounted through `SchemaRenderer`, so the zero is a reading,
* not a blind query. An author who wrote a toolbar got a green document and
* a blank result, with no signal anywhere that said so.
*
* `?: never` is this package's tombstone convention (see `crud.ts`
* `confirm`, {@link StaticTableColumn}, `TimelineSchema`'s `timeScale`), NOT
* a deletion: `BaseSchema`'s `[key: string]: any` would admit a deleted key
* as `any` again — the same silence one layer over. The Zod twin refuses it
* loudly via `retirementTombstone()` (`zod/data-display.zod.ts`).
*
* RETIRED (objectui#6881, ADR-0049) — never mounted by the data-table
* renderer. Use the built-in toolbar chrome instead (`searchable` /
* `exportable`), or compose your own nodes beside the table. A real
* toolbar slot must arrive as a redesigned proposal WITH its enforcing
* reader, per the ruling — not by reviving this key.
*/
toolbar?: SchemaNode[];
toolbar?: never;
/**
* Table columns
*/
Expand Down
2 changes: 1 addition & 1 deletion packages/types/src/zod/data-display.zod.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -241,7 +241,7 @@ export const DataTableSchema = BaseSchema.extend({
type: z.literal('data-table'),
caption: z.string().optional().describe('Table caption'),
borderless: z.boolean().optional().describe('Render the table without its outer rounded border (for embedding inside grouped rows or other containers).'),
toolbar: z.union([SchemaNodeSchema, z.array(SchemaNodeSchema)]).optional().describe('Toolbar content'),
toolbar: retirementTombstone('RETIRED (objectui#6881) — never mounted by the data-table renderer; use the built-in toolbar chrome (searchable / exportable), or compose nodes beside the table'),
columns: z.array(TableColumnSchema).describe('Table columns'),
data: z.array(z.any()).describe('Table data'),
pagination: z.boolean().optional().describe('Enable pagination'),
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 60 additions & 0 deletions .changeset/6881-retire-data-table-toolbar.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
---
'@object-ui/types': minor
---

**Breaking for authored metadata:** `DataTableSchema.toolbar` is RETIRED
(objectui#6881, maintainer ruling 2026-08-31). A `data-table` node that authors
`toolbar` no longer validates — the parse fails loudly on the `toolbar` path
with the remediation in the message — and the TS member is a `?: never`
tombstone, so the same document is refused at compile time.

**What was measured.** The key was declared on both published faces —
`data-display.ts` (`toolbar?: SchemaNode[]`, "Table toolbar actions/content")
and the Zod mirror (`SchemaNode | SchemaNode[]`) — documented, mirrored, and
read by NOTHING: `data-table.tsx`, the registered renderer for
`type: 'data-table'`, contains the word only in two prose comments and never
reads `schema.toolbar`. The sibling `emptyAction` slot on the same interface IS
mounted through `SchemaRenderer`, so the census zero is a reading, not a blind
query. An author who wrote a toolbar got a green document and a blank result,
with no signal anywhere that said so — the declared-vs-enforced failure mode
that is worst for AI-authored metadata, which has nothing but the declaration
to go on.

**Who is affected — a `toolbar` authored directly onto a `data-table` node,
in either spelling:**

```json
{ "type": "data-table",
"columns": [{ "header": "Name", "accessorKey": "name" }],
"data": [],
"toolbar": [{ "type": "button", "label": "Refresh" }] } // ← was tolerated, rendered nothing
```

now fails validation with:

> RETIRED (objectui#6881) — never mounted by the data-table renderer; use the
> built-in toolbar chrome (searchable / exportable), or compose nodes beside
> the table

The single-node spelling `"toolbar": { … }` — which only the Zod mirror ever
accepted; the TS face always refused it — is refused the same way, so the two
faces now agree by refusing both.

**Who is NOT affected.** A document that never wrote the key is untouched
(`absent` stays valid), and every other `SchemaNode` slot — `emptyAction`
included — is unchanged. No fixture, example, catalog entry, doc page or app
in this repository authored the key (measured: all five
`components-complex-data-table` catalog schemas are toolbar-free, and every
other `toolbar` occurrence repo-wide is an i18n key, an ARIA role, or an
unrelated React prop of the same name).

**Migration:** use the built-in toolbar chrome (`searchable` / `exportable`),
or compose your own nodes beside the table. Per the ruling, a real toolbar
slot must arrive as a redesigned proposal WITH its enforcing reader — published
zero-consumer capability gets no sunk-cost exemption.

Graded `minor`, not `patch`: this narrows the accepted input set, which is
breaking for any author who wrote the tolerated key. It is not `major` per
this repo's fixed-group convention (objectui's own breaking changes ship as
`minor`; the group's major tracks `@objectstack` — AGENTS.md 版本号策略,
mechanically enforced by `scripts/check-changeset-no-major.mjs`).
194 changes: 194 additions & 0 deletions packages/types/src/__tests__/data-table-toolbar-retired.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,194 @@
/**
* 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 — `DataTableSchema.toolbar` is REFUSED, not silently ignored
* (objectui#6881, maintainer ruling 2026-08-31: retire, do NOT wire).
*
* ## The failure this pin exists to prevent
*
* `toolbar` was declared on both published faces — `data-display.ts` and the
* Zod mirror — documented, mirrored, and read by NOTHING: `data-table.tsx`
* never mounts it (the word appears there only in two prose comments), while
* the sibling `emptyAction` slot on the SAME interface is mounted through
* `SchemaRenderer`, so the census zero is a reading, not a blind query. An
* author who wrote a toolbar got a green document and a blank result, with no
* signal anywhere that said so — the declared-vs-enforced failure mode, worst
* for AI-authored metadata, which has nothing but the declaration to go on.
*
* So the deliverable is not "toolbar renders". It is: **an authored `toolbar`
* is refused loudly at the authoring boundary**, with the remediation in the
* refusal (the built-in chrome: `searchable` / `exportable`). The ruling
* records that a real toolbar slot must arrive as a redesigned proposal WITH
* its enforcing reader — not by reviving this key.
*
* ## Why the tombstone, and not simply deleting the key
*
* `BaseSchema` is `.passthrough()` on the Zod side and carries a
* `[key: string]: any` index signature on the TS side. An UNDECLARED key is
* accepted by both halves, unvalidated — deleting `toolbar` outright would
* hand the authored spelling exactly the silent no-op this card exists to
* close. `?: never` / `retirementTombstone()` is this package's convention —
* {@link StaticTableColumn} (objectui#5474), `crud.ts` `confirm`
* (objectui#4314), `TimelineSchema.timeScale` (objectui#6355) — and it is
* lockstep: both halves or neither.
*
* ## The rider the retirement settles by construction
*
* Before this card the two faces disagreed on the SHAPE: TS said
* `SchemaNode[]`, the mirror admitted `SchemaNode | SchemaNode[]` — a
* mirror-wider-than-declared drift no parity ledger watches. Retiring both
* faces in the same stroke makes them agree by refusing BOTH spellings, so
* this file pins the single-node spelling refused too — the half only the
* mirror ever accepted.
*/

import { describe, it, expect } from 'vitest';
import { DataTableSchema } from '../zod/data-display.zod.js';
import type { DataTableSchema as DataTableSchemaTS, TableColumn } from '../data-display.js';

const GUIDANCE =
'RETIRED (objectui#6881) — never mounted by the data-table renderer; use the built-in toolbar chrome (searchable / exportable), or compose nodes beside the table';

/** A minimal document that is valid TODAY and stays valid — the inside of the boundary. */
const VALID_TABLE = {
type: 'data-table',
columns: [{ header: 'Name', accessorKey: 'name' }],
data: [],
} as const;

describe('DataTableSchema.toolbar is RETIRED — the Zod half of the tombstone (objectui#6881)', () => {
it('REFUSES the array spelling, naming the retired key', () => {
// The pin. Before the retirement this document parsed GREEN (`toolbar` was
// `z.union([SchemaNodeSchema, z.array(SchemaNodeSchema)]).optional()`),
// measured ACCEPTED on the retiring PR's base. Asserting the ENVELOPE —
// not merely `success:false` — so the pin cannot be satisfied by an
// unrelated rejection.
const result = DataTableSchema.safeParse({
...VALID_TABLE,
toolbar: [{ type: 'button', label: 'Refresh' }],
});
expect(result.success, 'an authored toolbar was ACCEPTED — it will render as a blank result with no signal').toBe(false);
if (result.success) return;

const issue = result.error.issues.find((i) => i.path[0] === 'toolbar');
expect(issue, 'parse failed, but not on the `toolbar` path').toBeTruthy();
expect(issue?.code).toBe('invalid_type');
expect((issue as { expected?: string } | undefined)?.expected).toBe('never');
});

it('REFUSES the single-node spelling — the half only the mirror ever accepted', () => {
// Pre-retirement, TS refused this spelling while the mirror admitted it
// (the drift recorded on objectui#6881 as the secondary observation). The
// two faces now agree by refusing both.
const result = DataTableSchema.safeParse({
...VALID_TABLE,
toolbar: { type: 'button', label: 'Refresh' },
});
expect(result.success).toBe(false);
if (result.success) return;
const issue = result.error.issues.find((i) => i.path[0] === 'toolbar');
expect(issue?.code).toBe('invalid_type');
});

it('the refusal CARRIES the remediation text, not zod\'s generic message', () => {
// `retirementTombstone()` writes the guidance once into the parse message
// and `.describe()` both (objectui#6931) — the author is told what to
// write instead: the built-in chrome.
const result = DataTableSchema.safeParse({
...VALID_TABLE,
toolbar: [{ type: 'button', label: 'Refresh' }],
});
expect(result.success).toBe(false);
if (result.success) return;

const issue = result.error.issues.find((i) => i.path[0] === 'toolbar');
expect(issue?.message).not.toContain('Invalid input: expected never, received ');
expect(issue?.message).toBe(GUIDANCE);
// ONE string, BOTH channels — asserted derived, so parse message and
// generated-docs metadata cannot drift apart.
expect(issue?.message).toBe(
(DataTableSchema.shape.toolbar as { description?: string }).description,
);
});

it('leaves a document that never wrote the key untouched — the inside of the boundary', () => {
// `absent` stays valid — `.optional()` on the tombstone. The retirement
// narrows exactly one key and nothing else.
expect(DataTableSchema.safeParse(VALID_TABLE).success).toBe(true);
});

it('still ACCEPTS the sibling `emptyAction` SchemaNode slot — the counter-probe', () => {
// `emptyAction` is the slot the census used as its positive control: same
// interface, same SchemaNode shape, actually mounted (data-table.tsx, via
// SchemaRenderer). Without this leg the refusals above would be satisfied
// by a schema that refuses every SchemaNode slot — a narrowing that
// refuses too much would pass a refusal-only test.
const result = DataTableSchema.safeParse({
...VALID_TABLE,
emptyAction: { type: 'button', label: 'New' },
});
expect(result.success ? null : result.error.issues).toBe(null);
});

it('keeps `toolbar` DECLARED — a tombstone, not a deletion', () => {
// The route guard. `BaseSchema` is `.passthrough()`, so removing the key
// from the mirror would make the authored spelling parse green again and
// do nothing — the silent no-op reintroduced by the very edit meant to
// remove it.
expect(
Object.keys(DataTableSchema.shape),
'toolbar left the mirror — under .passthrough() the retired key becomes a SILENT no-op again',
).toContain('toolbar');
});
});

describe('DataTableSchema.toolbar is RETIRED — the TS half of the tombstone (objectui#6881)', () => {
it('refuses the retired key at compile time', () => {
// On the pre-fix tree `toolbar` is `SchemaNode[] | undefined`, so the
// assignment is LEGAL, the directive below is unused, and `tsc` fails the
// build with TS2578 naming the key — this leg is red before the fix in
// `type-check`, not in vitest, which strips types.

// @ts-expect-error — `toolbar` is RETIRED (objectui#6881): declared `?: never`, so no value is authorable.
const retired: DataTableSchemaTS['toolbar'] = [{ type: 'button' }];

// Counter-probe on the same surface: the sibling SchemaNode slot still
// accepts a node, so the directive above pins the KEY's retirement and not
// a blanket narrowing of the interface.
const sibling: DataTableSchemaTS['emptyAction'] = { type: 'button' };

expect([retired, sibling]).toHaveLength(2);
});

it('refuses the retired key in the form authors actually write', () => {
// The leg that proves the tombstone survives `BaseSchema`'s
// `[key: string]: any`: if the index signature won, `toolbar` would widen
// back to `any` here and the directive would go unused (TS2578).
const columns: TableColumn[] = [{ header: 'Name', accessorKey: 'name' }];

const retiredDocument: DataTableSchemaTS = {
type: 'data-table',
columns,
data: [],
// @ts-expect-error — `toolbar` is RETIRED (objectui#6881); use the built-in chrome (`searchable` / `exportable`).
toolbar: [{ type: 'button', label: 'Refresh' }],
};

// The migrated document — built-in chrome instead — still type-checks.
const migratedDocument: DataTableSchemaTS = {
type: 'data-table',
columns,
data: [],
searchable: true,
exportable: true,
};

expect([retiredDocument, migratedDocument]).toHaveLength(2);
});
});
26 changes: 24 additions & 2 deletions packages/types/src/data-display.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -670,9 +670,31 @@ export interface DataTableSchema extends BaseSchema {
*/
caption?: string;
/**
* Table toolbar actions/content
* ADR-0049 RETIREMENT TOMBSTONE — `toolbar` (objectui#6881, maintainer
* ruling 2026-08-31: retire, do NOT wire).
*
* What was measured (objectui#6881, re-measured on the retiring PR's base):
* declared on both published faces, documented, mirrored — and read by
* NOTHING. `data-table.tsx`, the registered renderer for `type:
* 'data-table'`, contains the word only in two prose comments and never
* reads `schema.toolbar`; the sibling `emptyAction` slot on this same
* interface IS mounted through `SchemaRenderer`, so the zero is a reading,
* not a blind query. An author who wrote a toolbar got a green document and
* a blank result, with no signal anywhere that said so.
*
* `?: never` is this package's tombstone convention (see `crud.ts`
* `confirm`, {@link StaticTableColumn}, `TimelineSchema`'s `timeScale`), NOT
* a deletion: `BaseSchema`'s `[key: string]: any` would admit a deleted key
* as `any` again — the same silence one layer over. The Zod twin refuses it
* loudly via `retirementTombstone()` (`zod/data-display.zod.ts`).
*
* RETIRED (objectui#6881, ADR-0049) — never mounted by the data-table
* renderer. Use the built-in toolbar chrome instead (`searchable` /
* `exportable`), or compose your own nodes beside the table. A real
* toolbar slot must arrive as a redesigned proposal WITH its enforcing
* reader, per the ruling — not by reviving this key.
*/
toolbar?: SchemaNode[];
toolbar?: never;
/**
* Table columns
*/
Expand Down
2 changes: 1 addition & 1 deletion packages/types/src/zod/data-display.zod.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -241,7 +241,7 @@ export const DataTableSchema = BaseSchema.extend({
type: z.literal('data-table'),
caption: z.string().optional().describe('Table caption'),
borderless: z.boolean().optional().describe('Render the table without its outer rounded border (for embedding inside grouped rows or other containers).'),
toolbar: z.union([SchemaNodeSchema, z.array(SchemaNodeSchema)]).optional().describe('Toolbar content'),
toolbar: retirementTombstone('RETIRED (objectui#6881) — never mounted by the data-table renderer; use the built-in toolbar chrome (searchable / exportable), or compose nodes beside the table'),
columns: z.array(TableColumnSchema).describe('Table columns'),
data: z.array(z.any()).describe('Table data'),
pagination: z.boolean().optional().describe('Enable pagination'),
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 60 additions & 0 deletions .changeset/6881-retire-data-table-toolbar.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
---
'@object-ui/types': minor
---

**Breaking for authored metadata:** `DataTableSchema.toolbar` is RETIRED
(objectui#6881, maintainer ruling 2026-08-31). A `data-table` node that authors
`toolbar` no longer validates — the parse fails loudly on the `toolbar` path
with the remediation in the message — and the TS member is a `?: never`
tombstone, so the same document is refused at compile time.

**What was measured.** The key was declared on both published faces —
`data-display.ts` (`toolbar?: SchemaNode[]`, "Table toolbar actions/content")
and the Zod mirror (`SchemaNode | SchemaNode[]`) — documented, mirrored, and
read by NOTHING: `data-table.tsx`, the registered renderer for
`type: 'data-table'`, contains the word only in two prose comments and never
reads `schema.toolbar`. The sibling `emptyAction` slot on the same interface IS
mounted through `SchemaRenderer`, so the census zero is a reading, not a blind
query. An author who wrote a toolbar got a green document and a blank result,
with no signal anywhere that said so — the declared-vs-enforced failure mode
that is worst for AI-authored metadata, which has nothing but the declaration
to go on.

**Who is affected — a `toolbar` authored directly onto a `data-table` node,
in either spelling:**

```json
{ "type": "data-table",
"columns": [{ "header": "Name", "accessorKey": "name" }],
"data": [],
"toolbar": [{ "type": "button", "label": "Refresh" }] } // ← was tolerated, rendered nothing
```

now fails validation with:

> RETIRED (objectui#6881) — never mounted by the data-table renderer; use the
> built-in toolbar chrome (searchable / exportable), or compose nodes beside
> the table

The single-node spelling `"toolbar": { … }` — which only the Zod mirror ever
accepted; the TS face always refused it — is refused the same way, so the two
faces now agree by refusing both.

**Who is NOT affected.** A document that never wrote the key is untouched
(`absent` stays valid), and every other `SchemaNode` slot — `emptyAction`
included — is unchanged. No fixture, example, catalog entry, doc page or app
in this repository authored the key (measured: all five
`components-complex-data-table` catalog schemas are toolbar-free, and every
other `toolbar` occurrence repo-wide is an i18n key, an ARIA role, or an
unrelated React prop of the same name).

**Migration:** use the built-in toolbar chrome (`searchable` / `exportable`),
or compose your own nodes beside the table. Per the ruling, a real toolbar
slot must arrive as a redesigned proposal WITH its enforcing reader — published
zero-consumer capability gets no sunk-cost exemption.

Graded `minor`, not `patch`: this narrows the accepted input set, which is
breaking for any author who wrote the tolerated key. It is not `major` per
this repo's fixed-group convention (objectui's own breaking changes ship as
`minor`; the group's major tracks `@objectstack` — AGENTS.md 版本号策略,
mechanically enforced by `scripts/check-changeset-no-major.mjs`).
194 changes: 194 additions & 0 deletions packages/types/src/__tests__/data-table-toolbar-retired.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,194 @@
/**
* 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 — `DataTableSchema.toolbar` is REFUSED, not silently ignored
* (objectui#6881, maintainer ruling 2026-08-31: retire, do NOT wire).
*
* ## The failure this pin exists to prevent
*
* `toolbar` was declared on both published faces — `data-display.ts` and the
* Zod mirror — documented, mirrored, and read by NOTHING: `data-table.tsx`
* never mounts it (the word appears there only in two prose comments), while
* the sibling `emptyAction` slot on the SAME interface is mounted through
* `SchemaRenderer`, so the census zero is a reading, not a blind query. An
* author who wrote a toolbar got a green document and a blank result, with no
* signal anywhere that said so — the declared-vs-enforced failure mode, worst
* for AI-authored metadata, which has nothing but the declaration to go on.
*
* So the deliverable is not "toolbar renders". It is: **an authored `toolbar`
* is refused loudly at the authoring boundary**, with the remediation in the
* refusal (the built-in chrome: `searchable` / `exportable`). The ruling
* records that a real toolbar slot must arrive as a redesigned proposal WITH
* its enforcing reader — not by reviving this key.
*
* ## Why the tombstone, and not simply deleting the key
*
* `BaseSchema` is `.passthrough()` on the Zod side and carries a
* `[key: string]: any` index signature on the TS side. An UNDECLARED key is
* accepted by both halves, unvalidated — deleting `toolbar` outright would
* hand the authored spelling exactly the silent no-op this card exists to
* close. `?: never` / `retirementTombstone()` is this package's convention —
* {@link StaticTableColumn} (objectui#5474), `crud.ts` `confirm`
* (objectui#4314), `TimelineSchema.timeScale` (objectui#6355) — and it is
* lockstep: both halves or neither.
*
* ## The rider the retirement settles by construction
*
* Before this card the two faces disagreed on the SHAPE: TS said
* `SchemaNode[]`, the mirror admitted `SchemaNode | SchemaNode[]` — a
* mirror-wider-than-declared drift no parity ledger watches. Retiring both
* faces in the same stroke makes them agree by refusing BOTH spellings, so
* this file pins the single-node spelling refused too — the half only the
* mirror ever accepted.
*/

import { describe, it, expect } from 'vitest';
import { DataTableSchema } from '../zod/data-display.zod.js';
import type { DataTableSchema as DataTableSchemaTS, TableColumn } from '../data-display.js';

const GUIDANCE =
'RETIRED (objectui#6881) — never mounted by the data-table renderer; use the built-in toolbar chrome (searchable / exportable), or compose nodes beside the table';

/** A minimal document that is valid TODAY and stays valid — the inside of the boundary. */
const VALID_TABLE = {
type: 'data-table',
columns: [{ header: 'Name', accessorKey: 'name' }],
data: [],
} as const;

describe('DataTableSchema.toolbar is RETIRED — the Zod half of the tombstone (objectui#6881)', () => {
it('REFUSES the array spelling, naming the retired key', () => {
// The pin. Before the retirement this document parsed GREEN (`toolbar` was
// `z.union([SchemaNodeSchema, z.array(SchemaNodeSchema)]).optional()`),
// measured ACCEPTED on the retiring PR's base. Asserting the ENVELOPE —
// not merely `success:false` — so the pin cannot be satisfied by an
// unrelated rejection.
const result = DataTableSchema.safeParse({
...VALID_TABLE,
toolbar: [{ type: 'button', label: 'Refresh' }],
});
expect(result.success, 'an authored toolbar was ACCEPTED — it will render as a blank result with no signal').toBe(false);
if (result.success) return;

const issue = result.error.issues.find((i) => i.path[0] === 'toolbar');
expect(issue, 'parse failed, but not on the `toolbar` path').toBeTruthy();
expect(issue?.code).toBe('invalid_type');
expect((issue as { expected?: string } | undefined)?.expected).toBe('never');
});

it('REFUSES the single-node spelling — the half only the mirror ever accepted', () => {
// Pre-retirement, TS refused this spelling while the mirror admitted it
// (the drift recorded on objectui#6881 as the secondary observation). The
// two faces now agree by refusing both.
const result = DataTableSchema.safeParse({
...VALID_TABLE,
toolbar: { type: 'button', label: 'Refresh' },
});
expect(result.success).toBe(false);
if (result.success) return;
const issue = result.error.issues.find((i) => i.path[0] === 'toolbar');
expect(issue?.code).toBe('invalid_type');
});

it('the refusal CARRIES the remediation text, not zod\'s generic message', () => {
// `retirementTombstone()` writes the guidance once into the parse message
// and `.describe()` both (objectui#6931) — the author is told what to
// write instead: the built-in chrome.
const result = DataTableSchema.safeParse({
...VALID_TABLE,
toolbar: [{ type: 'button', label: 'Refresh' }],
});
expect(result.success).toBe(false);
if (result.success) return;

const issue = result.error.issues.find((i) => i.path[0] === 'toolbar');
expect(issue?.message).not.toContain('Invalid input: expected never, received ');
expect(issue?.message).toBe(GUIDANCE);
// ONE string, BOTH channels — asserted derived, so parse message and
// generated-docs metadata cannot drift apart.
expect(issue?.message).toBe(
(DataTableSchema.shape.toolbar as { description?: string }).description,
);
});

it('leaves a document that never wrote the key untouched — the inside of the boundary', () => {
// `absent` stays valid — `.optional()` on the tombstone. The retirement
// narrows exactly one key and nothing else.
expect(DataTableSchema.safeParse(VALID_TABLE).success).toBe(true);
});

it('still ACCEPTS the sibling `emptyAction` SchemaNode slot — the counter-probe', () => {
// `emptyAction` is the slot the census used as its positive control: same
// interface, same SchemaNode shape, actually mounted (data-table.tsx, via
// SchemaRenderer). Without this leg the refusals above would be satisfied
// by a schema that refuses every SchemaNode slot — a narrowing that
// refuses too much would pass a refusal-only test.
const result = DataTableSchema.safeParse({
...VALID_TABLE,
emptyAction: { type: 'button', label: 'New' },
});
expect(result.success ? null : result.error.issues).toBe(null);
});

it('keeps `toolbar` DECLARED — a tombstone, not a deletion', () => {
// The route guard. `BaseSchema` is `.passthrough()`, so removing the key
// from the mirror would make the authored spelling parse green again and
// do nothing — the silent no-op reintroduced by the very edit meant to
// remove it.
expect(
Object.keys(DataTableSchema.shape),
'toolbar left the mirror — under .passthrough() the retired key becomes a SILENT no-op again',
).toContain('toolbar');
});
});

describe('DataTableSchema.toolbar is RETIRED — the TS half of the tombstone (objectui#6881)', () => {
it('refuses the retired key at compile time', () => {
// On the pre-fix tree `toolbar` is `SchemaNode[] | undefined`, so the
// assignment is LEGAL, the directive below is unused, and `tsc` fails the
// build with TS2578 naming the key — this leg is red before the fix in
// `type-check`, not in vitest, which strips types.

// @ts-expect-error — `toolbar` is RETIRED (objectui#6881): declared `?: never`, so no value is authorable.
const retired: DataTableSchemaTS['toolbar'] = [{ type: 'button' }];

// Counter-probe on the same surface: the sibling SchemaNode slot still
// accepts a node, so the directive above pins the KEY's retirement and not
// a blanket narrowing of the interface.
const sibling: DataTableSchemaTS['emptyAction'] = { type: 'button' };

expect([retired, sibling]).toHaveLength(2);
});

it('refuses the retired key in the form authors actually write', () => {
// The leg that proves the tombstone survives `BaseSchema`'s
// `[key: string]: any`: if the index signature won, `toolbar` would widen
// back to `any` here and the directive would go unused (TS2578).
const columns: TableColumn[] = [{ header: 'Name', accessorKey: 'name' }];

const retiredDocument: DataTableSchemaTS = {
type: 'data-table',
columns,
data: [],
// @ts-expect-error — `toolbar` is RETIRED (objectui#6881); use the built-in chrome (`searchable` / `exportable`).
toolbar: [{ type: 'button', label: 'Refresh' }],
};

// The migrated document — built-in chrome instead — still type-checks.
const migratedDocument: DataTableSchemaTS = {
type: 'data-table',
columns,
data: [],
searchable: true,
exportable: true,
};

expect([retiredDocument, migratedDocument]).toHaveLength(2);
});
});
26 changes: 24 additions & 2 deletions packages/types/src/data-display.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -670,9 +670,31 @@ export interface DataTableSchema extends BaseSchema {
*/
caption?: string;
/**
* Table toolbar actions/content
* ADR-0049 RETIREMENT TOMBSTONE — `toolbar` (objectui#6881, maintainer
* ruling 2026-08-31: retire, do NOT wire).
*
* What was measured (objectui#6881, re-measured on the retiring PR's base):
* declared on both published faces, documented, mirrored — and read by
* NOTHING. `data-table.tsx`, the registered renderer for `type:
* 'data-table'`, contains the word only in two prose comments and never
* reads `schema.toolbar`; the sibling `emptyAction` slot on this same
* interface IS mounted through `SchemaRenderer`, so the zero is a reading,
* not a blind query. An author who wrote a toolbar got a green document and
* a blank result, with no signal anywhere that said so.
*
* `?: never` is this package's tombstone convention (see `crud.ts`
* `confirm`, {@link StaticTableColumn}, `TimelineSchema`'s `timeScale`), NOT
* a deletion: `BaseSchema`'s `[key: string]: any` would admit a deleted key
* as `any` again — the same silence one layer over. The Zod twin refuses it
* loudly via `retirementTombstone()` (`zod/data-display.zod.ts`).
*
* RETIRED (objectui#6881, ADR-0049) — never mounted by the data-table
* renderer. Use the built-in toolbar chrome instead (`searchable` /
* `exportable`), or compose your own nodes beside the table. A real
* toolbar slot must arrive as a redesigned proposal WITH its enforcing
* reader, per the ruling — not by reviving this key.
*/
toolbar?: SchemaNode[];
toolbar?: never;
/**
* Table columns
*/
Expand Down
2 changes: 1 addition & 1 deletion packages/types/src/zod/data-display.zod.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -241,7 +241,7 @@ export const DataTableSchema = BaseSchema.extend({
type: z.literal('data-table'),
caption: z.string().optional().describe('Table caption'),
borderless: z.boolean().optional().describe('Render the table without its outer rounded border (for embedding inside grouped rows or other containers).'),
toolbar: z.union([SchemaNodeSchema, z.array(SchemaNodeSchema)]).optional().describe('Toolbar content'),
toolbar: retirementTombstone('RETIRED (objectui#6881) — never mounted by the data-table renderer; use the built-in toolbar chrome (searchable / exportable), or compose nodes beside the table'),
columns: z.array(TableColumnSchema).describe('Table columns'),
data: z.array(z.any()).describe('Table data'),
pagination: z.boolean().optional().describe('Enable pagination'),
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 60 additions & 0 deletions .changeset/6881-retire-data-table-toolbar.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
---
'@object-ui/types': minor
---

**Breaking for authored metadata:** `DataTableSchema.toolbar` is RETIRED
(objectui#6881, maintainer ruling 2026-08-31). A `data-table` node that authors
`toolbar` no longer validates — the parse fails loudly on the `toolbar` path
with the remediation in the message — and the TS member is a `?: never`
tombstone, so the same document is refused at compile time.

**What was measured.** The key was declared on both published faces —
`data-display.ts` (`toolbar?: SchemaNode[]`, "Table toolbar actions/content")
and the Zod mirror (`SchemaNode | SchemaNode[]`) — documented, mirrored, and
read by NOTHING: `data-table.tsx`, the registered renderer for
`type: 'data-table'`, contains the word only in two prose comments and never
reads `schema.toolbar`. The sibling `emptyAction` slot on the same interface IS
mounted through `SchemaRenderer`, so the census zero is a reading, not a blind
query. An author who wrote a toolbar got a green document and a blank result,
with no signal anywhere that said so — the declared-vs-enforced failure mode
that is worst for AI-authored metadata, which has nothing but the declaration
to go on.

**Who is affected — a `toolbar` authored directly onto a `data-table` node,
in either spelling:**

```json
{ "type": "data-table",
"columns": [{ "header": "Name", "accessorKey": "name" }],
"data": [],
"toolbar": [{ "type": "button", "label": "Refresh" }] } // ← was tolerated, rendered nothing
```

now fails validation with:

> RETIRED (objectui#6881) — never mounted by the data-table renderer; use the
> built-in toolbar chrome (searchable / exportable), or compose nodes beside
> the table

The single-node spelling `"toolbar": { … }` — which only the Zod mirror ever
accepted; the TS face always refused it — is refused the same way, so the two
faces now agree by refusing both.

**Who is NOT affected.** A document that never wrote the key is untouched
(`absent` stays valid), and every other `SchemaNode` slot — `emptyAction`
included — is unchanged. No fixture, example, catalog entry, doc page or app
in this repository authored the key (measured: all five
`components-complex-data-table` catalog schemas are toolbar-free, and every
other `toolbar` occurrence repo-wide is an i18n key, an ARIA role, or an
unrelated React prop of the same name).

**Migration:** use the built-in toolbar chrome (`searchable` / `exportable`),
or compose your own nodes beside the table. Per the ruling, a real toolbar
slot must arrive as a redesigned proposal WITH its enforcing reader — published
zero-consumer capability gets no sunk-cost exemption.

Graded `minor`, not `patch`: this narrows the accepted input set, which is
breaking for any author who wrote the tolerated key. It is not `major` per
this repo's fixed-group convention (objectui's own breaking changes ship as
`minor`; the group's major tracks `@objectstack` — AGENTS.md 版本号策略,
mechanically enforced by `scripts/check-changeset-no-major.mjs`).
194 changes: 194 additions & 0 deletions packages/types/src/__tests__/data-table-toolbar-retired.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,194 @@
/**
* 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 — `DataTableSchema.toolbar` is REFUSED, not silently ignored
* (objectui#6881, maintainer ruling 2026-08-31: retire, do NOT wire).
*
* ## The failure this pin exists to prevent
*
* `toolbar` was declared on both published faces — `data-display.ts` and the
* Zod mirror — documented, mirrored, and read by NOTHING: `data-table.tsx`
* never mounts it (the word appears there only in two prose comments), while
* the sibling `emptyAction` slot on the SAME interface is mounted through
* `SchemaRenderer`, so the census zero is a reading, not a blind query. An
* author who wrote a toolbar got a green document and a blank result, with no
* signal anywhere that said so — the declared-vs-enforced failure mode, worst
* for AI-authored metadata, which has nothing but the declaration to go on.
*
* So the deliverable is not "toolbar renders". It is: **an authored `toolbar`
* is refused loudly at the authoring boundary**, with the remediation in the
* refusal (the built-in chrome: `searchable` / `exportable`). The ruling
* records that a real toolbar slot must arrive as a redesigned proposal WITH
* its enforcing reader — not by reviving this key.
*
* ## Why the tombstone, and not simply deleting the key
*
* `BaseSchema` is `.passthrough()` on the Zod side and carries a
* `[key: string]: any` index signature on the TS side. An UNDECLARED key is
* accepted by both halves, unvalidated — deleting `toolbar` outright would
* hand the authored spelling exactly the silent no-op this card exists to
* close. `?: never` / `retirementTombstone()` is this package's convention —
* {@link StaticTableColumn} (objectui#5474), `crud.ts` `confirm`
* (objectui#4314), `TimelineSchema.timeScale` (objectui#6355) — and it is
* lockstep: both halves or neither.
*
* ## The rider the retirement settles by construction
*
* Before this card the two faces disagreed on the SHAPE: TS said
* `SchemaNode[]`, the mirror admitted `SchemaNode | SchemaNode[]` — a
* mirror-wider-than-declared drift no parity ledger watches. Retiring both
* faces in the same stroke makes them agree by refusing BOTH spellings, so
* this file pins the single-node spelling refused too — the half only the
* mirror ever accepted.
*/

import { describe, it, expect } from 'vitest';
import { DataTableSchema } from '../zod/data-display.zod.js';
import type { DataTableSchema as DataTableSchemaTS, TableColumn } from '../data-display.js';

const GUIDANCE =
'RETIRED (objectui#6881) — never mounted by the data-table renderer; use the built-in toolbar chrome (searchable / exportable), or compose nodes beside the table';

/** A minimal document that is valid TODAY and stays valid — the inside of the boundary. */
const VALID_TABLE = {
type: 'data-table',
columns: [{ header: 'Name', accessorKey: 'name' }],
data: [],
} as const;

describe('DataTableSchema.toolbar is RETIRED — the Zod half of the tombstone (objectui#6881)', () => {
it('REFUSES the array spelling, naming the retired key', () => {
// The pin. Before the retirement this document parsed GREEN (`toolbar` was
// `z.union([SchemaNodeSchema, z.array(SchemaNodeSchema)]).optional()`),
// measured ACCEPTED on the retiring PR's base. Asserting the ENVELOPE —
// not merely `success:false` — so the pin cannot be satisfied by an
// unrelated rejection.
const result = DataTableSchema.safeParse({
...VALID_TABLE,
toolbar: [{ type: 'button', label: 'Refresh' }],
});
expect(result.success, 'an authored toolbar was ACCEPTED — it will render as a blank result with no signal').toBe(false);
if (result.success) return;

const issue = result.error.issues.find((i) => i.path[0] === 'toolbar');
expect(issue, 'parse failed, but not on the `toolbar` path').toBeTruthy();
expect(issue?.code).toBe('invalid_type');
expect((issue as { expected?: string } | undefined)?.expected).toBe('never');
});

it('REFUSES the single-node spelling — the half only the mirror ever accepted', () => {
// Pre-retirement, TS refused this spelling while the mirror admitted it
// (the drift recorded on objectui#6881 as the secondary observation). The
// two faces now agree by refusing both.
const result = DataTableSchema.safeParse({
...VALID_TABLE,
toolbar: { type: 'button', label: 'Refresh' },
});
expect(result.success).toBe(false);
if (result.success) return;
const issue = result.error.issues.find((i) => i.path[0] === 'toolbar');
expect(issue?.code).toBe('invalid_type');
});

it('the refusal CARRIES the remediation text, not zod\'s generic message', () => {
// `retirementTombstone()` writes the guidance once into the parse message
// and `.describe()` both (objectui#6931) — the author is told what to
// write instead: the built-in chrome.
const result = DataTableSchema.safeParse({
...VALID_TABLE,
toolbar: [{ type: 'button', label: 'Refresh' }],
});
expect(result.success).toBe(false);
if (result.success) return;

const issue = result.error.issues.find((i) => i.path[0] === 'toolbar');
expect(issue?.message).not.toContain('Invalid input: expected never, received ');
expect(issue?.message).toBe(GUIDANCE);
// ONE string, BOTH channels — asserted derived, so parse message and
// generated-docs metadata cannot drift apart.
expect(issue?.message).toBe(
(DataTableSchema.shape.toolbar as { description?: string }).description,
);
});

it('leaves a document that never wrote the key untouched — the inside of the boundary', () => {
// `absent` stays valid — `.optional()` on the tombstone. The retirement
// narrows exactly one key and nothing else.
expect(DataTableSchema.safeParse(VALID_TABLE).success).toBe(true);
});

it('still ACCEPTS the sibling `emptyAction` SchemaNode slot — the counter-probe', () => {
// `emptyAction` is the slot the census used as its positive control: same
// interface, same SchemaNode shape, actually mounted (data-table.tsx, via
// SchemaRenderer). Without this leg the refusals above would be satisfied
// by a schema that refuses every SchemaNode slot — a narrowing that
// refuses too much would pass a refusal-only test.
const result = DataTableSchema.safeParse({
...VALID_TABLE,
emptyAction: { type: 'button', label: 'New' },
});
expect(result.success ? null : result.error.issues).toBe(null);
});

it('keeps `toolbar` DECLARED — a tombstone, not a deletion', () => {
// The route guard. `BaseSchema` is `.passthrough()`, so removing the key
// from the mirror would make the authored spelling parse green again and
// do nothing — the silent no-op reintroduced by the very edit meant to
// remove it.
expect(
Object.keys(DataTableSchema.shape),
'toolbar left the mirror — under .passthrough() the retired key becomes a SILENT no-op again',
).toContain('toolbar');
});
});

describe('DataTableSchema.toolbar is RETIRED — the TS half of the tombstone (objectui#6881)', () => {
it('refuses the retired key at compile time', () => {
// On the pre-fix tree `toolbar` is `SchemaNode[] | undefined`, so the
// assignment is LEGAL, the directive below is unused, and `tsc` fails the
// build with TS2578 naming the key — this leg is red before the fix in
// `type-check`, not in vitest, which strips types.

// @ts-expect-error — `toolbar` is RETIRED (objectui#6881): declared `?: never`, so no value is authorable.
const retired: DataTableSchemaTS['toolbar'] = [{ type: 'button' }];

// Counter-probe on the same surface: the sibling SchemaNode slot still
// accepts a node, so the directive above pins the KEY's retirement and not
// a blanket narrowing of the interface.
const sibling: DataTableSchemaTS['emptyAction'] = { type: 'button' };

expect([retired, sibling]).toHaveLength(2);
});

it('refuses the retired key in the form authors actually write', () => {
// The leg that proves the tombstone survives `BaseSchema`'s
// `[key: string]: any`: if the index signature won, `toolbar` would widen
// back to `any` here and the directive would go unused (TS2578).
const columns: TableColumn[] = [{ header: 'Name', accessorKey: 'name' }];

const retiredDocument: DataTableSchemaTS = {
type: 'data-table',
columns,
data: [],
// @ts-expect-error — `toolbar` is RETIRED (objectui#6881); use the built-in chrome (`searchable` / `exportable`).
toolbar: [{ type: 'button', label: 'Refresh' }],
};

// The migrated document — built-in chrome instead — still type-checks.
const migratedDocument: DataTableSchemaTS = {
type: 'data-table',
columns,
data: [],
searchable: true,
exportable: true,
};

expect([retiredDocument, migratedDocument]).toHaveLength(2);
});
});
26 changes: 24 additions & 2 deletions packages/types/src/data-display.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -670,9 +670,31 @@ export interface DataTableSchema extends BaseSchema {
*/
caption?: string;
/**
* Table toolbar actions/content
* ADR-0049 RETIREMENT TOMBSTONE — `toolbar` (objectui#6881, maintainer
* ruling 2026-08-31: retire, do NOT wire).
*
* What was measured (objectui#6881, re-measured on the retiring PR's base):
* declared on both published faces, documented, mirrored — and read by
* NOTHING. `data-table.tsx`, the registered renderer for `type:
* 'data-table'`, contains the word only in two prose comments and never
* reads `schema.toolbar`; the sibling `emptyAction` slot on this same
* interface IS mounted through `SchemaRenderer`, so the zero is a reading,
* not a blind query. An author who wrote a toolbar got a green document and
* a blank result, with no signal anywhere that said so.
*
* `?: never` is this package's tombstone convention (see `crud.ts`
* `confirm`, {@link StaticTableColumn}, `TimelineSchema`'s `timeScale`), NOT
* a deletion: `BaseSchema`'s `[key: string]: any` would admit a deleted key
* as `any` again — the same silence one layer over. The Zod twin refuses it
* loudly via `retirementTombstone()` (`zod/data-display.zod.ts`).
*
* RETIRED (objectui#6881, ADR-0049) — never mounted by the data-table
* renderer. Use the built-in toolbar chrome instead (`searchable` /
* `exportable`), or compose your own nodes beside the table. A real
* toolbar slot must arrive as a redesigned proposal WITH its enforcing
* reader, per the ruling — not by reviving this key.
*/
toolbar?: SchemaNode[];
toolbar?: never;
/**
* Table columns
*/
Expand Down
2 changes: 1 addition & 1 deletion packages/types/src/zod/data-display.zod.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -241,7 +241,7 @@ export const DataTableSchema = BaseSchema.extend({
type: z.literal('data-table'),
caption: z.string().optional().describe('Table caption'),
borderless: z.boolean().optional().describe('Render the table without its outer rounded border (for embedding inside grouped rows or other containers).'),
toolbar: z.union([SchemaNodeSchema, z.array(SchemaNodeSchema)]).optional().describe('Toolbar content'),
toolbar: retirementTombstone('RETIRED (objectui#6881) — never mounted by the data-table renderer; use the built-in toolbar chrome (searchable / exportable), or compose nodes beside the table'),
columns: z.array(TableColumnSchema).describe('Table columns'),
data: z.array(z.any()).describe('Table data'),
pagination: z.boolean().optional().describe('Enable pagination'),
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 60 additions & 0 deletions .changeset/6881-retire-data-table-toolbar.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
---
'@object-ui/types': minor
---

**Breaking for authored metadata:** `DataTableSchema.toolbar` is RETIRED
(objectui#6881, maintainer ruling 2026-08-31). A `data-table` node that authors
`toolbar` no longer validates — the parse fails loudly on the `toolbar` path
with the remediation in the message — and the TS member is a `?: never`
tombstone, so the same document is refused at compile time.

**What was measured.** The key was declared on both published faces —
`data-display.ts` (`toolbar?: SchemaNode[]`, "Table toolbar actions/content")
and the Zod mirror (`SchemaNode | SchemaNode[]`) — documented, mirrored, and
read by NOTHING: `data-table.tsx`, the registered renderer for
`type: 'data-table'`, contains the word only in two prose comments and never
reads `schema.toolbar`. The sibling `emptyAction` slot on the same interface IS
mounted through `SchemaRenderer`, so the census zero is a reading, not a blind
query. An author who wrote a toolbar got a green document and a blank result,
with no signal anywhere that said so — the declared-vs-enforced failure mode
that is worst for AI-authored metadata, which has nothing but the declaration
to go on.

**Who is affected — a `toolbar` authored directly onto a `data-table` node,
in either spelling:**

```json
{ "type": "data-table",
"columns": [{ "header": "Name", "accessorKey": "name" }],
"data": [],
"toolbar": [{ "type": "button", "label": "Refresh" }] } // ← was tolerated, rendered nothing
```

now fails validation with:

> RETIRED (objectui#6881) — never mounted by the data-table renderer; use the
> built-in toolbar chrome (searchable / exportable), or compose nodes beside
> the table

The single-node spelling `"toolbar": { … }` — which only the Zod mirror ever
accepted; the TS face always refused it — is refused the same way, so the two
faces now agree by refusing both.

**Who is NOT affected.** A document that never wrote the key is untouched
(`absent` stays valid), and every other `SchemaNode` slot — `emptyAction`
included — is unchanged. No fixture, example, catalog entry, doc page or app
in this repository authored the key (measured: all five
`components-complex-data-table` catalog schemas are toolbar-free, and every
other `toolbar` occurrence repo-wide is an i18n key, an ARIA role, or an
unrelated React prop of the same name).

**Migration:** use the built-in toolbar chrome (`searchable` / `exportable`),
or compose your own nodes beside the table. Per the ruling, a real toolbar
slot must arrive as a redesigned proposal WITH its enforcing reader — published
zero-consumer capability gets no sunk-cost exemption.

Graded `minor`, not `patch`: this narrows the accepted input set, which is
breaking for any author who wrote the tolerated key. It is not `major` per
this repo's fixed-group convention (objectui's own breaking changes ship as
`minor`; the group's major tracks `@objectstack` — AGENTS.md 版本号策略,
mechanically enforced by `scripts/check-changeset-no-major.mjs`).
194 changes: 194 additions & 0 deletions packages/types/src/__tests__/data-table-toolbar-retired.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,194 @@
/**
* 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 — `DataTableSchema.toolbar` is REFUSED, not silently ignored
* (objectui#6881, maintainer ruling 2026-08-31: retire, do NOT wire).
*
* ## The failure this pin exists to prevent
*
* `toolbar` was declared on both published faces — `data-display.ts` and the
* Zod mirror — documented, mirrored, and read by NOTHING: `data-table.tsx`
* never mounts it (the word appears there only in two prose comments), while
* the sibling `emptyAction` slot on the SAME interface is mounted through
* `SchemaRenderer`, so the census zero is a reading, not a blind query. An
* author who wrote a toolbar got a green document and a blank result, with no
* signal anywhere that said so — the declared-vs-enforced failure mode, worst
* for AI-authored metadata, which has nothing but the declaration to go on.
*
* So the deliverable is not "toolbar renders". It is: **an authored `toolbar`
* is refused loudly at the authoring boundary**, with the remediation in the
* refusal (the built-in chrome: `searchable` / `exportable`). The ruling
* records that a real toolbar slot must arrive as a redesigned proposal WITH
* its enforcing reader — not by reviving this key.
*
* ## Why the tombstone, and not simply deleting the key
*
* `BaseSchema` is `.passthrough()` on the Zod side and carries a
* `[key: string]: any` index signature on the TS side. An UNDECLARED key is
* accepted by both halves, unvalidated — deleting `toolbar` outright would
* hand the authored spelling exactly the silent no-op this card exists to
* close. `?: never` / `retirementTombstone()` is this package's convention —
* {@link StaticTableColumn} (objectui#5474), `crud.ts` `confirm`
* (objectui#4314), `TimelineSchema.timeScale` (objectui#6355) — and it is
* lockstep: both halves or neither.
*
* ## The rider the retirement settles by construction
*
* Before this card the two faces disagreed on the SHAPE: TS said
* `SchemaNode[]`, the mirror admitted `SchemaNode | SchemaNode[]` — a
* mirror-wider-than-declared drift no parity ledger watches. Retiring both
* faces in the same stroke makes them agree by refusing BOTH spellings, so
* this file pins the single-node spelling refused too — the half only the
* mirror ever accepted.
*/

import { describe, it, expect } from 'vitest';
import { DataTableSchema } from '../zod/data-display.zod.js';
import type { DataTableSchema as DataTableSchemaTS, TableColumn } from '../data-display.js';

const GUIDANCE =
'RETIRED (objectui#6881) — never mounted by the data-table renderer; use the built-in toolbar chrome (searchable / exportable), or compose nodes beside the table';

/** A minimal document that is valid TODAY and stays valid — the inside of the boundary. */
const VALID_TABLE = {
type: 'data-table',
columns: [{ header: 'Name', accessorKey: 'name' }],
data: [],
} as const;

describe('DataTableSchema.toolbar is RETIRED — the Zod half of the tombstone (objectui#6881)', () => {
it('REFUSES the array spelling, naming the retired key', () => {
// The pin. Before the retirement this document parsed GREEN (`toolbar` was
// `z.union([SchemaNodeSchema, z.array(SchemaNodeSchema)]).optional()`),
// measured ACCEPTED on the retiring PR's base. Asserting the ENVELOPE —
// not merely `success:false` — so the pin cannot be satisfied by an
// unrelated rejection.
const result = DataTableSchema.safeParse({
...VALID_TABLE,
toolbar: [{ type: 'button', label: 'Refresh' }],
});
expect(result.success, 'an authored toolbar was ACCEPTED — it will render as a blank result with no signal').toBe(false);
if (result.success) return;

const issue = result.error.issues.find((i) => i.path[0] === 'toolbar');
expect(issue, 'parse failed, but not on the `toolbar` path').toBeTruthy();
expect(issue?.code).toBe('invalid_type');
expect((issue as { expected?: string } | undefined)?.expected).toBe('never');
});

it('REFUSES the single-node spelling — the half only the mirror ever accepted', () => {
// Pre-retirement, TS refused this spelling while the mirror admitted it
// (the drift recorded on objectui#6881 as the secondary observation). The
// two faces now agree by refusing both.
const result = DataTableSchema.safeParse({
...VALID_TABLE,
toolbar: { type: 'button', label: 'Refresh' },
});
expect(result.success).toBe(false);
if (result.success) return;
const issue = result.error.issues.find((i) => i.path[0] === 'toolbar');
expect(issue?.code).toBe('invalid_type');
});

it('the refusal CARRIES the remediation text, not zod\'s generic message', () => {
// `retirementTombstone()` writes the guidance once into the parse message
// and `.describe()` both (objectui#6931) — the author is told what to
// write instead: the built-in chrome.
const result = DataTableSchema.safeParse({
...VALID_TABLE,
toolbar: [{ type: 'button', label: 'Refresh' }],
});
expect(result.success).toBe(false);
if (result.success) return;

const issue = result.error.issues.find((i) => i.path[0] === 'toolbar');
expect(issue?.message).not.toContain('Invalid input: expected never, received ');
expect(issue?.message).toBe(GUIDANCE);
// ONE string, BOTH channels — asserted derived, so parse message and
// generated-docs metadata cannot drift apart.
expect(issue?.message).toBe(
(DataTableSchema.shape.toolbar as { description?: string }).description,
);
});

it('leaves a document that never wrote the key untouched — the inside of the boundary', () => {
// `absent` stays valid — `.optional()` on the tombstone. The retirement
// narrows exactly one key and nothing else.
expect(DataTableSchema.safeParse(VALID_TABLE).success).toBe(true);
});

it('still ACCEPTS the sibling `emptyAction` SchemaNode slot — the counter-probe', () => {
// `emptyAction` is the slot the census used as its positive control: same
// interface, same SchemaNode shape, actually mounted (data-table.tsx, via
// SchemaRenderer). Without this leg the refusals above would be satisfied
// by a schema that refuses every SchemaNode slot — a narrowing that
// refuses too much would pass a refusal-only test.
const result = DataTableSchema.safeParse({
...VALID_TABLE,
emptyAction: { type: 'button', label: 'New' },
});
expect(result.success ? null : result.error.issues).toBe(null);
});

it('keeps `toolbar` DECLARED — a tombstone, not a deletion', () => {
// The route guard. `BaseSchema` is `.passthrough()`, so removing the key
// from the mirror would make the authored spelling parse green again and
// do nothing — the silent no-op reintroduced by the very edit meant to
// remove it.
expect(
Object.keys(DataTableSchema.shape),
'toolbar left the mirror — under .passthrough() the retired key becomes a SILENT no-op again',
).toContain('toolbar');
});
});

describe('DataTableSchema.toolbar is RETIRED — the TS half of the tombstone (objectui#6881)', () => {
it('refuses the retired key at compile time', () => {
// On the pre-fix tree `toolbar` is `SchemaNode[] | undefined`, so the
// assignment is LEGAL, the directive below is unused, and `tsc` fails the
// build with TS2578 naming the key — this leg is red before the fix in
// `type-check`, not in vitest, which strips types.

// @ts-expect-error — `toolbar` is RETIRED (objectui#6881): declared `?: never`, so no value is authorable.
const retired: DataTableSchemaTS['toolbar'] = [{ type: 'button' }];

// Counter-probe on the same surface: the sibling SchemaNode slot still
// accepts a node, so the directive above pins the KEY's retirement and not
// a blanket narrowing of the interface.
const sibling: DataTableSchemaTS['emptyAction'] = { type: 'button' };

expect([retired, sibling]).toHaveLength(2);
});

it('refuses the retired key in the form authors actually write', () => {
// The leg that proves the tombstone survives `BaseSchema`'s
// `[key: string]: any`: if the index signature won, `toolbar` would widen
// back to `any` here and the directive would go unused (TS2578).
const columns: TableColumn[] = [{ header: 'Name', accessorKey: 'name' }];

const retiredDocument: DataTableSchemaTS = {
type: 'data-table',
columns,
data: [],
// @ts-expect-error — `toolbar` is RETIRED (objectui#6881); use the built-in chrome (`searchable` / `exportable`).
toolbar: [{ type: 'button', label: 'Refresh' }],
};

// The migrated document — built-in chrome instead — still type-checks.
const migratedDocument: DataTableSchemaTS = {
type: 'data-table',
columns,
data: [],
searchable: true,
exportable: true,
};

expect([retiredDocument, migratedDocument]).toHaveLength(2);
});
});
26 changes: 24 additions & 2 deletions packages/types/src/data-display.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -670,9 +670,31 @@ export interface DataTableSchema extends BaseSchema {
*/
caption?: string;
/**
* Table toolbar actions/content
* ADR-0049 RETIREMENT TOMBSTONE — `toolbar` (objectui#6881, maintainer
* ruling 2026-08-31: retire, do NOT wire).
*
* What was measured (objectui#6881, re-measured on the retiring PR's base):
* declared on both published faces, documented, mirrored — and read by
* NOTHING. `data-table.tsx`, the registered renderer for `type:
* 'data-table'`, contains the word only in two prose comments and never
* reads `schema.toolbar`; the sibling `emptyAction` slot on this same
* interface IS mounted through `SchemaRenderer`, so the zero is a reading,
* not a blind query. An author who wrote a toolbar got a green document and
* a blank result, with no signal anywhere that said so.
*
* `?: never` is this package's tombstone convention (see `crud.ts`
* `confirm`, {@link StaticTableColumn}, `TimelineSchema`'s `timeScale`), NOT
* a deletion: `BaseSchema`'s `[key: string]: any` would admit a deleted key
* as `any` again — the same silence one layer over. The Zod twin refuses it
* loudly via `retirementTombstone()` (`zod/data-display.zod.ts`).
*
* RETIRED (objectui#6881, ADR-0049) — never mounted by the data-table
* renderer. Use the built-in toolbar chrome instead (`searchable` /
* `exportable`), or compose your own nodes beside the table. A real
* toolbar slot must arrive as a redesigned proposal WITH its enforcing
* reader, per the ruling — not by reviving this key.
*/
toolbar?: SchemaNode[];
toolbar?: never;
/**
* Table columns
*/
Expand Down
2 changes: 1 addition & 1 deletion packages/types/src/zod/data-display.zod.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -241,7 +241,7 @@ export const DataTableSchema = BaseSchema.extend({
type: z.literal('data-table'),
caption: z.string().optional().describe('Table caption'),
borderless: z.boolean().optional().describe('Render the table without its outer rounded border (for embedding inside grouped rows or other containers).'),
toolbar: z.union([SchemaNodeSchema, z.array(SchemaNodeSchema)]).optional().describe('Toolbar content'),
toolbar: retirementTombstone('RETIRED (objectui#6881) — never mounted by the data-table renderer; use the built-in toolbar chrome (searchable / exportable), or compose nodes beside the table'),
columns: z.array(TableColumnSchema).describe('Table columns'),
data: z.array(z.any()).describe('Table data'),
pagination: z.boolean().optional().describe('Enable pagination'),
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 60 additions & 0 deletions .changeset/6881-retire-data-table-toolbar.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
---
'@object-ui/types': minor
---

**Breaking for authored metadata:** `DataTableSchema.toolbar` is RETIRED
(objectui#6881, maintainer ruling 2026-08-31). A `data-table` node that authors
`toolbar` no longer validates — the parse fails loudly on the `toolbar` path
with the remediation in the message — and the TS member is a `?: never`
tombstone, so the same document is refused at compile time.

**What was measured.** The key was declared on both published faces —
`data-display.ts` (`toolbar?: SchemaNode[]`, "Table toolbar actions/content")
and the Zod mirror (`SchemaNode | SchemaNode[]`) — documented, mirrored, and
read by NOTHING: `data-table.tsx`, the registered renderer for
`type: 'data-table'`, contains the word only in two prose comments and never
reads `schema.toolbar`. The sibling `emptyAction` slot on the same interface IS
mounted through `SchemaRenderer`, so the census zero is a reading, not a blind
query. An author who wrote a toolbar got a green document and a blank result,
with no signal anywhere that said so — the declared-vs-enforced failure mode
that is worst for AI-authored metadata, which has nothing but the declaration
to go on.

**Who is affected — a `toolbar` authored directly onto a `data-table` node,
in either spelling:**

```json
{ "type": "data-table",
"columns": [{ "header": "Name", "accessorKey": "name" }],
"data": [],
"toolbar": [{ "type": "button", "label": "Refresh" }] } // ← was tolerated, rendered nothing
```

now fails validation with:

> RETIRED (objectui#6881) — never mounted by the data-table renderer; use the
> built-in toolbar chrome (searchable / exportable), or compose nodes beside
> the table

The single-node spelling `"toolbar": { … }` — which only the Zod mirror ever
accepted; the TS face always refused it — is refused the same way, so the two
faces now agree by refusing both.

**Who is NOT affected.** A document that never wrote the key is untouched
(`absent` stays valid), and every other `SchemaNode` slot — `emptyAction`
included — is unchanged. No fixture, example, catalog entry, doc page or app
in this repository authored the key (measured: all five
`components-complex-data-table` catalog schemas are toolbar-free, and every
other `toolbar` occurrence repo-wide is an i18n key, an ARIA role, or an
unrelated React prop of the same name).

**Migration:** use the built-in toolbar chrome (`searchable` / `exportable`),
or compose your own nodes beside the table. Per the ruling, a real toolbar
slot must arrive as a redesigned proposal WITH its enforcing reader — published
zero-consumer capability gets no sunk-cost exemption.

Graded `minor`, not `patch`: this narrows the accepted input set, which is
breaking for any author who wrote the tolerated key. It is not `major` per
this repo's fixed-group convention (objectui's own breaking changes ship as
`minor`; the group's major tracks `@objectstack` — AGENTS.md 版本号策略,
mechanically enforced by `scripts/check-changeset-no-major.mjs`).
194 changes: 194 additions & 0 deletions packages/types/src/__tests__/data-table-toolbar-retired.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,194 @@
/**
* 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 — `DataTableSchema.toolbar` is REFUSED, not silently ignored
* (objectui#6881, maintainer ruling 2026-08-31: retire, do NOT wire).
*
* ## The failure this pin exists to prevent
*
* `toolbar` was declared on both published faces — `data-display.ts` and the
* Zod mirror — documented, mirrored, and read by NOTHING: `data-table.tsx`
* never mounts it (the word appears there only in two prose comments), while
* the sibling `emptyAction` slot on the SAME interface is mounted through
* `SchemaRenderer`, so the census zero is a reading, not a blind query. An
* author who wrote a toolbar got a green document and a blank result, with no
* signal anywhere that said so — the declared-vs-enforced failure mode, worst
* for AI-authored metadata, which has nothing but the declaration to go on.
*
* So the deliverable is not "toolbar renders". It is: **an authored `toolbar`
* is refused loudly at the authoring boundary**, with the remediation in the
* refusal (the built-in chrome: `searchable` / `exportable`). The ruling
* records that a real toolbar slot must arrive as a redesigned proposal WITH
* its enforcing reader — not by reviving this key.
*
* ## Why the tombstone, and not simply deleting the key
*
* `BaseSchema` is `.passthrough()` on the Zod side and carries a
* `[key: string]: any` index signature on the TS side. An UNDECLARED key is
* accepted by both halves, unvalidated — deleting `toolbar` outright would
* hand the authored spelling exactly the silent no-op this card exists to
* close. `?: never` / `retirementTombstone()` is this package's convention —
* {@link StaticTableColumn} (objectui#5474), `crud.ts` `confirm`
* (objectui#4314), `TimelineSchema.timeScale` (objectui#6355) — and it is
* lockstep: both halves or neither.
*
* ## The rider the retirement settles by construction
*
* Before this card the two faces disagreed on the SHAPE: TS said
* `SchemaNode[]`, the mirror admitted `SchemaNode | SchemaNode[]` — a
* mirror-wider-than-declared drift no parity ledger watches. Retiring both
* faces in the same stroke makes them agree by refusing BOTH spellings, so
* this file pins the single-node spelling refused too — the half only the
* mirror ever accepted.
*/

import { describe, it, expect } from 'vitest';
import { DataTableSchema } from '../zod/data-display.zod.js';
import type { DataTableSchema as DataTableSchemaTS, TableColumn } from '../data-display.js';

const GUIDANCE =
'RETIRED (objectui#6881) — never mounted by the data-table renderer; use the built-in toolbar chrome (searchable / exportable), or compose nodes beside the table';

/** A minimal document that is valid TODAY and stays valid — the inside of the boundary. */
const VALID_TABLE = {
type: 'data-table',
columns: [{ header: 'Name', accessorKey: 'name' }],
data: [],
} as const;

describe('DataTableSchema.toolbar is RETIRED — the Zod half of the tombstone (objectui#6881)', () => {
it('REFUSES the array spelling, naming the retired key', () => {
// The pin. Before the retirement this document parsed GREEN (`toolbar` was
// `z.union([SchemaNodeSchema, z.array(SchemaNodeSchema)]).optional()`),
// measured ACCEPTED on the retiring PR's base. Asserting the ENVELOPE —
// not merely `success:false` — so the pin cannot be satisfied by an
// unrelated rejection.
const result = DataTableSchema.safeParse({
...VALID_TABLE,
toolbar: [{ type: 'button', label: 'Refresh' }],
});
expect(result.success, 'an authored toolbar was ACCEPTED — it will render as a blank result with no signal').toBe(false);
if (result.success) return;

const issue = result.error.issues.find((i) => i.path[0] === 'toolbar');
expect(issue, 'parse failed, but not on the `toolbar` path').toBeTruthy();
expect(issue?.code).toBe('invalid_type');
expect((issue as { expected?: string } | undefined)?.expected).toBe('never');
});

it('REFUSES the single-node spelling — the half only the mirror ever accepted', () => {
// Pre-retirement, TS refused this spelling while the mirror admitted it
// (the drift recorded on objectui#6881 as the secondary observation). The
// two faces now agree by refusing both.
const result = DataTableSchema.safeParse({
...VALID_TABLE,
toolbar: { type: 'button', label: 'Refresh' },
});
expect(result.success).toBe(false);
if (result.success) return;
const issue = result.error.issues.find((i) => i.path[0] === 'toolbar');
expect(issue?.code).toBe('invalid_type');
});

it('the refusal CARRIES the remediation text, not zod\'s generic message', () => {
// `retirementTombstone()` writes the guidance once into the parse message
// and `.describe()` both (objectui#6931) — the author is told what to
// write instead: the built-in chrome.
const result = DataTableSchema.safeParse({
...VALID_TABLE,
toolbar: [{ type: 'button', label: 'Refresh' }],
});
expect(result.success).toBe(false);
if (result.success) return;

const issue = result.error.issues.find((i) => i.path[0] === 'toolbar');
expect(issue?.message).not.toContain('Invalid input: expected never, received ');
expect(issue?.message).toBe(GUIDANCE);
// ONE string, BOTH channels — asserted derived, so parse message and
// generated-docs metadata cannot drift apart.
expect(issue?.message).toBe(
(DataTableSchema.shape.toolbar as { description?: string }).description,
);
});

it('leaves a document that never wrote the key untouched — the inside of the boundary', () => {
// `absent` stays valid — `.optional()` on the tombstone. The retirement
// narrows exactly one key and nothing else.
expect(DataTableSchema.safeParse(VALID_TABLE).success).toBe(true);
});

it('still ACCEPTS the sibling `emptyAction` SchemaNode slot — the counter-probe', () => {
// `emptyAction` is the slot the census used as its positive control: same
// interface, same SchemaNode shape, actually mounted (data-table.tsx, via
// SchemaRenderer). Without this leg the refusals above would be satisfied
// by a schema that refuses every SchemaNode slot — a narrowing that
// refuses too much would pass a refusal-only test.
const result = DataTableSchema.safeParse({
...VALID_TABLE,
emptyAction: { type: 'button', label: 'New' },
});
expect(result.success ? null : result.error.issues).toBe(null);
});

it('keeps `toolbar` DECLARED — a tombstone, not a deletion', () => {
// The route guard. `BaseSchema` is `.passthrough()`, so removing the key
// from the mirror would make the authored spelling parse green again and
// do nothing — the silent no-op reintroduced by the very edit meant to
// remove it.
expect(
Object.keys(DataTableSchema.shape),
'toolbar left the mirror — under .passthrough() the retired key becomes a SILENT no-op again',
).toContain('toolbar');
});
});

describe('DataTableSchema.toolbar is RETIRED — the TS half of the tombstone (objectui#6881)', () => {
it('refuses the retired key at compile time', () => {
// On the pre-fix tree `toolbar` is `SchemaNode[] | undefined`, so the
// assignment is LEGAL, the directive below is unused, and `tsc` fails the
// build with TS2578 naming the key — this leg is red before the fix in
// `type-check`, not in vitest, which strips types.

// @ts-expect-error — `toolbar` is RETIRED (objectui#6881): declared `?: never`, so no value is authorable.
const retired: DataTableSchemaTS['toolbar'] = [{ type: 'button' }];

// Counter-probe on the same surface: the sibling SchemaNode slot still
// accepts a node, so the directive above pins the KEY's retirement and not
// a blanket narrowing of the interface.
const sibling: DataTableSchemaTS['emptyAction'] = { type: 'button' };

expect([retired, sibling]).toHaveLength(2);
});

it('refuses the retired key in the form authors actually write', () => {
// The leg that proves the tombstone survives `BaseSchema`'s
// `[key: string]: any`: if the index signature won, `toolbar` would widen
// back to `any` here and the directive would go unused (TS2578).
const columns: TableColumn[] = [{ header: 'Name', accessorKey: 'name' }];

const retiredDocument: DataTableSchemaTS = {
type: 'data-table',
columns,
data: [],
// @ts-expect-error — `toolbar` is RETIRED (objectui#6881); use the built-in chrome (`searchable` / `exportable`).
toolbar: [{ type: 'button', label: 'Refresh' }],
};

// The migrated document — built-in chrome instead — still type-checks.
const migratedDocument: DataTableSchemaTS = {
type: 'data-table',
columns,
data: [],
searchable: true,
exportable: true,
};

expect([retiredDocument, migratedDocument]).toHaveLength(2);
});
});
26 changes: 24 additions & 2 deletions packages/types/src/data-display.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -670,9 +670,31 @@ export interface DataTableSchema extends BaseSchema {
*/
caption?: string;
/**
* Table toolbar actions/content
* ADR-0049 RETIREMENT TOMBSTONE — `toolbar` (objectui#6881, maintainer
* ruling 2026-08-31: retire, do NOT wire).
*
* What was measured (objectui#6881, re-measured on the retiring PR's base):
* declared on both published faces, documented, mirrored — and read by
* NOTHING. `data-table.tsx`, the registered renderer for `type:
* 'data-table'`, contains the word only in two prose comments and never
* reads `schema.toolbar`; the sibling `emptyAction` slot on this same
* interface IS mounted through `SchemaRenderer`, so the zero is a reading,
* not a blind query. An author who wrote a toolbar got a green document and
* a blank result, with no signal anywhere that said so.
*
* `?: never` is this package's tombstone convention (see `crud.ts`
* `confirm`, {@link StaticTableColumn}, `TimelineSchema`'s `timeScale`), NOT
* a deletion: `BaseSchema`'s `[key: string]: any` would admit a deleted key
* as `any` again — the same silence one layer over. The Zod twin refuses it
* loudly via `retirementTombstone()` (`zod/data-display.zod.ts`).
*
* RETIRED (objectui#6881, ADR-0049) — never mounted by the data-table
* renderer. Use the built-in toolbar chrome instead (`searchable` /
* `exportable`), or compose your own nodes beside the table. A real
* toolbar slot must arrive as a redesigned proposal WITH its enforcing
* reader, per the ruling — not by reviving this key.
*/
toolbar?: SchemaNode[];
toolbar?: never;
/**
* Table columns
*/
Expand Down
2 changes: 1 addition & 1 deletion packages/types/src/zod/data-display.zod.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -241,7 +241,7 @@ export const DataTableSchema = BaseSchema.extend({
type: z.literal('data-table'),
caption: z.string().optional().describe('Table caption'),
borderless: z.boolean().optional().describe('Render the table without its outer rounded border (for embedding inside grouped rows or other containers).'),
toolbar: z.union([SchemaNodeSchema, z.array(SchemaNodeSchema)]).optional().describe('Toolbar content'),
toolbar: retirementTombstone('RETIRED (objectui#6881) — never mounted by the data-table renderer; use the built-in toolbar chrome (searchable / exportable), or compose nodes beside the table'),
columns: z.array(TableColumnSchema).describe('Table columns'),
data: z.array(z.any()).describe('Table data'),
pagination: z.boolean().optional().describe('Enable pagination'),
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 60 additions & 0 deletions .changeset/6881-retire-data-table-toolbar.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
---
'@object-ui/types': minor
---

**Breaking for authored metadata:** `DataTableSchema.toolbar` is RETIRED
(objectui#6881, maintainer ruling 2026-08-31). A `data-table` node that authors
`toolbar` no longer validates — the parse fails loudly on the `toolbar` path
with the remediation in the message — and the TS member is a `?: never`
tombstone, so the same document is refused at compile time.

**What was measured.** The key was declared on both published faces —
`data-display.ts` (`toolbar?: SchemaNode[]`, "Table toolbar actions/content")
and the Zod mirror (`SchemaNode | SchemaNode[]`) — documented, mirrored, and
read by NOTHING: `data-table.tsx`, the registered renderer for
`type: 'data-table'`, contains the word only in two prose comments and never
reads `schema.toolbar`. The sibling `emptyAction` slot on the same interface IS
mounted through `SchemaRenderer`, so the census zero is a reading, not a blind
query. An author who wrote a toolbar got a green document and a blank result,
with no signal anywhere that said so — the declared-vs-enforced failure mode
that is worst for AI-authored metadata, which has nothing but the declaration
to go on.

**Who is affected — a `toolbar` authored directly onto a `data-table` node,
in either spelling:**

```json
{ "type": "data-table",
"columns": [{ "header": "Name", "accessorKey": "name" }],
"data": [],
"toolbar": [{ "type": "button", "label": "Refresh" }] } // ← was tolerated, rendered nothing
```

now fails validation with:

> RETIRED (objectui#6881) — never mounted by the data-table renderer; use the
> built-in toolbar chrome (searchable / exportable), or compose nodes beside
> the table

The single-node spelling `"toolbar": { … }` — which only the Zod mirror ever
accepted; the TS face always refused it — is refused the same way, so the two
faces now agree by refusing both.

**Who is NOT affected.** A document that never wrote the key is untouched
(`absent` stays valid), and every other `SchemaNode` slot — `emptyAction`
included — is unchanged. No fixture, example, catalog entry, doc page or app
in this repository authored the key (measured: all five
`components-complex-data-table` catalog schemas are toolbar-free, and every
other `toolbar` occurrence repo-wide is an i18n key, an ARIA role, or an
unrelated React prop of the same name).

**Migration:** use the built-in toolbar chrome (`searchable` / `exportable`),
or compose your own nodes beside the table. Per the ruling, a real toolbar
slot must arrive as a redesigned proposal WITH its enforcing reader — published
zero-consumer capability gets no sunk-cost exemption.

Graded `minor`, not `patch`: this narrows the accepted input set, which is
breaking for any author who wrote the tolerated key. It is not `major` per
this repo's fixed-group convention (objectui's own breaking changes ship as
`minor`; the group's major tracks `@objectstack` — AGENTS.md 版本号策略,
mechanically enforced by `scripts/check-changeset-no-major.mjs`).
194 changes: 194 additions & 0 deletions packages/types/src/__tests__/data-table-toolbar-retired.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,194 @@
/**
* 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 — `DataTableSchema.toolbar` is REFUSED, not silently ignored
* (objectui#6881, maintainer ruling 2026-08-31: retire, do NOT wire).
*
* ## The failure this pin exists to prevent
*
* `toolbar` was declared on both published faces — `data-display.ts` and the
* Zod mirror — documented, mirrored, and read by NOTHING: `data-table.tsx`
* never mounts it (the word appears there only in two prose comments), while
* the sibling `emptyAction` slot on the SAME interface is mounted through
* `SchemaRenderer`, so the census zero is a reading, not a blind query. An
* author who wrote a toolbar got a green document and a blank result, with no
* signal anywhere that said so — the declared-vs-enforced failure mode, worst
* for AI-authored metadata, which has nothing but the declaration to go on.
*
* So the deliverable is not "toolbar renders". It is: **an authored `toolbar`
* is refused loudly at the authoring boundary**, with the remediation in the
* refusal (the built-in chrome: `searchable` / `exportable`). The ruling
* records that a real toolbar slot must arrive as a redesigned proposal WITH
* its enforcing reader — not by reviving this key.
*
* ## Why the tombstone, and not simply deleting the key
*
* `BaseSchema` is `.passthrough()` on the Zod side and carries a
* `[key: string]: any` index signature on the TS side. An UNDECLARED key is
* accepted by both halves, unvalidated — deleting `toolbar` outright would
* hand the authored spelling exactly the silent no-op this card exists to
* close. `?: never` / `retirementTombstone()` is this package's convention —
* {@link StaticTableColumn} (objectui#5474), `crud.ts` `confirm`
* (objectui#4314), `TimelineSchema.timeScale` (objectui#6355) — and it is
* lockstep: both halves or neither.
*
* ## The rider the retirement settles by construction
*
* Before this card the two faces disagreed on the SHAPE: TS said
* `SchemaNode[]`, the mirror admitted `SchemaNode | SchemaNode[]` — a
* mirror-wider-than-declared drift no parity ledger watches. Retiring both
* faces in the same stroke makes them agree by refusing BOTH spellings, so
* this file pins the single-node spelling refused too — the half only the
* mirror ever accepted.
*/

import { describe, it, expect } from 'vitest';
import { DataTableSchema } from '../zod/data-display.zod.js';
import type { DataTableSchema as DataTableSchemaTS, TableColumn } from '../data-display.js';

const GUIDANCE =
'RETIRED (objectui#6881) — never mounted by the data-table renderer; use the built-in toolbar chrome (searchable / exportable), or compose nodes beside the table';

/** A minimal document that is valid TODAY and stays valid — the inside of the boundary. */
const VALID_TABLE = {
type: 'data-table',
columns: [{ header: 'Name', accessorKey: 'name' }],
data: [],
} as const;

describe('DataTableSchema.toolbar is RETIRED — the Zod half of the tombstone (objectui#6881)', () => {
it('REFUSES the array spelling, naming the retired key', () => {
// The pin. Before the retirement this document parsed GREEN (`toolbar` was
// `z.union([SchemaNodeSchema, z.array(SchemaNodeSchema)]).optional()`),
// measured ACCEPTED on the retiring PR's base. Asserting the ENVELOPE —
// not merely `success:false` — so the pin cannot be satisfied by an
// unrelated rejection.
const result = DataTableSchema.safeParse({
...VALID_TABLE,
toolbar: [{ type: 'button', label: 'Refresh' }],
});
expect(result.success, 'an authored toolbar was ACCEPTED — it will render as a blank result with no signal').toBe(false);
if (result.success) return;

const issue = result.error.issues.find((i) => i.path[0] === 'toolbar');
expect(issue, 'parse failed, but not on the `toolbar` path').toBeTruthy();
expect(issue?.code).toBe('invalid_type');
expect((issue as { expected?: string } | undefined)?.expected).toBe('never');
});

it('REFUSES the single-node spelling — the half only the mirror ever accepted', () => {
// Pre-retirement, TS refused this spelling while the mirror admitted it
// (the drift recorded on objectui#6881 as the secondary observation). The
// two faces now agree by refusing both.
const result = DataTableSchema.safeParse({
...VALID_TABLE,
toolbar: { type: 'button', label: 'Refresh' },
});
expect(result.success).toBe(false);
if (result.success) return;
const issue = result.error.issues.find((i) => i.path[0] === 'toolbar');
expect(issue?.code).toBe('invalid_type');
});

it('the refusal CARRIES the remediation text, not zod\'s generic message', () => {
// `retirementTombstone()` writes the guidance once into the parse message
// and `.describe()` both (objectui#6931) — the author is told what to
// write instead: the built-in chrome.
const result = DataTableSchema.safeParse({
...VALID_TABLE,
toolbar: [{ type: 'button', label: 'Refresh' }],
});
expect(result.success).toBe(false);
if (result.success) return;

const issue = result.error.issues.find((i) => i.path[0] === 'toolbar');
expect(issue?.message).not.toContain('Invalid input: expected never, received ');
expect(issue?.message).toBe(GUIDANCE);
// ONE string, BOTH channels — asserted derived, so parse message and
// generated-docs metadata cannot drift apart.
expect(issue?.message).toBe(
(DataTableSchema.shape.toolbar as { description?: string }).description,
);
});

it('leaves a document that never wrote the key untouched — the inside of the boundary', () => {
// `absent` stays valid — `.optional()` on the tombstone. The retirement
// narrows exactly one key and nothing else.
expect(DataTableSchema.safeParse(VALID_TABLE).success).toBe(true);
});

it('still ACCEPTS the sibling `emptyAction` SchemaNode slot — the counter-probe', () => {
// `emptyAction` is the slot the census used as its positive control: same
// interface, same SchemaNode shape, actually mounted (data-table.tsx, via
// SchemaRenderer). Without this leg the refusals above would be satisfied
// by a schema that refuses every SchemaNode slot — a narrowing that
// refuses too much would pass a refusal-only test.
const result = DataTableSchema.safeParse({
...VALID_TABLE,
emptyAction: { type: 'button', label: 'New' },
});
expect(result.success ? null : result.error.issues).toBe(null);
});

it('keeps `toolbar` DECLARED — a tombstone, not a deletion', () => {
// The route guard. `BaseSchema` is `.passthrough()`, so removing the key
// from the mirror would make the authored spelling parse green again and
// do nothing — the silent no-op reintroduced by the very edit meant to
// remove it.
expect(
Object.keys(DataTableSchema.shape),
'toolbar left the mirror — under .passthrough() the retired key becomes a SILENT no-op again',
).toContain('toolbar');
});
});

describe('DataTableSchema.toolbar is RETIRED — the TS half of the tombstone (objectui#6881)', () => {
it('refuses the retired key at compile time', () => {
// On the pre-fix tree `toolbar` is `SchemaNode[] | undefined`, so the
// assignment is LEGAL, the directive below is unused, and `tsc` fails the
// build with TS2578 naming the key — this leg is red before the fix in
// `type-check`, not in vitest, which strips types.

// @ts-expect-error — `toolbar` is RETIRED (objectui#6881): declared `?: never`, so no value is authorable.
const retired: DataTableSchemaTS['toolbar'] = [{ type: 'button' }];

// Counter-probe on the same surface: the sibling SchemaNode slot still
// accepts a node, so the directive above pins the KEY's retirement and not
// a blanket narrowing of the interface.
const sibling: DataTableSchemaTS['emptyAction'] = { type: 'button' };

expect([retired, sibling]).toHaveLength(2);
});

it('refuses the retired key in the form authors actually write', () => {
// The leg that proves the tombstone survives `BaseSchema`'s
// `[key: string]: any`: if the index signature won, `toolbar` would widen
// back to `any` here and the directive would go unused (TS2578).
const columns: TableColumn[] = [{ header: 'Name', accessorKey: 'name' }];

const retiredDocument: DataTableSchemaTS = {
type: 'data-table',
columns,
data: [],
// @ts-expect-error — `toolbar` is RETIRED (objectui#6881); use the built-in chrome (`searchable` / `exportable`).
toolbar: [{ type: 'button', label: 'Refresh' }],
};

// The migrated document — built-in chrome instead — still type-checks.
const migratedDocument: DataTableSchemaTS = {
type: 'data-table',
columns,
data: [],
searchable: true,
exportable: true,
};

expect([retiredDocument, migratedDocument]).toHaveLength(2);
});
});
26 changes: 24 additions & 2 deletions packages/types/src/data-display.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -670,9 +670,31 @@ export interface DataTableSchema extends BaseSchema {
*/
caption?: string;
/**
* Table toolbar actions/content
* ADR-0049 RETIREMENT TOMBSTONE — `toolbar` (objectui#6881, maintainer
* ruling 2026-08-31: retire, do NOT wire).
*
* What was measured (objectui#6881, re-measured on the retiring PR's base):
* declared on both published faces, documented, mirrored — and read by
* NOTHING. `data-table.tsx`, the registered renderer for `type:
* 'data-table'`, contains the word only in two prose comments and never
* reads `schema.toolbar`; the sibling `emptyAction` slot on this same
* interface IS mounted through `SchemaRenderer`, so the zero is a reading,
* not a blind query. An author who wrote a toolbar got a green document and
* a blank result, with no signal anywhere that said so.
*
* `?: never` is this package's tombstone convention (see `crud.ts`
* `confirm`, {@link StaticTableColumn}, `TimelineSchema`'s `timeScale`), NOT
* a deletion: `BaseSchema`'s `[key: string]: any` would admit a deleted key
* as `any` again — the same silence one layer over. The Zod twin refuses it
* loudly via `retirementTombstone()` (`zod/data-display.zod.ts`).
*
* RETIRED (objectui#6881, ADR-0049) — never mounted by the data-table
* renderer. Use the built-in toolbar chrome instead (`searchable` /
* `exportable`), or compose your own nodes beside the table. A real
* toolbar slot must arrive as a redesigned proposal WITH its enforcing
* reader, per the ruling — not by reviving this key.
*/
toolbar?: SchemaNode[];
toolbar?: never;
/**
* Table columns
*/
Expand Down
2 changes: 1 addition & 1 deletion packages/types/src/zod/data-display.zod.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -241,7 +241,7 @@ export const DataTableSchema = BaseSchema.extend({
type: z.literal('data-table'),
caption: z.string().optional().describe('Table caption'),
borderless: z.boolean().optional().describe('Render the table without its outer rounded border (for embedding inside grouped rows or other containers).'),
toolbar: z.union([SchemaNodeSchema, z.array(SchemaNodeSchema)]).optional().describe('Toolbar content'),
toolbar: retirementTombstone('RETIRED (objectui#6881) — never mounted by the data-table renderer; use the built-in toolbar chrome (searchable / exportable), or compose nodes beside the table'),
columns: z.array(TableColumnSchema).describe('Table columns'),
data: z.array(z.any()).describe('Table data'),
pagination: z.boolean().optional().describe('Enable pagination'),
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 60 additions & 0 deletions .changeset/6881-retire-data-table-toolbar.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
---
'@object-ui/types': minor
---

**Breaking for authored metadata:** `DataTableSchema.toolbar` is RETIRED
(objectui#6881, maintainer ruling 2026-08-31). A `data-table` node that authors
`toolbar` no longer validates — the parse fails loudly on the `toolbar` path
with the remediation in the message — and the TS member is a `?: never`
tombstone, so the same document is refused at compile time.

**What was measured.** The key was declared on both published faces —
`data-display.ts` (`toolbar?: SchemaNode[]`, "Table toolbar actions/content")
and the Zod mirror (`SchemaNode | SchemaNode[]`) — documented, mirrored, and
read by NOTHING: `data-table.tsx`, the registered renderer for
`type: 'data-table'`, contains the word only in two prose comments and never
reads `schema.toolbar`. The sibling `emptyAction` slot on the same interface IS
mounted through `SchemaRenderer`, so the census zero is a reading, not a blind
query. An author who wrote a toolbar got a green document and a blank result,
with no signal anywhere that said so — the declared-vs-enforced failure mode
that is worst for AI-authored metadata, which has nothing but the declaration
to go on.

**Who is affected — a `toolbar` authored directly onto a `data-table` node,
in either spelling:**

```json
{ "type": "data-table",
"columns": [{ "header": "Name", "accessorKey": "name" }],
"data": [],
"toolbar": [{ "type": "button", "label": "Refresh" }] } // ← was tolerated, rendered nothing
```

now fails validation with:

> RETIRED (objectui#6881) — never mounted by the data-table renderer; use the
> built-in toolbar chrome (searchable / exportable), or compose nodes beside
> the table

The single-node spelling `"toolbar": { … }` — which only the Zod mirror ever
accepted; the TS face always refused it — is refused the same way, so the two
faces now agree by refusing both.

**Who is NOT affected.** A document that never wrote the key is untouched
(`absent` stays valid), and every other `SchemaNode` slot — `emptyAction`
included — is unchanged. No fixture, example, catalog entry, doc page or app
in this repository authored the key (measured: all five
`components-complex-data-table` catalog schemas are toolbar-free, and every
other `toolbar` occurrence repo-wide is an i18n key, an ARIA role, or an
unrelated React prop of the same name).

**Migration:** use the built-in toolbar chrome (`searchable` / `exportable`),
or compose your own nodes beside the table. Per the ruling, a real toolbar
slot must arrive as a redesigned proposal WITH its enforcing reader — published
zero-consumer capability gets no sunk-cost exemption.

Graded `minor`, not `patch`: this narrows the accepted input set, which is
breaking for any author who wrote the tolerated key. It is not `major` per
this repo's fixed-group convention (objectui's own breaking changes ship as
`minor`; the group's major tracks `@objectstack` — AGENTS.md 版本号策略,
mechanically enforced by `scripts/check-changeset-no-major.mjs`).
194 changes: 194 additions & 0 deletions packages/types/src/__tests__/data-table-toolbar-retired.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,194 @@
/**
* 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 — `DataTableSchema.toolbar` is REFUSED, not silently ignored
* (objectui#6881, maintainer ruling 2026-08-31: retire, do NOT wire).
*
* ## The failure this pin exists to prevent
*
* `toolbar` was declared on both published faces — `data-display.ts` and the
* Zod mirror — documented, mirrored, and read by NOTHING: `data-table.tsx`
* never mounts it (the word appears there only in two prose comments), while
* the sibling `emptyAction` slot on the SAME interface is mounted through
* `SchemaRenderer`, so the census zero is a reading, not a blind query. An
* author who wrote a toolbar got a green document and a blank result, with no
* signal anywhere that said so — the declared-vs-enforced failure mode, worst
* for AI-authored metadata, which has nothing but the declaration to go on.
*
* So the deliverable is not "toolbar renders". It is: **an authored `toolbar`
* is refused loudly at the authoring boundary**, with the remediation in the
* refusal (the built-in chrome: `searchable` / `exportable`). The ruling
* records that a real toolbar slot must arrive as a redesigned proposal WITH
* its enforcing reader — not by reviving this key.
*
* ## Why the tombstone, and not simply deleting the key
*
* `BaseSchema` is `.passthrough()` on the Zod side and carries a
* `[key: string]: any` index signature on the TS side. An UNDECLARED key is
* accepted by both halves, unvalidated — deleting `toolbar` outright would
* hand the authored spelling exactly the silent no-op this card exists to
* close. `?: never` / `retirementTombstone()` is this package's convention —
* {@link StaticTableColumn} (objectui#5474), `crud.ts` `confirm`
* (objectui#4314), `TimelineSchema.timeScale` (objectui#6355) — and it is
* lockstep: both halves or neither.
*
* ## The rider the retirement settles by construction
*
* Before this card the two faces disagreed on the SHAPE: TS said
* `SchemaNode[]`, the mirror admitted `SchemaNode | SchemaNode[]` — a
* mirror-wider-than-declared drift no parity ledger watches. Retiring both
* faces in the same stroke makes them agree by refusing BOTH spellings, so
* this file pins the single-node spelling refused too — the half only the
* mirror ever accepted.
*/

import { describe, it, expect } from 'vitest';
import { DataTableSchema } from '../zod/data-display.zod.js';
import type { DataTableSchema as DataTableSchemaTS, TableColumn } from '../data-display.js';

const GUIDANCE =
'RETIRED (objectui#6881) — never mounted by the data-table renderer; use the built-in toolbar chrome (searchable / exportable), or compose nodes beside the table';

/** A minimal document that is valid TODAY and stays valid — the inside of the boundary. */
const VALID_TABLE = {
type: 'data-table',
columns: [{ header: 'Name', accessorKey: 'name' }],
data: [],
} as const;

describe('DataTableSchema.toolbar is RETIRED — the Zod half of the tombstone (objectui#6881)', () => {
it('REFUSES the array spelling, naming the retired key', () => {
// The pin. Before the retirement this document parsed GREEN (`toolbar` was
// `z.union([SchemaNodeSchema, z.array(SchemaNodeSchema)]).optional()`),
// measured ACCEPTED on the retiring PR's base. Asserting the ENVELOPE —
// not merely `success:false` — so the pin cannot be satisfied by an
// unrelated rejection.
const result = DataTableSchema.safeParse({
...VALID_TABLE,
toolbar: [{ type: 'button', label: 'Refresh' }],
});
expect(result.success, 'an authored toolbar was ACCEPTED — it will render as a blank result with no signal').toBe(false);
if (result.success) return;

const issue = result.error.issues.find((i) => i.path[0] === 'toolbar');
expect(issue, 'parse failed, but not on the `toolbar` path').toBeTruthy();
expect(issue?.code).toBe('invalid_type');
expect((issue as { expected?: string } | undefined)?.expected).toBe('never');
});

it('REFUSES the single-node spelling — the half only the mirror ever accepted', () => {
// Pre-retirement, TS refused this spelling while the mirror admitted it
// (the drift recorded on objectui#6881 as the secondary observation). The
// two faces now agree by refusing both.
const result = DataTableSchema.safeParse({
...VALID_TABLE,
toolbar: { type: 'button', label: 'Refresh' },
});
expect(result.success).toBe(false);
if (result.success) return;
const issue = result.error.issues.find((i) => i.path[0] === 'toolbar');
expect(issue?.code).toBe('invalid_type');
});

it('the refusal CARRIES the remediation text, not zod\'s generic message', () => {
// `retirementTombstone()` writes the guidance once into the parse message
// and `.describe()` both (objectui#6931) — the author is told what to
// write instead: the built-in chrome.
const result = DataTableSchema.safeParse({
...VALID_TABLE,
toolbar: [{ type: 'button', label: 'Refresh' }],
});
expect(result.success).toBe(false);
if (result.success) return;

const issue = result.error.issues.find((i) => i.path[0] === 'toolbar');
expect(issue?.message).not.toContain('Invalid input: expected never, received ');
expect(issue?.message).toBe(GUIDANCE);
// ONE string, BOTH channels — asserted derived, so parse message and
// generated-docs metadata cannot drift apart.
expect(issue?.message).toBe(
(DataTableSchema.shape.toolbar as { description?: string }).description,
);
});

it('leaves a document that never wrote the key untouched — the inside of the boundary', () => {
// `absent` stays valid — `.optional()` on the tombstone. The retirement
// narrows exactly one key and nothing else.
expect(DataTableSchema.safeParse(VALID_TABLE).success).toBe(true);
});

it('still ACCEPTS the sibling `emptyAction` SchemaNode slot — the counter-probe', () => {
// `emptyAction` is the slot the census used as its positive control: same
// interface, same SchemaNode shape, actually mounted (data-table.tsx, via
// SchemaRenderer). Without this leg the refusals above would be satisfied
// by a schema that refuses every SchemaNode slot — a narrowing that
// refuses too much would pass a refusal-only test.
const result = DataTableSchema.safeParse({
...VALID_TABLE,
emptyAction: { type: 'button', label: 'New' },
});
expect(result.success ? null : result.error.issues).toBe(null);
});

it('keeps `toolbar` DECLARED — a tombstone, not a deletion', () => {
// The route guard. `BaseSchema` is `.passthrough()`, so removing the key
// from the mirror would make the authored spelling parse green again and
// do nothing — the silent no-op reintroduced by the very edit meant to
// remove it.
expect(
Object.keys(DataTableSchema.shape),
'toolbar left the mirror — under .passthrough() the retired key becomes a SILENT no-op again',
).toContain('toolbar');
});
});

describe('DataTableSchema.toolbar is RETIRED — the TS half of the tombstone (objectui#6881)', () => {
it('refuses the retired key at compile time', () => {
// On the pre-fix tree `toolbar` is `SchemaNode[] | undefined`, so the
// assignment is LEGAL, the directive below is unused, and `tsc` fails the
// build with TS2578 naming the key — this leg is red before the fix in
// `type-check`, not in vitest, which strips types.

// @ts-expect-error — `toolbar` is RETIRED (objectui#6881): declared `?: never`, so no value is authorable.
const retired: DataTableSchemaTS['toolbar'] = [{ type: 'button' }];

// Counter-probe on the same surface: the sibling SchemaNode slot still
// accepts a node, so the directive above pins the KEY's retirement and not
// a blanket narrowing of the interface.
const sibling: DataTableSchemaTS['emptyAction'] = { type: 'button' };

expect([retired, sibling]).toHaveLength(2);
});

it('refuses the retired key in the form authors actually write', () => {
// The leg that proves the tombstone survives `BaseSchema`'s
// `[key: string]: any`: if the index signature won, `toolbar` would widen
// back to `any` here and the directive would go unused (TS2578).
const columns: TableColumn[] = [{ header: 'Name', accessorKey: 'name' }];

const retiredDocument: DataTableSchemaTS = {
type: 'data-table',
columns,
data: [],
// @ts-expect-error — `toolbar` is RETIRED (objectui#6881); use the built-in chrome (`searchable` / `exportable`).
toolbar: [{ type: 'button', label: 'Refresh' }],
};

// The migrated document — built-in chrome instead — still type-checks.
const migratedDocument: DataTableSchemaTS = {
type: 'data-table',
columns,
data: [],
searchable: true,
exportable: true,
};

expect([retiredDocument, migratedDocument]).toHaveLength(2);
});
});
26 changes: 24 additions & 2 deletions packages/types/src/data-display.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -670,9 +670,31 @@ export interface DataTableSchema extends BaseSchema {
*/
caption?: string;
/**
* Table toolbar actions/content
* ADR-0049 RETIREMENT TOMBSTONE — `toolbar` (objectui#6881, maintainer
* ruling 2026-08-31: retire, do NOT wire).
*
* What was measured (objectui#6881, re-measured on the retiring PR's base):
* declared on both published faces, documented, mirrored — and read by
* NOTHING. `data-table.tsx`, the registered renderer for `type:
* 'data-table'`, contains the word only in two prose comments and never
* reads `schema.toolbar`; the sibling `emptyAction` slot on this same
* interface IS mounted through `SchemaRenderer`, so the zero is a reading,
* not a blind query. An author who wrote a toolbar got a green document and
* a blank result, with no signal anywhere that said so.
*
* `?: never` is this package's tombstone convention (see `crud.ts`
* `confirm`, {@link StaticTableColumn}, `TimelineSchema`'s `timeScale`), NOT
* a deletion: `BaseSchema`'s `[key: string]: any` would admit a deleted key
* as `any` again — the same silence one layer over. The Zod twin refuses it
* loudly via `retirementTombstone()` (`zod/data-display.zod.ts`).
*
* RETIRED (objectui#6881, ADR-0049) — never mounted by the data-table
* renderer. Use the built-in toolbar chrome instead (`searchable` /
* `exportable`), or compose your own nodes beside the table. A real
* toolbar slot must arrive as a redesigned proposal WITH its enforcing
* reader, per the ruling — not by reviving this key.
*/
toolbar?: SchemaNode[];
toolbar?: never;
/**
* Table columns
*/
Expand Down
2 changes: 1 addition & 1 deletion packages/types/src/zod/data-display.zod.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -241,7 +241,7 @@ export const DataTableSchema = BaseSchema.extend({
type: z.literal('data-table'),
caption: z.string().optional().describe('Table caption'),
borderless: z.boolean().optional().describe('Render the table without its outer rounded border (for embedding inside grouped rows or other containers).'),
toolbar: z.union([SchemaNodeSchema, z.array(SchemaNodeSchema)]).optional().describe('Toolbar content'),
toolbar: retirementTombstone('RETIRED (objectui#6881) — never mounted by the data-table renderer; use the built-in toolbar chrome (searchable / exportable), or compose nodes beside the table'),
columns: z.array(TableColumnSchema).describe('Table columns'),
data: z.array(z.any()).describe('Table data'),
pagination: z.boolean().optional().describe('Enable pagination'),
Expand Down
Loading