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
34 changes: 34 additions & 0 deletions .changeset/10387-header-bar-unread-keys-retired.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
---
'@object-ui/types': minor
---

fix(types): retire the eight `header-bar` keys the renderer never read (objectui#10387)

`HeaderBarSchema.title`, `logo`, `nav`, `left`, `center`, `right`, `sticky` and
`height` are retired on both faces (ADR-0049). None of them is in the spec, and
the `header-bar` renderer reads only `actions`, `crumbs`, `rightContent` and
`search`. Rendered through the real `SchemaRenderer`, a header with any of these
keys was identical to one without them.

- The zod mirror (`HeaderBarSchema` in `@object-ui/types/zod`) now refuses each
key by name. The refusal says what the renderer reads instead.
- The TypeScript declaration types each key `never`.
- `logo` also stops disagreeing with itself: the declaration said an image URL
string and the mirror a node or node list. Neither rendered, and both are
refused now.

Breaking, but only for documents the renderer already ignored. A `header-bar`
that carries any of the eight keys now fails validation and no longer
type-checks. To migrate, delete the key:

- `title`: name the current page with the last entry of `crumbs`.
- `logo`: put brand content in `rightContent` or `actions`.
- `nav`: put links in `crumbs`, or use a `navigation-menu` / `sidebar` node.
- `left` / `center`: put custom content in `rightContent` or `actions`.
- `right`: use `actions` (a node list) or `rightContent` (one node).
- `sticky`: make the parent layout sticky; the header itself never was.
- `height`: remove it. The header's height is fixed by the renderer.

The in-tree examples that used these keys (`packages/types/examples/dashboard.ts`,
the `@object-ui/types` README and the ObjectOS integration guide) now use
`crumbs` and `actions`.
16 changes: 16 additions & 0 deletions content/docs/components/navigation/header-bar.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,22 @@ interface HeaderBarSchema {
}
```

### Retired keys

`title`, `logo`, `nav`, `left`, `center`, `right`, `sticky` and `height` are retired
on this node (objectui#10387, alongside `variant` from objectui#10286). The renderer
never read any of them, so an authored value drew the same header as leaving it out.
They now fail validation by name, and the TypeScript declaration types each one
`never`. Delete the key:

- `title` — name the current page with the last entry of `crumbs`.
- `logo` — put brand content in `rightContent` or `actions`.
- `nav` — put links in `crumbs`, or use a `navigation-menu` / `sidebar` node.
- `left` / `center` — put custom content in `rightContent` or `actions`.
- `right` — use `actions` (a node list) or `rightContent` (one node).
- `sticky` — make the parent layout sticky; the header itself never was.
- `height` — the header's height is fixed (`h-14`, `sm:h-16`).

## Examples

### Application Header
Expand Down
8 changes: 1 addition & 7 deletions content/docs/guide/objectos-integration.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -118,13 +118,7 @@ export function App() {
template: 'header-sidebar-main',
header: {
type: 'header-bar',
title: 'My App',
navigation: {
items: [
{ label: 'Dashboard', path: '/', icon: 'home' },
{ label: 'Contacts', path: '/contacts', icon: 'users' }
]
}
crumbs: [{ label: 'My App' }]
},
sidebar: {
type: 'navigation-menu',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,7 @@ function renderRightContent(rightContent: unknown) {
const C = ComponentRegistry.get('header-bar') as React.ComponentType<any>;
const { container } = render(
<SidebarProvider>
<C schema={{ type: 'header-bar', title: 'H', rightContent }} />
<C schema={{ type: 'header-bar', rightContent }} />
</SidebarProvider>,
);
const header = container.querySelector('header');
Expand All @@ -94,7 +94,7 @@ function renderBaseline() {
const C = ComponentRegistry.get('header-bar') as React.ComponentType<any>;
const { container } = render(
<SidebarProvider>
<C schema={{ type: 'header-bar', title: 'H' }} />
<C schema={{ type: 'header-bar' }} />
</SidebarProvider>,
);
const header = container.querySelector('header');
Expand Down Expand Up @@ -128,7 +128,7 @@ describe('ui:header-bar rightContent numeric-falsy leak (objectui#9033)', () =>
// slot stopped admitting numbers and the rows below stopped being about
// anything — ⛔ that is a declaration change, not a licence to delete them
// (objectui#7105: node slots relax the RENDERER).
const parsed = HeaderBarSchemaZod.safeParse({ type: 'header-bar', title: 'H', rightContent: 0 });
const parsed = HeaderBarSchemaZod.safeParse({ type: 'header-bar', rightContent: 0 });
expect(parsed.success).toBe(true);
});
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -272,7 +272,7 @@ describe('SchemaNode slots refuse numeric-falsy authored values (objectui#9162)'
function renderHeaderBar(value: unknown): string {
const C = ComponentRegistry.get('header-bar', 'ui') as React.ComponentType<any>;
if (!C) throw new Error('no renderer registered for ui:header-bar');
const schema: Record<string, unknown> = { type: 'header-bar', title: 'H' };
const schema: Record<string, unknown> = { type: 'header-bar' };
if (value !== OMIT) schema.rightContent = value;
render(
<SidebarProvider>
Expand Down
2 changes: 1 addition & 1 deletion packages/types/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -325,7 +325,7 @@ const page: FlexSchema = {
type: 'flex',
direction: 'col',
children: [
{ type: 'header-bar', title: 'My App' },
{ type: 'header-bar', crumbs: [{ label: 'My App' }] },
{
type: 'flex',
direction: 'row',
Expand Down
6 changes: 2 additions & 4 deletions packages/types/examples/dashboard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,10 +24,8 @@ export const dashboardSchema: FlexSchema = {
// Header
{
type: 'header-bar',
title: 'Object UI Dashboard',
logo: '/logo.svg',
sticky: true,
right: [
crumbs: [{ label: 'Object UI Dashboard' }],
actions: [
{
type: 'button',
label: 'Profile',
Expand Down
102 changes: 102 additions & 0 deletions packages/types/src/__tests__/header-bar-unread-keys-10387.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
/**
* 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.
*/

/**
* objectui#10387 — the `header-bar` keys that have no read site and no in-tree
* author retire from both faces (ADR-0049 enforce-or-remove; objectui#7759 ruling
* rule 2 and D1-(ii): the key is objectui-own, the spec is silent, and the read
* site is the truth).
*
* All eight keys the card names retire: `title`, `logo`, `nav`, `left`,
* `center`, `right`, `sticky` and `height`. A one-off probe through the real
* `SchemaRenderer` and the real registry drew the header byte-identical to its
* absence for every value tried, with `rightContent` as the lit control that did
* change the markup. Four of them (`title`, `logo`, `right`, `sticky`) had
* documentation and type-check-example authors and no read site; the PM seat
* ruled they retire too, and those authors migrated to `crumbs` / `actions`.
*
* `BaseSchema` is `.passthrough()`: an UNDECLARED key parses green unexamined. So
* every refusal here has a lit control on the same document, an unknown key that
* must stay green, which shows the refusal is the declared key's own verdict.
*/

import { describe, it, expect } from 'vitest';
import { HeaderBarSchema } from '../zod/navigation.zod.js';
import type { HeaderBarSchema as HeaderBarSchemaType } from '../navigation.js';

const UNKNOWN_KEY = 'zzzNotAKeyAnySurfaceDeclares10387';
const NODE = { type: 'header-bar' as const, crumbs: [{ label: 'Home' }] };
const TEXT = { type: 'text', content: 'x' };

/** Values each face used to admit for the key, one per former arm. */
const FORMER_VALUES: Record<'title' | 'logo' | 'nav' | 'left' | 'center' | 'right' | 'sticky' | 'height', unknown[]> = {
// `logo` is the key whose two faces disagreed: the declaration said a URL string,
// the mirror a node or node list. Every arm either face admitted is refused now.
title: ['My App'],
logo: ['/logo.svg', TEXT, [TEXT]],
nav: [[{ label: 'Docs', href: '/docs' }], []],
left: [TEXT, [TEXT], 'plain text'],
center: [TEXT, [TEXT], 'plain text'],
right: [TEXT, [TEXT]],
sticky: [true, false],
height: ['64px', 64],
};

function refusedPaths(result: { success: boolean; error?: { issues: { path: PropertyKey[] }[] } }): string[] {
return result.success ? [] : (result.error?.issues ?? []).map((i) => i.path.map(String).join('.'));
}

describe('header-bar unread keys retire on both faces (objectui#10387)', () => {
for (const [key, values] of Object.entries(FORMER_VALUES)) {
it(`\`${key}\` is refused BY NAME for every value a former arm admitted`, () => {
for (const value of values) {
const result = HeaderBarSchema.safeParse({ ...NODE, [key]: value });
expect(refusedPaths(result), `${key}: ${JSON.stringify(value)}`).toEqual([key]);
}
});

it(`\`${key}\`'s refusal says why and names what the renderer reads instead`, () => {
const result = HeaderBarSchema.safeParse({ ...NODE, [key]: values[0] });
expect(result.success).toBe(false);
const issue = result.success ? undefined : result.error.issues[0];
expect(issue?.code).toBe('invalid_type');
expect(issue?.message.startsWith('REFUSED (objectui#10387, ADR-0049)')).toBe(true);
for (const read of ['actions', 'crumbs', 'rightContent', 'search']) expect(issue?.message).toContain(`\`${read}\``);
});
}

it('lit control: the same document without the keys, and with an undeclared key, parses', () => {
expect(HeaderBarSchema.safeParse(NODE).success).toBe(true);
expect(HeaderBarSchema.safeParse({ ...NODE, [UNKNOWN_KEY]: 'x' }).success).toBe(true);
});

it('the keys the renderer DOES read still parse (what the refusals point at)', () => {
const doc = { ...NODE, search: { enabled: true }, actions: [TEXT], rightContent: TEXT };
expect(HeaderBarSchema.safeParse(doc).success).toBe(true);
});

it('the declaration refuses them too (compile-time)', () => {
// @ts-expect-error — `title` is `never` on the TS face.
const e: HeaderBarSchemaType = { type: 'header-bar', title: 'My App' };
// @ts-expect-error — `logo` is `never` on the TS face.
const f: HeaderBarSchemaType = { type: 'header-bar', logo: '/logo.svg' };
// @ts-expect-error — `right` is `never` on the TS face.
const g: HeaderBarSchemaType = { type: 'header-bar', right: [] };
// @ts-expect-error — `sticky` is `never` on the TS face.
const h: HeaderBarSchemaType = { type: 'header-bar', sticky: true };
// @ts-expect-error — `nav` is `never` on the TS face.
const a: HeaderBarSchemaType = { type: 'header-bar', nav: [{ label: 'Docs', href: '/docs' }] };
// @ts-expect-error — `left` is `never` on the TS face.
const b: HeaderBarSchemaType = { type: 'header-bar', left: 'x' };
// @ts-expect-error — `center` is `never` on the TS face.
const c: HeaderBarSchemaType = { type: 'header-bar', center: 'x' };
// @ts-expect-error — `height` is `never` on the TS face.
const d: HeaderBarSchemaType = { type: 'header-bar', height: 64 };
expect([a, b, c, d, e, f, g, h].every((n) => n.type === 'header-bar')).toBe(true);
});
});
34 changes: 19 additions & 15 deletions packages/types/src/__tests__/zod-mirror-parity.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -427,8 +427,12 @@
* spelled "six" rots exactly as fast as one spelled `6`, it is just harder to
* point a regex at. ⛔ Do not spell a live figure out again, and ⛔ do not
* restate one without checking that the pin's spelling still reaches it.
* - **8 entries** in `WiderThanDeclared`, **10 keys** across them, and **11 arms**
* under those keys — split **5** SCHEMA-NODE, **5** CONCRETE, **0** MIXED, **1** unions.
* - **7 entries** in `WiderThanDeclared`, **9 keys** across them, and **9 arms**
* under those keys — split **5** SCHEMA-NODE, **4** CONCRETE, **0** MIXED, **0** unions.
* It read 8 / 10 / 11 — 5 / 5 / 0 / 1 — until objectui#10387 RETIRED
* `navigation.zod.ts#HeaderBarSchema::logo` on both faces: the `header-bar` renderer
* reads no `logo`, so the entry, its one key and BOTH its arms left together. It was
* the ledger's last two-arm key, which is why `unions` reached zero.
* It read 8 / 11 / 12 — 5 / 6 / 0 / 1 — until objectui#10334 (objectui#7759 group F)
* settled `complex.zod.ts#DashboardComponentSchema::dateRange` by rule 1: the spec
* declares the key, so both faces now take the spec's authoring member by reference and
Expand Down Expand Up @@ -3208,18 +3212,12 @@ interface WiderThanDeclared {
* narrow.
*/
'layout.zod.ts#PageNodeSchema': 'slots';
/**
* CONCRETE. (`variant` LEFT under objectui#10286 — retired on both faces, see the
* note where its `KnownDrift` entry stood.) `logo` ENTERED under objectui#7760: the mirror is
* `z.union([SchemaNodeSchema, z.array(SchemaNodeSchema)])` — the single-or-list
* spelling objectui#7069 called systematic — and the declaration states `logo?:
* string`. Both arms read now, and both are wider than a bare string, so an author
* may write a node here, `safeParse` returns green and `tsc` refuses it. ⛔ That card
* did not create the divergence: it made it MEASURABLE. `Unconstrained` had been
* excluding the key, because the face read `unknown` at the top level and one array
* deep.
*/
'navigation.zod.ts#HeaderBarSchema': 'logo';
// `navigation.zod.ts#HeaderBarSchema` recorded `logo` here (CONCRETE, two arms; ENTERED
// under objectui#7760): the mirror spelled it single-or-list `SchemaNode` and the
// declaration a bare `string`. The `header-bar` renderer reads no `logo` at all — through
// the real `SchemaRenderer` a URL, a node and a node list each drew the header
// byte-identical to its absence — and the spec does not declare the key, so objectui#10387
// RETIRED it on both faces (`?: never` beside a `retirementTombstone`). The entry is GONE.
// `overlay.zod.ts#TooltipSchema` recorded `content` here (CONCRETE; ENTERED under
// objectui#7760): the mirror spelled it single-or-list and the declaration the single arm.
// The renderer places `schema.content` RAW in a React child position, so the list arm parsed
Expand Down Expand Up @@ -3355,7 +3353,6 @@ const WIDER_ARMS: Readonly< Record< string, readonly WiderArmClass[] > > = {
'complex.zod.ts#DashboardComponentSchema::globalFilters': ['CONCRETE'],
'form.zod.ts#FormSchema::layout': ['CONCRETE'],
'layout.zod.ts#PageNodeSchema::slots': ['SCHEMA-NODE'],
'navigation.zod.ts#HeaderBarSchema::logo': ['CONCRETE', 'CONCRETE'],
'views.zod.ts#DetailViewFieldSchema::options': ['CONCRETE'],
'views.zod.ts#DetailViewSchema::fields': ['SCHEMA-NODE'],
'views.zod.ts#DetailViewSchema::sections': ['SCHEMA-NODE'],
Expand Down Expand Up @@ -5280,7 +5277,14 @@ describe('the WIDER ledger is judged per ARM, not per key (objectui#8252)', () =
// below would pass while measuring nothing; one that walked into object shapes
// would report many arms everywhere and the ledger would be rewritten to match
// an instrument rather than the mirrors. Neither is visible from the arity pin.
//
// The union direction reads a FIXED control slot beside the ledger's own rows:
// objectui#10387 retired the ledger's last two-arm key (`HeaderBarSchema::logo`),
// and a non-vacuity check that needs the LEDGER to hold a union would have gone
// red on a shrinking ledger rather than on a broken unwrapper. `SidebarSchema`'s
// `content` is the single-or-list spelling, two arms, outside the ledger.
const measured = widerArmRows().map(({ pair, key }) => measureMirrorArms(pair, key)?.length);
measured.push(measureMirrorArms('navigation.zod.ts#SidebarSchema', 'content')?.length);
expect(measured.filter((n) => n === 1).length).toBeGreaterThan(0);
expect(measured.filter((n) => n !== undefined && n > 1).length).toBeGreaterThan(0);
});
Expand Down
Loading
Loading